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.
-
+ 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.
-
-
-
-
);
}
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 (
+
+ );
+}
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";
))
}
-