1
0

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 16:25:51 +01:00
parent be1314f8b8
commit 026002a501
7 changed files with 184 additions and 44 deletions

View File

@@ -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
// ──────────────────────────────────────────────────────────────

View File

@@ -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);

View File

@@ -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 │ 1822px│ fluid via clamp() │
* │ --text-3xl │ 2228px│ fluid via clamp() │
* │ --text-4xl │ 2634px│ fluid via clamp() │
* │ --text-5xl │ 3244px│ 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); /* 1822px */
--text-3xl: clamp(1.375rem, 1.2rem + 0.75vw, 1.75rem); /* 2228px */
--text-4xl: clamp(1.625rem, 1.4rem + 1vw, 2.125rem); /* 2634px */
--text-5xl: clamp(2rem, 1.7rem + 1.5vw, 2.75rem); /* 3244px */
/* ── 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.1251.375rem */
--text-3xl: 1.5625rem; /* midpoint of 1.3751.75rem */
--text-4xl: 1.875rem; /* midpoint of 1.6252.125rem */
--text-5xl: 2.375rem; /* midpoint of 22.75rem */
}
}
}

View File

@@ -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); /* 1822px */
--text-3xl: clamp(1.375rem, 1.2rem + 0.75vw, 1.75rem); /* 2228px */
--text-4xl: clamp(1.625rem, 1.4rem + 1vw, 2.125rem); /* 2634px */
--text-5xl: clamp(2rem, 1.7rem + 1.5vw, 2.75rem); /* 3244px */
/* ── 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 */

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}