From 026002a5019c70b85a6c346e8ef12715ad23adaa Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 22 Mar 2026 16:25:51 +0100 Subject: [PATCH] feat: extract typography tokens into dedicated file with semantic aliases and a11y fallbacks Create typography.tokens.css as single source of truth for the type system: - Extract all primitive tokens from variables.base.css (--text-*, --leading-*, --font-*, --tracking-*) - Add semantic aliases (--text-body, --text-label, --text-caption, --text-title, --text-page-title) - Add --text-md (15px) escape hatch and --tracking-wider (0.05em) token - Add prefers-reduced-motion fallbacks locking fluid headings to fixed midpoints - Document scale ratio (1.25x major third, 14px base) with reference table - Migrate h1/h2 in app-shell.css to semantic aliases - Migrate 4 raw letter-spacing values to --tracking-* tokens - Extend TypographyTokenContractTest with 3 new tests (letter-spacing, aliases, reduced-motion) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TypographyTokenContractTest.php | 95 +++++++++++++++++-- web/css/app-layers.css | 3 + web/css/base/typography.tokens.css | 87 +++++++++++++++++ web/css/base/variables.base.css | 31 +----- web/css/components/app-tile.css | 2 +- web/css/layout/app-shell.css | 6 +- web/css/pages/app-docs.css | 4 +- 7 files changed, 184 insertions(+), 44 deletions(-) create mode 100644 web/css/base/typography.tokens.css diff --git a/tests/Architecture/TypographyTokenContractTest.php b/tests/Architecture/TypographyTokenContractTest.php index 22ece06..ab1d9f7 100644 --- a/tests/Architecture/TypographyTokenContractTest.php +++ b/tests/Architecture/TypographyTokenContractTest.php @@ -6,15 +6,15 @@ use PHPUnit\Framework\TestCase; /** * Typography token contract — ensures all core CSS files use design-system tokens - * (--text-*, --leading-*, --font-*) instead of raw font-size, line-height, and - * font-weight values. + * (--text-*, --leading-*, --font-*, --tracking-*) instead of raw font-size, + * line-height, font-weight, and letter-spacing values. * * Documented exceptions: * - Icon-font sizes in px (Bootstrap Icons at 10px, 15px, 16px) — annotated with * "icon-font metric — intentional px" * - Badge em-relative sizing (0.85em) — annotated with "em-relative sizing — intentional" * - Vendor layer (web/css/vendor/) — third-party styles are not under our control - * - The tokens layer itself (variables.base.css) — defines the tokens + * - The token definition files (variables.base.css, typography.tokens.css) — define the tokens * - CSS custom property fallback definitions (--app-font-size, --app-font-weight, --app-line-height) */ class TypographyTokenContractTest extends TestCase @@ -30,6 +30,7 @@ class TypographyTokenContractTest extends TestCase 'web/css/vendor/', 'web/css/vendor-overrides/', 'web/css/base/variables.base.css', + 'web/css/base/typography.tokens.css', ]; /** @@ -43,6 +44,11 @@ class TypographyTokenContractTest extends TestCase 'intentional px', ]; + /** + * The file that defines all typography tokens. + */ + private const TYPOGRAPHY_TOKENS_FILE = 'web/css/base/typography.tokens.css'; + // ────────────────────────────────────────────────────────────── // font-size // ────────────────────────────────────────────────────────────── @@ -97,35 +103,53 @@ class TypographyTokenContractTest extends TestCase ); } + // ────────────────────────────────────────────────────────────── + // letter-spacing + // ────────────────────────────────────────────────────────────── + + public function testLetterSpacingUsesTokens(): void + { + $violations = $this->scanForRawValues( + '/letter-spacing\s*:\s*+(?!var\(--tracking-|var\(--app-|inherit|unset|initial|revert|normal|0[;\s])/', + self::EXCEPTION_MARKERS, + ); + + $this->assertSame( + [], + $violations, + "Raw letter-spacing values found instead of --tracking-* tokens:\n" . implode("\n", $violations), + ); + } + // ────────────────────────────────────────────────────────────── // Token definitions exist // ────────────────────────────────────────────────────────────── public function testTypographyTokensAreDefined(): void { - $content = $this->readProjectFile('web/css/base/variables.base.css'); + $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE); $requiredTokens = [ - '--text-2xs', '--text-xs', '--text-sm', '--text-base', '--text-lg', + '--text-2xs', '--text-xs', '--text-sm', '--text-base', '--text-md', '--text-lg', '--text-xl', '--text-2xl', '--text-3xl', '--text-4xl', '--text-5xl', '--leading-none', '--leading-tight', '--leading-snug', '--leading-normal', '--leading-relaxed', '--leading-loose', '--font-regular', '--font-medium', '--font-semibold', '--font-bold', - '--tracking-tight', '--tracking-normal', '--tracking-wide', + '--tracking-tight', '--tracking-normal', '--tracking-wide', '--tracking-wider', ]; foreach ($requiredTokens as $token) { $this->assertStringContainsString( $token . ':', $content, - "Typography token {$token} is missing from variables.base.css", + "Typography token {$token} is missing from " . self::TYPOGRAPHY_TOKENS_FILE, ); } } public function testHeadingTokensUseClamp(): void { - $content = $this->readProjectFile('web/css/base/variables.base.css'); + $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE); $clampTokens = ['--text-2xl', '--text-3xl', '--text-4xl', '--text-5xl']; @@ -138,6 +162,61 @@ class TypographyTokenContractTest extends TestCase } } + // ────────────────────────────────────────────────────────────── + // Semantic aliases + // ────────────────────────────────────────────────────────────── + + public function testSemanticAliasesAreDefined(): void + { + $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE); + + $aliases = [ + '--text-body' => '--text-base', + '--text-label' => '--text-sm', + '--text-caption' => '--text-xs', + '--text-title' => '--text-xl', + '--text-page-title' => '--text-2xl', + ]; + + foreach ($aliases as $alias => $primitive) { + $this->assertMatchesRegularExpression( + '/' . preg_quote($alias, '/') . ':\s*var\(' . preg_quote($primitive, '/') . '\)/', + $content, + "Semantic alias {$alias} must resolve to {$primitive} in " . self::TYPOGRAPHY_TOKENS_FILE, + ); + } + } + + // ────────────────────────────────────────────────────────────── + // Reduced-motion fallbacks + // ────────────────────────────────────────────────────────────── + + public function testReducedMotionFallbacksExist(): void + { + $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE); + + $this->assertStringContainsString( + 'prefers-reduced-motion: reduce', + $content, + 'Typography tokens must include a prefers-reduced-motion fallback block', + ); + + $fluidTokens = ['--text-2xl', '--text-3xl', '--text-4xl', '--text-5xl']; + + // Extract the reduced-motion block content + preg_match('/prefers-reduced-motion:\s*reduce\).*?\{(.*?)\}\s*\}/s', $content, $matches); + $this->assertNotEmpty($matches, 'Could not parse the prefers-reduced-motion block'); + + $block = $matches[1]; + foreach ($fluidTokens as $token) { + $this->assertStringContainsString( + $token . ':', + $block, + "Fluid token {$token} must have a fixed fallback in the prefers-reduced-motion block", + ); + } + } + // ────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────── diff --git a/web/css/app-layers.css b/web/css/app-layers.css index d802345..13732e8 100644 --- a/web/css/app-layers.css +++ b/web/css/app-layers.css @@ -12,6 +12,9 @@ */ @layer tokens, vendor, layout, vendor-overrides, components, pages, utilities; +/* Load typography tokens first — consumed by variables.base.css and all downstream layers. */ +@import url("base/typography.tokens.css") layer(tokens); + /* Load third-party styles into the vendor layer so app layers can override them predictably. */ @import url("../vendor/bootstrap-icons/bootstrap-icons.css") layer(vendor); @import url("../vendor/gridjs/mermaid.min.css") layer(vendor); diff --git a/web/css/base/typography.tokens.css b/web/css/base/typography.tokens.css new file mode 100644 index 0000000..9471cac --- /dev/null +++ b/web/css/base/typography.tokens.css @@ -0,0 +1,87 @@ +@charset "UTF-8"; + +/* + * Typography tokens — single source of truth for the type system. + * + * ┌─────────┬──────────┬────────────────────────────────────────────┐ + * │ Token │ Size │ Notes │ + * ├─────────┼──────────┼────────────────────────────────────────────┤ + * │ --text-2xs │ 11px │ base ÷ 1.25²·⁵ (rounded) │ + * │ --text-xs │ 12px │ base ÷ 1.25² │ + * │ --text-sm │ 13px │ base ÷ 1.25 │ + * │ --text-base│ 14px │ ← anchor (enterprise admin density) │ + * │ --text-md │ 15px │ escape hatch between base and lg │ + * │ --text-lg │ 16px │ base × 1.25 (rounded to whole px) │ + * │ --text-xl │ 18px │ base × 1.25² │ + * │ --text-2xl │ 18–22px│ fluid via clamp() │ + * │ --text-3xl │ 22–28px│ fluid via clamp() │ + * │ --text-4xl │ 26–34px│ fluid via clamp() │ + * │ --text-5xl │ 32–44px│ fluid via clamp() │ + * └─────────┴──────────┴────────────────────────────────────────────┘ + * + * Scale ratio: ~1.25× (major third) from a 14px base. + * Fluid tokens (2xl+) use clamp(min, preferred, max) with a vw component. + * + * Semantic aliases: + * Use semantic aliases for role-based sizing (e.g. "this is a label"). + * Use primitives when the size is context-independent (e.g. "make it 12px"). + */ + +@layer tokens { +:host, +:root { + /* ── Size scale (primitives) ──────────────────────────────────── */ + --text-2xs: 0.6875rem; /* 11px */ + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.8125rem; /* 13px */ + --text-base: 0.875rem; /* 14px — enterprise admin baseline */ + --text-md: 0.9375rem; /* 15px — escape hatch */ + --text-lg: 1rem; /* 16px */ + --text-xl: 1.125rem; /* 18px */ + --text-2xl: clamp(1.125rem, 1rem + 0.5vw, 1.375rem); /* 18–22px */ + --text-3xl: clamp(1.375rem, 1.2rem + 0.75vw, 1.75rem); /* 22–28px */ + --text-4xl: clamp(1.625rem, 1.4rem + 1vw, 2.125rem); /* 26–34px */ + --text-5xl: clamp(2rem, 1.7rem + 1.5vw, 2.75rem); /* 32–44px */ + + /* ── Semantic aliases ─────────────────────────────────────────── */ + --text-caption: var(--text-xs); /* small annotations, hints */ + --text-label: var(--text-sm); /* form labels, nav items */ + --text-body: var(--text-base); /* default body text */ + --text-title: var(--text-xl); /* section headings (h2) */ + --text-page-title: var(--text-2xl); /* page headings (h1) */ + + /* ── Line-height tokens ─────────────────────────────────────── */ + --leading-none: 1; + --leading-tight: 1.2; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 1.75; + + /* ── Font-weight tokens ─────────────────────────────────────── */ + --font-regular: 400; + --font-medium: 500; + --font-semibold: 600; + --font-bold: 700; + + /* ── Letter-spacing tokens ──────────────────────────────────── */ + --tracking-tight: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; +} + +/* ── Reduced-motion fallback ──────────────────────────────────── */ +/* Lock fluid heading sizes to fixed midpoints for users who */ +/* prefer reduced motion — viewport-responsive text can feel */ +/* jarring when resizing. */ +@media (prefers-reduced-motion: reduce) { + :host, + :root { + --text-2xl: 1.25rem; /* midpoint of 1.125–1.375rem */ + --text-3xl: 1.5625rem; /* midpoint of 1.375–1.75rem */ + --text-4xl: 1.875rem; /* midpoint of 1.625–2.125rem */ + --text-5xl: 2.375rem; /* midpoint of 2–2.75rem */ + } +} +} diff --git a/web/css/base/variables.base.css b/web/css/base/variables.base.css index 6c666e7..58be7eb 100644 --- a/web/css/base/variables.base.css +++ b/web/css/base/variables.base.css @@ -75,36 +75,7 @@ --app-empty-text: var(--app-color); --app-empty-hint: var(--app-muted-color); - /* ── Typography scale (enterprise-density, 14px base, approximate 1.25× ratio) ── */ - --text-2xs: 0.6875rem; /* 11px */ - --text-xs: 0.75rem; /* 12px */ - --text-sm: 0.8125rem; /* 13px */ - --text-base: 0.875rem; /* 14px — enterprise admin baseline */ - --text-lg: 1rem; /* 16px */ - --text-xl: 1.125rem; /* 18px */ - --text-2xl: clamp(1.125rem, 1rem + 0.5vw, 1.375rem); /* 18–22px */ - --text-3xl: clamp(1.375rem, 1.2rem + 0.75vw, 1.75rem); /* 22–28px */ - --text-4xl: clamp(1.625rem, 1.4rem + 1vw, 2.125rem); /* 26–34px */ - --text-5xl: clamp(2rem, 1.7rem + 1.5vw, 2.75rem); /* 32–44px */ - - /* ── Line-height tokens ─────────────────────────────────────── */ - --leading-none: 1; - --leading-tight: 1.2; - --leading-snug: 1.375; - --leading-normal: 1.5; - --leading-relaxed: 1.625; - --leading-loose: 1.75; - - /* ── Font-weight tokens ─────────────────────────────────────── */ - --font-regular: 400; - --font-medium: 500; - --font-semibold: 600; - --font-bold: 700; - - /* ── Letter-spacing tokens ──────────────────────────────────── */ - --tracking-tight: -0.01em; - --tracking-normal: 0; - --tracking-wide: 0.025em; + /* Typography tokens defined in typography.tokens.css */ } /* Icons */ diff --git a/web/css/components/app-tile.css b/web/css/components/app-tile.css index 64c66f2..c78dba5 100644 --- a/web/css/components/app-tile.css +++ b/web/css/components/app-tile.css @@ -129,7 +129,7 @@ padding: 4px 10px 4px 10px; text-transform: uppercase; font-size: var(--text-sm); - letter-spacing: 0.4px; + letter-spacing: var(--tracking-wide); background: var(--app-blockquote-background-color); } diff --git a/web/css/layout/app-shell.css b/web/css/layout/app-shell.css index ccc0acc..1309cf4 100644 --- a/web/css/layout/app-shell.css +++ b/web/css/layout/app-shell.css @@ -36,13 +36,13 @@ } h1 { - --app-font-size: var(--text-2xl); + --app-font-size: var(--text-page-title); --app-line-height: var(--leading-tight); --app-typography-spacing-top: 3rem; } h2 { - --app-font-size: var(--text-xl); + --app-font-size: var(--text-title); --app-line-height: var(--leading-tight); --app-typography-spacing-top: 2.625rem; } @@ -178,7 +178,7 @@ th { text-transform: uppercase; font-size: var(--text-xs); - letter-spacing: 0.4px; + letter-spacing: var(--tracking-wide); border-top: 1px solid var(--app-border); } diff --git a/web/css/pages/app-docs.css b/web/css/pages/app-docs.css index 20e2ad2..a021f97 100644 --- a/web/css/pages/app-docs.css +++ b/web/css/pages/app-docs.css @@ -84,7 +84,7 @@ .app-docs-nav-group summary small { font-size: var(--text-xs); - letter-spacing: 0.05em; + letter-spacing: var(--tracking-wider); text-transform: uppercase; display: inline; } @@ -133,7 +133,7 @@ display: block; padding: 0 0 0.5rem; font-size: var(--text-xs); - letter-spacing: 0.05em; + letter-spacing: var(--tracking-wider); text-transform: uppercase; }