diff --git a/README.md b/README.md index 38d2260..af00934 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,7 @@ 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 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: diff --git a/package/src/pill-worklet.ts b/package/src/pill-worklet.ts index 71ab1cd..e75aa14 100644 --- a/package/src/pill-worklet.ts +++ b/package/src/pill-worklet.ts @@ -3,10 +3,31 @@ * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle */ -import { PILL_WORKLET_ATTRIBUTE } from "./variants"; +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. diff --git a/website/src/components/Layout.astro b/website/src/components/Layout.astro index fc5ff33..fe253a1 100644 --- a/website/src/components/Layout.astro +++ b/website/src/components/Layout.astro @@ -14,12 +14,8 @@ const { title } = Astro.props; {title} diff --git a/website/src/components/PillAlgorithmsDemo.tsx b/website/src/components/PillAlgorithmsDemo.tsx index 7140487..8df51a3 100644 --- a/website/src/components/PillAlgorithmsDemo.tsx +++ b/website/src/components/PillAlgorithmsDemo.tsx @@ -1,13 +1,25 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { paintDef as PillShape } from "@klinking/squircle/pill-shape.worklet"; +import { PILL_SHAPE_PROPERTIES as SHAPE } from "@klinking/squircle/pill-worklet"; +import { + DEFAULT_SHAPE, + PINNED, + PillShapeControls, + Slider, + shapeStyle, + type PillShapeSettings, +} from "./PillControls"; import { filletQuadrant, readParams } from "../lib/pill-fillet.worklet.js"; import filletWorkletUrl from "../lib/pill-fillet.worklet.js?url"; +import { refreshPill } from "../lib/pills"; const W = 320; const H = 80; const R = H / 2; -const NS = "--squircle-pill"; const FILLET_ATTRIBUTE = "data-pill-fillet"; +/** Whether the browser can draw the demo's own fillet worklet. */ +const hasPaintWorklet = + typeof CSS !== "undefined" && Boolean((CSS as { paintWorklet?: unknown }).paintWorklet); type Point = { x: number; y: number }; @@ -89,9 +101,9 @@ function arcPoints(r: number, rho: number, beta: number): Point[] { function spiralQuadrant(amt: number, ease: number, continuity: number): Quadrant { const worklet = new PillShape(); const props = propsFrom({ - [`${NS}-amt`]: amt, - [`${NS}-ease`]: ease, - [`${NS}-continuity`]: continuity, + [SHAPE.amt.name]: amt, + [SHAPE.ease.name]: ease, + [SHAPE.continuity.name]: continuity, }); const c = worklet.resolveContinuity(props); const fitted = worklet.fitEasing( @@ -173,6 +185,23 @@ function hermiteQuadrant(values: Record): Quadrant { // ── 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 }) { @@ -277,63 +306,25 @@ function Stats({ values }: { values: Record }) { ); } -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" - /> - {format(value)} - - ); -} - // ── The page ──────────────────────────────────────────────────── export default function PillAlgorithmsDemo() { - const [amt, setAmt] = useState(2); - const [ease, setEase] = useState(2); - const [spiralContinuity, setSpiralContinuity] = useState(3); + 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 || !("paintWorklet" in CSS)) return; + if (registered.current || !hasPaintWorklet) return; registered.current = true; CSS.paintWorklet .addModule(filletWorkletUrl) @@ -353,167 +344,160 @@ export default function PillAlgorithmsDemo() { [continuity, arcSetback, edgeSetback, bulgeStart, bulgeEnd, fit], ); - const spiral = useMemo( - () => spiralQuadrant(amt, ease, spiralContinuity), - [amt, ease, spiralContinuity], - ); + const spiral = useMemo(() => spiralQuadrant(shape.amt, shape.ease, shape.continuity), [shape]); const hermite = useMemo(() => hermiteQuadrant(filletValues), [filletValues]); - const spiralStyle = { - [`${NS}-amt`]: amt, - [`${NS}-ease`]: ease, - [`${NS}-continuity`]: spiralContinuity, - } as React.CSSProperties; - const filletStyle = filletValues as unknown as React.CSSProperties; + // 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. It is the default. -

-
- - - v.toFixed(2)} - onChange={setAmt} - /> - v.toFixed(2)} - onChange={setEase} - /> +
+
+
+

+ 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. +

-
-

- 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} - /> + + + + 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/lib/pill-gallery.ts b/website/src/lib/pill-gallery.ts index 2c26f11..1959f76 100644 --- a/website/src/lib/pill-gallery.ts +++ b/website/src/lib/pill-gallery.ts @@ -4,7 +4,7 @@ * paint worklet can never be unregistered. */ import { registerPillWorklet } from "@klinking/squircle/pill-worklet"; -import { polyfillPills } from "@klinking/squircle/pill-polyfill"; +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"; @@ -22,19 +22,30 @@ for (const link of document.querySelectorAll("[data-mode]")) 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") { - polyfillPills(); + 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") { - polyfillPills({ force: true }); + polyfill = polyfillPills({ force: true }); drawnBy = "the polyfill"; } status.textContent = drawnBy; } -void start(); +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/demos/pill-algorithms.astro b/website/src/pages/demos/pill-algorithms.astro index 381d418..594bce2 100644 --- a/website/src/pages/demos/pill-algorithms.astro +++ b/website/src/pages/demos/pill-algorithms.astro @@ -14,7 +14,8 @@ import PillAlgorithmsDemo from "../../components/PillAlgorithmsDemo"; 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. Requires a Chromium browser; elsewhere both pills are plain stadiums. + 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.

diff --git a/website/src/pages/demos/pill-gallery.astro b/website/src/pages/demos/pill-gallery.astro index 8479764..5d2f608 100644 --- a/website/src/pages/demos/pill-gallery.astro +++ b/website/src/pages/demos/pill-gallery.astro @@ -3,6 +3,7 @@ // 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"; @@ -257,9 +258,10 @@ const STANDALONE_BACKGROUND = "background: #6366f1"; )) } -

+

Drawn by …

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