Name the M3 replacement for every value the theme cleared

DesignGuard gains checks for Tailwind's breakpoint prefixes, for the radius,
shadow, type-size, weight, leading, tracking, easing and duration scales
tokens/theme.css clears, and for a colour written as a value; each violation
carries path:line, the token and the M3 utility to use instead. forbidAbsolutes()
and forbidOpacityInk() are opt-in like forbidColours(), because the package's own
disabled styles are drawn with M3's 38 %/12 % opacities. A fixture and a test per
pattern and per hint; the package's own views are asserted against every check
but the breakpoint one, which plan step 15 migrates.
Plan: docs/plans/material-3-alignment.md, step 9 (core C4a, C13).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:32:25 +02:00
co-authored by Claude Opus 5
parent 25ff5a8d71
commit faf5132c7a
8 changed files with 421 additions and 8 deletions
+227 -5
View File
@@ -8,16 +8,22 @@ use Symfony\Component\Finder\Finder;
/**
* Finds what compiles to nothing and fails silently: maryUI tags, daisyUI classes, colours the
* theme does not declare, icon names that are not Material Symbols — plus whatever an
* application bans on top (roles its own design rules leave out, patterns it has retired).
* theme does not declare, icon names that are not Material Symbols, Tailwind's breakpoint
* prefixes and the radius, shadow, type-size, weight, leading, tracking, easing and duration
* scales that tokens/theme.css clears for M3's own, and colours written as a literal value
* instead of a role — plus whatever an application bans on top (roles its own design rules
* leave out, the two absolutes, opacity used as emphasis, patterns it has retired).
*
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
* ->forbidColours(['tertiary', 'primary-container'])
* ->forbidAbsolutes()
* ->forbidOpacityInk()
* ->violations())->toBe([]);
*
* Each violation is "path:line what", with the path relative to the base path. It reads the
* source, so a class assembled at runtime (`'text-'.$tone`) is invisible to it — which is one
* more reason to write class names out whole, the only form Tailwind compiles anyway.
* Each violation is "path:line what", with the path relative to the base path; where there is
* an M3 utility to use instead, the line names it. It reads the source, so a class assembled at
* runtime (`'text-'.$tone`) is invisible to it — which is one more reason to write class names
* out whole, the only form Tailwind compiles anyway.
*/
class DesignGuard
{
@@ -36,12 +42,64 @@ class DesignGuard
protected const string TAILWIND_LOOKALIKES = '/^(?:select-(?:none|text|all|auto)|tab-\d+)$/';
/** A colour written as a value: an arbitrary hex, function or mix instead of a role. */
protected const string ARBITRARY_COLOUR = '/(?<![\w-])'.self::UTILITY.'-\[(?:#|rgb|hsl|oklch|color-mix)[^\]\s"\']*\]?/';
/** The two absolutes. M3's white is a surface role, not a colour. */
protected const string ABSOLUTE_COLOUR = '/(?<![\w-])'.self::UTILITY.'-(?:white|black)(?![\w-])/';
/** Opacity used as emphasis on ink, which M3 expresses as a role. */
protected const string OPACITY_INK = '/(?<![\w-])(?:bg|text|border(?:-[trblxyse])?)-[a-z][a-z\d-]*\/\d{1,3}(?![\w-])/';
/**
* Tailwind's breakpoint prefixes, cleared by tokens/theme.css, and the M3 window size class
* that replaces each. Tailwind's 640/768/1024/1280/1536 are 4088 px from M3's 600/840/1200/
* 1600, so a prefix maps to the class that carries the same intent, never to the same pixel:
* both of its phone breakpoints are M3's medium.
*/
protected const array WINDOW_CLASSES = [
'sm' => 'medium',
'md' => 'medium',
'lg' => 'expanded',
'xl' => 'large',
'2xl' => 'extra-large',
];
/** Tailwind's radius scale and the M3 corner that replaces each (styles §Shape). */
protected const array CORNERS = [
'none' => 'none',
'xs' => 'xs',
'sm' => 'sm',
'md' => 'md',
'lg' => 'lg',
'xl' => 'xl',
'2xl' => 'xxl',
'3xl' => 'xxl',
'4xl' => 'xxl',
'full' => 'full',
];
/** Tailwind's shadow scale and the M3 elevation level that replaces each (styles §Elevation). */
protected const array ELEVATIONS = [
'2xs' => 1,
'xs' => 1,
'sm' => 1,
'md' => 2,
'lg' => 3,
'xl' => 4,
'2xl' => 5,
];
/** @var list<array{pattern: string, reason: string}> */
protected array $forbidden = [];
/** @var list<string> */
protected array $forbiddenColours = [];
protected bool $forbiddenAbsolutes = false;
protected bool $forbiddenOpacityInk = false;
/**
* @param list<string> $paths
*/
@@ -68,6 +126,34 @@ class DesignGuard
return $this;
}
/**
* Also ban the two absolutes. M3 paints with roles only: the white of a light page is
* `surface-container-lowest`, the black of a dark one `surface-dim`, and the ink on a filled
* button is its `on-` role. Off by default, because a logo, a scrim or a print stylesheet
* sometimes does mean the absolute.
*/
public function forbidAbsolutes(): static
{
$this->forbiddenAbsolutes = true;
return $this;
}
/**
* Also ban opacity as emphasis on ink: M3 says secondary text is a role — on-surface-variant
* for supporting text, outline for the quietest — never a faded on-surface. It reserves two
* opacities for the disabled state, 38 % on content and 12 % on a container, and the
* package's own disabled styles are written with those two, which is why this is off by
* default; an application that draws its disabled states from the components does not need
* either opacity of its own.
*/
public function forbidOpacityInk(): static
{
$this->forbiddenOpacityInk = true;
return $this;
}
/**
* Any further pattern, matched line by line.
*/
@@ -123,6 +209,10 @@ class DesignGuard
}
}
foreach ($this->offTheTokens($text) as $what) {
$violations[] = "{$where}:{$line} {$what}";
}
foreach ($this->forbidden as $rule) {
if (preg_match($rule['pattern'], $text) === 1) {
$violations[] = "{$where}:{$line} {$rule['reason']}";
@@ -164,6 +254,138 @@ class DesignGuard
return '/(?<![\w-])'.self::UTILITY.'-(?:'.implode('|', $names).')(?![\w-])/';
}
/**
* Everything on one line that names a value the theme no longer carries, each with the M3
* utility that replaces it. Line by line like the colour check, so a class inside a PHP or
* JS string is seen too, not only one inside a `class` attribute.
*
* @return list<string>
*/
protected function offTheTokens(string $text): array
{
return [
...$this->breakpointPrefixes($text),
...$this->outsideTheScale($text),
...$this->colourValues($text),
];
}
/**
* `sm:`, `max-2xl:` and the rest, stacked variants included. A prefix must be followed at
* once by the next variant or the utility itself, so a `md:` that is really an object key
* (`md: { … }` in a script) is left alone.
*
* @return list<string>
*/
protected function breakpointPrefixes(string $text): array
{
static $pattern = null;
$pattern ??= '/(?<![\w-])(?<max>max-)?(?<name>'.$this->alternation(array_keys(self::WINDOW_CLASSES)).'):(?=[a-z\d!*\[(_-])/';
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
return array_map(function (array $match): string {
$max = $match['max'] ?? '';
return "Tailwind breakpoint `{$max}{$match['name']}:`, use `{$max}".self::WINDOW_CLASSES[$match['name']].':`';
}, $matches);
}
/**
* The radius, shadow, type-size, weight, leading, tracking, easing and duration utilities
* Tailwind ships and tokens/theme.css clears.
*
* @return list<string>
*/
protected function outsideTheScale(string $text): array
{
static $pattern = null;
$pattern ??= '/(?<![\w-])(?:'
.'rounded(?<side>-(?:ss|se|ee|es|tl|tr|br|bl|t|r|b|l|s|e))?-(?<corner>'.$this->alternation(array_keys(self::CORNERS)).')'
.'|shadow-(?<elevation>'.$this->alternation(array_keys(self::ELEVATIONS)).')'
.'|text-(?<size>xs|sm|base|lg|xl|[2-9]xl)'
.'|font-(?<weight>thin|extralight|light|normal|medium|semibold|bold|extrabold|black)'
.'|leading-(?<leading>none|tight|snug|normal|relaxed|loose|\d+(?:\.\d+)?)'
.'|tracking-(?<tracking>tighter|tight|normal|wider|widest|wide)'
.'|ease-(?<easing>in-out|linear|in|out)'
.'|duration-(?<duration>\d+)'
.')(?![\w-])/';
preg_match_all($pattern, $text, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
return array_map(
fn (array $match): string => "value outside the M3 scale `{$match[0]}`, use ".$this->scaleReplacement($match),
$matches,
);
}
/**
* The M3 utility for one match of the scale pattern. A type size, a leading and a tracking
* are all one type style: the `type-*` utilities set the three together, which is what keeps
* a line of text on the typescale.
*
* @param array<array-key, string|null> $match
*/
protected function scaleReplacement(array $match): string
{
return match (true) {
isset($match['corner']) => '`rounded'.($match['side'] ?? '').'-corner-'.self::CORNERS[$match['corner']].'`',
isset($match['elevation']) => '`shadow-elevation-'.self::ELEVATIONS[$match['elevation']].'`',
isset($match['weight']) => 'a `type-emphasized-*` style',
isset($match['easing']) => '`ease-standard` or an `ease-spatial-*`/`ease-effects-*` with its duration',
isset($match['duration']) => '`duration-(--md-sys-motion-…-duration)` paired with its easing',
default => 'a `type-*` style',
};
}
/**
* Colours written as a value rather than a role: an arbitrary one always, the two absolutes
* and opacity on ink when the application asks for them.
*
* @return list<string>
*/
protected function colourValues(string $text): array
{
$found = [];
preg_match_all(self::ARBITRARY_COLOUR, $text, $matches);
foreach ($matches[0] as $class) {
$found[] = "arbitrary colour `{$class}`, use an M3 role";
}
if ($this->forbiddenAbsolutes) {
preg_match_all(self::ABSOLUTE_COLOUR, $text, $matches);
foreach ($matches[0] as $class) {
$found[] = "absolute colour `{$class}`, use an M3 role";
}
}
if ($this->forbiddenOpacityInk) {
preg_match_all(self::OPACITY_INK, $text, $matches);
foreach ($matches[0] as $class) {
$found[] = "opacity on ink `{$class}`, use a role (`text-on-surface-variant`, `text-outline`)";
}
}
return $found;
}
/**
* The names as one alternation, longest first so that a prefix of another name — `xl` of
* `2xl` — cannot win where the pattern is not anchored.
*
* @param list<string> $names
*/
protected function alternation(array $names): string
{
usort($names, fn (string $a, string $b): int => strlen($b) <=> strlen($a));
return implode('|', array_map(fn (string $name): string => preg_quote($name, '/'), $names));
}
/**
* Blade comments blanked out, their line breaks kept so line numbers still match.
*/