Files
breadcrumb-the-shire/tests/Architecture/TypographyTokenContractTest.php
fs fc2ebe9314 fix: address 6 low-severity findings from typography token code review
- Replace mislabeled sidebar title 10px with var(--text-2xs) (F-001)
- Replace font-size: small with var(--text-sm) in app-shell (F-002)
- Annotate @media heading overrides as potentially redundant with clamp() (F-003)
- Correct type scale comment to "approximate major-third ratio" (F-004)
- Differentiate h5 (--text-sm) from h4 (--text-base) (F-005)
- Tighten contract test to flag bare normal/bold keywords (F-006)

Task run: UI-TYPOGRAPHY-SCALE-002

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 15:56:58 +01:00

215 lines
8.6 KiB
PHP

<?php
namespace MintyPHP\Tests\Architecture;
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.
*
* 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
* - CSS custom property fallback definitions (--app-font-size, --app-font-weight, --app-line-height)
*/
class TypographyTokenContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* Files and directories excluded from scanning.
*
* @var list<string>
*/
private const EXCLUDED_PATHS = [
'web/css/vendor/',
'web/css/vendor-overrides/',
'web/css/base/variables.base.css',
];
/**
* Patterns that identify intentional exceptions (icon fonts, em-relative badges, etc.).
*
* @var list<string>
*/
private const EXCEPTION_MARKERS = [
'icon-font metric',
'em-relative sizing',
'intentional px',
];
// ──────────────────────────────────────────────────────────────
// 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(
'/(?<!\-)line-height\s*:\s*+(?!var\(--leading-|var\(--app-line-height\)|var\(--app-line|inherit|unset|initial|revert|normal|0[;\s])/',
self::EXCEPTION_MARKERS,
);
$this->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),
);
}
// ──────────────────────────────────────────────────────────────
// Token definitions exist
// ──────────────────────────────────────────────────────────────
public function testTypographyTokensAreDefined(): void
{
$content = $this->readProjectFile('web/css/base/variables.base.css');
$requiredTokens = [
'--text-2xs', '--text-xs', '--text-sm', '--text-base', '--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',
];
foreach ($requiredTokens as $token) {
$this->assertStringContainsString(
$token . ':',
$content,
"Typography token {$token} is missing from variables.base.css",
);
}
}
public function testHeadingTokensUseClamp(): void
{
$content = $this->readProjectFile('web/css/base/variables.base.css');
$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",
);
}
}
// ──────────────────────────────────────────────────────────────
// 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<string> $exceptionMarkers Strings whose presence on the same line excuses the match
* @return list<string> 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;
}
}