From 015bd761bd9ee00962e60a3472436c76c6257b09 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 19:18:28 +0200 Subject: [PATCH] feat(eidos foundation): emit --style-{name}-* tokens for named typography styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles (hero, h1..h6, body, prose, label, caption, code) but the renderer only emitted families / sizes / weights — the style entries lived as data with no CSS reflection. Extend `appendTypographyDeclarations` so each named style emits: - `--style-{name}-font-family` → `var(--font-family-{family})` - `--style-{name}-font-size` → `var(--font-size-{size})` - `--style-{name}-line-height` → literal or `var(--font-line-height-{size})` - `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})` - `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric) - `--style-{name}-color` → `var(--color-{dot.path → dash-path})` Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`) emit the base value into `:root` and per-breakpoint `@media (min-width: …)` blocks that override the same variables. Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768, lg: 1024, xl: 1280, xxl: 1536). `renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the main `:root` block and appends one media-query block per breakpoint that has at least one responsive override. The `indentBlock` helper preserves indentation inside the wrapper. `base.css` regenerated. svelte-check 0 errors, `npm run component:audit` 81 / 81 PASS unchanged. This is foundation-only; no components consume the new tokens yet — that lands in the typography port (Text + Heading + Display + Code + CodeBlock + Kbd + Mark + Highlight + Link). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/eidos/generated/base.css | 86 ++++++++++++++ src/uix/eidos/lib/render-css.ts | 185 +++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 484f3feba..677f1aee2 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -166,6 +166,78 @@ --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; + --style-hero-font-family: var(--font-family-display); + --style-hero-font-size: var(--font-size-xxl); + --style-hero-line-height: 1; + --style-hero-letter-spacing: 0; + --style-hero-font-weight: var(--font-weight-bold); + --style-hero-color: var(--color-content-primary); + --style-h1-font-family: var(--font-family-display); + --style-h1-font-size: var(--font-size-xl); + --style-h1-line-height: 1.05; + --style-h1-letter-spacing: 0; + --style-h1-font-weight: var(--font-weight-bold); + --style-h1-color: var(--color-content-primary); + --style-h2-font-family: var(--font-family-display); + --style-h2-font-size: var(--font-size-lg); + --style-h2-line-height: 1.1; + --style-h2-letter-spacing: 0; + --style-h2-font-weight: var(--font-weight-bold); + --style-h2-color: var(--color-content-primary); + --style-h3-font-family: var(--font-family-primary); + --style-h3-font-size: var(--font-size-lg); + --style-h3-line-height: 1.2; + --style-h3-letter-spacing: 0; + --style-h3-font-weight: var(--font-weight-semibold); + --style-h3-color: var(--color-content-primary); + --style-h4-font-family: var(--font-family-primary); + --style-h4-font-size: var(--font-size-md); + --style-h4-line-height: 1.25; + --style-h4-letter-spacing: 0; + --style-h4-font-weight: var(--font-weight-semibold); + --style-h4-color: var(--color-content-primary); + --style-h5-font-family: var(--font-family-primary); + --style-h5-font-size: var(--font-size-sm); + --style-h5-line-height: 1.3; + --style-h5-letter-spacing: 0; + --style-h5-font-weight: var(--font-weight-semibold); + --style-h5-color: var(--color-content-primary); + --style-h6-font-family: var(--font-family-primary); + --style-h6-font-size: var(--font-size-xs); + --style-h6-line-height: 1.35; + --style-h6-letter-spacing: 0; + --style-h6-font-weight: var(--font-weight-semibold); + --style-h6-color: var(--color-content-primary); + --style-body-font-family: var(--font-family-primary); + --style-body-font-size: var(--font-size-md); + --style-body-line-height: 1.55; + --style-body-letter-spacing: 0; + --style-body-font-weight: var(--font-weight-regular); + --style-body-color: var(--color-content-primary); + --style-prose-font-family: var(--font-family-secondary); + --style-prose-font-size: var(--font-size-md); + --style-prose-line-height: 1.65; + --style-prose-letter-spacing: 0; + --style-prose-font-weight: var(--font-weight-regular); + --style-prose-color: var(--color-content-primary); + --style-label-font-family: var(--font-family-primary); + --style-label-font-size: var(--font-size-sm); + --style-label-line-height: 1.25; + --style-label-letter-spacing: 0; + --style-label-font-weight: var(--font-weight-medium); + --style-label-color: var(--color-content-primary); + --style-caption-font-family: var(--font-family-primary); + --style-caption-font-size: var(--font-size-xs); + --style-caption-line-height: 1.35; + --style-caption-letter-spacing: 0; + --style-caption-font-weight: var(--font-weight-regular); + --style-caption-color: var(--color-content-secondary); + --style-code-font-family: var(--font-family-mono); + --style-code-font-size: var(--font-size-sm); + --style-code-line-height: 1.45; + --style-code-letter-spacing: 0; + --style-code-font-weight: var(--font-weight-regular); + --style-code-color: var(--color-content-primary); --size-xxs-control-height: var(--control-height-xxs); --size-xxs-font-size: var(--font-size-xxs); --size-xxs-font-line-height: var(--font-line-height-xxs); @@ -2414,6 +2486,20 @@ --tooltip-shadow: var(--shadow-overlay); } +@media (min-width: 768px) { + :root { + --style-hero-font-size: var(--font-size-xxxl); + --style-hero-line-height: var(--font-line-height-xxxl); + --style-hero-letter-spacing: var(--font-letter-spacing-xxxl); + --style-h1-font-size: var(--font-size-xxl); + --style-h1-line-height: var(--font-line-height-xxl); + --style-h1-letter-spacing: var(--font-letter-spacing-xxl); + --style-h2-font-size: var(--font-size-xl); + --style-h2-line-height: var(--font-line-height-xl); + --style-h2-letter-spacing: var(--font-letter-spacing-xl); + } +} + [data-density='compact'] { --density-scale: var(--density-compact-scale); --density-space-scale: var(--density-compact-space-scale); diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index 0cd620a84..d82c2a4ad 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -159,11 +159,30 @@ export function renderStaticCss(options: EidosConfig): string { appendRecipeDeclarations(declarations, options.recipes) const blocks = [renderBlock(':root', declarations)] + + // Responsive typography style overrides — one media-query block per + // breakpoint that has at least one responsive size override. + const styleResponsive = collectTypographyStyleResponsive(primitives.typography) + for (const bp of STYLE_BREAKPOINT_ORDER) { + const lines = styleResponsive[bp] + if (!lines || lines.length === 0) continue + const width = STYLE_BREAKPOINT_WIDTHS[bp] + const rootBlock = renderBlock(':root', lines) + blocks.push(`@media (min-width: ${width}px) {\n${indentBlock(rootBlock)}\n}`) + } + if (primitives.density) blocks.push(renderDensityBlocks()) return blocks.join('\n\n') } +function indentBlock(block: string): string { + return block + .split('\n') + .map((line) => (line ? `\t${line}` : line)) + .join('\n') +} + export function renderContractCss( options: EidosConfig, renderOptions: RenderContractCssOptions = {} @@ -342,6 +361,172 @@ function appendTypographyDeclarations( for (const [name, weight] of Object.entries(typography.weights)) { declarations.push(cssVar(`font-weight-${name}`, weight)) } + + if (typography.styles) { + appendTypographyStyleDeclarations(declarations, typography) + } +} + +/** + * Breakpoint min-width thresholds matching `$libs/dom/responsive`. + * `base` is the implicit 0 fallback — emitted into `:root`, not as a + * media query. + */ +const STYLE_BREAKPOINT_WIDTHS: Record = { + sm: 480, + md: 768, + lg: 1024, + xl: 1280, + xxl: 1536 +} + +const STYLE_BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', 'xxl'] as const + +/** + * Emit the named typography styles (hero, h1..h6, body, prose, label, + * caption, code, …) as `--style-{name}-{font-family,font-size, + * font-weight,line-height,letter-spacing,color}` custom properties. + * + * Each style entry references primitives the foundation already emits + * (`var(--font-family-X)`, `var(--font-size-Y)`, etc.). Components + * consume these tokens directly — `` reads + * `var(--style-h1-font-size)` and friends. + * + * Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`) + * are emitted as base declarations in `:root` plus per-breakpoint + * `@media (min-width: …)` overrides appended after the main block. + * Non-size fields (family, weight, line-height, letter-spacing, color) + * are not responsive in this contract. + */ +function appendTypographyStyleDeclarations( + declarations: string[], + typography: TypographyPrimitiveSet +): void { + const styles = typography.styles + if (!styles) return + + // Capture responsive size overrides per breakpoint. Same shape as + // `declarations` but keyed by the active breakpoint so we can emit + // them as media-query blocks after the base. + for (const [name, style] of Object.entries(styles)) { + if (style.family) { + declarations.push( + cssVar(`style-${name}-font-family`, `var(--font-family-${style.family})`) + ) + } + + const sizeRes = style.size + if (sizeRes !== undefined) { + const baseSize = pickResponsiveBase(sizeRes) + if (baseSize !== undefined) { + declarations.push( + cssVar(`style-${name}-font-size`, resolveStyleSizeRef(baseSize)) + ) + declarations.push( + cssVar( + `style-${name}-line-height`, + style.lineHeight ?? resolveStyleLineHeightRef(baseSize) + ) + ) + declarations.push( + cssVar( + `style-${name}-letter-spacing`, + style.letterSpacing ?? resolveStyleLetterSpacingRef(baseSize) + ) + ) + } else if (style.lineHeight) { + declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight)) + } + } else if (style.lineHeight) { + declarations.push(cssVar(`style-${name}-line-height`, style.lineHeight)) + } + + if (style.weight !== undefined) { + const weightRef = + typeof style.weight === 'number' + ? String(style.weight) + : `var(--font-weight-${style.weight})` + declarations.push(cssVar(`style-${name}-font-weight`, weightRef)) + } + + if (style.color) { + declarations.push( + cssVar(`style-${name}-color`, `var(--color-${style.color.replace(/\./g, '-')})`) + ) + } + } +} + +/** + * Collect per-breakpoint style size overrides. Returns a record mapping + * breakpoint -> declaration list. Empty buckets are dropped by the + * caller before emitting media-query blocks. + */ +function collectTypographyStyleResponsive( + typography: TypographyPrimitiveSet | undefined +): Record { + const result: Record = {} + if (!typography?.styles) return result + + for (const [name, style] of Object.entries(typography.styles)) { + const sizeRes = style.size + if (sizeRes === undefined) continue + if (typeof sizeRes !== 'object' || sizeRes === null || isTextMetric(sizeRes)) continue + const responsive = sizeRes as Partial> + for (const bp of STYLE_BREAKPOINT_ORDER) { + const value = responsive[bp] + if (value === undefined) continue + const list = result[bp] ?? (result[bp] = []) + list.push(cssVar(`style-${name}-font-size`, resolveStyleSizeRef(value))) + list.push(cssVar(`style-${name}-line-height`, resolveStyleLineHeightRef(value))) + list.push(cssVar(`style-${name}-letter-spacing`, resolveStyleLetterSpacingRef(value))) + } + } + + return result +} + +function pickResponsiveBase(value: unknown): unknown { + if (value === undefined || value === null) return undefined + if (typeof value === 'string' || typeof value === 'number') return value + if (isTextMetric(value)) return value + if (typeof value === 'object') { + const obj = value as Partial> + if (obj.base !== undefined) return obj.base + // Fallback: take the first defined breakpoint in order. + for (const bp of STYLE_BREAKPOINT_ORDER) { + if (obj[bp] !== undefined) return obj[bp] + } + } + return undefined +} + +function isTextMetric(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + 'size' in value && + 'lineHeight' in value && + 'letterSpacing' in value + ) +} + +function resolveStyleSizeRef(value: unknown): string { + if (typeof value === 'string') return `var(--font-size-${value})` + if (isTextMetric(value)) return (value as { size: string }).size + return 'inherit' +} + +function resolveStyleLineHeightRef(value: unknown): string { + if (typeof value === 'string') return `var(--font-line-height-${value})` + if (isTextMetric(value)) return (value as { lineHeight: string }).lineHeight + return 'inherit' +} + +function resolveStyleLetterSpacingRef(value: unknown): string { + if (typeof value === 'string') return `var(--font-letter-spacing-${value})` + if (isTextMetric(value)) return (value as { letterSpacing: string }).letterSpacing + return 'normal' } function appendSizeDeclarations(declarations: string[], sizes: SizePrimitiveSet): void {