Cut duplicated and speculative code across the package

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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 19:29:21 +02:00
co-authored by Claude Opus 5
parent 471d927e64
commit 247c596c3a
233 changed files with 16635 additions and 10579 deletions
@@ -1,97 +1,19 @@
{{-- M3 Expressive's navigation rail: destinations down the start edge of a `medium` or wider
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width
pill). `mode`, props, slots and `$store.rail` are documented in SKILL.md.
<x-row align="stretch" style="min-height: 100dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="md-type-title-lg">Mail</span></x-slot:brand>
<x-slot:header>
<x-fab label="Compose" icon="edit" />
</x-slot:header>
Anything else inside the rail queries `--md-navigation-rail-value` (SKILL.md has the pattern
and an example); the rail's own two rules (resources/css/components/navigation-rail.css) are
what publish it, so every other rule just queries it instead of copying the rail's own
conditions. tests/Feature/Components/NavigationRailTest.php checks both halves.
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="/settings" />
</x-slot:footer>
</x-navigation-rail>
<main style="flex: 1; min-width: 0">…</main>
</x-row>
`mode` says what decides its width:
- `collapsed` — always collapsed; `expanded` — always expanded.
- `collapsible` (the default) — the visitor's choice: expanded until the menu button collapses
it. The choice is `$store.rail`, remembered in localStorage and applied by <x-theme-script>
before the first paint (<html data-rail>), so the rail never paints wide and snaps shut.
- `modal` collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
leaving the page closes it (Compose's ModalWideNavigationRail).
- `adaptive` — what `<x-scaffold>` uses, one rail per M3 window size class: on a compact
window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal;
at `medium` (600839) collapsed in the layout, opening as a modal; at `expanded` (8401199)
a standard rail, collapsed until its menu button expands it in place; from `large` (1200)
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
that choice in both standard bands.
Slots: `brand` beside the menu button, only while expanded; `header` under it — one
`<x-fab label="…" icon="…">`, which the rail morphs: the label's width springs open and shut
with the rail, so the FAB becomes an extended FAB and back rather than one being swapped for
the other, and its label names it at both widths. It also rests flat, because M3 puts a FAB
nested in another component at elevation 0, not the 3 a standalone one has. Then the
destinations in the default slot, which alone scroll when the window is too short, and
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
the scroller's edge.
Anything else inside takes both shapes from the rail's value: the rail publishes M3's two
(Compose's WideNavigationRailValue) as `--md-navigation-rail-value`, `collapsed` or
`expanded`, from the first paint and in step with the rail's own items, and every descendant
inherits it, so an application's CSS asks a style query instead of repeating the conditions:
@container style(--md-navigation-rail-value: collapsed) {
.account-summary { display: none; }
}
A rail open over a scrim reads `expanded`; outside a rail the property is unset and neither
value matches. Style queries on a custom property need Chrome 111, Safari 18 or Firefox 151.
The package's own rules write the conditions out (resources/css/components/navigation-rail.css
lists them), and tests/Feature/Components/NavigationRailTest.php keeps each copy to the same
conditions. Nothing that shows while collapsed may be wider than 96px.
Props: `label` names the landmark ("Main"); `width` is the expanded width (`256px`, held
between M3's 220 and 360dp) or the word `narrow`, M3's other *collapsed* width
(NavigationRailCollapsedTokens.NarrowContainerWidth, 80px against the default 96), where the
items are their icons alone because no label fits under a 56px indicator at that width; the
labels stay in the accessibility tree, since they are what name the destinations, and a
narrow rail expands to the default 256px; `align` is `top` (the default) or `center`, which
puts the destinations at the rail's vertical centre M3 prefers that on a tablet, for reach
while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3
asks; more destinations than fit go back to the top rather than out of reach above the
scroller; `hide-when-collapsed` is M3's other expanded behaviour, for a `collapsible` or
`adaptive` rail: collapsing it takes it out of the layout altogether instead of narrowing it
to 96px, and it comes back expanded over a scrim when something calls `$store.rail.show()` —
a menu button in the app bar, which is the only way back, so put one there. Its own menu
button then docks it into the layout again. Not below `medium` for a collapsible rail, nor at
`medium` for an adaptive one: there it is the window and not the visitor that collapses a
rail, and M3's collapsed rail may never hide; `menu` shows the menu button (by default for `collapsible`,
`modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page
is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill`
(`false`) drops the container colour for a transparent rail over the page's own background,
which M3 allows as long as the items keep a 3:1 contrast against what is behind them. A rail
open over a scrim keeps its fill and drops the divider whatever those say: it is a surface
over the page then. As it closes it keeps `data-md-closing` (`sheet` while the panel slides off
the window, `scrim` while only the scrim fades) until the exit has run, which is what holds it
on screen in Firefox, where `display` cannot transition (resources/js/navigation.js). The rail
does not scroll with the page: in a flex row it sticks to the top of the viewport, as tall as
the viewport at most.
As the rail closes it keeps `data-md-closing` (`sheet` while the panel slides off the window,
`scrim` while only the scrim fades) until the exit has run: Firefox cannot transition
`display`, so this is what holds a closing rail on screen there (resources/js/navigation.js).
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and
WideNavigationRail.kt under
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and WideNavigationRail.kt
— surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
the header and 40px under it, 4px between collapsed items. The styles are
resources/css/components/navigation-rail.css; the behaviour resources/js/navigation.js. --}}