Remove the dedicated "Edit"/"Open" action button column from all list pages. Instead, wrap the primary data column (name, description, subject, etc.) in a clickable <a> link via the new rowInteraction.linkColumn option. Double-click, Enter-key, and click-to-focus row navigation preserved. Pages with explicit actions config are unaffected. Address-book opts out of auto-wrapping (linkColumn: false) and renders the link in its own formatter to avoid nested <a> with avatar lightbox. Also fixes pre-existing XSS in insertActionColumn (unescaped href/label) and updates typography test for --text-page-title token change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
294 lines
12 KiB
PHP
294 lines
12 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-*, --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 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
|
|
{
|
|
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',
|
|
'web/css/base/typography.tokens.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',
|
|
];
|
|
|
|
/**
|
|
* 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(
|
|
'/(?<!\-)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),
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 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<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;
|
|
}
|
|
}
|