*/ private const EXCLUDED_PATHS = [ 'web/css/vendor/', 'web/css/vendor-overrides/', 'web/css/base/variables.base.css', 'web/css/base/typography.tokens.css', 'web/css/pages/app-error-debug.css', // Self-contained error page — inlined by ErrorRenderer, cannot use app tokens. ]; /** * Patterns that identify intentional exceptions (icon fonts, em-relative badges, etc.). * * @var list */ private const EXCEPTION_MARKERS = [ 'icon-font metric', 'em-relative sizing', 'intentional px', ]; /** * The file that defines all typography tokens. */ private const TYPOGRAPHY_TOKENS_FILE = 'web/css/base/typography.tokens.css'; // ────────────────────────────────────────────────────────────── // font-size // ────────────────────────────────────────────────────────────── public function testFontSizeUsesTokens(): void { $violations = $this->scanForRawValues( '/font-size\s*:\s*+(?!var\(--text-|var\(--app-|inherit|unset|initial|revert|0[;\s])/', self::EXCEPTION_MARKERS, ); $this->assertSame( [], $violations, "Raw font-size values found instead of --text-* tokens:\n" . implode("\n", $violations), ); } // ────────────────────────────────────────────────────────────── // line-height // ────────────────────────────────────────────────────────────── public function testLineHeightUsesTokens(): void { $violations = $this->scanForRawValues( '/(?assertSame( [], $violations, "Raw line-height values found instead of --leading-* tokens:\n" . implode("\n", $violations), ); } // ────────────────────────────────────────────────────────────── // font-weight // ────────────────────────────────────────────────────────────── public function testFontWeightUsesTokens(): void { $violations = $this->scanForRawValues( '/font-weight\s*:\s*+(?!var\(--font-|var\(--app-font-weight\)|var\(--app-form-label-font-weight|inherit|unset|initial|revert|bolder|lighter)/', self::EXCEPTION_MARKERS, ); $this->assertSame( [], $violations, "Raw font-weight values found instead of --font-* tokens:\n" . implode("\n", $violations), ); } // ────────────────────────────────────────────────────────────── // 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(self::TYPOGRAPHY_TOKENS_FILE); $requiredTokens = [ '--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-wider', ]; foreach ($requiredTokens as $token) { $this->assertStringContainsString( $token . ':', $content, "Typography token {$token} is missing from " . self::TYPOGRAPHY_TOKENS_FILE, ); } } public function testHeadingTokensUseClamp(): void { $content = $this->readProjectFile(self::TYPOGRAPHY_TOKENS_FILE); $clampTokens = ['--text-2xl', '--text-3xl', '--text-4xl', '--text-5xl']; foreach ($clampTokens as $token) { $this->assertMatchesRegularExpression( '/' . preg_quote($token, '/') . ':\s*clamp\(/', $content, "Heading token {$token} must use clamp() for responsive scaling", ); } } // ────────────────────────────────────────────────────────────── // 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-3xl', ]; 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 // ────────────────────────────────────────────────────────────── /** * Scan all CSS files in web/css/ for lines matching the violation pattern, * excluding allowed paths and lines that contain an exception marker. * * @param string $violationPattern Regex that matches a raw value * @param list $exceptionMarkers Strings whose presence on the same line excuses the match * @return list Violation descriptions ("file:line: content") */ private function scanForRawValues(string $violationPattern, array $exceptionMarkers): array { $root = $this->projectRootPath(); $cssDir = $root . '/web/css'; $this->assertDirectoryExists($cssDir); $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($cssDir), ); $violations = []; /** @var \SplFileInfo $file */ foreach ($iterator as $file) { if (!$file->isFile() || $file->getExtension() !== 'css') { continue; } $relativePath = str_replace($root . '/', '', $file->getPathname()); // Skip excluded paths foreach (self::EXCLUDED_PATHS as $excluded) { if (str_starts_with($relativePath, $excluded)) { continue 2; } } $content = file_get_contents($file->getPathname()); $this->assertNotFalse($content, 'Could not read file: ' . $relativePath); $lines = explode("\n", $content); foreach ($lines as $lineNumber => $line) { if (!preg_match($violationPattern, $line)) { continue; } // Check if the line contains a documented exception marker $isException = false; foreach ($exceptionMarkers as $marker) { if (str_contains($line, $marker)) { $isException = true; break; } } if ($isException) { continue; } // Skip CSS custom property definitions (--app-font-size, --app-line-height, etc.) if (preg_match('/^\s*--app-/', $line)) { continue; } $violations[] = sprintf('%s:%d: %s', $relativePath, $lineNumber + 1, trim($line)); } } sort($violations); return $violations; } }