diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17fd523..409fac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,18 @@ jobs: - run: npm ci - # Typecheck, build, tests, and a dry-run pack of the published files. + # Type checking, build, tests, and a dry-run pack of the published files. - run: npm run check + + browser: + name: Browser tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:browser diff --git a/.gitignore b/.gitignore index 0ce2600..1b66e14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ node_modules/ dist/ examples/react/main.js +examples/svelte/bundle.js .artifacts/ .npm-cache/ *.tgz *.tsbuildinfo .DS_Store +test-results/ +playwright-report/ diff --git a/README.md b/README.md index e2f56d8..f484ff1 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,18 @@ # squircles -Small Figma-style squircles for TypeScript and React. +[![npm version](https://img.shields.io/npm/v/squircles?style=flat-square&color=242424)](https://www.npmjs.com/package/squircles) +[![CI](https://img.shields.io/github/actions/workflow/status/eiserv/squircles/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/eiserv/squircles/actions/workflows/ci.yml) +[![npm downloads](https://img.shields.io/npm/dm/squircles?style=flat-square&color=666666)](https://www.npmjs.com/package/squircles) +[![TypeScript types included](https://img.shields.io/badge/TypeScript-types_included-3178c6?style=flat-square&logo=typescript&logoColor=white)](https://github.com/eiserv/squircles/blob/main/src/index.ts) +[![MIT license](https://img.shields.io/github/license/eiserv/squircles?style=flat-square&color=666666)](LICENSE) -Adopting it changes an element's corner shape and nothing else. Background, -border, shadow, hover, dark mode, and everything else stay in your CSS, where -you already know how to write them. +Figma-style corner smoothing for the web. Backgrounds, borders, shadows, and hover +states stay in your CSS. No extra wrapper, optional React and Svelte integrations, +and no runtime dependencies in the geometry or DOM packages. -- zero dependencies in the geometry package; -- React is an optional peer dependency; -- works on any element — buttons, links, cards, images, inputs; -- geometry and paint are read from ordinary CSS, not from props; -- no data-URL masks, canvas, paint worklets, or CSS-in-JS; -- responsive via `ResizeObserver`; -- plain `border-radius` during SSR and before hydration; -- ESM, strict TypeScript, and tree-shakeable entry points. - -## What it looks like - -`examples/playground.html` draws the same button twice — once through -`createSquirclePath()`, once with plain `border-radius` — and updates both while -you move radius, smoothing, size, and stroke width: - -```sh -npm run playground -``` - -![The playground with a 200 × 72 button at radius 24: the squircle and the -border-radius button side by side, their outlines overlaid, and the top left -corner magnified](docs/media/playground.png) - -At button-sized radii the two shapes are close, which is why the overlay and the -magnified corner are part of the page: they are the honest way to see the -difference. Note the corner budget in the readout — at radius 24 on a 72px-tall -button, Figma's constraint already reduces the effective smoothing. - -The difference shrinks with the radius. At a pill radius with `preserveSmoothing`, -the smoothed ends are almost identical to the semicircular `border-radius` -ends. But theres a sweet-spot- where differences are visible really well: - -![picture lol](docs/media/smoothing-extremes.png) - -## Why this exists - -Figma corner smoothing is not the same shape as `border-radius`. Existing web -solutions generally create an SVG path or clip path. A clip path works well for -fills, but a button border is easy to get wrong: subtracting a pixel from the -radius does not create the exact inner offset of a smoothed curve, and combining -a clip path with a separately rasterized mask can produce uneven-looking -one-pixel edges. - -This package draws the border as a native SVG stroke on the smoothed path -itself, inset by half its width. The stroke is therefore exactly as thick at a -smoothed corner as it is along a straight edge, and nothing is clipped away. +

+ The neutral playground with controls, shape comparison, and an enlarged corner +

## Install @@ -59,198 +20,101 @@ smoothed corner as it is along a straight edge, and nothing is clipped away. npm install squircles ``` -## React - -Give the element a class and let CSS do the rest: - -```tsx -import { Squircle } from "squircles/react"; - -export function CallToAction() { - return ( - - Learn more - - ); -} -``` +## One stylesheet, several ways to use it ```css .button { - background: linear-gradient(#243a8f, #192d73); - border: 1px solid #e5e7eb; + background: #242424; + color: white; + border: 1px solid #666; border-radius: 12px; --squircle-smoothing: 1; - min-height: 48px; - padding: 12px 16px; - color: white; - transition: background-color 150ms ease; -} - -.button:hover { - background: #14245e; -} - -.button:focus-visible { - outline: 2px solid #192d73; - outline-offset: 4px; + padding: 12px 20px; } ``` -The component reads `border-radius` and `--squircle-smoothing` from your CSS, so -it needs no props at all beyond `as`. Pass `radius`, `smoothing`, or -`preserveSmoothing` only when you want to override the stylesheet. - -Cards and images work the same way: +**React** (18 or later): ```tsx - - -

Some text

-
-``` +import { Squircle } from "squircles/react"; -```css -.card { - border-radius: 20px; - --squircle-smoothing: 1; - background: white; - border: 1px solid #dce1ed; - box-shadow: 0 12px 32px rgba(25, 45, 115, 0.18); -} + + Learn more + ``` -The shadow follows the smoothed contour rather than the `border-radius` one. +**Svelte** as an [action](https://svelte.dev/docs/svelte/use): -Use `as="a"` for a native link, or pass a ref-forwarding component such as -Next.js `Link`. In the Next.js App Router the file needs `"use client"`. +```svelte + -Do not enable `clipContent` on controls if their focus outline must remain -outside the shape. It is intended for cards whose content bleeds to the edge. + +``` -## Without React +**JavaScript, Vue, or other frameworks** through the DOM entry point: ```ts import { applySquircle } from "squircles/dom"; -const dispose = applySquircle(document.querySelector(".card")); +const element = document.querySelector(".button"); +const cleanup = element ? applySquircle(element) : () => {}; +// Call when the element is removed. +cleanup(); ``` -With no options it reads everything from CSS, exactly like the React component, -and returns a cleanup function. It works on any element, in any framework, and -in plain HTML. - -## Existing components - -`SquircleSurface` adds only the visual SVG layer, with fill and stroke on a -single path. Use it inside a component that already owns positioning and -semantics, or when you need a semi-transparent border: - -```tsx -import { SquircleSurface } from "squircles/react"; - -function ExistingButton() { - return ( - - ); -} -``` - -The parent must be positioned, its rectangular background/border must be -transparent, and its content must paint above the SVG. +The integrations read `border-radius` and `--squircle-smoothing` from CSS. +Class, style, and size changes, along with hover and focus, update the shape. +Server-rendered elements initially use their ordinary CSS radius. ## Geometry only -The root entry point does not import React or touch the DOM: - ```ts import { createSquirclePath, createSquircleClipPath } from "squircles"; -const path = createSquirclePath({ - width: 120, - height: 48, - radius: 8, - smoothing: 1, - inset: 0.5, -}); - -const clipPath = createSquircleClipPath({ width: 120, height: 48, radius: 8 }); +const options = { width: 200, height: 96, radius: 24, smoothing: 1 }; +const path = createSquirclePath(options); +const clipPath = createSquircleClipPath(options); ``` -`radius` accepts a number or individual corners: - -```ts -radius={{ - topLeft: 24, - topRight: 12, - bottomRight: 24, - bottomLeft: 12, -}} -``` +`radius` supports individual corners; `smoothing` ranges from `0` to `1`. +When space is limited, smoothing is constrained as in Figma. +`preserveSmoothing: true` keeps it by compressing the control points. +`inset` moves the path inward, for example by half an SVG stroke width. -## What is not supported +

+ Solid squircle and dashed border-radius outlines with an enlarged corner +

-These are deliberate limits, not bugs: +## Limits and more examples -- **Focus rings.** `outline` always follows `border-radius`. With - `outline-offset` the mismatch is hard to see; a ring sitting directly on the - edge will not match. -- **Different borders per side.** Only `border-top-width` and - `border-top-color` are read. -- **Dashed and dotted borders.** Only `border-style: solid` is drawn. -- **`inset` shadows.** They are left on the element and keep following - `border-radius`. -- **Semi-transparent borders.** The surface's clip edge shows through the - stroke. Use `SquircleSurface` for that case. -- **`backdrop-filter`.** The element itself is not clipped, so a backdrop - filter stays rectangular. -- **No JavaScript, or before hydration.** The element renders with its own - `border-radius`. There is no layout shift, but there is no squircle either. +- Borders must be solid and uniform; different borders per side are not supported. +- Focus outlines and inset shadows still follow `border-radius`. +- Images and inputs receive a clip path that also clips their border and shadow. + Use a wrapper when needed. +- For semi-transparent borders, use `SquircleSurface` from `squircles/react`. +- Without `ResizeObserver`, measurement runs on startup and window resize. + Other layout changes require a polyfill. -## Migrating from 0.1 - -`Squircle` no longer takes paint props. Move them into your stylesheet: - -| 0.1 prop | Now | -|---|---| -| `fill="#192d73"` | `background: #192d73;` | -| `stroke="#e5e7eb"` | `border-color: #e5e7eb;` | -| `strokeWidth={1}` | `border-width: 1px; border-style: solid;` | -| `radius={12}` | `border-radius: 12px;` (or keep the prop) | -| `smoothing={1}` | `--squircle-smoothing: 1;` (or keep the prop) | -| `surfaceClassName` | style `[data-squircle-fill]` / `[data-squircle-stroke]` | -| `surfaceStyle` | style `[data-squircle-fill]` / `[data-squircle-stroke]` | - -`SquircleSurface` is unchanged and still accepts all of them. +[Integration, options, and migration](docs/integration.md) · +[Architecture and limits](docs/architecture.md) ## Development ```sh -npm install +npm ci npm run check +npx playwright install chromium +npm run test:browser +npm run playground ``` -`npm run playground` builds the package and serves two example pages: -[examples/playground.html](examples/playground.html) on -, which shapes its own interface with the library, and -a React version on . `npm run visual:fixture` -writes a static rasterization fixture to `.artifacts/`. - -See [docs/architecture.md](docs/architecture.md) for the rendering rationale -and constraints. - -### Releasing +Playground: [localhost:5173](http://localhost:5173/), React: +[localhost:5173/react](http://localhost:5173/react/), Svelte: +[localhost:5173/svelte](http://localhost:5173/svelte/). +`npm run screenshots` regenerates the README images with transparent shadows. +`npm run visual:fixture` creates more geometry examples in `.artifacts/`. Commits on `main` follow [Conventional Commits](https://www.conventionalcommits.org/). -release-please keeps a release PR open that bumps the version and updates -[CHANGELOG.md](CHANGELOG.md); merging it tags the release, creates the GitHub -Release, and publishes to npm. +release-please opens a release PR; merging it publishes the npm package. diff --git a/browser-tests/playground.spec.mjs b/browser-tests/playground.spec.mjs new file mode 100644 index 0000000..9a308fd --- /dev/null +++ b/browser-tests/playground.spec.mjs @@ -0,0 +1,208 @@ +import { test, expect } from "@playwright/test"; + +test("playground fits small screens and updates the outline", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/"); + const path = page.locator("#squircle path"); + await expect(path).toHaveAttribute("d", /^M /); + const before = await path.getAttribute("d"); + await page.locator("#radius").fill("0"); + await expect(path).not.toHaveAttribute("d", before); + await expect(page.locator("#note")).toHaveText("Without a radius, both shapes are identical."); + await page.locator("#width").fill("420"); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(errors).toEqual([]); +}); + +test("DOM measurement handles vertical and sideways writing modes", async ({ page }) => { + await page.goto("/"); + for (const writingMode of ["horizontal-tb", "vertical-rl", "sideways-rl"]) { + await page.evaluate(async (mode) => { + document.getElementById("measurement")?.remove(); + const { applySquircle } = await import("/dist/dom/index.js"); + const element = document.createElement("div"); + element.id = "measurement"; + element.style.cssText = `width:160px;height:80px;writing-mode:${mode};border:1px solid black;border-radius:16px;background:white;box-sizing:border-box`; + document.body.append(element); + applySquircle(element); + }, writingMode); + await expect(page.locator("#measurement [data-squircle-stroke]")).toHaveAttribute("viewBox", "0 0 160 80"); + } +}); + +test("inline changes survive redraw and cleanup", async ({ page }) => { + await page.goto("/"); + await page.evaluate(async () => { + const { applySquircle } = await import("/dist/dom/index.js"); + const element = document.createElement("button"); + element.id = "dynamic"; + element.style.cssText = "width:180px;height:64px;border-radius:16px;background:rgb(10, 10, 10);padding:8px"; + document.body.append(element); + window.cleanup = applySquircle(element); + }); + const element = page.locator("#dynamic"); + await expect(element.locator("[data-squircle-fill]")).toHaveCSS("background-color", "rgb(10, 10, 10)"); + await element.evaluate((node) => { + node.style.backgroundColor = "rgb(70, 70, 70)"; + node.style.padding = "12px"; + node.style.setProperty("--squircle-smoothing", "0.5"); + }); + await expect(element.locator("[data-squircle-fill]")).toHaveCSS("background-color", "rgb(70, 70, 70)"); + await page.evaluate(() => { window.cleanup(); window.cleanup(); }); + await expect(element.locator("[data-squircle-fill]")).toHaveCount(0); + await expect(element).toHaveCSS("background-color", "rgb(70, 70, 70)"); + await expect(element).toHaveCSS("padding-top", "12px"); + await expect(element).toHaveCSS("--squircle-smoothing", "0.5"); +}); + +test("startup, window resize, and cleanup work without ResizeObserver", async ({ page }) => { + await page.addInitScript(() => { window.ResizeObserver = undefined; }); + await page.goto("/"); + await page.evaluate(async () => { + const { applySquircle } = await import("/dist/dom/index.js"); + const element = document.createElement("div"); + element.id = "fallback"; + element.style.cssText = "width:160px;height:80px;border:1px solid black;border-radius:16px;box-sizing:border-box"; + document.body.append(element); + window.cleanup = applySquircle(element); + }); + await expect(page.locator("#fallback svg")).toHaveAttribute("viewBox", "0 0 160 80"); + await page.evaluate(() => { + document.getElementById("fallback").style.width = "200px"; + window.dispatchEvent(new Event("resize")); + }); + await expect(page.locator("#fallback svg")).toHaveAttribute("viewBox", "0 0 200 80"); + await page.evaluate(() => window.cleanup()); + await expect(page.locator("#fallback svg")).toHaveCount(0); +}); + +test("React follows a change of the native host element", async ({ page }) => { + await page.goto("/react/"); + const element = page.locator("[data-switch]"); + await expect(element.locator("[data-squircle-fill]")).toHaveCount(1); + await page.getByRole("button", { name: "Switch element" }).click(); + await expect(element).toHaveJSProperty("tagName", "A"); + await expect(element.locator("[data-squircle-fill]")).toHaveCount(1); + await page.getByRole("button", { name: "Switch element" }).click(); + await expect(element).toHaveJSProperty("tagName", "BUTTON"); + await expect(element.locator("[data-squircle-fill]")).toHaveCount(1); +}); + +test("Svelte action updates options and safely handles repeated cleanup", async ({ page }) => { + await page.goto("/"); + await page.evaluate(async () => { + const { squircle } = await import("/dist/svelte/index.js"); + const element = document.createElement("button"); + element.id = "action"; + element.style.cssText = "width:160px;height:80px;border:1px solid black;border-radius:16px;box-sizing:border-box"; + document.body.append(element); + window.action = squircle(element); + }); + const stroke = page.locator("#action path"); + await expect(stroke).toHaveAttribute("d", /^M /); + const before = await stroke.getAttribute("d"); + await page.evaluate(() => window.action.update({ radius: 0 })); + await expect(stroke).not.toHaveAttribute("d", before); + await page.evaluate(() => window.action.update()); + await expect(stroke).toHaveAttribute("d", before); + await page.evaluate(() => { window.action.destroy(); window.action.destroy(); window.action.update({ radius: 24 }); }); + await expect(page.locator("#action [data-squircle-fill]")).toHaveCount(0); + await expect(page.locator("#action")).toHaveCSS("border-top-color", "rgb(0, 0, 0)"); +}); + +test("idle rendering produces no further DOM mutations", async ({ page }) => { + await page.goto("/react/"); + await expect(page.locator("[data-switch] [data-squircle-fill]")).toHaveCount(1); + const count = await page.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + let count = 0; + const observer = new MutationObserver((records) => { count += records.length; }); + observer.observe(document.querySelector("[data-switch]"), { attributes: true, childList: true, subtree: true }); + for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); + observer.disconnect(); + return count; + }); + expect(count).toBe(0); +}); + +test("Svelte renders actions and attachments with reactive changes", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/svelte/"); + const stroke = page.locator("[data-action] path"); + await expect(stroke).toHaveAttribute("d", /^M /); + await expect(page.locator("[data-attachment] [data-squircle-fill]")).toHaveCount(1); + const before = await stroke.getAttribute("d"); + await page.getByRole("slider", { name: "Smoothing" }).fill("0"); + await expect(stroke).not.toHaveAttribute("d", before); + await page.getByRole("button", { name: "Remove example" }).click(); + await expect(page.locator("[data-action]")).toHaveCount(0); + await page.getByRole("button", { name: "Show example" }).click(); + await expect(stroke).toHaveAttribute("d", /^M /); + expect(errors).toEqual([]); +}); + +test("removed inline styles and priorities remain correct after cleanup", async ({ page }) => { + await page.goto("/"); + await page.evaluate(async () => { + const { applySquircle } = await import("/dist/dom/index.js"); + const element = document.createElement("div"); + element.id = "inline"; + element.style.cssText = "width:160px;height:80px;background:rgb(20,20,20);padding:4px;border-radius:16px"; + document.body.append(element); + window.cleanup = applySquircle(element); + }); + await expect(page.locator("#inline [data-squircle-fill]")).toHaveCount(1); + await page.locator("#inline").evaluate((node) => { + node.style.removeProperty("padding"); + node.style.setProperty("background-color", "rgb(80, 80, 80)", "important"); + }); + await expect(page.locator("#inline [data-squircle-fill]")).toHaveCSS("background-color", "rgb(80, 80, 80)"); + await page.evaluate(() => window.cleanup()); + const styles = await page.locator("#inline").evaluate((node) => ({ + padding: node.style.padding, + priority: node.style.getPropertyPriority("background-color"), + color: node.style.backgroundColor, + })); + expect(styles).toEqual({ padding: "", priority: "important", color: "rgb(80, 80, 80)" }); +}); + +test("fully contracted shadows do not prevent rendering", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await page.evaluate(async () => { + const { applySquircle } = await import("/dist/dom/index.js"); + const element = document.createElement("div"); + element.id = "shadow"; + element.style.cssText = "width:160px;height:80px;background:white;border-radius:16px;box-shadow:0 2px 4px -100px oklch(40% 0 0)"; + document.body.append(element); + applySquircle(element); + }); + await expect(page.locator("#shadow [data-squircle-fill]")).toHaveCount(1); + await expect(page.locator("#shadow [data-squircle-shadow]")).toHaveCount(0); + expect(errors).toEqual([]); +}); + +test("a fully replaced style attribute survives cleanup", async ({ page }) => { + await page.goto("/"); + await page.evaluate(async () => { + const { applySquircle } = await import("/dist/dom/index.js"); + const element = document.createElement("div"); + element.id = "replaced-styles"; + element.style.cssText = "width:160px;height:80px;background:black;border:1px solid black;border-radius:16px;padding:4px"; + document.body.append(element); + window.cleanup = applySquircle(element); + }); + const element = page.locator("#replaced-styles"); + await expect(element.locator("[data-squircle-fill]")).toHaveCount(1); + await element.evaluate((node) => { node.style.cssText = "width:200px;height:100px;background:rgb(60,60,60);border:2px solid rgb(90,90,90);border-radius:24px"; }); + await expect(element.locator("svg")).toHaveAttribute("viewBox", "0 0 200 100"); + await expect(element.locator("[data-squircle-fill]")).toHaveCSS("background-color", "rgb(60, 60, 60)"); + await page.evaluate(() => window.cleanup()); + await expect(element).toHaveCSS("border-top-color", "rgb(90, 90, 90)"); + await expect(element).toHaveCSS("padding-top", "0px"); + await expect(element).toHaveCSS("background-color", "rgb(60, 60, 60)"); +}); diff --git a/docs/architecture.md b/docs/architecture.md index b9d2a43..6af4bc4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,140 +1,95 @@ # Architecture -## Why SVG instead of a mask - -The outer fill and border must be rasterized from the same geometry. Building a -border from an outer clip path plus a separately calculated inner mask has -three failure modes: - -1. reducing the radius does not create an exact parallel offset of a smoothed - Bézier curve; -2. `clip-path` and an encoded SVG mask can be rasterized in separate passes; -3. fractional layout sizes can quantize those two passes differently. - -This package draws one SVG path with both `fill` and `stroke`. The path is inset -by half the stroke width, so the complete stroke stays inside the component -box. There is no inner contour to drift away from the outer contour. - -## Layers - -- `squircles` is pure geometry and has no runtime dependencies. -- `squircles/dom` adds `applySquircle`, which shapes any element from its own - CSS. It has no React dependency. -- `squircles/react` adds the `Squircle`, `SquircleSurface`, `useSquirclePath`, - and `useSquircleClipPath` APIs. -- React is an optional peer dependency. The geometry entry point does not load - React and does not touch the DOM. -- `src/css/` converts `CSSStyleDeclaration` values into plain data. It never - reaches for a DOM global, which is what makes it unit-testable under - `node --test`. - -## Paint model - -`Squircle` renders only the host element and calls `applySquircle` in an effect, -so the paint layers never pass through React reconciliation. That is also why a -void element such as `img` works: nothing is rendered as its child. - -`applySquircle` appends up to three kinds of absolutely positioned layer inside -the host, each with `pointer-events: none`: - -| Layer | z-index | Purpose | -|---|---|---| -| `[data-squircle-shadow]` (one per shadow) | -3 | An offset, blurred, colour-filled shape, clipped to the contour grown by the shadow's spread | -| `[data-squircle-fill]` | -2 | The host's copied background longhands, clipped to the contour | -| `[data-squircle-stroke]` | -1 | An SVG path carrying the border as a stroke | - -All three layers are offset outwards by the border width. The geometry is -measured on the border box, but absolute offsets inside the host resolve against -its padding box, so on a bordered element the two would disagree by the border -width on every side — the outermost band of the contour would fall outside the -layer and the corners would be cut off square. - -The host itself is deliberately **not** clipped. `clip-path` removes everything -outside the contour, including shadows and including descendants, so clipping -the host would make shadows impossible. Instead the host's background is copied -onto the fill layer and the host is set to `transparent`; its `border-color` is -set to `transparent` rather than removing the border, so the border box and the -layout stay intact. - -Separating surface and stroke does not reintroduce the two-contour problem. The -stroke is inset by half its width and completely covers the fill's clip edge, so -there is still never a fill edge and a border edge that must agree. A -semi-transparent border is the exception: the clip edge shows through it, and -`SquircleSurface` remains the escape hatch for that case. - -## Reading the author's CSS - -Because the layers overwrite `background-color`, `background-image`, -`border-color`, and `box-shadow` on the host, those overrides are lifted again -for the duration of every read. Two consequences are worth knowing: - -- each re-read costs one style recalculation, which is why reads are coalesced - into a single `requestAnimationFrame`; -- transitions are suppressed during the read. Lifting an override on a property - that carries a transition makes the browser interpolate from the override, so - a plain read returns the value in flight rather than the authored target. A - hovered button with a background transition would otherwise keep its idle - colour. - -## Geometry - -The curve uses the cubic Bézier construction described in Figma's -"Desperately seeking squircles" article: - -- smoothing `0` is a normal circular corner; -- smoothing `1` uses the full transition and no circular arc; -- each corner gets a proportional budget on its two adjacent sides; -- by default smoothing is reduced if the requested curve would exceed that - budget, matching Figma's documented constraint; -- `preserveSmoothing` keeps the requested smoothing and compresses the first - Bézier controls instead. - -The implementation is original TypeScript structured around immutable corner -data, explicit input validation, per-corner radii, and stroke insetting. The -published Figma article and the MIT-licensed reference implementations are -credited in `NOTICE`. - -## Rendering contract - -`Squircle` is the high-level, polymorphic component. It: - -- preserves the semantic host (`div`, `button`, `a`, `img`, or a custom React - component); -- measures only layout-size changes with `ResizeObserver`; -- falls back to the element's own `border-radius` before the first measurement, - which needs no special handling because the host keeps its own CSS; -- does not clip focus outlines by default; -- re-reads on `ResizeObserver`, on `class` and `style` mutations, and on - `pointerenter`, `pointerleave`, `focusin`, `focusout`, `transitionrun`, and - `transitionend`. - -Void elements (`img`, `input`, and the rest) cannot hold layers. They get -`clip-path` directly and therefore support neither a border nor a shadow; wrap -them in a `Squircle` when those are needed. - -`SquircleSurface` is the low-level SVG visual. It is useful inside an existing -component that already owns positioning, semantics, and fallback behavior. - -## Known boundaries - -No renderer can make the two physical edges of an element land on identical -device-pixel phases when the element itself has a fractional physical-pixel -width. This package avoids amplifying that unavoidable rasterization detail: -it does not quantize layout to arbitrary quarter pixels, and it never combines -two independently rasterized contours. - -Beyond that, and by decision rather than by accident: - -- `outline`, and therefore every focus ring, follows `border-radius`. No - renderer exposes a hook to change that. -- Only `border-top-width`, `border-top-style`, and `border-top-color` are read. - Per-side borders cannot be expressed as one stroke. -- Only `border-style: solid` is drawn. `stroke-dasharray` could approximate - dashed and dotted borders, but the dash rhythm around a smoothed curve would - not match what CSS draws. -- `inset` shadows are ignored and left on the element. -- Multiple shadows become one layer each. They composite as stacked layers - rather than the way `box-shadow` composites its list. -- `backdrop-filter` on the host stays rectangular, because the host is not - clipped. -- An element whose size is animated recalculates its path once per frame. +## Package boundaries + +- `squircles`: pure geometry with no DOM access or runtime dependencies. +- `squircles/dom`: CSS-driven rendering through `applySquircle`. +- `squircles/react`: components and hooks, with React as an optional peer. +- `squircles/svelte`: an action with updates and cleanup, without a Svelte import. +- `src/css/`: structural readers for computed CSS values, testable in Node. + +The public API, documentation, comments, examples, and error messages use English. + +## Geometry and borders + +The geometry follows Figma's published cubic Bézier construction: +`smoothing: 0` uses circular arcs; `1` uses fully smoothed transitions. +Each corner receives a proportional share of its adjacent sides. When space is +limited, smoothing is constrained. `preserveSmoothing` instead compresses the +first control points. + +Borders use an SVG stroke on a path inset by half the stroke width. There is no +separate inner mask: simply shrinking a corner radius would not produce an exact +parallel offset of a Bézier curve. Sources and acknowledgments are in `NOTICE`. + +## Rendering layers + +`Squircle` renders only its native element. An effect binds `applySquircle` to +that element, keeping rendering layers outside React reconciliation. Changing +`as` removes the previous binding and measures the new element. + +| Layer | Stacking order | Purpose | +| --- | --- | --- | +| `[data-squircle-shadow]` | -3 | An offset, blurred shape for each outer shadow | +| `[data-squircle-fill]` | -2 | A copied background clipped to the smoothed shape | +| `[data-squircle-stroke]` | -1 | An SVG stroke on the smoothed path | + +All layers ignore pointer events. Their negative offset by the border width +compensates for the difference between padding and border boxes. The original +border becomes transparent but retains its width to prevent layout shifts. +The host is not clipped by default, keeping shadows and focus outlines visible. + +The SVG stroke covers the clipped background edge. Semi-transparent borders may +expose that edge; `SquircleSurface` avoids this by drawing fill and stroke +together on one SVG path. + +## Reading and restoring CSS + +Our overrides are temporarily lifted to read authored styles. Transitions stay +disabled during the read to prevent new transitions or endless updates. +Updates are coalesced per animation frame; our own mutation records are discarded +afterward. + +Each element stores its authored and most recently applied inline styles. +Differences from the applied state are merged into the authored state, preserving +later framework changes across redraws and cleanup. Assigning exactly the same +value already applied by the library cannot be distinguished from our override. + +Only one binding per element is supported. Cleanup is idempotent and removes +observers, event handlers, pending frames, and our own layers. + +## Size measurement + +DOM and React share the same measurement code. `ResizeObserver` provides logical +inline and block dimensions, which are mapped to physical width and height using +`writing-mode`, including `vertical-*` and `sideways-*`. Sizes are rounded to +1/64 pixel to avoid updates caused by tiny measurement differences. + +Without `ResizeObserver`, dimensions are measured on startup and window resize. +Other layout changes require a polyfill. Fixed sizes bypass measurement in the +React hooks. Boxes with zero width or height have no visible valid path. + +## Limits + +- `outline` and inset shadows still follow ordinary CSS rounding. +- Only solid borders based on the top border values are drawn. +- Elliptical radii are reduced to the inscribed circle. +- Void HTML elements such as `img` and `input` receive a clip path directly, + which also clips their own border and shadow. +- `backdrop-filter` remains rectangular unless `clipContent` is enabled. +- Shadow lists are composited as individual layers. Inset shadows are not + redrawn; in mixed lists, they disappear when the original shadow is suppressed. +- Changes exclusively to ancestors or stylesheets need an observed event on the + element, such as a size or class change. +- Background transitions are forwarded; geometry and border changes are redrawn + at update time. +- Fractional physical pixels can still produce slightly different rasterization + on opposite edges. + +## Verification and images + +`npm run check` verifies types, geometry, CSS readers, server-side React rendering, +and package contents. `npm run test:browser` checks DOM lifecycles, style changes, +writing modes, and the playground in Chromium. `npm run screenshots` captures the +playground and renders its shadow into a transparent PNG margin. Image widths in +the README are explicitly limited. diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..9da475d --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,149 @@ +# Integration + +## Start with CSS + +The DOM, React, and Svelte integrations share the same renderer. +`border-radius` and the following CSS custom properties define the shape: + +```css +.card { + background: white; + border: 1px solid #ddd; + border-radius: 24px; + --squircle-smoothing: 1; + --squircle-preserve-smoothing: 1; + box-shadow: 0 8px 24px rgb(0 0 0 / 12%); +} +``` + +Percentage radii resolve per axis. Elliptical CSS corners use the inscribed +circle. The top border determines the width and color of all sides. Outer +shadows also support `oklch()` and `color()`. Inset and outer shadows cannot be +combined: for mixed lists, the replacement rendering suppresses the original +shadow, including its inset entries. + +The optional `radius`, `smoothing`, `preserveSmoothing`, and `clipContent` values +override CSS. `clipContent` also clips children and external focus outlines; +leave it disabled on controls that need those outlines. + +Changes to the element's own `class` and `style` attributes, size, hover and focus +states, and the start and end of its own CSS transitions trigger updates. +Changes exclusively to ancestors, stylesheets, or media queries are not detected +automatically without one of these events. + +## React and Next.js + +```tsx +import { Squircle } from "squircles/react"; + +export function Card() { + return ( + + +

All native element properties remain available.

+
+ ); +} +``` + +`as` also accepts custom components that forward their ref to an HTML element, +such as Next.js `Link`. In the App Router, the calling file needs `"use client"`. +Geometry functions can also run on the server. `Squircle` initially renders only +its native element there, with CSS supplying the rounded-corner fallback. + +`useSquirclePath` and `useSquircleClipPath` measure a custom ref. With the option +`size: { width, height }`, they work without observation and during server-side +rendering. Empty boxes produce an empty path or `none`. + +### Existing components and semi-transparent borders + +`SquircleSurface` draws fill and stroke together on one SVG path: + +```tsx +import { SquircleSurface } from "squircles/react"; + + +``` + +The parent needs `position: relative`, transparent backgrounds and borders, +and text above the SVG, for example with `position: relative` on the `span`. +`width` and `height` also allow fixed dimensions without measurement. + +## Svelte + +The action uses the [`update` and `destroy` lifecycle](https://svelte.dev/docs/svelte-action). +It imports no Svelte runtime and works with Svelte 4 and 5. + +```svelte + + + + +``` + +New options replace previous ones. Removing the element cleans up observers and +rendered layers. In Svelte 5.29 or later, the DOM function can also be used +directly as an [attachment](https://svelte.dev/docs/svelte/@attach): + +```svelte + + + +``` + +## Vue and other frameworks + +`applySquircle` needs a mounted HTML element and returns a cleanup function. +For example, in Vue 3: + +```vue + + + +``` + +CSS changes on the element are picked up automatically. To change geometry +options, clean up the previous binding and apply it again. Cleanup can be called +repeatedly. Later inline style changes survive redraws and cleanup. +Only one active binding per element is supported. + +## Migrating from 0.1 + +Since 0.2, `Squircle` takes its paint styles from CSS: + +| Previous prop | CSS or replacement | +| --- | --- | +| `fill` | `background` | +| `stroke` | `border-color` | +| `strokeWidth` | `border-width` and `border-style: solid` | +| `radius` | `border-radius`, or keep using the option | +| `smoothing` | `--squircle-smoothing`, or keep using the option | +| `surfaceClassName`, `surfaceStyle` | `[data-squircle-fill]` and `[data-squircle-stroke]` | + +`SquircleSurface` still supports SVG paint props. diff --git a/docs/media/playground.png b/docs/media/playground.png index cb693f1..71e3957 100644 Binary files a/docs/media/playground.png and b/docs/media/playground.png differ diff --git a/docs/media/smoothing-extremes.png b/docs/media/smoothing-extremes.png index cf2f68b..61234f1 100644 Binary files a/docs/media/smoothing-extremes.png and b/docs/media/smoothing-extremes.png differ diff --git a/docs/superpowers/plans/2026-07-28-css-driven-squircles.md b/docs/superpowers/plans/2026-07-28-css-driven-squircles.md index 47985b6..8d8fd89 100644 --- a/docs/superpowers/plans/2026-07-28-css-driven-squircles.md +++ b/docs/superpowers/plans/2026-07-28-css-driven-squircles.md @@ -1695,8 +1695,8 @@ Insert after the `.insights` section, before `
`:
- Card mit Schatten - Hintergrund, Rand und Schatten kommen aus CSS. + Card with a shadow + Background, border, and shadow come from CSS.
@@ -1708,7 +1708,7 @@ Insert after the `.insights` section, before `
`: Badge -

Input und Badgebeliebige Elemente

+

Input and badgeany element

``` diff --git a/examples/demo.css b/examples/demo.css new file mode 100644 index 0000000..e1c2a7e --- /dev/null +++ b/examples/demo.css @@ -0,0 +1,83 @@ +:root { + color-scheme: light; + --paper: #ffffff; + --background: #f7f7f7; + --text: #242424; + --secondary: #666666; + --line: #dedede; + --comparison: #888888; + font: 15px/1.55 "Segoe UI", Helvetica, sans-serif; + color: var(--text); + background: var(--background); + -webkit-font-smoothing: antialiased; +} +* { box-sizing: border-box; } +body { margin: 0; } +main { max-width: 1040px; margin: 48px auto; padding: 0 32px; } +header { display: flex; justify-content: space-between; gap: 24px; align-items: baseline; margin-bottom: 32px; } +h1 { margin: 0 0 8px; font-size: 30px; font-weight: 600; letter-spacing: -.045em; line-height: 1.2; } +h2 { margin: 0 0 16px; font-size: 16px; font-weight: 600; } +p { margin: 0; } +header p, .note { color: var(--secondary); max-width: 64ch; } +a { color: inherit; text-underline-offset: 4px; } +nav { display: flex; gap: 16px; flex-shrink: 0; font-size: 13px; } +button, input { font: inherit; } +:focus-visible { outline: 2px solid var(--text); outline-offset: 4px; } +.playground { display: grid; grid-template-columns: 208px minmax(0, 1fr); background: var(--paper); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } +.controls { display: grid; gap: 24px; align-content: start; padding: 28px 24px; border-right: 1px solid var(--line); } +.controls label { display: grid; gap: 10px; font-size: 13px; } +.controls label:not(.toggle) > span { display: flex; justify-content: space-between; gap: 10px; } +output { color: var(--secondary); font-variant-numeric: tabular-nums; } +input[type="range"] { width: 100%; margin: 0; accent-color: var(--text); } +.controls .toggle { display: flex; align-items: start; gap: 8px; line-height: 1.4; } +.toggle input { accent-color: var(--text); margin: 2px 0 0; } +.controls small { display: block; color: var(--secondary); font-size: 12px; margin-top: 4px; } +.preview { min-width: 0; } +.comparison { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--line); } +figure { margin: 0; min-width: 0; } +.shape-stage { height: 218px; display: grid; place-items: center; padding: 24px; overflow: hidden; } +.shape-stage svg { display: block; overflow: visible; } +figcaption { color: var(--secondary); font-size: 12px; text-align: center; padding: 0 12px 20px; } +figcaption b { display: block; color: var(--text); font-size: 14px; font-weight: 600; margin-bottom: 2px; } +.analysis { display: grid; grid-template-columns: 1fr 1fr; } +.analysis figure { padding-top: 24px; } +.analysis .shape-stage { height: 176px; padding: 12px 24px 24px; } +.analysis svg { width: 100%; height: 100%; } +.analysis figcaption { padding-bottom: 0; } +.legend { display: flex; justify-content: center; flex-wrap: wrap; gap: 18px; padding: 12px 16px 24px; font-size: 12px; color: var(--secondary); } +.legend span::before { content: ""; display: inline-block; width: 20px; margin-right: 6px; vertical-align: middle; border-top: 2px solid var(--text); } +.legend span:last-child::before { border-top: 2px dashed var(--comparison); } +.note { font-size: 13px; margin: 16px 0 24px; } +details { border-top: 1px solid var(--line); padding: 18px 0; } +summary { cursor: pointer; font-size: 13px; } +pre { margin: 16px 0 0; overflow: auto; font: 12px/1.7 Consolas, monospace; } +code { font-size: .9em; } +.examples { margin-top: 40px; } +.row { display: flex; flex-wrap: wrap; align-items: start; gap: 24px; } +.stack { display: grid; gap: 20px; justify-items: start; } +.button { display: inline-grid; place-items: center; min-height: 44px; padding: 10px 20px; background: var(--text); border: 1px solid var(--text); border-radius: 12px; --squircle-smoothing: 1; color: white; font-weight: 600; cursor: pointer; text-decoration: none; transition: background-color 150ms ease; } +.button:hover { background: #444; } +.button--light { background: white; color: var(--text); border-color: var(--line); } +.button--light:hover { background: #eee; } +.card { width: 264px; max-width: 100%; border: 1px solid var(--line); border-radius: 20px; --squircle-smoothing: 1; background: white; box-shadow: 0 8px 20px rgb(0 0 0 / .08); } +.card-image { display: block; width: 100%; height: 124px; border-radius: 20px; object-fit: cover; } +.card-body { display: grid; gap: 4px; padding: 16px; font-size: 13px; } +.card-body span { color: var(--secondary); } +.input { width: 220px; max-width: 100%; padding: 10px 12px; border: 1px solid #aaa; border-radius: 10px; background: white; color: var(--text); } +.field { display: grid; gap: 8px; color: var(--secondary); font-size: 13px; } +.badge { padding: 6px 14px; border-radius: 999px; background: #e9e9e9; font-size: 12px; } +@media (max-width: 720px) { + main { margin: 28px auto; padding: 0 20px; } + header { display: block; margin-bottom: 24px; } + nav { margin-top: 16px; } + .playground { grid-template-columns: 1fr; } + .controls { grid-template-columns: 1fr 1fr; gap: 20px; padding: 20px; border-right: 0; border-bottom: 1px solid var(--line); } + .shape-stage { height: 190px; padding: 16px; } +} +@media (max-width: 400px) { + .comparison { grid-template-columns: 1fr; } + .shape-stage { height: 160px; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +} diff --git a/examples/playground.html b/examples/playground.html index 042d262..009c693 100644 --- a/examples/playground.html +++ b/examples/playground.html @@ -3,691 +3,144 @@ - squircles playground - + squircles · Playground +
-

squircles playground

-

- The same button, drawn twice. On the left with Figma corner smoothing, - on the right with plain border-radius. Move the sliders to - see where the two shapes disagree. -

+
+

squircles

+

Smoother corners, straight from your CSS. Compare smoothing with border-radius.

+
+
-
-
- - - - - - - - - - - +
+ + + + + + + -
-
-
-
- -
-

- squircles - createSquirclePath() -

-
- -
-
- -
-

- border-radius - circular corners -

-
+
+
+
+
+
SquircleSmoothed transitions
+
+
+
+
border-radiusCircular corners
+
- -
-
-
- -
-

- - squircle outline - - - border-radius outline - -

-
- -
-
- -
-

- Top left corner, magnified - The tinted band is the area corner smoothing adds. - -

-
-
- -
-
-
-
- -
- Card with a shadow - Background, border, and shadow all come from CSS. -
-
-
-

- Card - applySquircle() -

-
- -
-
- - Badge -
-

- Input and badge - any element -

-
-
- -
-

+          
+
Compare outlinesSame size, same radii
+
A closer lookTop left corner, enlarged
+
Squircleborder-radius
-
+

+
Geometry as code
+ +
+

With ordinary CSS

+
+
+ Two smoothed shapes in grayscale +
A card with smoother corners.Background, border, and shadow stay in CSS.
+
+
+ + + No dependencies +
+
+
+ diff --git a/examples/react/index.html b/examples/react/index.html index 623915a..bdc438f 100644 --- a/examples/react/index.html +++ b/examples/react/index.html @@ -3,158 +3,8 @@ - squircles with React - + squircles · React examples +
diff --git a/examples/react/main.tsx b/examples/react/main.tsx index be54f73..1369512 100644 --- a/examples/react/main.tsx +++ b/examples/react/main.tsx @@ -1,69 +1,55 @@ -import { StrictMode } from "react"; +import { StrictMode, useState } from "react"; import { createRoot } from "react-dom/client"; import { Squircle } from "../../src/react/index.js"; -function Demo() { +function Examples() { + const [asLink, setAsLink] = useState(false); return (
-

squircles with React

-

- Every shape below is styled in plain CSS. The components receive no - paint props at all — only as and a class name. -

+
+

squircles with React

+

Native elements with smoother corners. Styling stays in CSS.

+
+
-
-

Buttons

+
+

Buttons and links

- - Learn more - - - - Contact - + Learn more + See the examples
-

- Hover them: the fill transitions, and the geometry is not recalculated - for a colour change. -

+

Style hover, focus outlines, and backgrounds in ordinary CSS.

-
-

Card, image, input, badge

+
+

Card, image, and input

- -
- Card with a shadow - Background, border, and shadow all come from CSS. -
+ +
A card with smoother corners.Background, border, and shadow stay in CSS.
-
- - - Badge - + + No extra wrapper
+ +
+

Switch the element

+
+ + {asLink + ? Now a link + : Now a button} +
+

The shape follows the new element; old observers are removed.

+
); } const container = document.getElementById("root"); - -if (container) { - createRoot(container).render( - - - , - ); -} +if (container) createRoot(container).render(); diff --git a/examples/shapes.svg b/examples/shapes.svg new file mode 100644 index 0000000..602a745 --- /dev/null +++ b/examples/shapes.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/svelte/Examples.svelte b/examples/svelte/Examples.svelte new file mode 100644 index 0000000..0425cb8 --- /dev/null +++ b/examples/svelte/Examples.svelte @@ -0,0 +1,42 @@ + + +
+
+
+

squircles with Svelte

+

CSS-driven shapes as actions or attachments. No extra wrapper.

+
+ +
+ +
+

An action with reactive options

+ +
+ {#if visible} + + {/if} + +
+

New options update the shape. Removing the element cleans up all observers.

+
+ +
+

As an attachment in Svelte 5.29+

+ +

The DOM function already returns the required cleanup function.

+
+
diff --git a/examples/svelte/index.html b/examples/svelte/index.html new file mode 100644 index 0000000..9187f90 --- /dev/null +++ b/examples/svelte/index.html @@ -0,0 +1,13 @@ + + + + + + squircles · Svelte examples + + + +
+ + + diff --git a/examples/svelte/main.js b/examples/svelte/main.js new file mode 100644 index 0000000..089caf3 --- /dev/null +++ b/examples/svelte/main.js @@ -0,0 +1,4 @@ +import { mount } from "svelte"; +import Examples from "./Examples.svelte"; + +mount(Examples, { target: document.getElementById("root") }); diff --git a/package-lock.json b/package-lock.json index f67d286..8b399ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.2.1", "license": "MIT", "devDependencies": { + "@playwright/test": "^1.65.0", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "esbuild": "^0.25.12", "react": "^19.0.0", "react-dom": "^19.0.0", + "svelte": "^5.57.3", "typescript": "^6.0.0" }, "engines": { @@ -471,6 +473,89 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -501,6 +586,49 @@ "@types/react": "^19.2.0" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -508,6 +636,13 @@ "dev": true, "license": "MIT" }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -550,6 +685,87 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -580,6 +796,33 @@ "dev": true, "license": "MIT" }, + "node_modules/svelte": { + "version": "5.57.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz", + "integrity": "sha512-5mAftM3SoDV9X0zdF386F9p2pv8iKOwi/KfBS1XnBAkVCKFzVOLHUoa8iGWAt4buoicLmyWVRpuuD5wplXOQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -600,6 +843,13 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 047c198..f7309a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "squircles", "version": "0.2.1", - "description": "Small, dependency-free Figma-style squircle geometry with optional React components.", + "description": "Dependency-free Figma-style corner smoothing with DOM, React, and Svelte integrations.", "type": "module", "sideEffects": false, "files": [ @@ -30,6 +30,10 @@ "./react": { "types": "./dist/react/index.d.ts", "import": "./dist/react/index.js" + }, + "./svelte": { + "types": "./dist/svelte/index.d.ts", + "import": "./dist/svelte/index.js" } }, "scripts": { @@ -41,14 +45,19 @@ "prepublishOnly": "npm run typecheck && npm test", "visual:fixture": "npm run build && node scripts/create-visual-fixture.mjs", "build:examples": "node scripts/build-examples.mjs", - "playground": "npm run build && npm run build:examples && node scripts/serve-playground.mjs" + "playground": "npm run build && npm run build:examples && node scripts/serve-playground.mjs", + "test:browser": "npm run build && npm run build:examples && playwright test", + "screenshots": "npm run build && npm run build:examples && node scripts/screenshots.mjs" }, "keywords": [ "figma", "react", "squircle", "corner-smoothing", - "typescript" + "typescript", + "svelte", + "vue", + "css" ], "license": "MIT", "engines": { @@ -63,12 +72,14 @@ } }, "devDependencies": { + "@playwright/test": "^1.65.0", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "esbuild": "^0.25.12", "react": "^19.0.0", "react-dom": "^19.0.0", + "svelte": "^5.57.3", "typescript": "^6.0.0" }, "publishConfig": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a6d671e --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "@playwright/test"; +import process from "node:process"; + +export default defineConfig({ + testDir: "./browser-tests", + fullyParallel: true, + use: { baseURL: "http://127.0.0.1:5173", browserName: "chromium" }, + webServer: { + command: "node scripts/serve-playground.mjs", + url: "http://127.0.0.1:5173", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/scripts/build-examples.mjs b/scripts/build-examples.mjs index 8309bba..3e92de0 100644 --- a/scripts/build-examples.mjs +++ b/scripts/build-examples.mjs @@ -1,4 +1,7 @@ import { build } from "esbuild"; +import { readFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { compile } from "svelte/compiler"; await build({ entryPoints: ["examples/react/main.tsx"], @@ -9,3 +12,22 @@ await build({ jsx: "automatic", logLevel: "info", }); + +await build({ + entryPoints: ["examples/svelte/main.js"], + outfile: "examples/svelte/bundle.js", + bundle: true, + format: "esm", + target: "es2022", + logLevel: "info", + plugins: [{ + name: "svelte-examples", + setup(build) { + build.onLoad({ filter: /\.svelte$/ }, async ({ path }) => { + const source = await readFile(path, "utf8"); + const { js } = compile(source, { filename: path, generate: "client" }); + return { contents: js.code, resolveDir: dirname(path), loader: "js" }; + }); + }, + }], +}); diff --git a/scripts/create-visual-fixture.mjs b/scripts/create-visual-fixture.mjs index 28c546f..ac547fa 100644 --- a/scripts/create-visual-fixture.mjs +++ b/scripts/create-visual-fixture.mjs @@ -8,7 +8,7 @@ const cases = [ { width: 48, height: 48, radius: 12, smoothing: 1, strokeWidth: 1 }, { width: 180, height: 64, radius: 20, smoothing: 1, strokeWidth: 1.5 }, { width: 240, height: 96, radius: 32, smoothing: 0.8, strokeWidth: 2 }, - // Card, borderless surface, and pill, matching the new example pages. + // Card, borderless surface, and pill, matching the example pages. { width: 260, height: 180, radius: 20, smoothing: 1, strokeWidth: 1 }, { width: 260, height: 120, radius: 12, smoothing: 1, strokeWidth: 0 }, { width: 120, height: 32, radius: 999, smoothing: 1, strokeWidth: 1 }, @@ -36,8 +36,8 @@ const rows = cases > - Squircle visual fixture + Squircle geometry examples
-

Squircle rasterization fixture

-

One path, one fill, one centered SVG stroke inset by half its width.

+

Squircle rasterization

+

One path, one fill, and an SVG stroke inset by half its width.

${rows}
diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs new file mode 100644 index 0000000..44b089b --- /dev/null +++ b/scripts/screenshots.mjs @@ -0,0 +1,54 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { chromium } from "@playwright/test"; + +const root = new URL("../", import.meta.url); +const port = 5174; +const server = spawn(process.execPath, ["scripts/serve-playground.mjs"], { + cwd: fileURLToPath(root), + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, +}); +let browser; +try { + // Capture only after the startup message; a port conflict must fail the run. + await Promise.race([ + once(server.stdout, "data"), + once(server, "exit").then(() => { throw new Error("The preview server could not start."); }), + once(server, "error").then(([error]) => { throw error; }), + ]); + browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1040, height: 900 }, deviceScaleFactor: 2 }); + await page.goto(`http://127.0.0.1:${port}/`); + await page.locator("#squircle path").waitFor(); + await page.evaluate(() => document.fonts.ready); + + const images = [ + ["#playground", "playground.png", 976], + ["#analysis", "smoothing-extremes.png", 600], + ]; + for (const [selector, filename, width] of images) { + if (selector === "#analysis") { + await page.locator("#width").fill("160"); + await page.locator("#height").fill("160"); + await page.locator("#radius").fill("40"); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + } + const screenshot = await page.locator(selector).screenshot(); + const frame = await browser.newPage({ viewport: { width: width + 80, height: 1200 }, deviceScaleFactor: 2 }); + await frame.setContent(`
`); + await frame.locator("img").evaluate((img) => img.decode()); + await frame.locator("figure").screenshot({ path: fileURLToPath(new URL(`docs/media/${filename}`, root)), omitBackground: true }); + await frame.close(); + console.log(`README image created: ${filename}`); + } +} finally { + await browser?.close(); + server.kill(); +} diff --git a/scripts/serve-playground.mjs b/scripts/serve-playground.mjs index 2ad6b69..5399789 100644 --- a/scripts/serve-playground.mjs +++ b/scripts/serve-playground.mjs @@ -12,20 +12,23 @@ const types = { ".map": "application/json; charset=utf-8", ".css": "text/css; charset=utf-8", ".png": "image/png", + ".svg": "image/svg+xml", }; const pages = { "/": "examples/playground.html", "/react": "examples/react/index.html", "/react/": "examples/react/index.html", + "/svelte": "examples/svelte/index.html", + "/svelte/": "examples/svelte/index.html", }; -/** Resolves a request path inside the package, or null if it escapes it. */ +/** Resolves a request path inside the package and rejects paths outside it. */ function resolve(requestPath) { - // The React page is served under /react/, so its assets resolve there too. + // Each framework page shares a subpath with its assets. const relative = pages[requestPath] ?? - (requestPath.startsWith("/react/") + (/^\/(react|svelte)\//.test(requestPath) ? `examples${requestPath}` : requestPath.slice(1)); const target = new URL(relative, root); @@ -47,7 +50,7 @@ const server = createServer(async (request, response) => { const stats = await stat(path); if (!stats.isFile()) { - throw new Error("not a file"); + throw new Error("Not a file"); } } catch { response.writeHead(404).end("Not found"); @@ -62,6 +65,7 @@ const server = createServer(async (request, response) => { }); server.listen(port, () => { - console.log(`squircles playground: http://localhost:${port}/`); - console.log(`squircles with React: http://localhost:${port}/react/`); + console.log(`squircles Playground: http://localhost:${port}/`); + console.log(`squircles with React: http://localhost:${port}/react/`); + console.log(`squircles with Svelte: http://localhost:${port}/svelte/`); }); diff --git a/src/clip-path.ts b/src/clip-path.ts index 6ec939c..34be0fe 100644 --- a/src/clip-path.ts +++ b/src/clip-path.ts @@ -1,9 +1,6 @@ import { createSquirclePath, type SquirclePathOptions } from "./geometry.js"; -/** - * Builds a ready CSS `clip-path` value. Returns `none` for a degenerate box, - * so the result is always assignable to the property. - */ +/** Builds a ready-to-use CSS clip path. Empty boxes return the valid value `none`. */ export function createSquircleClipPath(options: SquirclePathOptions): string { const path = createSquirclePath(options); return path === "" ? "none" : `path('${path}')`; diff --git a/src/css/paint.ts b/src/css/paint.ts index 3866189..e24eb65 100644 --- a/src/css/paint.ts +++ b/src/css/paint.ts @@ -6,8 +6,8 @@ function read(styles: StyleReader, property: string, fallback: string): string { } /** - * Only the top border is read. Per-side borders and dashed or dotted styles - * cannot be expressed as a single SVG stroke and are out of scope. + * Reads only the top border. Per-side, dashed, and dotted borders cannot be + * represented by a single SVG stroke. */ export function readBorderFromComputed(styles: StyleReader): SquircleBorder { const style = read(styles, "border-top-style", "none"); diff --git a/src/css/radius.ts b/src/css/radius.ts index af33ca3..91afc41 100644 --- a/src/css/radius.ts +++ b/src/css/radius.ts @@ -20,8 +20,8 @@ function toPixels(value: string, basis: number): number { } /** - * A squircle corner is circular, so an elliptical CSS corner is reduced to the - * circle that fits inside it. Percentages resolve per axis first. + * Reduces elliptical CSS corners to the inscribed circle. Percentages are resolved + * per axis first. */ function resolveCorner(value: string, size: ElementSize): number { const parts = value.trim().split(/\s+/).filter(Boolean); diff --git a/src/css/shadow.ts b/src/css/shadow.ts index 5d048c8..8d5947f 100644 --- a/src/css/shadow.ts +++ b/src/css/shadow.ts @@ -1,10 +1,9 @@ import type { SquircleShadow } from "./types.js"; -const COLOR = /(rgba?\([^)]*\)|#[0-9a-f]{3,8}|\b[a-z]+\b(?!\s*\())/i; -const LENGTH = /-?\d*\.?\d+px/g; +const LENGTH = /^-?(?:\d*\.)?\d+(?:e[+-]?\d+)?px$/i; -/** Splits on commas that are not inside a colour function. */ -function splitShadows(value: string): string[] { +/** Splits only outside functions, including arbitrarily nested functions. */ +function splitOutsideFunctions(value: string, separator: RegExp): string[] { const parts: string[] = []; let depth = 0; let current = ""; @@ -14,7 +13,7 @@ function splitShadows(value: string): string[] { depth += 1; } else if (character === ")") { depth -= 1; - } else if (character === "," && depth === 0) { + } else if (separator.test(character) && depth === 0) { parts.push(current); current = ""; continue; @@ -25,13 +24,10 @@ function splitShadows(value: string): string[] { parts.push(current); - return parts.filter((part) => part.trim() !== ""); + return parts.map((part) => part.trim()).filter(Boolean); } -/** - * Computed `box-shadow` always serialises the colour first, which is why the - * colour can be lifted out before the lengths are read. - */ +/** Preserves modern color functions such as oklch() and color(). */ function parseShadow(part: string): SquircleShadow | null { const text = part.trim(); @@ -39,10 +35,11 @@ function parseShadow(part: string): SquircleShadow | null { return null; } - const color = COLOR.exec(text)?.[0] ?? "currentColor"; - const lengths = text.replace(color, " ").match(LENGTH); + const tokens = splitOutsideFunctions(text, /\s/); + const lengths = tokens.filter((token) => LENGTH.test(token)); + const color = tokens.filter((token) => !LENGTH.test(token)).join(" ") || "currentColor"; - if (!lengths || lengths.length < 2) { + if (lengths.length < 2 || lengths.length > 4) { return null; } @@ -64,7 +61,7 @@ export function parseBoxShadow(value: string): readonly SquircleShadow[] { return []; } - return splitShadows(text) + return splitOutsideFunctions(text, /,/) .map(parseShadow) .filter((shadow): shadow is SquircleShadow => shadow !== null); } diff --git a/src/css/types.ts b/src/css/types.ts index 2097f02..b5052d3 100644 --- a/src/css/types.ts +++ b/src/css/types.ts @@ -1,6 +1,6 @@ /** - * The slice of CSSStyleDeclaration these readers need. Declaring it - * structurally keeps this directory free of DOM types and testable in Node. + * The structural subset of CSSStyleDeclaration needed by the readers, keeping them + * testable in Node without a DOM. */ export type StyleReader = Readonly<{ getPropertyValue(property: string): string; diff --git a/src/dom/apply-squircle.ts b/src/dom/apply-squircle.ts index e2695c2..b7d43fe 100644 --- a/src/dom/apply-squircle.ts +++ b/src/dom/apply-squircle.ts @@ -1,23 +1,19 @@ import type { ElementSize } from "../size.js"; +import { observeElementSize } from "./observe-size.js"; import { clearLayers, syncLayers, type SquircleGeometryOptions, } from "./layers.js"; -/** Matches the quantisation the React hook already uses. */ -function roundLayoutSize(value: number): number { - return Math.round(value * 64) / 64; -} - const REREAD_EVENTS = ["pointerenter", "pointerleave", "focusin", "focusout"] as const; -/** Only the host's own transitions; these bubble up from every descendant. */ +/** Ignores transition events from descendant elements. */ const TRANSITION_EVENTS = ["transitionrun", "transitionend"] as const; /** - * Shapes an element from its own CSS and keeps it in sync. Returns a cleanup - * function that removes the layers and restores the inline style. + * Shapes an element from its CSS. The returned cleanup removes the layers and + * restores the authored inline styles. */ export function applySquircle( element: HTMLElement, @@ -35,12 +31,11 @@ export function applySquircle( } syncLayers(element, size, options); - // syncLayers writes the host's own inline style. Drop those records so - // the observer below does not schedule another sync every frame. + // Discard our own style mutations to avoid an endless update loop. mutationObserver.takeRecords(); }; - // Hover, focus, and class changes all land in the same frame. + // Coalesce hover, focus, and class changes into a single frame. const schedule = (): void => { if (disposed || frame !== 0) { return; @@ -49,12 +44,7 @@ export function applySquircle( frame = requestAnimationFrame(sync); }; - const measure = (width: number, height: number): void => { - const next = { - width: roundLayoutSize(width), - height: roundLayoutSize(height), - }; - + const measure = (next: ElementSize): void => { if (size?.width === next.width && size.height === next.height) { return; } @@ -63,35 +53,14 @@ export function applySquircle( schedule(); }; - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries.at(-1); - - if (!entry) { - return; - } - - const sizes = entry.borderBoxSize as - | ResizeObserverSize - | readonly ResizeObserverSize[]; - const box = Array.isArray(sizes) ? sizes[0] : (sizes as ResizeObserverSize); - - if (box) { - measure(box.inlineSize, box.blockSize); - return; - } - - const rect = element.getBoundingClientRect(); - measure(rect.width, rect.height); - }); - - resizeObserver.observe(element, { box: "border-box" }); - const mutationObserver = new MutationObserver(schedule); mutationObserver.observe(element, { attributes: true, attributeFilter: ["class", "style"], }); + const stopMeasuring = observeElementSize(element, measure); + const scheduleOwnTransition = (event: Event): void => { if (event.target === element) { schedule(); @@ -107,13 +76,14 @@ export function applySquircle( } return () => { + if (disposed) return; disposed = true; if (frame !== 0) { cancelAnimationFrame(frame); } - resizeObserver.disconnect(); + stopMeasuring(); mutationObserver.disconnect(); for (const type of REREAD_EVENTS) { diff --git a/src/dom/layers.ts b/src/dom/layers.ts index b334ade..016f631 100644 --- a/src/dom/layers.ts +++ b/src/dom/layers.ts @@ -24,7 +24,7 @@ const SHADOW = "data-squircle-shadow"; const SELECTOR = `[${FILL}], [${STROKE}], [${SHADOW}]`; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; -/** Everything `syncLayers` needs from the element's own CSS. */ +/** All CSS properties syncLayers needs from the host element. */ const READ_PROPERTIES = [ "border-top-left-radius", "border-top-right-radius", @@ -45,31 +45,57 @@ const READ_PROPERTIES = [ "position", ] as const; -const originalInline = new WeakMap(); +type InlineState = { authored: string; applied: string }; +const originalInline = new WeakMap(); function rememberInline(host: HTMLElement): void { if (!originalInline.has(host)) { - originalInline.set(host, host.style.cssText); + originalInline.set(host, { + authored: host.style.cssText, + applied: host.style.cssText, + }); + } +} + +/** Preserves later inline changes, including those made by reactive frameworks. */ +function reconcileInline(host: HTMLElement): InlineState | undefined { + const state = originalInline.get(host); + if (!state || state.applied === host.style.cssText) return state; + + const authored = host.ownerDocument.createElement("span").style; + const applied = host.ownerDocument.createElement("span").style; + authored.cssText = state.authored; + applied.cssText = state.applied; + const current = host.style; + const properties = new Set([...Array.from(applied), ...Array.from(current)]); + for (const property of properties) { + const value = current.getPropertyValue(property); + const priority = current.getPropertyPriority(property); + if (value !== applied.getPropertyValue(property) || priority !== applied.getPropertyPriority(property)) { + if (value) authored.setProperty(property, value, priority); + else authored.removeProperty(property); + } } + state.authored = authored.cssText; + return state; +} + +function rememberApplied(host: HTMLElement): void { + const state = originalInline.get(host); + if (state) state.applied = host.style.cssText; } /** - * Reads the styles the author wrote rather than the ones this module applied, - * by restoring the author's inline style for the duration of the read. - * - * Transitions stay off for the whole round trip. Otherwise lifting an - * override on a transitioned property starts a transition (and another one - * back on restore); its `transitionrun` schedules the next sync, which lifts - * the overrides again, and the element never settles. + * Temporarily lifts our overrides while reading authored styles. Transitions stay + * disabled so reads cannot start new transitions and endless updates. */ function readAuthoredStyles(host: HTMLElement): StyleReader { const applied = host.style.cssText; - const authored = originalInline.get(host) ?? applied; + const authored = reconcileInline(host)?.authored ?? applied; const computed = getComputedStyle(host); const snapshot = new Map(); - // This module never writes `transition` on the host, so the applied state - // already reports the authored value. + // The host transition is never overridden, so it can be read directly. snapshot.set("transition", computed.getPropertyValue("transition")); host.style.cssText = authored; @@ -83,8 +109,8 @@ function readAuthoredStyles(host: HTMLElement): StyleReader { snapshot.set(property, computed.getPropertyValue(property)); } - // Settle on the overrides while transitions are still off, so restoring the - // author's transition afterwards leaves nothing to animate. + // Apply overrides with transitions disabled, then restore the author's transition + // rule. host.style.cssText = applied; host.style.setProperty("transition", "none"); computed.getPropertyValue("transition"); @@ -96,10 +122,8 @@ function readAuthoredStyles(host: HTMLElement): StyleReader { } /** - * Absolute offsets resolve against the padding box, but the geometry is - * measured on the border box. Pulling every layer out by the border width - * lines the two up again; without it a bordered element loses the outermost - * band of its shape and the corners read as square. + * Absolute positioning uses the padding box; geometry uses the border box. The + * negative offset compensates for the border width. */ function createLayer( marker: string, @@ -120,10 +144,10 @@ export function clearLayers(host: HTMLElement): void { } } - const inline = originalInline.get(host); + const inline = reconcileInline(host); if (inline !== undefined) { - host.style.cssText = inline; + host.style.cssText = inline.authored; originalInline.delete(host); } } @@ -146,17 +170,17 @@ export function syncLayers( const shadows = parseBoxShadow(styles.getPropertyValue("box-shadow")); const geometry = { ...size, radius, smoothing, preserveSmoothing }; - // A void element has no room for layers; only the clip survives. + // Void elements receive only a clip path. if (isVoidElement(host.tagName)) { host.style.setProperty("clip-path", createSquircleClipPath(geometry)); + rememberApplied(host); return; } clearLayers(host); rememberInline(host); - // From the snapshot: a fresh read here would see the host without its - // overrides and start the very transitions readAuthoredStyles avoids. + // Use the snapshot: reading again without overrides could trigger transitions. if (styles.getPropertyValue("position") === "static") { host.style.setProperty("position", "relative"); } @@ -164,17 +188,22 @@ export function syncLayers( host.style.setProperty("isolation", "isolate"); for (const shadow of shadows) { + const width = size.width + shadow.spread * 2; + const height = size.height + shadow.spread * 2; + // A sufficiently negative spread can consume the entire shadow. + if (width <= 0 || height <= 0) continue; + const layer = createLayer(SHADOW, -3, border.width); layer.style.transform = `translate(${shadow.offsetX}px, ${shadow.offsetY}px)`; - // CSS blur() takes a standard deviation; box-shadow's blur is twice that. + // blur() uses the standard deviation; box-shadow uses twice that value. layer.style.filter = `blur(${shadow.blur / 2}px)`; const shape = document.createElement("span"); shape.style.cssText = `position:absolute;inset:${-shadow.spread}px;`; shape.style.background = shadow.color; shape.style.clipPath = createSquircleClipPath({ - width: size.width + shadow.spread * 2, - height: size.height + shadow.spread * 2, + width, + height, radius, smoothing, preserveSmoothing, @@ -184,7 +213,7 @@ export function syncLayers( host.append(layer); } - // Only suppress the original once a replacement has actually been drawn. + // Suppress the original outer shadows after processing their replacement shapes. if (shadows.length > 0) { host.style.setProperty("box-shadow", "none"); } @@ -209,8 +238,8 @@ export function syncLayers( stroke.setAttribute("focusable", "false"); stroke.setAttribute("preserveAspectRatio", "none"); stroke.setAttribute("viewBox", `0 0 ${size.width} ${size.height}`); - // Same border box correction as createLayer, expressed as a size because - // an SVG with four offsets and no size falls back to its intrinsic one. + // Express the border-box correction as an explicit size; otherwise SVG uses its + // intrinsic size. const outset = `calc(100% + ${border.width * 2}px)`; stroke.style.cssText = `position:absolute;left:${-border.width}px;top:${-border.width}px;z-index:-1;width:${outset};height:${outset};overflow:visible;pointer-events:none;display:block;`; @@ -228,11 +257,12 @@ export function syncLayers( stroke.append(path); host.append(stroke); - // Keeping the border box intact avoids a layout shift. + // A transparent border preserves dimensions and prevents layout shifts. host.style.setProperty("border-color", "transparent"); } if (options.clipContent === true) { host.style.setProperty("clip-path", createSquircleClipPath(geometry)); } + rememberApplied(host); } diff --git a/src/dom/observe-size.ts b/src/dom/observe-size.ts new file mode 100644 index 0000000..1aea34c --- /dev/null +++ b/src/dom/observe-size.ts @@ -0,0 +1,51 @@ +import type { ElementSize } from "../size.js"; + +function round(value: number): number { + return Math.round(value * 64) / 64; +} + +/** Reads physical dimensions, including vertical and sideways writing modes. */ +export function readBorderBox(entry: ResizeObserverEntry): ElementSize | null { + const sizes = entry.borderBoxSize as + | ResizeObserverSize + | readonly ResizeObserverSize[]; + const box = Array.isArray(sizes) ? sizes[0] : (sizes as ResizeObserverSize); + if (!box) return null; + + const mode = getComputedStyle(entry.target).writingMode; + const vertical = /^(vertical|sideways)/.test(mode); + return { + width: round(vertical ? box.blockSize : box.inlineSize), + height: round(vertical ? box.inlineSize : box.blockSize), + }; +} + +/** + * Shared DOM and React measurement, with window resize updates when no observer is + * available. + */ +export function observeElementSize( + element: Element, + update: (size: ElementSize) => void, +): () => void { + const measure = (): void => { + const rect = element.getBoundingClientRect(); + update({ width: round(rect.width), height: round(rect.height) }); + }; + + if (typeof ResizeObserver === "undefined") { + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + } + + const observer = new ResizeObserver((entries) => { + const entry = entries.at(-1); + if (!entry) return; + const box = readBorderBox(entry); + if (box) update(box); + else measure(); + }); + observer.observe(element, { box: "border-box" }); + return () => observer.disconnect(); +} diff --git a/src/dom/void-elements.ts b/src/dom/void-elements.ts index 826a4a5..c8c7b93 100644 --- a/src/dom/void-elements.ts +++ b/src/dom/void-elements.ts @@ -15,8 +15,8 @@ const VOID_ELEMENTS = new Set([ ]); /** - * Void elements cannot hold layers, so they are shaped with `clip-path` - * directly and lose border and shadow support. + * Void HTML elements cannot contain layers. Their clip path also clips their border + * and shadow. */ export function isVoidElement(tag: string): boolean { return VOID_ELEMENTS.has(tag.toLowerCase()); diff --git a/src/geometry.ts b/src/geometry.ts index 82f633a..ef0f40a 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -12,18 +12,18 @@ export type SquirclePathOptions = Readonly<{ height: number; radius?: SquircleRadius; /** - * Figma corner smoothing in the range 0..1. - * 0 is a regular rounded rectangle; 1 is fully smoothed. + * Figma corner smoothing from 0 to 1: 0 uses circular arcs; 1 uses fully smoothed + * transitions. */ smoothing?: number; /** - * Keeps the requested smoothing when a corner has little room. - * Figma-compatible budget clamping is used by default. + * Preserves smoothing when a corner runs out of room. By default, smoothing is + * constrained as in Figma. */ preserveSmoothing?: boolean; /** - * Moves the path inward on all sides and reduces the radii by the same - * amount. Use half a stroke width to keep an SVG stroke inside the box. + * Insets the path and reduces the radii by the same amount. Half a stroke width + * keeps the SVG stroke inside the box. */ inset?: number; /** Decimal places used in the generated SVG path. */ @@ -107,8 +107,8 @@ function insetRadii( } /** - * Allocates each corner a share of both adjacent sides. This is deterministic, - * symmetric, and guarantees that neighboring corner reaches never overlap. + * Allocates each corner a deterministic, symmetric share of its adjacent sides, + * preventing neighboring corners from overlapping. */ function getCornerBudgets( width: number, @@ -278,11 +278,8 @@ function topLeft(curve: CornerCurve, number: (value: number) => string) { } /** - * Creates a compact SVG path for Figma-style smoothed corners. - * - * The implementation follows Figma's published cubic Bézier construction. - * It adds strict input handling, per-corner radii, deterministic corner - * budgeting, and an inset mode intended for artifact-free SVG strokes. + * Creates a compact SVG path using Figma's cubic Bézier construction, with input + * validation, per-corner radii, and optional stroke insetting. */ export function createSquirclePath({ width, diff --git a/src/react/squircle.tsx b/src/react/squircle.tsx index 07f617e..2938399 100644 --- a/src/react/squircle.tsx +++ b/src/react/squircle.tsx @@ -23,14 +23,14 @@ type PolymorphicRef = type SquircleOwnProps = { as?: ElementType; children?: ReactNode; - /** Overrides the element's own `border-radius`. */ + /** Overrides the element's CSS border-radius. */ radius?: SquircleRadius; - /** Overrides the element's own `--squircle-smoothing`. */ + /** Overrides the element's CSS --squircle-smoothing. */ smoothing?: number; preserveSmoothing?: boolean; /** - * Additionally clips the element's children. Keep this false on controls - * whose focus outline must remain visible. + * Also clips children. Leave disabled on controls with focus outlines outside the + * shape. */ clipContent?: boolean; }; @@ -79,11 +79,10 @@ function SquircleImplementation( useEffect(() => { const element = ref.current; return element ? applySquircle(element, options) : undefined; - }, [options]); + }, [Element, options]); - // The layers are appended imperatively, so a void host stays childless. - // createElement with two arguments omits `children` entirely, which JSX - // cannot express and which a void element requires. + // Layers are managed outside React. Two createElement arguments omit children on + // void HTML elements. const hostProps = { ref, ...elementProps }; return typeof Element === "string" && isVoidElement(Element) diff --git a/src/react/use-element-size.ts b/src/react/use-element-size.ts index 8bb11c5..6fc3614 100644 --- a/src/react/use-element-size.ts +++ b/src/react/use-element-size.ts @@ -1,9 +1,5 @@ -import { - useEffect, - useLayoutEffect, - useState, - type RefObject, -} from "react"; +import { useEffect, useLayoutEffect, useState, type RefObject } from "react"; +import { observeElementSize } from "../dom/observe-size.js"; import type { ElementSize } from "../size.js"; export type { ElementSize }; @@ -11,31 +7,6 @@ export type { ElementSize }; const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; -function roundLayoutSize(value: number): number { - return Math.round(value * 64) / 64; -} - -function readBorderBox(entry: ResizeObserverEntry): ElementSize | null { - const sizes = entry.borderBoxSize as - | ResizeObserverSize - | readonly ResizeObserverSize[]; - const size = Array.isArray(sizes) - ? sizes[0] - : (sizes as ResizeObserverSize); - - if (!size) { - return null; - } - - const writingMode = getComputedStyle(entry.target).writingMode; - const vertical = writingMode.startsWith("vertical"); - - return { - width: roundLayoutSize(vertical ? size.blockSize : size.inlineSize), - height: roundLayoutSize(vertical ? size.inlineSize : size.blockSize), - }; -} - export function useElementSize( ref: RefObject, fixedSize?: ElementSize, @@ -43,57 +14,14 @@ export function useElementSize( const [size, setSize] = useState(null); useBrowserLayoutEffect(() => { - if (fixedSize) { - return; - } - - const element = ref.current; - if (!element) { - return; - } - - const update = (next: ElementSize) => { - if (next.width <= 0 || next.height <= 0) { - return; - } - + if (fixedSize || !ref.current) return; + return observeElementSize(ref.current, (next) => { setSize((current) => current?.width === next.width && current.height === next.height ? current : next, ); - }; - - if (typeof ResizeObserver === "undefined") { - const rect = element.getBoundingClientRect(); - update({ - width: roundLayoutSize(rect.width), - height: roundLayoutSize(rect.height), - }); - return; - } - - const observer = new ResizeObserver((entries) => { - const entry = entries.at(-1); - if (!entry) { - return; - } - - const borderBox = readBorderBox(entry); - if (borderBox) { - update(borderBox); - return; - } - - const rect = element.getBoundingClientRect(); - update({ - width: roundLayoutSize(rect.width), - height: roundLayoutSize(rect.height), - }); }); - - observer.observe(element, { box: "border-box" }); - return () => observer.disconnect(); }, [fixedSize?.height, fixedSize?.width, ref]); return fixedSize ?? size; diff --git a/src/react/use-squircle-clip-path.ts b/src/react/use-squircle-clip-path.ts index f7a42d9..ab7636c 100644 --- a/src/react/use-squircle-clip-path.ts +++ b/src/react/use-squircle-clip-path.ts @@ -17,7 +17,7 @@ export type SquircleClipPathResult = Readonly<{ ready: boolean; }>; -/** Gives an own component the CSS `clip-path` value for its measured box. */ +/** Returns a CSS clip path for the measured box of a custom component. */ export function useSquircleClipPath( ref: RefObject, options: UseSquircleClipPathOptions = {}, diff --git a/src/svelte/index.ts b/src/svelte/index.ts new file mode 100644 index 0000000..4cd2269 --- /dev/null +++ b/src/svelte/index.ts @@ -0,0 +1,26 @@ +import { applySquircle } from "../dom/apply-squircle.js"; +import type { SquircleGeometryOptions } from "../dom/layers.js"; + +export type { SquircleGeometryOptions }; + +/** Svelte action with CSS defaults, reactive options, and automatic cleanup. */ +export function squircle( + element: HTMLElement, + options: SquircleGeometryOptions = {}, +): { update(options?: SquircleGeometryOptions): void; destroy(): void } { + let dispose = applySquircle(element, options); + let destroyed = false; + + return { + update(next = {}) { + if (destroyed) return; + dispose(); + dispose = applySquircle(element, next); + }, + destroy() { + if (destroyed) return; + destroyed = true; + dispose(); + }, + }; +} diff --git a/test/css.test.mjs b/test/css.test.mjs index 2c7fd3a..2964405 100644 --- a/test/css.test.mjs +++ b/test/css.test.mjs @@ -41,7 +41,7 @@ test("resolves percentages against the shorter axis", () => { { width: 200, height: 100 }, ); - // 50% of 200 is 100 horizontally, 50 vertically; the circle must fit both. + // 50% of 200 is 100 horizontally and 50 vertically; the circle must fit both. assert.equal(radii.topLeft, 50); }); @@ -149,6 +149,25 @@ test("handles negative offsets", () => { ]); }); +test("preserves modern CSS color functions in shadows", () => { + for (const color of [ + "oklch(40% 0.05 270 / 0.2)", + "color(display-p3 0.2 0.3 0.4 / 0.5)", + "hsl(120 20% 30% / 0.4)", + "color-mix(in oklch, color(display-p3 0.2 0.3 0.4), rgb(0, 0, 0))", + ]) { + assert.deepEqual(parseBoxShadow(`${color} 1px -2px 8px 0px`), [ + { color, offsetX: 1, offsetY: -2, blur: 8, spread: 0 }, + ]); + } +}); + +test("reads shadows with trailing colors or without a color", () => { + assert.equal(parseBoxShadow("1px 2px 4px oklch(40% 0 0)")[0].color, "oklch(40% 0 0)"); + assert.equal(parseBoxShadow("1px 2px")[0].color, "currentColor"); + assert.deepEqual(parseBoxShadow("unreadable"), []); +}); + test("reads a solid border", () => { assert.deepEqual( readBorderFromComputed( diff --git a/test/react.test.mjs b/test/react.test.mjs index 634905d..8ec3fb9 100644 --- a/test/react.test.mjs +++ b/test/react.test.mjs @@ -24,7 +24,7 @@ test("does not crash on a void element", () => { createElement(Squircle, { as: "img", src: "/a.jpg", alt: "A picture" }), ); - // React 19 also emits a preload link for images; only the tag itself matters. + // React 19 also emits an image preload link; only the image element matters here. assert.match(html, /A picture/); assert.doesNotMatch(html, /]*>[^<]/); }); diff --git a/test/size.test.mjs b/test/size.test.mjs new file mode 100644 index 0000000..3cbf7f8 --- /dev/null +++ b/test/size.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readBorderBox } from "../dist/dom/observe-size.js"; + +test("handles older observers with a single size object", () => { + const original = globalThis.getComputedStyle; + globalThis.getComputedStyle = (element) => ({ writingMode: element.mode }); + try { + for (const mode of ["horizontal-tb", "vertical-rl", "sideways-lr"]) { + const vertical = mode !== "horizontal-tb"; + const box = { inlineSize: vertical ? 48 : 120, blockSize: vertical ? 120 : 48 }; + for (const borderBoxSize of [box, [box]]) { + assert.deepEqual(readBorderBox({ target: { mode }, borderBoxSize }), { + width: 120, + height: 48, + }); + } + } + } finally { + if (original === undefined) delete globalThis.getComputedStyle; + else globalThis.getComputedStyle = original; + } +}); + +test("missing border-box data allows fallback measurement", () => { + assert.equal(readBorderBox({ borderBoxSize: undefined }), null); + assert.equal(readBorderBox({ borderBoxSize: [] }), null); +}); diff --git a/tsconfig.json b/tsconfig.json index 2621c7c..ce76383 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "jsx": "react-jsx", + "types": ["node"], "strict": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, @@ -17,6 +18,9 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", - "type-tests/**/*.tsx" + "type-tests/**/*.tsx", + "type-tests/**/*.ts", + "examples/react/**/*.tsx", + "playwright.config.ts" ] } diff --git a/type-tests/svelte-api.ts b/type-tests/svelte-api.ts new file mode 100644 index 0000000..85181b8 --- /dev/null +++ b/type-tests/svelte-api.ts @@ -0,0 +1,9 @@ +import type { Action } from "svelte/action"; +import type { Attachment } from "svelte/attachments"; +import { squircle, type SquircleGeometryOptions } from "../src/svelte/index.js"; +import { applySquircle } from "../src/dom/index.js"; + +const action: Action = squircle; +const attachment: Attachment = (element) => applySquircle(element); +void action; +void attachment;