An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
8.2 KiB
PHP
200 lines
8.2 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Support\Facades\Route;
|
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
|
|
|
use function Orchestra\Testbench\workbench_path;
|
|
|
|
/**
|
|
* The theme <html> shows. A bare `html` selector would be read as text to search for.
|
|
*/
|
|
function theme(string $attribute, string $value): string
|
|
{
|
|
return "document.documentElement.getAttribute('{$attribute}') === '{$value}'";
|
|
}
|
|
|
|
it('follows the operating system while the visitor has not chosen', function () {
|
|
visit('/material')->inDarkMode()
|
|
->assertScript(theme('data-theme', 'dark'))
|
|
->assertScript(theme('data-theme-choice', 'system'))
|
|
->assertNoJavaScriptErrors();
|
|
|
|
visit('/material')->inLightMode()
|
|
->assertScript(theme('data-theme', 'light'));
|
|
});
|
|
|
|
it('keeps the visitor\'s choice over the operating system', function () {
|
|
// The theme switch waits for Alpine (x-cloak); a press before then would find nothing to press.
|
|
$page = ready(visit('/material')->inDarkMode())
|
|
->assertScript(theme('data-theme', 'dark'));
|
|
|
|
$page->click('label:has(input[name="material-theme"][value="light"])')
|
|
->assertScript(theme('data-theme', 'light'))
|
|
->assertScript('document.querySelector(\'input[name="material-theme"][value="light"]\').checked')
|
|
->assertScript("localStorage.getItem('material-theme') === 'light'");
|
|
|
|
$page->refresh()
|
|
->assertScript(theme('data-theme', 'light'))
|
|
->assertScript(theme('data-theme-choice', 'light'));
|
|
});
|
|
|
|
it('adopts an earlier toggle\'s choice once', function () {
|
|
config(['livewire-material.theme.legacy_keys' => ['mary-theme']]);
|
|
|
|
$page = visit('/material')->inLightMode()
|
|
->assertScript(theme('data-theme', 'light'));
|
|
|
|
$page->script("localStorage.setItem('mary-theme', JSON.stringify('dark'))");
|
|
|
|
$page->refresh()
|
|
->assertScript(theme('data-theme', 'dark'))
|
|
->assertScript("localStorage.getItem('mary-theme') === null")
|
|
->assertScript("localStorage.getItem('material-theme') === 'dark'");
|
|
});
|
|
|
|
it('repaints a section that sets its own theme', function () {
|
|
$swatch = fn (int $index): string => "getComputedStyle(document.querySelectorAll('#colour [data-theme] [data-md-showcase-swatch=\"primary\"]')[{$index}]).backgroundColor";
|
|
|
|
visit('/material/colour')->inLightMode()
|
|
->assertScript(theme('data-theme', 'light'))
|
|
->assertScript("{$swatch(0)} !== {$swatch(1)}");
|
|
});
|
|
|
|
/**
|
|
* The page has exactly one theme-color meta without a media query, and it shows the given colour.
|
|
*/
|
|
function themeColorIs(string $hex): string
|
|
{
|
|
return "(() => { const metas = document.head.querySelectorAll('meta[name=theme-color]:not([media])'); return metas.length === 1 && metas[0].getAttribute('content') === '{$hex}'; })()";
|
|
}
|
|
|
|
/**
|
|
* The surface colour role, as <html> resolves it.
|
|
*/
|
|
function pageSurfaceIs(string $hex): string
|
|
{
|
|
return "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface').trim() === '{$hex}'";
|
|
}
|
|
|
|
function themeReady(mixed $page): mixed
|
|
{
|
|
return ready($page);
|
|
}
|
|
|
|
it('adds a theme-color meta in the painted surface, and follows the theme and the colour profile', function () {
|
|
config([
|
|
'livewire-material.theme.meta' => true,
|
|
'livewire-material.scheme' => workbench_path('resources/css/material-scheme.json'),
|
|
]);
|
|
|
|
$profiles = Scheme::profiles();
|
|
|
|
$page = themeReady(visit('/material/colour')->inLightMode())
|
|
->assertScript(theme('data-scheme', 'baseline'))
|
|
->assertScript(themeColorIs($profiles['baseline']['light']['surface']))
|
|
->assertScript(pageSurfaceIs($profiles['baseline']['light']['surface']));
|
|
|
|
$page->click('label:has(input[name="material-theme"][value="dark"])')
|
|
->assertScript(theme('data-theme', 'dark'))
|
|
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
|
|
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
|
|
|
|
$page->click('#colour [data-md-scheme-picker-option="rose"]')
|
|
->assertScript(theme('data-scheme', 'rose'))
|
|
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
|
|
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
|
|
|
|
$page->click('label:has(input[name="material-theme"][value="light"])')
|
|
->assertScript(themeColorIs($profiles['rose']['light']['surface']))
|
|
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('picks the high contrast level when the operating system prefers it, until the visitor chooses', function () {
|
|
visit('/material', ['contrast' => 'more'])
|
|
->assertScript(theme('data-contrast', 'high'))
|
|
->assertNoJavaScriptErrors();
|
|
|
|
// Standard is the absence of the attribute (theme-script.blade.php): the stylesheet's plain
|
|
// blocks already are that level.
|
|
visit('/material', ['contrast' => 'no-preference'])
|
|
->assertScript("document.documentElement.hasAttribute('data-contrast') === false");
|
|
});
|
|
|
|
it('repaints the contrast level at once, and it survives a wire:navigate', function () {
|
|
Route::middleware('web')->get('/contrast-probe/{page}', fn (string $page) => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body>
|
|
<x-theme-toggle mode="contrast" />
|
|
<p id="page">This is page {{ $page }}.</p>
|
|
<a id="next" href="/contrast-probe/two" wire:navigate>Next</a>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE, ['page' => $page]));
|
|
|
|
// on-surface-variant is one of the roles a contrast level moves most (showcase/sections/colour.blade.php).
|
|
$ink = "getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface-variant').trim()";
|
|
|
|
$page = themeReady(visit('/contrast-probe/one'));
|
|
$standard = $page->script($ink);
|
|
|
|
$page->click('label:has(input[name="material-contrast"][value="high"])')
|
|
->assertScript(theme('data-contrast', 'high'))
|
|
->assertScript("{$ink} !== '{$standard}'")
|
|
->assertScript("localStorage.getItem('material-contrast') === 'high'");
|
|
|
|
$page->click('#next')
|
|
->assertSeeIn('#page', 'This is page two.')
|
|
->assertScript(theme('data-contrast', 'high'))
|
|
->assertScript("{$ink} !== '{$standard}'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('repaints the page\'s own theme-color meta, and the next page\'s after wire:navigate', function () {
|
|
config(['livewire-material.theme.meta' => true]);
|
|
|
|
Route::middleware('web')->get('/theme-color-probe/{page}', fn (string $page) => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="theme-color" content="#000000" />
|
|
<meta name="theme-color" media="print" content="#ffffff" />
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body style="background-color: var(--md-sys-color-surface);">
|
|
<p id="page">This is page {{ $page }}.</p>
|
|
<a id="next" href="/theme-color-probe/two" wire:navigate>Next</a>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE, ['page' => $page]));
|
|
|
|
$scheme = Scheme::load();
|
|
|
|
$page = themeReady(visit('/theme-color-probe/one')->inLightMode())
|
|
->assertScript(themeColorIs($scheme['light']['surface']))
|
|
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'");
|
|
|
|
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
|
|
|
|
$page->assertScript(themeColorIs($scheme['dark']['surface']));
|
|
|
|
$page->click('#next')
|
|
->assertSeeIn('#page', 'This is page two.')
|
|
->assertScript("window.eval('window.samePage') === true")
|
|
->assertScript(theme('data-theme', 'dark'))
|
|
->assertScript(themeColorIs($scheme['dark']['surface']))
|
|
->assertScript("document.head.querySelector('meta[media]').getAttribute('content') === '#ffffff'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|