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>
295 lines
11 KiB
CSS
295 lines
11 KiB
CSS
/*
|
|
* `<x-modal>`: M3's basic and full-screen dialogs (DialogTokens.kt, FullScreenDialogTokens.kt,
|
|
* androidx Compose Material 3, Apache-2.0; docs/reference/m3/
|
|
* components-actions-communication-containment.md § Dialogs → Specs).
|
|
*
|
|
* [data-md-modal] the <dialog>; data-md-fullscreen while `fullscreen` holds
|
|
* [data-md-modal-box] surface-container-high, extra-large corner, elevation 3
|
|
* [data-md-modal-bar] the 56px phone-only header bar (fullscreen, below 600px)
|
|
* [data-md-modal-head] title/subtitle/icon, pinned
|
|
* [data-md-modal-body] the only part that scrolls; [data-md-modal-content] wraps the slot
|
|
* [data-md-modal-actions] pinned, trailing-aligned
|
|
*
|
|
* 560/280px width (max/min), 48px scrim margin, 28px corner and 24dp padding all match the specs
|
|
* page; a compact window's full-screen dialog swaps the box for the whole screen with a 56px bar
|
|
* (this used to be 64px) instead of the headline block. It opens on the fast spatial spring
|
|
* and closes at once — a native `<dialog>` makes an exit transition awkward, since
|
|
* `@starting-style` only ever supplies an entry.
|
|
*
|
|
* The dividers (§ Dialogs → Anatomy): a 1px outline-variant rule under the pinned head and
|
|
* over the pinned actions, each only while the body has more content hidden on its side.
|
|
* resources/js/dialog.js marks the `<dialog>` with data-md-overflow-top/-bottom as the body
|
|
* scrolls; `data-md-modal-divider` says an element takes part at all — the head and the actions
|
|
* always do while a body exists (structural: whichever follows or precedes it), the phone bar
|
|
* only when it is the row actually sitting above the body there (computed in the view, because a
|
|
* subtitle or an icon can keep the head on screen too, in which case the bar must not draw a rule
|
|
* the head already owns); `data-md-separator` (the `separator` prop) draws the rule whatever the
|
|
* scroll. The rule is a pseudo-element over the row's own edge, so showing it moves nothing.
|
|
*
|
|
* Every rule that reads the full-screen flag or the head's icon and subtitle goes through
|
|
* `> [data-md-modal-box] >`, as the divider marks do: a basic dialog opened from inside a
|
|
* full-screen dialog's body is a descendant of it, and must keep its own title and padding.
|
|
*
|
|
* `[data-md-modal-body]:focus-visible` is the scrolling body catching the dialog's first focus when
|
|
* nothing inside can take it — natively in Chrome, whose scroll containers are focusable, and
|
|
* through `materialShowModal()` (resources/js/dialog.js) in Firefox and WebKit — M3's 3px secondary
|
|
* indicator, drawn inside the edge because the box's rounded, overflow-hidden corner would clip one
|
|
* drawn outside.
|
|
*/
|
|
|
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
|
|
|
@import './icon.css';
|
|
@import './button.css';
|
|
|
|
@layer material.components {
|
|
[data-md-modal] {
|
|
margin: auto;
|
|
max-width: 560px;
|
|
min-width: 280px;
|
|
width: calc(100vw - 48px);
|
|
max-height: calc(100dvh - 48px);
|
|
overflow: visible;
|
|
padding: 0;
|
|
background-color: transparent;
|
|
color: var(--md-sys-color-on-surface);
|
|
}
|
|
|
|
/* Dialog chrome shared with the time picker's dialog and a modal date picker (timepicker.css,
|
|
* datepicker.css, both import this file for it): M3's spatial-fast pop and a 32%-scrim
|
|
* backdrop, entering only — a native `<dialog>` makes an exit transition awkward, since
|
|
* `@starting-style` only ever supplies an entry. A plain selector list, not `:is()`: `:is()`
|
|
* would flatten every branch to the specificity of `[data-md-datepicker-picker]:modal`, the
|
|
* one with a pseudo-class, and outrank whatever else the modal or the time picker's dialog is
|
|
* layered against.
|
|
*/
|
|
[data-md-modal],
|
|
[data-md-timepicker-dialog],
|
|
[data-md-datepicker-picker]:modal {
|
|
opacity: 1;
|
|
scale: 1;
|
|
transition-property: opacity, scale;
|
|
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
|
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
|
}
|
|
|
|
@starting-style {
|
|
[data-md-modal],
|
|
[data-md-timepicker-dialog],
|
|
[data-md-datepicker-picker]:modal {
|
|
opacity: 0;
|
|
scale: 0.95;
|
|
}
|
|
}
|
|
|
|
[data-md-modal]::backdrop,
|
|
[data-md-timepicker-dialog]::backdrop,
|
|
[data-md-datepicker-picker]:modal::backdrop {
|
|
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
|
}
|
|
|
|
@media (width < 600px) {
|
|
[data-md-modal][data-md-fullscreen] {
|
|
margin: 0;
|
|
width: 100%;
|
|
max-width: none;
|
|
min-width: 0;
|
|
height: 100dvh;
|
|
max-height: none;
|
|
}
|
|
}
|
|
|
|
[data-md-modal-box] {
|
|
display: flex;
|
|
max-height: inherit;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
border-radius: var(--md-sys-shape-corner-xl);
|
|
background-color: var(--md-sys-color-surface-container-high);
|
|
box-shadow: var(--md-sys-elevation-3);
|
|
}
|
|
|
|
@media (width < 600px) {
|
|
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] {
|
|
height: 100%;
|
|
border-radius: var(--md-sys-shape-corner-none);
|
|
padding-top: var(--material-safe-top, env(safe-area-inset-top));
|
|
}
|
|
}
|
|
|
|
/* The phone-only header bar: M3's full-screen dialog header, 56dp. */
|
|
[data-md-modal-bar] {
|
|
display: flex;
|
|
height: var(--md-sys-measurement-space700);
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
gap: var(--md-sys-measurement-space50);
|
|
padding-inline: var(--md-sys-measurement-space50);
|
|
}
|
|
|
|
@media (width >= 600px) {
|
|
[data-md-modal-bar] {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
[data-md-modal-bar-title] {
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
font: var(--md-sys-typescale-title-lg);
|
|
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
|
|
}
|
|
|
|
[data-md-modal-head] {
|
|
flex-shrink: 0;
|
|
padding-inline: var(--md-sys-measurement-space300);
|
|
padding-top: var(--md-sys-measurement-space300);
|
|
}
|
|
|
|
[data-md-modal-head]:has(+ [data-md-modal-body]) {
|
|
padding-bottom: var(--md-sys-measurement-space100);
|
|
}
|
|
|
|
[data-md-modal-head]:has(> [data-md-icon]) {
|
|
text-align: center;
|
|
}
|
|
|
|
/* On a phone the bar already names a plain dialog; the head hides unless an icon or a
|
|
subtitle keeps it there (the bar cannot hold either). */
|
|
@media (width < 600px) {
|
|
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle])) {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
[data-md-modal-head] > [data-md-icon] {
|
|
margin-inline: auto;
|
|
margin-bottom: var(--md-sys-measurement-space200);
|
|
color: var(--md-sys-color-secondary);
|
|
}
|
|
|
|
[data-md-modal-title] {
|
|
font: var(--md-sys-typescale-headline-sm);
|
|
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
|
|
}
|
|
|
|
@media (width < 600px) {
|
|
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-title] {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
[data-md-modal-subtitle] {
|
|
color: var(--md-sys-color-on-surface-variant);
|
|
font: var(--md-sys-typescale-body-md);
|
|
letter-spacing: var(--md-sys-typescale-body-md-tracking);
|
|
}
|
|
|
|
[data-md-modal-title] ~ [data-md-modal-subtitle],
|
|
[data-md-modal-head] > [data-md-icon] ~ [data-md-modal-subtitle] {
|
|
margin-top: var(--md-sys-measurement-space200);
|
|
}
|
|
|
|
@media (width < 600px) {
|
|
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-subtitle] {
|
|
margin-top: 0;
|
|
}
|
|
}
|
|
|
|
[data-md-modal-body] {
|
|
min-height: 0;
|
|
flex: 1 1 0%;
|
|
overflow-y: auto;
|
|
padding-inline: var(--md-sys-measurement-space300);
|
|
padding-top: var(--md-sys-measurement-space300);
|
|
padding-bottom: var(--md-sys-measurement-space300);
|
|
color: var(--md-sys-color-on-surface-variant);
|
|
font: var(--md-sys-typescale-body-md);
|
|
letter-spacing: var(--md-sys-typescale-body-md-tracking);
|
|
outline: none;
|
|
}
|
|
|
|
[data-md-modal-body]:focus-visible {
|
|
outline: 3px solid var(--md-sys-color-secondary);
|
|
outline-offset: -3px;
|
|
}
|
|
|
|
[data-md-modal-head] ~ [data-md-modal-body] {
|
|
padding-top: var(--md-sys-measurement-space100);
|
|
}
|
|
|
|
@media (width < 600px) {
|
|
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle])) ~ [data-md-modal-body] {
|
|
padding-top: var(--md-sys-measurement-space200);
|
|
}
|
|
}
|
|
|
|
[data-md-modal-body]:has(~ [data-md-modal-actions]) {
|
|
padding-bottom: var(--md-sys-measurement-space100);
|
|
}
|
|
|
|
[data-md-modal-actions] {
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: var(--md-sys-measurement-space100);
|
|
padding-inline: var(--md-sys-measurement-space300);
|
|
padding-top: var(--md-sys-measurement-space300);
|
|
padding-bottom: var(--md-sys-measurement-space300);
|
|
}
|
|
|
|
[data-md-modal-body] ~ [data-md-modal-actions] {
|
|
padding-top: var(--md-sys-measurement-space200);
|
|
}
|
|
|
|
@media (width < 600px) {
|
|
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-actions] {
|
|
min-height: var(--md-sys-measurement-space700);
|
|
padding-top: var(--md-sys-measurement-space100);
|
|
padding-bottom: var(--md-sys-measurement-space100);
|
|
}
|
|
}
|
|
|
|
/* The pinned-edge dividers: a pseudo-element over the row's own padding, drawn only while
|
|
something is scrolled past it (or `separator` says always). */
|
|
[data-md-modal-head],
|
|
[data-md-modal-bar],
|
|
[data-md-modal-actions] {
|
|
position: relative;
|
|
}
|
|
|
|
[data-md-modal-head]::after,
|
|
[data-md-modal-bar]::after,
|
|
[data-md-modal-actions]::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset-inline: 0;
|
|
height: 1px;
|
|
background-color: var(--md-sys-color-outline-variant);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
|
}
|
|
|
|
[data-md-modal-head]::after,
|
|
[data-md-modal-bar]::after {
|
|
bottom: 0;
|
|
}
|
|
|
|
[data-md-modal-actions]::before {
|
|
top: 0;
|
|
}
|
|
|
|
dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after,
|
|
dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-bar][data-md-modal-divider]::after,
|
|
dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::before,
|
|
[data-md-modal-head][data-md-modal-divider][data-md-separator]::after,
|
|
[data-md-modal-bar][data-md-modal-divider][data-md-separator]::after,
|
|
[data-md-modal-actions][data-md-modal-divider][data-md-separator]::before {
|
|
opacity: 1;
|
|
}
|
|
}
|