From 247c596c3a65448bccbea85007fee4d926c8990f Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Thu, 17 Sep 2026 19:29:21 +0200 Subject: [PATCH] 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) --- .github/workflows/tests.yml | 33 +- README.md | 2 +- UPGRADE.md | 20 +- bin/check-font.mjs | 44 +- bin/loading-indicator.mjs | 346 +- bin/scheme.mjs | 2 +- bin/shapes.mjs | 492 +- bin/stylesheets.mjs | 58 + composer.json | 7 +- docs/audits/m3-alignment/actions.md | 811 -- docs/audits/m3-alignment/containment.md | 597 - docs/audits/m3-alignment/core.md | 292 - docs/audits/m3-alignment/inputs.md | 692 -- docs/audits/m3-alignment/navigation.md | 637 - docs/reference/m3/README.md | 2 - package.json | 4 +- resources/boost/guidelines/core.blade.php | 1 - .../boost/guidelines/material-3.blade.php | 2 +- .../livewire-material-development/SKILL.md | 44 +- .../boost/skills/material-3-design/SKILL.md | 32 +- resources/css/all.css | 3 +- resources/css/components/account-menu.css | 6 +- resources/css/components/alert.css | 37 +- resources/css/components/app-bar.css | 10 +- resources/css/components/badge.css | 52 +- resources/css/components/bottom-sheet.css | 4 +- resources/css/components/button-group.css | 5 +- resources/css/components/button.css | 56 +- resources/css/components/card.css | 3 +- resources/css/components/carousel-item.css | 7 +- resources/css/components/carousel.css | 12 +- resources/css/components/chip.css | 43 +- resources/css/components/collapse.css | 2 +- resources/css/components/color.css | 94 + resources/css/components/datepicker.css | 18 +- resources/css/components/drawer.css | 2 +- resources/css/components/error-page.css | 14 +- resources/css/components/fab-menu-item.css | 2 +- resources/css/components/fab-menu.css | 6 +- resources/css/components/field.css | 5 - resources/css/components/group.css | 15 +- resources/css/components/list-item.css | 20 +- resources/css/components/list.css | 2 +- resources/css/components/menu-item.css | 64 +- resources/css/components/menu.css | 30 +- resources/css/components/modal.css | 27 +- .../css/components/navigation-bar-item.css | 2 +- resources/css/components/navigation-item.css | 2 +- .../css/components/navigation-rail-item.css | 477 +- .../components/navigation-rail-section.css | 80 +- resources/css/components/navigation-rail.css | 483 +- resources/css/components/progress.css | 36 +- resources/css/components/rich-tooltip.css | 2 +- resources/css/components/scheme-picker.css | 14 +- resources/css/components/search.css | 5 - resources/css/components/section-nav.css | 6 +- resources/css/components/slider.css | 52 +- resources/css/components/split-button.css | 5 +- resources/css/components/tabs.css | 16 +- resources/css/components/theme-toggle.css | 12 +- resources/css/components/timepicker.css | 20 +- resources/css/components/toast.css | 10 +- resources/css/components/toolbar.css | 10 +- resources/css/foundation.css | 11 +- resources/css/foundation/tokens.css | 27 - resources/css/layout/scaffold.css | 42 +- resources/css/showcase.css | 46 +- resources/css/tokens/type.css | 4 +- resources/dist/error-page-fallback.css | 1802 +++ resources/dist/error-page.css | 592 + resources/dist/showcase.css | 10252 ++++++++++++++++ resources/js/bottom-sheet.js | 19 +- resources/js/carousel.js | 57 +- resources/js/chips.js | 19 +- resources/js/collapse.js | 8 +- resources/js/fab.js | 36 +- resources/js/field.js | 20 +- resources/js/figure.js | 13 +- resources/js/layers.js | 23 +- resources/js/menu.js | 1047 +- resources/js/navigation.js | 19 +- resources/js/popover-exit.js | 8 +- resources/js/progress.js | 646 +- resources/js/rich-tooltip.js | 57 +- resources/js/search.js | 21 +- resources/js/shapes.js | 772 ++ resources/js/slider.js | 47 +- resources/js/theme.js | 14 +- resources/js/timepicker.js | 39 +- resources/js/toolbar.js | 21 +- resources/js/tooltip.js | 85 +- resources/js/util.js | 88 + resources/node/scheme.mjs | 4 +- resources/views/components/alert.blade.php | 4 +- resources/views/components/badge.blade.php | 4 +- .../views/components/bottom-sheet.blade.php | 4 +- resources/views/components/button.blade.php | 5 +- resources/views/components/card.blade.php | 7 +- .../views/components/carousel-item.blade.php | 2 +- resources/views/components/carousel.blade.php | 4 +- resources/views/components/checkbox.blade.php | 6 +- resources/views/components/chip-set.blade.php | 4 +- resources/views/components/chip.blade.php | 28 +- resources/views/components/choices.blade.php | 13 +- resources/views/components/collapse.blade.php | 6 +- .../views/components/datepicker.blade.php | 109 +- resources/views/components/divider.blade.php | 8 +- resources/views/components/drawer.blade.php | 10 +- .../views/components/fab-menu-item.blade.php | 9 +- resources/views/components/fab-menu.blade.php | 6 +- resources/views/components/fab.blade.php | 5 +- resources/views/components/feed.blade.php | 4 +- resources/views/components/file.blade.php | 8 +- resources/views/components/grid.blade.php | 4 +- resources/views/components/group.blade.php | 11 +- resources/views/components/input.blade.php | 6 +- .../views/components/list-detail.blade.php | 10 +- .../views/components/menu-group.blade.php | 4 +- .../views/components/menu-item.blade.php | 13 +- resources/views/components/menu.blade.php | 137 +- resources/views/components/modal.blade.php | 10 +- .../components/navigation-bar-item.blade.php | 5 +- .../components/navigation-rail-item.blade.php | 5 +- .../components/navigation-rail.blade.php | 98 +- resources/views/components/pane.blade.php | 4 +- resources/views/components/password.blade.php | 6 +- resources/views/components/progress.blade.php | 4 +- resources/views/components/radio.blade.php | 6 +- .../views/components/rich-tooltip.blade.php | 4 +- resources/views/components/row.blade.php | 4 +- resources/views/components/scaffold.blade.php | 114 +- .../views/components/scheme-picker.blade.php | 3 +- resources/views/components/select.blade.php | 6 +- resources/views/components/stack.blade.php | 4 +- .../components/supporting-pane.blade.php | 4 +- resources/views/components/surface.blade.php | 4 +- resources/views/components/tab.blade.php | 34 +- resources/views/components/tabs.blade.php | 8 +- resources/views/components/textarea.blade.php | 6 +- .../views/components/timepicker.blade.php | 64 +- resources/views/components/toast.blade.php | 57 +- .../error-pages/errors/minimal.blade.php | 10 +- .../views/partials/menu-filter.blade.php | 39 + .../partials/timepicker-period.blade.php | 27 + resources/views/showcase/head.blade.php | 20 + resources/views/showcase/layout.blade.php | 21 +- .../views/showcase/sections/shape.blade.php | 2 +- resources/views/showcase/shell.blade.php | 21 +- routes/showcase.php | 7 +- src/Console/SchemeCommand.php | 69 +- .../Controllers/ShowcaseAssetController.php | 91 +- src/LivewireMaterialServiceProvider.php | 10 +- src/Support/ErrorPage.php | 143 +- src/Support/Field.php | 58 + src/Support/Link.php | 29 + src/Support/Scheme.php | 17 +- src/Support/SchemeStylesheet.php | 27 +- src/Support/Stylesheets.php | 345 +- src/Testing/DesignGuard.php | 119 +- tests/Browser/ActionsTest.php | 24 +- tests/Browser/BarsTest.php | 17 +- tests/Browser/CarouselTest.php | 6 +- tests/Browser/CascadeTest.php | 20 +- tests/Browser/ChipsTest.php | 9 +- tests/Browser/ColourProfilesTest.php | 3 +- tests/Browser/CommunicationTest.php | 49 +- tests/Browser/ContainmentTest.php | 163 +- tests/Browser/DataTest.php | 6 +- tests/Browser/DatepickerTest.php | 9 +- tests/Browser/ErrorPagesTest.php | 12 +- tests/Browser/FieldsTest.php | 12 +- tests/Browser/LayoutTest.php | 3 +- tests/Browser/NavigationTest.php | 3 +- tests/Browser/PickingTest.php | 22 +- tests/Browser/ProgressTest.php | 3 +- tests/Browser/ShowcaseTest.php | 12 +- tests/Browser/SliderTest.php | 6 +- tests/Browser/ThemeTest.php | 7 +- tests/Browser/TimepickerTest.php | 3 +- tests/Feature/BoostVocabularyTest.php | 3 - .../Components/ActionStylesheetsTest.php | 89 - tests/Feature/Components/AlertTest.php | 8 +- tests/Feature/Components/AppBarTest.php | 27 +- tests/Feature/Components/BadgeTest.php | 33 +- tests/Feature/Components/ButtonGroupTest.php | 2 +- tests/Feature/Components/ButtonTest.php | 28 +- tests/Feature/Components/CarouselTest.php | 4 +- tests/Feature/Components/ChipTest.php | 21 +- tests/Feature/Components/ChoicesTest.php | 3 +- tests/Feature/Components/ColorTest.php | 45 + .../Components/ComponentStylesheetsTest.php | 209 + .../Components/ContainmentStylesheetsTest.php | 109 - tests/Feature/Components/DataTest.php | 9 +- tests/Feature/Components/FabTest.php | 5 +- tests/Feature/Components/FeedTest.php | 23 +- tests/Feature/Components/FieldTest.php | 21 +- tests/Feature/Components/GridTest.php | 19 +- tests/Feature/Components/GroupTest.php | 2 + tests/Feature/Components/IconTest.php | 3 +- .../Components/InputStylesheetsTest.php | 195 - .../Components/LayoutComponentsTest.php | 68 + tests/Feature/Components/ListDetailTest.php | 16 +- tests/Feature/Components/MenuTest.php | 6 +- .../Feature/Components/NavigationBarTest.php | 6 +- .../Feature/Components/NavigationRailTest.php | 97 +- .../Components/NavigationStylesheetsTest.php | 143 - tests/Feature/Components/OverlayTest.php | 19 +- tests/Feature/Components/PaneTest.php | 19 +- tests/Feature/Components/ProgressTest.php | 23 +- tests/Feature/Components/RowTest.php | 24 +- tests/Feature/Components/ScaffoldTest.php | 18 +- tests/Feature/Components/SchemePickerTest.php | 3 +- tests/Feature/Components/SearchTest.php | 3 +- tests/Feature/Components/SelectionTest.php | 7 +- tests/Feature/Components/SliderTest.php | 43 +- tests/Feature/Components/SplitButtonTest.php | 5 +- tests/Feature/Components/StackTest.php | 25 +- .../Feature/Components/SupportingPaneTest.php | 18 +- tests/Feature/Components/SurfaceTest.php | 6 +- tests/Feature/Components/TabsTest.php | 16 +- tests/Feature/DesignGuardTest.php | 933 +- tests/Feature/ErrorPagesTest.php | 85 +- tests/Feature/FontTest.php | 24 +- tests/Feature/ServiceProviderTest.php | 6 - tests/Feature/ShowcaseAssetsTest.php | 30 +- tests/Feature/StylesheetsBundleTest.php | 308 +- tests/Feature/StylesheetsTest.php | 243 +- tests/Feature/TokensTest.php | 3 +- .../tailwind/custom-property.blade.php | 1 + .../design-guard/tailwind/inks.blade.php | 1 - .../design-guard/tailwind/one-x.blade.php | 3 - tests/Pest.php | 47 +- tests/Support/ComponentStylesheet.php | 54 +- 233 files changed, 16635 insertions(+), 10579 deletions(-) create mode 100644 bin/stylesheets.mjs delete mode 100644 docs/audits/m3-alignment/actions.md delete mode 100644 docs/audits/m3-alignment/containment.md delete mode 100644 docs/audits/m3-alignment/core.md delete mode 100644 docs/audits/m3-alignment/inputs.md delete mode 100644 docs/audits/m3-alignment/navigation.md create mode 100644 resources/css/components/color.css delete mode 100644 resources/css/foundation/tokens.css create mode 100644 resources/dist/error-page-fallback.css create mode 100644 resources/dist/error-page.css create mode 100644 resources/dist/showcase.css create mode 100644 resources/js/shapes.js create mode 100644 resources/js/util.js create mode 100644 resources/views/partials/menu-filter.blade.php create mode 100644 resources/views/partials/timepicker-period.blade.php create mode 100644 resources/views/showcase/head.blade.php create mode 100644 src/Support/Field.php create mode 100644 src/Support/Link.php delete mode 100644 tests/Feature/Components/ActionStylesheetsTest.php create mode 100644 tests/Feature/Components/ColorTest.php create mode 100644 tests/Feature/Components/ComponentStylesheetsTest.php delete mode 100644 tests/Feature/Components/ContainmentStylesheetsTest.php delete mode 100644 tests/Feature/Components/InputStylesheetsTest.php create mode 100644 tests/Feature/Components/LayoutComponentsTest.php delete mode 100644 tests/Feature/Components/NavigationStylesheetsTest.php create mode 100644 tests/Fixtures/design-guard/tailwind/custom-property.blade.php delete mode 100644 tests/Fixtures/design-guard/tailwind/one-x.blade.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e976858..d4f59d2b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,28 +11,6 @@ permissions: contents: read jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: '8.5' - tools: composer:v2 - coverage: none - - - name: Install dependencies - run: composer install --no-interaction --no-progress --prefer-dist - - - name: Check formatting - run: vendor/bin/pint --test - feature: runs-on: ubuntu-latest @@ -54,14 +32,21 @@ jobs: tools: composer:v2 coverage: none - # material:scheme's tests run the bundled colour script through Node. + # material:scheme's tests run the bundled colour script through Node, and the prebuilt + # stylesheets' staleness test rebuilds them with the installed Vite. - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: '22' - name: Install dependencies - run: composer install --no-interaction --no-progress --prefer-dist + run: | + composer install --no-interaction --no-progress --prefer-dist + npm ci + + - name: Check formatting + if: matrix.php == '8.5' + run: vendor/bin/pint --test - name: Run feature tests run: vendor/bin/pest --testsuite=Feature diff --git a/README.md b/README.md index c4f99351..a4dfbf91 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ it('uses only what compiles', function () { }); ``` -The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): a layout component and prop (`flex gap-4` → ``), an `md-*` class (`truncate` → `md-truncate`), or a token for the application's own CSS (`rounded-lg` → `var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read as Tailwind, and a mail theme stylesheet there is neither checked for literals nor a source of exempt classes. +The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — each with its replacement: a layout component and prop (`flex gap-4` → ``), an `md-*` class (`truncate` → `md-truncate`), or a token for the application's own CSS (`rounded-lg` → `var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read as Tailwind, and a mail theme stylesheet there is neither checked for literals nor a source of exempt classes. `missingStylesheets($cssEntry)` checks the entry's relative `@import` graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or ``), `->links()` and the rows they write by hand (`data-md-list-row` on anything but `` needs `components/list-item.css`), and names each missing `@import` line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave `resource_path('css')` out of `scan()` while the stylesheets still hold literals. `forbidColours([...])` names roles the application leaves out, and fails wherever one (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds any pattern of your own, line by line. diff --git a/UPGRADE.md b/UPGRADE.md index e1b2e627..31b568f8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -2,6 +2,18 @@ ## From 2.1.0 to 2.1.1 +- **Run `php artisan view:clear` after upgrading.** The components are no longer registered under a + view namespace named after a hash of their folder, which only views compiled before 1.0.1 still + named; such a view shows the tag as text until it is compiled again. +- **`DesignGuard`** no longer reports the 1.x package's own names (`text-meta`, `focus-ring`, + `rounded-corner-lg`, `shadow-elevation-2`, `type-body-md`, `ease-spatial-fast`) with their 2.0.0 + replacement. A colour utility on a 1.x ink is still reported, as a colour of the application's own + theme; the rest compile to nothing and pass unnoticed. +- **``** redraws when a script sets its input's `value`, as before, but no longer when it + sets `valueAsNumber`; write `value` instead. +- **The showcase's stylesheet** is served from `assets/css/showcase.{hash}.css`, prebuilt in + `resources/dist/`, and the error page inlines prebuilt bundles too. `Stylesheets::bundle()` and + `Stylesheets::resetCache()` are gone; nothing outside the package called them. - **`$store.rail.toggle()`** flips what the first rail on the page draws, as its menu button does. With nothing stored, ``'s rail is drawn collapsed from 840 to 1199px whatever `rail.default` says, and `toggle()` collapsed it again, so the first press of an application's @@ -124,9 +136,9 @@ ## From 1.x to 2.0.0 -2.0.0 aligns the library with Material Design 3 Expressive as Google documents it -(m3.material.io, checked page by page; the audits are in `docs/audits/m3-alignment/`). Most of -the change is inside the components. What reaches an application is below, in the order to do it. +2.0.0 aligns the library with Material Design 3 Expressive as Google documents it (m3.material.io, +checked page by page). Most of the change is inside the components. What reaches an application is +below, in the order to do it. ### 1. Breakpoints are M3's window size classes @@ -166,7 +178,7 @@ rest is a token in the application's own CSS: | `state-layer`, `focus-ring`, `touch-target`, `link` (1.x) | `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link` | The spacing tokens are the 4px grid Tailwind's scale was (`space200` is 16px). `DesignGuard` names -each one with its replacement. +each of Tailwind's own utilities with its replacement; the 1.x names are no longer reported. ### 3. Regenerate the colour scheme diff --git a/bin/check-font.mjs b/bin/check-font.mjs index 84b06005..2a82cf6c 100644 --- a/bin/check-font.mjs +++ b/bin/check-font.mjs @@ -1,28 +1,18 @@ /** - * Checks that the packaged Google Sans Flex subset still carries the variable axes the CSS uses. + * Prints the variable axes of the packaged Google Sans Flex subset. * - * Run from the repository root with `npm run check:font` (or `node bin/check-font.mjs [woff2]`). - * tests/Feature/FontTest.php runs it through the configured `node` binary as well, because PHP - * cannot open a woff2 without the Brotli extension. - * - * wght 400–700 — every typescale weight (font.css `font-weight: 400 700`, type.css's regular, - * medium and bold reference tokens). - * ROND 0–100 — the roundness axis every `type-emphasized-*` utility sets to 100. Re-subset - * the font without it and the emphasized styles quietly stop being round. + * Run from the repository root with `node bin/check-font.mjs [woff2]`. + * tests/Feature/FontTest.php runs it through the configured `node` binary and asserts the ranges + * the CSS needs (wght, ROND — see that test for why), because PHP cannot open a woff2 without the + * Brotli extension. * * Output is one JSON object on stdout — {file, postscriptName, numGlyphs, axes: {tag: {name, min, - * default, max}}} — and the exit status is 1, with the reason on stderr, when an axis is missing - * or narrower than the range above. + * default, max}}}. The exit status is 1, with the reason on stderr, only when the file itself + * can't be opened. */ import { openSync } from 'fontkit' import { fileURLToPath } from 'node:url' -/** The axes the stylesheets depend on, and the range each one has to cover. */ -const REQUIRED = { - wght: { min: 400, max: 700 }, - ROND: { min: 0, max: 100 }, -} - const file = process.argv[2] ?? fileURLToPath(new URL('../resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2', import.meta.url)) let font @@ -34,27 +24,9 @@ try { process.exit(1) } -const axes = font.variationAxes ?? {} -const problems = Object.entries(REQUIRED).flatMap(([tag, range]) => { - const axis = axes[tag] - - if (!axis) { - return [`${tag} is missing; the subset has ${Object.keys(axes).join(', ') || 'no variable axes'}.`] - } - - return axis.min > range.min || axis.max < range.max - ? [`${tag} covers ${axis.min}–${axis.max}, not the ${range.min}–${range.max} the stylesheets ask for.`] - : [] -}) - process.stdout.write(`${JSON.stringify({ file, postscriptName: font.postscriptName, numGlyphs: font.numGlyphs, - axes, + axes: font.variationAxes ?? {}, })}\n`) - -if (problems.length > 0) { - process.stderr.write(`${file}\n${problems.map((problem) => ` ${problem}`).join('\n')}\n`) - process.exit(1) -} diff --git a/bin/loading-indicator.mjs b/bin/loading-indicator.mjs index 0f4b6085..f38fbcd6 100644 --- a/bin/loading-indicator.mjs +++ b/bin/loading-indicator.mjs @@ -42,12 +42,11 @@ * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/LoadingIndicatorTokens.kt * compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/SpringSimulation.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FeatureMapping.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FloatMapping.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Morph.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/PolygonMeasure.kt * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt (calculateMaxBounds) * + * The feature matching and Morph itself (FeatureMapping.kt, FloatMapping.kt, Morph.kt, + * PolygonMeasure.kt) are shared with progress.js: see resources/js/shapes.js. + * * Copyright 2022-2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -65,13 +64,11 @@ */ import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { cubicBounds, point, pointOnCurve, split, SHAPES } from './shapes.mjs' +import { SHAPES } from './shapes.mjs' +import { asCubics, cubicBounds, match, pointOnCurve } from '../resources/js/shapes.js' const OUTPUT = 'resources/svg/loading-indicator' -const DISTANCE_EPSILON = 1e-4 -const ANGLE_EPSILON = 1e-6 - // LoadingIndicator.kt / LoadingIndicatorTokens.kt -------------------------------------- const SEQUENCE = ['soft-burst', 'cookie-9', 'pentagon', 'pill', 'sunny', 'cookie-4', 'oval'] @@ -94,339 +91,6 @@ const HANDOVER_LEAD = 0.001 /** The fastest collapse a keySpline can give: half the size within a frame, then a long tail. */ const COLLAPSE = [0, 1, 0, 1] -// Utils.kt / FloatMapping.kt ------------------------------------------------------------ - -const positiveModulo = (num, mod) => ((num % mod) + mod) % mod -const progressInRange = (progress, from, to) => - to >= from ? progress >= from && progress <= to : progress >= from || progress <= to - -function progressDistance(a, b) { - const d = Math.abs(a - b) - - return Math.min(d, 1 - d) -} - -function linearMap(xValues, yValues, x) { - const n = xValues.length - const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n])) - const end = (start + 1) % n - const sizeX = positiveModulo(xValues[end] - xValues[start], 1) - const sizeY = positiveModulo(yValues[end] - yValues[start], 1) - const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX - - return positiveModulo(yValues[start] + sizeY * position, 1) -} - -/** DoubleMapper: maps outline progress on one shape to the other and back, from [source, target] pairs. */ -function doubleMapper(mappings) { - const sources = mappings.map((m) => m[0]) - const targets = mappings.map((m) => m[1]) - - return { map: (x) => linearMap(sources, targets, x), mapBack: (x) => linearMap(targets, sources, x) } -} - -// PolygonMeasure.kt --------------------------------------------------------------------- - -const MEASURE_SEGMENTS = 3 - -/** LengthMeasurer.closestProgressTo: [the parameter at which `threshold` length is reached, the length]. */ -function closestProgressTo(c, threshold) { - let total = 0 - let remainder = threshold - let previous = point(c[0], c[1]) - - for (let i = 1; i <= MEASURE_SEGMENTS; i++) { - const progress = i / MEASURE_SEGMENTS - const p = pointOnCurve(c, progress) - const segment = Math.hypot(p.x - previous.x, p.y - previous.y) - - if (segment >= remainder) { - return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold] - } - - remainder -= segment - total += segment - previous = p - } - - return [1, total] -} - -const measureCubic = (c) => closestProgressTo(c, Infinity)[1] -const findCubicCutPoint = (c, measure) => closestProgressTo(c, measure)[0] - -class MeasuredCubic { - constructor(cubic, startOutlineProgress, endOutlineProgress) { - if (endOutlineProgress < startOutlineProgress) { - throw new Error('endOutlineProgress is expected to be equal or greater than startOutlineProgress') - } - - this.cubic = cubic - this.startOutlineProgress = startOutlineProgress - this.endOutlineProgress = endOutlineProgress - this.measuredSize = measureCubic(cubic) - } - - cutAtProgress(cutOutlineProgress) { - const bounded = Math.min(Math.max(cutOutlineProgress, this.startOutlineProgress), this.endOutlineProgress) - const relativeProgress = - (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress) - const t = findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize) - const [c1, c2] = split(this.cubic, t) - - return [ - new MeasuredCubic(c1, this.startOutlineProgress, bounded), - new MeasuredCubic(c2, bounded, this.endOutlineProgress), - ] - } -} - -class MeasuredPolygon { - constructor(features, cubics, outlineProgress) { - this.features = features - this.cubics = [] - - let startOutlineProgress = 0 - - for (let i = 0; i < cubics.length; i++) { - if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) { - this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1])) - startOutlineProgress = outlineProgress[i + 1] - } - } - - this.cubics.at(-1).endOutlineProgress = 1 - } - - static measure(polygon) { - const cubics = [] - const featureToCubic = [] - - for (const feature of polygon.features) { - feature.cubics.forEach((cubic, i) => { - if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) { - featureToCubic.push([feature, cubics.length]) - } - - cubics.push(cubic) - }) - } - - const measures = [0] - - for (const cubic of cubics) { - measures.push(measures.at(-1) + measureCubic(cubic)) - } - - const outlineProgress = measures.map((measure) => measure / measures.at(-1)) - const features = featureToCubic.map(([feature, ix]) => ({ - progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1), - feature, - })) - - return new MeasuredPolygon(features, cubics, outlineProgress) - } - - cutAndShift(cuttingPoint) { - if (cuttingPoint < DISTANCE_EPSILON) { - return this - } - - const n = this.cubics.length - const targetIndex = this.cubics.findIndex( - (c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress, - ) - const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint) - const cubics = [b2.cubic] - - for (let i = 1; i < n; i++) { - cubics.push(this.cubics[(i + targetIndex) % n].cubic) - } - - cubics.push(b1.cubic) - - const outlineProgress = Array.from({ length: n + 2 }, (_, index) => { - if (index === 0) { - return 0 - } - - if (index === n + 1) { - return 1 - } - - return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1) - }) - - const features = this.features.map(({ progress, feature }) => ({ - progress: positiveModulo(progress - cuttingPoint, 1), - feature, - })) - - return new MeasuredPolygon(features, cubics, outlineProgress) - } -} - -// FeatureMapping.kt --------------------------------------------------------------------- - -function featureRepresentativePoint(feature) { - const first = feature.cubics[0] - const last = feature.cubics.at(-1) - - return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2) -} - -function featureDistSquared(f1, f2) { - if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) { - return Infinity - } - - const p1 = featureRepresentativePoint(f1) - const p2 = featureRepresentativePoint(f2) - - return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2 -} - -function doMapping(features1, features2) { - const distanceVertexList = [] - - for (const f1 of features1) { - for (const f2 of features2) { - const distance = featureDistSquared(f1.feature, f2.feature) - - if (distance !== Infinity) { - distanceVertexList.push({ distance, f1, f2 }) - } - } - } - - // Array.prototype.sort is stable, like Kotlin's sortedBy. - distanceVertexList.sort((a, b) => a.distance - b.distance) - - if (distanceVertexList.length === 0) { - return [ - [0, 0], - [0.5, 0.5], - ] - } - - if (distanceVertexList.length === 1) { - const { f1, f2 } = distanceVertexList[0] - - return [ - [f1.progress, f2.progress], - [(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1], - ] - } - - const mapping = [] - const usedF1 = new Set() - const usedF2 = new Set() - - for (const { f1, f2 } of distanceVertexList) { - if (usedF1.has(f1) || usedF2.has(f2)) { - continue - } - - const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress) - const index = insertionIndex === -1 ? mapping.length : insertionIndex - - if (index < mapping.length && mapping[index][0] === f1.progress) { - throw new Error("There can't be two features with the same progress") - } - - const n = mapping.length - - if (n >= 1) { - const [before1, before2] = mapping[(index + n - 1) % n] - const [after1, after2] = mapping[index % n] - - if ( - progressDistance(f1.progress, before1) < DISTANCE_EPSILON || - progressDistance(f1.progress, after1) < DISTANCE_EPSILON || - progressDistance(f2.progress, before2) < DISTANCE_EPSILON || - progressDistance(f2.progress, after2) < DISTANCE_EPSILON - ) { - continue - } - - if (n > 1 && !progressInRange(f2.progress, before2, after2)) { - continue - } - } - - mapping.splice(index, 0, [f1.progress, f2.progress]) - usedF1.add(f1) - usedF2.add(f2) - } - - return mapping -} - -function featureMapper(features1, features2) { - const corners = (features) => features.filter(({ feature }) => feature.type === 'corner') - - return doubleMapper(doMapping(corners(features1), corners(features2))) -} - -// Morph.kt ------------------------------------------------------------------------------ - -/** Morph.match: the start and end shapes cut into pairs of matching cubics. */ -function match(p1, p2) { - const measuredPolygon1 = MeasuredPolygon.measure(p1) - const measuredPolygon2 = MeasuredPolygon.measure(p2) - const mapper = featureMapper(measuredPolygon1.features, measuredPolygon2.features) - const polygon2CutPoint = mapper.map(0) - const bs1 = measuredPolygon1.cubics - const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics - const pairs = [] - - let i1 = 0 - let i2 = 0 - let b1 = bs1[i1++] - let b2 = bs2[i2++] - - while (b1 !== undefined && b2 !== undefined) { - const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress - const b2a = - i2 === bs2.length ? 1 : mapper.mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1)) - const minb = Math.min(b1a, b2a) - let seg1 - let seg2 - - if (b1a > minb + ANGLE_EPSILON) { - ;[seg1, b1] = b1.cutAtProgress(minb) - } else { - seg1 = b1 - b1 = bs1[i1++] - } - - if (b2a > minb + ANGLE_EPSILON) { - ;[seg2, b2] = b2.cutAtProgress(positiveModulo(mapper.map(minb) - polygon2CutPoint, 1)) - } else { - seg2 = b2 - b2 = bs2[i2++] - } - - pairs.push([seg1.cubic, seg2.cubic]) - } - - if (b1 !== undefined || b2 !== undefined) { - throw new Error("Expected both Polygon's Cubic to be fully matched") - } - - return pairs -} - -/** Morph.asCubics: every matched pair interpolated at `progress`, closed exactly on its first anchor. */ -function asCubics(pairs, progress) { - const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress)) - - cubics.at(-1)[6] = cubics[0][0] - cubics.at(-1)[7] = cubics[0][1] - - return cubics -} - // LoadingIndicator.kt: calculateScaleFactor, processPath -------------------------------- /** RoundedPolygon.calculateMaxBounds: a square holding the shape in any rotation. */ diff --git a/bin/scheme.mjs b/bin/scheme.mjs index 745f3ef1..fd720f74 100644 --- a/bin/scheme.mjs +++ b/bin/scheme.mjs @@ -91,7 +91,7 @@ const contrast = Number(input.contrast ?? 0) const harmonize = Boolean(input.harmonize ?? false) if (!(contrast >= -1 && contrast < LEVELS.medium)) { - fail(`The standard contrast level runs from -1 to below ${LEVELS.medium}, "${input.contrast}" given; medium and high are generated as their own blocks.`) + fail(`The contrast level ${input.contrast} is the standard block's, from -1 to below ${LEVELS.medium}. Medium (${LEVELS.medium}) and high (${LEVELS.high}) are always generated beside it, under [data-contrast]; the head script picks one.`) } const source = Hct.fromInt(argbFromHex(input.seed)) diff --git a/bin/shapes.mjs b/bin/shapes.mjs index 3ebcc7aa..849d7ab8 100644 --- a/bin/shapes.mjs +++ b/bin/shapes.mjs @@ -20,12 +20,9 @@ * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MaterialShapes.kt * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt * compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Matrix.kt (rotateZ, scale) - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/CornerRounding.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Cubic.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Point.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Shapes.kt - * graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Utils.kt + * + * The RoundedPolygon construction itself (CornerRounding, Cubic, Point, RoundedPolygon, Shapes, + * Utils) is shared with bin/loading-indicator.mjs and progress.js: see resources/js/shapes.js. * * Copyright 2022-2024 The Android Open Source Project * @@ -45,480 +42,14 @@ import { mkdirSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { circlePolygon, length, minus, plus, point, polygonFromVertices, regularPolygon, rounding, split, star, times, turningPoints, UNROUNDED } from '../resources/js/shapes.js' const OUTPUT = 'resources/svg/shapes' const VIEWBOX = 100 const FILL = 96 -const DISTANCE_EPSILON = 1e-4 - -// Point.kt / Utils.kt ------------------------------------------------------------------ - -const point = (x, y) => ({ x, y }) -const plus = (a, b) => point(a.x + b.x, a.y + b.y) -const minus = (a, b) => point(a.x - b.x, a.y - b.y) -const times = (a, k) => point(a.x * k, a.y * k) -const div = (a, k) => point(a.x / k, a.y / k) -const dot = (a, b) => a.x * b.x + a.y * b.y -const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y) -const rotate90 = (a) => point(-a.y, a.x) -const clockwise = (a, b) => a.x * b.y - a.y * b.x > 0 -const lerp = (a, b, f) => (1 - f) * a + f * b -const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f)) - -function direction(a) { - const d = length(a) - - if (!(d > 0)) { - throw new Error("Can't get the direction of a 0-length vector") - } - - return div(a, d) -} - -const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius) -const convex = (previous, current, next) => clockwise(minus(current, previous), minus(next, current)) - -// Cubic.kt ------------------------------------------------------------------------------ - -/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */ -const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y] - -function straightLine(x0, y0, x1, y1) { - return [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1] -} - -function circularArc(centerX, centerY, x0, y0, x1, y1) { - const p0d = direction(point(x0 - centerX, y0 - centerY)) - const p1d = direction(point(x1 - centerX, y1 - centerY)) - const rotatedP0 = rotate90(p0d) - const rotatedP1 = rotate90(p1d) - const isClockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0 - const cosa = dot(p0d, p1d) - - if (cosa > 0.999) { - return straightLine(x0, y0, x1, y1) - } - - const k = - (((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) * - (Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) / - (1 - cosa) * - (isClockwise ? 1 : -1) - - return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1] -} - -function pointOnCurve(c, t) { - const u = 1 - t - - return point( - c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t), - c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t), - ) -} - -function split(c, t) { - const u = 1 - t - const p = pointOnCurve(c, t) - - return [ - [ - c[0], - c[1], - c[0] * u + c[2] * t, - c[1] * u + c[3] * t, - c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t), - c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t), - p.x, - p.y, - ], - [ - p.x, - p.y, - c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t), - c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t), - c[4] * u + c[6] * t, - c[5] * u + c[7] * t, - c[6], - c[7], - ], - ] -} - -const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]] -const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON - -/** The parameters in (0, 1) where one axis of a cubic turns: the roots of its derivative. */ -function turningPoints(c, axis) { - const [p0, p1, p2, p3] = [c[axis], c[axis + 2], c[axis + 4], c[axis + 6]] - const a = -p0 + 3 * p1 - 3 * p2 + p3 - const b = 2 * (p0 - 2 * p1 + p2) - const k = p1 - p0 - const roots = [] - - if (Math.abs(a) < 1e-9) { - if (Math.abs(b) > 1e-9) { - roots.push(-k / b) - } - } else if (b * b - 4 * a * k >= 0) { - const root = Math.sqrt(b * b - 4 * a * k) - roots.push((-b + root) / (2 * a), (-b - root) / (2 * a)) - } - - return roots.filter((t) => t > 1e-6 && t < 1 - 1e-6) -} - -/** Axis-aligned bounds of one cubic: of all four points when approximate, else of the curve itself. */ -function cubicBounds(c, approximate) { - const xs = [c[0], c[6]] - const ys = [c[1], c[7]] - - if (approximate) { - xs.push(c[2], c[4]) - ys.push(c[3], c[5]) - } else { - turningPoints(c, 0).forEach((t) => xs.push(pointOnCurve(c, t).x)) - turningPoints(c, 1).forEach((t) => ys.push(pointOnCurve(c, t).y)) - } - - return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] -} - -// CornerRounding.kt --------------------------------------------------------------------- - -const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing }) -const UNROUNDED = rounding() - -// RoundedPolygon.kt --------------------------------------------------------------------- - -class RoundedCorner { - constructor(p0, p1, p2, cornerRounding) { - this.p0 = p0 - this.p1 = p1 - this.p2 = p2 - - const v01 = minus(p0, p1) - const v21 = minus(p2, p1) - const d01 = length(v01) - const d21 = length(v21) - - if (d01 > 0 && d21 > 0) { - this.d1 = div(v01, d01) - this.d2 = div(v21, d21) - this.cornerRadius = cornerRounding?.radius ?? 0 - this.smoothing = cornerRounding?.smoothing ?? 0 - this.cosAngle = dot(this.d1, this.d2) - this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle) - this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0 - } else { - this.d1 = point(0, 0) - this.d2 = point(0, 0) - this.cornerRadius = 0 - this.smoothing = 0 - this.cosAngle = 0 - this.sinAngle = 0 - this.expectedRoundCut = 0 - } - } - - get expectedCut() { - return (1 + this.smoothing) * this.expectedRoundCut - } - - getCubics(allowedCut0, allowedCut1 = allowedCut0) { - const allowedCut = Math.min(allowedCut0, allowedCut1) - - if ( - this.expectedRoundCut < DISTANCE_EPSILON || - allowedCut < DISTANCE_EPSILON || - this.cornerRadius < DISTANCE_EPSILON - ) { - return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)] - } - - const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut) - const actualSmoothing0 = this.actualSmoothing(allowedCut0) - const actualSmoothing1 = this.actualSmoothing(allowedCut1) - const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut - const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut) - const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance)) - const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut)) - const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut)) - const flanking0 = this.flankingCurve( - actualRoundCut, - actualSmoothing0, - this.p1, - this.p0, - circleIntersection0, - circleIntersection2, - center, - actualR, - ) - const flanking2 = reverse( - this.flankingCurve( - actualRoundCut, - actualSmoothing1, - this.p1, - this.p2, - circleIntersection2, - circleIntersection0, - center, - actualR, - ), - ) - - return [ - flanking0, - circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]), - flanking2, - ] - } - - actualSmoothing(allowedCut) { - if (allowedCut > this.expectedCut) { - return this.smoothing - } - - if (allowedCut > this.expectedRoundCut) { - return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut) - } - - return 0 - } - - flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) { - const sideDirection = direction(minus(sideStart, corner)) - const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing))) - const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing) - const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR)) - const circleTangent = rotate90(minus(curveEnd, circleCenter)) - const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection - const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3) - - return cubic(curveStart, anchorStart, anchorEnd, curveEnd) - } -} - -function lineIntersection(p0, d0, p1, d1) { - const rotatedD1 = rotate90(d1) - const den = dot(d0, rotatedD1) - - if (Math.abs(den) < DISTANCE_EPSILON) { - return null - } - - const num = dot(minus(p1, p0), rotatedD1) - - if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) { - return null - } - - return plus(p0, times(d0, num / den)) -} - -/** - * A polygon as androidx keeps it: its features (corners and the edges between them, each a - * list of cubics) and a centre. `cubics` flattens the features exactly as RoundedPolygon does. - */ -class RoundedPolygon { - constructor(features, center) { - this.features = features - this.center = center - this.cubics = flatten(features, center) - } - - transformed(f) { - const move = (c) => { - const out = [] - - for (let i = 0; i < 8; i += 2) { - const p = f(c[i], c[i + 1]) - out.push(p.x, p.y) - } - - return out - } - - return new RoundedPolygon( - this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })), - f(this.center.x, this.center.y), - ) - } - - bounds(approximate) { - const all = this.cubics.map((c) => (zeroLength(c) ? [c[0], c[1], c[0], c[1]] : cubicBounds(c, approximate))) - - return [ - Math.min(...all.map((b) => b[0])), - Math.min(...all.map((b) => b[1])), - Math.max(...all.map((b) => b[2])), - Math.max(...all.map((b) => b[3])), - ] - } - - normalized() { - const [left, top, right, bottom] = this.bounds(true) - const width = right - left - const height = bottom - top - const side = Math.max(width, height) - const offsetX = (side - width) / 2 - left - const offsetY = (side - height) / 2 - top - - return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side)) - } -} - -function flatten(features, center) { - const out = [] - let firstCubic = null - let lastCubic = null - let firstFeatureSplitStart = null - let firstFeatureSplitEnd = null - - if (features.length > 0 && features[0].cubics.length === 3) { - const [start, end] = split(features[0].cubics[1], 0.5) - firstFeatureSplitStart = [features[0].cubics[0], start] - firstFeatureSplitEnd = [end, features[0].cubics[2]] - } - - for (let i = 0; i <= features.length; i++) { - let featureCubics - - if (i === 0 && firstFeatureSplitEnd !== null) { - featureCubics = firstFeatureSplitEnd - } else if (i === features.length) { - if (firstFeatureSplitStart === null) { - break - } - - featureCubics = firstFeatureSplitStart - } else { - featureCubics = features[i].cubics - } - - for (const c of featureCubics) { - if (!zeroLength(c)) { - if (lastCubic !== null) { - out.push(lastCubic) - } - - lastCubic = c - - if (firstCubic === null) { - firstCubic = c - } - } else if (lastCubic !== null) { - lastCubic = [...lastCubic] - lastCubic[6] = c[0] - lastCubic[7] = c[1] - } - } - } - - if (lastCubic !== null && firstCubic !== null) { - out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]]) - } else { - out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y]) - } - - for (let i = 0; i < out.length; i++) { - const previous = out[(i + out.length - 1) % out.length] - - if ( - Math.abs(out[i][0] - previous[6]) > DISTANCE_EPSILON || - Math.abs(out[i][1] - previous[7]) > DISTANCE_EPSILON - ) { - throw new Error('RoundedPolygon must be contiguous') - } - } - - return out -} - -/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY) */ -function polygonFromVertices(vertices, { cornerRounding = UNROUNDED, perVertexRounding = null, center = null } = {}) { - const n = vertices.length - - if (n < 3) { - throw new Error('Polygons must have at least 3 vertices') - } - - if (perVertexRounding !== null && perVertexRounding.length !== n) { - throw new Error('perVertexRounding list should be either null or the same size as the number of vertices') - } - - const roundedCorners = vertices.map( - (vertex, i) => - new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding?.[i] ?? cornerRounding), - ) - - const cutAdjusts = vertices.map((vertex, i) => { - const next = (i + 1) % n - const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut - const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut - const sideSize = length(minus(vertex, vertices[next])) - - if (expectedRoundCut > sideSize) { - return [sideSize / expectedRoundCut, 0] - } - - if (expectedCut > sideSize) { - return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)] - } - - return [1, 1] - }) - - const corners = roundedCorners.map((corner, i) => { - const allowedCuts = [0, 1].map((delta) => { - const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n] - - return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio - }) - - return corner.getCubics(allowedCuts[0], allowedCuts[1]) - }) - - const features = [] - - for (let i = 0; i < n; i++) { - const previous = vertices[(i + n - 1) % n] - const next = vertices[(i + 1) % n] - const end = corners[i].at(-1) - const start = corners[(i + 1) % n][0] - - features.push({ type: 'corner', convex: convex(previous, vertices[i], next), cubics: corners[i] }) - features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] }) - } - - if (center === null) { - center = point( - vertices.reduce((sum, v) => sum + v.x, 0) / n, - vertices.reduce((sum, v) => sum + v.y, 0) / n, - ) - } - - return new RoundedPolygon(features, center) -} - // Shapes.kt ----------------------------------------------------------------------------- -/** RoundedPolygon(numVertices, radius, centerX, centerY, rounding, perVertexRounding) */ -function regularPolygon(numVertices, { radius = 1, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) { - const vertices = Array.from({ length: numVertices }, (_, i) => - radialToCartesian(radius, (Math.PI / numVertices) * 2 * i), - ) - - return polygonFromVertices(vertices, { cornerRounding, perVertexRounding, center: point(0, 0) }) -} - -function circlePolygon(numVertices = 8, radius = 1) { - const polygonRadius = radius / Math.cos(Math.PI / numVertices) - - return regularPolygon(numVertices, { radius: polygonRadius, cornerRounding: rounding(radius) }) -} - function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) { const [left, top, right, bottom] = [-width / 2, -height / 2, width / 2, height / 2] @@ -529,17 +60,6 @@ function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVerte }) } -function star(numVerticesPerRadius, { radius = 1, innerRadius = 0.5, cornerRounding = UNROUNDED } = {}) { - const vertices = [] - - for (let i = 0; i < numVerticesPerRadius; i++) { - vertices.push(radialToCartesian(radius, (Math.PI / numVerticesPerRadius) * 2 * i)) - vertices.push(radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius) * (2 * i + 1))) - } - - return polygonFromVertices(vertices, { cornerRounding, center: point(0, 0) }) -} - // Matrix.kt (rotateZ, scale) and ShapeUtil.kt (RoundedPolygon.transformed(Matrix)) -------- function rotateZ(degrees) { @@ -865,8 +385,8 @@ function pathData(polygon) { return `${d}Z` } -/** The geometry, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */ -export { point, pointOnCurve, split, cubicBounds, RoundedPolygon, SHAPES } +/** The shape catalog, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */ +export { SHAPES } if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { mkdirSync(OUTPUT, { recursive: true }) diff --git a/bin/stylesheets.mjs b/bin/stylesheets.mjs new file mode 100644 index 00000000..8095a04e --- /dev/null +++ b/bin/stylesheets.mjs @@ -0,0 +1,58 @@ +/** + * Builds resources/dist/, the stylesheets the package serves on its own, outside an application's + * Vite build: the showcase's (`ShowcaseAssetController`) and the error page's + * (`ErrorPage::layoutStyles()`, `ErrorPage::fallbackStyles()`). + * + * Rebuild with `npm run build:stylesheets` after changing anything under resources/css; the + * output is committed so applications need no Node, and tests/Feature/StylesheetsBundleTest.php + * fails while it is stale. `node bin/stylesheets.mjs ` writes the same files elsewhere. + * + * The `@import`s are inlined by Vite's own `preprocessCSS()` — postcss-import, the same inlining an + * application's build does, each file once, first occurrence kept — so these bundles cannot + * order a rule differently from an application's. A relative `url()` is rebased onto + * resources/dist/, the folder the showcase serves beside the fonts and SVGs. The fallback drops + * every `@font-face`: without a build nothing serves the font, and the brand typeface's stack + * (tokens/type.css) falls back to the system fonts on its own. + */ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { preprocessCSS, resolveConfig } from 'vite' + +const css = resolve(import.meta.dirname, '../resources/css') +const dist = resolve(import.meta.dirname, '../resources/dist') +const out = resolve(process.argv[2] ?? dist) + +const BUNDLES = { + 'showcase.css': ['all.css', 'showcase.css'], + 'error-page.css': ['components/error-page.css'], + 'error-page-fallback.css': ['foundation.css', 'components/error-page.css'], +} + +const rebase = { + postcssPlugin: 'livewire-material', + OnceExit(root, { result }) { + // The prose lives in resources/css; the bundle only has to be served. + root.walkComments((comment) => comment.remove()) + + root.walkDecls((decl) => { + decl.value = decl.value.replace(/url\((['"]?)(?![a-z][a-z0-9+.-]*:|\/|#)([^'")]+)\1\)/gi, (_, quote, url) => + `url(${quote}${relative(dist, resolve(dirname(decl.source.input.file), url))}${quote})`) + }) + + if (result.opts.from.endsWith('error-page-fallback.css')) { + root.walkAtRules('font-face', (rule) => rule.remove()) + } + }, +} + +const config = await resolveConfig({ configFile: false, logLevel: 'error', css: { postcss: { plugins: [rebase] } } }, 'build') + +await mkdir(out, { recursive: true }) + +for (const [name, files] of Object.entries(BUNDLES)) { + const entry = files.map((file) => `@import './${file}';`).join('\n') + // The entry is named after the bundle, beside the files it imports, but is never read from disk. + const { code } = await preprocessCSS(entry, `${css}/bundle-${name}`, config) + + await writeFile(`${out}/${name}`, `${code.trim()}\n`) +} diff --git a/composer.json b/composer.json index af701b21..c40a8f3e 100644 --- a/composer.json +++ b/composer.json @@ -29,8 +29,7 @@ "laravel/pint": "^1.32", "orchestra/testbench": "^11.2", "pestphp/pest": "^5.1", - "pestphp/pest-plugin-browser": "^5.0", - "pestphp/pest-plugin-laravel": "^5.0" + "pestphp/pest-plugin-browser": "^5.0" }, "autoload": { "psr-4": { @@ -62,9 +61,7 @@ "Composer\\Config::disableProcessTimeout", "@build", "@php vendor/bin/testbench serve --ansi" - ], - "lint": "pint", - "test": "pest" + ] }, "config": { "sort-packages": true, diff --git a/docs/audits/m3-alignment/actions.md b/docs/audits/m3-alignment/actions.md deleted file mode 100644 index 228b08c4..00000000 --- a/docs/audits/m3-alignment/actions.md +++ /dev/null @@ -1,811 +0,0 @@ -# Audit: actions and communication - -Scope: ``, ``, ``, ``, ``, ``, -``, ``/``/``/``, ``, -``, ``, ``, ``, ``, ``, -``, ``, plus `resources/css/components/{groups,menu}.css`, -`resources/css/tokens/{state,motion,shape}.css`, `resources/js/{menu,snackbar,tooltip,rich-tooltip,progress,figure}.js` -and `src/Concerns/Toasts.php`. - -Sources are named per finding. `RC-A` = `docs/reference/m3/reference-components-a.md`, `RF` = -`reference-foundations.md`, `RS` = `reference-styles.md`, `tokens/X.kt` = the androidx Compose -Material 3 token file in `docs/reference/m3/tokens/`. Two numbers were fetched live from androidx-main -during this audit (`Button.kt`, `ButtonGroup.kt`) and are marked as such. - -## Summary - -The button family is the strongest part of this library: all five Expressive sizes, the icon-button -width matrix, the corner/press-morph scale, the outline widths, the per-variant toggle colours and -the per-size type roles match the androidx tokens *exactly*, digit for digit — I could not find a -single wrong number in ``'s own size tables, and the icon-button widths (narrow/default/wide -× five sizes, fifteen values) are all correct. The problems are concentrated elsewhere: the split -button reuses the connected button group's corner variables, so its inner corners are wrong at four of -five sizes and morph in the *opposite* direction to M3 on press; the snackbar's live region is created -together with its message (so the announcement is unreliable) and auto-dismisses even when it carries -an action, which M3's accessibility page forbids outright; a spinner inside a filled button is drawn -in `text-primary` on a `bg-primary` container, i.e. invisible; ``'s segments carry no 48 px -touch target; and `` has no maximum height, so a long menu runs off the viewport with no way -to scroll it. Colour is animated on the overshooting *spatial* spring in four components, which the -library's own `motion.css` comment says must never happen. Nothing in this group implements a -deprecated M3 component — segmented buttons and the small FAB are correctly absent — and the two -non-M3 pieces (alert, stat, empty state) stay inside M3's colour roles, type scale and shape scale. - -Counts: 5 must-fix, 17 should-fix, 14 nice-to-have (36 findings). - -## Findings - -### ACT-01 · button / loading · A button's spinner is invisible on a filled or tonal button -- Severity: must-fix -- M3 says: "when embedded in another component (e.g. a button), the active indicator should match the - label/icon color and the **track should be removed** in that context" (RC-A, Progress indicators → - Accessibility; same rule repeated for the loading indicator: "when embedded in another component - (e.g. a button), the indicator needs ≥3:1 against that component"). -- Library does: `resources/views/components/button.blade.php:207` renders - `` — only a size class. `loading.blade.php:22` - detects a caller colour with `preg_match('/(^|\s)text-(?!…)/')`, which `size-5` does not match, so - `loading.blade.php:31` adds `text-primary`. The SVG is `fill="currentColor"` - (`resources/svg/loading-indicator/*.svg`), so a `spinner` on `` - (`bg-primary`) paints primary on primary — contrast 1:1. Same on `tonal` (primary on - secondary-container) and on `variant="filled" color="error"`. -- Fix: in `button.blade.php:207` pass `:class="$iconSize.' text-current'"` — `text-current` satisfies - `loading.blade.php`'s `$coloured` test, so no `text-primary` is added and the indicator inherits the - button's label colour. -- Effort: S -- Breaks API? no - -### ACT-02 · toast · The snackbar's live region is created together with its message -- Severity: must-fix -- M3 says: "Announcement: use a **live region with a 'polite'/queued** announcement (not assertive) on - Android and web" (RC-A, Snackbar → Accessibility). -- Library does: `resources/views/components/toast.blade.php:41` wraps the whole snackbar in - `