diff --git a/README.md b/README.md index 40963d8..e8cb54d 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) @@ -443,6 +444,94 @@ The parameters are deliberately untyped so relative units (`em`, `rem`, containe +## Pill Shapes with Houdini CSS Paint Worklet + +The standard `corner-shape: superellipse()` can't perfectly render pill shapes because it creates hard corners where the curved ends meet the straight sides. **Pill shapes** use a Houdini CSS Paint API worklet to render mathematically smooth, G2-continuous transitions from semicircular ends to straight edges—ideal for button pills, badge pills, and other pill-shaped UI elements. + +
+Tailwind CSS v4 + +### 1. Install the pill plugin + +```bash +npm install @klinking/squircle +``` + +### 2. Add the pill plugin to your CSS + +```css +@import "tailwindcss"; +@import "@klinking/squircle/tailwind-pill"; +@import "@klinking/squircle/squircle-pill.css"; +``` + +Or with the JS plugin version: + +```css +@import "tailwindcss"; +@plugin "@klinking/squircle/tailwind-pill"; +@import "@klinking/squircle/squircle-pill.css"; +``` + +### 3. Register the paint worklet + +In your entry point (e.g., `main.ts`), register the Houdini paint worklet: + +```typescript +// Register the paint worklet +CSS.paintWorklet.addModule( + new URL("@klinking/squircle/pill-shape.worklet.js", import.meta.url).href +); +``` + +### 4. Use the pill utility + +Use the `squircle-pill` class to apply pill shapes to any element: + +```html + + + + +
New
+ + +
+ + +
+``` + +**Available variants:** + +- Base utility: `squircle-pill` +- Side variants: `squircle-pill-t`, `squircle-pill-r`, `squircle-pill-b`, `squircle-pill-l` (top, right, bottom, left) +- Corner variants: `squircle-pill-tl`, `squircle-pill-tr`, `squircle-pill-br`, `squircle-pill-bl` (and logical equivalents) +- Amount control: `squircle-pill-amt-*` (e.g., `squircle-pill-amt-1`, `squircle-pill-amt-2.5`, `squircle-pill-amt-3`) to adjust the smoothness of the semicircle-to-edge transition + +The pill radius is automatically calculated from the element's dimensions: `radius = min(width, height) / 2`, ensuring perfect pills at any size. + +### Browser support and fallback + +- **Chrome/Edge 89+**: Full Houdini CSS Paint API support — perfect pill shapes with G2-continuous curves +- **Safari/Firefox**: Graceful fallback to `corner-shape: superellipse()` which approximates a pill shape + +
+ +### How pill shapes work + +Pill shapes render using Houdini's CSS Paint API to draw: + +- **Horizontal pills** (width > height): Semicircles on left and right, straight top and bottom edges +- **Vertical pills** (height > width): Semicircles on top and bottom, straight left and right edges +- **Circular pills** (width = height): Full circles + +The worklet computes G2-continuous Bezier curves at junctions between the semicircles and straight edges, using cubic Bezier approximation (magic constant `0.55228`) to smoothly transition from the semicircle's constant curvature to the straight edge's zero curvature. + +**Fallback for unsupported browsers:** Falls back to `corner-shape: superellipse()` which approximates a pill shape without perfect mathematical continuity. + ## 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. diff --git a/package/index.html b/package/index.html new file mode 100644 index 0000000..a683345 --- /dev/null +++ b/package/index.html @@ -0,0 +1,467 @@ + + + + + + Pill Shape Dev + + + + + +
+

Pill Shape Dev

+

+ Testing squircle-pill utilities with Houdini paint worklet (hot reload enabled) +

+ + + +

+ The caps stay circular arcs. amt sets how much of each cap + is handed to the easing — 1 is a bare semicircle, identical to + border-radius: 9999px and only G1. spread smooths that join + further along the flat edge without spending more of the arc, so raising it lets + the amount come back down. 0 is a clothoid and the sane floor: below it the + easing shortens and arrives ever more steeply, and at -2 it is a bare + corner. When the element is too narrow for the request, both are eased down together so the + curvature rate stays put; a square has no flat edge at all and renders as a plain circle. +

+ + + +
+

Perfect Pills

+
+ +
Badge
+
+ ✓ +
+
+
+ +
+

Different Aspect Ratios

+
+
Wide
+
Tall
+
+ Square +
+
+
+ +
+

Backgrounds, Borders & Shadows

+

+ The shape is a mask, so the element keeps its own background and that + background gets pill-shaped. The flip side: a mask erases everything outside the shape, so + outline and an outer box-shadow cannot survive on the pill + itself, and filter runs before the mask so even drop-shadow set + on the pill is clipped away. Each row below is the technique that does work. +

+ +
+
+

Linear gradient

+
+
+ +
+

Conic gradient

+
+
+ +
+

Background image

+
+
+ +
+

+ Border — --%SQUIRCLE_NS%-pill-border-width +

+
+
+ +
+

Border only, no background

+
+
+ +
+

+ Dashed — from --tw-border-style +

+
+
+ +
+

+ Dotted — from --tw-border-style +

+
+
+ +
+

Inner outline — inset pseudo

+
+
+ +
+

Outer outline — masked wrapper

+
+
+
+
+ +
+

Drop shadow — wrapper filter

+
+
+
+
+ +
+

Inner shadow — inset on pseudo

+
+
+ +
+

Everything at once

+
+
+
+
+
+
+ +
+

Use Cases

+
+
+

Buttons

+
+ + + +
+
+ +
+

Tags & Badges

+
+ Tag + Status + Warning +
+
+ +
+

Icon Buttons

+
+ + + +
+
+
+
+ +
+

+ Note: @supports (background-image: paint(anything)) is true + in Chrome for any paint name, registered or not — so DevTools showing + paint(pill-shape) as applied does not mean the worklet loaded. Check + document.documentElement.dataset.worklet (or the console) instead. +

+
+
+ + diff --git a/package/package.json b/package/package.json index d82d56d..68cd9d7 100644 --- a/package/package.json +++ b/package/package.json @@ -26,7 +26,21 @@ "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-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" @@ -37,6 +51,7 @@ } }, "scripts": { + "dev": "vp run pill-dev", "prepublishOnly": "vp run build" }, "devDependencies": { @@ -74,5 +89,8 @@ "optional": true } }, - "packageManager": "pnpm@10.33.0" + "packageManager": "pnpm@10.33.0", + "squircle": { + "cssNamespace": "squircle" + } } diff --git a/package/scripts/copy-pill-assets.ts b/package/scripts/copy-pill-assets.ts new file mode 100644 index 0000000..4d75011 --- /dev/null +++ b/package/scripts/copy-pill-assets.ts @@ -0,0 +1,33 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const distDir = join(__dirname, "..", "dist"); +mkdirSync(distDir, { recursive: true }); + +/* + * Copy the pill stylesheet, rewriting the custom-property namespace if one was + * configured. The source carries the default so it stays readable and lintable; + * the worklet and the plugins take the same value through a build-time define, + * so all three agree however it is set. + */ +const DEFAULT_CSS_NAMESPACE = "squircle"; +const cssNamespace: string = + process.env.SQUIRCLE_CSS_NAMESPACE || + JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).squircle?.cssNamespace || + DEFAULT_CSS_NAMESPACE; + +const pillCssSrc = join(__dirname, "..", "src", "squircle-pill.css"); +const pillCssDest = join(distDir, "squircle-pill.css"); +const pillCss = readFileSync(pillCssSrc, "utf8").replaceAll( + `--${DEFAULT_CSS_NAMESPACE}-pill-`, + `--${cssNamespace}-pill-`, +); +writeFileSync(pillCssDest, pillCss); +console.log(`Copied ${pillCssDest} (namespace: ${cssNamespace})`); + +// Note: pill-shape.worklet.ts will be bundled by vp pack as a regular module +// Users will need to register it with CSS.paintWorklet.addModule() in their app +console.log("Pill shape worklet will be bundled as pill-shape.worklet.mjs"); 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-shape.geometry.test.ts b/package/src/pill-shape.geometry.test.ts new file mode 100644 index 0000000..99383bb --- /dev/null +++ b/package/src/pill-shape.geometry.test.ts @@ -0,0 +1,514 @@ +/*! + * @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_EASE_SPREAD_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 = ""; + vertices: Point[] = []; + closed = false; + + beginPath(): void {} + fill(): void {} + moveTo(x: number, y: number): void { + this.vertices.push({ x, y }); + } + lineTo(x: number, y: number): void { + this.vertices.push({ x, y }); + } + arc(): void { + throw new Error("the outline must be emitted as a polyline"); + } + closePath(): void { + this.closed = true; + } +} + +const props = (amt?: number, spread?: number) => ({ + get(name: string) { + if (name === PILL_AMT_VAR_NAME && amt !== undefined) { + return { toString: () => String(amt) }; + } + if (name === PILL_EASE_SPREAD_VAR_NAME && spread !== undefined) { + return { toString: () => String(spread) }; + } + return undefined; + }, +}); + +const paint = (width: number, height: number, amt?: number, spread?: number): 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, spread)); + 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("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 spread 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 spread 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 spread 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, `spread ${q}`).toBeGreaterThan(0); + expect(arrivalAngle(paint(WIDTH, HEIGHT, 2, q)), `spread ${q}`).toBeLessThan(3); + expect(curvatureGradient(paint(WIDTH, HEIGHT, 2, q), R), `spread ${q}`).toBeLessThan(3); + } + }); + + it("defaults to one step above the clothoid", () => { + expect(paint(WIDTH, HEIGHT, 2).vertices).toEqual(paint(WIDTH, HEIGHT, 2, 1).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, 1).vertices); + expect(ctx.vertices).not.toEqual(paint(WIDTH, HEIGHT, 1, 1).vertices); + }); + + it("honours spreads 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], `spread step ${i}`).toBeLessThan(angles[i - 1]); + } + // The lowest spread is a genuine corner, not an easing. + expect(angles[0]).toBeGreaterThan(30); + }); + + it("stays finite and inside the box at every spread", () => { + 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, `spread ${q} @ ${w}x${h}`).toBeGreaterThan(3); + for (const p of v) { + const tag = `spread ${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, `spread ${q} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6); + expect(p.x, `spread ${q} @ ${w}x${h}`).toBeLessThanOrEqual(w + 1e-6); + expect(p.y, `spread ${q} @ ${w}x${h}`).toBeGreaterThanOrEqual(-1e-6); + expect(p.y, `spread ${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), `spread ${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 spread 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 spread 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 the default spread alone", () => { + // With the spread 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, spread] 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, spread).vertices) { + const tag = `amt ${amt} spread ${spread} @ ${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, spread] of [ + [600, 60, 4, 4], + [240, 60, 4, 4], + [1200, 200, 4, 4], + [600, 60, 2, 0], + ]) { + const ctx = paint(w, h, amt, spread); + 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} spread ${spread}`).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); + }); + }); +}); diff --git a/package/src/pill-shape.test.ts b/package/src/pill-shape.test.ts new file mode 100644 index 0000000..3c238c7 --- /dev/null +++ b/package/src/pill-shape.test.ts @@ -0,0 +1,142 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { paintDef } from "./pill-shape.worklet"; +import { + CSS_NAMESPACE, + DEFAULT_PILL_AMT, + DEFAULT_PILL_EASE_SPREAD, + PILL_AMT_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + PILL_EASE_SPREAD_VAR_NAME, + PILL_STROKE_WIDTH_VAR_NAME, +} from "./variants"; + +const stylesheet = readFileSync(join(import.meta.dirname, "squircle-pill.css"), "utf-8"); + +const registeredProperties = (css: string): string[] => + [...css.matchAll(/@property\s+(--[\w-]+)/g)].map((m) => m[1]); + +const initialValueOf = (css: string, name: string): string | undefined => + new RegExp(`@property\\s+${name}\\s*\\{[^}]*initial-value:\\s*([^;]+);`).exec(css)?.[1].trim(); + +const inputProperties = (paintDef as unknown as { inputProperties: string[] }).inputProperties; +const customInputs = inputProperties.filter((p) => p.startsWith("--")); + +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); + } + }); + + it("reads exactly the properties it needs", () => { + expect(inputProperties).toEqual([ + PILL_AMT_VAR_NAME, + PILL_EASE_SPREAD_VAR_NAME, + PILL_STROKE_WIDTH_VAR_NAME, + PILL_BORDER_STYLE_VAR_NAME, + ]); + }); + + 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_SPREAD_VAR_NAME]) { + expect(registered, `${name} must be registered`).toContain(name); + } + }); + + it("registers nothing the worklet does not read", () => { + // Registrations for properties the paint function ignores are dead + // plumbing: they read as configuration but change nothing. + for (const name of registeredProperties(stylesheet)) { + expect(customInputs, `${name} is registered but never read`).toContain(name); + } + }); + + it("leaves the stroke width unregistered on purpose", () => { + // The ring sets it inline on ::after. Registering it with an initial + // value of 0 would be harmless, but registering it as inherited would + // make every nested pill draw its parent's border. + expect(registeredProperties(stylesheet)).not.toContain(PILL_STROKE_WIDTH_VAR_NAME); + }); + + it("never paints the shape as a background", () => { + // Painting it as a background covers whatever background the element + // already had; masking keeps it and shapes it instead. + expect(stylesheet).not.toContain("background-image: paint("); + expect(stylesheet).toContain("mask-image: paint(pill-shape)"); + }); + + 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_SPREAD_VAR_NAME)).toBe( + String(DEFAULT_PILL_EASE_SPREAD), + ); + }); + + 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 — which is how a framework's variable is bridged across. + 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("bridges Tailwind's border style variable into the pill's own", () => { + // Where a utility exposes a variable, read it rather than asking for a + // second source of truth. Tailwind registers --tw-border-style as + // non-inheriting, so the explicit `inherit` is load-bearing. + expect(stylesheet).toContain("--tw-border-style: inherit"); + expect(stylesheet).toContain(`${PILL_BORDER_STYLE_VAR_NAME}: var(--tw-border-style`); + }); + }); + + 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. + const paintWith = (props: Record | undefined) => { + const vertices: { x: number; y: number }[] = []; + const ctx = { + fillStyle: "", + beginPath() {}, + fill() {}, + closePath() {}, + moveTo: (x: number, y: number) => vertices.push({ x, y }), + lineTo: (x: number, y: number) => vertices.push({ x, y }), + }; + const lookup = { + get: (n: string) => (props?.[n] ? { toString: () => props[n] } : undefined), + }; + new (paintDef as unknown as new () => { + paint(c: unknown, s: { width: number; height: number }, p: unknown): void; + })().paint(ctx, { width: 240, height: 60 }, lookup); + return vertices; + }; + + expect(paintWith(undefined)).toEqual( + paintWith({ + [PILL_AMT_VAR_NAME]: String(DEFAULT_PILL_AMT), + [PILL_EASE_SPREAD_VAR_NAME]: String(DEFAULT_PILL_EASE_SPREAD), + }), + ); + }); +}); diff --git a/package/src/pill-shape.worklet.ts b/package/src/pill-shape.worklet.ts new file mode 100644 index 0000000..918abe5 --- /dev/null +++ b/package/src/pill-shape.worklet.ts @@ -0,0 +1,386 @@ +/*! + * @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_SPREAD_VAR = `${NS}-ease-spread`; +const STROKE_WIDTH_VAR = `${NS}-stroke-width`; +const BORDER_STYLE_VAR = `${NS}-border-style`; + +interface PaintSize { + width: number; + height: number; +} + +interface PaintProperties { + get(name: string): { toString(): string } | undefined; +} + +interface Point { + x: number; + y: number; +} + +/** + * 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 `fitEase`. + */ +// 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 spread 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 spread offsets the exponent that governs that fall, + * `k(t) = (1 / R) * (1 - t)^(q - 1)` with `q = spread + 2`, which makes the + * transition `q * beta * R` long. Any spread above -1 still starts at `1 / R` + * and ends at `0`, so G2 holds throughout. + * + * The default sits one step above the clothoid, which reads as a softer join + * without noticeably flattening the cap. Kept in step with + * DEFAULT_PILL_EASE_SPREAD in variants.ts by a test. + */ +const DEFAULT_SPREAD = 1; +/** The exponent a spread 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_SPREAD = -CLOTHOID_EXPONENT; + +/** Integration steps along one transition. Trapezoid error here is sub-pixel. */ +const EASE_STEPS = 512; + +/** + * 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 spread 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 { + static get inputProperties() { + // `color` is needed because a paint worklet cannot resolve the + // `currentColor` keyword itself — it has to be passed in as a property. + return [AMT_VAR, EASE_SPREAD_VAR, STROKE_WIDTH_VAR, BORDER_STYLE_VAR]; + } + + /** + * Dash pattern for the stroke, in multiples of its width, for + * the border style. `none` and `hidden` suppress the ring entirely, + * matching what 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(props: PaintProperties | undefined, width: number): number[] | null { + const style = props?.get(BORDER_STYLE_VAR)?.toString().trim(); + if (style === "none" || style === "hidden") return null; + if (style === "dashed") return [width * 3, width * 2]; + if (style === "dotted") return [width, width * 2]; + return []; + } + + /** + * Stroke width, in pixels. Zero fills the shape; anything larger draws an + * inset band of exactly that width, hugging the inside of the outline. + */ + resolveStrokeWidth(props?: PaintProperties): number { + const raw = Number.parseFloat(props?.get(STROKE_WIDTH_VAR)?.toString() ?? ""); + return Number.isFinite(raw) && raw > 0 ? raw : 0; + } + + /** The requested easing angle, in radians, before it is fitted to the box. */ + resolveEase(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 spread along the flat edge, as the curvature exponent + * it offsets; see `DEFAULT_SPREAD`. + */ + resolveExponent(props?: PaintProperties): number { + const raw = Number.parseFloat(props?.get(EASE_SPREAD_VAR)?.toString() ?? ""); + const spread = Number.isFinite(raw) ? raw : DEFAULT_SPREAD; + return Math.max(spread, MIN_SPREAD) + CLOTHOID_EXPONENT; + } + + /** + * 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. + */ + fresnel(beta: number, q: number): { cos: number[]; sin: number[] } { + const cos = [0]; + const sin = [0]; + const h = 1 / EASE_STEPS; + let c = 0; + let s = 0; + + for (let i = 1; i <= EASE_STEPS; i++) { + // Integrating in tau, where u = 1 - tau. + const u0 = (1 - (i - 1) * h) ** q; + const u1 = (1 - i * h) ** q; + c += ((Math.cos(beta * u0) + Math.cos(beta * u1)) / 2) * h; + s += ((Math.sin(beta * u0) + Math.sin(beta * u1)) / 2) * h; + cos.push(c); + sin.push(s); + } + + return { cos, sin }; + } + + /** + * 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 (`q R beta * S`) must equal `r`. + */ + capMetrics( + r: number, + beta: number, + q: number, + fresnel: { cos: number[]; sin: number[] }, + ): { radius: number; junction: number } { + const totalCos = fresnel.cos[EASE_STEPS]; + const totalSin = fresnel.sin[EASE_STEPS]; + const radius = r / (Math.cos(beta) + q * beta * totalSin); + const junction = radius * (1 - Math.sin(beta) + q * beta * totalCos); + return { radius, junction }; + } + + /** + * The softest easing that still fits, backing off the amount and the spread + * 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, + ): { beta: number; exponent: number } { + if (wantedBeta <= 0) return { beta: 0, exponent: wantedExponent }; + + // 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; + + const fits = (beta: number): boolean => { + const exponent = exponentFor(beta); + return this.capMetrics(r, beta, exponent, this.fresnel(beta, exponent)).junction <= half; + }; + + if (fits(wantedBeta)) return { beta: wantedBeta, exponent: wantedExponent }; + + let low = 0; + let high = wantedBeta; + for (let i = 0; i < 24; i++) { + const mid = (low + high) / 2; + if (fits(mid)) low = mid; + else high = mid; + } + return { beta: low, exponent: exponentFor(low) }; + } + + /** + * 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): Point[] { + const fresnel = this.fresnel(beta, q); + const { radius } = this.capMetrics(r, beta, q, fresnel); + 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 (beta <= 0) return points; + + // The lowest spread gives the transition no length at all: the arc alone + // spans the height and meets the flat edge at a corner. + if (q <= 0) return points; + + // Curvature ramps from 1 / radius down to 0 across the transition, which + // the exponent makes q * beta * radius long. Vertices land where the chord + // would otherwise drift off the curve. + const length = q * 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 = (t: number): number => beta * (1 - (1 - t) ** q); + + let anchor = 0; + for (let k = 1; k < EASE_STEPS; k++) { + const span = length * ((k - anchor) / EASE_STEPS); + const turn = turnTo(k / EASE_STEPS) - turnTo(anchor / EASE_STEPS); + if (span * turn >= 8 * MAX_SAGITTA) { + points.push(at(k)); + anchor = k; + } + } + points.push(at(EASE_STEPS)); + + return points; + } + + paint(ctx: CanvasRenderingContext2D, size: PaintSize, props?: PaintProperties): void { + const { width, height } = size; + if (width <= 0 || height <= 0) return; + + /* + * Opaque, always. The shape is consumed as a mask, where only the alpha + * channel counts, and the element's own background supplies the colour. + * Reading `color` here would mean `color: transparent` erased the element. + */ + const stroke = this.resolveStrokeWidth(props); + const dash = stroke > 0 ? this.resolveDash(props, stroke) : []; + // `border-style: none` leaves nothing to draw. + if (dash === null) return; + + if (stroke > 0) { + ctx.strokeStyle = "#000"; + // Doubled, because the half outside the outline is clipped away below. + ctx.lineWidth = stroke * 2; + if (dash.length > 0) ctx.setLineDash(dash); + } else { + ctx.fillStyle = "#000"; + } + ctx.beginPath(); + + // Work along the pill's long axis, then transpose for a vertical pill. + const vertical = height > width; + const long = vertical ? height : width; + const short = vertical ? width : height; + const r = short / 2; + const { beta, exponent } = this.fitEasing( + r, + long / 2, + this.resolveEase(props), + this.resolveExponent(props), + ); + + const outline = this.outline(long, short, this.quadrant(r, beta, exponent)); + 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(); + + if (stroke > 0) { + /* + * Clip to the outline before stroking, so the band sits wholly inside it. + * A centred stroke would spill half its width past the outline, and that + * half is cut off by the edge of the paint canvas rather than by the + * shape — which trims it on the flat edges, where the outline runs along + * the canvas boundary, but not through the caps, where the outline curves + * inward. The ring would come out flattened and uneven. + */ + ctx.clip(); + ctx.stroke(); + } else { + ctx.fill(); + } + } + + /** 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; + +if (typeof registerPaint !== "undefined") { + registerPaint("pill-shape", paintDef); +} diff --git a/package/src/squircle-pill.css b/package/src/squircle-pill.css new file mode 100644 index 0000000..f86be7f --- /dev/null +++ b/package/src/squircle-pill.css @@ -0,0 +1,95 @@ +/*! + * @klinking/squircle — MIT License — Copyright (c) 2026 Chris Klink + * https://squircle.klink.ing/ · https://github.com/klink-ing/squircle + */ + +/* ── Register paint worklet input properties ────────────────── + * The worklet reads these by name through `inputProperties`. + * Registering them makes the values typed and animatable and gives + * them initial values matching the worklet's own fallbacks. + * + * Registration is not conditional on paint support: a property is + * worth registering either way, and `@supports` says nothing about + * whether the worklet actually loaded. + * ──────────────────────────────────────────────────────────── */ + +@property --squircle-pill-amt { + syntax: ""; + initial-value: 2; + inherits: false; +} + +/* How far the easing is spread along the flat edge. Offsets the curvature + * exponent, so 0 is a plain clothoid; see the pill-shape worklet. */ +@property --squircle-pill-ease-spread { + syntax: ""; + initial-value: 1; + inherits: false; +} + +/* ── Base pill shape utility ────────────────────────────────── + * Hands the element to the pill-shape paint worklet, which derives + * the whole silhouette from the element's own size. Shape it with + * --squircle-pill-amt and --squircle-pill-ease-spread. + * + * Attribute-based so it works without Tailwind; the plugin's + * `squircle-pill` utilities carry the same rules on the class. + * ──────────────────────────────────────────────────────────── */ + +@supports (mask-image: paint(pill-shape)) { + [data-squircle-pill] { + -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; + /* The worklet only runs where there is an area to paint. */ + min-width: 1px; + min-height: 1px; + position: relative; + } + + /* A CSS border cannot follow this shape, so the worklet draws one in stroke + * mode, which lays an inset band along the inside of the outline. */ + [data-squircle-pill]::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: var(--squircle-pill-border-color, transparent); + --squircle-pill-stroke-width: var(--squircle-pill-border-width, 0px); + /* Read Tailwind's own variable where it exposes one: border-dashed and + * friends set --tw-border-style. It is registered as non-inheriting, hence + * the explicit inherit. */ + --tw-border-style: inherit; + --squircle-pill-border-style: var(--tw-border-style, solid); + -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; + } +} + +/* ── Fallback for browsers without Paint Worklet support ────── + * A plain fully-rounded rectangle, and nothing else. A superellipse + * corner reads as more wrong than a stadium here: on a pill the cap + * is the whole shape, so reshaping it changes the silhouette rather + * than just softening a corner. + * + * Gated only on the worklet being absent, so browsers with neither + * feature still get a pill. The radius matches FULL_RADIUS in + * variants.ts, and so Tailwind's own `rounded-full`. Native border, + * outline and box-shadow all work on this branch, because + * border-radius is a shape the platform understands. + * ──────────────────────────────────────────────────────────── */ + +@supports not (mask-image: paint(pill-shape)) { + [data-squircle-pill] { + border-radius: calc(infinity * 1px); + } +} diff --git a/package/src/tailwind-pill-border.test.ts b/package/src/tailwind-pill-border.test.ts new file mode 100644 index 0000000..679fff5 --- /dev/null +++ b/package/src/tailwind-pill-border.test.ts @@ -0,0 +1,71 @@ +/*! + * @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 { PILL_BORDER_COLOR_VAR_NAME, PILL_BORDER_WIDTH_VAR_NAME } from "./variants"; + +const { compilePlugin } = createCompiler(import.meta.dirname); +const compileBorder = (candidates: string[], block = "") => + compilePlugin(candidates, block, "./tailwind-pill-border.ts"); + +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`); + }); + }); + + 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 on pills only", async () => { + // Under the mask a real border is a rectangle clipped to the pill. + const css = await compileBorder(["border-red-500"]); + const scoped = css.slice(css.indexOf("&:is(.squircle-pill)")); + expect(scoped).toContain("border-color: transparent"); + }); + + it("honours a custom prefix", async () => { + const css = await compileBorder(["border-2"], 'prefix: "pillbox";'); + expect(css).toContain("&:is(.pillbox)"); + }); + }); +}); diff --git a/package/src/tailwind-pill-border.ts b/package/src/tailwind-pill-border.ts new file mode 100644 index 0000000..237da35 --- /dev/null +++ b/package/src/tailwind-pill-border.ts @@ -0,0 +1,76 @@ +/*! + * @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 } from "./variants"; + +export interface SquirclePillBorderPluginOptions { + /** Class name of the pill utility these borders apply to (default: "squircle-pill") */ + prefix?: string; +} + +/** + * Teaches Tailwind's own `border-*` utilities to drive a pill's drawn border. + * + * A pill is shaped by a mask, and a mask erases everything outside the shape, + * so a real CSS border survives only as rectangle fragments. The shape's border + * has to be drawn by the worklet instead, from `--pill-border-width` and + * `--pill-border-color` — 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 `--pill-border-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 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. + */ +const squirclePillBorder: ReturnType> = + plugin.withOptions( + (options = {}) => + ({ matchUtilities, theme }) => { + const prefix = options.prefix ?? "squircle-pill"; + + /* + * 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. + */ + const onPill = (declarations: Record) => ({ + [`&:is(.${prefix})`]: declarations, + }); + + matchUtilities( + { + border: (value: string) => onPill({ [PILL_BORDER_WIDTH_VAR_NAME]: value }), + }, + { values: theme("borderWidth") ?? {} }, + ); + + matchUtilities( + { + border: (value: string) => + onPill({ + [PILL_BORDER_COLOR_VAR_NAME]: value, + // The real border must not paint: under the mask it is a + // rectangle clipped to the pill. Its width still contributes to + // layout, which correctly reserves room for the drawn ring. + "border-color": "transparent", + }), + }, + { values: theme("colors") ?? {}, type: "color" }, + ); + }, + ); + +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..4e345e6 --- /dev/null +++ b/package/src/tailwind-pill.test.ts @@ -0,0 +1,109 @@ +/*! + * @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_WIDTH_VAR_NAME, + PILL_EASE_SPREAD_VAR_NAME, + PILL_STROKE_WIDTH_VAR_NAME, +} 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"); + +describe("tailwind-pill.ts utilities", () => { + it("masks the element to the pill where the worklet is available", async () => { + const css = await compilePill(["squircle-pill"]); + expect(css).toContain("@supports (mask-image: paint(pill-shape))"); + expect(css).toContain("mask-image: paint(pill-shape)"); + expect(css).toContain("-webkit-mask-image: paint(pill-shape)"); + }); + + it("never paints the shape as a background", async () => { + // A painted background covers whatever background the element already had. + // Masking keeps the element's own background — colour, gradient, image — + // and shapes that instead. + const css = await compilePill(["squircle-pill"]); + expect(css).not.toContain("background-image: paint("); + }); + + it("draws a border the shape can actually follow", async () => { + // A CSS border would be a rectangle clipped to the pill, so the worklet + // strokes one on ::after instead. + const css = await compilePill(["squircle-pill"]); + expect(css).toContain("&::after"); + expect(css).toContain(`${PILL_STROKE_WIDTH_VAR_NAME}: var(${PILL_BORDER_WIDTH_VAR_NAME}, 0px)`); + expect(css).toContain(`background: var(${PILL_BORDER_COLOR_VAR_NAME}, transparent)`); + }); + + describe("fallback without the paint worklet", () => { + it("is a plain fully-rounded rectangle", async () => { + const css = await compilePill(["squircle-pill"]); + expect(css).toContain("@supports not (mask-image: paint(pill-shape))"); + // The same radius the `-full` utilities use, matching `rounded-full`. + expect(css).toContain(`border-radius: ${FULL_RADIUS}`); + }); + + 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("does not require corner-shape support to apply", async () => { + // Gating on corner-shape left browsers with neither feature square. + const css = await compilePill(["squircle-pill"]); + expect(css).not.toContain("@supports (corner-shape"); + }); + + 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 worklet reads", async () => { + // Without this the class alone would leave them unregistered, so they + // could not be typed or animated. + const css = await compilePillAll(["squircle-pill"]); + expect(css).toContain(`@property ${PILL_AMT_VAR_NAME}`); + expect(css).toContain(`@property ${PILL_EASE_SPREAD_VAR_NAME}`); + expect(css).toContain("initial-value: 2"); + expect(css).toContain("initial-value: 1"); + }); + + 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"); + }); + }); + + it("honours a custom prefix", async () => { + const css = await compilePill(["pillbox"], 'prefix: "pillbox";'); + expect(css).toContain(".pillbox"); + expect(css).toContain(`border-radius: ${FULL_RADIUS}`); + }); +}); diff --git a/package/src/tailwind-pill.ts b/package/src/tailwind-pill.ts new file mode 100644 index 0000000..43e01de --- /dev/null +++ b/package/src/tailwind-pill.ts @@ -0,0 +1,139 @@ +/*! + * @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 { + DEFAULT_PILL_AMT, + DEFAULT_PILL_EASE_SPREAD, + FULL_RADIUS, + PILL_AMT_VAR_NAME, + PILL_BORDER_COLOR_VAR_NAME, + PILL_BORDER_STYLE_FALLBACK, + PILL_BORDER_STYLE_VAR_NAME, + PILL_BORDER_WIDTH_VAR_NAME, + PILL_EASE_SPREAD_VAR_NAME, + PILL_STROKE_WIDTH_VAR_NAME, + variantEntries, +} from "./variants"; + +export interface SquirclePillPluginOptions { + /** Class name prefix for utilities (default: "squircle-pill") */ + prefix?: string; +} + +const squirclePill: ReturnType> = + plugin.withOptions((options = {}) => ({ addBase, addUtilities }) => { + const prefix = options.prefix ?? "squircle-pill"; + + /* + * Register the properties the worklet reads. The utility has to carry + * these itself rather than lean on squircle-pill.css: that stylesheet + * hangs its rules off `[data-squircle-pill]`, and no stylesheet can set + * an attribute, so a class could never pull them in. Registering also + * makes the values typed and animatable, with initial values matching + * the worklet's own fallbacks. + */ + addBase({ + [`@property ${PILL_AMT_VAR_NAME}`]: { + syntax: '""', + "initial-value": String(DEFAULT_PILL_AMT), + inherits: "false", + }, + [`@property ${PILL_EASE_SPREAD_VAR_NAME}`]: { + syntax: '""', + "initial-value": String(DEFAULT_PILL_EASE_SPREAD), + inherits: "false", + }, + }); + + /* + * The shape is applied as a mask, not painted as a background, so the + * element keeps whatever background it already has — a colour, a gradient, + * an image — and that background is what gets pill-shaped. + * + * A mask erases everything outside the shape, which no `border`, `outline` + * or `box-shadow` can survive: a border would be a rectangle clipped to the + * pill, and the other two are painted outside the box and vanish entirely. + * `filter` is applied before the mask, so even `drop-shadow` set here would + * shadow the unmasked rectangle and then be clipped away. + * + * A border is therefore drawn, on `::after`, by the same worklet in stroke + * mode, which lays an inset band along the inside of the outline. For a + * shadow, put `filter: drop-shadow(...)` on a wrapper, where it applies to + * the already-masked result. + */ + const mask = { + "-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", + }; + + const pillBase = { + "@supports (mask-image: paint(pill-shape))": { + ...mask, + // The worklet only runs where there is an area to paint. + "min-width": "1px", + "min-height": "1px", + position: "relative", + + "&::after": { + content: '""', + position: "absolute", + inset: "0", + "pointer-events": "none", + background: `var(${PILL_BORDER_COLOR_VAR_NAME}, transparent)`, + [PILL_STROKE_WIDTH_VAR_NAME]: `var(${PILL_BORDER_WIDTH_VAR_NAME}, 0px)`, + /* + * Where a Tailwind border utility exposes a variable, read it rather + * than asking for a second source of truth: `border-dashed` and + * friends set `--tw-border-style`, so the drawn ring picks up the + * style straight from it. Tailwind registers that property as + * non-inheriting, hence the explicit `inherit` to pull the element's + * value onto the pseudo. Width and colour have no such variable — + * those utilities set `border-width`/`border-color` directly — so + * they still come from the pill's own properties. + */ + "--tw-border-style": "inherit", + [PILL_BORDER_STYLE_VAR_NAME]: `var(--tw-border-style, ${PILL_BORDER_STYLE_FALLBACK})`, + ...mask, + }, + }, + /* + * Without the paint worklet, fall back to a plain fully-rounded rectangle + * and nothing else. A superellipse corner reads as more wrong than a + * stadium here: the cap is the whole shape, so reshaping it changes the + * silhouette rather than just softening a corner. + * + * Gated only on the worklet being absent, so browsers with neither + * feature still get a pill. The radius is the same `FULL_RADIUS` the + * `-full` utilities use, matching Tailwind's `rounded-full`. Native + * border, outline and box-shadow all work on this branch, because + * border-radius is a shape the platform understands. + */ + "@supports not (mask-image: paint(pill-shape))": { + "border-radius": FULL_RADIUS, + }, + }; + + addUtilities({ + [`.${prefix}`]: pillBase, + }); + + // Side-specific variants + for (const [suffix] of variantEntries()) { + if (suffix) { + // Only add side-specific variants, not the base + addUtilities({ + [`.${prefix}-${suffix}`]: { ...pillBase }, + }); + } + } + }); + +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..79c0036 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; 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..bb470b5 100644 --- a/package/src/variants.ts +++ b/package/src/variants.ts @@ -4,8 +4,62 @@ */ 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 PILL_EASE_SPREAD_VAR_NAME: string = pillVar("ease-spread"); +export const DEFAULT_PILL_AMT = 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; +/** Internal: what the worklet keys stroke mode off, set on the ring only. */ +export const PILL_STROKE_WIDTH_VAR_NAME: string = pillVar("stroke-width"); +export const DEFAULT_PILL_EASE_SPREAD = 1 as const; +/** + * 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..b6fe7e5 100644 --- a/package/vite.config.ts +++ b/package/vite.config.ts @@ -1,16 +1,78 @@ +import { readFileSync } from "node:fs"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite-plus"; +/** + * Prefix for every custom property this package owns, inlined at build time + * from `squircle.cssNamespace` in package.json. + * + * 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. + * + * 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. + * + * 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 CSS_NAMESPACE: string = + process.env.SQUIRCLE_CSS_NAMESPACE || + JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).squircle + ?.cssNamespace || + "squircle"; + +/** Tasks whose output depends on the namespace. */ +const NAMESPACE_ENV = ["SQUIRCLE_CSS_NAMESPACE"]; + +/** + * A registered paint worklet cannot be replaced or unregistered, so the worklet + * cannot be hot-swapped in place. Editing it therefore triggers a full page + * reload, which re-runs CSS.paintWorklet.addModule() against the fresh source. + */ +/** Replaces %SQUIRCLE_NS% in the dev page, so it never hardcodes the namespace. */ +const pillDevNamespace = () => ({ + name: "pill-dev-namespace", + transformIndexHtml(html: string) { + return html.replaceAll("%SQUIRCLE_NS%", CSS_NAMESPACE); + }, +}); + +const pillWorkletHmr = () => ({ + name: "pill-worklet-hmr", + handleHotUpdate({ file, server }: { file: string; server: { ws: { send(p: unknown): void } } }) { + if (file.replace(/\\/g, "/").endsWith("src/pill-shape.worklet.ts")) { + server.ws.send({ type: "full-reload", path: "*" }); + return []; + } + }, +}); + export default defineConfig({ - plugins: [tailwindcss()], + plugins: [tailwindcss(), pillWorkletHmr(), pillDevNamespace()], + // Covers the dev server and 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", + "pill-shape.worklet": "./src/pill-shape.worklet.ts", }, format: "esm", dts: true, @@ -18,16 +80,19 @@ export default defineConfig({ run: { tasks: { "test:tailwind": { + 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": { + env: NAMESPACE_ENV, command: "vp test run squircle-css", dependsOn: ["build"], }, "test:radius": { + env: NAMESPACE_ENV, command: "vp test run squircle-radius", dependsOn: ["build"], }, @@ -37,16 +102,39 @@ export default defineConfig({ "test:stylex": { command: "vp test run stylex", }, + "test:pill": { + env: NAMESPACE_ENV, + command: "vp test run pill-shape", + }, 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: { + env: NAMESPACE_ENV, command: - "tsx scripts/generate-stylex.ts && vp pack && tsx scripts/generate-squircle-css.ts", + "tsx scripts/generate-stylex.ts && vp pack && tsx scripts/generate-squircle-css.ts && tsx scripts/copy-pill-assets.ts", + }, + "build:pill": { + env: NAMESPACE_ENV, + command: "vp pack", + }, + "pill-dev": { + env: NAMESPACE_ENV, + // The dev page loads the worklet straight from src/, which Vite + // compiles on the fly, so no build step is needed (and a stale dist/ + // can no longer mask source edits). + command: "vp dev", }, }, }, diff --git a/pill-test.html b/pill-test.html new file mode 100644 index 0000000..0e4d508 --- /dev/null +++ b/pill-test.html @@ -0,0 +1,128 @@ + + + + + + Pill Shape Test + + + + +
+

Pill Shape Test

+

Testing squircle-pill utilities with Houdini paint worklet

+ +
+

Perfect Pills

+
+ +
Badge
+
+ ✓ +
+
+
+ +
+

Different Aspect Ratios

+
+
Wide
+
Tall
+
+ Square +
+
+
+ +
+

Use Cases

+
+
+

Buttons

+
+ + + +
+
+ +
+

Tags & Badges

+
+ Tag + Status + Warning +
+
+ +
+

Icon Buttons

+
+ + + +
+
+
+
+ +
+

+ Note: Open DevTools to check if paint(pill-shape) is being used or if + fallback to border-radius is active. +

+
+
+ + diff --git a/website/src/components/PillTailwindDemo.tsx b/website/src/components/PillTailwindDemo.tsx new file mode 100644 index 0000000..a8c87d2 --- /dev/null +++ b/website/src/components/PillTailwindDemo.tsx @@ -0,0 +1,91 @@ +function PillBox({ label, className }: { label: string; className: string }) { + return ( +
+
+ {label} +
+ ); +} + +export default function PillTailwindDemo() { + return ( +
+
+

+ Pill shapes (G2-continuous transitions) +

+

+ Smooth, mathematically perfect pill shapes with semicircular ends. +

+
+ + + + +
+
+ +
+

Pill variants (side-specific)

+
+ + + + +
+
+ +
+

+ Pill amount control (squircle-pill-amt-*) +

+

+ Adjust superellipse smoothness at transition points. +

+
+ + + + +
+
+ +
+

Common use cases

+
+
+

Pill button

+ +
+
+

Pill badge

+ + New + +
+
+

Pill with icon placeholder

+
+
+ Label +
+
+
+
+
+ ); +} diff --git a/website/src/pages/demos/panda.astro b/website/src/pages/demos/panda.astro index 105ece3..af5cabe 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 and pills with visual examples. +

+ +

Squircle Corners

+

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

Pill Shapes

+

+ Pill-shaped rectangles with G2-continuous transitions using Houdini CSS Paint API. +

+ +

Edit on StackBlitz

StyleX

+ Explore StyleX utilities for squircles and pills 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,12 @@ import StyleXDemo from "../../components/StyleXDemo"; +

Pill Shapes

+

+ Pill-shaped rectangles with G2-continuous transitions using Houdini CSS Paint API. +

+ +

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 with G2-continuous transitions using Houdini CSS Paint API. +

+ + +

Edit on StackBlitz