diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 92a1199..36cf12e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,3 +20,18 @@ commit. After each release, main and beta are merged back down into the branches below them automatically. --> + +## Release note + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7fee7f..57b2081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - run: vp run ready + - name: Test the release notes + run: node --test 'release/*.test.mjs' + - run: npx semantic-release --dry-run env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index b3ea235..130b787 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -1,4 +1,8 @@ -name: PR title check +name: PR title and release note + +# Checks the PR title against Conventional Commits, and the "## Release note" +# section of the description (release/check-release-note.mjs). Both report the +# required `check` status. on: pull_request: @@ -14,3 +18,16 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} pass_on_octokit_error: false configuration_path: .github/pr-title-checker.json + + - uses: actions/checkout@v6 + if: github.event_name == 'pull_request' + with: + sparse-checkout: release + + - name: Check the release note + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + HEAD_REF: ${{ github.head_ref }} + run: node release/check-release-note.mjs diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000..4625f5e --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,49 @@ +name: Release notes preview + +# On a promotion PR (alpha or beta into main or beta), keeps one comment with +# the version and release notes that adding the `promote` label would +# publish, made by the same code as the real ones. See release/preview.mjs. + +on: + pull_request: + branches: [main, beta] + types: [opened, edited, synchronize, reopened] + +permissions: {} + +jobs: + preview: + if: github.head_ref == 'alpha' || github.head_ref == 'beta' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Write the preview + env: + GITHUB_TOKEN: ${{ github.token }} + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BODY: ${{ github.event.pull_request.body }} + run: node release/preview.mjs >"$RUNNER_TEMP/preview.md" + + - name: Post or update the comment + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq '.[] | select(.body | startswith("")) | .id' | head -1) + if [[ -n $existing ]]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \ + -F body=@"$RUNNER_TEMP/preview.md" >/dev/null + else + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/preview.md" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4b0c38..5129fbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: push: branches: - main + - beta + - alpha workflow_dispatch: permissions: diff --git a/.github/workflows/sync-readme.yml b/.github/workflows/sync-readme.yml index 43bbef1..cdc64b6 100644 --- a/.github/workflows/sync-readme.yml +++ b/.github/workflows/sync-readme.yml @@ -2,7 +2,7 @@ name: Sync README code blocks on: pull_request: - branches: [main] + branches: [main, beta, alpha] workflow_dispatch: permissions: diff --git a/.github/workflows/sync-stylex.yml b/.github/workflows/sync-stylex.yml index 9a1068c..0bb410e 100644 --- a/.github/workflows/sync-stylex.yml +++ b/.github/workflows/sync-stylex.yml @@ -2,7 +2,7 @@ name: Sync generated stylex.ts on: pull_request: - branches: [main] + branches: [main, beta, alpha] paths: - package/src/stylex.template.ts - package/src/stylex.ts diff --git a/.gitignore b/.gitignore index 3def3a1..b336008 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ dist/ .pnpm-store/ .DS_Store .playwright-mcp -.superpowers/ \ No newline at end of file +.superpowers/.netlify/ diff --git a/README.md b/README.md index 40963d8..af00934 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ We're all excited about `corner-shape: squircle`, but we're in a pickle right no - [Requirements](#requirements) - [Install & setup](#install--setup) +- [Pill Shapes with Houdini CSS Paint Worklet](#pill-shapes-with-houdini-css-paint-worklet) - [How the radius correction works](#how-the-radius-correction-works) - [Browser support & fallback strategy](#browser-support--fallback-strategy) - [Why it called "squircle" when it use "superellipse()"?](#why-it-called-squircle-when-it-use-superellipse) @@ -21,6 +22,7 @@ We're all excited about `corner-shape: squircle`, but we're in a pickle right no - [Copy/paste source](#copypaste-source) - [Prior art & credits](#prior-art--credits) - [License](#license) + ## Requirements @@ -68,8 +70,8 @@ Or with options: ```css @import "tailwindcss"; @plugin "@klinking/squircle/tailwind" { - prefix: sq; /* use `sq-md`, `sq-t-lg`, etc. */ - amt-var: --my-amt; /* use `--my-amt` instead of `--squircle-amt` */ + prefix: sq; /* use `sq-md`, `sq-t-lg`, etc. */ + amt-var: --my-amt; /* use `--my-amt` instead of `--squircle-amt` */ } ``` @@ -305,7 +307,7 @@ If you've already standardized on different variable names — say your design s ```ts squirclePreset({ amtVar: "--corner-amt", // default: --squircle-amt - rVar: "--corner-r", // default: --squircle-r + rVar: "--corner-r", // default: --squircle-r }); ``` @@ -363,7 +365,7 @@ import { squircle } from "@klinking/squircle/stylex"; `squircle` exposes one entry per variant — same 15-name table as the Panda preset (`all`, `top`, `right`, `bottom`, `left`, `start`, `end`, `topLeft`, `topRight`, `bottomRight`, `bottomLeft`, `startStart`, `startEnd`, `endStart`, `endEnd`). Each entry is a function with this signature: ```ts -(radius: string | number, amt?: string | number) => StyleXStyles +(radius: string | number, amt?: string | number) => StyleXStyles; ``` - `radius` — any value valid for `border-radius` (rem, px, %, a `var(--…)` reference, or a number which StyleX converts to px). @@ -380,7 +382,7 @@ const styles = stylex.create({ card: { padding: 16, backgroundColor: "#fff", boxShadow: "0 1px 2px #0002" }, }); -
+
; ``` ### 4. Use shared radius tokens @@ -402,7 +404,7 @@ export const radii = stylex.defineVars({ import { radii } from "./theme/radii.stylex"; import { squircle } from "@klinking/squircle/stylex"; -
+
; ``` `radii.md` is a `var(--xR-…)` reference at runtime, which StyleX wraps in another custom property and the squircle calc resolves transitively. @@ -443,6 +445,137 @@ The parameters are deliberately untyped so relative units (`em`, `rem`, containe +## Pill Shapes with Houdini CSS Paint Worklet + +`rounded-full` gives you a stadium: two semicircles and two straight edges, meeting where the curvature drops from `1/r` to zero in a single step. That step is visible as a faint crease at each end of every pill button. `corner-shape: superellipse()` can't fix it — on a pill the cap _is_ the shape, so reshaping the corner changes the silhouette — and no combination of `border-radius` values can ease one curvature into another. + +`squircle-pill` draws the pill with a [Houdini paint worklet](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Painting_API) instead. The caps stay circular arcs, and the last stretch of each arc is replaced by a **G2-continuous easing**: a power-law spiral (a [clothoid](https://en.wikipedia.org/wiki/Euler_spiral) at its simplest setting) along which curvature falls smoothly to zero before the flat edge begins. There are no size variants — the shape is derived from the element's own dimensions, so one utility covers every button, badge and avatar. + +
+Tailwind CSS v4 + +### 1. Add the plugins + +```css +@import "tailwindcss"; +@plugin "@klinking/squircle/tailwind-pill"; +@plugin "@klinking/squircle/tailwind-pill-border"; /* optional: border-* drives the pill's border */ +``` + +Both plugins take a `prefix` option (default `squircle-pill`), the same way the squircle plugin does. + +### 2. Register the paint worklet + +Once, in your app's entry point: + +```js +import { registerPillWorklet } from "@klinking/squircle/pill-worklet"; + +registerPillWorklet(); +``` + +The helper loads the worklet shipped next to it and, once it is in, marks `` with `data-squircle-pill-worklet`. That mark is what switches pills from their `rounded-full` fallback to the drawn shape — `@supports (mask-image: paint(pill-shape))` is true whether or not a worklet by that name ever loaded, so gating on it alone would erase every pill the moment the file failed to load. Where paint worklets are unsupported it resolves to `false` and nothing changes; a load that fails rejects, so the error shows up in the console rather than as blank buttons. The same module exports `PILL_SHAPE_PROPERTIES`, each shape setting's custom property name and default, for scripts that set or show them. + +The default locates the worklet with `new URL("./pill-shape.worklet.mjs", import.meta.url)`, which Vite, webpack 5 and Parcel all turn into an emitted asset. If your bundler doesn't, or you serve the file yourself, pass its URL: + +```js +// Vite +import workletUrl from "@klinking/squircle/pill-shape.worklet.js?url"; +registerPillWorklet(workletUrl); +``` + +### 3. Use it + +```html + +New +
+``` + +| Utility | Effect | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `squircle-pill` | The pill. Caps derived from the element's size. | +| `squircle-pill-amt-*` | How much of each cap is handed to the easing, in 30° steps. `1` is a bare semicircle; default `2`. | +| `squircle-pill-ease-*` | Stretches the easing further along the flat edge without spending more of the arc. `0` is a clothoid; default `2`. | +| `squircle-pill-g2` | Matches curvature only (G2), where the easing leaves the arc and where it meets the edge. `squircle-pill-g3`, the default, also matches the rate curvature changes (G3) at both, for any ease above `0`. | + +Both accept bare numbers (`squircle-pill-amt-3`, `squircle-pill-amt-1.5`) and arbitrary values (`squircle-pill-amt-[2.5]`), and reject anything else. They only set the custom property — `--squircle-pill-amt` and `--squircle-pill-ease`, and `--squircle-pill-continuity` (`2` or `3`) for the `-g2`/`-g3` utilities — which you can also set yourself, on the element or an ancestor; both are registered and animate. When an element is too narrow for what you asked for, both are eased down together so the join stays smooth; a square renders as a plain circle. + +### Borders, outlines and shadows + +The element itself is left unshaped. Its background is hidden and painted again on its `::before`, under the content, shaped exactly to the pill, so whatever the background is — colour, gradient, image — that's what gets pill-shaped. Nothing of the stadium `border-radius` underneath ever shows, which matters most next to a shadow: the stadium sits up to a few pixels outside the pill near its caps, and a background painted there would show as a hairline between the pill and its shadow. + +**Borders, outlines, rings and box shadows are drawn by the pill**, on its `::after`, along its true outline. With `tailwind-pill-border` loaded, Tailwind's own utilities drive them, and nothing new needs learning: + +- `border-*`: a band along the inside of the outline, solid, dashed or dotted. It reserves the same room in layout as a real border. +- `outline-*` and `outline-offset-*`: a band outside the outline, or inside it with a negative offset, solid, dashed or dotted. +- `ring-*` and `ring-offset-*`: a band outside the outline, beyond an offset band in the offset colour, as Tailwind draws them. +- `inset-ring-*`: a band inside the border. +- `shadow-*`: cast by the outline, with the same offsets, blur and spread. Inner shadows (`shadow-inner`, `inset-shadow-*`) are painted by the copy of the background, inside the pill. + +The two pseudo-elements are the pill's own. Variants work as anywhere else, so `focus-visible:ring-2` draws a focus ring along the pill and `hover:shadow-lg` lifts it. On any element that isn't a pill, the utilities keep behaving normally. Without the plugin, set the pill's own properties: `--squircle-pill-border-width`, `-border-color` and `-border-style`, `--squircle-pill-outline-width`, `-outline-offset`, `-outline-color` and `-outline-style`, `--squircle-pill-ring-width`, `-ring-color`, `-ring-offset-width` and `-ring-offset-color`, `--squircle-pill-inset-ring-width` and `-inset-ring-color`, and `--squircle-pill-box-shadow`, with `--squircle-pill-shadow-reach` set to how far its shadows reach past the pill — offset plus blur plus spread. + +**Everything else is left to the browser**, and follows the pill by itself: a `drop-shadow-*` or any other `filter` sees the pill-shaped background, and Tailwind's `mask-*` utilities and any `clip-path` apply to the whole pill as they would to any element — `squircle-pill mask-b-from-20%` is a pill that fades out towards the bottom, its border and shadow with it. The browser's own focus ring, set by no utility, follows the stadium. + +One thing to know: the pill hides its own background by clipping it to its text, and its own box shadow, both with `!important` so that nothing — a utility, an inline `background` — can put the stadium back. So `bg-clip-text` on the pill itself has no effect of its own: the copy still fills the pill. For gradient text inside a pill, put the text in a child. + +### Fallback + +Without the worklet — Safari, Firefox, or before `registerPillWorklet()` resolves — a pill is a plain `rounded-full` stadium with a real border, and nothing else. Not a superellipse: on a pill the cap is the whole shape, so a superellipse would change the silhouette rather than soften a corner. No layout shift when the worklet lands. + +### Polyfill for browsers without paint worklets + +To get the real shape in Safari and Firefox too, load the polyfill where the worklet isn't available: + +```js +import { registerPillWorklet } from "@klinking/squircle/pill-worklet"; + +if (!(await registerPillWorklet())) { + const { polyfillPills } = await import("@klinking/squircle/pill-polyfill"); + polyfillPills(); +} +``` + +It runs the worklet's own geometry on the main thread: a `ResizeObserver` watches every pill, and on each new size the outline is set as a `clip-path: path()` for the copy of the background, and everything drawn around it — border, outline, rings, shadows — as an SVG image, on two custom properties the pill styles read. A clip rather than a mask image for the shape, because a mask image is decoded and rasterised again on every resize, which was over five times slower. The shapes are identical to the worklet's, borders, dashes and shadows included; a square needs no clip at all, its stadium already being the circle it has to be. Each pill shows its stadium until its clip is computed, so there is no flash of anything worse, and a resize too large for one frame spreads over the next few rather than dropping them. The element's own `clip-path` and masks are never touched, so they apply as they do with the worklet. + +It picks up pills added or removed later, and re-reads a pill when its `class` changes and when it gains or loses focus, hover or a press, so `focus-visible:` and `hover:` decorations work too. Changes it can't see — an inline `style` setting a pill property or a clip, a stylesheet swap, a media query — need a `refresh()`: + +```js +const pills = polyfillPills(); +pills.refresh(element); // or pills.refresh() for all of them +``` + +The cost is main-thread work on resize; the site's `/bench/pills` page measures it against the worklet for a few thousand pills at once, and `website/scripts/bench-pills.mjs` runs the same matrix headlessly. + +
+ +
+Without Tailwind + +The same rules, hung off an attribute: + +```css +@import "@klinking/squircle/squircle-pill.css"; +``` + +```html + +``` + +Register the worklet as above. Here the pill's own properties also drive a real border, so a bordered pill degrades to a bordered stadium without the worklet; `--squircle-pill-border-color` defaults to `currentColor` and `--squircle-pill-border-style` to `solid`. The stylesheet is generated from the same source as the Tailwind utility, so the two never disagree. + +
+ +### How pill shapes work + +A circular arc has constant curvature `1/R`; a straight edge has none. The worklet joins them with a transition along which curvature falls off over the transition's arc length. With `squircle-pill-g2` it falls as `k(t) = (1/R) · (1 − t)^(q − 1)` — a clothoid at `q = 2`, where the fall is linear, and a softer spiral above it. `squircle-pill-amt-*` sets how much of the arc (β, in 30° steps) the transition replaces, and `squircle-pill-ease-*` offsets `q` (`q = ease + 2`), which lengthens the transition to `q · R · β` along the edge without eating any more of the arc. The cap radius is then solved so that the arc's rise plus the transition's rise is exactly half the element's height, so the outline always fits the box; the position along the transition is the Fresnel-type integral of that curvature, evaluated numerically once per shape and cached. + +That profile leaves the arc already shedding curvature at a finite rate, so it is only G2 where it leaves the arc. The default, G3, uses `k(t) = (1/R) · (1 − t²)^(q − 1)` instead, whose curvature starts falling with zero slope — G3 at the arc — and, for `q > 2` (any ease above `0`), also arrives with zero slope at the edge. Everything else, including the fit to the box, is the same for both. + +That's a different construction from the curvature-continuous fillet in CAD tools (SolidWorks and Onshape's "curvature continuous", Fusion's G2, Rhino's `BlendCrv`), which blend arc into edge with a quintic Hermite polynomial that has position, tangent and curvature prescribed at both ends, controlled by a setback per face and a bulge per end. The spiral's advantage is that its curvature profile is monotone by construction and its length is a closed-form function of the cap, which is what lets it fit itself to the box; the Hermite blend keeps the cap at its full radius and lets the blend bow outward, which is right for a model and wrong for a button. The site's [pill algorithms demo](https://squircle.klink.ing/demos/pill-algorithms) renders the two side by side, with each one's controls and a curvature comb, so you can judge for yourself. + +**Browser support:** the Paint API is in Chromium (Chrome, Edge, Opera, Samsung Internet). Safari and Firefox get the stadium fallback. Both `--squircle-pill-*` properties are registered with `@property`, which those browsers support, so nothing else changes. + ## How the radius correction works A superellipse at the same outer `border-radius` as a circular arc pokes further into the corner. The fix is to scale the radius up by some maths, so the _apparent_ roundness matches what you'd get from `rounded-*`. That is, the distance from the corner to the maximum pokage will match for both the superelliptical corner and the circular corner. @@ -490,6 +623,7 @@ See the [interactive demo](https://dogmar.github.io/squircle) for a visual expla | [Logical properties](https://caniuse.com/css-logical-props) | `squircle-s/e/ss/se/es/ee-*` | Widely supported | | [CSS `@function`](https://caniuse.com/?search=%40function) | Optional `squircle-radius()` helper | Experimental; Chrome flag only | | [CSS custom properties](https://caniuse.com/css-variables) | Theme tokens, `--squircle-amt`, `--squircle-r` | Universal | +| [CSS Paint API](https://caniuse.com/css-paint-api) | `squircle-pill` shape and border | Chromium only; fallback to `rounded-full` elsewhere | The Tailwind utilities depend on rows 1–4 and row 6. Only `corner-shape` itself is "new" — everything else is shipped broadly. The standalone `@function` helper is the only genuinely experimental piece. @@ -540,7 +674,7 @@ Partially, at time of writing — recent Chrome ships `corner-shape`, Safari and The **CSS utilities** (`tailwind/utils.css`) are v4-only — they use `@utility` and `--value()`, which don't exist in v3. -The **JS plugin** uses only APIs that exist in both v3 and v4 (`plugin.withOptions`, `matchUtilities`, `type: "length" | "number"`, `theme()`), so it's likely to work in v3 via a `tailwind.config.js`-style registration — but it's not currently tested or declared against v3. Tracked in [#26](https://github.com/dogmar/squircle/pull/26). +The **JS plugin** uses only APIs that exist in both v3 and v4 (`plugin.withOptions`, `matchUtilities`, `type: "length" | "number"`, `theme()`), so it's likely to work in v3 via a `tailwind.config.js`-style registration — but it's not currently tested or declared against v3. Tracked in [#26](https://github.com/klink-ing/squircle/pull/26). @@ -634,7 +768,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-full { - border-radius: calc(infinity * 1px); + border-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-shape: superellipse(var(--squircle-amt, 2)); } @@ -643,7 +777,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-* { border-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + --squircle-r: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); border-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); } @@ -657,8 +794,8 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-t-full { - border-top-left-radius: calc(infinity * 1px); - border-top-right-radius: calc(infinity * 1px); + border-top-left-radius: calc(infinity* 1px); + border-top-right-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-top-left-shape: superellipse(var(--squircle-amt, 2)); corner-top-right-shape: superellipse(var(--squircle-amt, 2)); @@ -669,7 +806,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-top-left-radius: --value(--radius-*, [length]); border-top-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + --squircle-r: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); border-top-left-radius: var(--squircle-r); border-top-right-radius: var(--squircle-r); corner-top-left-shape: superellipse(var(--squircle-amt, 2)); @@ -683,8 +823,8 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-r-full { - border-top-right-radius: calc(infinity * 1px); - border-bottom-right-radius: calc(infinity * 1px); + border-top-right-radius: calc(infinity* 1px); + border-bottom-right-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-top-right-shape: superellipse(var(--squircle-amt, 2)); corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); @@ -695,7 +835,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-top-right-radius: --value(--radius-*, [length]); border-bottom-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + --squircle-r: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); border-top-right-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); corner-top-right-shape: superellipse(var(--squircle-amt, 2)); @@ -709,8 +852,8 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-b-full { - border-bottom-left-radius: calc(infinity * 1px); - border-bottom-right-radius: calc(infinity * 1px); + border-bottom-left-radius: calc(infinity* 1px); + border-bottom-right-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); @@ -721,7 +864,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-bottom-left-radius: --value(--radius-*, [length]); border-bottom-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + --squircle-r: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); border-bottom-left-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); @@ -735,8 +881,8 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-l-full { - border-top-left-radius: calc(infinity * 1px); - border-bottom-left-radius: calc(infinity * 1px); + border-top-left-radius: calc(infinity* 1px); + border-bottom-left-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-top-left-shape: superellipse(var(--squircle-amt, 2)); corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); @@ -747,7 +893,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-top-left-radius: --value(--radius-*, [length]); border-bottom-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + --squircle-r: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); border-top-left-radius: var(--squircle-r); border-bottom-left-radius: var(--squircle-r); corner-top-left-shape: superellipse(var(--squircle-amt, 2)); @@ -763,8 +912,8 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-s-full { - border-start-start-radius: calc(infinity * 1px); - border-end-start-radius: calc(infinity * 1px); + border-start-start-radius: calc(infinity* 1px); + border-end-start-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-start-start-shape: superellipse(var(--squircle-amt, 2)); corner-end-start-shape: superellipse(var(--squircle-amt, 2)); @@ -775,7 +924,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-start-start-radius: --value(--radius-*, [length]); border-end-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + --squircle-r: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); border-start-start-radius: var(--squircle-r); border-end-start-radius: var(--squircle-r); corner-start-start-shape: superellipse(var(--squircle-amt, 2)); @@ -789,8 +941,8 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-e-full { - border-start-end-radius: calc(infinity * 1px); - border-end-end-radius: calc(infinity * 1px); + border-start-end-radius: calc(infinity* 1px); + border-end-end-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-start-end-shape: superellipse(var(--squircle-amt, 2)); corner-end-end-shape: superellipse(var(--squircle-amt, 2)); @@ -801,7 +953,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand border-start-end-radius: --value(--radius-*, [length]); border-end-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + --squircle-r: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); border-start-end-radius: var(--squircle-r); border-end-end-radius: var(--squircle-r); corner-start-end-shape: superellipse(var(--squircle-amt, 2)); @@ -816,7 +971,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-tl-full { - border-top-left-radius: calc(infinity * 1px); + border-top-left-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-top-left-shape: superellipse(var(--squircle-amt, 2)); } @@ -825,7 +980,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-tl-* { border-top-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-top-left-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-top-left-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-top-left-shape: superellipse(var(--squircle-amt, 2)); } } @@ -835,7 +993,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-tr-full { - border-top-right-radius: calc(infinity * 1px); + border-top-right-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-top-right-shape: superellipse(var(--squircle-amt, 2)); } @@ -844,7 +1002,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-tr-* { border-top-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-top-right-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-top-right-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-top-right-shape: superellipse(var(--squircle-amt, 2)); } } @@ -854,7 +1015,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-br-full { - border-bottom-right-radius: calc(infinity * 1px); + border-bottom-right-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } @@ -863,7 +1024,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-br-* { border-bottom-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-bottom-right-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-bottom-right-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-bottom-right-shape: superellipse(var(--squircle-amt, 2)); } } @@ -873,7 +1037,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-bl-full { - border-bottom-left-radius: calc(infinity * 1px); + border-bottom-left-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } @@ -882,7 +1046,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-bl-* { border-bottom-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-bottom-left-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-bottom-left-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-bottom-left-shape: superellipse(var(--squircle-amt, 2)); } } @@ -894,7 +1061,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-ss-full { - border-start-start-radius: calc(infinity * 1px); + border-start-start-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-start-start-shape: superellipse(var(--squircle-amt, 2)); } @@ -903,7 +1070,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-ss-* { border-start-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-start-start-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-start-start-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-start-start-shape: superellipse(var(--squircle-amt, 2)); } } @@ -913,7 +1083,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-se-full { - border-start-end-radius: calc(infinity * 1px); + border-start-end-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-start-end-shape: superellipse(var(--squircle-amt, 2)); } @@ -922,7 +1092,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-se-* { border-start-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-start-end-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-start-end-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-start-end-shape: superellipse(var(--squircle-amt, 2)); } } @@ -932,7 +1105,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-es-full { - border-end-start-radius: calc(infinity * 1px); + border-end-start-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-end-start-shape: superellipse(var(--squircle-amt, 2)); } @@ -941,7 +1114,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-es-* { border-end-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-end-start-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-end-start-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-end-start-shape: superellipse(var(--squircle-amt, 2)); } } @@ -951,7 +1127,7 @@ If you'd rather not add a dependency, copy the source directly. Click to expand } @utility squircle-ee-full { - border-end-end-radius: calc(infinity * 1px); + border-end-end-radius: calc(infinity* 1px); @supports (corner-shape: superellipse(2)) { corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } @@ -960,7 +1136,10 @@ If you'd rather not add a dependency, copy the source directly. Click to expand @utility squircle-ee-* { border-end-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-end-end-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); + border-end-end-radius: calc( + --value(--radius- *, [length]) * (1 - pow(2, -0.5)) / + (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) + ); corner-end-end-shape: superellipse(var(--squircle-amt, 2)); } } @@ -1187,8 +1366,12 @@ import { extendTailwindMerge } from "tailwind-merge"; // group also conflicts with its `rounded` counterpart (and vice versa). // `squircle-amt-*` is orthogonal: radius classes never cancel it. const SIDE_CORNERS = { - t: ["tl", "tr"], r: ["tr", "br"], b: ["br", "bl"], - l: ["tl", "bl"], s: ["ss", "es"], e: ["se", "ee"], + t: ["tl", "tr"], + r: ["tr", "br"], + b: ["br", "bl"], + l: ["tl", "bl"], + s: ["ss", "es"], + e: ["se", "ee"], }; const CORNERS = ["tl", "tr", "br", "bl", "ss", "se", "es", "ee"]; const SIDES = Object.keys(SIDE_CORNERS); diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..e843ae8 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,22 @@ +# Deploy previews for pull requests. Production stays on GitHub Pages +# (.github/workflows/deploy-site.yml). +# +# Netlify reads this file from the repository root. `base` is set here rather +# than left to the site's UI settings, and `publish` is relative to it. +# +# Node comes from .node-version and pnpm from the packageManager field, both +# of which Netlify reads on its own. +# +# The steps are called directly rather than through `vp run website#build`: +# the vite-plus task runner builds fine locally and in GitHub Actions but +# hangs on Netlify's build image until the build times out. The package has +# to be built first because the website imports it from dist/. + +[build] +base = "website" +# Netlify skips a build when nothing under `base` changed, but the site +# bundles the package from ../package, so a package-only change would leave +# the preview stale. Exit 1 means build; skip only when neither changed. +ignore = "git diff --quiet \"$CACHED_COMMIT_REF\" \"$COMMIT_REF\" -- . ../package ../pnpm-lock.yaml ../netlify.toml" +command = "pnpm --filter @klinking/squircle exec tsx scripts/build.ts && pnpm --filter website exec astro build" +publish = "dist" diff --git a/package/package.json b/package/package.json index d82d56d..0ae10dd 100644 --- a/package/package.json +++ b/package/package.json @@ -26,7 +26,29 @@ "import": "./dist/tailwind/index.mjs" }, "./tailwind/utils.css": "./dist/tailwind/utils.css", + "./tailwind-pill": { + "types": "./dist/tailwind-pill/index.d.mts", + "import": "./dist/tailwind-pill/index.mjs" + }, + "./tailwind-pill-border": { + "types": "./dist/tailwind-pill-border/index.d.mts", + "import": "./dist/tailwind-pill-border/index.mjs" + }, "./radius-function.css": "./dist/radius-function.css", + "./squircle-pill.css": "./dist/squircle-pill.css", + "./pill-polyfill": { + "types": "./dist/pill-polyfill.d.mts", + "import": "./dist/pill-polyfill.mjs" + }, + "./pill-worklet": { + "types": "./dist/pill-worklet.d.mts", + "import": "./dist/pill-worklet.mjs" + }, + "./pill-shape.worklet": { + "types": "./dist/pill-shape.worklet.d.mts", + "import": "./dist/pill-shape.worklet.mjs" + }, + "./pill-shape.worklet.js": "./dist/pill-shape.worklet.mjs", "./panda": { "types": "./dist/panda/index.d.mts", "import": "./dist/panda/index.mjs" @@ -50,9 +72,9 @@ "oxlint": "^1.58.0", "tsx": "^4.21.0", "typescript": "^6.0.2", - "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.15", + "vite": "catalog:", "vite-plus": "catalog:", - "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.15" + "vitest": "catalog:" }, "peerDependencies": { "@pandacss/dev": ">=0.40.0", @@ -74,5 +96,8 @@ "optional": true } }, - "packageManager": "pnpm@10.33.0" + "packageManager": "pnpm@10.33.0", + "squircle": { + "cssNamespace": "squircle" + } } diff --git a/package/scripts/build.ts b/package/scripts/build.ts new file mode 100644 index 0000000..67c96c4 --- /dev/null +++ b/package/scripts/build.ts @@ -0,0 +1,27 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/* + * The whole package build, as one step. + * + * `vp run` caches each `&&`-separated segment of a task command on its own, so + * a chain of `vp pack && tsx generate-*.ts` could re-run the pack — which + * empties dist/ first — while replaying the generators from cache, leaving + * dist/ without the generated CSS. One script is one cache entry. + */ +const pkgDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const bin = (name: string) => + [ + join(pkgDir, "node_modules", ".bin", name), + join(pkgDir, "..", "node_modules", ".bin", name), + ].find(existsSync) ?? name; + +const run = (file: string, args: string[]) => + execFileSync(file, args, { cwd: pkgDir, stdio: "inherit" }); + +run(bin("tsx"), ["scripts/generate-stylex.ts"]); +run(bin("vp"), ["pack"]); +run(bin("tsx"), ["scripts/generate-squircle-css.ts"]); +run(bin("tsx"), ["scripts/generate-pill-css.ts"]); diff --git a/package/scripts/generate-pill-css.ts b/package/scripts/generate-pill-css.ts new file mode 100644 index 0000000..f2f15aa --- /dev/null +++ b/package/scripts/generate-pill-css.ts @@ -0,0 +1,17 @@ +// Pins the namespace before variants.ts is evaluated; keep it first. +import { CSS_NAMESPACE } from "./load-namespace"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderPillCss } from "../src/pill-css"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const distDir = join(__dirname, "..", "dist"); +mkdirSync(distDir, { recursive: true }); + +// Rendered from the same rules the Tailwind utility carries, so the two +// cannot drift, and under the same namespace the worklet was built with. +const outPath = join(distDir, "squircle-pill.css"); +writeFileSync(outPath, renderPillCss()); +console.log(`Generated ${outPath} (namespace: ${CSS_NAMESPACE})`); diff --git a/package/scripts/generate-squircle-css.ts b/package/scripts/generate-squircle-css.ts index f308009..5de45bf 100644 --- a/package/scripts/generate-squircle-css.ts +++ b/package/scripts/generate-squircle-css.ts @@ -1,3 +1,5 @@ +// Pins the namespace before variants.ts is evaluated; keep it first. +import "./load-namespace"; import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/package/scripts/load-namespace.ts b/package/scripts/load-namespace.ts new file mode 100644 index 0000000..c0a6c27 --- /dev/null +++ b/package/scripts/load-namespace.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; + +/** + * The one place the custom-property namespace is resolved. + * + * `SQUIRCLE_CSS_NAMESPACE` overrides it for a one-off build; + * `squircle.cssNamespace` in package.json is the committed default for a + * fork that wants it permanently. Importing this module first pins the + * resolved value into the environment, which is where `variants.ts` reads it + * from when it runs outside Vite (under `tsx`) and so has no define. + */ +export const DEFAULT_CSS_NAMESPACE = "squircle"; + +export function resolveCssNamespace(): string { + return ( + process.env["SQUIRCLE_CSS_NAMESPACE"] || + JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).squircle + ?.cssNamespace || + DEFAULT_CSS_NAMESPACE + ); +} + +export const CSS_NAMESPACE: string = resolveCssNamespace(); +process.env["SQUIRCLE_CSS_NAMESPACE"] = CSS_NAMESPACE; diff --git a/package/src/panda.ts b/package/src/panda.ts index 2566f5e..248f152 100644 --- a/package/src/panda.ts +++ b/package/src/panda.ts @@ -7,6 +7,7 @@ import { definePreset, type PropertyConfig } from "@pandacss/dev"; import { CAMEL_VARIANTS, DEFAULT_AMOUNT_VAR_NAME, + DEFAULT_R_VAR_NAME, SUPPORTS_RULE, squircleCssObj, variantEntries, @@ -14,8 +15,17 @@ import { export interface SquirclePandaPresetOptions { /** CSS custom property name for the superellipse amount (default: "--squircle-amt"). */ + /** + * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at + * build time, which renames every property this package owns together. This + * option still works and still wins. + */ amtVar?: string; /** CSS custom property name for the intermediate corrected radius (default: "--squircle-r"). */ + /** + * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at + * build time. This option still works and still wins. + */ rVar?: string; } @@ -38,7 +48,7 @@ export interface SquirclePandaPresetOptions { */ export function squirclePandaPreset(options: SquirclePandaPresetOptions = {}) { const amtVar = options.amtVar ?? DEFAULT_AMOUNT_VAR_NAME; - const rVar = options.rVar ?? "--squircle-r"; + const rVar = options.rVar ?? DEFAULT_R_VAR_NAME; const utilities: Record = {}; diff --git a/package/src/pill-css.ts b/package/src/pill-css.ts new file mode 100644 index 0000000..05b0f24 --- /dev/null +++ b/package/src/pill-css.ts @@ -0,0 +1,349 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { + DEFAULT_PILL_AMT, + DEFAULT_PILL_CONTINUITY, + DEFAULT_PILL_EASE, + FULL_RADIUS, + PILL_AMT_VAR_NAME, + PILL_BACKGROUND_INSET_VAR_NAME, + PILL_ATTRIBUTE, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_FALLBACK, + PILL_BORDER_STYLE_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BOX_SHADOW_VAR_NAME, + PILL_CLIP_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_DECORATED_VAR_NAME, + PILL_DECORATION_CLIP_VAR_NAME, + PILL_DECORATION_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_INSET_RING_COLOR_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_STYLE_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_POLYFILL_ATTRIBUTE, + PILL_REACH_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_RING_OFFSET_COLOR_VAR_NAME, + PILL_RING_OFFSET_WIDTH_VAR_NAME, + PILL_RING_WIDTH_VAR_NAME, + PILL_SHADOW_REACH_VAR_NAME, + PILL_WORKLET_ATTRIBUTE, +} from "./variants"; + +/** Nested CSS-in-JS: a declaration, or a nested rule keyed by its selector. */ +export type PillCss = { [key: string]: string | PillCss }; + +/** + * Where the pill's rules come from decides how its border and shadows are + * wired up. + * + * `tailwind`: the `border-*`, `shadow-*` and ring utilities own those, and + * the pill reads them — through `tailwind-pill-border` for widths, and + * through Tailwind's own variables for styles, colours and shadow lists. + * + * `standalone`: the pill's own properties are the only API, so they drive a + * real border too. That real border is what shows without the worklet, a + * stadium ring, and what reserves room for the drawn one with it. + */ +export type PillCssFlavor = "tailwind" | "standalone"; + +/** + * `@property` registrations for everything the pill reads, with initial + * values matching the worklet's own fallbacks. Registering makes the values + * typed and animatable, and resolves lengths to px before the worklet sees + * them. + * + * Everything the pseudo-elements read inherits, so they get it the ordinary + * way. Pulling a non-inheriting property down with `inherit` works once, but + * WebKit never restyles the pseudo when it changes: a hover colour, a new + * width, or the clip the polyfill computes after the first paint would never + * reach it in Safari. A pill nested in a decorated pill is kept from drawing + * its parent's border, rings and shadows by resetting them on every pill + * instead; see `pillCssObj`. + */ +export function pillPropertyRegistrations(): Record> { + const lengths = [ + PILL_BORDER_WIDTH_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_RING_WIDTH_VAR_NAME, + PILL_RING_OFFSET_WIDTH_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_SHADOW_REACH_VAR_NAME, + PILL_REACH_VAR_NAME, + PILL_BACKGROUND_INSET_VAR_NAME, + ]; + const colors = [ + PILL_BORDER_COLOR_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_RING_OFFSET_COLOR_VAR_NAME, + PILL_INSET_RING_COLOR_VAR_NAME, + ]; + return { + [`@property ${PILL_AMT_VAR_NAME}`]: { + syntax: '""', + "initial-value": String(DEFAULT_PILL_AMT), + inherits: "true", + }, + [`@property ${PILL_EASE_VAR_NAME}`]: { + syntax: '""', + "initial-value": String(DEFAULT_PILL_EASE), + inherits: "true", + }, + [`@property ${PILL_CONTINUITY_VAR_NAME}`]: { + syntax: '""', + "initial-value": String(DEFAULT_PILL_CONTINUITY), + inherits: "true", + }, + ...Object.fromEntries( + lengths.map((name) => [ + `@property ${name}`, + { syntax: '""', "initial-value": "0px", inherits: "true" }, + ]), + ), + ...Object.fromEntries( + colors.map((name) => [ + `@property ${name}`, + { syntax: '""', "initial-value": "transparent", inherits: "true" }, + ]), + ), + // The polyfill's shape for the background, and its drawing of the rest. + [`@property ${PILL_CLIP_VAR_NAME}`]: { syntax: '"*"', inherits: "true" }, + [`@property ${PILL_DECORATION_VAR_NAME}`]: { syntax: '"*"', inherits: "true" }, + [`@property ${PILL_DECORATION_CLIP_VAR_NAME}`]: { syntax: '"*"', inherits: "true" }, + }; +} + +/** Tailwind's ring and shadow variables, in the order `box-shadow` lists them. */ +const TAILWIND_BOX_SHADOW = [ + "--tw-inset-shadow", + "--tw-inset-ring-shadow", + "--tw-ring-offset-shadow", + "--tw-ring-shadow", + "--tw-shadow", +] + .map((name) => `var(${name}, 0 0 #0000)`) + .join(", "); + +/** + * The rules one pill utility carries, with `&` standing for the utility's own + * selector. + * + * Nothing about the element itself is masked. Its background is hidden — + * clipped to its text, which sits on top of it anyway — and painted again on + * `::before`, under the content, shaped exactly to the pill. Whatever the + * background is, a colour, a gradient, an image, that copy of it is what + * shows. Hiding the original matters: it would paint into the stadium + * `border-radius`, which sits up to a few pixels outside the pill near its + * caps, and in that sliver no shadow could show. + * + * Around the pill, on `::after`, the pill draws its own border, outline, + * rings and box shadows, cast by its true outline; the element's own box + * shadows are switched off, and the copy of the background paints the inset + * ones. A drop shadow, a filter on the element, sees the pill-shaped copy and + * follows it by itself. A mask on the element — Tailwind's `mask-*` — applies + * as it would anywhere, shadows and all. + */ +export function pillCssObj(flavor: PillCssFlavor): PillCss { + const background = (shape: PillCss): PillCss => ({ + content: '""', + position: "absolute", + // The pseudo is positioned against the padding box; the border box is + // what it has to cover. + inset: `calc(-1 * var(${PILL_BORDER_WIDTH_VAR_NAME}))`, + "z-index": "-1", + "pointer-events": "none", + "border-radius": "inherit", + background: "inherit", + // A border as wide as the element's, so a background positioned against + // the padding box lines up with the original. Painted under it too. + border: `var(${PILL_BORDER_WIDTH_VAR_NAME}) solid transparent`, + "-webkit-background-clip": "border-box", + "background-clip": "border-box", + // The inset shadows paint inside the copy; the outer ones are cut away + // with everything else outside the pill, and drawn on `::after` instead. + "box-shadow": `var(${PILL_BOX_SHADOW_VAR_NAME}, none)`, + ...shape, + }); + + const decoration = (paint: PillCss): PillCss => ({ + // With Tailwind, only on pills `tailwind-pill-border` has seen a border, + // outline, ring or shadow utility on: a pseudo on every pill, even drawing + // nothing, costs a paint each per resize. Without that plugin, always. + content: flavor === "tailwind" ? `var(${PILL_DECORATED_VAR_NAME}, "")` : '""', + position: "absolute", + "pointer-events": "none", + inset: `calc(-1 * (var(${PILL_BORDER_WIDTH_VAR_NAME}) + var(${PILL_REACH_VAR_NAME})))`, + ...paint, + }); + + const shaped = (shape: PillCss, paint: PillCss): PillCss => ({ + // The worklet only runs where there is an area to paint. + "min-width": "1px", + "min-height": "1px", + position: "relative", + // Keeps the copy of the background, at `z-index: -1`, above the element's + // own and below its content. + isolation: "isolate", + // The real border must not paint: it is a stadium ring. Its width still + // reserves room for the drawn one. + "border-color": "transparent", + "&::before": background(shape), + "&::after": decoration(paint), + }); + + return { + // A stadium on every branch: it is the whole fallback without the + // worklet, and what an outline the pill doesn't draw itself — the + // browser's focus ring — follows with it. + "border-radius": FULL_RADIUS, + ...(flavor === "standalone" + ? { + "border-width": `var(${PILL_BORDER_WIDTH_VAR_NAME})`, + "border-style": `var(${PILL_BORDER_STYLE_VAR_NAME}, ${PILL_BORDER_STYLE_FALLBACK})`, + "border-color": `var(${PILL_BORDER_COLOR_VAR_NAME})`, + } + : {}), + // Same default colours a real border and ring have. Zero specificity, so + // a value set any other way — a utility, a rule, an inline style — wins + // whatever the order. + // + // Everything here inherits, so each pill also starts from none of it, + // rather than drawing a decorated parent's border, rings or shadows. + ":where(&)": { + [PILL_BORDER_WIDTH_VAR_NAME]: "0px", + [PILL_BORDER_COLOR_VAR_NAME]: "currentColor", + [PILL_OUTLINE_WIDTH_VAR_NAME]: "0px", + [PILL_OUTLINE_OFFSET_VAR_NAME]: "0px", + [PILL_OUTLINE_COLOR_VAR_NAME]: "currentColor", + [PILL_RING_WIDTH_VAR_NAME]: "0px", + [PILL_INSET_RING_WIDTH_VAR_NAME]: "0px", + [PILL_SHADOW_REACH_VAR_NAME]: "0px", + [PILL_CLIP_VAR_NAME]: "initial", + [PILL_DECORATION_VAR_NAME]: "initial", + [PILL_DECORATION_CLIP_VAR_NAME]: "initial", + // How far the outline, the ring and the shadows reach past the box, + // which the decoration's box grows by. + [PILL_REACH_VAR_NAME]: `max(0px, var(${PILL_OUTLINE_OFFSET_VAR_NAME}) + var(${PILL_OUTLINE_WIDTH_VAR_NAME}), var(${PILL_RING_OFFSET_WIDTH_VAR_NAME}) + var(${PILL_RING_WIDTH_VAR_NAME}), var(${PILL_SHADOW_REACH_VAR_NAME}))`, + // Half a pixel in under a border or an inset ring touching the outline: + // the background and the band over it, anti-aliased along the same + // outer edge, would both leave the pixels there partly uncovered, and + // the background would show past the band as a hairline. + [PILL_BACKGROUND_INSET_VAR_NAME]: `min(0.5px, var(${PILL_BORDER_WIDTH_VAR_NAME}) + var(${PILL_INSET_RING_WIDTH_VAR_NAME}))`, + ...(flavor === "tailwind" + ? { + // `border-dashed` and friends set `--tw-border-style`, so the + // pill reads it rather than asking for a second source of truth. + // Mapped here, on the element, because Tailwind registers it as + // non-inheriting; the pseudo-elements inherit the result. + [PILL_BORDER_STYLE_VAR_NAME]: `var(--tw-border-style, ${PILL_BORDER_STYLE_FALLBACK})`, + // Likewise the outline's style, every ring setting Tailwind keeps + // in a variable of its own, and its whole shadow list. Widths + // aren't among them — a ring's is folded into its shadow — so + // `tailwind-pill-border` mirrors those from the utilities. + [PILL_OUTLINE_STYLE_VAR_NAME]: "var(--tw-outline-style, solid)", + [PILL_RING_COLOR_VAR_NAME]: "var(--tw-ring-color, currentColor)", + [PILL_RING_OFFSET_WIDTH_VAR_NAME]: "var(--tw-ring-offset-width, 0px)", + [PILL_RING_OFFSET_COLOR_VAR_NAME]: "var(--tw-ring-offset-color, #fff)", + [PILL_INSET_RING_COLOR_VAR_NAME]: "var(--tw-inset-ring-color, currentColor)", + [PILL_BOX_SHADOW_VAR_NAME]: TAILWIND_BOX_SHADOW, + } + : { + [PILL_RING_COLOR_VAR_NAME]: "currentColor", + [PILL_RING_OFFSET_WIDTH_VAR_NAME]: "0px", + [PILL_RING_OFFSET_COLOR_VAR_NAME]: "transparent", + [PILL_INSET_RING_COLOR_VAR_NAME]: "currentColor", + [PILL_BOX_SHADOW_VAR_NAME]: "none", + }), + }, + // Only once the worklet has actually loaded; see PILL_WORKLET_ATTRIBUTE. + // `:where()` keeps the specificity that of the bare utility. + [`:where(:root[${PILL_WORKLET_ATTRIBUTE}]) &`]: shaped( + { + "-webkit-mask-image": "paint(pill-shape)", + "mask-image": "paint(pill-shape)", + "-webkit-mask-size": "100% 100%", + "mask-size": "100% 100%", + "-webkit-mask-repeat": "no-repeat", + "mask-repeat": "no-repeat", + "mask-mode": "alpha", + }, + { background: "paint(pill-decoration)" }, + ), + // Without a worklet, `polyfillPills()` computes the same shape per + // element as a `clip-path: path()`, and the decoration as an image — or, + // for a single band, as a clip over its colour. Until they are computed + // the background shows as a stadium. + [`:where(:root[${PILL_POLYFILL_ATTRIBUTE}]) &`]: shaped( + { "clip-path": `var(${PILL_CLIP_VAR_NAME}, none)` }, + { + "background-image": `var(${PILL_DECORATION_VAR_NAME}, none)`, + "background-size": "100% 100%", + "background-repeat": "no-repeat", + "clip-path": `var(${PILL_DECORATION_CLIP_VAR_NAME}, none)`, + }, + ), + // Hiding the element's own background and shadows, so nothing of the + // stadium shows past the pill. Important, so neither a background or + // shadow utility nor an inline `background` shorthand — which resets + // `background-clip` — brings them back; either would put back the very + // hairline this is here to prevent. + [`:where(:root[${PILL_WORKLET_ATTRIBUTE}], :root[${PILL_POLYFILL_ATTRIBUTE}]) &`]: { + "-webkit-background-clip": "text !important", + "background-clip": "text !important", + "box-shadow": "none !important", + }, + }; +} + +function renderDeclarations(obj: PillCss, indent: string): string[] { + const lines: string[] = []; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string") lines.push(`${indent}${key}: ${value};`); + } + return lines; +} + +/** Flattens nested rules, resolving `&` against the parent selector. */ +function renderRule(selector: string, obj: PillCss): string[] { + const blocks: string[] = []; + const declarations = renderDeclarations(obj, " "); + if (declarations.length > 0) blocks.push(`${selector} {\n${declarations.join("\n")}\n}`); + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string") continue; + const child = key.includes("&") ? key.replaceAll("&", selector) : `${selector} ${key}`; + blocks.push(...renderRule(child, value)); + } + return blocks; +} + +/** + * The standalone stylesheet, `squircle-pill.css`: the same rules the Tailwind + * utility carries, hung off `[data--pill]` so they work without + * Tailwind. Generated from the one source so the two cannot drift. + */ +export function renderPillCss(selector = `[${PILL_ATTRIBUTE}]`): string { + const blocks: string[] = [ + `/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */`, + `/* Generated from src/pill-css.ts — do not edit by hand. */`, + ]; + + for (const [rule, decls] of Object.entries(pillPropertyRegistrations())) { + blocks.push(`${rule} {\n${renderDeclarations(decls, " ").join("\n")}\n}`); + } + + blocks.push(...renderRule(selector, pillCssObj("standalone"))); + return blocks.join("\n\n") + "\n"; +} diff --git a/package/src/pill-polyfill.test.ts b/package/src/pill-polyfill.test.ts new file mode 100644 index 0000000..9d40ac3 --- /dev/null +++ b/package/src/pill-polyfill.test.ts @@ -0,0 +1,420 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { describe, expect, it } from "vitest"; +import { + pillBackgroundInset, + pillClipPath, + pillDecoration, + pillDecorationImage, + pillOutlinePath, + pillOutlinePoints, +} from "./pill-polyfill"; +import { paintDef } from "./pill-shape.worklet"; +import { + PILL_AMT_VAR_NAME, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BOX_SHADOW_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_INSET_RING_COLOR_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_STYLE_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_REACH_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_RING_OFFSET_COLOR_VAR_NAME, + PILL_RING_OFFSET_WIDTH_VAR_NAME, + PILL_RING_WIDTH_VAR_NAME, +} from "./variants"; + +type Point = { x: number; y: number }; + +const parse = (d: string): Point[][] => + d + .split("Z") + .filter((sub) => sub.includes("M")) + .map((sub) => + [...sub.matchAll(/[ML](-?[\d.]+) (-?[\d.]+)/g)].map((m) => ({ + x: Number(m[1]), + y: Number(m[2]), + })), + ); + +/** The vertices the worklet itself draws, without the repeats where quadrants meet. */ +const workletVertices = (width: number, height: number, values: Record = {}) => { + const vertices: Point[] = []; + const push = (x: number, y: number) => { + const last = vertices.at(-1); + if (!last || Math.abs(last.x - x) > 1e-6 || Math.abs(last.y - y) > 1e-6) + vertices.push({ x, y }); + }; + const ctx = { + fillStyle: "", + beginPath() {}, + fill() {}, + closePath() {}, + moveTo: push, + lineTo: push, + }; + new (paintDef as unknown as new () => { + paint(c: unknown, s: { width: number; height: number }, p: unknown): void; + })().paint( + ctx, + { width, height }, + { + get: (n: string) => (values[n] ? { toString: () => values[n] } : undefined), + }, + ); + return vertices; +}; + +/** Distance from `p` to the nearest segment of the closed polyline `ring`. */ +const distanceTo = (p: Point, ring: Point[]) => { + let best = Infinity; + for (let i = 0; i < ring.length; i++) { + const a = ring[i] as Point; + const b = ring[(i + 1) % ring.length] as Point; + const dx = b.x - a.x; + const dy = b.y - a.y; + const t = Math.max( + 0, + Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy || 1)), + ); + best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))); + } + return best; +}; + +describe("pill polyfill", () => { + describe("outline", () => { + it("traces exactly the outline the worklet draws", () => { + for (const [w, h] of [ + [240, 60], + [70, 60], + [60, 240], + [60, 60], + ] as const) { + const ours = pillOutlinePoints(w, h); + const theirs = workletVertices(w, h); + // The worklet closes its path by returning to the first point; ours + // leaves the close to the path's `Z`. + if (theirs.length === ours.length + 1) theirs.pop(); + expect(ours.length, `${w}x${h}`).toBe(theirs.length); + ours.forEach((p, i) => { + expect(p.x).toBeCloseTo((theirs[i] as Point).x, 6); + expect(p.y).toBeCloseTo((theirs[i] as Point).y, 6); + }); + } + }); + + it("honours the shape properties the worklet reads", () => { + const shape = { amt: "3", ease: "4", continuity: "2" }; + const ours = pillOutlinePoints(240, 60, shape); + const theirs = workletVertices(240, 60, { + [PILL_AMT_VAR_NAME]: "3", + [PILL_EASE_VAR_NAME]: "4", + [PILL_CONTINUITY_VAR_NAME]: "2", + }); + expect(ours[10]?.x).toBeCloseTo((theirs[10] as Point).x, 6); + expect(ours[10]?.y).toBeCloseTo((theirs[10] as Point).y, 6); + expect(pillOutlinePath(240, 60, shape)).not.toBe(pillOutlinePath(240, 60)); + }); + + it("is one closed path, and empty for a box with no area", () => { + expect(pillOutlinePath(240, 60)).toMatch(/^M[^Z]*Z$/); + expect(pillOutlinePath(0, 60)).toBe(""); + }); + }); + + describe("background clip", () => { + it("pulls back half a pixel under a border or inset ring, as the worklet's mask does", () => { + expect(pillBackgroundInset({})).toBe(0); + expect(pillBackgroundInset({ [PILL_BORDER_WIDTH_VAR_NAME]: "3px" })).toBe(0.5); + expect(pillBackgroundInset({ [PILL_INSET_RING_WIDTH_VAR_NAME]: "0.25px" })).toBe(0.25); + const clip = pillClipPath(240, 60, {}, 0.5) as string; + const outline = pillOutlinePoints(240, 60); + for (const p of parse(/"([^"]*)"/.exec(clip)?.[1] ?? "")[0] as Point[]) { + expect(distanceTo(p, outline)).toBeCloseTo(0.5, 1); + } + // Even a square, whose stadium would otherwise do. + expect(pillClipPath(48, 48, {}, 0.5)).not.toBeNull(); + }); + + it("is the pill's outline, in the coordinates of its box", () => { + expect(pillClipPath(240, 60)).toBe(`path("${pillOutlinePath(240, 60)}")`); + const shape = { amt: "3" }; + expect(pillClipPath(240, 60, shape)).toBe(`path("${pillOutlinePath(240, 60, shape)}")`); + }); + + it("is not needed for a square, whose stadium is already its circle", () => { + expect(pillClipPath(48, 48)).toBeNull(); + expect(pillClipPath(48, 47)).not.toBeNull(); + expect(pillClipPath(0, 60)).toBeNull(); + }); + }); + + describe("decoration image", () => { + const decorate = (values: Record, width = 240, height = 60) => + pillDecorationImage(width, height, values); + const svg = (image: string | null) => + decodeURIComponent(/^url\("data:image\/svg\+xml,(.*)"\)$/.exec(image ?? "")?.[1] ?? ""); + const strokes = (drawing: string) => + [...drawing.matchAll(/]*style="stroke:([^"]*)"/g)].map( + (m) => ({ points: parse(m[1] as string)[0] as Point[], color: m[2] as string }), + ); + + it("draws nothing without a border, outline, ring or shadow", () => { + expect(decorate({})).toBeNull(); + expect(decorate({ [PILL_OUTLINE_COLOR_VAR_NAME]: "red" })).toBeNull(); + expect(decorate({ [PILL_BOX_SHADOW_VAR_NAME]: "0 0 #0000" })).toBeNull(); + }); + + it("strokes the border as a band its width wide, just inside the outline", () => { + const drawing = svg( + decorate({ [PILL_BORDER_WIDTH_VAR_NAME]: "3px", [PILL_BORDER_COLOR_VAR_NAME]: "red" }), + ); + // Nothing reaches outside the box. + expect(drawing).toContain('width="240" height="60"'); + expect(drawing).toContain('stroke-width="3"'); + const [border] = strokes(drawing); + expect(border?.color).toBe("red"); + const outline = pillOutlinePoints(240, 60); + for (const p of border?.points ?? []) expect(distanceTo(p, outline)).toBeCloseTo(1.5, 1); + for (const p of border?.points ?? []) { + expect(p.x).toBeGreaterThan(1.4); + expect(p.y).toBeGreaterThan(1.4); + } + }); + + it("insets a tall pill's border inwards too, though its outline runs the other way", () => { + const [border] = strokes( + svg( + decorate( + { [PILL_BORDER_WIDTH_VAR_NAME]: "3px", [PILL_BORDER_COLOR_VAR_NAME]: "red" }, + 60, + 240, + ), + ), + ); + for (const p of border?.points ?? []) { + expect(p.x).toBeGreaterThan(1.4); + expect(p.y).toBeGreaterThan(1.4); + } + }); + + it("rings a square too, though its background needs no clip", () => { + expect( + decorate( + { [PILL_BORDER_WIDTH_VAR_NAME]: "2px", [PILL_BORDER_COLOR_VAR_NAME]: "red" }, + 48, + 48, + ), + ).not.toBeNull(); + }); + + it("dashes and dots the way the worklet does, and skips none and hidden", () => { + const border = { [PILL_BORDER_WIDTH_VAR_NAME]: "2px", [PILL_BORDER_COLOR_VAR_NAME]: "red" }; + expect(svg(decorate({ ...border, [PILL_BORDER_STYLE_VAR_NAME]: "dashed" }))).toContain( + 'stroke-dasharray="6 4"', + ); + expect(svg(decorate({ ...border, [PILL_BORDER_STYLE_VAR_NAME]: "dotted" }))).toContain( + 'stroke-dasharray="2 4"', + ); + expect(decorate({ ...border, [PILL_BORDER_STYLE_VAR_NAME]: "none" })).toBeNull(); + expect(decorate({ ...border, [PILL_BORDER_STYLE_VAR_NAME]: " hidden " })).toBeNull(); + }); + + it("strokes an outline beyond its offset, on a canvas grown by the reach", () => { + const drawing = svg( + decorate({ + [PILL_REACH_VAR_NAME]: "6px", + [PILL_OUTLINE_WIDTH_VAR_NAME]: "2px", + [PILL_OUTLINE_OFFSET_VAR_NAME]: "4px", + [PILL_OUTLINE_COLOR_VAR_NAME]: "rgb(255, 0, 0)", + }), + ); + // 4px of offset and 2px of outline on each side. + expect(drawing).toContain('width="252" height="72"'); + expect(drawing).toContain('stroke-width="2"'); + // Centred 5px out from the outline, which itself sits 6px in. + const [band] = strokes(drawing); + expect(band?.color).toBe("rgb(255, 0, 0)"); + const outline = pillOutlinePoints(240, 60).map((p) => ({ x: p.x + 6, y: p.y + 6 })); + for (const p of band?.points ?? []) expect(distanceTo(p, outline)).toBeCloseTo(5, 1); + }); + + it("dashes a dashed outline and skips one with no style", () => { + const outline = { + [PILL_OUTLINE_WIDTH_VAR_NAME]: "2px", + [PILL_OUTLINE_COLOR_VAR_NAME]: "red", + }; + expect(svg(decorate({ ...outline, [PILL_OUTLINE_STYLE_VAR_NAME]: "dashed" }))).toContain( + 'stroke-dasharray="6 4"', + ); + expect(decorate({ ...outline, [PILL_OUTLINE_STYLE_VAR_NAME]: "none" })).toBeNull(); + }); + + it("lays the border, an inset ring inside it, and a ring beyond its offset band", () => { + const drawing = svg( + decorate({ + [PILL_REACH_VAR_NAME]: "5px", + [PILL_BORDER_WIDTH_VAR_NAME]: "1px", + [PILL_BORDER_COLOR_VAR_NAME]: "black", + [PILL_RING_WIDTH_VAR_NAME]: "2px", + [PILL_RING_COLOR_VAR_NAME]: "blue", + [PILL_RING_OFFSET_WIDTH_VAR_NAME]: "3px", + [PILL_RING_OFFSET_COLOR_VAR_NAME]: "white", + [PILL_INSET_RING_WIDTH_VAR_NAME]: "2px", + [PILL_INSET_RING_COLOR_VAR_NAME]: "green", + }), + ); + expect(drawing).toContain('width="250" height="70"'); + // Bottom first: the border, the inset ring, the offset band, the ring. + const drawn = strokes(drawing); + expect(drawn.map((s) => s.color)).toEqual(["black", "green", "white", "blue"]); + const outline = pillOutlinePoints(240, 60).map((p) => ({ x: p.x + 5, y: p.y + 5 })); + const [border, inner, offset, ring] = drawn.map((s) => s.points[0] as Point) as [ + Point, + Point, + Point, + Point, + ]; + expect(distanceTo(border, outline)).toBeCloseTo(0.5, 1); + expect(distanceTo(inner, outline)).toBeCloseTo(2, 1); + // The offset band runs half a pixel on under the ring. + expect(distanceTo(offset, outline)).toBeCloseTo(1.75, 1); + expect(distanceTo(ring, outline)).toBeCloseTo(4, 1); + }); + + it("casts outer shadows outside the outline only, blurred as CSS blurs them", () => { + const drawing = svg( + decorate({ + [PILL_REACH_VAR_NAME]: "20px", + [PILL_BOX_SHADOW_VAR_NAME]: + "inset 0 2px 4px red, 0 0 0 2px blue, 0 4px 8px -2px rgb(0 0 0 / 0.6)", + }), + ); + expect(drawing).toContain('width="280" height="100"'); + // The box with the outline cut out of it. + expect(drawing).toMatch(/ m[1]); + expect(fills).toEqual(["rgb(0 0 0 / 0.6)", "blue"]); + expect(drawing).not.toContain("red"); + // A standard deviation of half the blur radius, and none for a hard one. + expect(drawing).toContain(''); + expect([...drawing.matchAll(/]*style="fill:/g)].map( + (m) => parse(m[1] as string)[0] as Point[], + ); + const top = (points: Point[]) => Math.min(...points.map((p) => p.y)); + expect(top(shadows[0] as Point[])).toBeCloseTo(20 + 2 + 4, 1); + expect(top(shadows[1] as Point[])).toBeCloseTo(20 - 2, 1); + }); + }); + + describe("single band", () => { + const border = { [PILL_BORDER_WIDTH_VAR_NAME]: "3px", [PILL_BORDER_COLOR_VAR_NAME]: "red" }; + const pathData = (clip: string) => /"([^"]*)"/.exec(clip)?.[1] ?? ""; + + it("is its colour through a clip, rather than an image", () => { + const drawing = pillDecoration(240, 60, border); + expect(drawing?.image).toBe("linear-gradient(red, red)"); + expect(drawing?.clip).toMatch(/^path\(evenodd, "/); + }); + + it("clips to a band exactly the border wide, inside the outline", () => { + const clip = pillDecoration(240, 60, border)?.clip as string; + const [outer, inner] = parse(pathData(clip)) as [Point[], Point[]]; + expect(outer.length).toBe(inner.length); + const outline = pillOutlinePoints(240, 60); + for (const p of outer) expect(distanceTo(p, outline)).toBeCloseTo(0, 1); + for (const p of inner) { + expect(distanceTo(p, outline)).toBeCloseTo(3, 1); + expect(p.x).toBeGreaterThan(2.9); + expect(p.y).toBeGreaterThan(2.9); + } + }); + + it("insets a tall pill's band inwards too, though its outline runs the other way", () => { + const clip = pillDecoration(60, 240, border)?.clip as string; + const [, inner] = parse(pathData(clip)) as [Point[], Point[]]; + for (const p of inner) { + expect(p.x).toBeGreaterThan(2.9); + expect(p.y).toBeGreaterThan(2.9); + } + }); + + it("lies on the decoration's grown box, for an outline beyond its offset", () => { + const clip = pillDecoration(240, 60, { + [PILL_REACH_VAR_NAME]: "6px", + [PILL_OUTLINE_WIDTH_VAR_NAME]: "2px", + [PILL_OUTLINE_OFFSET_VAR_NAME]: "4px", + [PILL_OUTLINE_COLOR_VAR_NAME]: "white", + })?.clip as string; + const outline = pillOutlinePoints(240, 60).map((p) => ({ x: p.x + 6, y: p.y + 6 })); + const [outer, inner] = parse(pathData(clip)) as [Point[], Point[]]; + for (const p of outer) expect(distanceTo(p, outline)).toBeCloseTo(6, 1); + for (const p of inner) expect(distanceTo(p, outline)).toBeCloseTo(4, 1); + }); + + it("cuts dashes and dots along the band the way the worklet dashes", () => { + const dashes = (style: string) => + parse( + pathData( + pillDecoration(240, 60, { + [PILL_BORDER_WIDTH_VAR_NAME]: "2px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + [PILL_BORDER_STYLE_VAR_NAME]: style, + })?.clip as string, + ), + ).length; + // Measured along the centre line, 1px inside the outline: a convex + // curve moved in by 1 is shorter by 2π. + const outline = pillOutlinePoints(240, 60); + const perimeter = outline.reduce((sum, p, i) => { + const q = outline[(i + 1) % outline.length] as Point; + return sum + Math.hypot(q.x - p.x, q.y - p.y); + }, 0); + const centre = perimeter - 2 * Math.PI; + // Width 2: dashes are 6 on, 4 off; dots 2 on, 4 off. + expect(dashes("dashed")).toBe(Math.ceil(centre / 10)); + expect(dashes("dotted")).toBe(Math.ceil(centre / 6)); + }); + + it("draws currentColor in the element's colour", () => { + const drawing = pillDecoration(240, 60, { + color: "rgb(1, 2, 3)", + [PILL_BORDER_WIDTH_VAR_NAME]: "2px", + [PILL_BORDER_COLOR_VAR_NAME]: "currentcolor", + }); + expect(drawing?.image).toBe("linear-gradient(rgb(1, 2, 3), rgb(1, 2, 3))"); + }); + + it("leaves more than one band, or any shadow, to the image", () => { + const both = pillDecoration(240, 60, { + ...border, + [PILL_RING_WIDTH_VAR_NAME]: "2px", + [PILL_RING_COLOR_VAR_NAME]: "blue", + }); + expect(both?.clip).toBeNull(); + expect(both?.image).toMatch(/^url\("data:image\/svg\+xml,/); + const shadowed = pillDecoration(240, 60, { + ...border, + [PILL_REACH_VAR_NAME]: "10px", + [PILL_BOX_SHADOW_VAR_NAME]: "0 2px 4px black", + }); + expect(shadowed?.clip).toBeNull(); + expect(pillDecoration(240, 60, {})).toBeNull(); + }); + }); +}); diff --git a/package/src/pill-polyfill.ts b/package/src/pill-polyfill.ts new file mode 100644 index 0000000..3b44ef5 --- /dev/null +++ b/package/src/pill-polyfill.ts @@ -0,0 +1,650 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { paintDef } from "./pill-shape.worklet"; +import { + PILL_AMT_VAR_NAME, + PILL_ATTRIBUTE, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BOX_SHADOW_VAR_NAME, + PILL_CLIP_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_DECORATION_CLIP_VAR_NAME, + PILL_DECORATION_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_INSET_RING_COLOR_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_STYLE_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_POLYFILL_ATTRIBUTE, + PILL_REACH_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_RING_OFFSET_COLOR_VAR_NAME, + PILL_RING_OFFSET_WIDTH_VAR_NAME, + PILL_RING_WIDTH_VAR_NAME, +} from "./variants"; + +export { PILL_POLYFILL_ATTRIBUTE }; + +interface Point { + x: number; + y: number; +} + +/** The custom-property values that shape one pill, as computed strings. */ +export interface PillShapeInput { + amt?: string; + ease?: string; + continuity?: string; +} + +interface PillGeometry { + resolveAngle(props: Lookup): number; + resolveExponent(props: Lookup): number; + resolveContinuity(props: Lookup): 2 | 3; + fittedQuadrant( + long: number, + short: number, + ease: number, + exponent: number, + continuity: 2 | 3, + ): Point[]; + outline(long: number, short: number, quadrant: Point[]): Point[]; + offsetOutline(points: Point[], distance: number): Point[]; + resolveLength(props: Lookup, name: string): number; + decorationOutset(props: Lookup): number; + decorationBands(props: Lookup): { from: number; to: number; color: string; dash: number[] }[]; + decorationShadows( + props: Lookup, + ): { x: number; y: number; blur: number; spread: number; color: string }[]; +} + +interface Lookup { + get(name: string): { toString(): string } | undefined; +} + +// The worklet's own geometry, so the polyfill draws exactly the same shape. +const geometry = new (paintDef as unknown as new () => PillGeometry)(); + +const lookup = (values: Record): Lookup => ({ + get: (name) => { + const value = values[name]; + return value === undefined || value === "" ? undefined : { toString: () => value }; + }, +}); + +/** + * The pill's outline in box coordinates, in the order the worklet draws it: + * clockwise from the leftmost point for a wide pill, and that mirrored — + * anticlockwise from the topmost — for a tall one. + */ +export function pillOutlinePoints( + width: number, + height: number, + shape: PillShapeInput = {}, +): Point[] { + if (width <= 0 || height <= 0) return []; + const props = lookup({ + [PILL_AMT_VAR_NAME]: shape.amt, + [PILL_EASE_VAR_NAME]: shape.ease, + [PILL_CONTINUITY_VAR_NAME]: shape.continuity, + }); + const vertical = height > width; + const long = vertical ? height : width; + const short = vertical ? width : height; + const quadrant = geometry.fittedQuadrant( + long, + short, + geometry.resolveAngle(props), + geometry.resolveExponent(props), + geometry.resolveContinuity(props), + ); + const out: Point[] = []; + for (const p of geometry.outline(long, short, quadrant)) { + const q = vertical ? { x: p.y, y: p.x } : p; + // The mirrored quadrants meet at shared points; drop the repeats, which + // have no direction to offset along. + const last = out[out.length - 1]; + if (!last || Math.abs(last.x - q.x) > 1e-6 || Math.abs(last.y - q.y) > 1e-6) out.push(q); + } + // The outline returns to where it began; `Z` closes it instead. + const first = out[0]; + const last = out[out.length - 1]; + if ( + first && + last && + out.length > 1 && + Math.abs(first.x - last.x) < 1e-6 && + Math.abs(first.y - last.y) < 1e-6 + ) { + out.pop(); + } + return out; +} + +/** + * Two decimals is a hundredth of a pixel, well under anything a clip can + * resolve. + */ +const round = (v: number) => Math.round(v * 100) / 100; +const subpath = (points: Point[]): string => { + // Rounding by hand is several times cheaper than `toFixed`, and this runs + // for every point of every pill on every resize. + const first = points[0] as Point; + let d = `M${round(first.x)} ${round(first.y)}`; + for (let i = 1; i < points.length; i++) { + const p = points[i] as Point; + d += `L${round(p.x)} ${round(p.y)}`; + } + return `${d}Z`; +}; + +/** The pill's outline as SVG path data, in the coordinates of its box. */ +export function pillOutlinePath(width: number, height: number, shape: PillShapeInput = {}): string { + const points = pillOutlinePoints(width, height, shape); + return points.length > 0 ? subpath(points) : ""; +} + +/** + * The `clip-path` for the copy of the element's background the pill shows: + * the pill's outline, pulled in by `inset`. `null` for a square that needs no + * pulling in, whose stadium `border-radius` is already the circle a square + * pill has to be. + */ +export function pillClipPath( + width: number, + height: number, + shape?: PillShapeInput, + inset = 0, +): string | null { + if (width <= 0 || height <= 0 || (width === height && inset <= 0)) return null; + if (inset <= 0) return `path("${pillOutlinePath(width, height, shape)}")`; + return `path("${subpath(geometry.offsetOutline(pillOutlinePoints(width, height, shape), -inset))}")`; +} + +/** + * How far the copy of the background pulls back from the outline: half a + * pixel under a border or an inset ring touching it, so the two never share + * an anti-aliased edge; the stylesheet works out the same for the + * worklet. + */ +export function pillBackgroundInset(decoration: PillDecorationInput): number { + const props = lookup(decoration); + const covered = + geometry.resolveLength(props, PILL_BORDER_WIDTH_VAR_NAME) + + geometry.resolveLength(props, PILL_INSET_RING_WIDTH_VAR_NAME); + return Math.min(Math.max(covered, 0), 0.5); +} + +/** What the pill draws around itself, as computed property values. */ +export type PillDecorationInput = Record; + +/** The properties `PillDecorationInput` is keyed by. */ +export const PILL_DECORATION_PROPERTIES: readonly string[] = [ + PILL_REACH_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_STYLE_VAR_NAME, + PILL_RING_WIDTH_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_RING_OFFSET_WIDTH_VAR_NAME, + PILL_RING_OFFSET_COLOR_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_INSET_RING_COLOR_VAR_NAME, + PILL_BOX_SHADOW_VAR_NAME, + // What `currentColor` in any of them stands for. + "color", +]; + +/** + * Everything the pill draws around its outline — box shadows, border, inset + * ring, ring, outline — as an SVG image for `::after`, which the stylesheet + * sizes to the border box grown by the decoration's reach on every side; + * `null` where there is nothing to draw. The same bands and shadows the + * worklet draws, from the same geometry: each band a stroke along the + * outline moved out to its middle, each shadow the outline grown by its + * spread, blurred, offset, and drawn only outside the outline. An image + * rather than a clip because they come in several colours; it is only + * rebuilt when the pill changes size or decoration. + */ +export function pillDecorationImage( + width: number, + height: number, + decoration: PillDecorationInput, + shape: PillShapeInput = {}, +): string | null { + if (width <= 0 || height <= 0) return null; + const props = lookup(decoration); + const bands = geometry.decorationBands(props); + const shadows = geometry.decorationShadows(props); + if (bands.length === 0 && shadows.length === 0) return null; + const outset = geometry.decorationOutset(props); + const outline = pillOutlinePoints(width, height, shape).map((p) => ({ + x: p.x + outset, + y: p.y + outset, + })); + const w = round(width + 2 * outset); + const h = round(height + 2 * outset); + const pill = subpath(outline); + let defs = ""; + let body = ""; + if (shadows.length > 0) { + defs += ``; + let layers = ""; + // Last first, as CSS stacks them. + [...shadows].reverse().forEach((shadow, i) => { + const id = `s${i}`; + // CSS blurs a shadow with a standard deviation of half its blur radius. + if (shadow.blur > 0) { + defs += ``; + } + const shifted = geometry + .offsetOutline(outline, shadow.spread) + .map((p) => ({ x: p.x + shadow.x, y: p.y + shadow.y })); + const filter = shadow.blur > 0 ? ` filter="url(#${id})"` : ""; + layers += ``; + }); + body += `${layers}`; + } + for (const band of bands) { + const d = subpath(geometry.offsetOutline(outline, (band.from + band.to) / 2)); + const dash = band.dash.length > 0 ? ` stroke-dasharray="${band.dash.join(" ")}"` : ""; + body += ``; + } + const svg = `${defs ? `${defs}` : ""}${body}`; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; +} + +/** One band's outline, its two edges and its centre line, point for point. */ +interface BandEdges { + outer: Point[]; + inner: Point[]; + centre: Point[]; +} + +/** Where `along` falls on `points`, measured by `lengths`: the segment, and how far into it. */ +function locate(lengths: number[], along: number): { index: number; t: number } { + let i = 1; + while (i < lengths.length - 1 && (lengths[i] as number) < along) i++; + const span = (lengths[i] as number) - (lengths[i - 1] as number) || 1; + return { index: i, t: (along - (lengths[i - 1] as number)) / span }; +} + +const lerpOn = (points: Point[], { index, t }: { index: number; t: number }): Point => { + const a = points[(index - 1) % points.length] as Point; + const b = points[index % points.length] as Point; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +}; + +/** + * A band's `clip-path`: the region between its two edges, cut into dashes the + * way the canvas dashes a stroke — measured along its centre line from the + * first point. + */ +function bandClipPath({ outer, inner, centre }: BandEdges, dash: number[]): string { + if (dash.length === 0) { + // One edge, then the other the other way round: with even-odd fill the + // inner one is a hole, leaving the band. + return `path(evenodd, "${subpath(outer)}${subpath([...inner].reverse())}")`; + } + const n = centre.length; + const lengths = [0]; + for (let i = 1; i <= n; i++) { + const a = centre[i - 1] as Point; + const b = centre[i % n] as Point; + lengths.push((lengths[i - 1] as number) + Math.hypot(b.x - a.x, b.y - a.y)); + } + const total = lengths[n] as number; + const [on, off] = dash as [number, number]; + let d = ""; + for (let start = 0; start < total; start += on + off) { + const from = locate(lengths, start); + const to = locate(lengths, Math.min(start + on, total)); + const outerRun: Point[] = [lerpOn(outer, from)]; + const innerRun: Point[] = [lerpOn(inner, from)]; + for (let i = from.index; i < to.index; i++) { + outerRun.push(outer[i % n] as Point); + innerRun.push(inner[i % n] as Point); + } + outerRun.push(lerpOn(outer, to)); + innerRun.push(lerpOn(inner, to)); + d += subpath([...outerRun, ...innerRun.reverse()]); + } + return `path("${d}")`; +} + +/** What `::after` shows: an image, and the clip it is shown through, if any. */ +export interface PillDecorationDrawing { + image: string; + clip: string | null; +} + +/** + * Everything the pill draws around its outline, the cheapest way it can be + * drawn; `null` where there is nothing to draw. + * + * A single band and no shadow — a border, a ring or an outline alone, which + * is most decorated pills — is its colour, clipped to the band: a clip is + * redrawn on resize for next to nothing, where an image is decoded and + * rasterised again, several times slower across many pills. Anything more is + * the image from `pillDecorationImage`. + */ +export function pillDecoration( + width: number, + height: number, + decoration: PillDecorationInput, + shape: PillShapeInput = {}, +): PillDecorationDrawing | null { + if (width <= 0 || height <= 0) return null; + const props = lookup(decoration); + const bands = geometry.decorationBands(props); + const shadows = geometry.decorationShadows(props); + if (bands.length === 0 && shadows.length === 0) return null; + const band = bands[0]; + if (bands.length > 1 || shadows.length > 0 || !band) { + const image = pillDecorationImage(width, height, decoration, shape); + return image ? { image, clip: null } : null; + } + const outset = geometry.decorationOutset(props); + const outline = pillOutlinePoints(width, height, shape).map((p) => ({ + x: p.x + outset, + y: p.y + outset, + })); + // A band reaching in past the middle would cross itself; it ends there. + const deepest = -(Math.min(width, height) / 2 - 0.01); + const edges: BandEdges = { + outer: geometry.offsetOutline(outline, Math.max(band.to, deepest)), + inner: geometry.offsetOutline(outline, Math.max(band.from, deepest)), + centre: geometry.offsetOutline(outline, Math.max((band.from + band.to) / 2, deepest)), + }; + return { + image: `linear-gradient(${band.color}, ${band.color})`, + clip: bandClipPath(edges, band.dash), + }; +} + +export interface PillPolyfillOptions { + /** Class name of the pill utility (default: "squircle-pill"). */ + prefix?: string; + /** Where to look for pills (default: `document`). */ + root?: Document | Element; + /** Run even where paint worklets are supported, e.g. to compare the two. */ + force?: boolean; +} + +export interface PillPolyfill { + /** Recompute one pill, or all of them, after a change no resize reveals. */ + refresh(element?: Element): void; + /** Stop watching, and drop the clips it set. */ + disconnect(): void; +} + +const CLIP_CACHE_SIZE = 512; +const clipCache = new Map(); +const cached = (key: string, make: () => string | null): string | null => { + if (clipCache.has(key)) return clipCache.get(key) as string | null; + const value = make(); + if (clipCache.size >= CLIP_CACHE_SIZE) clipCache.delete(clipCache.keys().next().value as string); + clipCache.set(key, value); + return value; +}; +const drawingCache = new Map(); +const cachedDrawing = ( + key: string, + make: () => PillDecorationDrawing | null, +): PillDecorationDrawing | null => { + if (drawingCache.has(key)) return drawingCache.get(key) as PillDecorationDrawing | null; + const value = make(); + if (drawingCache.size >= CLIP_CACHE_SIZE) { + drawingCache.delete(drawingCache.keys().next().value as string); + } + drawingCache.set(key, value); + return value; +}; + +/** + * Draws pills without the paint worklet, for browsers that lack one. + * + * Every pill is watched with a `ResizeObserver`; on each size it computes the + * outline with the worklet's own geometry and sets it, as a `clip-path` for + * the copy of the background the pill shows and as a drawing of everything + * around it, on the custom properties the pill styles read where + * `` carries the polyfill attribute. Until they are computed the + * background shows as a stadium. + * + * Shape and decoration properties are read when the polyfill first sees a + * pill, whenever its `class` changes, and when it + * gains or loses focus, hover or a press; call `refresh()` after anything + * else that changes them, such as an inline style, a stylesheet change or a + * media query. + * + * Returns `null` without doing anything where paint worklets are supported, + * unless `force` is set. + */ +export function polyfillPills(options: PillPolyfillOptions = {}): PillPolyfill | null { + const supported = (globalThis.CSS as { paintWorklet?: unknown } | undefined)?.paintWorklet; + if (supported && !options.force) return null; + + const root = options.root ?? document; + const doc = root instanceof Document ? root : root.ownerDocument; + const selector = `.${options.prefix ?? "squircle-pill"}, [${PILL_ATTRIBUTE}]`; + const lastKey = new WeakMap(); + const watched = new Set(); + const sizes = new WeakMap(); + + /** + * Each pill's shape settings, read once and kept until its class changes or + * `refresh()` is called. A resize never changes them, and re-reading + * computed style for every pill on every frame of one costs more than the + * geometry does. + */ + interface Settings { + shape: PillShapeInput; + decoration: PillDecorationInput; + decorationKey: string; + inset: number; + } + const settings = new WeakMap(); + const read = (el: Element): Settings => { + const known = settings.get(el); + if (known) return known; + const style = getComputedStyle(el); + const values: PillDecorationInput = {}; + for (const name of PILL_DECORATION_PROPERTIES) values[name] = style.getPropertyValue(name); + // Most pills have none, and are spared working it out on every resize. + const props = lookup(values); + const decorated = + geometry.decorationBands(props).length > 0 || geometry.decorationShadows(props).length > 0; + const fresh: Settings = { + shape: { + amt: style.getPropertyValue(PILL_AMT_VAR_NAME), + ease: style.getPropertyValue(PILL_EASE_VAR_NAME), + continuity: style.getPropertyValue(PILL_CONTINUITY_VAR_NAME), + }, + decoration: decorated ? values : {}, + decorationKey: decorated ? Object.values(values).join("|") : "", + inset: pillBackgroundInset(values), + }; + settings.set(el, fresh); + return fresh; + }; + + /** + * Pills waiting for their shape. A large resize can change more pills than + * fit in one frame; those over the budget keep their previous clip for a + * frame and are picked up on the next, rather than the whole page dropping + * frames while every one is recomputed. + */ + const pending = new Set(); + const FRAME_BUDGET_MS = 6; + let scheduled = false; + const schedule = () => { + if (scheduled || pending.size === 0) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + flush(); + }); + }; + + const flush = () => { + const deadline = performance.now() + FRAME_BUDGET_MS; + // All reads first, then all writes, so no read forces a style recalc + // that an earlier write invalidated. + const updates: [HTMLElement, string | null, PillDecorationDrawing | null][] = []; + for (const el of pending) { + if (performance.now() > deadline && updates.length > 0) break; + pending.delete(el); + const size = sizes.get(el); + if (!size) continue; + const { width, height } = size; + const { shape, decoration, decorationKey, inset } = read(el); + const shapeKey = `${width},${height},${shape.amt},${shape.ease},${shape.continuity}`; + const key = `${shapeKey},${inset},${decorationKey}`; + if (lastKey.get(el) === key) continue; + lastKey.set(el, key); + updates.push([ + el as HTMLElement, + cached(`m:${shapeKey},${inset}`, () => pillClipPath(width, height, shape, inset)), + decorationKey + ? cachedDrawing(`d:${key}`, () => pillDecoration(width, height, decoration, shape)) + : null, + ]); + } + for (const [el, clip, decorated] of updates) { + if (clip) el.style.setProperty(PILL_CLIP_VAR_NAME, clip); + else el.style.removeProperty(PILL_CLIP_VAR_NAME); + if (decorated) el.style.setProperty(PILL_DECORATION_VAR_NAME, decorated.image); + else el.style.removeProperty(PILL_DECORATION_VAR_NAME); + if (decorated?.clip) el.style.setProperty(PILL_DECORATION_CLIP_VAR_NAME, decorated.clip); + else el.style.removeProperty(PILL_DECORATION_CLIP_VAR_NAME); + } + schedule(); + }; + + const apply = (elements: Iterable) => { + for (const el of elements) pending.add(el); + flush(); + }; + + const resizes = new ResizeObserver((entries) => { + for (const entry of entries) { + const box = entry.borderBoxSize?.[0]; + const rect = box ? null : entry.target.getBoundingClientRect(); + sizes.set(entry.target, { + width: box ? box.inlineSize : (rect as DOMRect).width, + height: box ? box.blockSize : (rect as DOMRect).height, + }); + } + apply(entries.map((entry) => entry.target)); + }); + + const watch = (el: Element) => { + if (watched.has(el)) return; + watched.add(el); + resizes.observe(el, { box: "border-box" }); + }; + const unwatch = (el: Element) => { + if (!watched.delete(el)) return; + resizes.unobserve(el); + }; + const scan = (node: ParentNode) => { + if (node instanceof Element && node.matches(selector)) watch(node); + for (const el of node.querySelectorAll(selector)) watch(el); + }; + + const mutations = new MutationObserver((records) => { + const changed = new Set(); + for (const record of records) { + if (record.type === "attributes") { + const el = record.target as Element; + if (el.matches(selector)) { + watch(el); + changed.add(el); + } else unwatch(el); + continue; + } + for (const node of record.addedNodes) if (node instanceof Element) scan(node); + for (const node of record.removedNodes) { + if (!(node instanceof Element)) continue; + unwatch(node); + for (const el of node.querySelectorAll(selector)) unwatch(el); + } + } + for (const el of changed) settings.delete(el); + if (changed.size > 0) apply(changed); + }); + + /* + * State a class change doesn't reveal — `focus-visible:ring-2`, + * `hover:outline-*`, `active:…` — is picked up from the events that change + * it: each pill the event passes through is re-read on the next frame, once + * the new state's styles apply. + */ + const reread = (event: Event) => { + let el = event.target instanceof Element ? event.target.closest(selector) : null; + while (el) { + if (watched.has(el)) { + settings.delete(el); + pending.add(el); + } + el = el.parentElement?.closest(selector) ?? null; + } + schedule(); + }; + const STATE_EVENTS = [ + "focusin", + "focusout", + "pointerover", + "pointerout", + "pointerdown", + "pointerup", + ]; + for (const type of STATE_EVENTS) + root.addEventListener(type, reread, { capture: true, passive: true }); + + doc.documentElement.setAttribute(PILL_POLYFILL_ATTRIBUTE, ""); + scan(root instanceof Document ? root.documentElement : root); + mutations.observe(root, { + childList: true, + subtree: true, + attributes: true, + // Not `style`: the clips are written there, so watching it would re-read + // every pill after every write. + attributeFilter: ["class", PILL_ATTRIBUTE], + }); + + return { + refresh(element) { + const targets = element ? [element] : [...watched]; + for (const el of targets) { + lastKey.delete(el); + settings.delete(el); + } + apply(targets); + }, + disconnect() { + pending.clear(); + for (const type of STATE_EVENTS) root.removeEventListener(type, reread, { capture: true }); + mutations.disconnect(); + resizes.disconnect(); + for (const el of watched) { + (el as HTMLElement).style.removeProperty(PILL_CLIP_VAR_NAME); + (el as HTMLElement).style.removeProperty(PILL_DECORATION_VAR_NAME); + (el as HTMLElement).style.removeProperty(PILL_DECORATION_CLIP_VAR_NAME); + } + watched.clear(); + doc.documentElement.removeAttribute(PILL_POLYFILL_ATTRIBUTE); + }, + }; +} diff --git a/package/src/pill-shape.geometry.test.ts b/package/src/pill-shape.geometry.test.ts new file mode 100644 index 0000000..dc0ce8c --- /dev/null +++ b/package/src/pill-shape.geometry.test.ts @@ -0,0 +1,664 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { describe, expect, it } from "vitest"; +import { paintDef } from "./pill-shape.worklet"; +import { PILL_AMT_VAR_NAME, PILL_CONTINUITY_VAR_NAME, PILL_EASE_VAR_NAME } from "./variants"; + +interface Point { + x: number; + y: number; +} + +/** + * Records the polyline the worklet emits — that polyline is the shape. + */ +class RecordingContext { + fillStyle: unknown = ""; + strokeStyle: unknown = ""; + lineWidth = 0; + lineJoin = ""; + vertices: Point[] = []; + closed = false; + filled = false; + + beginPath(): void {} + fill(): void { + this.filled = true; + } + stroke(): void {} + setLineDash(): void {} + rect(): void {} + moveTo(x: number, y: number): void { + if (!this.filled) this.vertices.push({ x, y }); + } + lineTo(x: number, y: number): void { + if (!this.filled) this.vertices.push({ x, y }); + } + arc(): void { + if (!this.filled) throw new Error("the outline must be emitted as a polyline"); + } + closePath(): void { + if (!this.filled) this.closed = true; + } +} + +const props = (amt?: number, ease?: number, continuity?: number | null) => ({ + get(name: string) { + if (name === PILL_CONTINUITY_VAR_NAME && continuity != null) { + return { toString: () => String(continuity) }; + } + if (name === PILL_AMT_VAR_NAME && amt !== undefined) { + return { toString: () => String(amt) }; + } + if (name === PILL_EASE_VAR_NAME && ease !== undefined) { + return { toString: () => String(ease) }; + } + return undefined; + }, +}); + +/** + * Paints at G2 unless asked otherwise: most of these tests pin down the G2 + * profile, whose formula they check. `null` leaves continuity unset, for the + * worklet's own default. + */ +const paint = ( + width: number, + height: number, + amt?: number, + ease?: number, + continuity: number | null = 2, +): RecordingContext => { + const ctx = new RecordingContext(); + const instance = new (paintDef as new () => { + paint(c: unknown, s: { width: number; height: number }, p: unknown): void; + })(); + instance.paint(ctx, { width, height }, props(amt, ease, continuity)); + return ctx; +}; + +/** Circle through three points; used to measure the cap without assuming it. */ +const circleThrough = (a: Point, b: Point, c: Point): { x: number; y: number; r: number } => { + const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + const sq = (p: Point) => p.x * p.x + p.y * p.y; + const x = (sq(a) * (b.y - c.y) + sq(b) * (c.y - a.y) + sq(c) * (a.y - b.y)) / d; + const y = (sq(a) * (c.x - b.x) + sq(b) * (a.x - c.x) + sq(c) * (b.x - a.x)) / d; + return { x, y, r: Math.hypot(a.x - x, a.y - y) }; +}; + +/** Menger curvature at b, given its neighbours a and c. */ +const curvature = (a: Point, b: Point, c: Point): number => { + const ab = Math.hypot(b.x - a.x, b.y - a.y); + const bc = Math.hypot(c.x - b.x, c.y - b.y); + const ca = Math.hypot(a.x - c.x, a.y - c.y); + if (ab === 0 || bc === 0 || ca === 0) return 0; + const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + return (2 * Math.abs(cross)) / (ab * bc * ca); +}; + +/** Index of the vertex where the top-left outline meets the flat top edge. */ +const junctionIndex = (ctx: RecordingContext): number => { + let best = 0; + let longest = 0; + for (let i = 1; i < ctx.vertices.length; i++) { + const d = Math.hypot( + ctx.vertices[i].x - ctx.vertices[i - 1].x, + ctx.vertices[i].y - ctx.vertices[i - 1].y, + ); + if (d > longest) { + longest = d; + best = i - 1; + } + } + return best; +}; + +/** + * Largest rate of curvature change over the cap, scaled to be size-independent. + * This is what reads as a smooth or an abrupt transition. + */ +const curvatureGradient = (ctx: RecordingContext, r: number): number => { + const j = junctionIndex(ctx); + const v = ctx.vertices; + let worst = 0; + for (let i = 2; i < j; i++) { + const before = curvature(v[i - 2], v[i - 1], v[i]); + const after = curvature(v[i - 1], v[i], v[i + 1]); + const ds = Math.hypot(v[i].x - v[i - 1].x, v[i].y - v[i - 1].y); + if (ds > 1e-9) worst = Math.max(worst, Math.abs(after - before) / ds); + } + return worst * r * r; +}; + +/** + * Angle, in degrees, at which the outline arrives at the flat edge. Curvature + * decaying to zero shows up here as arriving tangent; a cliff arrives steeply. + * Unlike a curvature sample, this does not depend on where vertices happen to + * land. + */ +const arrivalAngle = (ctx: RecordingContext): number => { + const j = junctionIndex(ctx); + const dx = ctx.vertices[j].x - ctx.vertices[j - 1].x; + const dy = ctx.vertices[j].y - ctx.vertices[j - 1].y; + return Math.abs((Math.atan2(dy, dx) * 180) / Math.PI); +}; + +/** Curvature profile over the cap, ending at the flat-edge junction. */ +const capCurvature = (ctx: RecordingContext): number[] => { + const j = junctionIndex(ctx); + const out: number[] = []; + for (let i = 1; i < j; i++) { + out.push(curvature(ctx.vertices[i - 1], ctx.vertices[i], ctx.vertices[i + 1])); + } + return out; +}; + +const WIDTH = 240; +const HEIGHT = 60; +const R = HEIGHT / 2; + +describe("pill-shape worklet geometry", () => { + describe("outline", () => { + it("fills the box exactly and closes", () => { + const ctx = paint(WIDTH, HEIGHT); + const xs = ctx.vertices.map((p) => p.x); + const ys = ctx.vertices.map((p) => p.y); + + expect(Math.min(...xs)).toBeCloseTo(0, 4); + expect(Math.max(...xs)).toBeCloseTo(WIDTH, 4); + expect(Math.min(...ys)).toBeCloseTo(0, 4); + expect(Math.max(...ys)).toBeCloseTo(HEIGHT, 4); + expect(ctx.closed).toBe(true); + }); + + it("keeps flat top and bottom edges between the caps", () => { + const ctx = paint(WIDTH, HEIGHT); + const j = junctionIndex(ctx); + const a = ctx.vertices[j]; + const b = ctx.vertices[j + 1]; + + expect(a.y).toBeCloseTo(0, 6); + expect(b.y).toBeCloseTo(0, 6); + expect(b.x - a.x).toBeGreaterThan(0); + // Mirrored, so the edge is centred. + expect(a.x + b.x).toBeCloseTo(WIDTH, 4); + }); + }); + + describe("caps stay round", () => { + it("keeps a cap radius close to half the height", () => { + const ctx = paint(WIDTH, HEIGHT); + // Derive the circle from three arc samples rather than assuming where + // its centre is — the easing pulls the centre inward. + const arc = ctx.vertices.slice(0, 13); + const [a, b, c] = [arc[0], arc[6], arc[12]]; + const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + const sq = (p: Point) => p.x * p.x + p.y * p.y; + const centre = { + x: (sq(a) * (b.y - c.y) + sq(b) * (c.y - a.y) + sq(c) * (a.y - b.y)) / d, + y: (sq(a) * (c.x - b.x) + sq(b) * (a.x - c.x) + sq(c) * (b.x - a.x)) / d, + }; + const radii = arc.map((p) => Math.hypot(p.x - centre.x, p.y - centre.y)); + + // Every arc sample lies on that one circle... + for (const radius of radii) expect(radius).toBeCloseTo(radii[0], 4); + // ...centred on the pill's axis, at a radius near a true semicircle. + expect(centre.y).toBeCloseTo(R, 4); + expect(radii[0]).toBeGreaterThan(0.9 * R); + expect(radii[0]).toBeLessThanOrEqual(R + 1e-9); + }); + + it("holds constant curvature through the arc, unlike a superellipse", () => { + const profile = capCurvature(paint(WIDTH, HEIGHT)); + const arcPart = profile.slice(0, 10); + for (const k of arcPart) expect(k).toBeCloseTo(arcPart[0], 3); + expect(arcPart[0]).toBeGreaterThan(0.9 / R); + }); + }); + + describe("curvature easing (G2)", () => { + it("ramps curvature to zero instead of dropping it off a cliff", () => { + const eased = capCurvature(paint(WIDTH, HEIGHT)); + const peak = Math.max(...eased); + + // It arrives at the flat edge already flattened... + expect(peak).toBeGreaterThan(0); + expect(arrivalAngle(paint(WIDTH, HEIGHT))).toBeLessThan(3); + // ...and curvature sheds at a bounded rate. Measured per unit arc length, + // so it does not depend on how densely the outline happens to be sampled. + expect(curvatureGradient(paint(WIDTH, HEIGHT), R)).toBeLessThan(3); + }); + + it("falls monotonically once the easing starts", () => { + const eased = capCurvature(paint(WIDTH, HEIGHT)); + const peak = eased.indexOf(Math.max(...eased)); + for (let i = peak + 2; i < eased.length; i++) { + expect(eased[i]).toBeLessThanOrEqual(eased[i - 1] + 1e-9); + } + }); + + it("drops off a cliff at amt = 1, which is the plain stadium", () => { + const stadium = capCurvature(paint(WIDTH, HEIGHT, 1)); + const peak = Math.max(...stadium); + + // A bare semicircle holds 1 / R right up to the edge. + expect(peak).toBeCloseTo(1 / R, 3); + expect(stadium[stadium.length - 1]).toBeCloseTo(1 / R, 3); + // ...so unlike an eased cap it does not arrive flattened. + expect(arrivalAngle(paint(WIDTH, HEIGHT, 1))).toBeGreaterThan( + arrivalAngle(paint(WIDTH, HEIGHT, 3)), + ); + }); + + it("eases more softly as the amount rises", () => { + const softness = [1, 2, 3].map((amt) => { + const ctx = paint(WIDTH, HEIGHT, amt); + // Where the flat edge begins: a softer ease starts turning sooner. + return ctx.vertices[junctionIndex(ctx)].x; + }); + expect(softness[1]).toBeGreaterThan(softness[0]); + expect(softness[2]).toBeGreaterThan(softness[1]); + }); + }); + + describe("fitting the easing to the box", () => { + it("eases harder when the pill is too stubby for the request", () => { + const roomy = paint(400, 60); + const stubby = paint(80, 60); + const easeSpan = (ctx: RecordingContext) => { + const j = junctionIndex(ctx); + // Angular span handed to the easing, via where the arc stops. + return ctx.vertices[j].x; + }; + expect(easeSpan(stubby)).toBeLessThan(easeSpan(roomy)); + }); + + it("draws a square as a circle without searching for an easing", () => { + const worklet = new (paintDef as unknown as new () => { + junctionRatio(...args: unknown[]): number; + fitEasing(r: number, half: number, beta: number, q: number, c: 2 | 3): { beta: number }; + })(); + let probes = 0; + const junctionRatio = worklet.junctionRatio.bind(worklet); + worklet.junctionRatio = (...args) => (probes++, junctionRatio(...args)); + for (const c of [2, 3] as const) { + expect(worklet.fitEasing(50, 50, Math.PI / 3, 6, c).beta).toBe(0); + } + expect(probes).toBe(0); + }); + + it("settles narrow pills in a couple of exact evaluations, from a table built once", () => { + const worklet = new (paintDef as unknown as new () => { + junctionRatio(...args: unknown[]): number; + fitEasing(r: number, half: number, beta: number, q: number, c: 2 | 3): { beta: number }; + })(); + let probes = 0; + const junctionRatio = worklet.junctionRatio.bind(worklet); + worklet.junctionRatio = (...args) => (probes++, junctionRatio(...args)); + // A request no other test makes, so its table is built here. + const wanted = Math.PI / 3 - 0.0123; + for (const [q, c] of [ + [4.5, 2], + [3.5, 3], + ] as const) { + // The table: the request's own reach, then its samples. + worklet.fitEasing(1, 1.0001, wanted, q, c); + probes = 0; + const fits = 200; + for (let i = 1; i <= fits; i++) { + const ratio = 1 + (i / fits) * 0.6; + const { beta } = worklet.fitEasing(1, ratio, wanted, q, c); + // Fits, and gives up no more than the tolerance to do it. + const exponent = (b: number) => + Math.min(Math.max(1 / (1 - ((q - 1) / (q * wanted)) * b), 2), q); + expect(junctionRatio(beta, exponent(beta), c)).toBeLessThanOrEqual(ratio + 1e-12); + const past = beta + 1e-4 * wanted; + expect(junctionRatio(past, exponent(past), c)).toBeGreaterThan(ratio); + } + expect(probes / fits, `G${c}`).toBeLessThan(2); + } + }); + + it("renders a perfect square as a plain circle", () => { + const ctx = paint(100, 100); + const centre = { x: 50, y: 50 }; + for (const p of ctx.vertices) { + expect(Math.hypot(p.x - centre.x, p.y - centre.y)).toBeCloseTo(50, 4); + } + }); + + it("never bulges outside the box, at any amount or ratio", () => { + for (const amt of [1, 2, 3, 6]) { + for (const [w, h] of [ + [240, 60], + [70, 60], + [60, 60], + [60, 240], + [61, 60], + ]) { + for (const p of paint(w, h, amt).vertices) { + expect(p.x, `amt ${amt} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6); + expect(p.x, `amt ${amt} @ ${w}x${h}`).toBeLessThanOrEqual(w + 1e-6); + expect(p.y, `amt ${amt} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6); + expect(p.y, `amt ${amt} @ ${w}x${h}`).toBeLessThanOrEqual(h + 1e-6); + } + } + } + }); + }); + + describe("the ease property", () => { + const arcOf = (ctx: RecordingContext) => + circleThrough(ctx.vertices[0], ctx.vertices[6], ctx.vertices[12]); + const flatEdgeStart = (ctx: RecordingContext) => ctx.vertices[junctionIndex(ctx)].x; + + it("stretches the transition along the flat edge", () => { + const reach = [0, 1, 2, 4].map((q) => flatEdgeStart(paint(WIDTH, HEIGHT, 2, q))); + for (let i = 1; i < reach.length; i++) { + expect(reach[i]).toBeGreaterThan(reach[i - 1]); + } + }); + + it("leaves the cap arc alone while doing so", () => { + // The whole point: a longer transition must not eat into the circle. + const caps = [0, 1, 2, 4].map((q) => arcOf(paint(WIDTH, HEIGHT, 2, q))); + for (const cap of caps) { + expect(cap.y).toBeCloseTo(R, 4); + // Radius barely moves, and stays close to a true semicircle... + expect(cap.r).toBeGreaterThan(0.9 * R); + } + expect(Math.abs(caps[caps.length - 1].r - caps[0].r)).toBeLessThan(0.05 * R); + }); + + it("reaches further than a high amount while keeping rounder ends", () => { + // The motivating case: amt 3 buys reach by spending the arc; a high + // a wide ease buys the same reach and keeps the arc. + const spendy = paint(WIDTH, HEIGHT, 3, 0); + const thrifty = paint(WIDTH, HEIGHT, 1.5, 4); + + expect(flatEdgeStart(thrifty)).toBeGreaterThan(0.9 * flatEdgeStart(spendy)); + expect(arcOf(thrifty).r).toBeGreaterThan(arcOf(spendy).r); + }); + + it("keeps curvature continuous at every ease at or above the clothoid", () => { + for (const q of [0, 1, 2, 6]) { + const profile = capCurvature(paint(WIDTH, HEIGHT, 2, q)); + const peak = Math.max(...profile); + + // Starts on the arc at 1 / cap radius, arrives flat, with no cliff. + expect(peak, `ease ${q}`).toBeGreaterThan(0); + expect(arrivalAngle(paint(WIDTH, HEIGHT, 2, q)), `ease ${q}`).toBeLessThan(3); + expect(curvatureGradient(paint(WIDTH, HEIGHT, 2, q), R), `ease ${q}`).toBeLessThan(3); + } + }); + + it("defaults to two steps above the clothoid", () => { + expect(paint(WIDTH, HEIGHT, 2).vertices).toEqual(paint(WIDTH, HEIGHT, 2, 2).vertices); + // ...which is a real easing, not the clothoid itself. + expect(paint(WIDTH, HEIGHT, 2).vertices).not.toEqual(paint(WIDTH, HEIGHT, 2, 0).vertices); + }); + + it("defaults the amount to 2", () => { + const ctx = paint(WIDTH, HEIGHT); + expect(ctx.vertices).toEqual(paint(WIDTH, HEIGHT, 2, 2).vertices); + expect(ctx.vertices).not.toEqual(paint(WIDTH, HEIGHT, 1, 2).vertices); + }); + + it("honours eases below the clothoid, corner and all", () => { + // Below 2 the transition is shorter and arrives ever more steeply, until + // at 0 it has no length and the arc meets the flat edge at a corner. + // Ugly, but it renders rather than being silently clamped away. + const angles = [-2, -1.5, -1, -0.5, 0].map((q) => arrivalAngle(paint(WIDTH, HEIGHT, 4, q))); + for (let i = 1; i < angles.length; i++) { + expect(angles[i], `ease step ${i}`).toBeLessThan(angles[i - 1]); + } + // The lowest ease is a genuine corner, not an easing. + expect(angles[0]).toBeGreaterThan(30); + }); + + it("stays finite and inside the box at every ease", () => { + for (const q of [-2, -1.75, -1.5, -1, -0.5, 0, 18]) { + for (const [w, h] of [ + [600, 60], + [140, 60], + [60, 60], + [60, 600], + ]) { + const v = paint(w, h, 4, q).vertices; + expect(v.length, `ease ${q} @ ${w}x${h}`).toBeGreaterThan(3); + for (const p of v) { + const tag = `ease ${q} @ ${w}x${h}`; + expect(Number.isFinite(p.x) && Number.isFinite(p.y), tag).toBe(true); + expect(p.x, tag).toBeGreaterThanOrEqual(-1e-6); + expect(p.x, tag).toBeLessThanOrEqual(w + 1e-6); + expect(p.y, tag).toBeGreaterThanOrEqual(-1e-6); + expect(p.y, tag).toBeLessThanOrEqual(h + 1e-6); + } + } + } + }); + + it("still fits the box, and still collapses a square to a circle", () => { + for (const q of [2, 4, 8]) { + for (const [w, h] of [ + [240, 60], + [70, 60], + [60, 240], + ]) { + for (const p of paint(w, h, 3, q).vertices) { + expect(p.x, `ease ${q} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6); + expect(p.x, `ease ${q} @ ${w}x${h}`).toBeLessThanOrEqual(w + 1e-6); + expect(p.y, `ease ${q} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6); + expect(p.y, `ease ${q} @ ${w}x${h}`).toBeLessThanOrEqual(h + 1e-6); + } + } + for (const p of paint(100, 100, 3, q).vertices) { + expect(Math.hypot(p.x - 50, p.y - 50), `ease ${q}`).toBeCloseTo(50, 4); + } + } + }); + }); + + describe("fitting both controls together", () => { + // A pill too narrow for the requested easing has to give something up. + // Surrendering the amount alone drives the curvature rate up like 1 / beta, + // which is what makes a narrow pill look abruptly cornered. + const WIDTHS = [600, 300, 240, 180, 140, 110]; + + it("holds the curvature rate steady as the pill narrows", () => { + const rates = WIDTHS.map((w) => curvatureGradient(paint(w, HEIGHT, 4, 4), R)); + const widest = rates[0]; + for (let i = 0; i < rates.length; i++) { + expect(rates[i], `width ${WIDTHS[i]}`).toBeLessThan(1.3 * widest); + } + }); + + it("gives up ease as well as amount", () => { + // At 180 the requested easing does not fit, so both must come down. + const roomy = paint(600, HEIGHT, 4, 4); + const tight = paint(180, HEIGHT, 4, 4); + + const capOf = (ctx: RecordingContext) => + circleThrough(ctx.vertices[0], ctx.vertices[3], ctx.vertices[6]); + const flatEdgeStart = (ctx: RecordingContext) => ctx.vertices[junctionIndex(ctx)].x; + + // The tight pill still eases — it has not collapsed to a stadium... + expect(flatEdgeStart(tight)).toBeGreaterThan(capOf(tight).r * 1.2); + // ...and it eases over less room than the roomy one. + expect(flatEdgeStart(tight)).toBeLessThan(flatEdgeStart(roomy)); + }); + + it("keeps more of the amount than backing off the amount alone would", () => { + // Trading ease for amount is the whole point: the cap should stay + // meaningfully eased rather than snapping back to a bare semicircle. + const tight = paint(140, HEIGHT, 4, 4); + const profile = capCurvature(tight); + const peak = Math.max(...profile); + + // A real ramp, not a cliff, at a width where beta-only fitting collapses. + expect(peak).toBeGreaterThan(0); + expect(arrivalAngle(tight)).toBeLessThan(3); + expect(profile.length).toBeGreaterThan(8); + }); + + it("leaves a clothoid alone", () => { + // With the ease already at the clothoid there is nothing to trade, + // so narrowing may only reduce the amount. + for (const w of [600, 240, 140, 90]) { + const ctx = paint(w, HEIGHT, 4, 0); + for (const p of ctx.vertices) { + expect(p.x, `width ${w}`).toBeGreaterThanOrEqual(-1e-6); + expect(p.x, `width ${w}`).toBeLessThanOrEqual(w + 1e-6); + } + } + }); + + it("still fits the box while trading the two off", () => { + for (const [amt, ease] of [ + [4, 4], + [3, 6], + [2, 8], + ]) { + for (const [w, h] of [ + [600, 60], + [140, 60], + [70, 60], + [61, 60], + [60, 60], + [60, 600], + ]) { + for (const p of paint(w, h, amt, ease).vertices) { + const tag = `amt ${amt} ease ${ease} @ ${w}x${h}`; + expect(p.x, tag).toBeGreaterThanOrEqual(-1e-6); + expect(p.x, tag).toBeLessThanOrEqual(w + 1e-6); + expect(p.y, tag).toBeGreaterThanOrEqual(-1e-6); + expect(p.y, tag).toBeLessThanOrEqual(h + 1e-6); + } + } + } + }); + }); + + describe("outline sampling", () => { + it("never lets a chord drift far from the curve", () => { + // Sagitta of each segment: ds * dphi / 8. Sampling that starves the + // start of the transition shows up here as a visible facet. + for (const [w, h, amt, ease] of [ + [600, 60, 4, 4], + [240, 60, 4, 4], + [1200, 200, 4, 4], + [600, 60, 2, 0], + ]) { + const ctx = paint(w, h, amt, ease); + const v = ctx.vertices; + const cap = junctionIndex(ctx); + for (let i = 1; i < cap; i++) { + const d1 = { x: v[i].x - v[i - 1].x, y: v[i].y - v[i - 1].y }; + const d2 = { x: v[i + 1].x - v[i].x, y: v[i + 1].y - v[i].y }; + const l1 = Math.hypot(d1.x, d1.y); + if (l1 < 1e-9 || Math.hypot(d2.x, d2.y) < 1e-9) continue; + const turn = Math.abs(Math.atan2(d1.x * d2.y - d1.y * d2.x, d1.x * d2.x + d1.y * d2.y)); + expect((l1 * turn) / 8, `${w}x${h} amt ${amt} ease ${ease}`).toBeLessThan(0.1); + } + } + }); + }); + + describe("vertical pills", () => { + it("caps the short axis and keeps flat sides", () => { + const ctx = paint(60, 240); + const xs = ctx.vertices.map((p) => p.x); + const ys = ctx.vertices.map((p) => p.y); + + expect(Math.min(...xs)).toBeCloseTo(0, 4); + expect(Math.max(...xs)).toBeCloseTo(60, 4); + expect(Math.min(...ys)).toBeCloseTo(0, 4); + expect(Math.max(...ys)).toBeCloseTo(240, 4); + + const j = junctionIndex(ctx); + expect(Math.abs(ctx.vertices[j].x - ctx.vertices[j + 1].x)).toBeLessThan(1e-6); + }); + }); + + describe("G3 continuity", () => { + /** + * The rate curvature changes at each end of the transition, relative to + * the curvature it leaves the arc with, from the worklet's own profile. + * `remaining` is the share of the turn still to come, so its first + * differences are proportional to curvature, and their change to dk/ds. + */ + const endRates = (ease: number, continuity: 2 | 3) => { + const worklet = new (paintDef as unknown as new () => { + profile(q: number, c: 2 | 3): { remaining: number[] }; + })(); + const { remaining } = worklet.profile(ease + 2, continuity); + const n = remaining.length - 1; + const d = (i: number) => remaining[i] - remaining[i + 1]; + return { + leaving: (Math.abs(d(0) - d(1)) / d(0)) * n, + arriving: (Math.abs(d(n - 2) - d(n - 1)) / d(0)) * n, + }; + }; + + it("defaults to G3, at amount 2 and ease 2", () => { + const unset = paint(WIDTH, HEIGHT, undefined, undefined, null).vertices; + expect(unset).toEqual(paint(WIDTH, HEIGHT, 2, 2, 3).vertices); + expect(unset).not.toEqual(paint(WIDTH, HEIGHT, 2, 2, 2).vertices); + }); + + it("leaves the arc with curvature still flat, where G2 is already falling", () => { + for (const ease of [0, 1, 4]) { + // G2 sheds curvature at (q - 1) / R from the very first step. + expect(endRates(ease, 2).leaving, `G2 ease ${ease}`).toBeGreaterThan(0.9); + expect(endRates(ease, 3).leaving, `G3 ease ${ease}`).toBeLessThan(0.05); + } + }); + + it("arrives at the edge flat too, once the ease is above the clothoid", () => { + for (const ease of [1, 4]) { + expect(endRates(ease, 3).arriving, `G3 ease ${ease}`).toBeLessThan(0.05); + } + }); + + it("still ramps curvature to zero and arrives flat", () => { + const ctx = paint(WIDTH, HEIGHT, 2, 1, 3); + expect(arrivalAngle(ctx)).toBeLessThan(3); + expect(curvatureGradient(ctx, R)).toBeLessThan(3); + const eased = capCurvature(ctx); + const peak = eased.indexOf(Math.max(...eased)); + for (let i = peak + 2; i < eased.length; i++) { + expect(eased[i]).toBeLessThanOrEqual(eased[i - 1] + 1e-9); + } + }); + + it("fits the box and collapses a square to a circle", () => { + for (const [amt, ease] of [ + [2, 1], + [4, 4], + [3, -2], + ]) { + for (const [w, h] of [ + [600, 60], + [140, 60], + [70, 60], + [60, 600], + ]) { + for (const p of paint(w, h, amt, ease, 3).vertices) { + const tag = `G3 amt ${amt} ease ${ease} @ ${w}x${h}`; + expect(Number.isFinite(p.x) && Number.isFinite(p.y), tag).toBe(true); + expect(p.x, tag).toBeGreaterThanOrEqual(-1e-6); + expect(p.x, tag).toBeLessThanOrEqual(w + 1e-6); + expect(p.y, tag).toBeGreaterThanOrEqual(-1e-6); + expect(p.y, tag).toBeLessThanOrEqual(h + 1e-6); + } + } + } + for (const p of paint(100, 100, 3, 1, 3).vertices) { + expect(Math.hypot(p.x - 50, p.y - 50)).toBeCloseTo(50, 4); + } + }); + }); +}); diff --git a/package/src/pill-shape.test.ts b/package/src/pill-shape.test.ts new file mode 100644 index 0000000..9bf1874 --- /dev/null +++ b/package/src/pill-shape.test.ts @@ -0,0 +1,654 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { describe, expect, it } from "vitest"; +import { renderPillCss } from "./pill-css"; +import { decorationDef, paintDef } from "./pill-shape.worklet"; +import { + CSS_NAMESPACE, + DEFAULT_PILL_AMT, + DEFAULT_PILL_CONTINUITY, + DEFAULT_PILL_EASE, + PILL_AMT_VAR_NAME, + PILL_ATTRIBUTE, + PILL_BACKGROUND_INSET_VAR_NAME, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BOX_SHADOW_VAR_NAME, + PILL_CLIP_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_DECORATION_CLIP_VAR_NAME, + PILL_DECORATION_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_INSET_RING_COLOR_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_STYLE_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_POLYFILL_ATTRIBUTE, + PILL_REACH_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_RING_OFFSET_COLOR_VAR_NAME, + PILL_RING_OFFSET_WIDTH_VAR_NAME, + PILL_RING_WIDTH_VAR_NAME, + PILL_SHADOW_REACH_VAR_NAME, + PILL_WORKLET_ATTRIBUTE, +} from "./variants"; + +// The standalone stylesheet is rendered from the same rules the Tailwind +// utility carries, so this is the sheet `dist/squircle-pill.css` ships. +const stylesheet = renderPillCss(); + +const registeredProperties = (css: string): string[] => + [...css.matchAll(/@property\s+(--[\w-]+)/g)].map((m) => m[1]); + +const registration = (css: string, name: string): string | undefined => + new RegExp(`@property\\s+${name}\\s*\\{([^}]*)\\}`).exec(css)?.[1]; + +const initialValueOf = (css: string, name: string): string | undefined => + new RegExp(`@property\\s+${name}\\s*\\{[^}]*initial-value:\\s*([^;]+);`).exec(css)?.[1].trim(); + +/** The declarations of the first rule whose selector is exactly `selector`. */ +const ruleBody = (css: string, selector: string): string | undefined => { + const start = css.indexOf(`\n${selector} {`); + if (start < 0) return undefined; + const open = css.indexOf("{", start); + return css.slice(open + 1, css.indexOf("}", open)); +}; + +const inputProperties = (paintDef as unknown as { inputProperties: string[] }).inputProperties; +const decorationInputs = (decorationDef as unknown as { inputProperties: string[] }) + .inputProperties; +// What either paint reads: the shape's or the decoration's. +const customInputs = [...inputProperties, ...decorationInputs].filter((p) => p.startsWith("--")); + +const SHAPE = `[${PILL_ATTRIBUTE}]`; +const WORKLET = `:where(:root[${PILL_WORKLET_ATTRIBUTE}]) ${SHAPE}`; +const POLYFILL = `:where(:root[${PILL_POLYFILL_ATTRIBUTE}]) ${SHAPE}`; + +type Point = { x: number; y: number }; +type Drawn = { + /** Every path traced, in order, with what was done with it. */ + paths: { points: Point[]; op?: "fill" | "stroke" | "clip"; rule?: string }[]; + strokes: { color: string; width: number; dash: number[]; points: Point[] }[]; + shadows: { color: string; blur: number; x: number; y: number; points: Point[] }[]; +}; + +type Painter = new () => { + paint(c: unknown, s: { width: number; height: number }, p: unknown): void; +}; + +/** Paints with a canvas that records what it was asked to draw. */ +const record = ( + def: unknown, + props: Record | undefined, + width = 240, + height = 60, +): Drawn => { + const drawn: Drawn = { paths: [], strokes: [], shadows: [] }; + let current: Point[] = []; + let dash: number[] = []; + const ctx = { + fillStyle: "", + strokeStyle: "", + lineWidth: 0, + lineJoin: "", + shadowColor: "", + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + save() {}, + restore() {}, + beginPath() { + current = []; + drawn.paths.push({ points: current }); + }, + moveTo: (x: number, y: number) => current.push({ x, y }), + lineTo: (x: number, y: number) => current.push({ x, y }), + rect: (x: number, y: number, w: number, h: number) => + current.push({ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }), + closePath() {}, + setLineDash(d: number[]) { + dash = d; + }, + fill() { + drawn.paths[drawn.paths.length - 1].op = "fill"; + if (ctx.shadowColor) { + drawn.shadows.push({ + color: ctx.shadowColor, + blur: ctx.shadowBlur, + // Where the shadow lands: the shape is drawn off the canvas and its + // shadow thrown back. + x: ctx.shadowOffsetX, + y: ctx.shadowOffsetY, + points: current.map((p) => ({ x: p.x + ctx.shadowOffsetX, y: p.y + ctx.shadowOffsetY })), + }); + } + }, + stroke() { + drawn.paths[drawn.paths.length - 1].op = "stroke"; + drawn.strokes.push({ color: ctx.strokeStyle, width: ctx.lineWidth, dash, points: current }); + }, + clip(rule?: string) { + const path = drawn.paths[drawn.paths.length - 1]; + path.op = "clip"; + path.rule = rule; + }, + }; + const lookup = { + get: (n: string) => (props?.[n] !== undefined ? { toString: () => props[n] } : undefined), + }; + new (def as Painter)().paint(ctx, { width, height }, lookup); + return drawn; +}; + +const bounds = (points: Point[]) => ({ + left: Math.min(...points.map((p) => p.x)), + right: Math.max(...points.map((p) => p.x)), + top: Math.min(...points.map((p) => p.y)), + bottom: Math.max(...points.map((p) => p.y)), +}); + +describe("pill-shape worklet contract", () => { + it("namespaces every property it owns", () => { + // `--pill-*` is the kind of name a design system is likely to have taken. + // The prefix is fixed at build time, so the worklet, the plugins and the + // stylesheet all have to derive it from the same value. + const prefix = `--${CSS_NAMESPACE}-pill-`; + for (const name of customInputs) { + expect(name, `${name} is not namespaced`).toContain(prefix); + } + for (const name of registeredProperties(stylesheet)) { + expect(name, `${name} is not namespaced`).toContain(prefix); + } + expect(PILL_ATTRIBUTE).toBe(`data-${CSS_NAMESPACE}-pill`); + expect(PILL_WORKLET_ATTRIBUTE).toBe(`data-${CSS_NAMESPACE}-pill-worklet`); + }); + + it("reads exactly the properties it needs, and no more", () => { + // Every input is gathered for every pill on every resize, so the shape — + // painted for every pill — reads only what shapes it. + expect(inputProperties).toEqual([ + PILL_AMT_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_BACKGROUND_INSET_VAR_NAME, + ]); + expect(decorationInputs).toEqual([ + PILL_AMT_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_REACH_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_STYLE_VAR_NAME, + PILL_RING_WIDTH_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_RING_OFFSET_WIDTH_VAR_NAME, + PILL_RING_OFFSET_COLOR_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_INSET_RING_COLOR_VAR_NAME, + PILL_BOX_SHADOW_VAR_NAME, + // What `currentColor` stands for. + "color", + ]); + }); + + describe("against squircle-pill.css", () => { + it("registers every shaping property the worklet reads", () => { + const registered = registeredProperties(stylesheet); + for (const name of [PILL_AMT_VAR_NAME, PILL_EASE_VAR_NAME, PILL_CONTINUITY_VAR_NAME]) { + expect(registered, `${name} must be registered`).toContain(name); + } + }); + + it("registers nothing that is neither read nor fed into something read", () => { + // Registrations for properties nothing consumes are dead plumbing: they + // read as configuration but change nothing. + const referenced = new Set([...stylesheet.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1])); + for (const name of registeredProperties(stylesheet)) { + const consumed = customInputs.includes(name) || referenced.has(name); + expect(consumed, `${name} is registered but never consumed`).toBe(true); + } + }); + + it("registers everything the pseudo-elements read as inheriting", () => { + // WebKit never restyles a pseudo-element when a non-inheriting property + // it pulls down with `inherit` changes, so a border fed that way goes + // stale in Safari: a hover colour never shows, nor the polyfill's clip. + for (const name of registeredProperties(stylesheet)) { + expect(registration(stylesheet, name), name).toContain("inherits: true"); + } + expect(stylesheet).not.toMatch(/--[\w-]+: inherit;/); + // A `` registration is what hands the worklet a px value however + // the width was written. + for (const name of [PILL_BORDER_WIDTH_VAR_NAME, PILL_REACH_VAR_NAME]) { + expect(registration(stylesheet, name), name).toContain('syntax: ""'); + } + expect(registration(stylesheet, PILL_BORDER_COLOR_VAR_NAME)).toContain('syntax: ""'); + }); + + it("never masks or clips the element itself", () => { + // A mask or clip on the element is the user's: Tailwind's `mask-*` and + // `clip-path` utilities apply as they would anywhere, and a drop shadow + // follows the pill-shaped copy of the background on its own. + for (const selector of [SHAPE, WORKLET, POLYFILL]) { + const body = ruleBody(stylesheet, selector) ?? ""; + expect(body, selector).not.toMatch(/mask|clip-path/); + } + }); + + it("hides the element's own background and shadows, so no stadium shows past the pill", () => { + // The stadium `border-radius` sits a few pixels outside the pill near + // the caps: a background painted into it is the hairline between the + // pill and its shadow. Important, so neither a utility nor an inline + // `background` shorthand, which resets the clip, can bring it back. + const body = ruleBody( + stylesheet, + `:where(:root[${PILL_WORKLET_ATTRIBUTE}], :root[${PILL_POLYFILL_ATTRIBUTE}]) ${SHAPE}`, + ); + expect(body).toContain("background-clip: text !important;"); + expect(body).toContain("-webkit-background-clip: text !important;"); + expect(body).toContain("box-shadow: none !important;"); + }); + + it("paints a copy of the background under the content, shaped to the pill", () => { + const before = ruleBody(stylesheet, `${WORKLET}::before`) ?? ""; + expect(before).toContain("background: inherit;"); + // After the shorthand, which would otherwise inherit the element's + // `text` clip. + expect(before.indexOf("background-clip: border-box")).toBeGreaterThan( + before.indexOf("background: inherit"), + ); + expect(before).toContain("z-index: -1;"); + expect(before).toContain(`box-shadow: var(${PILL_BOX_SHADOW_VAR_NAME}, none);`); + expect(before).toContain("mask-image: paint(pill-shape);"); + expect(before).toContain("mask-size: 100% 100%;"); + // Covering the border box, which the background is positioned against. + expect(before).toContain(`inset: calc(-1 * var(${PILL_BORDER_WIDTH_VAR_NAME}));`); + // `isolation` keeps it above the element's own stacking context's + // background and below its content. + expect(ruleBody(stylesheet, WORKLET)).toContain("isolation: isolate;"); + }); + + it("shapes only once the worklet is known to have loaded", () => { + // `@supports (mask-image: paint(pill-shape))` is true for any paint + // name, loaded or not, so a mask gated on it alone would erase every + // pill's background the moment the worklet failed to load. + expect(stylesheet).not.toContain("@supports"); + const painted = [...stylesheet.matchAll(/^([^\n{]+)\{[^}]*paint\(/gm)].map((m) => + m[1].trim(), + ); + expect(painted.length).toBe(2); + for (const selector of painted) { + expect(selector).toContain(`:where(:root[${PILL_WORKLET_ATTRIBUTE}])`); + } + }); + + it("takes the polyfill's shape and drawing only where the polyfill runs", () => { + // Until they are computed, the copy of the background shows as a + // stadium (`none`) and nothing is drawn around it. + expect(ruleBody(stylesheet, `${POLYFILL}::before`)).toContain( + `clip-path: var(${PILL_CLIP_VAR_NAME}, none);`, + ); + const after = ruleBody(stylesheet, `${POLYFILL}::after`); + expect(after).toContain(`background-image: var(${PILL_DECORATION_VAR_NAME}, none);`); + // A single band is its colour through a clip. + expect(after).toContain(`clip-path: var(${PILL_DECORATION_CLIP_VAR_NAME}, none);`); + // Clips, not masks, for the shape: a mask image is decoded and + // rasterised per resize. + expect(stylesheet).not.toContain("mask-image: var("); + }); + + it("is a plain stadium on every branch", () => { + // The whole fallback without the worklet, and what a focus ring the + // pill doesn't draw follows with it. Never a percentage: `50%` is an + // ellipse on any non-square element. Never a superellipse: on a pill + // the cap is the whole shape, so reshaping it changes the silhouette. + expect(ruleBody(stylesheet, SHAPE)).toContain("border-radius: calc(infinity * 1px);"); + expect(stylesheet).not.toContain("border-radius: 50%"); + expect(stylesheet).not.toContain("corner-shape"); + }); + + it("drives a real border from the pill's own properties, and draws its own over it", () => { + // Without the worklet that real border is the pill's border, a stadium + // ring; with it, its colour is suppressed and its width reserves room + // for the drawn one, on a box grown by how far the decoration reaches. + const shape = ruleBody(stylesheet, SHAPE); + expect(shape).toContain(`border-width: var(${PILL_BORDER_WIDTH_VAR_NAME});`); + expect(shape).toContain(`border-color: var(${PILL_BORDER_COLOR_VAR_NAME});`); + expect(ruleBody(stylesheet, WORKLET)).toContain("border-color: transparent;"); + const after = ruleBody(stylesheet, `${WORKLET}::after`); + expect(after).toContain( + `inset: calc(-1 * (var(${PILL_BORDER_WIDTH_VAR_NAME}) + var(${PILL_REACH_VAR_NAME})));`, + ); + expect(after).toContain("background: paint(pill-decoration);"); + }); + + it("hands the pseudo-elements nothing custom by an explicit inherit", () => { + for (const pseudo of ["::before", "::after"]) { + for (const branch of [WORKLET, POLYFILL]) { + expect(ruleBody(stylesheet, `${branch}${pseudo}`)).not.toMatch(/--[\w-]+: inherit/); + } + } + }); + + it("starts each pill with no decoration of its own, at zero specificity", () => { + // Same default colours a real border has, and a value set any other way + // wins; the reset keeps a pill nested in a decorated one from drawing + // its parent's border, rings and shadows. + const block = ruleBody(stylesheet, `:where(${SHAPE})`); + expect(block).toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: 0px;`); + expect(block).toContain(`${PILL_BORDER_COLOR_VAR_NAME}: currentColor;`); + expect(block).toContain(`${PILL_OUTLINE_WIDTH_VAR_NAME}: 0px;`); + expect(block).toContain(`${PILL_RING_WIDTH_VAR_NAME}: 0px;`); + expect(block).toContain(`${PILL_INSET_RING_WIDTH_VAR_NAME}: 0px;`); + expect(block).toContain(`${PILL_SHADOW_REACH_VAR_NAME}: 0px;`); + expect(block).toContain(`${PILL_BOX_SHADOW_VAR_NAME}: none;`); + expect(block).toContain(`${PILL_CLIP_VAR_NAME}: initial;`); + expect(block).toContain(`${PILL_DECORATION_VAR_NAME}: initial;`); + expect(block).toContain(`${PILL_DECORATION_CLIP_VAR_NAME}: initial;`); + }); + + it("reaches as far as the outline, the ring or a shadow does", () => { + const block = ruleBody(stylesheet, `:where(${SHAPE})`); + expect(block).toContain( + `${PILL_REACH_VAR_NAME}: max(0px, var(${PILL_OUTLINE_OFFSET_VAR_NAME}) + var(${PILL_OUTLINE_WIDTH_VAR_NAME}), var(${PILL_RING_OFFSET_WIDTH_VAR_NAME}) + var(${PILL_RING_WIDTH_VAR_NAME}), var(${PILL_SHADOW_REACH_VAR_NAME}));`, + ); + }); + + it("starts the properties where the worklet's own fallbacks do", () => { + expect(initialValueOf(stylesheet, PILL_AMT_VAR_NAME)).toBe(String(DEFAULT_PILL_AMT)); + expect(initialValueOf(stylesheet, PILL_EASE_VAR_NAME)).toBe(String(DEFAULT_PILL_EASE)); + expect(initialValueOf(stylesheet, PILL_CONTINUITY_VAR_NAME)).toBe( + String(DEFAULT_PILL_CONTINUITY), + ); + }); + + it("assigns no custom property that nothing consumes", () => { + // Catches leftovers like `--pill-width: 100%` that outlived the paint + // function that once consumed them. A property is legitimate if the + // worklet reads it, or if the sheet itself feeds it into one that is + // read. + const assigned = [...stylesheet.matchAll(/^\s*(--[\w-]+):/gm)].map((m) => m[1]); + const referenced = new Set([...stylesheet.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1])); + for (const name of assigned) { + const consumed = customInputs.includes(name) || referenced.has(name); + expect(consumed, `${name} is assigned but nothing consumes it`).toBe(true); + } + }); + + it("knows nothing about Tailwind", () => { + // A plain-CSS consumer sets the pill's own border-style property; a + // sheet that bridged `--tw-border-style` here would shadow it with a + // variable that never exists outside Tailwind. That bridge belongs to + // the Tailwind plugin. + expect(stylesheet).not.toContain("--tw-"); + expect(stylesheet).toContain(`var(${PILL_BORDER_STYLE_VAR_NAME}, solid)`); + }); + }); + + describe("the shape", () => { + it("fills the pill and nothing else, whatever decoration is set", () => { + const drawn = record(paintDef, { + [PILL_BORDER_WIDTH_VAR_NAME]: "4px", + [PILL_RING_WIDTH_VAR_NAME]: "2px", + [PILL_BOX_SHADOW_VAR_NAME]: "0 4px 8px black", + }); + expect(drawn.paths).toHaveLength(1); + expect(drawn.paths[0].op).toBe("fill"); + expect(drawn.strokes).toEqual([]); + const b = bounds(drawn.paths[0].points); + expect(b.left).toBeCloseTo(0, 9); + expect(b.right).toBeCloseTo(240, 9); + expect(b.top).toBeCloseTo(0, 9); + expect(b.bottom).toBeCloseTo(60, 9); + }); + + it("pulls in by the background inset, to clear a border's anti-aliased edge", () => { + const drawn = record(paintDef, { [PILL_BACKGROUND_INSET_VAR_NAME]: "0.5px" }); + const b = bounds(drawn.paths[0].points); + expect(b.left).toBeCloseTo(0.5, 5); + expect(b.top).toBeCloseTo(0.5, 5); + expect(b.right).toBeCloseTo(239.5, 5); + expect(b.bottom).toBeCloseTo(59.5, 5); + }); + + it("works the background inset out from the border and inset ring", () => { + expect(ruleBody(stylesheet, `:where(${SHAPE})`)).toContain( + `${PILL_BACKGROUND_INSET_VAR_NAME}: min(0.5px, var(${PILL_BORDER_WIDTH_VAR_NAME}) + var(${PILL_INSET_RING_WIDTH_VAR_NAME}));`, + ); + expect(registration(stylesheet, PILL_BACKGROUND_INSET_VAR_NAME)).toContain( + 'syntax: ""', + ); + }); + + it("draws nothing on an empty box", () => { + expect(record(paintDef, undefined, 0, 60).paths).toEqual([]); + }); + }); + + describe("the decoration", () => { + it("draws nothing at all without a border, ring, outline or shadow", () => { + expect(record(decorationDef, {}).paths).toEqual([]); + // Nor for widths that resolve to nothing, or colours that can't show. + expect( + record(decorationDef, { + [PILL_BORDER_WIDTH_VAR_NAME]: "0px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + [PILL_RING_WIDTH_VAR_NAME]: "2px", + [PILL_RING_COLOR_VAR_NAME]: "transparent", + [PILL_BOX_SHADOW_VAR_NAME]: "0 0 #0000, 0 4px 8px rgb(0 0 0 / 0)", + }).paths, + ).toEqual([]); + }); + + it("draws nothing for border-style none or hidden", () => { + for (const style of ["none", "hidden"]) { + const drawn = record(decorationDef, { + [PILL_BORDER_WIDTH_VAR_NAME]: "3px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + [PILL_BORDER_STYLE_VAR_NAME]: style, + }); + expect(drawn.paths, style).toEqual([]); + } + }); + + it("strokes the border as a band exactly its width wide, just inside the outline", () => { + const drawn = record(decorationDef, { + [PILL_BORDER_WIDTH_VAR_NAME]: "4px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + }); + expect(drawn.strokes).toHaveLength(1); + const [border] = drawn.strokes; + expect(border.color).toBe("red"); + expect(border.width).toBe(4); + expect(border.dash).toEqual([]); + // Centred 2px inside the outline. + const b = bounds(border.points); + expect(b.left).toBeCloseTo(2, 5); + expect(b.top).toBeCloseTo(2, 5); + expect(b.right).toBeCloseTo(238, 5); + expect(b.bottom).toBeCloseTo(58, 5); + }); + + it("draws currentColor in the element's colour", () => { + // A registered colour keeps `currentcolor` as its computed value, which + // a canvas would otherwise draw black, or not at all. + const drawn = record(decorationDef, { + color: "rgb(1, 2, 3)", + [PILL_REACH_VAR_NAME]: "20px", + [PILL_BORDER_WIDTH_VAR_NAME]: "2px", + [PILL_BORDER_COLOR_VAR_NAME]: "currentcolor", + [PILL_RING_WIDTH_VAR_NAME]: "2px", + [PILL_RING_COLOR_VAR_NAME]: "color-mix(in srgb, currentColor 50%, transparent)", + [PILL_BOX_SHADOW_VAR_NAME]: "0 4px 8px", + }); + expect(drawn.strokes.map((s) => s.color)).toEqual([ + "rgb(1, 2, 3)", + "color-mix(in srgb, rgb(1, 2, 3) 50%, transparent)", + ]); + expect(drawn.shadows.map((s) => s.color)).toEqual(["rgb(1, 2, 3)"]); + }); + + it("dashes and dots the border the way a real border does", () => { + const dash = (style: string) => + record(decorationDef, { + [PILL_BORDER_WIDTH_VAR_NAME]: "2px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + [PILL_BORDER_STYLE_VAR_NAME]: style, + }).strokes[0].dash; + expect(dash("dashed")).toEqual([6, 4]); + expect(dash("dotted")).toEqual([2, 4]); + }); + + it("lays bands bottom first: border, inset ring, ring offset, ring, outline", () => { + const reach = 2 + 3; + const drawn = record( + decorationDef, + { + [PILL_REACH_VAR_NAME]: `${reach}px`, + [PILL_BORDER_WIDTH_VAR_NAME]: "1px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + [PILL_INSET_RING_WIDTH_VAR_NAME]: "2px", + [PILL_INSET_RING_COLOR_VAR_NAME]: "green", + [PILL_RING_WIDTH_VAR_NAME]: "3px", + [PILL_RING_COLOR_VAR_NAME]: "blue", + [PILL_RING_OFFSET_WIDTH_VAR_NAME]: "2px", + [PILL_RING_OFFSET_COLOR_VAR_NAME]: "white", + [PILL_OUTLINE_WIDTH_VAR_NAME]: "1px", + [PILL_OUTLINE_OFFSET_VAR_NAME]: "1px", + [PILL_OUTLINE_COLOR_VAR_NAME]: "black", + }, + 240 + 2 * reach, + 60 + 2 * reach, + ); + // Each lower band runs half a pixel on under any band meeting it; see + // "closes seams". + expect(drawn.strokes.map((s) => [s.color, s.width])).toEqual([ + ["red", 2], + ["green", 2], + ["white", 2.5], + ["blue", 3.5], + ["black", 1], + ]); + // Each centred on its band, measured out from the outline, which sits + // `reach` in from the edge of the grown box. + const tops = drawn.strokes.map((s) => bounds(s.points).top); + const expected = [-0.5, -2, 1.25, 3.25, 1.5].map((d) => reach - d); + tops.forEach((top, i) => expect(top).toBeCloseTo(expected[i], 5)); + }); + + it("closes seams: a lower band runs on under the band that meets it", () => { + const bands = (props: Record) => + new (decorationDef as unknown as new () => { + decorationBands(p: unknown): { from: number; to: number; color: string }[]; + })() + .decorationBands({ + get: (n: string) => (props[n] !== undefined ? { toString: () => props[n] } : undefined), + }) + .map(({ from, to, color }) => [color, from, to]); + const ring = { [PILL_RING_WIDTH_VAR_NAME]: "2px", [PILL_RING_COLOR_VAR_NAME]: "blue" }; + // Alone, a ring runs on over the background it meets. + expect(bands(ring)).toEqual([["blue", -0.5, 2]]); + // Over a border, the border runs on under it instead, and the + // background pulls back under the border. + expect( + bands({ + ...ring, + [PILL_BORDER_WIDTH_VAR_NAME]: "1px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + }), + ).toEqual([ + ["red", -1, 0.5], + ["blue", 0, 2], + ]); + // Never under a dashed band, whose gaps would show it. + expect( + bands({ + [PILL_BORDER_WIDTH_VAR_NAME]: "1px", + [PILL_BORDER_COLOR_VAR_NAME]: "red", + [PILL_OUTLINE_WIDTH_VAR_NAME]: "2px", + [PILL_OUTLINE_COLOR_VAR_NAME]: "blue", + [PILL_OUTLINE_STYLE_VAR_NAME]: "dashed", + }), + ).toEqual([ + ["red", -1, 0], + ["blue", 0, 2], + ]); + }); + + it("rings a tall pill on the same side, though its outline runs the other way", () => { + const drawn = record( + decorationDef, + { [PILL_BORDER_WIDTH_VAR_NAME]: "4px", [PILL_BORDER_COLOR_VAR_NAME]: "red" }, + 60, + 240, + ); + const b = bounds(drawn.strokes[0].points); + expect(b.left).toBeCloseTo(2, 5); + expect(b.top).toBeCloseTo(2, 5); + }); + + it("casts outer shadows only outside the outline, last first, as CSS stacks them", () => { + const reach = 20; + const drawn = record( + decorationDef, + { + [PILL_REACH_VAR_NAME]: `${reach}px`, + [PILL_BOX_SHADOW_VAR_NAME]: + "inset 0 2px 4px red, 0 0 0 2px blue, 0 4px 8px -2px rgb(0 0 0 / 0.6)", + }, + 240 + 2 * reach, + 60 + 2 * reach, + ); + // The clip: the whole box, with the outline cut out of it. + const clip = drawn.paths.find((p) => p.op === "clip"); + expect(clip?.rule).toBe("evenodd"); + // The inset one is the copy of the background's to paint. + expect(drawn.shadows.map((s) => s.color)).toEqual(["rgb(0 0 0 / 0.6)", "blue"]); + const [soft, solid] = drawn.shadows; + expect(soft.blur).toBe(8); + expect(soft.y).toBe(4); + // Thrown back exactly onto the canvas: the shape grown by its spread, + // shifted by its offset. + const s = bounds(solid.points); + expect(s.left).toBeCloseTo(reach - 2, 5); + expect(s.right).toBeCloseTo(reach + 242, 5); + const d = bounds(soft.points); + expect(d.top).toBeCloseTo(reach + 2 + 4, 5); + expect(d.left).toBeCloseTo(reach + 2, 5); + }); + + it("takes shadow lengths in rem and em at the default font size", () => { + const drawn = record( + decorationDef, + { [PILL_REACH_VAR_NAME]: "20px", [PILL_BOX_SHADOW_VAR_NAME]: "0 0.25rem 1em black" }, + 280, + 100, + ); + expect(drawn.shadows[0].y).toBe(4); + expect(drawn.shadows[0].blur).toBe(16); + }); + }); + + it("defaults match the shared constants", () => { + // The worklet is deliberately import-free, so its own fallbacks are + // duplicated from variants.ts; this is what keeps them from drifting. + expect(record(paintDef, undefined).paths).toEqual( + record(paintDef, { + [PILL_AMT_VAR_NAME]: String(DEFAULT_PILL_AMT), + [PILL_EASE_VAR_NAME]: String(DEFAULT_PILL_EASE), + [PILL_CONTINUITY_VAR_NAME]: String(DEFAULT_PILL_CONTINUITY), + }).paths, + ); + }); +}); diff --git a/package/src/pill-shape.worklet.ts b/package/src/pill-shape.worklet.ts new file mode 100644 index 0000000..e2e6f96 --- /dev/null +++ b/package/src/pill-shape.worklet.ts @@ -0,0 +1,1107 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +/** + * Prefix for the custom properties this worklet reads, inlined at build time + * from the same value `variants.ts` uses, so the two cannot disagree. See + * SQUIRCLE_CSS_NAMESPACE in vite.config.ts. + * + * This module stays import-free — a paint worklet is loaded as a standalone + * module script — so it takes the value from the define rather than importing + * the constants. + */ +declare const __SQUIRCLE_CSS_NAMESPACE__: string | undefined; + +const NS = `--${typeof __SQUIRCLE_CSS_NAMESPACE__ === "string" ? __SQUIRCLE_CSS_NAMESPACE__ : "squircle"}-pill`; +const AMT_VAR = `${NS}-amt`; +const EASE_VAR = `${NS}-ease`; +const BORDER_STYLE_VAR = `${NS}-border-style`; +const CONTINUITY_VAR = `${NS}-continuity`; +const BORDER_WIDTH_VAR = `${NS}-border-width`; +const BORDER_COLOR_VAR = `${NS}-border-color`; +const OUTLINE_WIDTH_VAR = `${NS}-outline-width`; +const OUTLINE_OFFSET_VAR = `${NS}-outline-offset`; +const OUTLINE_COLOR_VAR = `${NS}-outline-color`; +const OUTLINE_STYLE_VAR = `${NS}-outline-style`; +const RING_WIDTH_VAR = `${NS}-ring-width`; +const RING_COLOR_VAR = `${NS}-ring-color`; +const RING_OFFSET_WIDTH_VAR = `${NS}-ring-offset-width`; +const RING_OFFSET_COLOR_VAR = `${NS}-ring-offset-color`; +const INSET_RING_WIDTH_VAR = `${NS}-inset-ring-width`; +const INSET_RING_COLOR_VAR = `${NS}-inset-ring-color`; +const BOX_SHADOW_VAR = `${NS}-box-shadow`; +const REACH_VAR = `${NS}-reach`; +const BACKGROUND_INSET_VAR = `${NS}-background-inset`; +/** What decides the decoration; see `decorationBands` and `decorationShadows`. */ +const DECORATION_INPUTS = [ + REACH_VAR, + BORDER_WIDTH_VAR, + BORDER_COLOR_VAR, + BORDER_STYLE_VAR, + OUTLINE_WIDTH_VAR, + OUTLINE_OFFSET_VAR, + OUTLINE_COLOR_VAR, + OUTLINE_STYLE_VAR, + RING_WIDTH_VAR, + RING_COLOR_VAR, + RING_OFFSET_WIDTH_VAR, + RING_OFFSET_COLOR_VAR, + INSET_RING_WIDTH_VAR, + INSET_RING_COLOR_VAR, + BOX_SHADOW_VAR, + // What `currentColor` stands for: a registered colour keeps the keyword as + // its computed value, which a canvas has no element to resolve against. + "color", +]; + +/** How far a band runs on to close a seam; see `closeSeams`. */ +const SEAM = 0.5; + +/** One band of a decoration; see `decorationBands`. */ +interface Band { + from: number; + to: number; + color: string; + dash: number[]; +} + +/** One outer box shadow, in px; see `decorationShadows`. */ +interface Shadow { + x: number; + y: number; + blur: number; + spread: number; + color: string; +} + +/** Splits at top-level `separator`s, leaving parentheses whole. */ +function splitTopLevel(value: string, separator: "," | " "): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const ch of value) { + if (ch === "(") depth++; + else if (ch === ")") depth--; + else if (depth === 0 && (separator === "," ? ch === "," : /\s/.test(ch))) { + if (current.trim()) parts.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) parts.push(current.trim()); + return parts; +} + +/** + * A shadow's length in px, or `null` for a token that isn't one. A custom + * property hands the list over as written, so `rem` and `em` are taken at + * the default 16px. + */ +function lengthInPx(token: string): number | null { + const m = /^(-?(?:\d+\.?\d*|\.\d+))(px|rem|em)?$/.exec(token); + if (!m) return null; + const value = Number(m[1]); + if (!m[2]) return value === 0 ? 0 : null; + return m[2] === "px" ? value : value * 16; +} + +/** `color` with any `currentColor` in it replaced by the element's own colour. */ +function resolveCurrentColor(color: string, props?: PaintProperties): string { + if (!/currentcolor/i.test(color)) return color; + const current = props?.get("color")?.toString().trim() || "#000"; + return color.replace(/currentcolor/gi, current); +} + +/** Whether a colour as written could show at all. */ +function isVisible(color: string): boolean { + const c = color.trim().toLowerCase(); + return !( + c === "transparent" || + c === "#0000" || + c === "#00000000" || + /^rgba\(.*,\s*0\)$/.test(c) || + /\/\s*0%?\s*\)$/.test(c) + ); +} + +interface PaintSize { + width: number; + height: number; +} + +interface PaintProperties { + get(name: string): { toString(): string } | undefined; +} + +interface Point { + x: number; + y: number; +} + +interface FresnelTotals { + totalCos: number; + totalSin: number; +} + +interface Fresnel extends FresnelTotals { + cos: Float64Array; + sin: Float64Array; +} + +/** + * How curvature falls across the transition, sampled at `EASE_STEPS + 1` + * evenly spaced points of its length. + * + * `remaining[i]` is the share of the transition's turn still to come at that + * point, from 1 where it leaves the arc to 0 where it meets the flat edge. + * `lambda` sets the transition's length, `lambda * beta * R`: curvature + * starts at `1 / R`, so a profile that sheds it sooner needs longer to turn + * the same `beta`. + */ +interface Profile { + remaining: Float64Array; + lambda: number; +} + +/** + * The geometric continuity the transition keeps at both of its ends. + * + * `2` matches curvature: `k(t) = (1 / R) * (1 - t)^(q - 1)`, which leaves the + * arc already shedding curvature at a finite rate. `3` also matches the rate + * curvature changes, `dk/ds`, with `k(t) = (1 / R) * (1 - t^2)^(q - 1)`: flat + * where it leaves the arc, and, for any exponent above 2, flat where it meets + * the edge. The ease keeps its meaning — it is the same exponent — but G3 + * floors it at 0, the lowest ease that still arrives at the edge at all. + * + * G3 is the default: with the default ease it is flat at both ends, which + * reads as the smoothest join. Kept in step with DEFAULT_PILL_CONTINUITY in + * variants.ts by a test. + */ +type Continuity = 2 | 3; +const DEFAULT_CONTINUITY: Continuity = 3; + +const QUADRANT_CACHE_SIZE = 64; +const quadrantCache = new Map(); + +/** + * The narrowest pill, as half-length over half-height, that fits a requested + * easing unchanged, keyed by the request. Wider pills skip the fit entirely, + * which is most of them: the boundary depends only on the request, not on the + * element's size. + */ +const fitsAsAskedCache = new Map(); +/** Fitted easings for narrower pills, keyed by aspect ratio and request. */ +const FIT_CACHE_SIZE = 256; +/** + * How closely the fit pins down the easing angle, relative to the request. + * Whatever it settles on fits the box; this only decides how much of the + * requested easing is kept, and 1e-4 of it is far below anything visible. + */ +const FIT_TOLERANCE = 1e-4; +const fitCache = new Map(); + +/** + * How far the easing reaches, sampled across the angles a request can be + * fitted down to, keyed by the request. The reach grows smoothly and steadily + * with the angle and depends on nothing but the request, so one table, built + * the first time a pill is too narrow for its request, gives every narrower + * pill a starting angle within a few tolerances of its fit; one or two exact + * evaluations then settle it, where searching from scratch takes four to ten. + * Pages usually share one request across all their pills, so the table is + * paid for once. + */ +interface FitTable { + betas: number[]; + ratios: number[]; + /** `dbeta/dratio` at each node, for each of the two segments it bounds. */ + slopesIn: number[]; + slopesOut: number[]; +} +const FIT_TABLE_STEPS = 24; +const FIT_TABLE_CACHE_SIZE = 16; +const fitTableCache = new Map(); + +/** + * The transition for one easing — its profile, the running integrals that + * place its points, and the cap radius over the half-height — keyed by the + * easing. It depends on no size at all, so every pill wide enough for its + * requested easing shares one, and a new size only has to scale and sample it. + */ +interface Transition { + profile: Profile; + fresnel: Fresnel; + radiusRatio: number; +} +const TRANSITION_CACHE_SIZE = 64; +const transitionCache = new Map(); + +const remember = (cache: Map, size: number, key: string, value: V): V => { + if (cache.size >= size) cache.delete(cache.keys().next().value as string); + cache.set(key, value); + return value; +}; + +/** + * The pill amount sets how soft the easing is: how much of each cap is + * given over to the curvature transition, in units of 30 degrees. `1` keeps a + * bare semicircle (a plain stadium, what `border-radius: 9999px` already + * draws), the default `2` eases the last 30 degrees at each end, `3` eases 60. + * + * `1` meaning "circular" matches `--squircle-amt` elsewhere in this package. + * The request is only ever honoured up to what the element's aspect ratio can + * fit; see `fitEasing`. + */ +// Kept in step with DEFAULT_PILL_AMT in variants.ts by a test; this module is +// deliberately import-free so the worklet stays a standalone module script. +const DEFAULT_AMOUNT = 2; +const EASE_PER_AMOUNT = Math.PI / 6; +const MAX_EASE = Math.PI / 3; + +/** + * The ease smooths the join into the flat edge: it draws the transition + * further along that edge without spending any more of the arc, so a softer + * join no longer costs you a rounder cap. Raising it lets the amount come + * down. + * + * `0` is a clothoid, where curvature falls linearly from the arc to the edge. + * The ease offsets the exponent that governs that fall, + * `k(t) = (1 / R) * (1 - t)^(q - 1)` with `q = ease + 2`, which makes the + * transition `q * beta * R` long. Any ease above -1 still starts at `1 / R` + * and ends at `0`, so G2 holds throughout. + * + * The default sits two steps above the clothoid, which reads as a soft join + * without noticeably flattening the cap. Kept in step with DEFAULT_PILL_EASE + * in variants.ts by a test. + */ +const DEFAULT_EASE = 2; +/** The exponent an ease of 0 means: curvature falling linearly, a clothoid. */ +const CLOTHOID_EXPONENT = 2; +/** + * 0 is the sane floor for real use. Below it the curvature still reaches zero, + * but `dk/ds` diverges as it arrives; at -1 curvature never decays at all, + * leaving the same corner a bare stadium has; and at -2 the transition has no + * length. Those are still honoured so the effect can be seen. Lower would need + * a negative exponent, where `u ** q` blows up at `u = 0`. + */ +const MIN_EASE = -CLOTHOID_EXPONENT; + +/** Integration steps along one transition. Trapezoid error here is sub-pixel. */ +const EASE_STEPS = 512; + +/** + * `log(1 - t)` and `log(1 - t^2)` at every integration node. Both profiles + * raise one of these fixed bases to a varying power at each node, and + * `exp(p * log(b))` with the logarithm looked up is several times cheaper + * than `b ** p`, which recomputes it. + */ +const LOG_ONE_MINUS_T = new Float64Array(EASE_STEPS + 1); +const LOG_ONE_MINUS_T2 = new Float64Array(EASE_STEPS + 1); +for (let i = 0; i <= EASE_STEPS; i++) { + const t = i / EASE_STEPS; + LOG_ONE_MINUS_T[i] = Math.log(1 - t); + LOG_ONE_MINUS_T2[i] = Math.log(1 - t * t); +} + +/** `base ** power` from the base's logarithm, with `0 ** 0` kept at 1. */ +const powFromLog = (logBase: number, power: number): number => + power === 0 ? 1 : Math.exp(power * logBase); + +/** + * How far the emitted polyline may sit from the true curve, in pixels. + * + * A chord spanning arc length `ds` while the curve turns `dphi` misses it by + * about `ds * dphi / 8`, so bounding that product places vertices densely where + * the outline turns hardest and sparsely down the near-straight tail. Sampling + * at a fixed rate instead starves the start of the transition, which is exactly + * where a wide ease piles up all of the curvature. + */ +const MAX_SAGITTA = 0.03; +const MIN_SEGMENTS = 4; +const MAX_SEGMENTS = 256; + +export const paintDef = class PillShape implements PaintWorklet { + /* + * As few as it can do with: every input is gathered for every pill on every + * resize, which adds up over hundreds of them. + */ + static get inputProperties() { + return [AMT_VAR, EASE_VAR, CONTINUITY_VAR, BACKGROUND_INSET_VAR]; + } + + /** + * Dash pattern for a band `width` wide drawn in a border or outline + * `style`, in multiples of its width; `null` for `none` and `hidden`, which + * draw nothing, as those keywords do to a real border. + * + * The worklet keeps its own vocabulary rather than reading a framework's + * variables directly; the Tailwind layer maps `--tw-border-style` onto this. + */ + resolveDash(style: string | undefined, width: number): number[] | null { + const value = style?.trim() || "solid"; + if (value === "none" || value === "hidden") return null; + if (value === "dashed") return [width * 3, width * 2]; + if (value === "dotted") return [width, width * 2]; + return []; + } + + /** The requested easing angle, in radians, before it is fitted to the box. */ + resolveAngle(props?: PaintProperties): number { + const raw = Number.parseFloat(props?.get(AMT_VAR)?.toString() ?? ""); + const amt = Number.isFinite(raw) ? raw : DEFAULT_AMOUNT; + return Math.min(Math.max(amt - 1, 0) * EASE_PER_AMOUNT, MAX_EASE); + } + + /** + * How far the join is eased along the flat edge, as the curvature exponent + * it offsets; see `DEFAULT_EASE`. + */ + resolveExponent(props?: PaintProperties): number { + const raw = Number.parseFloat(props?.get(EASE_VAR)?.toString() ?? ""); + const ease = Number.isFinite(raw) ? raw : DEFAULT_EASE; + return Math.max(ease, MIN_EASE) + CLOTHOID_EXPONENT; + } + + /** `2` for G2, anything else the G3 default; see `Continuity`. */ + resolveContinuity(props?: PaintProperties): Continuity { + const raw = Number.parseFloat(props?.get(CONTINUITY_VAR)?.toString() ?? ""); + return Number.isFinite(raw) && raw < 2.5 ? 2 : DEFAULT_CONTINUITY; + } + + /** The transition's curvature profile for exponent `q`; see `Profile`. */ + profile(q: number, continuity: Continuity): Profile { + const h = 1 / EASE_STEPS; + const remaining = new Float64Array(EASE_STEPS + 1); + if (continuity === 2) { + for (let i = 0; i <= EASE_STEPS; i++) remaining[i] = powFromLog(LOG_ONE_MINUS_T[i], q); + return { remaining, lambda: q }; + } + + // Curvature (1 - t^2)^e, integrated for the turn taken so far; the running + // total is kept in `remaining` and normalised in place. + const e = Math.max(q - 1, 1); + let k0 = 1; + for (let i = 1; i <= EASE_STEPS; i++) { + const k1 = powFromLog(LOG_ONE_MINUS_T2[i], e); + remaining[i] = remaining[i - 1] + ((k0 + k1) / 2) * h; + k0 = k1; + } + const total = remaining[EASE_STEPS]; + for (let i = 0; i <= EASE_STEPS; i++) remaining[i] = 1 - remaining[i] / total; + return { remaining, lambda: 1 / total }; + } + + /** + * Running integrals of `cos(b * u^q)` and `sin(b * u^q)` over `[0, t]`. + * + * At `q = 2` these are the Fresnel integrals describing a clothoid — the + * curve whose curvature falls linearly with arc length, and so the curve that + * joins a circular arc to a straight line with no jump in curvature. Other + * exponents keep both endpoint curvatures and just redistribute the fall. The + * same quadrature sizes the cap and places the points, so the transition + * lands exactly on the straight edge. + * + * Only the totals are needed to size a cap, so `fresnelTotals` runs the same + * quadrature without keeping the running values; the fit probes it many + * times over and would otherwise allocate two arrays per probe. + */ + fresnel(beta: number, { remaining }: Profile): Fresnel { + const cos = new Float64Array(EASE_STEPS + 1); + const sin = new Float64Array(EASE_STEPS + 1); + const h = 1 / EASE_STEPS; + let c = 0; + let s = 0; + // Each node's cosine and sine serve both segments it bounds. + let c0 = Math.cos(beta * remaining[0]); + let s0 = Math.sin(beta * remaining[0]); + + for (let i = 1; i <= EASE_STEPS; i++) { + const c1 = Math.cos(beta * remaining[i]); + const s1 = Math.sin(beta * remaining[i]); + c += ((c0 + c1) / 2) * h; + s += ((s0 + s1) / 2) * h; + cos[i] = c; + sin[i] = s; + c0 = c1; + s0 = s1; + } + + return { cos, sin, totalCos: c, totalSin: s }; + } + + fresnelTotals(beta: number, profile: Profile): FresnelTotals { + const { totalCos, totalSin } = this.fresnel(beta, profile); + return { totalCos, totalSin }; + } + + /** + * Where the easing meets the flat edge, over the cap's half-height, for a + * cap easing through `beta` with exponent `q`. The fit evaluates this many + * times over, so it computes the profile and its quadrature in one pass, + * allocating nothing; it matches `capMetrics` over `profile` and `fresnel`. + */ + junctionRatio(beta: number, q: number, continuity: Continuity): number { + const h = 1 / EASE_STEPS; + let e = 0; + let lambda = q; + if (continuity === 3) { + // G3 needs the profile's total before it can be normalised. + e = Math.max(q - 1, 1); + let total = 0; + let k0 = 1; + for (let i = 1; i <= EASE_STEPS; i++) { + const k1 = powFromLog(LOG_ONE_MINUS_T2[i], e); + total += ((k0 + k1) / 2) * h; + k0 = k1; + } + lambda = 1 / total; + } + + let c = 0; + let s = 0; + let taken = 0; + let k0 = 1; + let c0 = Math.cos(beta); + let s0 = Math.sin(beta); + for (let i = 1; i <= EASE_STEPS; i++) { + let remaining: number; + if (continuity === 2) { + remaining = powFromLog(LOG_ONE_MINUS_T[i], q); + } else { + const k1 = powFromLog(LOG_ONE_MINUS_T2[i], e); + taken += ((k0 + k1) / 2) * h; + k0 = k1; + remaining = 1 - taken * lambda; + } + const c1 = Math.cos(beta * remaining); + const s1 = Math.sin(beta * remaining); + c += ((c0 + c1) / 2) * h; + s += ((s0 + s1) / 2) * h; + c0 = c1; + s0 = s1; + } + + return this.capMetrics(1, beta, lambda, { totalCos: c, totalSin: s }).junction; + } + + /** + * Cap radius, and the coordinate where the easing meets the flat edge, for a + * cap of half-height `r` easing through `beta`. + * + * The cap still has to span the full height, so the arc's rise + * (`R cos beta`) plus the transition's rise (`lambda R beta * S`) must equal + * `r`. + */ + capMetrics( + r: number, + beta: number, + lambda: number, + { totalCos, totalSin }: FresnelTotals, + ): { radius: number; junction: number } { + const radius = r / (Math.cos(beta) + lambda * beta * totalSin); + const junction = radius * (1 - Math.sin(beta) + lambda * beta * totalCos); + return { radius, junction }; + } + + /** + * The softest easing that still fits, backing off the amount and the ease + * together. + * + * What reads as a smooth transition is the rate curvature changes, + * `|dk/ds| * R^2 = (q - 1) / (q * beta)`. Surrendering beta alone sends that + * rate up like `1 / beta`, so a pill too narrow for the requested easing ends + * up looking abruptly cornered even though it is still formally G2. Holding + * the rate fixed instead pins the exponent to whatever beta survives: + * + * q = 1 / (1 - rate * beta) + * + * which returns the requested exponent at the requested beta and eases down + * towards the plain clothoid as the room runs out. Once it bottoms out there + * the rate does climb, on the way to the bare semicircle a square has no + * choice but to be. + */ + fitEasing( + r: number, + half: number, + wantedBeta: number, + wantedExponent: number, + continuity: Continuity = DEFAULT_CONTINUITY, + ): { beta: number; exponent: number } { + // A square, or anything no longer than it is tall, has no flat edge to + // ease into: only a circle fits, so there is nothing to search for. + if (wantedBeta <= 0 || half <= r) return { beta: 0, exponent: wantedExponent }; + + // The fit depends on the box only through its aspect ratio: the whole + // shape scales with the half-height. + const ratio = half / r; + const request = `${wantedBeta},${wantedExponent},${continuity}`; + const fitsAsAsked = + fitsAsAskedCache.get(request) ?? + remember( + fitsAsAskedCache, + FIT_CACHE_SIZE, + request, + this.junctionRatio(wantedBeta, wantedExponent, continuity), + ); + if (ratio >= fitsAsAsked) return { beta: wantedBeta, exponent: wantedExponent }; + + const key = `${ratio},${request}`; + const cached = fitCache.get(key); + if (cached) return cached; + + // There is only something to trade above the clothoid. At or below it the + // requested exponent is passed through and the amount absorbs the + // shortfall, which also keeps the rate away from the 0 and 1 singularities. + const tradeable = wantedExponent > CLOTHOID_EXPONENT; + const rate = tradeable ? (wantedExponent - 1) / (wantedExponent * wantedBeta) : 0; + // rate * beta <= rate * wantedBeta = (q - 1) / q < 1, so the denominator + // stays positive. + const exponentFor = (beta: number): number => + tradeable + ? Math.min(Math.max(1 / (1 - rate * beta), CLOTHOID_EXPONENT), wantedExponent) + : wantedExponent; + + // How far past the box's centre the easing lands, in half-heights. No + // easing at all is a bare semicircle, which lands at 1 and fits any pill. + const reach = (beta: number): number => this.junctionRatio(beta, exponentFor(beta), continuity); + // Where the exponent stops being held at the clothoid, the reach has a + // kink that the table has to sample exactly. + const kink = tradeable ? 1 / (2 * rate) : 0; + const table = + fitTableCache.get(request) ?? + remember( + fitTableCache, + FIT_TABLE_CACHE_SIZE, + request, + this.fitTable(wantedBeta, fitsAsAsked, kink, reach), + ); + + // Start from the table: its segment brackets the answer, and its guess + // lands within a few tolerances of it. `low` always fits, so it is what is + // returned; each step aims a little short so it is usually the next `low`, + // and the search stops once the root is estimated within the tolerance. + const tolerance = FIT_TOLERANCE * wantedBeta; + const { segment, beta: guess } = this.fitGuess(table, ratio); + let low = table.betas[segment] as number; + let high = table.betas[segment + 1] as number; + let fLow = (table.ratios[segment] as number) - ratio; + let fHigh = (table.ratios[segment + 1] as number) - ratio; + let next = guess - tolerance / 2; + for (let i = 0; i < 40; i++) { + // Out of the bracket, or still searching after a few steps: bisect. + if (!(next > low && next < high) || i > 3) next = (low + high) / 2; + const f = reach(next) - ratio; + if (f <= 0) { + low = next; + fLow = f; + } else { + high = next; + fHigh = f; + } + const gap = (-fLow * (high - low)) / (fHigh - fLow); + if (gap <= tolerance || high - low <= tolerance) break; + next = low + gap - tolerance / 2; + } + return remember(fitCache, FIT_CACHE_SIZE, key, { beta: low, exponent: exponentFor(low) }); + } + + /** + * Samples the reach at evenly spaced angles up to the request, plus the + * kink, with the slopes `fitGuess` interpolates along: harmonic means of the + * neighbouring secants (Fritsch–Carlson, which keeps the interpolant + * monotone), one-sided at the ends and either side of the kink. + */ + fitTable( + wantedBeta: number, + fitsAsAsked: number, + kink: number, + reach: (beta: number) => number, + ): FitTable { + const betas: number[] = []; + for (let i = 0; i <= FIT_TABLE_STEPS; i++) betas.push((wantedBeta * i) / FIT_TABLE_STEPS); + const near = (a: number, b: number) => Math.abs(a - b) <= 1e-9 * wantedBeta; + if (kink > 0 && kink < wantedBeta && !betas.some((b) => near(b, kink))) { + betas.push(kink); + betas.sort((a, b) => a - b); + } + const n = betas.length - 1; + const ratios = betas.map((b, i) => (i === 0 ? 1 : i === n ? fitsAsAsked : reach(b))); + const secants: number[] = []; + for (let i = 0; i < n; i++) { + secants.push( + ((betas[i + 1] as number) - (betas[i] as number)) / + ((ratios[i + 1] as number) - (ratios[i] as number)), + ); + } + const slopesIn: number[] = []; + const slopesOut: number[] = []; + for (let i = 0; i <= n; i++) { + const before = secants[i - 1]; + const after = secants[i]; + const smooth = + before !== undefined && after !== undefined && !near(betas[i] as number, kink) + ? before * after <= 0 + ? 0 + : 2 / (1 / before + 1 / after) + : undefined; + slopesIn.push(smooth ?? before ?? 0); + slopesOut.push(smooth ?? after ?? 0); + } + return { betas, ratios, slopesIn, slopesOut }; + } + + /** + * The table's estimate of the angle reaching `ratio`, and the segment that + * brackets it. + * + * Nearly square boxes fall in the first segment, where the reach grows + * linearly from 1 and bends only gently: `ratio - 1 = a beta + b beta^2`, + * fitted through the first two samples, gives the angle straight from the + * quadratic formula. Elsewhere it is a monotone cubic through the samples. + */ + fitGuess(table: FitTable, ratio: number): { segment: number; beta: number } { + const { betas, ratios, slopesIn, slopesOut } = table; + const last = betas.length - 2; + let segment = 0; + while (segment < last && (ratios[segment + 1] as number) < ratio) segment++; + + if (segment === 0) { + const b1 = betas[1] as number; + const b2 = betas[2] ?? b1; + const y1 = (ratios[1] as number) - 1; + const y2 = ((ratios[2] ?? ratios[1]) as number) - 1; + const b = b2 === b1 ? 0 : (y2 / b2 - y1 / b1) / (b2 - b1); + const a = y1 / b1 - b * b1; + const y = ratio - 1; + // The root of b beta^2 + a beta - y, written to stay exact as b -> 0. + const disc = a * a + 4 * b * y; + return { segment, beta: disc > 0 ? (2 * y) / (a + Math.sqrt(disc)) : y / a }; + } + + const r0 = ratios[segment] as number; + const h = (ratios[segment + 1] as number) - r0; + const t = (ratio - r0) / h; + const t2 = t * t; + const t3 = t2 * t; + return { + segment, + beta: + (2 * t3 - 3 * t2 + 1) * (betas[segment] as number) + + (t3 - 2 * t2 + t) * h * (slopesOut[segment] as number) + + (-2 * t3 + 3 * t2) * (betas[segment + 1] as number) + + (t3 - t2) * h * (slopesIn[segment + 1] as number), + }; + } + + /** + * One quadrant of the outline: from the leftmost point of the cap, round the + * arc, and through the easing to where it becomes the flat top edge. + */ + quadrant( + r: number, + beta: number, + q: number, + continuity: Continuity = DEFAULT_CONTINUITY, + ): Point[] { + // No easing is a bare circular cap of the full half-height: nothing to + // integrate. + const eased = beta > 0 ? this.transition(beta, q, continuity) : null; + const radius = eased ? eased.radiusRatio * r : r; + const points: Point[] = []; + + // Circular cap, from the leftmost point to where the easing takes over. + // Constant radius, so a constant step keeps the chord error in bounds. + const sweep = Math.PI / 2 - beta; + const arcSteps = Math.min( + Math.max(Math.ceil(sweep / Math.sqrt((8 * MAX_SAGITTA) / radius)), MIN_SEGMENTS), + MAX_SEGMENTS, + ); + for (let i = 0; i <= arcSteps; i++) { + const theta = Math.PI + (sweep * i) / arcSteps; + points.push({ x: radius + radius * Math.cos(theta), y: r + radius * Math.sin(theta) }); + } + + if (!eased) return points; + const { profile, fresnel } = eased; + + // The lowest ease gives the transition no length at all: the arc alone + // spans the height and meets the flat edge at a corner. + if (profile.lambda <= 0) return points; + + // Curvature ramps from 1 / radius down to 0 across the transition, which + // the profile makes lambda * beta * radius long. Vertices land where the + // chord would otherwise drift off the curve. + const length = profile.lambda * radius * beta; + const start = points[points.length - 1]; + const at = (k: number): Point => ({ + x: start.x + length * fresnel.cos[k], + y: start.y - length * fresnel.sin[k], + }); + const turnTo = (k: number): number => beta * (1 - profile.remaining[k]); + + let anchor = 0; + for (let k = 1; k < EASE_STEPS; k++) { + const span = length * ((k - anchor) / EASE_STEPS); + const turn = turnTo(k) - turnTo(anchor); + if (span * turn >= 8 * MAX_SAGITTA) { + points.push(at(k)); + anchor = k; + } + } + points.push(at(EASE_STEPS)); + + return points; + } + + /** + * Fills the pill, opaque. It is consumed as a mask — of the copy of the + * element's background that the pill shows — where only the alpha channel + * counts and the background supplies the colour. Reading `color` here would + * mean `color: transparent` erased the pill. + */ + paint(ctx: CanvasRenderingContext2D, size: PaintSize, props?: PaintProperties): void { + const { width, height } = size; + if (width <= 0 || height <= 0) return; + ctx.fillStyle = "#000"; + ctx.beginPath(); + // Pulled in under a border; see the stylesheet's background inset. + const inset = Math.max(this.resolveLength(props, BACKGROUND_INSET_VAR), 0); + const outline = + inset > 0 + ? this.offsetOutline(this.boxOutline(width, height, props), -inset) + : this.boxOutline(width, height, props); + for (let i = 0; i < outline.length; i++) { + const p = outline[i]; + if (i === 0) ctx.moveTo(p.x, p.y); + else ctx.lineTo(p.x, p.y); + } + ctx.closePath(); + ctx.fill(); + } + + /** The size-independent transition for an easing; see `Transition`. */ + transition(beta: number, q: number, continuity: Continuity): Transition { + const key = `${beta},${q},${continuity}`; + const cached = transitionCache.get(key); + if (cached) return cached; + const profile = this.profile(q, continuity); + const fresnel = this.fresnel(beta, profile); + const { radius } = this.capMetrics(1, beta, profile.lambda, fresnel); + return remember(transitionCache, TRANSITION_CACHE_SIZE, key, { + profile, + fresnel, + radiusRatio: radius, + }); + } + + /** + * The quadrant for a box, with the easing fitted to it. + * + * Memoised, because the same shape is painted at least twice — once as the + * background's mask and once as its decoration — and again on every repaint that + * changes nothing about it, such as a hover. The quadrant is the expensive + * part, and it depends only on these five values. + */ + fittedQuadrant( + long: number, + short: number, + ease: number, + exponent: number, + continuity: Continuity = DEFAULT_CONTINUITY, + ): Point[] { + const key = `${long},${short},${ease},${exponent},${continuity}`; + const cached = quadrantCache.get(key); + if (cached) return cached; + + const r = short / 2; + const fitted = this.fitEasing(r, long / 2, ease, exponent, continuity); + const points = this.quadrant(r, fitted.beta, fitted.exponent, continuity); + + if (quadrantCache.size >= QUADRANT_CACHE_SIZE) { + quadrantCache.delete(quadrantCache.keys().next().value as string); + } + quadrantCache.set(key, points); + return points; + } + + /** + * The pill's outline in the coordinates of a `width` by `height` box: worked + * out along the long axis, then transposed for a tall pill, which leaves a + * tall pill's outline running the other way round. + */ + boxOutline(width: number, height: number, props?: PaintProperties): Point[] { + const vertical = height > width; + const long = vertical ? height : width; + const short = vertical ? width : height; + const outline = this.outline( + long, + short, + this.fittedQuadrant( + long, + short, + this.resolveAngle(props), + this.resolveExponent(props), + this.resolveContinuity(props), + ), + ); + return vertical ? outline.map((p) => ({ x: p.y, y: p.x })) : outline; + } + + /** + * The outline moved `distance` outwards (inwards where negative), each + * point along the average of its two edges' outward normals. Exact enough + * for a convex outline sampled this densely, out to any distance and in to + * well past any decoration's width; the points where the quadrants meet, + * repeated, are dropped first, having no direction of their own. + */ + offsetOutline(points: Point[], distance: number): Point[] { + const ring: Point[] = []; + for (const p of points) { + const last = ring[ring.length - 1]; + if (!last || Math.abs(last.x - p.x) > 1e-6 || Math.abs(last.y - p.y) > 1e-6) ring.push(p); + } + const first = ring[0]; + const end = ring[ring.length - 1]; + if (ring.length > 1 && Math.abs(first.x - end.x) < 1e-6 && Math.abs(first.y - end.y) < 1e-6) { + ring.pop(); + } + if (distance === 0) return ring; + // Clockwise in screen coordinates, an edge (dx, dy)'s outward normal is + // (dy, -dx); anticlockwise, its opposite. + let twiceArea = 0; + for (let i = 0; i < ring.length; i++) { + const p = ring[i]; + const q = ring[(i + 1) % ring.length]; + twiceArea += p.x * q.y - q.x * p.y; + } + const out = twiceArea > 0 ? distance : -distance; + const n = ring.length; + return ring.map((p, i) => { + const prev = ring[(i - 1 + n) % n]; + const next = ring[(i + 1) % n]; + const normal = (a: Point, b: Point) => { + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = Math.hypot(dx, dy) || 1; + return { x: dy / length, y: -dx / length }; + }; + const a = normal(prev, p); + const b = normal(p, next); + const mx = a.x + b.x; + const my = a.y + b.y; + const length = Math.hypot(mx, my) || 1; + return { x: p.x + (mx / length) * out, y: p.y + (my / length) * out }; + }); + } + + /** A length property in px, or 0 where it is unset or not a number. */ + resolveLength(props: PaintProperties | undefined, name: string): number { + const value = Number.parseFloat(props?.get(name)?.toString() ?? ""); + return Number.isFinite(value) ? value : 0; + } + + /** + * How far the decoration reaches beyond the border box — the outline, the + * ring, the shadows — which is how much larger than it the decoration's box + * is on every side. The stylesheet works it out, and sizes that box by it. + */ + decorationOutset(props?: PaintProperties): number { + return Math.max(this.resolveLength(props, REACH_VAR), 0); + } + + /** + * The outer shadows in a `box-shadow` list, in px, first on top as CSS + * paints them. Inset shadows are left to the copy of the background, which + * paints them itself; invisible ones are dropped. + */ + decorationShadows(props?: PaintProperties): Shadow[] { + const value = props?.get(BOX_SHADOW_VAR)?.toString().trim() ?? ""; + if (value === "" || value === "none") return []; + const shadows: Shadow[] = []; + for (const layer of splitTopLevel(value, ",")) { + const tokens = splitTopLevel(layer, " "); + if (tokens.includes("inset")) continue; + const lengths: number[] = []; + const color: string[] = []; + for (const token of tokens) { + const length = lengthInPx(token); + if (length === null) color.push(token); + else lengths.push(length); + } + const [x = 0, y = 0, blur = 0, spread = 0] = lengths; + const paint = resolveCurrentColor(color.join(" ") || "currentColor", props); + if (lengths.length < 2 || !isVisible(paint)) continue; + shadows.push({ x, y, blur: Math.max(blur, 0), spread, color: paint }); + } + return shadows; + } + + /** + * The bands to draw around and inside the outline, bottom first, as + * distances out from it (negative is inwards), each with its colour and + * dash pattern. They mean what their CSS namesakes do, measured from the + * pill's own outline instead of the stadium: an inset ring inside the + * border, a ring outside the border box beyond its offset band, an outline + * on top beyond its offset. + */ + decorationBands(props?: PaintProperties): Band[] { + const color = (name: string) => + resolveCurrentColor(props?.get(name)?.toString().trim() ?? "", props); + const visible = (c: string) => c !== "" && isVisible(c); + const bands: Band[] = []; + const border = this.resolveLength(props, BORDER_WIDTH_VAR); + const borderColor = color(BORDER_COLOR_VAR); + const borderDash = this.resolveDash(props?.get(BORDER_STYLE_VAR)?.toString(), border); + if (border > 0 && visible(borderColor) && borderDash !== null) { + bands.push({ from: -border, to: 0, color: borderColor, dash: borderDash }); + } + + const insetRing = this.resolveLength(props, INSET_RING_WIDTH_VAR); + const insetRingColor = color(INSET_RING_COLOR_VAR); + if (insetRing > 0 && visible(insetRingColor)) { + bands.push({ from: -border - insetRing, to: -border, color: insetRingColor, dash: [] }); + } + + const ring = this.resolveLength(props, RING_WIDTH_VAR); + if (ring > 0) { + const offset = Math.max(this.resolveLength(props, RING_OFFSET_WIDTH_VAR), 0); + const offsetColor = color(RING_OFFSET_COLOR_VAR); + if (offset > 0 && visible(offsetColor)) { + bands.push({ from: 0, to: offset, color: offsetColor, dash: [] }); + } + const ringColor = color(RING_COLOR_VAR); + if (visible(ringColor)) { + bands.push({ from: offset, to: offset + ring, color: ringColor, dash: [] }); + } + } + + const outline = this.resolveLength(props, OUTLINE_WIDTH_VAR); + const outlineColor = color(OUTLINE_COLOR_VAR); + const outlineDash = this.resolveDash(props?.get(OUTLINE_STYLE_VAR)?.toString(), outline); + if (outline > 0 && visible(outlineColor) && outlineDash !== null) { + const offset = this.resolveLength(props, OUTLINE_OFFSET_VAR); + bands.push({ from: offset, to: offset + outline, color: outlineColor, dash: outlineDash }); + } + return this.closeSeams(bands); + } + + /** + * Two shapes anti-aliased along the same edge each leave the pixels there + * partly uncovered, so whatever is behind shows through as a hairline. So + * where two bands meet, the lower one runs `SEAM` on under the upper one, + * where it is hidden; not under a dashed one, whose gaps would show it. A + * band meeting the background's edge from outside runs on over the + * background instead, unless a band already covers that edge from inside — + * in which case the background is what pulls back; see the stylesheet's + * background inset and the polyfill's background clip. + */ + closeSeams(bands: Band[]): Band[] { + const meets = (a: number, b: number) => Math.abs(a - b) < 1e-6; + const edgeCovered = bands.some((b) => b.from < 0 && meets(b.to, 0)); + return bands.map((band, i) => { + let { from, to } = band; + for (const upper of bands.slice(i + 1)) { + if (upper.dash.length > 0) continue; + if (meets(upper.from, band.to)) to = band.to + SEAM; + if (meets(upper.to, band.from)) from = band.from - SEAM; + } + if (!edgeCovered && meets(band.from, 0)) from = -SEAM; + return { ...band, from, to }; + }); + } + + /** Mirror one quadrant into the full outline, walking clockwise. */ + outline(long: number, short: number, quadrant: Point[]): Point[] { + const reversed = [...quadrant].reverse(); + return [ + // Leftmost point, up through the easing into the flat top edge. + ...quadrant, + // Mirrored in x: back down to the rightmost point. + ...reversed.map((p) => ({ x: long - p.x, y: p.y })), + // Then the bottom half, mirrored in y. + ...quadrant.map((p) => ({ x: long - p.x, y: short - p.y })), + ...reversed.map((p) => ({ x: p.x, y: short - p.y })), + ]; + } +}; + +// `registerPaint` only exists inside a paint worklet global scope. Guarding the +// call keeps this module importable from tests and bundlers. +declare const registerPaint: ((name: string, def: unknown) => void) | undefined; + +/** + * Everything the pill draws around its outline, on `::after`, over a box + * larger than the element's border box by `decorationOutset` on every side: + * its box shadows, then its border, inset ring, ring and outline. + * + * Each band is a stroke along the outline moved out to the band's middle, as + * wide as the band: the outline is convex, so that is exactly the region + * between the band's two edges. The shadows are cast by the outline itself, + * grown by their spread, and drawn only outside it, as CSS draws a box + * shadow only outside the box. + */ +export const decorationDef = class PillDecoration extends paintDef { + static override get inputProperties() { + return [AMT_VAR, EASE_VAR, CONTINUITY_VAR, ...DECORATION_INPUTS]; + } + + override paint(ctx: CanvasRenderingContext2D, size: PaintSize, props?: PaintProperties): void { + const bands = this.decorationBands(props); + const shadows = this.decorationShadows(props); + if (bands.length === 0 && shadows.length === 0) return; + const outset = this.decorationOutset(props); + const width = size.width - 2 * outset; + const height = size.height - 2 * outset; + if (width <= 0 || height <= 0) return; + const outline = this.boxOutline(width, height, props).map((p) => ({ + x: p.x + outset, + y: p.y + outset, + })); + const trace = (path: Point[], dx = 0, dy = 0) => { + ctx.beginPath(); + path.forEach((p, i) => + i === 0 ? ctx.moveTo(p.x + dx, p.y + dy) : ctx.lineTo(p.x + dx, p.y + dy), + ); + ctx.closePath(); + }; + + if (shadows.length > 0) { + ctx.save(); + // Only outside the outline. + ctx.beginPath(); + ctx.rect(0, 0, size.width, size.height); + outline.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.closePath(); + ctx.clip("evenodd"); + // The shape itself is drawn far off the canvas, its shadow thrown back + // onto it, so only the shadow lands. Last first, as CSS stacks them. + const away = size.width + size.height + 1000; + ctx.fillStyle = "#000"; + for (const shadow of [...shadows].reverse()) { + ctx.shadowColor = shadow.color; + ctx.shadowBlur = shadow.blur; + ctx.shadowOffsetX = shadow.x + away; + ctx.shadowOffsetY = shadow.y; + trace(this.offsetOutline(outline, shadow.spread), -away); + ctx.fill(); + } + ctx.restore(); + } + + ctx.lineJoin = "round"; + for (const band of bands) { + trace(this.offsetOutline(outline, (band.from + band.to) / 2)); + ctx.strokeStyle = band.color; + ctx.lineWidth = band.to - band.from; + ctx.setLineDash(band.dash); + ctx.stroke(); + } + } +}; + +if (typeof registerPaint !== "undefined") { + registerPaint("pill-shape", paintDef); + registerPaint("pill-decoration", decorationDef); +} diff --git a/package/src/pill-worklet.ts b/package/src/pill-worklet.ts new file mode 100644 index 0000000..e75aa14 --- /dev/null +++ b/package/src/pill-worklet.ts @@ -0,0 +1,62 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { + DEFAULT_PILL_AMT, + DEFAULT_PILL_CONTINUITY, + DEFAULT_PILL_EASE, + PILL_AMT_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_WORKLET_ATTRIBUTE, +} from "./variants"; + +export { PILL_WORKLET_ATTRIBUTE }; + +/** + * The pill's shape settings — the custom properties `squircle-pill-amt-*`, + * `squircle-pill-ease-*` and `squircle-pill-g2`/`-g3` set — and the values + * they start at, for scripts that set or show them: a settings panel, a + * preview. The same constants the stylesheet's registrations and the + * worklet's own fallbacks come from, so they never disagree. + */ +export const PILL_SHAPE_PROPERTIES = { + amt: { name: PILL_AMT_VAR_NAME, default: DEFAULT_PILL_AMT }, + ease: { name: PILL_EASE_VAR_NAME, default: DEFAULT_PILL_EASE }, + continuity: { name: PILL_CONTINUITY_VAR_NAME, default: DEFAULT_PILL_CONTINUITY }, +} as const; + +/** + * Loads the pill-shape paint worklet and, once it is in, marks the document so + * the pill styles switch from their stadium fallback to the drawn shape. + * + * That mark is the whole reason to go through here rather than calling + * `CSS.paintWorklet.addModule()` directly: `@supports (mask-image: + * paint(pill-shape))` is true in a browser with the Paint API whether or not + * a worklet by that name ever loaded, so a mask gated on it alone would erase + * every pill the moment the worklet failed to load. Gated on the mark, a pill + * stays a plain stadium until the shape is really there to draw. + * + * Resolves to `false` where paint worklets are unsupported, and the pills keep + * their fallback. A load that fails — a wrong URL, a CSP that blocks it — + * rejects, so it surfaces in the console as the error it is; the pills still + * keep their fallback. + * + * @param moduleUrl Where the worklet script is served from. The default + * points at the copy shipped next to this module, which bundlers that + * understand `new URL(…, import.meta.url)` — Vite, webpack 5, Parcel — will + * emit as an asset. Pass a URL where that is not the case, e.g. Vite's + * `import url from "@klinking/squircle/pill-shape.worklet.js?url"`. + */ +export async function registerPillWorklet( + moduleUrl: string | URL = new URL("./pill-shape.worklet.mjs", import.meta.url), + root: Document = document, +): Promise { + const paintWorklet = (globalThis.CSS as { paintWorklet?: Worklet } | undefined)?.paintWorklet; + if (!paintWorklet) return false; + await paintWorklet.addModule(String(moduleUrl)); + root.documentElement.setAttribute(PILL_WORKLET_ATTRIBUTE, ""); + return true; +} diff --git a/package/src/tailwind-pill-border.test.ts b/package/src/tailwind-pill-border.test.ts new file mode 100644 index 0000000..893606e --- /dev/null +++ b/package/src/tailwind-pill-border.test.ts @@ -0,0 +1,230 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { describe, expect, it } from "vitest"; +import { shadowReach } from "./tailwind-pill-border"; +import { createCompiler } from "./test-utils"; +import { + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_DECORATED_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_POLYFILL_ATTRIBUTE, + PILL_RING_WIDTH_VAR_NAME, + PILL_SHADOW_REACH_VAR_NAME, + PILL_WORKLET_ATTRIBUTE, +} from "./variants"; + +const { compilePlugin } = createCompiler(import.meta.dirname); +const compileBorder = (candidates: string[], block = "") => + compilePlugin(candidates, block, "./tailwind-pill-border.ts"); + +const LOADED = `:where(:root[${PILL_WORKLET_ATTRIBUTE}], :root[${PILL_POLYFILL_ATTRIBUTE}]) &:is(.squircle-pill)`; + +describe("tailwind-pill-border.ts", () => { + describe("extends rather than replaces", () => { + it("leaves Tailwind's own border-width output intact", async () => { + const css = await compileBorder(["border-2"]); + expect(css).toContain("border-width: 2px"); + expect(css).toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: 2px`); + }); + + it("leaves Tailwind's own border-color output intact", async () => { + const css = await compileBorder(["border-red-500"]); + expect(css).toContain("border-color: var(--color-red-500)"); + expect(css).toContain(`${PILL_BORDER_COLOR_VAR_NAME}:`); + }); + + it("does not disturb utilities it has no values for", async () => { + // `border-dashed` is a style, and this plugin registers only widths and + // colours, so it must fall through to Tailwind untouched. + const css = await compileBorder(["border-dashed"]); + expect(css).toContain("--tw-border-style: dashed"); + expect(css).not.toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: dashed`); + expect(css).not.toContain(`${PILL_BORDER_COLOR_VAR_NAME}: dashed`); + }); + + it("covers arbitrary values without enumerating them", async () => { + const css = await compileBorder(["border-[3px]"]); + expect(css).toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: 3px`); + }); + + it("passes a length through in its own unit", async () => { + // The pill registers its width as a ``, so the browser is what + // resolves this to px before the worklet sees it. + const css = await compileBorder(["border-[0.25rem]"]); + expect(css).toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: 0.25rem`); + }); + + it("never takes a paren reference for a width", async () => { + // Tailwind resolves `border-(--x)` as a colour. Registered as a width + // as well, the worklet would be handed a colour to parse as a length. + const css = await compileBorder(["border-(--x)"]); + expect(css).toContain("border-color: var(--x)"); + expect(css).not.toContain(PILL_BORDER_WIDTH_VAR_NAME); + }); + }); + + describe("scoping", () => { + it("only touches elements that are pills", async () => { + // A border utility has to keep behaving normally everywhere else. + const css = await compileBorder(["border-2", "border-red-500"]); + expect(css).toContain("&:is(.squircle-pill)"); + // The pill vars never appear unscoped. + for (const line of css.split("\n")) { + if ( + line.includes(PILL_BORDER_WIDTH_VAR_NAME) || + line.includes(PILL_BORDER_COLOR_VAR_NAME) + ) { + expect(css.indexOf("&:is(.squircle-pill)")).toBeLessThan(css.indexOf(line)); + } + } + }); + + it("suppresses the real border's paint only once the worklet or polyfill draws", async () => { + // Without the worklet the real border is the pill's border, a stadium + // ring on the fallback shape, and has to keep painting. + const css = await compileBorder(["border-red-500"]); + expect(css).toContain(`${LOADED} {\n border-color: transparent;`); + const suppressions = [...css.matchAll(/border-color: transparent/g)].length; + const gated = [ + ...css.matchAll( + new RegExp( + `:where\\(:root\\[${PILL_WORKLET_ATTRIBUTE}\\], :root\\[${PILL_POLYFILL_ATTRIBUTE}\\]\\)`, + "g", + ), + ), + ].length; + expect(suppressions).toBe(gated); + }); + + it("suppresses it for a width as well as a colour", async () => { + // `border-2` alone is a visible border — Tailwind's default colour is + // currentColor — so a width alone must suppress the real one too, or it + // paints a stadium ring around the drawn one. + const css = await compileBorder(["border-2"]); + expect(css).toContain(`${LOADED} {\n border-color: transparent;`); + }); + + it("honours a custom prefix", async () => { + const css = await compileBorder(["border-2"], 'prefix: "pillbox";'); + expect(css).toContain("&:is(.pillbox)"); + // The gate is the namespaced attribute, not the class, so it stays. + expect(css).not.toContain(".squircle-pill"); + }); + }); + + describe("outlines and rings", () => { + const ruleFor = (css: string, selector: string) => { + const start = css.lastIndexOf(`${selector} {\n &:is(.squircle-pill)`); + return start === -1 ? "" : css.slice(start, css.indexOf("\n }\n", start)); + }; + + it("mirrors outline width, colour and offset onto pills, negative offsets included", async () => { + const css = await compileBorder([ + "outline", + "outline-2", + "outline-3", + "outline-white", + "outline-offset-4", + "-outline-offset-2", + ]); + expect(ruleFor(css, ".outline")).toContain(`${PILL_OUTLINE_WIDTH_VAR_NAME}: 1px`); + expect(ruleFor(css, ".outline-2")).toContain(`${PILL_OUTLINE_WIDTH_VAR_NAME}: 2px`); + expect(ruleFor(css, ".outline-3")).toContain(`${PILL_OUTLINE_WIDTH_VAR_NAME}: 3px`); + expect(ruleFor(css, ".outline-white")).toContain(`${PILL_OUTLINE_COLOR_VAR_NAME}:`); + expect(ruleFor(css, ".outline-offset-4")).toContain(`${PILL_OUTLINE_OFFSET_VAR_NAME}: 4px`); + expect(css).toContain(`${PILL_OUTLINE_OFFSET_VAR_NAME}: calc(2px * -1)`); + // Tailwind's own outline is still emitted, for everything that isn't a pill. + expect(css).toContain("outline-width: 2px"); + }); + + it("keeps the native outline from painting on pills only while the pill draws one", async () => { + const css = await compileBorder(["outline-2", "outline-offset-4"]); + const width = ruleFor(css, ".outline-2"); + expect(width).toContain(`${LOADED} {\n outline-color: transparent;`); + // An offset alone paints nothing to suppress. + expect(ruleFor(css, ".outline-offset-4")).not.toContain("outline-color"); + }); + + it("mirrors ring and inset ring widths, with v4's 1px for a bare ring", async () => { + const css = await compileBorder(["ring", "ring-2", "ring-[3px]", "inset-ring-2"]); + expect(ruleFor(css, ".ring")).toContain(`${PILL_RING_WIDTH_VAR_NAME}: 1px`); + expect(ruleFor(css, ".ring-2")).toContain(`${PILL_RING_WIDTH_VAR_NAME}: 2px`); + expect(css).toContain(`${PILL_RING_WIDTH_VAR_NAME}: 3px`); + expect(ruleFor(css, ".inset-ring-2")).toContain(`${PILL_INSET_RING_WIDTH_VAR_NAME}: 2px`); + }); + + it("empties the native ring shadows on pills, leaving other shadows alone", async () => { + const css = await compileBorder(["ring-2", "inset-ring-2", "shadow-lg"]); + const ring = ruleFor(css, ".ring-2"); + expect(ring).toContain("--tw-ring-shadow: 0 0 #0000"); + expect(ring).toContain("--tw-ring-offset-shadow: 0 0 #0000"); + expect(ring).not.toContain("--tw-shadow:"); + expect(ruleFor(css, ".inset-ring-2")).toContain("--tw-inset-ring-shadow: 0 0 #0000"); + }); + + it("follows variants, so focus rings are drawn along the pill too", async () => { + const css = await compileBorder(["focus-visible:ring-2"]); + expect(css).toMatch( + new RegExp( + `&:focus-visible \\{\\s*&:is\\(\\.squircle-pill\\) \\{\\s*${PILL_RING_WIDTH_VAR_NAME}: 2px`, + ), + ); + }); + + it("leaves ring colours to Tailwind's variables, which the pill reads", async () => { + const css = await compileBorder(["ring-white", "ring-offset-2"]); + expect(css).toContain("--tw-ring-color:"); + expect(css).not.toContain(`${PILL_RING_WIDTH_VAR_NAME}: white`); + }); + }); + + describe("shadows", () => { + it("tells the decoration how far a shadow reaches, and gives the pill one", async () => { + const css = await compileBorder(["shadow-lg", "shadow-none", "shadow-[0_0_0_3px_red]"]); + // 0 10px 15px -3px, 0 4px 6px -4px: 10 + 15 - 3. + expect(css).toMatch( + new RegExp( + `\\.shadow-lg \\{\\s*&:is\\(\\.squircle-pill\\) \\{\\s*${PILL_SHADOW_REACH_VAR_NAME}: 22px;\\s*${PILL_DECORATED_VAR_NAME}: "";`, + ), + ); + expect(css).toMatch( + new RegExp( + `\\.shadow-none \\{\\s*&:is\\(\\.squircle-pill\\) \\{\\s*${PILL_SHADOW_REACH_VAR_NAME}: 0px`, + ), + ); + expect(css).toContain(`${PILL_SHADOW_REACH_VAR_NAME}: 3px`); + // Tailwind's own shadow is untouched: the pill reads its variables. + expect(css).toContain("--tw-shadow:"); + }); + + it("leaves shadow colours alone", async () => { + const css = await compileBorder(["shadow-black/60"]); + expect(css).not.toContain(PILL_SHADOW_REACH_VAR_NAME); + }); + + it("gives only decorated pills an ::after", async () => { + const css = await compileBorder(["border-2", "outline-2", "ring-2", "inset-ring-2"]); + expect(css).toContain(`.squircle-pill {\n ${PILL_DECORATED_VAR_NAME}: none;`); + expect([...css.matchAll(new RegExp(`${PILL_DECORATED_VAR_NAME}: ""`, "g"))]).toHaveLength(4); + // A colour alone draws nothing. + expect(await compileBorder(["border-red-500"])).not.toContain( + `${PILL_DECORATED_VAR_NAME}: ""`, + ); + }); + }); + + it("measures shadow reach from offset, blur and spread, ignoring inset ones", () => { + expect(shadowReach("0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)")).toBe(4); + expect(shadowReach("-8px 2px 4px 2px red")).toBe(14); + expect(shadowReach("inset 0 2px 4px black")).toBe(0); + expect(shadowReach("0 0 0 0.25rem var(--c, rgb(0 0 0))")).toBe(4); + expect(shadowReach("0 0 #0000")).toBe(0); + }); +}); diff --git a/package/src/tailwind-pill-border.ts b/package/src/tailwind-pill-border.ts new file mode 100644 index 0000000..d91c3eb --- /dev/null +++ b/package/src/tailwind-pill-border.ts @@ -0,0 +1,224 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import plugin from "tailwindcss/plugin"; +import { + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_DECORATED_VAR_NAME, + PILL_INSET_RING_WIDTH_VAR_NAME, + PILL_OUTLINE_COLOR_VAR_NAME, + PILL_OUTLINE_OFFSET_VAR_NAME, + PILL_OUTLINE_WIDTH_VAR_NAME, + PILL_POLYFILL_ATTRIBUTE, + PILL_RING_WIDTH_VAR_NAME, + PILL_SHADOW_REACH_VAR_NAME, + PILL_WORKLET_ATTRIBUTE, +} from "./variants"; + +/** + * How far a `box-shadow` list reaches past the box it is cast by: offset, + * blur and spread together, for the furthest outer shadow. Inset shadows, + * and lengths it can't read, count for nothing. + */ +export function shadowReach(value: string): number { + let reach = 0; + let depth = 0; + let layer = ""; + const layers: string[] = []; + for (const ch of value) { + if (ch === "(") depth++; + else if (ch === ")") depth--; + if (ch === "," && depth === 0) { + layers.push(layer); + layer = ""; + } else layer += ch; + } + layers.push(layer); + for (const shadow of layers) { + if (/\binset\b/.test(shadow)) continue; + // Lengths outside any colour function, in order: x, y, blur, spread. + const bare = shadow.replace(/[a-z-]+\([^()]*(?:\([^()]*\)[^()]*)*\)/gi, " "); + const lengths = [...bare.matchAll(/(^|\s)(-?(?:\d+\.?\d*|\.\d+))(px|rem|em)?(?=\s|$)/g)].map( + (m) => Number(m[2]) * (m[3] === "rem" || m[3] === "em" ? 16 : 1), + ); + const [x = 0, y = 0, blur = 0, spread = 0] = lengths; + reach = Math.max(reach, Math.max(Math.abs(x), Math.abs(y)) + Math.max(blur, 0) + spread); + } + return Math.ceil(Math.max(reach, 0)); +} + +export interface SquirclePillBorderPluginOptions { + /** Class name of the pill utility these borders apply to (default: "squircle-pill") */ + prefix?: string; +} + +/** + * Teaches Tailwind's own `border-*`, `outline-*` and `ring-*` utilities to + * drive the border, outline and rings a pill draws along its own outline. + * + * A real CSS border follows the stadium `border-radius`, which the pill sits + * up to a few pixels inside near its caps, so the pill hides it and draws its + * own along its outline instead, from its own width and colour properties — + * which would otherwise mean a second way of spelling something Tailwind + * already spells. + * + * This registers those same utility names again. Tailwind does not treat that + * as an override: it emits a second rule alongside its own, so `border-2` keeps + * setting `border-width` and additionally sets the pill's width. Nothing here + * reimplements what a border utility means, which is what keeps it from + * breaking when those utilities change. + * + * It also means this plugin never has to decide whether `border-red-500` is a + * width or a colour: a functional utility only matches when the value resolves + * against the values and type given to it, so widths and colours can be + * registered separately and anything unrecognised falls through to Tailwind + * untouched. + * + * `border-dashed` and friends need no help — they set `--tw-border-style`, and + * the pill utility reads that variable directly. + * + * Outlines and rings are the same story one step out: natively they would + * follow the stadium too, far enough from the pill for a crisp line to + * visibly part from it. So their widths, an outline's colour and offset are + * mirrored too, and the native ones are kept from painting on pills while the + * pill draws its own. Ring colours, the ring + * offset and the outline style already live in Tailwind variables, which the + * pill utility reads directly. The browser's own focus ring, set by no + * utility, is left as it is. + * + * Box shadows are drawn by the pill too, cast by its own outline, from the + * shadow list Tailwind's utilities build, which the pill utility reads. The + * one thing it can't read from CSS is how far a shadow reaches, which the box + * it is drawn on has to grow by; `shadow-*` says. Drop shadows need nothing: + * a filter on a pill already follows its shape. + * + * Every pill draws its border, outline, rings and shadows on `::after`, which + * costs a paint per resize even with nothing to draw; with this plugin + * loaded, a pill only has one when one of those utilities is on it. + */ +const squirclePillBorder: ReturnType> = + plugin.withOptions( + (options = {}) => + ({ addBase, matchUtilities, theme }) => { + const prefix = options.prefix ?? "squircle-pill"; + + // No `::after` unless a utility below asks for one. + addBase({ [`.${prefix}`]: { [PILL_DECORATED_VAR_NAME]: "none" } }); + + /* + * Scoped to pills, so a border utility keeps behaving normally + * everywhere else. `:is()` also lifts specificity above a bare utility + * class, which is what lets the pill suppress the real border's paint + * without depending on which rule Tailwind happens to emit last. + * + * The suppression only applies once the worklet or the polyfill is + * drawing the ring: without either, the real border is the pill's + * border, a stadium ring on the fallback shape, and must keep + * painting. Width and colour both suppress it, because either one + * alone is a visible border. + */ + const onPill = ( + declarations: Record, + suppress: Record = { "border-color": "transparent" }, + ) => ({ + [`&:is(.${prefix})`]: declarations, + [`:where(:root[${PILL_WORKLET_ATTRIBUTE}], :root[${PILL_POLYFILL_ATTRIBUTE}]) &:is(.${prefix})`]: + suppress, + }); + + // Bare numbers are px, as Tailwind reads `border-3` or `ring-3`. + const widths = (themed: Record | undefined, fallback?: string) => + ({ + ...themed, + // After the theme: v4's bare `ring` is 1px, where the theme + // Tailwind keeps for plugins still says v3's 3px. + ...(fallback ? { DEFAULT: fallback } : {}), + __BARE_VALUE__: ({ value }: { value: string }) => + /^\d+(\.\d+)?$/.test(value) ? `${value}px` : undefined, + }) as unknown as Record; + const colors = theme("colors") ?? {}; + // `length` keeps a paren reference like `border-(--var)`, which + // Tailwind resolves as a colour, from being taken for a width as well. + const asWidths = (themed: Record | undefined, fallback?: string) => ({ + values: widths(themed, fallback), + type: "length" as const, + }); + + // Gives the pill the `::after` its border, outline, rings and shadows + // are drawn on. + const decorated = { [PILL_DECORATED_VAR_NAME]: '""' }; + matchUtilities( + { + border: (value: string) => + onPill({ [PILL_BORDER_WIDTH_VAR_NAME]: value, ...decorated }), + }, + asWidths(theme("borderWidth"), "1px"), + ); + matchUtilities( + { border: (value: string) => onPill({ [PILL_BORDER_COLOR_VAR_NAME]: value }) }, + { values: colors, type: "color" }, + ); + + const noOutline = { "outline-color": "transparent" }; + matchUtilities( + { + outline: (value: string) => + onPill({ [PILL_OUTLINE_WIDTH_VAR_NAME]: value, ...decorated }, noOutline), + }, + asWidths(theme("outlineWidth"), "1px"), + ); + matchUtilities( + { + outline: (value: string) => onPill({ [PILL_OUTLINE_COLOR_VAR_NAME]: value }, noOutline), + }, + { values: colors, type: "color" }, + ); + matchUtilities( + { + "outline-offset": (value: string) => + onPill({ [PILL_OUTLINE_OFFSET_VAR_NAME]: value }, {}), + }, + { ...asWidths(theme("outlineOffset")), supportsNegativeValues: true }, + ); + + // The shadow list itself reaches the pill through Tailwind's own + // variables; only its reach has to be worked out here. + matchUtilities( + { + shadow: (value: string) => ({ + [`&:is(.${prefix})`]: { + [PILL_SHADOW_REACH_VAR_NAME]: `${shadowReach(value)}px`, + ...decorated, + }, + }), + }, + { values: theme("boxShadow") ?? {}, type: "any" }, + ); + + matchUtilities( + { + ring: (value: string) => + onPill( + { [PILL_RING_WIDTH_VAR_NAME]: value, ...decorated }, + { "--tw-ring-shadow": "0 0 #0000", "--tw-ring-offset-shadow": "0 0 #0000" }, + ), + }, + asWidths(theme("ringWidth"), "1px"), + ); + matchUtilities( + { + "inset-ring": (value: string) => + onPill( + { [PILL_INSET_RING_WIDTH_VAR_NAME]: value, ...decorated }, + { "--tw-inset-ring-shadow": "0 0 #0000" }, + ), + }, + asWidths(theme("ringWidth"), "1px"), + ); + }, + ); + +export default squirclePillBorder; diff --git a/package/src/tailwind-pill.test.ts b/package/src/tailwind-pill.test.ts new file mode 100644 index 0000000..68e8508 --- /dev/null +++ b/package/src/tailwind-pill.test.ts @@ -0,0 +1,257 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { describe, expect, it } from "vitest"; +import { createCompiler } from "./test-utils"; +import { + FULL_RADIUS, + PILL_AMT_VAR_NAME, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BOX_SHADOW_VAR_NAME, + PILL_CLIP_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_DECORATED_VAR_NAME, + PILL_DECORATION_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_POLYFILL_ATTRIBUTE, + PILL_REACH_VAR_NAME, + PILL_RING_COLOR_VAR_NAME, + PILL_WORKLET_ATTRIBUTE, +} from "./variants"; + +const { compilePlugin, compilePluginAll } = createCompiler(import.meta.dirname); +const compilePill = (candidates: string[], block = "") => + compilePlugin(candidates, block, "./tailwind-pill.ts"); +const compilePillAll = (candidates: string[], block = "") => + compilePluginAll(candidates, block, "./tailwind-pill.ts"); + +const LOADED = `:where(:root[${PILL_WORKLET_ATTRIBUTE}]) &`; + +describe("tailwind-pill.ts utilities", () => { + it("shapes a copy of the background once the worklet has loaded", async () => { + const css = await compilePill(["squircle-pill"]); + expect(css).toContain(`${LOADED} {`); + const before = css.slice(css.indexOf("&::before")); + expect(before).toContain("background: inherit"); + expect(before).toMatch(/[^-]mask-image: paint\(pill-shape\);/); + expect(before).toMatch(/-webkit-mask-image: paint\(pill-shape\);/); + expect(css).toContain("background: paint(pill-decoration)"); + }); + + it("never gates on @supports alone", async () => { + // `@supports (mask-image: paint(pill-shape))` is true for any paint + // name, loaded or not, so a mask gated on it alone would erase every + // pill's background the moment the worklet failed to load. + const css = await compilePill(["squircle-pill"]); + expect(css).not.toContain("@supports"); + }); + + it("hides the element's own background and box shadows, whatever else sets them", async () => { + // Painted into the stadium `border-radius`, they would show as a hairline + // just outside the pill. Important, so a `bg-*` or `shadow-*` utility, or + // an inline `background` shorthand, cannot bring them back. + const css = await compilePill(["squircle-pill"]); + const own = css.slice( + css.indexOf( + `:where(:root[${PILL_WORKLET_ATTRIBUTE}], :root[${PILL_POLYFILL_ATTRIBUTE}]) & {`, + ), + ); + const block = own.slice(0, own.indexOf("}")); + expect(block).toContain("background-clip: text !important"); + expect(block).toContain("box-shadow: none !important"); + }); + + it("leaves masks and clip-paths on the element to Tailwind", async () => { + // The shape is on the copy of the background, so `mask-*` and + // `clip-path` utilities apply to the whole pill as they would anywhere. + const css = await compilePill(["squircle-pill", "mask-b-from-50%"]); + const pill = css.slice(css.indexOf(".squircle-pill {"), css.indexOf(".mask-b-from-50\\%")); + // Everything but the pill's own pseudo-elements. + const element = pill.replace(/&::(?:before|after) \{[^}]*\}/g, ""); + expect(element).not.toMatch(/mask|clip-path/); + }); + + it("draws its decoration on ::after, over a box grown by its reach", async () => { + // A CSS border would follow the stadium, so the pill draws its own along + // its outline, outside the room the real border reserves. + const css = await compilePill(["squircle-pill"]); + expect(css).toContain("&::after"); + expect(css).toContain( + `inset: calc(-1 * (var(${PILL_BORDER_WIDTH_VAR_NAME}) + var(${PILL_REACH_VAR_NAME})))`, + ); + // Only where `tailwind-pill-border` saw a utility that draws something. + expect(css).toContain(`content: var(${PILL_DECORATED_VAR_NAME}, "")`); + expect(css).toContain("border-color: transparent"); + }); + + it("hands the pseudo-elements the element's values by inheritance alone", async () => { + // WebKit never restyles a pseudo-element when a non-inheriting property + // it pulls down with `inherit` changes. + const css = await compilePill(["squircle-pill"]); + expect(css).not.toMatch(/--[\w-]+: inherit/); + }); + + it("bridges Tailwind's variables into the pill's own, on the element", async () => { + // Where a utility exposes a variable, read it rather than asking for a + // second source of truth. Tailwind registers these as non-inheriting, so + // they are mapped where they are set and the pseudo-elements inherit the + // result. + const css = await compilePill(["squircle-pill"]); + const own = /:where\(&\) \{([^}]*)\}/.exec(css)?.[1]; + expect(own).toContain(`${PILL_BORDER_STYLE_VAR_NAME}: var(--tw-border-style, solid)`); + expect(own).toContain(`${PILL_RING_COLOR_VAR_NAME}: var(--tw-ring-color, currentColor)`); + expect(own).toContain( + `${PILL_BOX_SHADOW_VAR_NAME}: var(--tw-inset-shadow, 0 0 #0000), var(--tw-inset-ring-shadow, 0 0 #0000), var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow, 0 0 #0000)`, + ); + }); + + it("starts each pill with no decoration of its own, at zero specificity", async () => { + // Same default colour a real border has, so `border-2` alone draws a + // visible border, and a value set any other way wins whatever the order; + // the reset keeps a pill nested in a bordered one from drawing its + // parent's border. + const css = await compilePill(["squircle-pill"]); + const own = /:where\(&\) \{([^}]*)\}/.exec(css)?.[1]; + expect(own).toContain(`${PILL_BORDER_WIDTH_VAR_NAME}: 0px;`); + expect(own).toContain(`${PILL_BORDER_COLOR_VAR_NAME}: currentColor;`); + expect(own).toContain(`${PILL_CLIP_VAR_NAME}: initial;`); + expect(own).toContain(`${PILL_DECORATION_VAR_NAME}: initial;`); + }); + + it("shapes with a clip under the polyfill, on the copy of the background", async () => { + const css = await compilePill(["squircle-pill"]); + const branch = css.slice(css.indexOf(`:where(:root[${PILL_POLYFILL_ATTRIBUTE}]) & {`)); + const before = branch.slice(branch.indexOf("&::before")); + expect(before.slice(0, before.indexOf("}"))).toContain( + `clip-path: var(${PILL_CLIP_VAR_NAME}, none)`, + ); + expect(branch).toContain(`background-image: var(${PILL_DECORATION_VAR_NAME}, none)`); + }); + + describe("the stadium underneath", () => { + it("is a plain fully-rounded rectangle on every branch", async () => { + // The whole fallback without the worklet — the same radius the `-full` + // utilities use, matching `rounded-full` — and, with it, the shape + // native inset decorations follow before the mask trims them. + const css = await compilePill(["squircle-pill"]); + expect(css).toMatch( + new RegExp( + `\\.squircle-pill \\{\\s*border-radius: ${FULL_RADIUS.replace(/[()*]/g, "\\$&")}`, + ), + ); + }); + + it("never reshapes the corner", async () => { + // On a pill the cap is the whole shape, so a superellipse changes the + // silhouette rather than softening a corner — it reads worse than a + // plain stadium. + const css = await compilePill(["squircle-pill"]); + expect(css).not.toContain("corner-shape"); + expect(css).not.toContain("superellipse"); + }); + + it("never falls back to a percentage radius", async () => { + // `50%` is an ellipse on any non-square element. + const css = await compilePill(["squircle-pill"]); + expect(css).not.toContain("border-radius: 50%"); + }); + }); + + describe("standing on its own", () => { + it("emits no declaration that pretends to set an attribute", async () => { + // A stylesheet cannot set an attribute, so `data-squircle-pill: ;` was + // inert: it could never make squircle-pill.css's `[data-squircle-pill]` + // rules match an element that only carries the class. + const css = await compilePill(["squircle-pill"]); + expect(css).not.toContain("data-squircle-pill:"); + }); + + it("registers the properties the pill reads", async () => { + // Without this the class alone would leave them unregistered, so they + // could not be typed, animated, or resolved to px for the worklet. + const css = await compilePillAll(["squircle-pill"]); + for (const name of [ + PILL_AMT_VAR_NAME, + PILL_EASE_VAR_NAME, + PILL_CONTINUITY_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_BORDER_COLOR_VAR_NAME, + ]) { + expect(css).toContain(`@property ${name}`); + } + // Amount 2, ease 2, G3. + expect(css).toMatch(new RegExp(`@property ${PILL_AMT_VAR_NAME} \\{[^}]*initial-value: 2;`)); + expect(css).toMatch(new RegExp(`@property ${PILL_EASE_VAR_NAME} \\{[^}]*initial-value: 2;`)); + expect(css).toMatch( + new RegExp(`@property ${PILL_CONTINUITY_VAR_NAME} \\{[^}]*initial-value: 3;`), + ); + expect(css).toContain("initial-value: 0px"); + }); + + it("gives the worklet an area to paint", async () => { + const css = await compilePill(["squircle-pill"]); + expect(css).toContain("min-width: 1px"); + expect(css).toContain("min-height: 1px"); + }); + }); + + describe("one shape, two knobs", () => { + it("has no size or side variants", async () => { + // A pill's caps are derived from its own size, and a mask has no + // per-side meaning, so there is nothing for such a variant to set. The + // border plugin scopes to `.squircle-pill` alone, so a copy under + // another name would also silently lose its border. + const css = await compilePill([ + "squircle-pill-t", + "squircle-pill-tl", + "squircle-pill-ss", + "squircle-pill-md", + "squircle-pill-full", + ]); + expect(css).toBe(""); + }); + + it("sets the amount, bare or arbitrary, and nothing else", async () => { + const css = await compilePill(["squircle-pill-amt-3", "squircle-pill-amt-[2.5]"]); + expect(css).toContain(`${PILL_AMT_VAR_NAME}: 3`); + expect(css).toContain(`${PILL_AMT_VAR_NAME}: 2.5`); + expect(css).not.toContain("mask-image"); + }); + + it("sets the ease, bare or arbitrary, and nothing else", async () => { + const css = await compilePill(["squircle-pill-ease-4", "squircle-pill-ease-[0.5]"]); + expect(css).toContain(`${PILL_EASE_VAR_NAME}: 4`); + expect(css).toContain(`${PILL_EASE_VAR_NAME}: 0.5`); + expect(css).not.toContain("mask-image"); + }); + + it("switches continuity with -g2 and -g3, and nothing else", async () => { + const css = await compilePill(["squircle-pill-g2", "squircle-pill-g3"]); + expect(css).toContain(`${PILL_CONTINUITY_VAR_NAME}: 2`); + expect(css).toContain(`${PILL_CONTINUITY_VAR_NAME}: 3`); + expect(css).not.toContain("mask-image"); + }); + + it("rejects values that are not numbers", async () => { + for (const candidate of [ + "squircle-pill-amt-[1em]", + "squircle-pill-amt-foo", + "squircle-pill-amt-(--my-amt)", + "squircle-pill-ease-[1px]", + ]) { + expect(await compilePill([candidate]), candidate).toBe(""); + } + }); + }); + + it("honours a custom prefix", async () => { + const css = await compilePill(["pillbox", "pillbox-amt-3"], 'prefix: "pillbox";'); + expect(css).toContain(".pillbox"); + expect(css).toContain(`border-radius: ${FULL_RADIUS}`); + expect(css).toContain(`${PILL_AMT_VAR_NAME}: 3`); + }); +}); diff --git a/package/src/tailwind-pill.ts b/package/src/tailwind-pill.ts new file mode 100644 index 0000000..d3227c0 --- /dev/null +++ b/package/src/tailwind-pill.ts @@ -0,0 +1,60 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import plugin from "tailwindcss/plugin"; +import { pillCssObj, pillPropertyRegistrations } from "./pill-css"; +import { PILL_AMT_VAR_NAME, PILL_CONTINUITY_VAR_NAME, PILL_EASE_VAR_NAME } from "./variants"; + +export interface SquirclePillPluginOptions { + /** Class name prefix for utilities (default: "squircle-pill") */ + prefix?: string; +} + +/** + * One utility, `squircle-pill`, because a pill is one shape: its caps are + * derived from the element's own size, so there is nothing for a size or a + * side variant to set. The knobs the shape does have — the easing amount, its + * ease, and its continuity — get `-amt-*`, `-ease-*` and `-g2`/`-g3` + * utilities, which only set the custom property, the same thing writing it + * yourself does. + * + * The rules themselves live in pill-css.ts, shared with the standalone + * stylesheet. + */ +const squirclePill: ReturnType> = + plugin.withOptions( + (options = {}) => + ({ addBase, addUtilities, matchUtilities }) => { + const prefix = options.prefix ?? "squircle-pill"; + + // The utility has to carry the registrations itself rather than lean + // on squircle-pill.css, which a Tailwind project need not load. + addBase(pillPropertyRegistrations()); + + addUtilities({ + [`.${prefix}`]: pillCssObj("tailwind") as Record>, + [`.${prefix}-g2`]: { [PILL_CONTINUITY_VAR_NAME]: "2" }, + [`.${prefix}-g3`]: { [PILL_CONTINUITY_VAR_NAME]: "3" }, + }); + + // `type: "number"` alone only admits arbitrary values; the bare-value + // hook is what lets `-amt-3` work like `squircle-amt-3` does through + // `@utility`. Anything that is not a plain number falls through. + const numbers = { + __BARE_VALUE__: ({ value }: { value: string }) => + /^-?\d+(\.\d+)?$/.test(value) ? value : undefined, + } as unknown as Record; + + matchUtilities( + { + [`${prefix}-amt`]: (value: string) => ({ [PILL_AMT_VAR_NAME]: value }), + [`${prefix}-ease`]: (value: string) => ({ [PILL_EASE_VAR_NAME]: value }), + }, + { type: "number", values: numbers }, + ); + }, + ); + +export default squirclePill; diff --git a/package/src/tailwind.test.ts b/package/src/tailwind.test.ts index 7c9d48d..6e90b6a 100644 --- a/package/src/tailwind.test.ts +++ b/package/src/tailwind.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { createCompiler, VARIANTS } from "./test-utils"; +import { DEFAULT_AMOUNT_VAR_NAME } from "./variants"; const { compilePlugin } = createCompiler(import.meta.dirname); @@ -151,6 +152,19 @@ describe("plugin.ts custom options", () => { expect(css).toContain("--squircle-amt: 3"); }); + it("deprecated amt-var still wins over the namespace default", async () => { + // The option predates the namespace and is deprecated, but overriding it + // must keep working — and must beat whatever the namespace resolves to. + const css = await compilePlugin(["squircle-md"], "amt-var: --se-amt;"); + expect(css).toContain("--se-amt"); + expect(css).not.toContain(DEFAULT_AMOUNT_VAR_NAME); + }); + + it("falls back to the namespaced name when the option is absent", async () => { + const css = await compilePlugin(["squircle-md"]); + expect(css).toContain(DEFAULT_AMOUNT_VAR_NAME); + }); + it("custom amt-var changes the CSS variable name", async () => { const css = await compilePlugin(["squircle-md"], "amt-var: --se-amt;"); expect(css).toContain("var(--se-amt, 2)"); diff --git a/package/src/tailwind.ts b/package/src/tailwind.ts index cf9bb86..ebf7238 100644 --- a/package/src/tailwind.ts +++ b/package/src/tailwind.ts @@ -18,12 +18,27 @@ import { export interface SquirclePluginOptions { /** CSS custom property name for the superellipse amount (default: "--squircle-amt") */ + /** + * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at + * build time, which renames every property this package owns together. This + * option still works and still wins, but it only ever reached the utilities + * this plugin emits — never the paint worklet, which names the properties it + * reads in a static `inputProperties` list. + */ amtVar?: string; - /** @plugin CSS alias for amtVar */ + /** + * @deprecated Alias for {@link SquirclePluginOptions.amtVar}; see there. + */ "amt-var"?: string; /** CSS custom property name for the intermediate corrected radius (default: "--squircle-r") */ + /** + * @deprecated Set the namespace instead, with `SQUIRCLE_CSS_NAMESPACE` at + * build time. This option still works and still wins. + */ rVar?: string; - /** @plugin CSS alias for rVar */ + /** + * @deprecated Alias for {@link SquirclePluginOptions.rVar}; see there. + */ "r-var"?: string; /** Class name prefix for utilities (default: "squircle") */ prefix?: string; @@ -33,60 +48,61 @@ const squircle: ReturnType> = plugin.withOptions((options = {}) => // eslint-disable-next-line @typescript-eslint/unbound-method ({ addUtilities, matchUtilities, theme }) => { - const amtVar = options.amtVar ?? options["amt-var"] ?? DEFAULT_AMOUNT_VAR_NAME; - const rVar = options.rVar ?? options["r-var"] ?? DEFAULT_R_VAR_NAME; - const prefix = options.prefix ?? "squircle"; - // Drop none/full from the functional values (the v3-compat theme still - // carries them) — they're registered as static utilities below instead, - // with the same values Tailwind uses for rounded-none/rounded-full. - const { none: _none, full: _full, ...radiusValues } = theme("borderRadius") ?? {}; + const amtVar = options.amtVar ?? options["amt-var"] ?? DEFAULT_AMOUNT_VAR_NAME; + const rVar = options.rVar ?? options["r-var"] ?? DEFAULT_R_VAR_NAME; + const prefix = options.prefix ?? "squircle"; + // Drop none/full from the functional values (the v3-compat theme still + // carries them) — they're registered as static utilities below instead, + // with the same values Tailwind uses for rounded-none/rounded-full. + const { none: _none, full: _full, ...radiusValues } = theme("borderRadius") ?? {}; - // Only sets the amount — the same thing writing the custom property - // yourself does. Applying a corner-shape here would reshape all four - // corners, including ones no squircle-* utility claimed. - matchUtilities( - { [`${prefix}-amt`]: (value: string) => ({ [amtVar]: value }) }, - { type: "number" }, - ); - - for (const [suffix, props] of variantEntries()) { - const name = suffix ? `${prefix}-${suffix}` : prefix; - // Static -none/-full utilities, registered the same way Tailwind - // defines rounded-none and rounded-full (0 and calc(infinity * 1px) - // rather than theme values). -none needs no superellipse correction. - addUtilities({ - [`.${name}-none`]: Object.fromEntries(props.map((p) => [p, NONE_RADIUS])), - [`.${name}-full`]: squircleFullCssObj(props, { amtVar }) as Record< - string, - string | Record - >, - }); + // Only sets the amount — the same thing writing the custom property + // yourself does. Applying a corner-shape here would reshape all four + // corners, including ones no squircle-* utility claimed. matchUtilities( - { - [name]: (value: string) => - squircleCssObj(props, value, { amtVar, rVar }) as Record< - string, - string | Record - >, - }, - { type: "length", values: radiusValues }, + { [`${prefix}-amt`]: (value: string) => ({ [amtVar]: value }) }, + { type: "number" }, ); - // Re-declare the matching rounded-* utility so it also resets the - // corners it owns back to `round`. Tailwind keeps its own definition and - // emits it alongside this one, so the radius still comes from core and - // this contributes only the reset — the initial value, so it is inert - // unless a squircle class set a shape on the same element. Without it a - // rounded-* utility cannot take a corner back from a squircle, since it - // only ever sets a radius. Restricted to lengths, the same values the - // squircle-* utilities accept: a paren ref keeps its squircle shape, so - // reach for a theme key there as everywhere else in this package. - const roundedName = suffix ? `rounded-${suffix}` : "rounded"; - const reset = Object.fromEntries(props.map((p) => [cornerShapeProp(p), "round"])); - addUtilities({ [`.${roundedName}-full`]: reset }); - matchUtilities({ [roundedName]: () => reset }, { type: "length", values: radiusValues }); - } - }); + for (const [suffix, props] of variantEntries()) { + const name = suffix ? `${prefix}-${suffix}` : prefix; + // Static -none/-full utilities, registered the same way Tailwind + // defines rounded-none and rounded-full (0 and calc(infinity * 1px) + // rather than theme values). -none needs no superellipse correction. + addUtilities({ + [`.${name}-none`]: Object.fromEntries(props.map((p) => [p, NONE_RADIUS])), + [`.${name}-full`]: squircleFullCssObj(props, { amtVar }) as Record< + string, + string | Record + >, + }); + matchUtilities( + { + [name]: (value: string) => + squircleCssObj(props, value, { amtVar, rVar }) as Record< + string, + string | Record + >, + }, + { type: "length", values: radiusValues }, + ); + + // Re-declare the matching rounded-* utility so it also resets the + // corners it owns back to `round`. Tailwind keeps its own definition and + // emits it alongside this one, so the radius still comes from core and + // this contributes only the reset — the initial value, so it is inert + // unless a squircle class set a shape on the same element. Without it a + // rounded-* utility cannot take a corner back from a squircle, since it + // only ever sets a radius. Restricted to lengths, the same values the + // squircle-* utilities accept: a paren ref keeps its squircle shape, so + // reach for a theme key there as everywhere else in this package. + const roundedName = suffix ? `rounded-${suffix}` : "rounded"; + const reset = Object.fromEntries(props.map((p) => [cornerShapeProp(p), "round"])); + addUtilities({ [`.${roundedName}-full`]: reset }); + matchUtilities({ [roundedName]: () => reset }, { type: "length", values: radiusValues }); + } + }, + ); export default squircle; diff --git a/package/src/test-utils.ts b/package/src/test-utils.ts index 6d081cd..79d45cd 100644 --- a/package/src/test-utils.ts +++ b/package/src/test-utils.ts @@ -65,10 +65,14 @@ export function createCompiler(srcDir: string) { return extractUtilitiesLayer(compiler.build(candidates)); } - async function compilePlugin(candidates: string[], pluginBlock = ""): Promise { + async function compilePlugin( + candidates: string[], + pluginBlock = "", + plugin = "./tailwind.ts", + ): Promise { const pluginDecl = pluginBlock - ? `@plugin "./tailwind.ts" {\n${pluginBlock}\n}` - : `@plugin "./tailwind.ts";`; + ? `@plugin "${plugin}" {\n${pluginBlock}\n}` + : `@plugin "${plugin}";`; const input = ` @import "tailwindcss"; ${pluginDecl} @@ -81,5 +85,22 @@ ${pluginDecl} return extractUtilitiesLayer(compiler.build(candidates)); } - return { compileCss, compilePlugin }; + /** The whole build, not just the utilities layer — for base-layer output. */ + async function compilePluginAll( + candidates: string[], + pluginBlock = "", + plugin = "./tailwind.ts", + ): Promise { + const pluginDecl = pluginBlock + ? `@plugin "${plugin}" {\n${pluginBlock}\n}` + : `@plugin "${plugin}";`; + const compiler = await compile(`\n@import "tailwindcss";\n${pluginDecl}\n`, { + base: srcDir, + loadStylesheet, + loadModule, + }); + return compiler.build(candidates); + } + + return { compileCss, compilePlugin, compilePluginAll }; } diff --git a/package/src/variants.ts b/package/src/variants.ts index 8a01759..79d16f8 100644 --- a/package/src/variants.ts +++ b/package/src/variants.ts @@ -4,8 +4,141 @@ */ export const DEFAULT_AMT = 2 as const; -export const DEFAULT_AMOUNT_VAR_NAME = "--squircle-amt" as const; -export const DEFAULT_R_VAR_NAME = "--squircle-r" as const; + +/* ── Pill custom properties ─────────────────────────────────── + * Namespaced, because `--pill-*` is the kind of name a design + * system is likely to have taken already. + * + * The worklet names these in `inputProperties`, a static list read + * once at registration, so there is no hook to rename them per + * project the way `--squircle-amt` can be. The prefix is therefore + * fixed when the package is built: set SQUIRCLE_CSS_NAMESPACE to + * change it, and the worklet, the plugins and the stylesheet all + * follow from the same value. + * + * Both the Tailwind plugin and squircle-pill.css register these + * with initial values matching the worklet's own fallbacks. + * ──────────────────────────────────────────────────────────── */ +declare const __SQUIRCLE_CSS_NAMESPACE__: string | undefined; + +/** + * Vite inlines the define; build scripts that import this module under plain + * `tsx` get no define, so they read the environment directly. `typeof` on an + * undeclared name is safe, which is what makes the first branch usable either + * way. + */ +export const CSS_NAMESPACE: string = + typeof __SQUIRCLE_CSS_NAMESPACE__ === "string" + ? __SQUIRCLE_CSS_NAMESPACE__ + : (globalThis.process?.env?.SQUIRCLE_CSS_NAMESPACE ?? "squircle"); + +/** `---`, e.g. `--squircle-amt`. */ +const coreVar = (name: string) => `--${CSS_NAMESPACE}-${name}`; +/** `---pill-`, e.g. `--squircle-pill-border-width`. */ +const pillVar = (name: string) => `--${CSS_NAMESPACE}-pill-${name}`; + +export const PILL_AMT_VAR_NAME: string = pillVar("amt"); +export const DEFAULT_PILL_AMT = 2 as const; +/** + * How far the pill's easing is drawn along the flat edge: `0` is a clothoid, + * higher is softer. See the worklet. + */ +export const PILL_EASE_VAR_NAME: string = pillVar("ease"); +export const DEFAULT_PILL_EASE = 2 as const; +/** Border the worklet draws itself, since a CSS border cannot follow the shape. */ +export const PILL_BORDER_WIDTH_VAR_NAME: string = pillVar("border-width"); +export const PILL_BORDER_COLOR_VAR_NAME: string = pillVar("border-color"); +export const PILL_BORDER_STYLE_VAR_NAME: string = pillVar("border-style"); +export const PILL_BORDER_STYLE_FALLBACK = "solid" as const; +/** + * Outline, ring and inset ring the pill draws itself, around or inside its + * own outline: a native `outline` or ring `box-shadow` follows the stadium + * `border-radius`, which sits up to a few pixels off the pill. They mirror + * what `outline-*`, `ring-*`, `ring-offset-*` and `inset-ring-*` mean. + */ +export const PILL_OUTLINE_WIDTH_VAR_NAME: string = pillVar("outline-width"); +export const PILL_OUTLINE_OFFSET_VAR_NAME: string = pillVar("outline-offset"); +export const PILL_OUTLINE_COLOR_VAR_NAME: string = pillVar("outline-color"); +export const PILL_OUTLINE_STYLE_VAR_NAME: string = pillVar("outline-style"); +export const PILL_RING_WIDTH_VAR_NAME: string = pillVar("ring-width"); +export const PILL_RING_COLOR_VAR_NAME: string = pillVar("ring-color"); +export const PILL_RING_OFFSET_WIDTH_VAR_NAME: string = pillVar("ring-offset-width"); +export const PILL_RING_OFFSET_COLOR_VAR_NAME: string = pillVar("ring-offset-color"); +export const PILL_INSET_RING_WIDTH_VAR_NAME: string = pillVar("inset-ring-width"); +export const PILL_INSET_RING_COLOR_VAR_NAME: string = pillVar("inset-ring-color"); +/** + * The pill's box shadows, as a `box-shadow` list. The pill draws the outer + * ones itself, cast by its own outline: a native shadow starts at the + * stadium `border-radius`, a pixel or more outside the pill near its caps. + * The inset ones paint inside it as usual. With Tailwind this is built from + * the `shadow-*`, `inset-shadow-*` and ring utilities' own variables. + */ +export const PILL_BOX_SHADOW_VAR_NAME: string = pillVar("box-shadow"); +/** + * How far those shadows reach past the box — offset, blur and spread + * together — which the box they are drawn on has to grow by. + * `tailwind-pill-border` sets it for `shadow-*`. + */ +export const PILL_SHADOW_REACH_VAR_NAME: string = pillVar("shadow-reach"); +/** + * Internal: how far everything the pill draws around itself reaches past + * its box — outline, ring, shadows — worked out by the stylesheet. + */ +export const PILL_REACH_VAR_NAME: string = pillVar("reach"); +/** + * Internal: how far the copy of the background pulls back from the outline, + * worked out by the stylesheet: half a pixel under a border or inset ring + * touching the outline, so the two never share an anti-aliased edge. + */ +export const PILL_BACKGROUND_INSET_VAR_NAME: string = pillVar("background-inset"); +/** + * Internal: `""` on a pill `tailwind-pill-border` has seen a border, outline, + * ring or shadow utility on, which is what gives it the `::after` those are + * drawn on; `none` otherwise. + */ +export const PILL_DECORATED_VAR_NAME: string = pillVar("decorated"); +/** Internal: the polyfill's drawing of those, as an image for `::after`. */ +export const PILL_DECORATION_VAR_NAME: string = pillVar("decoration"); +/** + * Internal: where the polyfill's drawing is a single band — a border, ring or + * outline alone — the band's `clip-path` for `::after`, its colour then being + * the image. A clip is far cheaper to redraw on resize than an image. + */ +export const PILL_DECORATION_CLIP_VAR_NAME: string = pillVar("decoration-clip"); +/** + * Geometric continuity of the pill's easing: `2` matches curvature where the + * cap meets the edge, `3` also matches its rate of change. See the worklet. + */ +export const PILL_CONTINUITY_VAR_NAME: string = pillVar("continuity"); +export const DEFAULT_PILL_CONTINUITY = 3 as const; +/** + * Attribute `registerPillWorklet()` sets on `` once the worklet has + * loaded. `@supports (mask-image: paint(pill-shape))` is true for any paint + * name, loaded or not, so the mask is gated on this instead; until it appears + * a pill is a plain stadium. + */ +export const PILL_WORKLET_ATTRIBUTE: string = `data-${CSS_NAMESPACE}-pill-worklet`; +/** + * Attribute `polyfillPills()` sets on `` where there is no paint + * worklet. The pills then take their shape from the `clip-path` below, which + * it computes per element from the same geometry the worklet draws. + */ +export const PILL_POLYFILL_ATTRIBUTE: string = `data-${CSS_NAMESPACE}-pill-polyfill`; +export const PILL_CLIP_VAR_NAME: string = pillVar("clip"); +/** What the standalone stylesheet hangs its rules off, so it works without Tailwind. */ +export const PILL_ATTRIBUTE: string = `data-${CSS_NAMESPACE}-pill`; +/** + * The shared amount and radius properties, from the same namespace as + * everything else. At the default namespace these are the documented + * `--squircle-amt` and `--squircle-r`, unchanged. + * + * The `amtVar` and `rVar` plugin options still override them and take + * precedence, but are deprecated: they predate the namespace and only ever + * reached utilities the plugins emit, never the paint worklet, which names the + * properties it reads in a static `inputProperties` list. + */ +export const DEFAULT_AMOUNT_VAR_NAME: string = coreVar("amt"); +export const DEFAULT_R_VAR_NAME: string = coreVar("r"); /** Static value for `squircle-full`; matches Tailwind's `rounded-full`. */ export const FULL_RADIUS = "calc(infinity * 1px)" as const; /** Static value for `squircle-none`; matches Tailwind's `rounded-none`. */ diff --git a/package/vite.config.ts b/package/vite.config.ts index ec5db17..c47931e 100644 --- a/package/vite.config.ts +++ b/package/vite.config.ts @@ -1,16 +1,48 @@ -import tailwindcss from "@tailwindcss/vite"; +// Pins the namespace before src/ modules are evaluated; keep it first. +import { CSS_NAMESPACE } from "./scripts/load-namespace"; import { defineConfig } from "vite-plus"; +/** + * Prefix for every custom property this package owns, resolved once in + * scripts/load-namespace.ts and inlined at build time. + * + * The paint worklet names the properties it reads in `inputProperties`, which + * is a static list read once when the worklet registers — there is no per- + * element or per-consumer hook to rename them through. So the namespace has to + * be fixed when the code is built, not when it is used, and it is shared from + * here with the worklet, the plugins and the stylesheet so they cannot disagree. + * + * Every task that bakes the value in declares the variable in its `env`, which + * both forwards it to the task process and folds it into the cache key. An + * undeclared variable is stripped, so without that it would appear to work when + * a script was run directly and quietly do nothing through `vp run`. + */ +const NAMESPACE_ENV = ["SQUIRCLE_CSS_NAMESPACE"]; + export default defineConfig({ - plugins: [tailwindcss()], + // Covers the test run; `pack.define` covers the library + // build, which does not inherit this one. + define: { + __SQUIRCLE_CSS_NAMESPACE__: JSON.stringify(CSS_NAMESPACE), + }, test: { include: ["src/**/*.test.ts"], }, pack: { + define: { + __SQUIRCLE_CSS_NAMESPACE__: JSON.stringify(CSS_NAMESPACE), + }, entry: { "tailwind/index": "./src/tailwind.ts", + "tailwind-pill/index": "./src/tailwind-pill.ts", + "tailwind-pill-border/index": "./src/tailwind-pill-border.ts", "panda/index": "./src/panda.ts", "stylex/index": "./src/stylex.ts", + // Siblings: the registration helper locates the worklet relative to + // itself, so the two have to land in the same directory. + "pill-worklet": "./src/pill-worklet.ts", + "pill-polyfill": "./src/pill-polyfill.ts", + "pill-shape.worklet": "./src/pill-shape.worklet.ts", }, format: "esm", dts: true, @@ -18,16 +50,19 @@ export default defineConfig({ run: { tasks: { "test:tailwind": { + cache: { env: NAMESPACE_ENV }, // Matches tailwind.test.ts and tailwind-merge.test.ts; the latter // compiles the generated utils.css, so the build has to run first. command: "vp test run tailwind", dependsOn: ["build"], }, "test:css": { + cache: { env: NAMESPACE_ENV }, command: "vp test run squircle-css", dependsOn: ["build"], }, "test:radius": { + cache: { env: NAMESPACE_ENV }, command: "vp test run squircle-radius", dependsOn: ["build"], }, @@ -37,16 +72,29 @@ export default defineConfig({ "test:stylex": { command: "vp test run stylex", }, + "test:pill": { + cache: { env: NAMESPACE_ENV }, + command: "vp test run pill-", + }, test: { command: "echo 'All tests passed'", - dependsOn: ["test:tailwind", "test:css", "test:radius", "test:panda", "test:stylex"], + dependsOn: [ + "test:tailwind", + "test:css", + "test:radius", + "test:panda", + "test:stylex", + "test:pill", + ], }, "generate:stylex": { command: "tsx scripts/generate-stylex.ts", }, build: { - command: - "tsx scripts/generate-stylex.ts && vp pack && tsx scripts/generate-squircle-css.ts", + cache: { env: NAMESPACE_ENV }, + // One script, so the pack and the generators are one cache entry; + // see scripts/build.ts. + command: "tsx scripts/build.ts", }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8a643b..b0d5890 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,12 +28,21 @@ catalogs: specifier: ^25.0.3 version: 25.0.3 vite-plus: - specifier: latest - version: 0.1.15 + specifier: ^1.0.0 + version: 1.0.0 overrides: - vite: npm:@voidzero-dev/vite-plus-core@latest - vitest: npm:@voidzero-dev/vite-plus-test@latest + vite: npm:@voidzero-dev/vite-plus-core@^1.0.0 + vitest: 5.0.3 + '@vitest/browser': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/mocker': 5.0.3 + '@vitest/pretty-format': 5.0.3 + '@vitest/snapshot': 5.0.3 + '@vitest/spy': 5.0.3 + '@vitest/ui': 5.0.3 + '@vitest/utils': 5.0.3 + '@vitest/expect': 5.0.3 importers: @@ -62,7 +71,7 @@ importers: version: 25.0.3(typescript@6.0.2) vite-plus: specifier: 'catalog:' - version: 0.1.15(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) package: dependencies: @@ -90,13 +99,13 @@ importers: version: 0.18.3 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + version: 4.2.2(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) '@types/node': specifier: ^25.5.2 version: 25.5.2 oxlint: specifier: ^1.58.0 - version: 1.58.0(oxlint-tsgolint@0.18.1) + version: 1.58.0(oxlint-tsgolint@7.0.2003) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -104,14 +113,14 @@ importers: specifier: ^6.0.2 version: 6.0.2 vite: - specifier: npm:@voidzero-dev/vite-plus-core@latest - version: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: npm:@voidzero-dev/vite-plus-core@^1.0.0 + version: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.15(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + version: 1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) vitest: - specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + specifier: 5.0.3 + version: 5.0.3(@types/node@25.5.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) website: dependencies: @@ -357,6 +366,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -369,6 +382,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -927,8 +943,8 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1024,162 +1040,165 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - '@oxc-project/runtime@0.122.0': - resolution: {integrity: sha512-vevyz3bNjevQFCV2Yg5o6Sp9BSoiYiJVymMrzA3S1ZGj4J8ak4YiywhFyQMueQ3UNlJU6HZOZYDy70TUc99aHw==} + '@oxc-project/runtime@0.151.0': + resolution: {integrity: sha512-fNuliiqGTseerW2FTbxq6UOQavKL/NvNh9K3peJZZVNi1NbM4Ehscwp3MX9nu+9bfbSfmPUEivF779XQU5exiw==} engines: {node: ^20.19.0 || >=22.12.0} '@oxc-project/types@0.122.0': resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} - '@oxfmt/binding-android-arm-eabi@0.43.0': - resolution: {integrity: sha512-CgU2s+/9hHZgo0IxVxrbMPrMj+tJ6VM3mD7Mr/4oiz4FNTISLoCvRmB5nk4wAAle045RtRjd86m673jwPyb1OQ==} + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@oxfmt/binding-android-arm-eabi@0.70.0': + resolution: {integrity: sha512-Xd7YO4/T2axEj6FTLcj4Why3mTBqFMg+x24xtorT4Lb2+1g82090GH0a/4U1m0pABGYiix2bq1pqkYrmV3f0Sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.43.0': - resolution: {integrity: sha512-T9OfRwjA/EdYxAqbvR7TtqLv5nIrwPXuCtTwOHtS7aR9uXyn74ZYgzgTo6/ZwvTq9DY4W+DsV09hB2EXgn9EbA==} + '@oxfmt/binding-android-arm64@0.70.0': + resolution: {integrity: sha512-x9rlMYyKXdgKdYyUJzGsK1ZV8P4di/J32ipzcS6Jet6p9r9UAh28neXIMtdlSaJJycdi61Z4YkcLKLpk8ueFjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.43.0': - resolution: {integrity: sha512-o3i49ZUSJWANzXMAAVY1wnqb65hn4JVzwlRQ5qfcwhRzIA8lGVaud31Q3by5ALHPrksp5QEaKCQF9aAS3TXpZA==} + '@oxfmt/binding-darwin-arm64@0.70.0': + resolution: {integrity: sha512-IUTUPvrBVYy7POh4stXzRdz4IVC/1QSaviCWoyenSlOhGu0X9j5K07vCTM9biLjAA2Zs31l0Rj5vvRpj9n95wA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.43.0': - resolution: {integrity: sha512-vWECzzCFkb0kK6jaHjbtC5sC3adiNWtqawFCxhpvsWlzVeKmv5bNvkB4nux+o4JKWTpHCM57NDK/MeXt44txmA==} + '@oxfmt/binding-darwin-x64@0.70.0': + resolution: {integrity: sha512-vw745q870oTd6J517O24asoX4/E+eK0nxYIFoedSLqgJ+nI5En7+ZS82iZSHZ69zevQrnOXiyHP01dA+t8xD8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.43.0': - resolution: {integrity: sha512-rgz8JpkKiI/umOf7fl9gwKyQasC8bs5SYHy6g7e4SunfLBY3+8ATcD5caIg8KLGEtKFm5ujKaH8EfjcmnhzTLg==} + '@oxfmt/binding-freebsd-x64@0.70.0': + resolution: {integrity: sha512-NO14EgSM9dFkcg+MfGPxvsKqXYs9LKaxPrOKXpv1R0rLokGGFDcCq6dBMq18dE4wlpFOovX0UZY2uh1P30O7QA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': - resolution: {integrity: sha512-nWYnF3vIFzT4OM1qL/HSf1Yuj96aBuKWSaObXHSWliwAk2rcj7AWd6Lf7jowEBQMo4wCZVnueIGw/7C4u0KTBQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': + resolution: {integrity: sha512-139OEhHarj9CYoJ/i9gXlPv4KLBGtLj2toseOWYFf09QwlhklaZk+wW3aOvlqoeZtuayvkoSNVWra7WJ21s3VQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.43.0': - resolution: {integrity: sha512-sFg+NWJbLfupYTF4WELHAPSnLPOn1jiDZ33Z1jfDnTaA+cC3iB35x0FMMZTFdFOz3icRIArncwCcemJFGXu6TQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': + resolution: {integrity: sha512-GEh2PY3IWTE0M24eNhTduountANSbWyDmMnzFSQE/nGg/bjPugbUgiGuFu+xdqcQSd/HKSwH80/F2yVVD48yhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.43.0': - resolution: {integrity: sha512-MelWqv68tX6wZEILDrTc9yewiGXe7im62+5x0bNXlCYFOZdA+VnYiJfAihbROsZ5fm90p9C3haFrqjj43XnlAA==} + '@oxfmt/binding-linux-arm64-gnu@0.70.0': + resolution: {integrity: sha512-En5i+UJmZSPxuSf47F2Hl5YOzKB0bicQLnGQkeTCMQ35cWLtbrSwACJKfiLqRZrk05DwSnsJkhBRaM3OURtIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.43.0': - resolution: {integrity: sha512-ROaWfYh+6BSJ1Arwy5ujijTlwnZetxDxzBpDc1oBR4d7rfrPBqzeyjd5WOudowzQUgyavl2wEpzn1hw3jWcqLA==} + '@oxfmt/binding-linux-arm64-musl@0.70.0': + resolution: {integrity: sha512-WWOoV5W9Im3flVwOVrWn/2DUlOF8v5vcCip+kcNuaMpulRCh6nzzt1Su2vcL2F908YJIXNV3HvegbBHuyLwKHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.43.0': - resolution: {integrity: sha512-PJRs/uNxmFipJJ8+SyKHh7Y7VZIKQicqrrBzvfyM5CtKi8D7yZKTwUOZV3ffxmiC2e7l1SDJpkBEOyue5NAFsg==} + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': + resolution: {integrity: sha512-YUouneIqW+5n7aE8xx/zeZ6/utr/KH7oykcGoFyd8Uz8uh591T1oKlnoWA3BsRq/ZR42oY1w4MUYvS/0e/MQOA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.43.0': - resolution: {integrity: sha512-j6biGAgzIhj+EtHXlbNumvwG7XqOIdiU4KgIWRXAEj/iUbHKukKW8eXa4MIwpQwW1YkxovduKtzEAPnjlnAhVQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': + resolution: {integrity: sha512-iEnMf21S5aGVa4hViDGY8sAQ/AHyCu2JPyrQF8P06wtHhSkD1YJBeT4m/KiGewgf7+a5XCYSCRIPcRQa1xwEoQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.43.0': - resolution: {integrity: sha512-RYWxAcslKxvy7yri24Xm9cmD0RiANaiEPs007EFG6l9h1ChM69Q5SOzACaCoz4Z9dEplnhhneeBaTWMEdpgIbA==} + '@oxfmt/binding-linux-riscv64-musl@0.70.0': + resolution: {integrity: sha512-91Sdniaj20fQzyMeCxMDzTP4c9s4RB8dGQ308xHhDR0n6U7+1Xq7N9klE7mfXq8iV3lRmIGSXi5X23Hn/0XX/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.43.0': - resolution: {integrity: sha512-DT6Q8zfQQy3jxpezAsBACEHNUUixKSYTwdXeXojNHe4DQOoxjPdjr3Szu6BRNjxLykZM/xMNmp9ElOIyDppwtw==} + '@oxfmt/binding-linux-s390x-gnu@0.70.0': + resolution: {integrity: sha512-uUV30M6E+2TKKGMaKiwfeL4RZrviHXlUxsrYJ/jFBb+1EZy+pnFT+hF73eeWdzh5NqPOAnw0iiMAIqjqiLZPFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.43.0': - resolution: {integrity: sha512-R8Yk7iYcuZORXmCfFZClqbDxRZgZ9/HEidUuBNdoX8Ptx07cMePnMVJ/woB84lFIDjh2ROHVaOP40Ds3rBXFqg==} + '@oxfmt/binding-linux-x64-gnu@0.70.0': + resolution: {integrity: sha512-ivMcX6kNDPhqtbOaBt/ItFlLlTlXNHLgRuNmxP6Na6UuYXRT10llpJcAPbGeRgjjb3Qzv4jwPp3fB0hui50WNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.43.0': - resolution: {integrity: sha512-F2YYqyvnQNvi320RWZNAvsaWEHwmW3k4OwNJ1hZxRKXupY63expbBaNp6jAgvYs7y/g546vuQnGHQuCBhslhLQ==} + '@oxfmt/binding-linux-x64-musl@0.70.0': + resolution: {integrity: sha512-w+S+fERxYmlZSyZlJK/U292FjyBoH8cCEj21/tYJX6atX5kNSn+HDkhlFQKT2zcMwUW0uAtUL/bOrlwJZwqRdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.43.0': - resolution: {integrity: sha512-OE6TdietLXV3F6c7pNIhx/9YC1/2YFwjU9DPc/fbjxIX19hNIaP1rS0cFjCGJlGX+cVJwIKWe8Mos+LdQ1yAJw==} + '@oxfmt/binding-openharmony-arm64@0.70.0': + resolution: {integrity: sha512-Zlom1Xkx257R8bk4ZI4zJsrGno2opknz1+5v5baka3nn4FPyvNSdh8JUL4CdN1S1OWRMvJ9UJQ+RfIqGGCUEfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.43.0': - resolution: {integrity: sha512-0nWK6a7pGkbdoypfVicmV9k/N1FwjPZENoqhlTU+5HhZnAhpIO3za30nEE33u6l6tuy9OVfpdXUqxUgZ+4lbZw==} + '@oxfmt/binding-win32-arm64-msvc@0.70.0': + resolution: {integrity: sha512-FQgPW5R17vzt7cgrJ8eG/dqX00o2xHsqFeLfw4xzA9FRHpN/DjFo9YDonvIIXGxiEuS9F/jZPnGO+KHNKuCo4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.43.0': - resolution: {integrity: sha512-9aokTR4Ft+tRdvgN/pKzSkVy2ksc4/dCpDm9L/xFrbIw0yhLtASLbvoG/5WOTUh/BRPPnfGTsWznEqv0dlOmhA==} + '@oxfmt/binding-win32-ia32-msvc@0.70.0': + resolution: {integrity: sha512-ZfZublNhZ+XBndMiXhkiLlPE+XyGRDa0CweeTL6t1fZypfCh1LTg7e5CvnOeTBunq15MskOcRempumSPGAaCQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.43.0': - resolution: {integrity: sha512-4bPgdQux2ZLWn3bf2TTXXMHcJB4lenmuxrLqygPmvCJ104Yqzj1UctxSRzR31TiJ4MLaG22RK8dUsVpJtrCz5g==} + '@oxfmt/binding-win32-x64-msvc@0.70.0': + resolution: {integrity: sha512-HlIZEn+WzLQL0DszNzldiRl/DPRCX5R0Vkt6qeUPR1YHwy52hZZo4x6HoTOVmKRP2wUiwPGtKsihNY/f8KRaBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.18.1': - resolution: {integrity: sha512-CxSd15ZwHn70UJFTXVvy76bZ9zwI097cVyjvUFmYRJwvkQF3VnrTf2oe1gomUacErksvtqLgn9OKvZhLMYwvog==} + '@oxlint-tsgolint/darwin-arm64@7.0.2003': + resolution: {integrity: sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.18.1': - resolution: {integrity: sha512-LE7VW/T/VcKhl3Z1ev5BusrxdlQ3DWweSeOB+qpBeur2h8+vCWq+M7tCO29C7lveBDfx1+rNwj4aiUVlA+Qs+g==} + '@oxlint-tsgolint/darwin-x64@7.0.2003': + resolution: {integrity: sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.18.1': - resolution: {integrity: sha512-2AG8YIXVJJbnM0rcsJmzzWOjZXBu5REwowgUpbHZueF7OYM3wR7Xu8pXEpAojEHAtYYZ3X4rpPoetomkJx7kCw==} + '@oxlint-tsgolint/linux-arm64@7.0.2003': + resolution: {integrity: sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.18.1': - resolution: {integrity: sha512-f8vDYPEdiwpA2JaDEkadTXfuqIgweQ8zcL4SX75EN2kkW2oAynjN7cd8m86uXDgB0JrcyOywbRtwnXdiIzXn2A==} + '@oxlint-tsgolint/linux-x64@7.0.2003': + resolution: {integrity: sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.18.1': - resolution: {integrity: sha512-fBdML05KMDAL9ebWeoHIzkyI86Eq6r9YH5UDRuXJ9vAIo1EnKo0ti7hLUxNdc2dy2FF/T4k98p5wkkXvLyXqfA==} + '@oxlint-tsgolint/win32-arm64@7.0.2003': + resolution: {integrity: sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.18.1': - resolution: {integrity: sha512-cYZMhNrsq9ZZ3OUWHyawqiS+c8HfieYG0zuZP2LbEuWWPfdZM/22iAlo608J+27G1s9RXQhvgX6VekwWbXbD7A==} + '@oxlint-tsgolint/win32-x64@7.0.2003': + resolution: {integrity: sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==} cpu: [x64] os: [win32] @@ -1189,42 +1208,84 @@ packages: cpu: [arm] os: [android] + '@oxlint/binding-android-arm-eabi@1.85.0': + resolution: {integrity: sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxlint/binding-android-arm64@1.58.0': resolution: {integrity: sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxlint/binding-android-arm64@1.85.0': + resolution: {integrity: sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxlint/binding-darwin-arm64@1.58.0': resolution: {integrity: sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxlint/binding-darwin-arm64@1.85.0': + resolution: {integrity: sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxlint/binding-darwin-x64@1.58.0': resolution: {integrity: sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxlint/binding-darwin-x64@1.85.0': + resolution: {integrity: sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxlint/binding-freebsd-x64@1.58.0': resolution: {integrity: sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxlint/binding-freebsd-x64@1.85.0': + resolution: {integrity: sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxlint/binding-linux-arm-gnueabihf@1.58.0': resolution: {integrity: sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': + resolution: {integrity: sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.58.0': resolution: {integrity: sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.85.0': + resolution: {integrity: sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm64-gnu@1.58.0': resolution: {integrity: sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1232,6 +1293,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-arm64-gnu@1.85.0': + resolution: {integrity: sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-arm64-musl@1.58.0': resolution: {integrity: sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1239,6 +1307,13 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-arm64-musl@1.85.0': + resolution: {integrity: sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxlint/binding-linux-ppc64-gnu@1.58.0': resolution: {integrity: sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1246,6 +1321,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-ppc64-gnu@1.85.0': + resolution: {integrity: sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-riscv64-gnu@1.58.0': resolution: {integrity: sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1253,6 +1335,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-riscv64-gnu@1.85.0': + resolution: {integrity: sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-riscv64-musl@1.58.0': resolution: {integrity: sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1260,6 +1349,13 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-riscv64-musl@1.85.0': + resolution: {integrity: sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxlint/binding-linux-s390x-gnu@1.58.0': resolution: {integrity: sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1267,6 +1363,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-s390x-gnu@1.85.0': + resolution: {integrity: sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-x64-gnu@1.58.0': resolution: {integrity: sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1274,6 +1377,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-x64-gnu@1.85.0': + resolution: {integrity: sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-x64-musl@1.58.0': resolution: {integrity: sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1281,30 +1391,65 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-x64-musl@1.85.0': + resolution: {integrity: sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxlint/binding-openharmony-arm64@1.58.0': resolution: {integrity: sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxlint/binding-openharmony-arm64@1.85.0': + resolution: {integrity: sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxlint/binding-win32-arm64-msvc@1.58.0': resolution: {integrity: sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxlint/binding-win32-arm64-msvc@1.85.0': + resolution: {integrity: sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.58.0': resolution: {integrity: sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.85.0': + resolution: {integrity: sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxlint/binding-win32-x64-msvc@1.58.0': resolution: {integrity: sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxlint/binding-win32-x64-msvc@1.85.0': + resolution: {integrity: sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.79.0': + resolution: {integrity: sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pandacss/config@1.11.0': resolution: {integrity: sha512-GYBhyhAwTFdHNJmFmly3uOwa3BDxSdGDEXiacBmJv0iZlk2O6IXEccsZcaJmCbFRPMfGqYbvcdz40D+3w/uQjA==} @@ -1561,9 +1706,6 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@stylexjs/babel-plugin@0.18.3': resolution: {integrity: sha512-5VeMIChNaXWlizLUL6c7WrMfuhydHXl3ASTlbj3O0qlEkNOlcgZMH3zzZ0iUrOSFTSgbHAOWUsVJKX5bFeZk4A==} @@ -1675,12 +1817,25 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@ts-morph/common@0.29.0': resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1769,35 +1924,68 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@voidzero-dev/vite-plus-core@0.1.15': - resolution: {integrity: sha512-0qAbqwcvQwiC8xGKSSuFtsjJUEM4LZzpXF7dffRazghGEQ8HH8NAvVryp/PiMSFwreJlV3rujwL4amKjnwCHpg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@vitest/browser-preview@5.0.3': + resolution: {integrity: sha512-jfnEBv5yDRPR1MeiTlTW5bcZoPsiI7MBhpZR8IJiSxVhdK6Pa4gPk3tLHTRxVHy24kkWxY2TnUbnszU3J1bQJw==} + peerDependencies: + vitest: 5.0.3 + + '@vitest/browser@5.0.3': + resolution: {integrity: sha512-7KHIkVA/gTRxQV9qPa9qLK+g5INZG6iRrlwXSFG5EKsOomW3UzzW7wsdt1Yp5PufzQ43lniuONlb4lUVCC2qSg==} + peerDependencies: + vitest: 5.0.3 + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@5.0.3': + resolution: {integrity: sha512-3hr5eV5z2ff0l2Q1vaKQbB4j3M3tElw8stDokEOTqXJB3Es5VrT8gBRIMY59gGuFp8Y61sWlWFK1aNUH5dHDYw==} + + '@vitest/snapshot@5.0.3': + resolution: {integrity: sha512-SK4YTg9jL+jwakHIZxfciuvyOQsbY15AueR3USGtsXzmy+3FbJgqjAbC+ac20mRLwfEDtO5sfTqK6bUCDTRVPA==} + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + '@vitest/ui@5.0.3': + resolution: {integrity: sha512-9dfvqDBvSbHM6y9YVXxAypkl2z7mrWozK/JFo237qI0cj/3M/kzDKiVZqzi3TE54Ler7Jditud7OMBZCyUvRHA==} + peerDependencies: + vitest: 5.0.3 + + '@vitest/utils@5.0.3': + resolution: {integrity: sha512-77fDTZv8eu1CzlIfgPU2yrl5MXSUaUAOvgdZEeVm3X3WIxUubIX2SforqS6176lT32HCF6uBVMYVqfHxw7o6eg==} + + '@voidzero-dev/vite-plus-core@1.0.0': + resolution: {integrity: sha512-GHCd6d7h3JVojI/yxe/EAHwX5gn+OI7G932VBnYoTiwATLJrvHOs+okx7Ek2LGEJ57NJRENwCFgVYefjwPLfFA==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 - '@tsdown/css': 0.21.7 - '@tsdown/exe': 0.21.7 '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 - publint: ^0.3.0 + publint: ^0.3.8 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 - typescript: ^5.0.0 || ^6.0.0 - unplugin-unused: ^0.5.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: '>=0.5.0' + unrun: '*' yaml: ^2.4.2 peerDependenciesMeta: '@arethetypeswrong/core': optional: true - '@tsdown/css': - optional: true - '@tsdown/exe': - optional: true '@types/node': optional: true '@vitejs/devtools': @@ -1826,83 +2014,60 @@ packages: optional: true unplugin-unused: optional: true + unrun: + optional: true yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.1.15': - resolution: {integrity: sha512-arFq8phXg96rQ5J+FYvkBYdEGxIhP1ePAXlUeQY2hV8hJPzse+CdxusWxcjfpTgvFi+dpsKzE4KSNS22PyBo7w==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-darwin-arm64@1.0.0': + resolution: {integrity: sha512-lR+0rHOCYQTUo4Un0Sy14Xi1NBqoPXRnhsRyc0Joe53fomZ+wPtgErk15tkvWQdSLGNOr8hDDWJKo/kzI0Y37w==} + engines: {node: '>=20.0.0'} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@0.1.15': - resolution: {integrity: sha512-2eY+gTEIZvLH33nQmcL2tKlf+iHfClaqaSMYIlUpTp/CN+xqh4Ir4y2vN1XGEuFDIW0FshSZTg3ulPtduneEDA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-darwin-x64@1.0.0': + resolution: {integrity: sha512-MW3YnWpYP6wxvpd5KLFYTdtHTcAVnN0mVoLrmA9KCKWfOzO8nanr8hpuyEIdLMQT3r/7VNKRvgROI0CZZDP9/A==} + engines: {node: '>=20.0.0'} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.15': - resolution: {integrity: sha512-jJgz84pp61oHeXAYIUXKsVwQsMQ7NHK0+dBe6v1Q+Z034xXsyBrxi/JASSeVmCpAd6CN+xzOCsfMyn3whVTTxQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n4onkD4oT+g4jLj9rR37TM4i2IbaYvIiU68p1A/5KrwnIHzbv2maO0BqBYdIRnBy1Mil8pIx9HsT8riWCjZeTw==} + engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.15': - resolution: {integrity: sha512-F0Wig+We0ERhGecf3fDIwM/kfqT0vP2htH0vKUnV/inHIVbPc1MsrjcExX1eJ6KFSp5YTfchRN8HGecqtsudPA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EZfgINa0FL/eIsLkL3MfQz7+Dc4VDAeIrf1UZDlitt//8NIOqI6nVMPnIFzMZThaIncIose+cVWsEkKLAKMM/A==} + engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.15': - resolution: {integrity: sha512-aT5Yr2GphvRjoc2URmELDqjWwhe5VPvyy15Tzum+jPhEjY4I/lPXxKXEROjQe3TIv6MmFSHCe3oNCSaFdUE1pA==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-R6+/oVLCSEvatOG1utYo0d0I++QPjWuRt2moD21+UUu2mQ8VyZHaaQLgoxrQG9shA9o0RBDmwxhGUXL20Rk1FA==} + engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.15': - resolution: {integrity: sha512-Q6qMBMdVp5v84YVzFvMUpzVIHLfJuwZQR/KUtAOn/hzpfNITigKR2GrZZDgQvszFW+0CPhDFcK3kqLkxlJCdFg==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-B1Ottn22hn9RZ6XIVrWCXAAMfCOQRJ4KWoKbPL4JxC+UgvEiY9kC1ZFPqEAI89zIbW8eLdP3BeT14NQ0h1rtxw==} + engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-test@0.1.15': - resolution: {integrity: sha512-jxMUEX6PDpzMUz+KOVOoB8HiODMf5mWjH19pof0k9l/RZT4iLDyVXB+p9PoWjbVrEMMGzq9BTOVob7wfOZeZEA==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/ui': 4.1.2 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.15': - resolution: {integrity: sha512-EePrs+NIUy3gE60qaXPXzj8mw+JAXEBfGKsfweYBgNK6jo9ZXZto5ViKTuQsVVuWLVaELZSjoudbkzXB8wnJoQ==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-XXK6N5xMkGVIcqMaVDjqDh44z0Z00DA8dYn2BWUkFOQflUU1Z3IWt+bPm/1Y7LjkwBWj5qD2zsQY1ANDCeX9QA==} + engines: {node: '>=20.0.0'} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.15': - resolution: {integrity: sha512-vfYfwOG/5a/WUtgGrbUCatRkc5x0Rq/9GDlCzQQIAFGDB5BfyIjGbdCOqamQWOh+yQbeOHwvgAhqjZ7Dv1oo/w==} - engines: {node: ^20.19.0 || >=22.12.0} + '@voidzero-dev/vite-plus-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-XabdwdAsJ9QqEVSpSOuiiyJjiEwbwOspGahei9svQUGjs7e8MPuj4y/ltBHJZpyCBdZI6fhlc7Xaps9bUtl7Og==} + engines: {node: '>=20.0.0'} cpu: [x64] os: [win32] @@ -1921,6 +2086,141 @@ packages: '@vue/shared@3.5.25': resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==} + '@yuku-codegen/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-jrOY5WM+AaAqkv51fHP1x28ifto4WgcZVzodLUyMU1jMWn5Sq+VciRdk/n8E0Ey5w4p4cWWE+m5GfXWOYh7Kzw==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-4O4lkCQIzPZGjiNB1GORSI6hBECbiiSBS/APZXPvNKYH+nhY4uuqv03LNXA+SET3hoBjvr95P5rIhY8KQaQUBA==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-9EvoUO0SEhD6/d8VHdWuPerepPMSR1y84+UEgz8Un1Ope14Oe7xMvePpEQLTLovoIFZ8Zg3iZ8z8E11pZMqC3g==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-HqT78WwgHTmp8lwujoUa9CrIortX4DdpuiVC18ZPSGvuuJf4ylpIEI6QrQEM78Zwz3muhAWAOXZ5irdiYY+AyA==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-QJXwIW6Ms3QIawbcBIedmrmXnfdpuAOjZJ/eAABq5XTzWvSxZ7lutu9W5yIHahlaTaFnBo7ikrVMD1szLTpPUw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-eHbFy3IHGb+IYarrYwAo0yWSEQV3eAmEn6VrsKA6I1Wy1YPJxWqSJlV69JhqsHtJuJlljUIF3ex0y46yaKIu9w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-ByoJMbySTaDhjAXSu8q6Lh7HKg3YoesXpcT72aYk0Aiw4PCznmY4ybpLTq0RCvp0RIPhFm/6yECFiGBwyCC1nw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-gD9vfXIoBw1toSxhO9rgmSu/FEfy3PMznJAxVjIuH8DrWEiDKXmJO0pJKfj1Ltbe/TmtWVZR+TjISqeSIGQzMg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-BARvdnvqMGjOr5Iel2JH+9H5vAIE0R6H0Z2fsT02xrvmI/1ZXNB8lkuX+ZGevPhZb3zJ9WeC/R8JZstrsUOK5g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-x8flcevS1fbb7ESrmpOY/pON4eInSaE/7Ktjqx2udOE2W33BNSZmJuwpyUgo54AoHNqrcaM7szqydyJn5fNvew==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-KOL/rBatWqH4ZpCNoF8ZtSNdOJbAxBJLmk/VeRciepGROPTbPum1A9t67GpFgOU7qrkUWlPJdJ+KHKbvbmOt+w==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FxENahEjWSan59Syh/us/Kf4wNq8qrJLFJ3R2N4Oiwtb6yNdz/rWLNTIoNSssnsp7IoWJdUP6o/Z8ppg7lXMcg==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.9.5': + resolution: {integrity: sha512-A2JCFCSHfnficqYEw4Iujpx7XrkMM3UfFcgJFmYpZSo9zM4nyhmdIIE0FogSduuU60lhM0/UcfuUBXIVNBMlGQ==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.9.5': + resolution: {integrity: sha512-3PiyU+Eare4YuKaQ22N98/yAiROPY5o/NQJHraICzDvk4pgS+m+bgLKOvkGBRn33OnV95Vdv1Mn38b+MQHpULQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.9.5': + resolution: {integrity: sha512-blFMAFI7AInI83XaiOF8cIeiRM46Nz9EfpZtZPRLbKxSA9aAr5v8aHYpmfN6NoyXxAv/10pwS4gsAuc1W6fy5Q==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.9.5': + resolution: {integrity: sha512-TsNuL4qsZdO0tHp5GW43Y5fHeLPpPHA675wdcPNTcdq2ZO5AvsQWFFoiDg8CH4oBktfsQ9tdvKhjLnLYwKvp4g==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + resolution: {integrity: sha512-b0afYK5gHeV8RdmOcqAlgM8ONsye4cax4DMnIBaoJyPMd1UTGvTbpkqQcPVdhmHlcfcGVGMV1laeVovlr/dscw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.9.5': + resolution: {integrity: sha512-fD3lKzl+r6j6n8DwiMY53qnh5DLqD8KJjCp+NbVud54Nnh3Q9Wprqss3YzyMHP3NSJk663Wlg1E1X5qOuFVFig==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + resolution: {integrity: sha512-aRU/aCphV1MWCl/lvI6NX8LgucHQ68Fx+vz7NBb/5MEr2EuzCxiPOQqeFcMdWh+2AED/p0AvAREch0c+cSnlhA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + resolution: {integrity: sha512-5+Guro0l8H473YXlEjVNBRLN/IbPbJdnQh1zo0OLt49xxnON+XMJRRaEyTOTfkn9QSRg0+BhEKGR4W9Gu2uZJQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + resolution: {integrity: sha512-pwwSyV9q+GlvzSXlsMZBMlgk22L5bud714/vqZCdeUTivzeUVltQzUaf3IQXOGXdpc59JYTeuMCtbGquaAUoCA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.9.5': + resolution: {integrity: sha512-z18j6JN3lBHH8vzN7gG1M8fI0nlgItSfnC9PfbmUbt97iHViKfw2iA2G9fGwRMe5LyPRZBrejIlapbygPbpdaw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.9.5': + resolution: {integrity: sha512-s6Gwttb1dQvtPX6Bgkw+UPC9IO3UBDXc6Zezog8MMgvu3UfJBBB9TQqKBX/2quu0Eyh+lLSAyNlIyyecaagUPg==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.9.5': + resolution: {integrity: sha512-FrERt9YWatY3bJfSUdi4YWY+6iQcyo3MzCC821BxlWM5TFZEHijnpz/bknR79VHlXY/9CvXz8ZlaAGPsTtN3nw==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.9.5': + resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1976,6 +2276,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1993,6 +2297,9 @@ packages: argv-formatter@1.0.0: resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2081,10 +2388,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cac@7.0.0: - resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} - engines: {node: '>=20.19.0'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2107,6 +2410,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -2379,6 +2686,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2464,6 +2774,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2522,6 +2835,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.4.1: resolution: {integrity: sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==} engines: {node: '>= 16'} @@ -2563,6 +2880,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -2591,6 +2911,9 @@ packages: resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} engines: {node: '>=18'} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -2975,9 +3298,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -3001,6 +3321,12 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.31.1: resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} @@ -3013,6 +3339,12 @@ packages: cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.31.1: resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} @@ -3025,6 +3357,12 @@ packages: cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.31.1: resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} @@ -3037,6 +3375,12 @@ packages: cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.31.1: resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} @@ -3049,6 +3393,12 @@ packages: cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.31.1: resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} @@ -3063,6 +3413,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} @@ -3077,6 +3434,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} @@ -3091,6 +3455,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} @@ -3105,6 +3476,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} @@ -3117,6 +3495,12 @@ packages: cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.31.1: resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} @@ -3129,6 +3513,12 @@ packages: cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.31.1: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} @@ -3137,6 +3527,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3192,9 +3586,16 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} @@ -3572,8 +3973,9 @@ packages: resolution: {integrity: sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==} engines: {node: '>= 10.12.0'} - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -3601,13 +4003,21 @@ packages: outdent@0.8.0: resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==} - oxfmt@0.43.0: - resolution: {integrity: sha512-KTYNG5ISfHSdmeZ25Xzb3qgz9EmQvkaGAxgBY/p38+ZiAet3uZeu7FnMwcSQJg152Qwl0wnYAxDc+Z/H6cvrwA==} + oxfmt@0.70.0: + resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true - oxlint-tsgolint@0.18.1: - resolution: {integrity: sha512-Hgb0wMfuXBYL0ddY+1hAG8IIfC40ADwPnBuUaC6ENAuCtTF4dHwsy7mCYtQ2e7LoGvfoSJRY0+kqQRiembJ/jQ==} + oxlint-tsgolint@7.0.2003: + resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} hasBin: true oxlint@1.58.0: @@ -3620,6 +4030,19 @@ packages: oxlint-tsgolint: optional: true + oxlint@1.85.0: + resolution: {integrity: sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-each-series@3.0.0: resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} engines: {node: '>=12'} @@ -3760,14 +4183,14 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} - pixelmatch@7.1.0: - resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} - hasBin: true - pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -3837,6 +4260,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -3889,6 +4316,9 @@ packages: peerDependencies: react: ^19.2.4 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4145,8 +4575,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -4256,21 +4686,34 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} tinyexec@1.0.4: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4552,10 +4995,18 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-plus@0.1.15: - resolution: {integrity: sha512-PBUvTq4D4BJcuusCA3mrSQmXcGVdPX9CIPpS7Y6+T+LbDsrmAZ+ITl9FzuE6zXvpT6Nht9cpHtwOLJw7m3adog==} - engines: {node: ^20.19.0 || >=22.12.0} + vite-plus@1.0.0: + resolution: {integrity: sha512-2ezzBWt+AVO+I2F9Cpb2Of0J12Lat35kWZ/FRL2monMnKZuACKfkYAA/nMqdDo1KwB8sWjTKzmD9mRCh1Q1n+g==} + engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} hasBin: true + peerDependencies: + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + peerDependenciesMeta: + '@vitest/browser-playwright': + optional: true + '@vitest/browser-webdriverio': + optional: true vite@8.0.3: resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} @@ -4608,6 +5059,47 @@ packages: vite: optional: true + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4623,6 +5115,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + widest-line@5.0.0: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} @@ -4649,8 +5146,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4716,6 +5213,15 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yuku-ast@0.9.5: + resolution: {integrity: sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ==} + + yuku-codegen@0.9.5: + resolution: {integrity: sha512-zGUVyDpSK4b6c9B0yyxi2P0wujn1XZTbG9dCb7d6gyAoP63EMgyLzUwPUvwxPG5jgYqhlI7w+S3oCyapqGr4PA==} + + yuku-parser@0.9.5: + resolution: {integrity: sha512-IBnAdNVMswWbJcM7woPluOudectJzFjJ1N8jVRxP9Uq4CIv5hZdBIcdmtRbFDYF/Ph2j2gVup4YJ4N9mh0jYFA==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -4802,7 +5308,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.0.4 + tinyexec: 1.3.1 '@astrojs/compiler@2.13.1': {} @@ -4842,15 +5348,13 @@ snapshots: dependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': 4.7.0(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + '@vitejs/plugin-react': 4.7.0(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) ultrahtml: 1.6.0 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' transitivePeerDependencies: - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - '@types/node' - '@vitejs/devtools' - esbuild @@ -4866,6 +5370,7 @@ snapshots: - tsx - typescript - unplugin-unused + - unrun - yaml '@astrojs/telemetry@3.3.0': @@ -5053,6 +5558,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -5076,6 +5583,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@blazediff/core@1.10.0': {} + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.3 @@ -5411,7 +5920,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -5421,12 +5930,12 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@modelcontextprotocol/sdk@1.29.0(zod@4.4.2)': dependencies: @@ -5542,142 +6051,203 @@ snapshots: '@oslojs/encoding@1.1.0': {} - '@oxc-project/runtime@0.122.0': {} + '@oxc-project/runtime@0.151.0': {} '@oxc-project/types@0.122.0': {} - '@oxfmt/binding-android-arm-eabi@0.43.0': + '@oxc-project/types@0.151.0': {} + + '@oxfmt/binding-android-arm-eabi@0.70.0': optional: true - '@oxfmt/binding-android-arm64@0.43.0': + '@oxfmt/binding-android-arm64@0.70.0': optional: true - '@oxfmt/binding-darwin-arm64@0.43.0': + '@oxfmt/binding-darwin-arm64@0.70.0': optional: true - '@oxfmt/binding-darwin-x64@0.43.0': + '@oxfmt/binding-darwin-x64@0.70.0': optional: true - '@oxfmt/binding-freebsd-x64@0.43.0': + '@oxfmt/binding-freebsd-x64@0.70.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.43.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.70.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.43.0': + '@oxfmt/binding-linux-arm-musleabihf@0.70.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.43.0': + '@oxfmt/binding-linux-arm64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.43.0': + '@oxfmt/binding-linux-arm64-musl@0.70.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.43.0': + '@oxfmt/binding-linux-ppc64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.43.0': + '@oxfmt/binding-linux-riscv64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.43.0': + '@oxfmt/binding-linux-riscv64-musl@0.70.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.43.0': + '@oxfmt/binding-linux-s390x-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.43.0': + '@oxfmt/binding-linux-x64-gnu@0.70.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.43.0': + '@oxfmt/binding-linux-x64-musl@0.70.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.43.0': + '@oxfmt/binding-openharmony-arm64@0.70.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.43.0': + '@oxfmt/binding-win32-arm64-msvc@0.70.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.43.0': + '@oxfmt/binding-win32-ia32-msvc@0.70.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.43.0': + '@oxfmt/binding-win32-x64-msvc@0.70.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.18.1': + '@oxlint-tsgolint/darwin-arm64@7.0.2003': optional: true - '@oxlint-tsgolint/darwin-x64@0.18.1': + '@oxlint-tsgolint/darwin-x64@7.0.2003': optional: true - '@oxlint-tsgolint/linux-arm64@0.18.1': + '@oxlint-tsgolint/linux-arm64@7.0.2003': optional: true - '@oxlint-tsgolint/linux-x64@0.18.1': + '@oxlint-tsgolint/linux-x64@7.0.2003': optional: true - '@oxlint-tsgolint/win32-arm64@0.18.1': + '@oxlint-tsgolint/win32-arm64@7.0.2003': optional: true - '@oxlint-tsgolint/win32-x64@0.18.1': + '@oxlint-tsgolint/win32-x64@7.0.2003': optional: true '@oxlint/binding-android-arm-eabi@1.58.0': optional: true + '@oxlint/binding-android-arm-eabi@1.85.0': + optional: true + '@oxlint/binding-android-arm64@1.58.0': optional: true + '@oxlint/binding-android-arm64@1.85.0': + optional: true + '@oxlint/binding-darwin-arm64@1.58.0': optional: true + '@oxlint/binding-darwin-arm64@1.85.0': + optional: true + '@oxlint/binding-darwin-x64@1.58.0': optional: true + '@oxlint/binding-darwin-x64@1.85.0': + optional: true + '@oxlint/binding-freebsd-x64@1.58.0': optional: true + '@oxlint/binding-freebsd-x64@1.85.0': + optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.58.0': optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.85.0': + optional: true + '@oxlint/binding-linux-arm-musleabihf@1.58.0': optional: true + '@oxlint/binding-linux-arm-musleabihf@1.85.0': + optional: true + '@oxlint/binding-linux-arm64-gnu@1.58.0': optional: true + '@oxlint/binding-linux-arm64-gnu@1.85.0': + optional: true + '@oxlint/binding-linux-arm64-musl@1.58.0': optional: true + '@oxlint/binding-linux-arm64-musl@1.85.0': + optional: true + '@oxlint/binding-linux-ppc64-gnu@1.58.0': optional: true + '@oxlint/binding-linux-ppc64-gnu@1.85.0': + optional: true + '@oxlint/binding-linux-riscv64-gnu@1.58.0': optional: true + '@oxlint/binding-linux-riscv64-gnu@1.85.0': + optional: true + '@oxlint/binding-linux-riscv64-musl@1.58.0': optional: true + '@oxlint/binding-linux-riscv64-musl@1.85.0': + optional: true + '@oxlint/binding-linux-s390x-gnu@1.58.0': optional: true + '@oxlint/binding-linux-s390x-gnu@1.85.0': + optional: true + '@oxlint/binding-linux-x64-gnu@1.58.0': optional: true + '@oxlint/binding-linux-x64-gnu@1.85.0': + optional: true + '@oxlint/binding-linux-x64-musl@1.58.0': optional: true + '@oxlint/binding-linux-x64-musl@1.85.0': + optional: true + '@oxlint/binding-openharmony-arm64@1.58.0': optional: true + '@oxlint/binding-openharmony-arm64@1.85.0': + optional: true + '@oxlint/binding-win32-arm64-msvc@1.58.0': optional: true + '@oxlint/binding-win32-arm64-msvc@1.85.0': + optional: true + '@oxlint/binding-win32-ia32-msvc@1.58.0': optional: true + '@oxlint/binding-win32-ia32-msvc@1.85.0': + optional: true + '@oxlint/binding-win32-x64-msvc@1.58.0': optional: true + '@oxlint/binding-win32-x64-msvc@1.85.0': + optional: true + + '@oxlint/plugins@1.79.0': {} + '@pandacss/config@1.11.0': dependencies: '@pandacss/logger': 1.11.0 @@ -6059,7 +6629,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.4 + picomatch: 4.0.7 '@sec-ant/readable-stream@0.4.1': {} @@ -6198,8 +6768,6 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@standard-schema/spec@1.1.0': {} - '@stylexjs/babel-plugin@0.18.3': dependencies: '@babel/core': 7.29.0 @@ -6290,12 +6858,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.2(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' '@tailwindcss/vite@4.2.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: @@ -6304,17 +6872,34 @@ snapshots: tailwindcss: 4.2.2 vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@ts-morph/common@0.29.0': dependencies: minimatch: 10.2.5 path-browserify: 1.0.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -6375,59 +6960,176 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + + '@visulima/fs@4.1.0(yaml@2.8.3)': + dependencies: + '@visulima/path': 2.0.5 + type-fest: 5.5.0 + optionalDependencies: + yaml: 2.8.3 + + '@visulima/package@4.1.7(@types/node@24.12.2)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@inquirer/confirm': 5.1.21(@types/node@24.12.2) + '@visulima/fs': 4.1.0(yaml@2.8.3) + '@visulima/path': 2.0.5 + json5: 2.2.3 + normalize-package-data: 8.0.0 + yaml: 2.8.3 + transitivePeerDependencies: + - '@types/node' + + '@visulima/path@2.0.5': {} + + '@vitejs/plugin-react@4.7.0(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + transitivePeerDependencies: + - supports-color + + '@vitest/browser-preview@5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3)': + dependencies: + '@testing-library/dom': 10.4.2 + '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.2) + '@vitest/browser': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + vitest: 5.0.3(@types/node@24.12.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser-preview@5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3)': + dependencies: + '@testing-library/dom': 10.4.2 + '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.2) + '@vitest/browser': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + vitest: 5.0.3(@types/node@25.5.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitest/ui': 5.0.3(vitest@5.0.3) + '@vitest/utils': 5.0.3 + magic-string: 1.4.2 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@24.12.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitest/ui': 5.0.3(vitest@5.0.3) + '@vitest/utils': 5.0.3 + magic-string: 1.4.2 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@25.5.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/mocker@5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + + '@vitest/mocker@5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))': dependencies: - '@types/react': 19.2.14 + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - '@types/react@19.2.14': + '@vitest/pretty-format@5.0.3': dependencies: - csstype: 3.2.3 - - '@types/unist@3.0.3': {} - - '@ungap/structured-clone@1.3.0': {} + tinyrainbow: 3.2.0 - '@visulima/fs@4.1.0(yaml@2.8.3)': + '@vitest/snapshot@5.0.3': dependencies: - '@visulima/path': 2.0.5 - type-fest: 5.5.0 - optionalDependencies: - yaml: 2.8.3 + '@vitest/pretty-format': 5.0.3 + '@vitest/utils': 5.0.3 + magic-string: 1.4.2 + pathe: 2.0.3 - '@visulima/package@4.1.7(@types/node@24.12.2)': - dependencies: - '@antfu/install-pkg': 1.1.0 - '@inquirer/confirm': 5.1.21(@types/node@24.12.2) - '@visulima/fs': 4.1.0(yaml@2.8.3) - '@visulima/path': 2.0.5 - json5: 2.2.3 - normalize-package-data: 8.0.0 - yaml: 2.8.3 - transitivePeerDependencies: - - '@types/node' + '@vitest/spy@5.0.3': {} - '@visulima/path@2.0.5': {} + '@vitest/ui@5.0.3(vitest@5.0.3)': + dependencies: + '@vitest/utils': 5.0.3 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@24.12.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) - '@vitejs/plugin-react@4.7.0(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + '@vitest/utils@5.0.3': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' - transitivePeerDependencies: - - supports-color + '@vitest/pretty-format': 5.0.3 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 - '@voidzero-dev/vite-plus-core@0.1.15(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': dependencies: - '@oxc-project/runtime': 0.122.0 - '@oxc-project/types': 0.122.0 - lightningcss: 1.32.0 + '@oxc-project/runtime': 0.151.0 + '@oxc-project/types': 0.151.0 + lightningcss: 1.33.0 postcss: 8.5.8 + yuku-codegen: 0.9.5 + yuku-parser: 0.9.5 optionalDependencies: '@types/node': 24.12.2 + '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.0.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.0.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.0.0 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.6.1 @@ -6435,14 +7137,24 @@ snapshots: typescript: 6.0.2 yaml: 2.8.3 - '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: - '@oxc-project/runtime': 0.122.0 - '@oxc-project/types': 0.122.0 - lightningcss: 1.32.0 + '@oxc-project/runtime': 0.151.0 + '@oxc-project/types': 0.151.0 + lightningcss: 1.33.0 postcss: 8.5.8 + yuku-codegen: 0.9.5 + yuku-parser: 0.9.5 optionalDependencies: '@types/node': 25.5.2 + '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.0.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.0.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.0.0 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.6.1 @@ -6450,14 +7162,24 @@ snapshots: typescript: 5.9.3 yaml: 2.8.3 - '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': + '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': dependencies: - '@oxc-project/runtime': 0.122.0 - '@oxc-project/types': 0.122.0 - lightningcss: 1.32.0 + '@oxc-project/runtime': 0.151.0 + '@oxc-project/types': 0.151.0 + lightningcss: 1.33.0 postcss: 8.5.8 + yuku-codegen: 0.9.5 + yuku-parser: 0.9.5 optionalDependencies: '@types/node': 25.5.2 + '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.0.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.0.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.0.0 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.6.1 @@ -6465,106 +7187,28 @@ snapshots: typescript: 6.0.2 yaml: 2.8.3 - '@voidzero-dev/vite-plus-darwin-arm64@0.1.15': + '@voidzero-dev/vite-plus-darwin-arm64@1.0.0': optional: true - '@voidzero-dev/vite-plus-darwin-x64@0.1.15': + '@voidzero-dev/vite-plus-darwin-x64@1.0.0': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.15': + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.0.0': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.15': + '@voidzero-dev/vite-plus-linux-arm64-musl@1.0.0': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.15': + '@voidzero-dev/vite-plus-linux-x64-gnu@1.0.0': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@0.1.15': + '@voidzero-dev/vite-plus-linux-x64-musl@1.0.0': optional: true - '@voidzero-dev/vite-plus-test@0.1.15(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - es-module-lexer: 1.7.0 - obug: 2.1.1 - pixelmatch: 7.1.0 - pngjs: 7.0.0 - sirv: 3.0.2 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) - ws: 8.20.0 - optionalDependencies: - '@types/node': 24.12.2 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - - '@vitejs/devtools' - - bufferutil - - esbuild - - jiti - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - utf-8-validate - - yaml - - '@voidzero-dev/vite-plus-test@0.1.15(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - es-module-lexer: 1.7.0 - obug: 2.1.1 - pixelmatch: 7.1.0 - pngjs: 7.0.0 - sirv: 3.0.2 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - ws: 8.20.0 - optionalDependencies: - '@types/node': 25.5.2 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - - '@vitejs/devtools' - - bufferutil - - esbuild - - jiti - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - utf-8-validate - - yaml - - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.15': + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.0.0': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.15': + '@voidzero-dev/vite-plus-win32-x64-msvc@1.0.0': optional: true '@vue/compiler-core@3.5.25': @@ -6599,6 +7243,80 @@ snapshots: '@vue/shared@3.5.25': {} + '@yuku-codegen/binding-android-arm64@0.9.5': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.9.5': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.9.5': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.9.5': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.9.5': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.9.5': + optional: true + + '@yuku-codegen/binding-win32-x64@0.9.5': + optional: true + + '@yuku-parser/binding-android-arm64@0.9.5': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.9.5': + optional: true + + '@yuku-parser/binding-darwin-x64@0.9.5': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.9.5': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.9.5': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.9.5': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.9.5': + optional: true + + '@yuku-parser/binding-win32-arm64@0.9.5': + optional: true + + '@yuku-parser/binding-win32-x64@0.9.5': + optional: true + + '@yuku-toolchain/types@0.9.5': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -6646,6 +7364,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -6659,6 +7379,10 @@ snapshots: argv-formatter@1.0.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-ify@1.0.0: {} @@ -6726,8 +7450,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5 vfile: 6.0.3 - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' - vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + vitefu: 1.1.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -6748,8 +7472,6 @@ snapshots: - '@deno/kv' - '@netlify/blobs' - '@planetscale/database' - - '@tsdown/css' - - '@tsdown/exe' - '@types/node' - '@upstash/redis' - '@vercel/blob' @@ -6773,6 +7495,7 @@ snapshots: - tsx - typescript - unplugin-unused + - unrun - uploadthing - yaml @@ -6850,8 +7573,6 @@ snapshots: cac@6.7.14: {} - cac@7.0.0: {} - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6870,6 +7591,8 @@ snapshots: ccount@2.0.1: {} + chai@6.3.0: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -7111,6 +7834,8 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -7185,6 +7910,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -7298,6 +8025,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.4.0: {} + express-rate-limit@8.4.1(express@5.2.1): dependencies: express: 5.2.1 @@ -7362,6 +8091,12 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -7396,6 +8131,8 @@ snapshots: semver-regex: 4.0.5 super-regex: 1.1.0 + flatted@3.4.4: {} + flattie@1.1.1: {} fontace@0.4.1: @@ -7788,8 +8525,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.3.1: {} - jsonfile@6.2.0: dependencies: universalify: 2.0.1 @@ -7806,66 +8541,99 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.31.1: optional: true lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.31.1: optional: true lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.31.1: optional: true lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.31.1: optional: true lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.31.1: optional: true lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.31.1: optional: true lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.31.1: optional: true lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.31.1: optional: true lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.31.1: optional: true lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 @@ -7898,6 +8666,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} load-json-file@4.0.0: @@ -7944,9 +8728,15 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 magicast@0.5.2: dependencies: @@ -8413,7 +9203,7 @@ snapshots: object-path@0.11.8: {} - obug@2.1.1: {} + obug@2.2.1: {} ofetch@1.5.1: dependencies: @@ -8445,40 +9235,66 @@ snapshots: outdent@0.8.0: {} - oxfmt@0.43.0: + oxfmt@0.70.0(vite-plus@1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): dependencies: - tinypool: 2.1.0 + tinypool: 2.1.2 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.70.0 + '@oxfmt/binding-android-arm64': 0.70.0 + '@oxfmt/binding-darwin-arm64': 0.70.0 + '@oxfmt/binding-darwin-x64': 0.70.0 + '@oxfmt/binding-freebsd-x64': 0.70.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.70.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.70.0 + '@oxfmt/binding-linux-arm64-gnu': 0.70.0 + '@oxfmt/binding-linux-arm64-musl': 0.70.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-musl': 0.70.0 + '@oxfmt/binding-linux-s390x-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-musl': 0.70.0 + '@oxfmt/binding-openharmony-arm64': 0.70.0 + '@oxfmt/binding-win32-arm64-msvc': 0.70.0 + '@oxfmt/binding-win32-ia32-msvc': 0.70.0 + '@oxfmt/binding-win32-x64-msvc': 0.70.0 + vite-plus: 1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + + oxfmt@0.70.0(vite-plus@1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + dependencies: + tinypool: 2.1.2 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.43.0 - '@oxfmt/binding-android-arm64': 0.43.0 - '@oxfmt/binding-darwin-arm64': 0.43.0 - '@oxfmt/binding-darwin-x64': 0.43.0 - '@oxfmt/binding-freebsd-x64': 0.43.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.43.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.43.0 - '@oxfmt/binding-linux-arm64-gnu': 0.43.0 - '@oxfmt/binding-linux-arm64-musl': 0.43.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.43.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.43.0 - '@oxfmt/binding-linux-riscv64-musl': 0.43.0 - '@oxfmt/binding-linux-s390x-gnu': 0.43.0 - '@oxfmt/binding-linux-x64-gnu': 0.43.0 - '@oxfmt/binding-linux-x64-musl': 0.43.0 - '@oxfmt/binding-openharmony-arm64': 0.43.0 - '@oxfmt/binding-win32-arm64-msvc': 0.43.0 - '@oxfmt/binding-win32-ia32-msvc': 0.43.0 - '@oxfmt/binding-win32-x64-msvc': 0.43.0 - - oxlint-tsgolint@0.18.1: + '@oxfmt/binding-android-arm-eabi': 0.70.0 + '@oxfmt/binding-android-arm64': 0.70.0 + '@oxfmt/binding-darwin-arm64': 0.70.0 + '@oxfmt/binding-darwin-x64': 0.70.0 + '@oxfmt/binding-freebsd-x64': 0.70.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.70.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.70.0 + '@oxfmt/binding-linux-arm64-gnu': 0.70.0 + '@oxfmt/binding-linux-arm64-musl': 0.70.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.70.0 + '@oxfmt/binding-linux-riscv64-musl': 0.70.0 + '@oxfmt/binding-linux-s390x-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-gnu': 0.70.0 + '@oxfmt/binding-linux-x64-musl': 0.70.0 + '@oxfmt/binding-openharmony-arm64': 0.70.0 + '@oxfmt/binding-win32-arm64-msvc': 0.70.0 + '@oxfmt/binding-win32-ia32-msvc': 0.70.0 + '@oxfmt/binding-win32-x64-msvc': 0.70.0 + vite-plus: 1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + + oxlint-tsgolint@7.0.2003: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.18.1 - '@oxlint-tsgolint/darwin-x64': 0.18.1 - '@oxlint-tsgolint/linux-arm64': 0.18.1 - '@oxlint-tsgolint/linux-x64': 0.18.1 - '@oxlint-tsgolint/win32-arm64': 0.18.1 - '@oxlint-tsgolint/win32-x64': 0.18.1 - - oxlint@1.58.0(oxlint-tsgolint@0.18.1): + '@oxlint-tsgolint/darwin-arm64': 7.0.2003 + '@oxlint-tsgolint/darwin-x64': 7.0.2003 + '@oxlint-tsgolint/linux-arm64': 7.0.2003 + '@oxlint-tsgolint/linux-x64': 7.0.2003 + '@oxlint-tsgolint/win32-arm64': 7.0.2003 + '@oxlint-tsgolint/win32-x64': 7.0.2003 + + oxlint@1.58.0(oxlint-tsgolint@7.0.2003): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.58.0 '@oxlint/binding-android-arm64': 1.58.0 @@ -8499,7 +9315,55 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.58.0 '@oxlint/binding-win32-ia32-msvc': 1.58.0 '@oxlint/binding-win32-x64-msvc': 1.58.0 - oxlint-tsgolint: 0.18.1 + oxlint-tsgolint: 7.0.2003 + + oxlint@1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.85.0 + '@oxlint/binding-android-arm64': 1.85.0 + '@oxlint/binding-darwin-arm64': 1.85.0 + '@oxlint/binding-darwin-x64': 1.85.0 + '@oxlint/binding-freebsd-x64': 1.85.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.85.0 + '@oxlint/binding-linux-arm-musleabihf': 1.85.0 + '@oxlint/binding-linux-arm64-gnu': 1.85.0 + '@oxlint/binding-linux-arm64-musl': 1.85.0 + '@oxlint/binding-linux-ppc64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-musl': 1.85.0 + '@oxlint/binding-linux-s390x-gnu': 1.85.0 + '@oxlint/binding-linux-x64-gnu': 1.85.0 + '@oxlint/binding-linux-x64-musl': 1.85.0 + '@oxlint/binding-openharmony-arm64': 1.85.0 + '@oxlint/binding-win32-arm64-msvc': 1.85.0 + '@oxlint/binding-win32-ia32-msvc': 1.85.0 + '@oxlint/binding-win32-x64-msvc': 1.85.0 + oxlint-tsgolint: 7.0.2003 + vite-plus: 1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + + oxlint@1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.85.0 + '@oxlint/binding-android-arm64': 1.85.0 + '@oxlint/binding-darwin-arm64': 1.85.0 + '@oxlint/binding-darwin-x64': 1.85.0 + '@oxlint/binding-freebsd-x64': 1.85.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.85.0 + '@oxlint/binding-linux-arm-musleabihf': 1.85.0 + '@oxlint/binding-linux-arm64-gnu': 1.85.0 + '@oxlint/binding-linux-arm64-musl': 1.85.0 + '@oxlint/binding-linux-ppc64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-gnu': 1.85.0 + '@oxlint/binding-linux-riscv64-musl': 1.85.0 + '@oxlint/binding-linux-s390x-gnu': 1.85.0 + '@oxlint/binding-linux-x64-gnu': 1.85.0 + '@oxlint/binding-linux-x64-musl': 1.85.0 + '@oxlint/binding-openharmony-arm64': 1.85.0 + '@oxlint/binding-win32-arm64-msvc': 1.85.0 + '@oxlint/binding-win32-ia32-msvc': 1.85.0 + '@oxlint/binding-win32-x64-msvc': 1.85.0 + oxlint-tsgolint: 7.0.2003 + vite-plus: 1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) p-each-series@3.0.0: {} @@ -8617,11 +9481,9 @@ snapshots: picomatch@4.0.4: {} - pify@3.0.0: {} + picomatch@4.0.7: {} - pixelmatch@7.1.0: - dependencies: - pngjs: 7.0.0 + pify@3.0.0: {} pkce-challenge@5.0.1: {} @@ -8685,6 +9547,12 @@ snapshots: prettier@3.2.5: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -8736,6 +9604,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.4: {} @@ -9145,7 +10015,7 @@ snapshots: statuses@2.0.2: {} - std-env@4.0.0: {} + std-env@4.3.0: {} stream-combiner2@1.1.1: dependencies: @@ -9264,16 +10134,25 @@ snapshots: tiny-inflate@1.0.3: {} - tinybench@2.9.0: {} + tinybench@6.1.4: {} tinyexec@1.0.4: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@2.1.0: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.1.2: {} + + tinyrainbow@3.2.0: {} to-regex-range@5.0.1: dependencies: @@ -9493,35 +10372,39 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.1.15(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): - dependencies: - '@oxc-project/types': 0.122.0 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.15(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - cac: 7.0.0 - cross-spawn: 7.0.6 - jsonc-parser: 3.3.1 - oxfmt: 0.43.0 - oxlint: 1.58.0(oxlint-tsgolint@0.18.1) - oxlint-tsgolint: 0.18.1 - picocolors: 1.1.1 + vite-plus@1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.151.0 + '@oxlint/plugins': 1.79.0 + '@vitest/browser': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + '@vitest/browser-preview': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + '@vitest/mocker': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitest/pretty-format': 5.0.3 + '@vitest/snapshot': 5.0.3 + '@vitest/spy': 5.0.3 + '@vitest/utils': 5.0.3 + oxfmt: 0.70.0(vite-plus@1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + oxlint: 1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@24.12.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + oxlint-tsgolint: 7.0.2003 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: 5.0.3(@types/node@24.12.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.15 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.15 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.15 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.15 + '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.0.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.0.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.0.0 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - '@types/node' - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' - '@vitest/ui' - bufferutil - esbuild @@ -9529,48 +10412,54 @@ snapshots: - jiti - jsdom - less + - msw - publint - sass - sass-embedded - stylus - sugarss + - svelte - terser - tsx - typescript - unplugin-unused + - unrun - utf-8-validate - - vite - yaml - vite-plus@0.1.15(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): - dependencies: - '@oxc-project/types': 0.122.0 - '@voidzero-dev/vite-plus-core': 0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.15(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - cac: 7.0.0 - cross-spawn: 7.0.6 - jsonc-parser: 3.3.1 - oxfmt: 0.43.0 - oxlint: 1.58.0(oxlint-tsgolint@0.18.1) - oxlint-tsgolint: 0.18.1 - picocolors: 1.1.1 + vite-plus@1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3): + dependencies: + '@oxc-project/types': 0.151.0 + '@oxlint/plugins': 1.79.0 + '@vitest/browser': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + '@vitest/browser-preview': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + '@vitest/mocker': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + '@vitest/pretty-format': 5.0.3 + '@vitest/snapshot': 5.0.3 + '@vitest/spy': 5.0.3 + '@vitest/utils': 5.0.3 + oxfmt: 0.70.0(vite-plus@1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + oxlint: 1.85.0(oxlint-tsgolint@7.0.2003)(vite-plus@1.0.0(@types/node@25.5.2)(@vitest/ui@5.0.3(vitest@5.0.3))(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + oxlint-tsgolint: 7.0.2003 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + vitest: 5.0.3(@types/node@25.5.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.15 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.15 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.15 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.15 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.15 + '@voidzero-dev/vite-plus-darwin-arm64': 1.0.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.0.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.0.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.0.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.0.0 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - '@types/node' - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' - '@vitest/ui' - bufferutil - esbuild @@ -9578,28 +10467,30 @@ snapshots: - jiti - jsdom - less + - msw - publint - sass - sass-embedded - stylus - sugarss + - svelte - terser - tsx - typescript - unplugin-unused + - unrun - utf-8-validate - - vite - yaml - vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 + lightningcss: 1.33.0 + picomatch: 4.0.7 postcss: 8.5.8 rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 25.5.2 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.6.1 @@ -9609,27 +10500,55 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + vitefu@1.1.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)): + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + + vitest@5.0.3(@types/node@24.12.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - tinyglobby: 0.2.15 + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.1.4 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + why-is-node-running: 3.2.1 optionalDependencies: - '@types/node': 25.5.2 - esbuild: 0.27.7 - fsevents: 2.3.3 - jiti: 2.6.1 - tsx: 4.21.0 - yaml: 2.8.3 + '@types/node': 24.12.2 + '@vitest/browser-preview': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + '@vitest/ui': 5.0.3(vitest@5.0.3) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + - msw - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)): + vitest@5.0.3(@types/node@25.5.2)(@vitest/browser-preview@5.0.3)(@vitest/ui@5.0.3(vitest@5.0.3))(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.1.4 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: '@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' + why-is-node-running: 3.2.1 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.1.15(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + '@types/node': 25.5.2 + '@vitest/browser-preview': 5.0.3(@voidzero-dev/vite-plus-core@1.0.0(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(vitest@5.0.3) + '@vitest/ui': 5.0.3(vitest@5.0.3) + transitivePeerDependencies: + - msw web-namespaces@2.0.1: {} @@ -9641,6 +10560,8 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.1: {} + widest-line@5.0.0: dependencies: string-width: 7.2.0 @@ -9669,7 +10590,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0: {} + ws@8.22.0: {} xtend@4.0.2: {} @@ -9716,6 +10637,45 @@ snapshots: yoctocolors@2.1.2: {} + yuku-ast@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + + yuku-codegen@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-arm64': 0.9.5 + '@yuku-codegen/binding-darwin-x64': 0.9.5 + '@yuku-codegen/binding-freebsd-x64': 0.9.5 + '@yuku-codegen/binding-linux-arm-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm-musl': 0.9.5 + '@yuku-codegen/binding-linux-arm64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-arm64-musl': 0.9.5 + '@yuku-codegen/binding-linux-x64-gnu': 0.9.5 + '@yuku-codegen/binding-linux-x64-musl': 0.9.5 + '@yuku-codegen/binding-win32-arm64': 0.9.5 + '@yuku-codegen/binding-win32-x64': 0.9.5 + + yuku-parser@0.9.5: + dependencies: + '@yuku-toolchain/types': 0.9.5 + yuku-ast: 0.9.5 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.9.5 + '@yuku-parser/binding-darwin-arm64': 0.9.5 + '@yuku-parser/binding-darwin-x64': 0.9.5 + '@yuku-parser/binding-freebsd-x64': 0.9.5 + '@yuku-parser/binding-linux-arm-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm-musl': 0.9.5 + '@yuku-parser/binding-linux-arm64-gnu': 0.9.5 + '@yuku-parser/binding-linux-arm64-musl': 0.9.5 + '@yuku-parser/binding-linux-x64-gnu': 0.9.5 + '@yuku-parser/binding-linux-x64-musl': 0.9.5 + '@yuku-parser/binding-win32-arm64': 0.9.5 + '@yuku-parser/binding-win32-x64': 0.9.5 + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22e742f..fa998ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,15 +12,24 @@ catalog: semantic-release: ^25.0.3 tailwindcss: ^4.2.2 typescript: ^5 - vite: npm:@voidzero-dev/vite-plus-core@latest - vite-plus: latest - vitest: npm:@voidzero-dev/vite-plus-test@latest + vite: npm:@voidzero-dev/vite-plus-core@^1.0.0 + vite-plus: ^1.0.0 + vitest: 5.0.3 catalogMode: prefer overrides: vite: "catalog:" vitest: "catalog:" + "@vitest/browser": 5.0.3 + "@vitest/browser-preview": 5.0.3 + "@vitest/mocker": 5.0.3 + "@vitest/pretty-format": 5.0.3 + "@vitest/snapshot": 5.0.3 + "@vitest/spy": 5.0.3 + "@vitest/ui": 5.0.3 + "@vitest/utils": 5.0.3 + "@vitest/expect": 5.0.3 peerDependencyRules: allowAny: diff --git a/release.config.mjs b/release.config.mjs index 1259f99..82759dd 100644 --- a/release.config.mjs +++ b/release.config.mjs @@ -2,7 +2,12 @@ * @type {import('semantic-release').GlobalConfig} */ export default { - branches: ["main"], + // `main` publishes to npm's `latest`. The prerelease branches publish + // `x.y.z-alpha.n` / `x.y.z-beta.n` to dist-tags of the same name, so + // `npm install @klinking/squircle` never picks them up; `@alpha` or + // `@beta` does. Promoting a prerelease branch into `main` (the `promote` + // label, .github/workflows/promote.yml) releases it. + branches: ["main", { name: "beta", prerelease: true }, { name: "alpha", prerelease: true }], plugins: [ [ "@semantic-release/commit-analyzer", @@ -10,12 +15,8 @@ export default { preset: "conventionalcommits", }, ], - [ - "@semantic-release/release-notes-generator", - { - preset: "conventionalcommits", - }, - ], + // One line per PR, from its "## Release note" section. See release/notes.mjs. + "./release/notes.mjs", [ "@anolilab/semantic-release-pnpm", { diff --git a/release/check-release-note.mjs b/release/check-release-note.mjs new file mode 100644 index 0000000..d614b25 --- /dev/null +++ b/release/check-release-note.mjs @@ -0,0 +1,18 @@ +/** + * Fails a PR whose release note is missing or too long, for the PR checks + * workflow (pr-title.yml). See checkReleaseNote in release-note.mjs for the + * rules. Reads PR_TITLE, PR_BODY and HEAD_REF. + */ +import { checkReleaseNote } from "./release-note.mjs"; + +const problem = checkReleaseNote({ + title: process.env.PR_TITLE ?? "", + body: process.env.PR_BODY ?? "", + headRef: process.env.HEAD_REF ?? "", +}); + +if (problem) { + console.log(`::error title=Release note::${problem}`); + process.exit(1); +} +console.log("The release note is fine."); diff --git a/release/notes.mjs b/release/notes.mjs new file mode 100644 index 0000000..3a6f23d --- /dev/null +++ b/release/notes.mjs @@ -0,0 +1,136 @@ +/** + * semantic-release plugin that writes the release notes, in place of + * @semantic-release/release-notes-generator. + * + * Each feature, fix and performance change gets one line: its PR's release + * note (the paragraph under `## Release note`, see release-note.mjs), read + * from the PR as it is now, so a note can still be edited after merging. A + * PR without one falls back to the note in its squashed commit, then to its + * title. Breaking changes come first, whatever their type. A promotion PR's + * own release note becomes the summary at the top of the release it makes. + * Everything else (chores, docs, CI, the bot's merges) is left out. + */ +import { + SECTIONS, + extractReleaseNote, + isNone, + normalizeNote, + parseCommit, +} from "./release-note.mjs"; + +/** The bot's merge commit for a promotion PR (.github/scripts/promote.sh). */ +const PROMOTION = /^Merge (?:alpha|beta) into (?:beta|main) \(#(\d+)\)$/; + +/** `owner/repo` on GitHub, from Actions' environment or the repository URL. */ +export function repoSlug(repositoryUrl, env = process.env) { + if (env.GITHUB_REPOSITORY) return env.GITHUB_REPOSITORY; + const match = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(repositoryUrl ?? ""); + return match ? `${match[1]}/${match[2]}` : null; +} + +/** + * A function that fetches a PR's body from GitHub, once per PR. It resolves to + * null when it can't, so a GitHub outage costs a note, never the release. + */ +export function prBodyFetcher({ slug, token, logger = console }) { + const bodies = new Map(); + return (number) => { + if (!slug || !token) return Promise.resolve(null); + if (!bodies.has(number)) { + const url = `https://api.github.com/repos/${slug}/pulls/${number}`; + const headers = { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" }; + bodies.set( + number, + fetch(url, { headers }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }) + .then((pr) => pr.body ?? "") + .catch((error) => { + logger.warn( + `Couldn't read PR #${number}, so its note comes from its commit: ${error.message}`, + ); + return null; + }), + ); + } + return bodies.get(number); + }; +} + +/** + * The release notes for `commits` (`{ message, hash }`, as git log lists them). + * Returns the Markdown, and the PRs whose line fell back to their title + * because they have no release note. + */ +export async function renderNotes({ version, previousTag, tag, date, slug, commits, fetchPrBody }) { + const repoUrl = slug ? `https://github.com/${slug}` : null; + const summary = []; + const breaking = []; + const sections = new Map(SECTIONS.map(([type]) => [type, []])); + const untitled = []; + + for (const { message, hash } of commits) { + const promotion = PROMOTION.exec(message.split("\n")[0]); + if (promotion) { + const note = extractReleaseNote(await fetchPrBody(Number(promotion[1]))); + if (note && !isNone(note)) summary.push(normalizeNote(note)); + continue; + } + + const commit = parseCommit(message); + const list = commit.breaking ? breaking : sections.get(commit.type); + if (!list) continue; + + const prBody = commit.pr ? await fetchPrBody(commit.pr) : null; + let note = extractReleaseNote(prBody) || extractReleaseNote(commit.body); + if (note && isNone(note)) { + if (!commit.breaking) continue; + note = null; + } + if (!note) { + note = commit.subject; + if (commit.pr) untitled.push(commit.pr); + } + + const link = commit.pr + ? `[#${commit.pr}](${repoUrl}/pull/${commit.pr})` + : `[${hash.slice(0, 7)}](${repoUrl}/commit/${hash})`; + list.push(`- ${normalizeNote(note)}${repoUrl ? ` (${link})` : ""}`); + } + + const title = + repoUrl && previousTag + ? `## [${version}](${repoUrl}/compare/${previousTag}...${tag}) (${date})` + : `## ${version} (${date})`; + const blocks = [title, ...summary]; + if (breaking.length) blocks.push(`### Breaking changes\n\n${breaking.join("\n")}`); + for (const [type, heading] of SECTIONS) { + const lines = sections.get(type); + if (lines.length) blocks.push(`### ${heading}\n\n${lines.join("\n")}`); + } + if (blocks.length === 1) blocks.push("No user-facing changes."); + return { markdown: `${blocks.join("\n\n")}\n`, untitled }; +} + +/** semantic-release's generateNotes step. */ +export async function generateNotes(_pluginConfig, context) { + const { commits, lastRelease, nextRelease, options, env, logger } = context; + const slug = repoSlug(options.repositoryUrl, env); + const { markdown, untitled } = await renderNotes({ + version: nextRelease.version, + previousTag: lastRelease?.gitTag, + tag: nextRelease.gitTag, + date: new Date().toISOString().slice(0, 10), + slug, + commits: commits.map(({ message, hash }) => ({ message, hash })), + fetchPrBody: prBodyFetcher({ slug, token: env.GITHUB_TOKEN || env.GH_TOKEN, logger }), + }); + if (untitled.length) { + logger.log( + `No release note on ${untitled.map((pr) => `#${pr}`).join(", ")}, so their titles are used.`, + ); + } + return markdown; +} diff --git a/release/notes.test.mjs b/release/notes.test.mjs new file mode 100644 index 0000000..1c3ac69 --- /dev/null +++ b/release/notes.test.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + checkReleaseNote, + extractReleaseNote, + normalizeNote, + parseCommit, +} from "./release-note.mjs"; +import { renderNotes, repoSlug } from "./notes.mjs"; +import { nextVersion } from "./preview.mjs"; + +const body = (note) => + `## Summary\n\nLots of detail.\n\n## Release note\n\n${note}\n\n## Test plan\n\n- [x] ran it`; + +describe("extractReleaseNote", () => { + it("takes the paragraph under the heading, up to the next one", () => { + assert.equal(extractReleaseNote(body("Adds `x`.")), "Adds `x`."); + }); + it("accepts ### and 'notes', in any case, at the end of the body", () => { + assert.equal(extractReleaseNote("Intro\n\n### release Notes\nFixes y.\n"), "Fixes y."); + }); + it("drops the template's comments", () => { + assert.equal( + extractReleaseNote("## Release note\n\n\nFixes y."), + "Fixes y.", + ); + }); + it("is null without the heading and empty when there's nothing under it", () => { + assert.equal(extractReleaseNote("## Summary\n\nNo note here."), null); + assert.equal(extractReleaseNote(null), null); + assert.equal(extractReleaseNote("## Release note\n\n\n\n## Test plan"), ""); + }); + it("takes only the first paragraph, so a footer after it stays out", () => { + assert.equal( + extractReleaseNote( + "## Release note\n\nAdds `x`.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)", + ), + "Adds `x`.", + ); + assert.equal( + extractReleaseNote("## Release note\n\n\nAdds `x`\nacross lines.\n\nMore."), + "Adds `x`\nacross lines.", + ); + }); + it("handles CRLF bodies", () => { + assert.equal(extractReleaseNote("## Release note\r\n\r\nFixes y.\r\n## Next"), "Fixes y."); + }); +}); + +describe("normalizeNote", () => { + it("makes one capitalised line ending in a full stop", () => { + assert.equal(normalizeNote("adds `x`\n to the plugin"), "Adds `x` to the plugin."); + }); + it("keeps existing end punctuation, code and parentheses", () => { + assert.equal(normalizeNote("Use `x`"), "Use `x`"); + assert.equal(normalizeNote("Fixed (finally)"), "Fixed (finally)"); + assert.equal(normalizeNote("Done!"), "Done!"); + }); +}); + +describe("parseCommit", () => { + it("reads type, scope, subject and PR number", () => { + assert.deepEqual( + (({ type, scope, subject, breaking, pr }) => ({ type, scope, subject, breaking, pr }))( + parseCommit("feat(pill): add ease (#38)\n\nbody"), + ), + { type: "feat", scope: "pill", subject: "add ease", breaking: false, pr: 38 }, + ); + }); + it("sees breaking changes in the header and in a footer", () => { + assert.equal(parseCommit("feat!: rename").breaking, true); + assert.equal(parseCommit("fix(x)!: rename").breaking, true); + assert.equal(parseCommit("fix: y\n\nBREAKING CHANGE: z").breaking, true); + assert.equal(parseCommit("fix: y\n\nBREAKING-CHANGE: z").breaking, true); + assert.equal(parseCommit("fix: y\n\nnot a BREAKING CHANGE: z").breaking, false); + }); + it("has no type for the bot's merges", () => { + assert.equal(parseCommit("Merge main into alpha").type, null); + assert.equal(parseCommit("Merge alpha into main (#43)").type, null); + }); +}); + +describe("checkReleaseNote", () => { + const check = (title, note, headRef = "claude/x") => + checkReleaseNote({ title, body: note === undefined ? "## Summary\n\nx" : body(note), headRef }); + + it("requires a note on features, fixes, performance and breaking changes", () => { + for (const title of ["feat: x", "fix(pill): x", "perf: x", "chore!: x"]) { + assert.match(check(title), /Add a "## Release note" section/, title); + } + }); + it("doesn't on other types, promotions or syncs", () => { + assert.equal(check("chore: x"), null); + assert.equal(check("ci: x"), null); + for (const head of ["alpha", "beta", "main", "sync/main-into-alpha"]) { + assert.equal(check("feat: x", undefined, head), null, head); + } + }); + it("rejects an empty note, and 'None' on a breaking change only", () => { + assert.match(check("feat: x", ""), /is empty/); + assert.equal(check("feat(demos): x", "None."), null); + assert.match(check("feat!: x", "None"), /breaking change/); + }); + it("wants one short paragraph", () => { + assert.equal(check("feat: x", "One.\n\nA footer after it is fine."), null); + assert.match(check("feat: x", "- a\n- b"), /not a list/); + assert.match(check("feat: x", "1. a"), /not a list/); + assert.match(check("feat: x", "a".repeat(300)), /is 301 characters; keep it under 300/); + assert.equal(check("feat: x", "a".repeat(299)), null); + }); + it("still checks a note someone added to a chore", () => { + assert.match(check("chore: x", "- a"), /not a list/); + }); +}); + +describe("renderNotes", () => { + const prs = { + 38: body("New `squircle-pill` utility."), + 40: "## Summary\n\nNo note in the PR.", + 43: body("This release adds pill shapes."), + 44: body("None"), + 45: body("Rename `--a` to `--b`."), + }; + const render = (commits, extra = {}) => + renderNotes({ + version: "0.11.0", + previousTag: "v0.10.0", + tag: "v0.11.0", + date: "2026-10-07", + slug: "o/r", + commits: commits.map((message, i) => ({ message, hash: `${i}`.repeat(40) })), + fetchPrBody: async (n) => prs[n] ?? null, + ...extra, + }); + + it("lists notes by section, puts the promotion's note on top, and leaves the rest out", async () => { + const { markdown, untitled } = await render([ + "Merge alpha into main (#43)", + "Merge main into alpha", + "chore(demos): hide pages (#41)", + "feat: export defaults (#40)", + "fix: crease (#39)\n\n## Release note\n\nfixes the crease in Safari", + "feat: Add pill (#38)", + "feat(demos): demo only (#44)", + ]); + assert.equal( + markdown, + [ + "## [0.11.0](https://github.com/o/r/compare/v0.10.0...v0.11.0) (2026-10-07)", + "", + "This release adds pill shapes.", + "", + "### Features", + "", + "- Export defaults. ([#40](https://github.com/o/r/pull/40))", + "- New `squircle-pill` utility. ([#38](https://github.com/o/r/pull/38))", + "", + "### Fixes", + "", + "- Fixes the crease in Safari. ([#39](https://github.com/o/r/pull/39))", + "", + ].join("\n"), + ); + assert.deepEqual(untitled, [40]); + }); + + it("puts breaking changes first, whatever their type, and never drops them", async () => { + const { markdown } = await render([ + "fix!: rename (#45)", + "feat!: other (#44)", + "feat: x (#38)", + ]); + assert.match( + markdown, + /### Breaking changes\n\n- Rename `--a` to `--b`\. \(\[#45\]\(.*\)\)\n- Other\. \(\[#44\]/, + ); + assert.ok(markdown.indexOf("### Breaking changes") < markdown.indexOf("### Features")); + }); + + it("links the commit when there's no PR, and says when nothing is user-facing", async () => { + assert.match( + (await render(["fix: direct push"])).markdown, + /- Direct push\. \(\[0000000\]\(https:\/\/github\.com\/o\/r\/commit\/0{40}\)\)/, + ); + assert.match( + (await render(["feat(demos): demo only (#44)"])).markdown, + /No user-facing changes\./, + ); + }); + + it("falls back to the commit's note when GitHub can't be read", async () => { + const { markdown, untitled } = await render( + ["feat: x (#50)\n\n## Release note\n\nfrom the commit"], + { fetchPrBody: async () => null }, + ); + assert.match(markdown, /- From the commit\. \(\[#50\]/); + assert.deepEqual(untitled, []); + }); + + it("has no compare link for a first release", async () => { + const { markdown } = await render(["feat: x (#38)"], { previousTag: undefined }); + assert.match(markdown, /^## 0\.11\.0 \(2026-10-07\)\n/); + }); +}); + +describe("nextVersion", () => { + const commits = (...messages) => messages.map((message) => ({ message })); + it("bumps like commit-analyzer, including major on 0.x", () => { + assert.equal(nextVersion("v0.10.0", commits("chore: x", "feat: y")), "0.11.0"); + assert.equal(nextVersion("v0.10.3", commits("fix: y", "Merge main into alpha")), "0.10.4"); + assert.equal(nextVersion("v0.10.0", commits("feat: y", "fix!: z")), "1.0.0"); + assert.equal(nextVersion("v0.10.0", commits("chore: x", "Merge alpha into main (#1)")), null); + }); +}); + +describe("repoSlug", () => { + it("prefers GITHUB_REPOSITORY, then reads the URL", () => { + assert.equal(repoSlug("x", { GITHUB_REPOSITORY: "a/b" }), "a/b"); + assert.equal(repoSlug("https://github.com/klink-ing/squircle.git", {}), "klink-ing/squircle"); + assert.equal(repoSlug("git@github.com:klink-ing/squircle.git", {}), "klink-ing/squircle"); + assert.equal(repoSlug("file:///tmp/x", {}), null); + }); +}); diff --git a/release/preview.mjs b/release/preview.mjs new file mode 100644 index 0000000..1a3d676 --- /dev/null +++ b/release/preview.mjs @@ -0,0 +1,109 @@ +/** + * Prints the comment release-notes.yml keeps on a promotion PR: the version + * and release notes that adding the `promote` label would publish, made by the + * same code as the real ones (notes.mjs). + * + * The version is worked out the way commit-analyzer does it, from the last + * stable tag on the base branch. Prereleases (into beta) aren't numbered here. + * + * Reads BASE_REF, HEAD_REF, BASE_SHA, HEAD_SHA, PR_NUMBER and PR_BODY, plus + * GITHUB_REPOSITORY and GITHUB_TOKEN to read the other PRs' notes. + */ +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { parseCommit } from "./release-note.mjs"; +import { prBodyFetcher, renderNotes, repoSlug } from "./notes.mjs"; + +export const MARKER = ""; + +const git = (...args) => execFileSync("git", args, { encoding: "utf8" }); + +/** The commits on head that base doesn't have, newest first. */ +function commitsBetween(base, head) { + return git("log", "--format=%H%x1f%B%x1e", `${base}..${head}`) + .split("\x1e") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [hash, message] = entry.split("\x1f"); + return { hash, message: message.trim() }; + }); +} + +/** The next stable version after `tag` for these commits, or null for none. */ +export function nextVersion(tag, commits) { + const parsed = commits.map(({ message }) => parseCommit(message)); + const [major, minor, patch] = tag.replace(/^v/, "").split(".").map(Number); + if (parsed.some((commit) => commit.breaking)) return `${major + 1}.0.0`; + if (parsed.some((commit) => commit.type === "feat")) return `${major}.${minor + 1}.0`; + if (parsed.some((commit) => ["fix", "perf"].includes(commit.type))) { + return `${major}.${minor}.${patch + 1}`; + } + return null; +} + +export async function preview({ baseRef, headRef, baseSha, headSha, pr, prBody, slug, token }) { + const commits = commitsBetween(baseSha, headSha); + // The merge commit promoting this PR doesn't exist yet; stand one in, so its + // release note becomes the summary just as it will in the release. + commits.unshift({ hash: headSha, message: `Merge ${headRef} into ${baseRef} (#${pr})` }); + const fetchFromGitHub = prBodyFetcher({ slug, token }); + const fetchPrBody = (number) => + number === pr ? Promise.resolve(prBody) : fetchFromGitHub(number); + + const lines = [MARKER, "### Release notes preview", ""]; + let version = "the next beta"; + let previousTag; + if (baseRef === "main") { + previousTag = git("describe", "--tags", "--abbrev=0", "--exclude=*-*", baseSha).trim(); + const next = nextVersion(previousTag, commits); + if (!next) { + lines.push( + "Promoting this releases nothing: none of its commits are features, fixes, performance changes or breaking changes.", + ); + return `${lines.join("\n")}\n`; + } + version = next; + } + + const { markdown, untitled } = await renderNotes({ + version, + previousTag, + tag: `v${version}`, + date: new Date().toISOString().slice(0, 10), + slug, + commits, + fetchPrBody, + }); + lines.push(`Adding the \`promote\` label releases **${version}** with these notes:`, ""); + if (untitled.length) { + const refs = untitled.map((number) => `#${number}`); + const prs = refs.length > 1 ? `${refs.slice(0, -1).join(", ")} and ${refs.at(-1)}` : refs[0]; + lines.push( + `> [!NOTE]`, + `> ${prs} ${untitled.length === 1 ? "has" : "have"} no release note, so ${untitled.length === 1 ? "its title is" : "their titles are"} used. Add a \`## Release note\` section to ${untitled.length === 1 ? "its" : "their"} description, then re-run this check.`, + "", + ); + } + lines.push("---", "", markdown.trim(), "", "---", ""); + lines.push( + `Each line is a PR's "Release note" section, and the paragraph under the heading is this PR's. Edit those, then re-run this check to update the preview.`, + ); + return `${lines.join("\n")}\n`; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const { env } = process; + process.stdout.write( + await preview({ + baseRef: env.BASE_REF, + headRef: env.HEAD_REF, + baseSha: env.BASE_SHA, + headSha: env.HEAD_SHA, + pr: Number(env.PR_NUMBER), + prBody: env.PR_BODY ?? "", + slug: repoSlug(undefined, env), + token: env.GITHUB_TOKEN, + }), + ); +} diff --git a/release/release-note.mjs b/release/release-note.mjs new file mode 100644 index 0000000..58603f0 --- /dev/null +++ b/release/release-note.mjs @@ -0,0 +1,123 @@ +/** + * The release note a PR carries for the release notes: the paragraph under its + * `## Release note` heading. Shared by the notes generator (notes.mjs), the + * promotion preview (preview.mjs) and the PR check (check-release-note.mjs). + * No dependencies, so the workflows can run it without installing anything. + */ + +/** Longest note allowed, in characters. A note is a line in a list, not a description. */ +export const MAX_LENGTH = 300; + +/** Types that appear in the release notes, in order, with their headings. */ +export const SECTIONS = [ + ["feat", "Features"], + ["fix", "Fixes"], + ["perf", "Performance"], +]; + +const HEADING = /^#{2,3}[ \t]*release[ \t]+notes?[ \t]*$/im; +const NEXT_HEADING = /^#{1,3}[ \t]/m; +const COMMENT = //g; +const NONE = /^none\.?$/i; + +// The same patterns as the conventionalcommits preset that commit-analyzer +// uses, so a commit counts as a feature or a breaking change here exactly when +// it does for the version number. +const HEADER = /^(\w*)(?:\((.*)\))?!?: (.*)$/; +const BREAKING_HEADER = /^(\w*)(?:\((.*)\))?!: (.*)$/; +const BREAKING_NOTE = /^[\s|*]*BREAKING[ -]CHANGE[:\s]/m; + +/** + * A PR body's release note: the first paragraph under its `## Release note` + * heading, comments aside. Only the first, so a footer after it (a sign-off, + * a co-author) stays out of the release notes. `null` without the heading, + * `""` when there's nothing under it. + */ +export function extractReleaseNote(body) { + if (!body) return null; + const text = body.replace(/\r\n/g, "\n"); + const heading = HEADING.exec(text); + if (!heading) return null; + let section = text.slice(heading.index + heading[0].length); + const next = NEXT_HEADING.exec(section); + if (next) section = section.slice(0, next.index); + return section + .replace(COMMENT, "") + .trim() + .split(/\n[ \t]*\n/)[0] + .trim(); +} + +/** Whether a note says the change needs no line in the release notes. */ +export function isNone(note) { + return NONE.test(note); +} + +/** + * One line of release notes from a note: a single paragraph that starts with a + * capital letter and ends with a full stop. + */ +export function normalizeNote(note) { + let text = note.replace(/\s+/g, " ").trim(); + text = text.charAt(0).toUpperCase() + text.slice(1); + return /[.!?)`]$/.test(text) ? text : `${text}.`; +} + +/** + * A commit's or PR title's conventional header, as the version is worked out + * from it: `{ type, scope, subject, breaking, pr }`. `type` is null when the + * header isn't conventional, like the bot's "Merge alpha into main". + */ +export function parseCommit(message) { + const [header = "", ...rest] = message.replace(/\r\n/g, "\n").split("\n"); + const body = rest.join("\n"); + const match = HEADER.exec(header); + const pr = /\(#(\d+)\)\s*$/.exec(header)?.[1]; + const subject = (match ? match[3] : header).replace(/\s*\(#\d+\)\s*$/, ""); + return { + type: match ? match[1] : null, + scope: match?.[2] ?? null, + subject, + breaking: BREAKING_HEADER.test(header) || BREAKING_NOTE.test(body), + pr: pr ? Number(pr) : null, + body, + }; +} + +/** Whether a PR's head is one of the release branches or a sync/* branch. */ +export function isPromotionHead(headRef) { + return ["main", "beta", "alpha"].includes(headRef) || headRef.startsWith("sync/"); +} + +/** + * Checks a PR's release note. Promotions and syncs don't need one; features, + * fixes, performance changes and breaking changes do. Returns a message + * saying what to change, or null when the note is fine. + */ +export function checkReleaseNote({ title, body, headRef }) { + if (isPromotionHead(headRef)) return null; + const { type, breaking } = parseCommit(title); + const needed = breaking || SECTIONS.some(([sectionType]) => sectionType === type); + const note = extractReleaseNote(body); + + if (!needed && !note) return null; + if (note === null) { + return `Add a "## Release note" section to the description: one or two sentences for people using the package, saying what's new or fixed and how to use it. It goes into the release notes as written. Write "None" if users won't notice the change.`; + } + if (note === "") { + return `The "## Release note" section is empty. Write one or two sentences for people using the package, or "None" if they won't notice the change.`; + } + if (isNone(note)) { + return breaking + ? `This is a breaking change, so its release note can't be "None": say what changed and what to do about it.` + : null; + } + if (/^\s*([-*+]|\d+\.)\s/m.test(note)) { + return `Write the release note as one short paragraph, not a list: it's one line in the release notes. The rest of the description is the place for detail.`; + } + const length = normalizeNote(note).length; + if (length > MAX_LENGTH) { + return `The release note is ${length} characters; keep it under ${MAX_LENGTH}. Say what changed for users and leave how it works to the description.`; + } + return null; +} diff --git a/website/scripts/bench-pills.mjs b/website/scripts/bench-pills.mjs new file mode 100644 index 0000000..cbf015b --- /dev/null +++ b/website/scripts/bench-pills.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Runs the pill resize benchmark headlessly and prints the results. + * + * pnpm --filter website exec astro build && pnpm --filter website exec astro preview & + * node website/scripts/bench-pills.mjs [--url http://localhost:4321] [--counts 10,100,1000] + * [--modes none,worklet,polyfill] [--chrome /path/to/chrome] + * + * Talks to Chrome over the DevTools protocol with Node's built-in WebSocket, so + * it needs nothing installed beyond Chrome itself. Headless frame timing is + * not a real display's, so compare runs from the same machine against each + * other rather than reading the numbers as absolutes. + */ +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ + options: { + url: { type: "string", default: "http://localhost:4321" }, + counts: { type: "string", default: "10,100,500,1000" }, + modes: { type: "string", default: "none,worklet,polyfill" }, + chrome: { type: "string", default: process.env.CHROME ?? "google-chrome" }, + port: { type: "string", default: "9333" }, + timeout: { type: "string", default: "600" }, + }, +}); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const profile = mkdtempSync(join(tmpdir(), "pill-bench-")); + +const chrome = spawn( + values.chrome, + [ + "--headless=new", + `--remote-debugging-port=${values.port}`, + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-sandbox", + "--window-size=1280,900", + // Keep the page rendering at full rate while nobody is looking at it. + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--disable-backgrounding-occluded-windows", + "about:blank", + ], + { stdio: "ignore" }, +); + +async function target() { + for (let i = 0; i < 100; i++) { + try { + const pages = await (await fetch(`http://127.0.0.1:${values.port}/json`)).json(); + const page = pages.find((p) => p.type === "page"); + if (page) return page.webSocketDebuggerUrl; + } catch { + // Not listening yet. + } + await sleep(100); + } + throw new Error("Chrome did not start"); +} + +function connect(url) { + const socket = new WebSocket(url); + let id = 0; + const pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + pending.get(message.id)(message); + pending.delete(message.id); + } + }); + const send = (method, params = {}) => + new Promise((resolve) => { + const messageId = ++id; + pending.set(messageId, resolve); + socket.send(JSON.stringify({ id: messageId, method, params })); + }); + return new Promise((resolve) => socket.addEventListener("open", () => resolve({ send, socket }))); +} + +try { + const { send, socket } = await connect(await target()); + const counts = values.counts.split(","); + const modes = values.modes.split(","); + const page = `${values.url.replace(/\/$/, "")}/bench/pills/`; + await send("Page.enable"); + await send("Page.navigate", { url: page }); + await sleep(1500); + // Tick exactly the requested runs, then submit. + await send("Runtime.evaluate", { + expression: `(() => { + const counts = ${JSON.stringify(counts)}, modes = ${JSON.stringify(modes)}; + for (const box of document.querySelectorAll('input[name=count]')) box.checked = counts.includes(box.value); + for (const box of document.querySelectorAll('input[name=mode]')) box.checked = modes.includes(box.value); + document.getElementById('config').requestSubmit(); + })()`, + }); + + const deadline = Date.now() + Number(values.timeout) * 1000; + let state; + while (Date.now() < deadline) { + await sleep(1000); + const { result } = await send("Runtime.evaluate", { + expression: "JSON.stringify(window.__pillBench ?? null)", + returnByValue: true, + }); + state = JSON.parse(result?.result?.value ?? "null"); + if (state?.done) break; + if (state) process.stderr.write(`\r${state.results.length} runs done… `); + } + process.stderr.write("\n"); + socket.close(); + + if (!state?.done) throw new Error("benchmark did not finish in time"); + const header = "| mode | pills | setup ms | mean | p50 | p95 | max | >20ms frames |"; + console.log(header); + console.log("|---|--:|--:|--:|--:|--:|--:|--:|"); + for (const r of state.results) { + if (r.error) { + console.log(`| error | | ${r.error} | | | | | |`); + continue; + } + console.log( + `| ${r.mode} | ${r.count} | ${r.setupMs} | ${r.meanFrameMs.toFixed(1)} | ${r.p50FrameMs.toFixed(1)} | ${r.p95FrameMs.toFixed(1)} | ${r.maxFrameMs.toFixed(1)} | ${r.slowFrames} |`, + ); + } +} finally { + // Wait for Chrome to exit before removing its profile: it is still writing + // to it as it shuts down. + const exited = new Promise((resolve) => chrome.once("exit", resolve)); + chrome.kill(); + await Promise.race([exited, sleep(5000)]); + rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} diff --git a/website/src/bench/pill-frame.ts b/website/src/bench/pill-frame.ts new file mode 100644 index 0000000..5109db7 --- /dev/null +++ b/website/src/bench/pill-frame.ts @@ -0,0 +1,145 @@ +/** + * One run of the pill resize benchmark; see src/pages/bench/pill-frame.astro. + */ +import { registerPillWorklet } from "@klinking/squircle/pill-worklet"; +import { polyfillPills } from "@klinking/squircle/pill-polyfill"; +import workletUrl from "@klinking/squircle/pill-shape.worklet.js?url"; + +type Mode = "worklet" | "polyfill" | "none"; + +const params = new URLSearchParams(location.search); +const count = Number(params.get("count") ?? 100); +const mode = (params.get("mode") ?? "worklet") as Mode; +const frames = Number(params.get("frames") ?? 120); +const seed = Number(params.get("seed") ?? 1); +// `masked` puts a Tailwind mask utility on every other pill, to see the pill +// shape and Tailwind's masks combine, and what that costs. +const masked = params.has("masked"); +// `clipped` puts a clip-path of its own on every third pill, to see the +// polyfill fold it into the pill's: Tailwind has no clip-path utilities of +// its own beyond `sr-only`, so these are arbitrary properties. +const clipped = params.has("clipped"); +const OWN_CLIPS = [ + "[clip-path:inset(0_0_0_50%)]", + "[clip-path:polygon(0_0,100%_0,0_100%)]", + "[clip-path:circle(40%_at_100%_50%)]", +]; + +const stage = document.getElementById("stage") as HTMLElement; +const status = document.getElementById("status") as HTMLElement; +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + +/** Deterministic, so every mode resizes exactly the same pills. */ +function mulberry32(a: number) { + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Spelled out in full so Tailwind finds and generates every class. +const FILLS = ["bg-pink-600", "bg-indigo-500", "bg-emerald-500", "bg-amber-500", "bg-sky-500"]; +const HEIGHTS = [20, 28, 36, 48, 64, 96]; + +function build() { + const random = mulberry32(seed); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < count; i++) { + const pill = document.createElement("div"); + const fill = FILLS[Math.floor(random() * FILLS.length)]; + const classes = ["squircle-pill", fill]; + // A share with borders and a share at G3, so the ring and the more + // expensive profile are both exercised. + if (random() < 0.25) classes.push("border-2", "border-zinc-100"); + if (random() < 0.25) classes.push("squircle-pill-g3"); + if (masked && i % 2 === 0) classes.push("mask-b-from-20%"); + if (clipped && i % 3 === 0) classes.push(OWN_CLIPS[(i / 3) % OWN_CLIPS.length] as string); + pill.className = classes.join(" "); + // Widths are a share of the stage, so every pill resizes, and its + // aspect ratio changes, as the stage does. + pill.style.width = `${(4 + random() * 28).toFixed(2)}%`; + pill.style.height = `${HEIGHTS[Math.floor(random() * HEIGHTS.length)]}px`; + fragment.append(pill); + } + stage.append(fragment); +} + +const percentile = (sorted: number[], p: number) => + sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; + +async function run() { + const t0 = performance.now(); + build(); + + if (mode === "worklet") { + await registerPillWorklet(workletUrl); + } else if (mode === "polyfill") { + polyfillPills({ force: true }); + } + // Settled once two frames have rendered with the shapes in place; for + // the polyfill, once every pill has had its clip computed. + const pills = [...stage.children] as HTMLElement[]; + for (let i = 0; i < 600; i++) { + await nextFrame(); + if (mode !== "polyfill" || pills.every((p) => p.style.length > 2)) break; + } + await nextFrame(); + await nextFrame(); + const setupMs = performance.now() - t0; + + // Long tasks, where the browser reports them. + let longTasks = 0; + let longTaskMs = 0; + try { + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + longTasks++; + longTaskMs += entry.duration; + } + }).observe({ type: "longtask", buffered: false }); + } catch { + // Not supported; frame times still tell the story. + } + + status.textContent = `${mode} · ${count} pills · resizing…`; + const full = stage.parentElement!.clientWidth - 16; + const deltas: number[] = []; + let last = await nextFrame(); + const resizeStart = last; + for (let i = 1; i <= frames; i++) { + // Down to 45% of the width and back, once, eased. + const t = (1 - Math.cos((2 * Math.PI * i) / frames)) / 2; + stage.style.width = `${(full * (1 - 0.55 * t)).toFixed(1)}px`; + const now = await nextFrame(); + deltas.push(now - last); + last = now; + } + const resizeMs = last - resizeStart; + + // `frames=0` measures setup alone. + const sorted = deltas.length > 0 ? [...deltas].sort((a, b) => a - b) : [0]; + const result = { + mode, + count, + frames, + setupMs: Math.round(setupMs), + meanFrameMs: +(resizeMs / frames).toFixed(2), + p50FrameMs: +percentile(sorted, 50).toFixed(2), + p95FrameMs: +percentile(sorted, 95).toFixed(2), + maxFrameMs: +sorted[sorted.length - 1].toFixed(2), + slowFrames: deltas.filter((d) => d > 20).length, + longTasks, + longTaskMs: Math.round(longTaskMs), + }; + status.textContent = JSON.stringify(result); + (window as unknown as { __pillBench: unknown }).__pillBench = result; + window.parent?.postMessage({ type: "pill-bench", result }, "*"); +} + +run().catch((error) => { + status.textContent = `failed: ${error}`; + window.parent?.postMessage({ type: "pill-bench", error: String(error), mode, count }, "*"); +}); diff --git a/website/src/components/DemoEmbed.tsx b/website/src/components/DemoEmbed.tsx index 0331b2f..74cd64b 100644 --- a/website/src/components/DemoEmbed.tsx +++ b/website/src/components/DemoEmbed.tsx @@ -11,7 +11,7 @@ export default function DemoEmbed({ file = "src/App.tsx", height = 500, }: DemoEmbedProps) { - const repo = "dogmar/squircle"; + const repo = "klink-ing/squircle"; const branch = "main"; const src = `https://stackblitz.com/github/${repo}/tree/${branch}/${projectPath}?embed=1&theme=dark&file=${file}&hideExplorer=0&terminalHeight=0`; diff --git a/website/src/components/Layout.astro b/website/src/components/Layout.astro index 4f49b03..fe253a1 100644 --- a/website/src/components/Layout.astro +++ b/website/src/components/Layout.astro @@ -13,6 +13,10 @@ const { title } = Astro.props; {title} +
@@ -20,7 +24,7 @@ const { title } = Astro.props;

@klinking/squircle

GitHub @@ -51,7 +55,7 @@ const { title } = Astro.props; class="hover:text-zinc-400">npm GitHub ) => ({ + get: (name: string) => + values[name] === undefined ? undefined : { toString: () => String(values[name]) }, +}); + +// ── Measurement ───────────────────────────────────────────────── + +function profile({ points, k }: Quadrant): { s: number[]; k: number[] } { + const s = [0]; + for (let i = 1; i < points.length; i++) { + s.push(s[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y)); + } + return { s, k }; +} + +function stats(q: Quadrant): Record { + const { points, join } = q; + const { s, k } = profile(q); + let peakRate = 0; + for (let i = join + 1; i + 1 < points.length - 1; i++) { + const ds = s[i + 1] - s[i]; + if (ds > 1e-9) peakRate = Math.max(peakRate, Math.abs(k[i + 1] - k[i]) / ds); + } + const last = points[points.length - 1]; + const before = points[points.length - 2]; + const arrival = Math.abs((Math.atan2(last.y - before.y, last.x - before.x) * 180) / Math.PI); + const jump = join + 1 < k.length ? Math.abs(k[join] - k[join + 1]) : 0; + const overshoot = Math.max(0, ...points.map((p) => Math.max(-p.y, -p.x, p.x - W / 2))); + return { + "cap radius / r": (q.capRadius / R).toFixed(3), + "flat edge starts at x / r": (q.edgeStart / R).toFixed(2), + "Δκ·R at arc join": (jump * R).toFixed(3), + "peak |dκ/ds|·R² in join": (peakRate * R * R).toFixed(2), + "arrives at edge at": `${arrival.toFixed(2)}°`, + "leaves the box by": overshoot > 0.01 ? `${overshoot.toFixed(2)}px` : "0 (fits)", + }; +} + +// ── Geometry, from the worklets themselves ────────────────────── + +const ARC_SAMPLES = 120; + +/** Points along a cap arc of radius `rho`, centred on the axis at half-height `r`. */ +function arcPoints(r: number, rho: number, beta: number): Point[] { + const sweep = Math.PI / 2 - beta; + const out: Point[] = []; + for (let i = 0; i <= ARC_SAMPLES; i++) { + const theta = Math.PI + (sweep * i) / ARC_SAMPLES; + out.push({ x: rho + rho * Math.cos(theta), y: r + rho * Math.sin(theta) }); + } + return out; +} + +/** + * The shipped easing, rebuilt from the worklet's own fit, profile and + * quadrature, at every one of its integration steps rather than the sparser + * vertices it draws. + */ +function spiralQuadrant(amt: number, ease: number, continuity: number): Quadrant { + const worklet = new PillShape(); + const props = propsFrom({ + [SHAPE.amt.name]: amt, + [SHAPE.ease.name]: ease, + [SHAPE.continuity.name]: continuity, + }); + const c = worklet.resolveContinuity(props); + const fitted = worklet.fitEasing( + R, + W / 2, + worklet.resolveAngle(props), + worklet.resolveExponent(props), + c, + ); + const prof = worklet.profile(fitted.exponent, c); + const fresnel = worklet.fresnel(fitted.beta, prof); + const { radius } = worklet.capMetrics(R, fitted.beta, prof.lambda, fresnel); + + const points = arcPoints(R, radius, fitted.beta); + const k = points.map(() => 1 / radius); + const join = points.length - 1; + + if (fitted.beta > 0 && prof.lambda > 0) { + const { remaining } = prof; + const n = remaining.length - 1; + const length = prof.lambda * radius * fitted.beta; + const ds = length / n; + const start = points[join]; + for (let i = 1; i <= n; i++) { + points.push({ x: start.x + length * fresnel.cos[i], y: start.y - length * fresnel.sin[i] }); + // Turn per unit length, centred where both neighbours exist. + const a = Math.max(i - 1, 0); + const b = Math.min(i + 1, n); + k.push((fitted.beta * (remaining[a] - remaining[b])) / ((b - a) * ds)); + } + k[k.length - 1] = 0; + } + return { points, join, capRadius: radius, edgeStart: points[points.length - 1].x, k }; +} + +/** + * Signed curvature of a Bézier curve at `t`, from its first two derivatives. + * Signed, so a blend that reverses its bend shows up below zero rather than + * folded back above it. + */ +function bezierCurvature(controls: Point[], t: number): number { + const derive = (pts: Point[]) => + pts.slice(1).map((p, i) => ({ + x: (pts.length - 1) * (p.x - pts[i].x), + y: (pts.length - 1) * (p.y - pts[i].y), + })); + const at = (pts: Point[]) => { + let cur = pts; + while (cur.length > 1) { + cur = cur.slice(1).map((p, i) => ({ + x: cur[i].x + (p.x - cur[i].x) * t, + y: cur[i].y + (p.y - cur[i].y) * t, + })); + } + return cur[0]; + }; + const d1 = derive(controls); + const v = at(d1); + const a = at(derive(d1)); + const speed = Math.hypot(v.x, v.y); + return speed === 0 ? 0 : (v.x * a.y - v.y * a.x) / speed ** 3; +} + +function hermiteQuadrant(values: Record): Quadrant { + const { arc, blend, controls, edgeStart, capRadius } = filletQuadrant( + R, + W / 2, + readParams(propsFrom(values)), + ); + const k = [ + ...arc.map(() => 1 / capRadius), + ...blend.map((_, i) => bezierCurvature(controls, (i + 1) / blend.length)), + ]; + // Orient the sign so the blend bends the way the arc does where it leaves it. + const sign = Math.sign(bezierCurvature(controls, 0)) || 1; + for (let i = arc.length; i < k.length; i++) k[i] *= sign; + return { points: [...arc, ...blend], join: arc.length - 1, capRadius, edgeStart, k }; +} + +// ── Drawing ───────────────────────────────────────────────────── + +/** + * The whole outline of a `W` by `H` pill from one quadrant, mirrored the way + * the worklets mirror theirs, as a `clip-path`: how the fillet pill is drawn + * where there is no paint worklet to draw it. + */ +function outlineClipPath({ points }: Quadrant): string { + const reversed = [...points].reverse(); + const outline = [ + ...points, + ...reversed.map((p) => ({ x: W - p.x, y: p.y })), + ...points.map((p) => ({ x: W - p.x, y: H - p.y })), + ...reversed.map((p) => ({ x: p.x, y: H - p.y })), + ]; + const d = outline.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`); + return `path("${d.join("")}Z")`; +} + +const polyline = (pts: Point[]) => pts.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" "); + +function Comb({ q, colour }: { q: Quadrant; colour: string }) { + const { points, join } = q; + const { s, k } = profile(q); + const combScale = 0.7 * R; + const right = Math.min(Math.max(q.edgeStart + 0.6 * R, 2 * R), W / 2); + // Evenly spaced along the curve, so both sides read the same however + // densely each is sampled. + const spacing = s[s.length - 1] / 90; + let lastTooth = -Infinity; + const teeth: string[] = []; + for (let i = 1; i + 1 < points.length; i++) { + if (s[i] - lastTooth < spacing) continue; + lastTooth = s[i]; + const t = { x: points[i + 1].x - points[i - 1].x, y: points[i + 1].y - points[i - 1].y }; + const len = Math.hypot(t.x, t.y) || 1; + const n = { x: t.y / len, y: -t.x / len }; + const h = k[i] * R * combScale; + teeth.push( + `M${points[i].x.toFixed(2)},${points[i].y.toFixed(2)}l${(n.x * h).toFixed(2)},${(n.y * h).toFixed(2)}`, + ); + } + const mirrored = [...points].reverse().map((p) => ({ x: p.x, y: H - p.y })); + const j = points[join]; + return ( + + + + + + + + ); +} + +function Plot({ q, colour }: { q: Quadrant; colour: string }) { + const { s, k } = profile(q); + const total = s[s.length - 1] || 1; + const PW = 320; + const PH = 90; + const pad = 6; + const x = (v: number) => pad + ((PW - 2 * pad) * v) / total; + // A little room below zero, where a blend that reverses its bend dips. + const y = (v: number) => + PH - pad - ((PH - 2 * pad) * (Math.min(Math.max(v * R, -0.15), 1.15) + 0.15)) / 1.3; + const pts = s.map((v, i) => ({ x: x(v), y: y(k[i]) })); + return ( + + + + + + + κ·R = 1 (the arc) + + + arc length → 0 = flat edge + + + ); +} + +function Stats({ values }: { values: Record }) { + return ( +
+ {Object.entries(values).map(([name, value]) => ( +
+
{name}
+
{value}
+
+ ))} +
+ ); +} + +// ── The page ──────────────────────────────────────────────────── + +export default function PillAlgorithmsDemo() { + const [shape, setShape] = useState(DEFAULT_SHAPE); + const [continuity, setContinuity] = useState(2); + const [fit, setFit] = useState(true); + const [arcSetback, setArcSetback] = useState(30); + const [edgeSetback, setEdgeSetback] = useState(1); + const [bulgeStart, setBulgeStart] = useState(1); + const [bulgeEnd, setBulgeEnd] = useState(1); + // On small screens the fillet's controls fold away, so the pinned bar + // leaves room for the pills. + const [filletOpen, setFilletOpen] = useState(false); + + // The demo worklet is registered here rather than in the layout: it is + // this page's alone. Gated on its own attribute, like the shipped one. + const registered = useRef(false); + useEffect(() => { + if (registered.current || !hasPaintWorklet) return; + registered.current = true; + CSS.paintWorklet + .addModule(filletWorkletUrl) + .then(() => document.documentElement.setAttribute(FILLET_ATTRIBUTE, "")) + .catch((error: unknown) => console.error("pill-fillet worklet failed to load:", error)); + }, []); + + const filletValues = useMemo( + () => ({ + "--fillet-continuity": continuity, + "--fillet-arc-setback": arcSetback, + "--fillet-edge-setback": edgeSetback, + "--fillet-bulge-start": bulgeStart, + "--fillet-bulge-end": bulgeEnd, + "--fillet-fit": fit ? 1 : 0, + }), + [continuity, arcSetback, edgeSetback, bulgeStart, bulgeEnd, fit], + ); + + const spiral = useMemo(() => spiralQuadrant(shape.amt, shape.ease, shape.continuity), [shape]); + const hermite = useMemo(() => hermiteQuadrant(filletValues), [filletValues]); + + // Without paint worklets the demo's own fillet worklet can't run either, so + // its pill is clipped to the same outline instead. + const filletStyle = { + ...(filletValues as unknown as React.CSSProperties), + ...(hasPaintWorklet ? {} : { clipPath: outlineClipPath(hermite) }), + }; + + // The polyfill only notices a class change; the sliders change inline + // properties, so it is told. + const spiralPill = useRef(null); + useEffect(() => { + if (spiralPill.current) refreshPill(spiralPill.current); + }, [shape]); + + return ( +
+
+
+

+ Power-law spiral easing — this package +

+

+ Curvature falls from 1/R to 0 as{" "} + + uq−1 + {" "} + along the arc length — a clothoid at q = 2 — and the cap radius is solved + so the outline fits the box exactly. The G3 profile,{" "} + + (1 − t²)q−1 + + , also leaves the arc with curvature flat, and arrives flat at the edge for any ease + above 0. The controls open on the package's defaults. +

+
+
+

+ Hermite blend — CAD-style fillet +

+

+ The construction behind SolidWorks and Onshape's "curvature continuous", Fusion's G2 and + Rhino's BlendCrv: a polynomial with position, tangent and curvature + prescribed at both ends. Fitted, the cap radius is shrunk until the blend stays inside + the box, as the spiral's is, so the two differ only in the transition. Unfitted, the cap + keeps its full radius, as a dimensioned CAD fillet would — and since an easing turns + more slowly than the arc it leaves, it has to bow out past the box to finish turning. +

+
+
+ +
+ +
+ +
+ + + + setFit(e.target.checked)} + className="col-span-2 h-4 w-4 justify-self-start accent-indigo-500" + /> + String(v)} + onChange={setArcSetback} + /> + v.toFixed(2)} + onChange={setEdgeSetback} + /> + v.toFixed(2)} + onChange={setBulgeStart} + /> + v.toFixed(2)} + onChange={setBulgeEnd} + /> +
+
+
+ +
+
+
+ + + + + + + + + +
+
+ ); +} diff --git a/website/src/components/PillControls.tsx b/website/src/components/PillControls.tsx new file mode 100644 index 0000000..f070d29 --- /dev/null +++ b/website/src/components/PillControls.tsx @@ -0,0 +1,134 @@ +import { PILL_SHAPE_PROPERTIES as SHAPE } from "@klinking/squircle/pill-worklet"; + +/** The pill's shape settings, as the controls hold them. */ +export interface PillShapeSettings { + amt: number; + ease: number; + continuity: number; +} + +/** The package's own defaults, so every preview opens on the pill you get. */ +export const DEFAULT_SHAPE: PillShapeSettings = { + amt: SHAPE.amt.default, + ease: SHAPE.ease.default, + continuity: SHAPE.continuity.default, +}; + +/** The settings as the custom properties a pill reads, for a `style`. */ +export const shapeStyle = (shape: PillShapeSettings): Record => ({ + [SHAPE.amt.name]: shape.amt, + [SHAPE.ease.name]: shape.ease, + [SHAPE.continuity.name]: shape.continuity, +}); + +/** + * Pinned to the top of the screen on small screens, where the pills scroll + * out from under the controls otherwise; in the flow from `md` up. + */ +export const PINNED = + "sticky top-0 z-10 -mx-4 border-b border-zinc-800 bg-zinc-950/95 px-4 py-3 backdrop-blur md:static md:mx-0 md:border-0 md:bg-transparent md:px-0 md:py-0 md:backdrop-blur-none"; + +export function Slider({ + id, + label, + value, + min, + max, + step, + format, + onChange, +}: { + id: string; + label: string; + value: number; + min: number; + max: number; + step: number; + format: (v: number) => string; + onChange: (v: number) => void; +}) { + return ( + <> + + onChange(+e.target.value)} + className="slider-filled min-w-0" + /> + {format(value)} + + ); +} + +/** + * The three things a pill's shape can be told: how much of each cap is + * eased, how far the easing runs along the edge, and its continuity. The + * labels are the utilities that set them, and each control marks the + * package's default. + */ +export function PillShapeControls({ + shape, + onChange, + idPrefix = "pill", +}: { + shape: PillShapeSettings; + onChange: (shape: PillShapeSettings) => void; + idPrefix?: string; +}) { + const isDefault = + shape.amt === DEFAULT_SHAPE.amt && + shape.ease === DEFAULT_SHAPE.ease && + shape.continuity === DEFAULT_SHAPE.continuity; + const defaultLabel = (value: number) => (value === DEFAULT_SHAPE.continuity ? " (default)" : ""); + return ( +
+ + + + v.toFixed(2)} + onChange={(amt) => onChange({ ...shape, amt })} + /> + v.toFixed(2)} + onChange={(ease) => onChange({ ...shape, ease })} + /> +
+ ); +} diff --git a/website/src/components/PillGalleryControls.tsx b/website/src/components/PillGalleryControls.tsx new file mode 100644 index 0000000..d7c89b3 --- /dev/null +++ b/website/src/components/PillGalleryControls.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { refreshPills } from "../lib/pill-gallery"; +import { + DEFAULT_SHAPE, + PINNED, + PillShapeControls, + shapeStyle, + type PillShapeSettings, +} from "./PillControls"; + +/** + * The shape controls for the whole gallery. The settings are set on `` + * and every pill inherits them, as they would from any ancestor. + */ +export default function PillGalleryControls() { + const [shape, setShape] = useState(DEFAULT_SHAPE); + useEffect(() => { + const root = document.documentElement; + for (const [name, value] of Object.entries(shapeStyle(shape))) { + root.style.setProperty(name, String(value)); + } + refreshPills(); + }, [shape]); + return ( +
+ +
+ ); +} diff --git a/website/src/components/PillTailwindDemo.tsx b/website/src/components/PillTailwindDemo.tsx new file mode 100644 index 0000000..4158882 --- /dev/null +++ b/website/src/components/PillTailwindDemo.tsx @@ -0,0 +1,123 @@ +function PillBox({ + label, + className, + style, +}: { + label: string; + className: string; + style?: React.CSSProperties; +}) { + return ( +
+
+ {label} +
+ ); +} + +export default function PillTailwindDemo() { + return ( +
+
+

One utility, any size

+

+ squircle-pill derives the caps from the element's + own size, so there is nothing to configure per size. Next to it, the plain{" "} + rounded-full stadium the pill falls back to where + paint worklets are unsupported. +

+
+ + + + +
+
+ +
+

Easing amount and ease

+

+ squircle-pill-amt-* sets how much of each cap is + handed to the curvature easing — 1 is a bare + semicircle. squircle-pill-ease-* stretches the join + further along the flat edge without spending more of the arc. +

+
+ + + + +
+
+ +
+

Borders, the usual way

+

+ With tailwind-pill-border loaded, Tailwind's own{" "} + border-* utilities drive a ring the worklet draws + along the real outline. Inset shadows and outlines need nothing special. +

+
+ + + + + +
+
+ +
+

Common use cases

+
+
+

Pill button

+ +
+
+

Pill badge

+ + New + +
+
+

Pill with icon placeholder

+
+
+ Label +
+
+
+
+
+ ); +} diff --git a/website/src/lib/pill-fillet.worklet.js b/website/src/lib/pill-fillet.worklet.js new file mode 100644 index 0000000..c54b488 --- /dev/null +++ b/website/src/lib/pill-fillet.worklet.js @@ -0,0 +1,355 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +/** + * Demo only; not part of the package. + * + * A second paint worklet, `pill-fillet`, that draws the same pill as the + * shipped `pill-shape` worklet but builds the arc-to-edge join the way CAD + * fillet tools build a curvature-continuous fillet: as a polynomial Hermite + * blend between the two curves, with position, tangent and — for G2 and + * above — curvature prescribed at both ends. That is the construction behind + * SolidWorks' and Onshape's "curvature continuous" fillets, Fusion's G2 + * fillet, and Rhino's BlendCrv, and the knobs those tools expose map onto the + * properties this worklet reads: + * + * --fillet-continuity 1 | 2 | 3 — G1 (cubic), G2 (quintic), G3 (septic). + * Rhino's continuity picker; SolidWorks/Onshape offer + * G1 (circular) and G2 (curvature continuous). + * --fillet-arc-setback Degrees of the semicircle replaced by the blend. + * The "distance" along the first face of an + * asymmetric fillet. + * --fillet-edge-setback How far along the flat edge the blend ends, in + * multiples of the cap's half-height. The second + * distance of an asymmetric fillet. + * --fillet-bulge-start Handle length at each end, as a multiple of the + * --fillet-bulge-end chord. Rhino's per-end bulge sliders; the same + * role as SolidWorks' and Onshape's rho (ρ), which + * also sets how full the blend is. + * + * --fillet-fit 1 (default) shrinks the cap radius until the blend + * stays inside the box, the way the shipped worklet + * fits its easing. 0 keeps the cap at full radius, + * as a CAD fillet would keep a dimensioned radius, + * and the blend bows out past the box to ease off it. + * + * Plain JS rather than TS so the same file can be imported for its geometry + * and handed to `CSS.paintWorklet.addModule()` by URL, in dev and in the + * built site alike. + */ + +const CONTINUITY_VAR = "--fillet-continuity"; +const ARC_SETBACK_VAR = "--fillet-arc-setback"; +const EDGE_SETBACK_VAR = "--fillet-edge-setback"; +const BULGE_START_VAR = "--fillet-bulge-start"; +const BULGE_END_VAR = "--fillet-bulge-end"; +const FIT_VAR = "--fillet-fit"; + +const BLEND_SAMPLES = 96; +/** Arc step, in radians: a constant radius needs no adaptive sampling. */ +const ARC_STEP = Math.PI / 90; + +/** + * @typedef {{ x: number, y: number }} Point + * @typedef {{ get(name: string): { toString(): string } | undefined }} PaintProperties + * @typedef {{ + * continuity: 1 | 2 | 3, + * arcSetback: number, + * edgeSetback: number, + * bulgeStart: number, + * bulgeEnd: number, + * fit: boolean, + * }} FilletParams + * @typedef {{ + * arc: Point[], + * blend: Point[], + * controls: Point[], + * edgeStart: number, + * capRadius: number, + * }} FilletQuadrant + */ + +/** @type {FilletParams} */ +export const DEFAULT_FILLET = { + continuity: 2, + arcSetback: Math.PI / 6, + edgeSetback: 1, + bulgeStart: 1, + bulgeEnd: 1, + fit: true, +}; + +/** + * @param {PaintProperties | undefined} props + * @param {string} name + * @param {number} fallback + */ +const number = (props, name, fallback) => { + const raw = Number.parseFloat(props?.get(name)?.toString() ?? ""); + return Number.isFinite(raw) ? raw : fallback; +}; + +/** + * @param {PaintProperties} [props] + * @returns {FilletParams} + */ +export function readParams(props) { + const continuity = /** @type {1 | 2 | 3} */ ( + Math.min(Math.max(Math.round(number(props, CONTINUITY_VAR, 2)), 1), 3) + ); + const degrees = Math.min(Math.max(number(props, ARC_SETBACK_VAR, 30), 0), 89); + return { + continuity, + arcSetback: (degrees * Math.PI) / 180, + edgeSetback: Math.max(number(props, EDGE_SETBACK_VAR, 1), 0), + bulgeStart: Math.max(number(props, BULGE_START_VAR, 1), 0.05), + bulgeEnd: Math.max(number(props, BULGE_END_VAR, 1), 0.05), + fit: number(props, FIT_VAR, 1) >= 0.5, + }; +} + +/** @type {(a: Point, b: Point) => Point} */ +const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y }); +/** @type {(a: Point, k: number) => Point} */ +const scale = (a, k) => ({ x: a.x * k, y: a.y * k }); + +/** + * Derivatives, up to third order, of a curve leaving a point along unit + * tangent `T` at speed `s` with curvature `k` and curvature rate zero. `N` is + * the normal `T` turns towards. Arc-length parametrised and then rescaled, so + * the speed is constant and the second derivative is pure normal. + * + * @param {Point} T + * @param {number} k + * @param {number} s + * @returns {[Point, Point, Point]} + */ +function endDerivatives(T, k, s) { + const N = { x: -T.y, y: T.x }; + return [ + scale(T, s), + scale(N, s * s * k), + // d/ds of (k N) with k' = 0 is -k^2 T. + scale(T, -(s ** 3) * k * k), + ]; +} + +/** + * Control points of the degree-(2m+1) Bézier curve that interpolates the + * given derivatives at both ends — the Hermite blend. Forward differences at + * the start, backward at the end: Δ^j P_0 = C^(j)(0) · (n-j)! / n!. + * + * @param {Point} P0 + * @param {Point} T0 + * @param {number} k0 + * @param {number} s0 + * @param {Point} P1 + * @param {Point} T1 + * @param {number} k1 + * @param {number} s1 + * @param {1 | 2 | 3} m + * @returns {Point[]} + */ +export function blendControlPoints(P0, T0, k0, s0, P1, T1, k1, s1, m) { + const n = 2 * m + 1; + /** @type {(P: Point, derivs: [Point, Point, Point]) => Point[]} */ + const fromEnd = (P, [first, second, third]) => { + const d1 = scale(first, 1 / n); + const d2 = scale(second, 1 / (n * (n - 1))); + const d3 = scale(third, 1 / (n * (n - 1) * (n - 2))); + const p1 = add(P, d1); + const p2 = add(add(scale(p1, 2), scale(P, -1)), d2); + const p3 = add(add(add(scale(p2, 3), scale(p1, -3)), P), d3); + return [P, p1, p2, p3].slice(0, m + 1); + }; + + const head = fromEnd(P0, endDerivatives(T0, k0, s0)); + // The curve run backwards: odd derivatives flip sign, even ones do not. + const [first, second, third] = endDerivatives(T1, k1, s1); + const tail = fromEnd(P1, [scale(first, -1), second, scale(third, -1)]); + return [...head, ...tail.reverse()]; +} + +/** + * De Casteljau. + * + * @param {Point[]} controls + * @param {number} t + * @returns {Point} + */ +export function bezierPoint(controls, t) { + let pts = controls; + while (pts.length > 1) { + /** @type {Point[]} */ + const next = []; + for (let i = 0; i + 1 < pts.length; i++) { + const a = pts[i]; + const b = pts[i + 1]; + next.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); + } + pts = next; + } + return pts[0]; +} + +/** How far below the box the cap radius may be shrunk, as a share of `r`. */ +const MIN_FIT_RADIUS = 0.3; +/** How far past the box edge a fitted blend may stray, as a share of `r`. */ +const FIT_TOLERANCE = 1e-4; + +/** + * One quadrant of the outline for a cap of radius `rho` in a box of + * half-height `r` and half-length `half`. The cap is a circular arc centred + * on the pill's axis and touching the box's end; the blend leaves it + * `arcSetback` before the arc's top and lands on the flat edge + * `edgeSetback · r` past the arc's top, or at the box's centre if sooner. + * + * @param {number} r + * @param {number} rho + * @param {number} half + * @param {FilletParams} params + * @returns {FilletQuadrant} + */ +function quadrantFor(r, rho, half, params) { + const beta = params.arcSetback; + const P0 = { x: rho - rho * Math.sin(beta), y: r - rho * Math.cos(beta) }; + const T0 = { x: Math.cos(beta), y: -Math.sin(beta) }; + const reach = Math.max(Math.min(params.edgeSetback * r, half - rho), 0); + const P1 = { x: rho + reach, y: 0 }; + const T1 = { x: 1, y: 0 }; + + /** @type {Point[]} */ + const arc = []; + const sweep = Math.PI / 2 - beta; + const steps = Math.max(Math.ceil(sweep / ARC_STEP), 1); + for (let i = 0; i <= steps; i++) { + const theta = Math.PI + (sweep * i) / steps; + arc.push({ x: rho + rho * Math.cos(theta), y: r + rho * Math.sin(theta) }); + } + + if (beta <= 0 && reach <= 0 && rho >= r) { + return { arc, blend: [], controls: [], edgeStart: rho, capRadius: rho }; + } + + const chord = Math.hypot(P1.x - P0.x, P1.y - P0.y); + const controls = blendControlPoints( + P0, + T0, + 1 / rho, + params.bulgeStart * chord, + P1, + T1, + 0, + params.bulgeEnd * chord, + params.continuity, + ); + /** @type {Point[]} */ + const blend = []; + for (let i = 1; i <= BLEND_SAMPLES; i++) blend.push(bezierPoint(controls, i / BLEND_SAMPLES)); + + return { arc, blend, controls, edgeStart: P1.x, capRadius: rho }; +} + +/** + * How far the blend strays past the box: above the flat edge, before the + * box's end, or past its centre line. + * + * @param {FilletQuadrant} q + * @param {number} half + */ +const overshoot = (q, half) => Math.max(0, ...q.blend.map((p) => Math.max(-p.y, -p.x, p.x - half))); + +/** + * One quadrant of the outline, for a box of half-height `r` and half-length + * `half`. + * + * Unfitted, the cap keeps the full radius `r`. A blend that eases curvature + * off an arc already touching the box edge turns more slowly than the arc + * would have, so it has to climb past that edge to finish turning: the + * bulge is geometry, not a setting. + * + * Fitted, the largest cap radius whose blend stays inside the box is found + * by bisection — the same constraint the shipped spiral satisfies by solving + * for its radius — so the two differ only in the shape of the transition. + * + * @param {number} r + * @param {number} half + * @param {FilletParams} params + * @returns {FilletQuadrant} + */ +export function filletQuadrant(r, half, params) { + const full = quadrantFor(r, r, half, params); + if (!params.fit || overshoot(full, half) <= FIT_TOLERANCE * r) return full; + + let low = MIN_FIT_RADIUS * r; + let high = r; + let best = quadrantFor(r, low, half, params); + for (let i = 0; i < 30; i++) { + const mid = (low + high) / 2; + const candidate = quadrantFor(r, mid, half, params); + if (overshoot(candidate, half) <= FIT_TOLERANCE * r) { + low = mid; + best = candidate; + } else { + high = mid; + } + } + return best; +} + +export const paintDef = class PillFillet { + static get inputProperties() { + return [ + CONTINUITY_VAR, + ARC_SETBACK_VAR, + EDGE_SETBACK_VAR, + BULGE_START_VAR, + BULGE_END_VAR, + FIT_VAR, + ]; + } + + /** + * @param {CanvasRenderingContext2D} ctx + * @param {{ width: number, height: number }} size + * @param {PaintProperties} [props] + */ + paint(ctx, size, props) { + const { width, height } = size; + if (width <= 0 || height <= 0) return; + + const vertical = height > width; + const long = vertical ? height : width; + const short = vertical ? width : height; + const r = short / 2; + const { arc, blend } = filletQuadrant(r, long / 2, readParams(props)); + const quadrant = [...arc, ...blend]; + const reversed = [...quadrant].reverse(); + const outline = [ + ...quadrant, + ...reversed.map((p) => ({ x: long - p.x, y: p.y })), + ...quadrant.map((p) => ({ x: long - p.x, y: short - p.y })), + ...reversed.map((p) => ({ x: p.x, y: short - p.y })), + ]; + + ctx.fillStyle = "#000"; + ctx.beginPath(); + for (let i = 0; i < outline.length; i++) { + const p = outline[i]; + const x = vertical ? p.y : p.x; + const y = vertical ? p.x : p.y; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fill(); + } +}; + +// `registerPaint` only exists inside a paint worklet global scope. Guarding +// the call keeps this module importable from the page for its geometry. +if (typeof registerPaint !== "undefined") { + registerPaint("pill-fillet", paintDef); +} diff --git a/website/src/lib/pill-gallery.ts b/website/src/lib/pill-gallery.ts new file mode 100644 index 0000000..1959f76 --- /dev/null +++ b/website/src/lib/pill-gallery.ts @@ -0,0 +1,51 @@ +/** + * Draws the pill gallery (src/pages/demos/pill-gallery.astro) in the mode its + * URL asks for. Each mode is a page load of its own, because a registered + * paint worklet can never be unregistered. + */ +import { registerPillWorklet } from "@klinking/squircle/pill-worklet"; +import { polyfillPills, type PillPolyfill } from "@klinking/squircle/pill-polyfill"; +import workletUrl from "@klinking/squircle/pill-shape.worklet.js?url"; + +type Mode = "auto" | "worklet" | "polyfill" | "none"; +const MODES: Mode[] = ["auto", "worklet", "polyfill", "none"]; + +const asked = new URLSearchParams(location.search).get("mode") as Mode | null; +const mode: Mode = asked && MODES.includes(asked) ? asked : "auto"; + +for (const link of document.querySelectorAll("[data-mode]")) { + const current = link.dataset.mode === mode; + link.classList.toggle("bg-indigo-600", current); + link.classList.toggle("text-white", current); + if (current) link.setAttribute("aria-current", "page"); +} + +const status = document.getElementById("drawn-by") as HTMLElement; + +/** The polyfill, where this mode uses it. */ +let polyfill: PillPolyfill | null = null; + +async function start() { + let drawnBy = "the plain stadium fallback"; + if (mode === "auto" || mode === "worklet") { + if (await registerPillWorklet(workletUrl)) drawnBy = "the paint worklet"; + else if (mode === "auto") { + polyfill = polyfillPills(); + drawnBy = "the polyfill (this browser has no paint worklet)"; + } else drawnBy = "the plain stadium fallback (this browser has no paint worklet)"; + } else if (mode === "polyfill") { + polyfill = polyfillPills({ force: true }); + drawnBy = "the polyfill"; + } + status.textContent = drawnBy; +} + +const started = start(); + +/** + * Redraws every pill after the shape controls change it: the polyfill only + * notices a class change, and the controls set custom properties. + */ +export function refreshPills(): void { + void started.then(() => polyfill?.refresh()); +} diff --git a/website/src/lib/pills.ts b/website/src/lib/pills.ts new file mode 100644 index 0000000..a77b549 --- /dev/null +++ b/website/src/lib/pills.ts @@ -0,0 +1,30 @@ +/** + * Draws the site's pills: with the paint worklet where the browser has one, + * and with the polyfill where it doesn't. Shared by the layout and anything + * on the page that changes a pill in a way the polyfill can't see, such as + * an inline style set from a slider, which calls `refreshPill` after. + */ +import { registerPillWorklet } from "@klinking/squircle/pill-worklet"; +import type { PillPolyfill } from "@klinking/squircle/pill-polyfill"; +// Vite serves the worklet as a hashed asset, so hand its URL over rather +// than relying on the helper's default sibling lookup. +import workletUrl from "@klinking/squircle/pill-shape.worklet.js?url"; + +/** The polyfill, once it runs; `null` where the worklet draws the pills. */ +export const pills: Promise = registerPillWorklet(workletUrl) + .catch((error: unknown) => { + // A worklet that fails to load is a bug worth seeing, but the pills can + // still be drawn. + console.error("pill worklet failed to load; using the polyfill:", error); + return false; + }) + .then(async (loaded) => { + if (loaded) return null; + const { polyfillPills } = await import("@klinking/squircle/pill-polyfill"); + return polyfillPills({ force: true }); + }); + +/** Redraws a pill whose settings just changed; the worklet needs no telling. */ +export function refreshPill(element: Element): void { + void pills.then((polyfill) => polyfill?.refresh(element)); +} diff --git a/website/src/pages/bench/pill-frame.astro b/website/src/pages/bench/pill-frame.astro new file mode 100644 index 0000000..98b554b --- /dev/null +++ b/website/src/pages/bench/pill-frame.astro @@ -0,0 +1,25 @@ +--- +// One benchmark run: `count` pills, drawn by `mode` — "worklet", "polyfill" +// or "none" (the plain stadium, as a baseline) — resized every frame. +// +// Deliberately not built on the site layout: that registers the worklet, +// which can never be unregistered, and each mode has to choose for itself. +// The runner at /bench/pills loads this in a fresh iframe per run. +import "../../styles/global.css"; +--- + + + + + + Pill benchmark run + + +

starting…

+
+ + + + diff --git a/website/src/pages/bench/pills.astro b/website/src/pages/bench/pills.astro new file mode 100644 index 0000000..8ac8015 --- /dev/null +++ b/website/src/pages/bench/pills.astro @@ -0,0 +1,165 @@ +--- +import Layout from "../../components/Layout.astro"; +--- + + +

Pill resize benchmark

+

+ Each run renders a number of pills at mixed sizes — a quarter with a border, a quarter at G3 + — whose widths are a share of their container, then shrinks that container to 45% and back + over 120 frames so every pill resizes, and changes aspect ratio, every frame. Runs happen in + a fresh iframe each, because a registered paint worklet cannot be unregistered. + worklet is the CSS Paint API path, + polyfill the clip-path fallback forced on, and + none the plain stadium as a baseline. Frame times are + the gaps between animation frames, so anything over 16.7 ms is a dropped frame at + 60 Hz. Keep this tab in front while it runs. +

+ +
+
+ pill counts +
+ { + [10, 100, 500, 1000, 2000].map((n) => ( + + )) + } +
+
+
+ modes +
+ { + ["none", "worklet", "polyfill"].map((m) => ( + + )) + } +
+
+ + +
+ +
+ + + + + + + + + + + + + + + +
modepillssetup msmean framep50p95maxframes >20mslong tasks (ms)
+
+ + + + +
diff --git a/website/src/pages/demos/panda.astro b/website/src/pages/demos/panda.astro index 105ece3..c145a61 100644 --- a/website/src/pages/demos/panda.astro +++ b/website/src/pages/demos/panda.astro @@ -8,6 +8,11 @@ import PandaDemo from "../../components/PandaDemo";

Panda CSS

+ Explore Panda CSS utilities for squircles with visual examples. +

+ +

Squircle Corners

+

Squircle utilities consumed via the Panda preset's squircle* shorthands. Panda is configured with +

Edit on StackBlitz

+

Pill algorithms, side by side

+

+ Two ways to join a pill's semicircular cap to its straight edge with no jump in curvature. +

+

+ Left, the shipped squircle-pill worklet. Right, the same pill + with the join built the way CAD tools build a curvature-continuous fillet, complete with the + controls those tools expose. The comb on each cap is curvature — the same graph Rhino's + CurvatureGraph and SolidWorks' curvature combs draw — and the plot beneath is + κ·R against arc length. A step at the arc join is G1 only, a + kink is G2, no kink is G3. Where the browser has no paint worklets, both pills are drawn without + them: the shipped one by its polyfill, the fillet by a clip-path of the same outline. +

+ + + +

+ Back to the Tailwind demo. +

+ + + diff --git a/website/src/pages/demos/pill-gallery.astro b/website/src/pages/demos/pill-gallery.astro new file mode 100644 index 0000000..5d2f608 --- /dev/null +++ b/website/src/pages/demos/pill-gallery.astro @@ -0,0 +1,320 @@ +--- +// Every way of decorating a pill, labelled, in whichever mode the URL asks +// for. Deliberately not built on the site layout: that registers the worklet, +// which can never be unregistered, and each mode has to choose for itself. +import "../../styles/global.css"; +import PillGalleryControls from "../../components/PillGalleryControls"; +// The plain-CSS stylesheet too, for the section that uses it without Tailwind. +import "@klinking/squircle/squircle-pill.css"; + +interface Example { + /** + * The classes under test, shown as the label; for a `standalone` pill, the + * inline style instead. + */ + classes: string; + /** What to expect. */ + note: string; + /** + * A plain pill; a focusable button for focus states; a pill holding a + * smaller one; or a `data-squircle-pill` element styled with the plain-CSS + * stylesheet's own properties, no Tailwind involved. + */ + build?: "pill" | "button" | "nested" | "standalone"; + /** Shown on a light tile, whatever its section uses. */ + light?: boolean; +} + +interface Section { + title: string; + intro: string; + examples: Example[]; + /** Shown on light tiles, where a dark shadow reads clearly. */ + light?: boolean; +} + +// Every class is spelled out in full so Tailwind finds and generates it. +const sections: Section[] = [ + { + title: "Backgrounds", + intro: + "The pill hides the element's own background and paints a copy of it shaped to the pill, so any background works: colours, gradients, images, translucency.", + examples: [ + { classes: "bg-linear-to-r from-indigo-500 to-pink-500", note: "A linear gradient." }, + { + classes: "bg-conic-210 from-amber-400 via-violet-500 to-amber-400", + note: "A conic gradient.", + }, + { + classes: + "bg-[repeating-conic-gradient(#0ea5e9_0_25%,#f59e0b_0_50%)] bg-size-[28px_28px]", + note: "A repeating image, positioned against the box as usual.", + }, + { classes: "bg-indigo-500/40", note: "Translucent, so the page shows through." }, + ], + }, + { + title: "Clip paths of its own", + intro: + "The pill shapes a copy of its background rather than the element itself, so the element's own clip-path applies on top of it, border and all, with the worklet and the polyfill alike.", + examples: [ + { classes: "[clip-path:inset(0_0_0_50%)]", note: "The right half of the pill." }, + { + classes: "[clip-path:polygon(0_0,100%_0,0_100%)]", + note: "The pill's top-left triangle.", + }, + { + classes: "[clip-path:circle(40%_at_100%_50%)]", + note: "A lens: the pill inside a circle centred on its right edge.", + }, + { + classes: "[clip-path:inset(6px_round_10px)]", + note: "The pill inside a rounded rectangle 6px in from each edge.", + }, + ], + }, + { + title: "Tailwind mask utilities", + intro: + "A mask on the element applies to the whole pill, as it would to any element: the shape is on the copy of its background, so the two never compete.", + examples: [ + { classes: "mask-b-from-20%", note: "Fades out towards the bottom." }, + { classes: "mask-r-from-40%", note: "Fades out towards the right." }, + { classes: "mask-radial-from-40%", note: "Fades out from the centre." }, + ], + }, + { + title: "Borders", + intro: + "Drawn by the pill along its own outline, from Tailwind's border utilities, with tailwind-pill-border loaded.", + examples: [ + { classes: "border-2 border-white", note: "A solid ring along the outline." }, + { classes: "border-4 border-dashed border-amber-300", note: "Dashes along the outline." }, + { classes: "border-2 border-dotted border-white", note: "Dots along the outline." }, + { + classes: "border-2", + note: "Width only: the colour defaults to currentColor, as a real border's does.", + }, + { + classes: "border-2 border-violet-300", + note: "A pill inside a bordered pill draws no border of its own.", + build: "nested", + }, + ], + }, + { + title: "Outlines", + intro: + "Drawn by the pill along its own outline, with tailwind-pill-border loaded: offset outwards, or inwards with a negative offset, solid, dashed or dotted.", + examples: [ + { classes: "outline-2 outline-white", note: "Hugging the outline." }, + { classes: "outline-2 outline-offset-4 outline-white", note: "Offset 4px out." }, + { + classes: "outline-2 outline-offset-2 outline-dashed outline-amber-300", + note: "Dashed, offset 2px out.", + }, + { + classes: "outline-2 -outline-offset-2 outline-white", + note: "Inset 2px, just inside the edge.", + }, + { classes: "outline-2 -outline-offset-6 outline-white", note: "Inset 6px." }, + ], + }, + { + title: "Tailwind ring utilities", + intro: + "Drawn by the pill along its own outline too, with tailwind-pill-border loaded. A ring offset is a band of its own colour, as in Tailwind.", + examples: [ + { classes: "ring-2 ring-white", note: "A ring hugging the outline." }, + { + classes: "ring-2 ring-white ring-offset-2 ring-offset-zinc-950", + note: "With a 2px offset in the page's colour.", + }, + { + classes: "ring-4 ring-amber-300/60", + note: "Semi-transparent, so the background shows through.", + }, + { classes: "inset-ring-2 inset-ring-white", note: "An inset ring, inside the edge." }, + ], + }, + { + title: "Focus", + intro: + "Tab through these. The pill picks up focus states like any other, outside the box included; with no utility at all the browser's own focus ring shows, around the stadium.", + examples: [ + { + classes: "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white", + note: "An offset outline on keyboard focus.", + build: "button", + }, + { + classes: + "focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950", + note: "A ring with an offset on keyboard focus.", + build: "button", + }, + { classes: "", note: "The browser's own focus ring.", build: "button" }, + ], + }, + { + title: "Shadows", + intro: + "Box shadows are cast by the pill's own outline, so they meet it with no gap. A drop shadow follows the pill-shaped background by itself; inner shadows are painted inside the pill. Shown on light tiles so the shadows stand out.", + light: true, + examples: [ + { classes: "shadow-xl shadow-black/60", note: "A box shadow." }, + { classes: "drop-shadow-xl drop-shadow-black/60", note: "A drop shadow filter." }, + { classes: "shadow-inner shadow-black/80", note: "An inner shadow." }, + { classes: "inset-shadow-sm inset-shadow-black/80", note: "An inset shadow." }, + { + classes: "shadow-lg shadow-black/50 ring-2 ring-white", + note: "A shadow under a ring.", + }, + { + classes: + "bg-linear-to-r from-blue-700 to-violet-600 border-4 border-zinc-900 outline-2 -outline-offset-8 outline-white/60 drop-shadow-xl drop-shadow-black/60", + note: "A gradient, a border, an inset outline and a drop shadow, all at once.", + }, + ], + }, + { + title: "Without Tailwind", + intro: + "The plain-CSS stylesheet, squircle-pill.css, on data-squircle-pill elements, driven by the pill's own custom properties. Each label is the element's inline style.", + examples: [ + { + classes: + "--squircle-pill-border-width: 3px; --squircle-pill-border-color: #fbbf24; --squircle-pill-border-style: dashed", + note: "A dashed border.", + build: "standalone", + }, + { + classes: "--squircle-pill-border-width: 0.2rem", + note: "Width only: currentColor, and any length unit.", + build: "standalone", + }, + { + classes: + "--squircle-pill-ring-width: 2px; --squircle-pill-ring-color: #fff; --squircle-pill-ring-offset-width: 2px; --squircle-pill-ring-offset-color: #09090b", + note: "A ring beyond an offset band.", + build: "standalone", + }, + { + classes: + "--squircle-pill-outline-width: 2px; --squircle-pill-outline-offset: -6px; --squircle-pill-outline-color: #fff", + note: "An inset outline.", + build: "standalone", + }, + { + classes: + "--squircle-pill-box-shadow: 0 8px 16px rgb(0 0 0 / 0.6); --squircle-pill-shadow-reach: 24px", + note: "A box shadow, with how far it reaches past the pill.", + build: "standalone", + light: true, + }, + ], + }, +]; + +const modes = [ + ["auto", "Auto: worklet, or the polyfill"], + ["worklet", "Worklet only"], + ["polyfill", "Polyfill"], + ["none", "No shaping"], +] as const; + +const PILL = "squircle-pill h-12 w-40 bg-indigo-500"; +/** The same pill for the plain-CSS stylesheet: the attribute, and its own background. */ +const STANDALONE_BACKGROUND = "background: #6366f1"; +--- + + + + + + Pill gallery + + +
+

← Demos

+

Pill gallery

+

+ Every pill below is {PILL} plus the classes in its label, + unless its section says otherwise. + Switch modes to compare how each is drawn; each loads the page afresh. The notes describe a + shaped pill: with no shaping each is a plain stadium, so what the shape would erase still + shows. +

+ +

+ Drawn by … +

+ + + { + sections.map((section) => ( +
+

{section.title}

+

{section.intro}

+
    + {section.examples.map(({ classes, note, build = "pill", light }) => ( +
  • +
    + {build === "pill" &&
    } + {build === "nested" && ( +
    +
    + Label +
    + )} + {build === "standalone" && ( +
    + )} + {build === "button" && ( + + )} +
    +
    +

    + {classes || "(no classes)"} +

    +

    {note}

    +
    +
  • + ))} +
+
+ )) + } +
+ + + + diff --git a/website/src/pages/demos/stylex.astro b/website/src/pages/demos/stylex.astro index fe01bf8..36b4012 100644 --- a/website/src/pages/demos/stylex.astro +++ b/website/src/pages/demos/stylex.astro @@ -7,6 +7,11 @@ import StyleXDemo from "../../components/StyleXDemo";

StyleX

+ Explore StyleX utilities for squircles with visual examples. +

+ +

Squircle Corners

+

Squircle utilities authored as StyleX dynamic styles. Each variant is a function that takes a radius (and an optional superellipse amt) and produces a @@ -19,6 +24,7 @@ import StyleXDemo from "../../components/StyleXDemo"; +

Edit on StackBlitz

Tailwind

- This demo compares regular rounded-* corners - with squircle squircle-* corners at several - radii. + Explore Tailwind utilities for squircles and pills with visual examples. +

+ +

Squircle Corners

+

+ Compare regular rounded-* corners + with squircle squircle-* corners at several radii.

+

Pill Shapes

+

+ Pill-shaped rectangles whose semicircular caps ease into the straight edges with no + jump in curvature, or in how fast it changes (G3), drawn by a Houdini paint worklet. Browsers without the + Paint API show the plain rounded-full stadium instead. +

+ + +

Edit on StackBlitz

More coming soon

-

Checkout the README for more information.

+

Checkout the README for more information.

diff --git a/website/src/styles/global.css b/website/src/styles/global.css index 390fd1f..4eee39f 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -1,5 +1,7 @@ @import "tailwindcss"; @plugin "../../../package/dist/tailwind/index.mjs"; +@plugin "../../../package/dist/tailwind-pill/index.mjs"; +@plugin "../../../package/dist/tailwind-pill-border/index.mjs"; @theme { /* Semantic colors used by the demo boxes. Each names what the box is