feat(eidos): typography scales — tracking/leading/features/measure + optical tracking (Phase 3a)

TYPOGRAPHY_ENGINE_RFC Phase 3 (engine tokens). Additive scales, pure CSS, no binaries.

- config-types: TypographyPrimitiveSet += tracking / leading / features / measure
  (optional Record<string,string>).
- render-css: emits --tracking-{k} / --leading-{k} / --font-feature-{k} / --measure-{k}.
- typography.ts defaults: tracking (tighter…wider), leading (none…loose), features
  (tabular = "tnum"+"lnum" for data, oldstyle/smallcaps/ligatures), measure (54/66/78ch).
  + optical tracking baked into the size tokens: small text slightly looser
  (xxs +0.01em), display tighter (xxxl -0.02em) — was all 0.
- config.ts: validates the 4 scales (CSS-value maps).
- generated/base.css regenerated. test: scales + optical tracking emission.

Additive tokens (recipes/components consume var(--tracking-tight) etc.) so the scales
are zero-risk; the only rendered change is the gentle optical tracking on headings/small
text. check 0 errors; eidos suite green (3 pre-existing words failures only).

Next (Phase 3b): wire the component props (wrap: balance/pretty, numeric: tabular,
tracking/leading/measure) on <Text>/<Heading>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent f53299d42b
commit d85c2fd376

@ -441,6 +441,17 @@ describe('ActiveEidos config', () => {
expect((css.match(/font-family: 'Lora';/g) ?? []).length).toBe(8)
})
it('emits the Phase 3 typography scales + optical tracking', () => {
const css = createThemeBaseEidos().renderStaticCss()
expect(css).toContain('--tracking-tight: -0.02em;')
expect(css).toContain('--leading-normal: 1.5;')
expect(css).toContain('--font-feature-tabular: "tnum" 1, "lnum" 1;')
expect(css).toContain('--measure-normal: 66ch;')
// optical tracking baked into the size tokens (display negative, small positive)
expect(css).toContain('--font-letter-spacing-xxxl: -0.02em;')
expect(css).toContain('--font-letter-spacing-xxs: 0.01em;')
})
it('validates typography style references', () => {
const cfg = structuredClone(THEME_BASE_OPTIONS)
const typography = cfg.primitives.typography!

@ -258,10 +258,10 @@
--font-family-display: Lora, Georgia, 'Times New Roman', serif;
--font-size-xxs: calc(0.625rem * var(--scaling));
--font-line-height-xxs: 1.5;
--font-letter-spacing-xxs: 0;
--font-letter-spacing-xxs: 0.01em;
--font-size-xs: calc(0.75rem * var(--scaling));
--font-line-height-xs: 1.5;
--font-letter-spacing-xs: 0;
--font-letter-spacing-xs: 0.005em;
--font-size-sm: calc(0.875rem * var(--scaling));
--font-line-height-sm: 1.45;
--font-letter-spacing-sm: 0;
@ -273,17 +273,36 @@
--font-letter-spacing-lg: 0;
--font-size-xl: calc(clamp(1.5rem, calc(1.35rem + 0.5vw), 1.75rem) * var(--scaling));
--font-line-height-xl: 1.2;
--font-letter-spacing-xl: 0;
--font-letter-spacing-xl: -0.01em;
--font-size-xxl: calc(clamp(2rem, calc(1.4rem + 2vw), 3rem) * var(--scaling));
--font-line-height-xxl: 1.05;
--font-letter-spacing-xxl: 0;
--font-letter-spacing-xxl: -0.015em;
--font-size-xxxl: calc(clamp(2.5rem, calc(1rem + 5vw), 5rem) * var(--scaling));
--font-line-height-xxxl: 1;
--font-letter-spacing-xxxl: 0;
--font-letter-spacing-xxxl: -0.02em;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--tracking-tighter: -0.04em;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.05em;
--leading-none: 1;
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
--leading-loose: 2;
--font-feature-normal: normal;
--font-feature-tabular: "tnum" 1, "lnum" 1;
--font-feature-oldstyle: "onum" 1, "pnum" 1;
--font-feature-smallcaps: "smcp" 1;
--font-feature-ligatures: "liga" 1, "clig" 1, "dlig" 1;
--measure-narrow: 54ch;
--measure-normal: 66ch;
--measure-wide: 78ch;
--style-hero-font-family: var(--font-family-display);
--style-hero-font-size: var(--font-size-xxxl);
--style-hero-line-height: 1;

@ -640,6 +640,14 @@ export interface TypographyPrimitiveSet {
readonly sizes: Record<TextSize, TextMetric>;
readonly weights: Record<string, string | number>;
readonly styles: Record<string, TypographyStyle>;
/** Letter-spacing scale → `--tracking-{key}` (tighter…wider). (Fase 3) */
readonly tracking?: Record<string, string>;
/** Line-height scale → `--leading-{key}` (none…loose). (Fase 3) */
readonly leading?: Record<string, string>;
/** `font-feature-settings` presets → `--font-feature-{key}` (tabular, ligatures…). */
readonly features?: Record<string, string>;
/** Line-length / measure scale → `--measure-{key}` (narrow…wide, in `ch`). */
readonly measure?: Record<string, string>;
}
export interface PrimitiveSet {

@ -822,6 +822,28 @@ function validateTypography(options: EidosConfig, issues: EidosValidationIssue[]
}
}
// Phase 3 scales — tracking / leading / features / measure (optional CSS-value maps).
for (const scale of ['tracking', 'leading', 'features', 'measure'] as const) {
const record = typography[scale]
if (record === undefined) continue
if (!isPlainRecord(record)) {
issues.push({
path: `primitives.typography.${scale}`,
message: `typography ${scale} must be a plain object`
})
continue
}
for (const [name, value] of Object.entries(record)) {
validateCssTokenSuffix(
`primitives.typography.${scale}.${name}`,
name,
`typography ${scale}`,
issues
)
validateNonEmptyCssValue(`primitives.typography.${scale}.${name}`, value, issues)
}
}
if (styles) {
for (const [name, style] of Object.entries(styles)) {
const path = `primitives.typography.styles.${name}`

@ -52,16 +52,18 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = {
sizes: {
// Fixed steps in rem (a11y: scale with the browser's font-zoom). Body (md) and
// below stay fixed — fluid body harms readability + line-length stability.
xxs: { size: '0.625rem', lineHeight: '1.5', letterSpacing: '0' }, // 10px
xs: { size: '0.75rem', lineHeight: '1.5', letterSpacing: '0' }, // 12px
// letterSpacing = optical tracking: small text reads better slightly looser,
// large display tighter (gentle; serif display benefits most). (Fase 3)
xxs: { size: '0.625rem', lineHeight: '1.5', letterSpacing: '0.01em' }, // 10px
xs: { size: '0.75rem', lineHeight: '1.5', letterSpacing: '0.005em' }, // 12px
sm: { size: '0.875rem', lineHeight: '1.45', letterSpacing: '0' }, // 14px
md: { size: '1rem', lineHeight: '1.45', letterSpacing: '0' }, // 16px (body)
// Fluid headings (Utopia clamp): min @480px → max @1280px. Max = the previous
// fixed size, so desktop is ~unchanged; mobile scales down to avoid overflow.
lg: { size: { min: '1.125rem', max: '1.25rem' }, lineHeight: '1.35', letterSpacing: '0' }, // 18→20
xl: { size: { min: '1.5rem', max: '1.75rem' }, lineHeight: '1.2', letterSpacing: '0' }, // 24→28
xxl: { size: { min: '2rem', max: '3rem' }, lineHeight: '1.05', letterSpacing: '0' }, // 32→48
xxxl: { size: { min: '2.5rem', max: '5rem' }, lineHeight: '1', letterSpacing: '0' } // 40→80
xl: { size: { min: '1.5rem', max: '1.75rem' }, lineHeight: '1.2', letterSpacing: '-0.01em' }, // 24→28
xxl: { size: { min: '2rem', max: '3rem' }, lineHeight: '1.05', letterSpacing: '-0.015em' }, // 32→48
xxxl: { size: { min: '2.5rem', max: '5rem' }, lineHeight: '1', letterSpacing: '-0.02em' } // 40→80
},
weights: {
regular: 400,
@ -168,5 +170,37 @@ export const STATIC_TYPOGRAPHY: TypographyPrimitiveSet = {
letterSpacing: '0',
color: 'content.primary'
}
},
// Tracking scale (letter-spacing) → --tracking-{key}. (Fase 3)
tracking: {
tighter: '-0.04em',
tight: '-0.02em',
normal: '0',
wide: '0.02em',
wider: '0.05em'
},
// Leading scale (line-height) → --leading-{key}.
leading: {
none: '1',
tight: '1.15',
snug: '1.3',
normal: '1.5',
relaxed: '1.65',
loose: '2'
},
// font-feature-settings presets → --font-feature-{key}. `tabular` = lining +
// tabular numerals for data/tables; others opt-in via the `numeric` / class.
features: {
normal: 'normal',
tabular: '"tnum" 1, "lnum" 1',
oldstyle: '"onum" 1, "pnum" 1',
smallcaps: '"smcp" 1',
ligatures: '"liga" 1, "clig" 1, "dlig" 1'
},
// Measure (line-length) for readable prose → --measure-{key}.
measure: {
narrow: '54ch',
normal: '66ch',
wide: '78ch'
}
}

@ -732,6 +732,21 @@ function appendTypographyDeclarations(
declarations.push(cssVar(`font-weight-${name}`, weight))
}
// Phase 3 scales — tracking / leading / features / measure. Additive tokens that
// recipes + components consume (var(--tracking-tight), etc.).
for (const [name, value] of Object.entries(typography.tracking ?? {})) {
declarations.push(cssVar(`tracking-${name}`, value))
}
for (const [name, value] of Object.entries(typography.leading ?? {})) {
declarations.push(cssVar(`leading-${name}`, value))
}
for (const [name, value] of Object.entries(typography.features ?? {})) {
declarations.push(cssVar(`font-feature-${name}`, value))
}
for (const [name, value] of Object.entries(typography.measure ?? {})) {
declarations.push(cssVar(`measure-${name}`, value))
}
if (typography.styles) {
appendTypographyStyleDeclarations(declarations, typography)
}

Loading…
Cancel
Save

Powered by TurnKey Linux.