From f12dc9bf623196489953cbb58ae305c09026b8a0 Mon Sep 17 00:00:00 2001
From: eiserv69 <156132387+eiserv69@users.noreply.github.com>
Date: Sat, 7 Nov 2026 21:05:23 +0100
Subject: [PATCH 1/2] feat: Spielwiese vereinfachen und Framework-Anbindungen
robuster machen
---
.github/workflows/ci.yml | 15 +-
.github/workflows/publish.yml | 33 +-
.gitignore | 3 +
README.md | 274 +++-------
browser-tests/playground.spec.mjs | 208 ++++++++
docs/architecture.md | 241 ++++-----
docs/integration.md | 150 ++++++
docs/media/playground.png | Bin 93048 -> 109482 bytes
docs/media/smoothing-extremes.png | Bin 7089 -> 50767 bytes
examples/demo.css | 83 +++
examples/formen.svg | 5 +
examples/playground.html | 795 +++++-----------------------
examples/react/index.html | 156 +-----
examples/react/main.tsx | 84 ++-
examples/svelte/Beispiele.svelte | 42 ++
examples/svelte/index.html | 13 +
examples/svelte/main.js | 4 +
package-lock.json | 250 +++++++++
package.json | 17 +-
playwright.config.ts | 13 +
scripts/build-examples.mjs | 22 +
scripts/create-visual-fixture.mjs | 34 +-
scripts/screenshots.mjs | 54 ++
scripts/serve-playground.mjs | 23 +-
src/clip-path.ts | 4 +-
src/css/paint.ts | 4 +-
src/css/radius.ts | 4 +-
src/css/shadow.ts | 25 +-
src/css/types.ts | 4 +-
src/dom/apply-squircle.ts | 52 +-
src/dom/layers.ts | 93 ++--
src/dom/observe-size.ts | 48 ++
src/dom/void-elements.ts | 4 +-
src/geometry.ts | 31 +-
src/react/squircle.tsx | 15 +-
src/react/use-element-size.ts | 80 +--
src/react/use-squircle-clip-path.ts | 2 +-
src/svelte/index.ts | 26 +
test/clip-path.test.mjs | 6 +-
test/css.test.mjs | 63 ++-
test/geometry.test.mjs | 22 +-
test/react.test.mjs | 18 +-
test/size.test.mjs | 28 +
tsconfig.json | 6 +-
type-tests/react-api.tsx | 10 +-
type-tests/svelte-api.ts | 9 +
46 files changed, 1555 insertions(+), 1518 deletions(-)
create mode 100644 browser-tests/playground.spec.mjs
create mode 100644 docs/integration.md
create mode 100644 examples/demo.css
create mode 100644 examples/formen.svg
create mode 100644 examples/svelte/Beispiele.svelte
create mode 100644 examples/svelte/index.html
create mode 100644 examples/svelte/main.js
create mode 100644 playwright.config.ts
create mode 100644 scripts/screenshots.mjs
create mode 100644 src/dom/observe-size.ts
create mode 100644 src/svelte/index.ts
create mode 100644 test/size.test.mjs
create mode 100644 type-tests/svelte-api.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17fd523..eee3d89 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.
+ # Typprüfung, Erstellung, Tests und Prüfung des npm-Pakets.
- run: npm run check
+
+ browser:
+ name: Browsertests
+ 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/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 55410d1..7a17bf6 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,14 +1,11 @@
-name: Release
+name: Veröffentlichung
-# Every push to main updates a release PR maintained by release-please. Merging
-# that PR bumps package.json, writes CHANGELOG.md, tags vX.Y.Z, creates the
-# GitHub Release, and publishes the package below.
-#
-# A release published by hand also publishes. Releases that release-please
-# creates with GITHUB_TOKEN do not trigger the `release` event, so nothing is
-# published twice.
-#
-# Keep this file name: npm trusted publishing is bound to it.
+# Jeder Push auf main aktualisiert die Versions-PR von release-please.
+# Ihr Merge aktualisiert Version und Änderungsverlauf, erstellt Tag und
+# GitHub-Veröffentlichung und veröffentlicht anschließend das npm-Paket.
+# Manuell veröffentlichte Releases starten ebenfalls die Veröffentlichung.
+# Mit GITHUB_TOKEN erstellte Releases lösen kein weiteres release-Ereignis aus.
+# Dateinamen beibehalten: Die vertrauenswürdige npm-Veröffentlichung ist daran gebunden.
on:
push:
branches: [main]
@@ -17,7 +14,7 @@ on:
workflow_dispatch:
inputs:
dry-run:
- description: "Build and pack, but do not publish"
+ description: "Erstellen und packen, ohne Veröffentlichung"
type: boolean
default: true
@@ -48,7 +45,7 @@ jobs:
publish:
needs: release-please
- # release-please is skipped for release and dispatch events; run anyway.
+ # Auch bei Release- und manuellen Ereignissen ohne release-please ausführen.
if: >-
!cancelled() && (
needs.release-please.outputs.release_created == 'true' ||
@@ -58,7 +55,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
- # Required for npm provenance and for trusted publishing.
+ # Für npm-Herkunftsnachweise und die vertrauenswürdige Veröffentlichung nötig.
id-token: write
steps:
@@ -69,27 +66,27 @@ jobs:
node-version: 22
registry-url: https://registry.npmjs.org
- # npm 11.5.1+ is needed for trusted publishing.
+ # Die vertrauenswürdige Veröffentlichung benötigt npm ab 11.5.1.
- run: npm install -g npm@latest
- run: npm ci
- - name: Verify the tag matches package.json
+ - name: Tag mit package.json abgleichen
if: github.event_name != 'workflow_dispatch'
env:
TAG: ${{ needs.release-please.outputs.tag_name || github.ref_name }}
run: |
version="$(node -p 'require("./package.json").version')"
if [ "${TAG#v}" != "$version" ]; then
- echo "Tag $TAG does not match package.json version $version" >&2
+ echo "Tag $TAG stimmt nicht mit Paketversion $version überein" >&2
exit 1
fi
- run: npm run check
- - name: Publish
+ - name: Veröffentlichen
if: github.event_name != 'workflow_dispatch' || !inputs.dry-run
run: npm publish --provenance --access public
env:
- # Ignored once trusted publishing is configured on npmjs.com.
+ # Wird nach Einrichtung der vertrauenswürdigen Veröffentlichung auf npmjs.com ignoriert.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
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..7ecd7c2 100644
--- a/README.md
+++ b/README.md
@@ -1,256 +1,114 @@
# squircles
-Small Figma-style squircles for TypeScript and React.
+Eckenglättung nach Figma für das Web. Hintergrund, Kontur, Schatten und Hover
+bleiben in deinem CSS. Ohne zusätzliche Hülle, mit optionaler React- und
+Svelte-Anbindung und ohne Laufzeitabhängigkeiten im Geometrie- und DOM-Paket.
-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.
+
+
+
-- 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
-```
-
-
-
-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:
-
-
-
-## 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.
-
-## Install
+## Installation
```sh
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
-
- );
-}
-```
+## Ein CSS, mehrere Wege
```css
-.button {
- background: linear-gradient(#243a8f, #192d73);
- border: 1px solid #e5e7eb;
+.taste {
+ 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** (ab Version 18):
```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);
-}
+
+ Mehr erfahren
+
```
-The shadow follows the smoothed contour rather than the `border-radius` one.
+**Svelte** als [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 oder andere Frameworks** über den DOM-Einstieg:
```ts
import { applySquircle } from "squircles/dom";
-const dispose = applySquircle(document.querySelector(".card"));
+const element = document.querySelector(".taste");
+const aufraeumen = element ? applySquircle(element) : () => {};
+// Beim Entfernen des Elements aufrufen.
+aufraeumen();
```
-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
+Die Anbindungen lesen `border-radius` und `--squircle-smoothing` aus dem CSS.
+Klassen-, Stil- und Größenänderungen sowie Hover und Fokus aktualisieren die Form.
+Beim serverseitigen Rendern bleibt zunächst der normale CSS-Radius sichtbar.
-`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.
-
-## Geometry only
-
-The root entry point does not import React or touch the DOM:
+## Nur die Geometrie
```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 optionen = { width: 200, height: 96, radius: 24, smoothing: 1 };
+const pfad = createSquirclePath(optionen);
+const clipPath = createSquircleClipPath(optionen);
```
-`radius` accepts a number or individual corners:
-
-```ts
-radius={{
- topLeft: 24,
- topRight: 12,
- bottomRight: 24,
- bottomLeft: 12,
-}}
-```
+`radius` unterstützt einzelne Ecken; `smoothing` reicht von `0` bis `1`.
+Bei wenig Platz wird die Glättung wie in Figma begrenzt.
+`preserveSmoothing: true` erhält sie durch komprimierte Kontrollpunkte.
+`inset` versetzt den Pfad nach innen, etwa um eine halbe SVG-Konturbreite.
-## What is not supported
+
+
+
-These are deliberate limits, not bugs:
+## Grenzen und weitere Beispiele
-- **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.
+- Durchgezogene, einheitliche Konturen; keine unterschiedlichen Ränder pro Seite.
+- Fokusmarkierungen und innere Schatten folgen weiterhin `border-radius`.
+- Bilder und Eingaben erhalten einen Clip-Pfad, der auch ihre Kontur und ihren
+ Schatten beschneidet. Bei Bedarf ein umgebendes Element verwenden.
+- Für halbtransparente Konturen gibt es `SquircleSurface` unter `squircles/react`.
+- Ohne `ResizeObserver` wird beim Start und bei Fenstergrößenänderungen gemessen.
+ Für sonstige Layoutänderungen ist ein Polyfill nötig.
-## Migrating from 0.1
+[Integration, Optionen und Migration](docs/integration.md) ·
+[Architektur und Grenzen](docs/architecture.md)
-`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.
-
-## Development
+## Entwicklung
```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
+Spielwiese: [localhost:5173](http://localhost:5173/), React:
+[localhost:5173/react](http://localhost:5173/react/).
+Svelte-Beispiele: [localhost:5173/svelte](http://localhost:5173/svelte/).
+`npm run screenshots` erstellt die README-Bilder mit transparentem Schatten neu.
+`npm run visual:fixture` erzeugt weitere Geometriebeispiele 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.
+Commits auf `main` verwenden [Conventional Commits](https://www.conventionalcommits.org/).
+release-please erstellt die Versions-PR; ihr Merge veröffentlicht das npm-Paket.
diff --git a/browser-tests/playground.spec.mjs b/browser-tests/playground.spec.mjs
new file mode 100644
index 0000000..10da6f0
--- /dev/null
+++ b/browser-tests/playground.spec.mjs
@@ -0,0 +1,208 @@
+import { test, expect } from "@playwright/test";
+
+test("Spielwiese passt auf kleine Bildschirme und aktualisiert die Kontur", async ({ page }) => {
+ const fehler = [];
+ page.on("pageerror", (error) => fehler.push(error.message));
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.goto("/");
+ const pfad = page.locator("#squircle path");
+ await expect(pfad).toHaveAttribute("d", /^M /);
+ const vorher = await pfad.getAttribute("d");
+ await page.locator("#radius").fill("0");
+ await expect(pfad).not.toHaveAttribute("d", vorher);
+ await expect(page.locator("#hinweis")).toHaveText("Ohne Radius sind beide Formen identisch.");
+ await page.locator("#breite").fill("420");
+ await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ expect(fehler).toEqual([]);
+});
+
+test("DOM-Messung berücksichtigt vertikale und seitliche Schreibrichtungen", async ({ page }) => {
+ await page.goto("/");
+ for (const writingMode of ["horizontal-tb", "vertical-rl", "sideways-rl"]) {
+ await page.evaluate(async (mode) => {
+ document.getElementById("messung")?.remove();
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("div");
+ element.id = "messung";
+ 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("#messung [data-squircle-stroke]")).toHaveAttribute("viewBox", "0 0 160 80");
+ }
+});
+
+test("Inline-Änderungen bleiben beim Neuzeichnen und Aufräumen erhalten", async ({ page }) => {
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("button");
+ element.id = "dynamisch";
+ element.style.cssText = "width:180px;height:64px;border-radius:16px;background:rgb(10, 10, 10);padding:8px";
+ document.body.append(element);
+ window.aufraeumen = applySquircle(element);
+ });
+ const element = page.locator("#dynamisch");
+ 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.aufraeumen(); window.aufraeumen(); });
+ 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("ohne ResizeObserver funktionieren Start, Fensteränderung und Aufräumen", 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 = "ersatz";
+ element.style.cssText = "width:160px;height:80px;border:1px solid black;border-radius:16px;box-sizing:border-box";
+ document.body.append(element);
+ window.aufraeumen = applySquircle(element);
+ });
+ await expect(page.locator("#ersatz svg")).toHaveAttribute("viewBox", "0 0 160 80");
+ await page.evaluate(() => {
+ document.getElementById("ersatz").style.width = "200px";
+ window.dispatchEvent(new Event("resize"));
+ });
+ await expect(page.locator("#ersatz svg")).toHaveAttribute("viewBox", "0 0 200 80");
+ await page.evaluate(() => window.aufraeumen());
+ await expect(page.locator("#ersatz svg")).toHaveCount(0);
+});
+
+test("React folgt dem Wechsel des nativen Elements", async ({ page }) => {
+ await page.goto("/react/");
+ const element = page.locator("[data-wechsel]");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(1);
+ await page.getByRole("button", { name: "Element wechseln" }).click();
+ await expect(element).toHaveJSProperty("tagName", "A");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(1);
+ await page.getByRole("button", { name: "Element wechseln" }).click();
+ await expect(element).toHaveJSProperty("tagName", "BUTTON");
+ await expect(element.locator("[data-squircle-fill]")).toHaveCount(1);
+});
+
+test("Svelte-Action aktualisiert Optionen und räumt mehrfach sicher auf", 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 kontur = page.locator("#action path");
+ await expect(kontur).toHaveAttribute("d", /^M /);
+ const vorher = await kontur.getAttribute("d");
+ await page.evaluate(() => window.action.update({ radius: 0 }));
+ await expect(kontur).not.toHaveAttribute("d", vorher);
+ await page.evaluate(() => window.action.update());
+ await expect(kontur).toHaveAttribute("d", vorher);
+ 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("Darstellung erzeugt im Ruhezustand keine weiteren DOM-Änderungen", async ({ page }) => {
+ await page.goto("/react/");
+ await expect(page.locator("[data-wechsel] [data-squircle-fill]")).toHaveCount(1);
+ const anzahl = 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-wechsel]"), { attributes: true, childList: true, subtree: true });
+ for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame);
+ observer.disconnect();
+ return count;
+ });
+ expect(anzahl).toBe(0);
+});
+
+test("Svelte rendert Action und Attachment mit reaktiven Änderungen", async ({ page }) => {
+ const fehler = [];
+ page.on("pageerror", (error) => fehler.push(error.message));
+ await page.goto("/svelte/");
+ const kontur = page.locator("[data-action] path");
+ await expect(kontur).toHaveAttribute("d", /^M /);
+ await expect(page.locator("[data-attachment] [data-squircle-fill]")).toHaveCount(1);
+ const vorher = await kontur.getAttribute("d");
+ await page.getByRole("slider", { name: "Glättung" }).fill("0");
+ await expect(kontur).not.toHaveAttribute("d", vorher);
+ await page.getByRole("button", { name: "Beispiel entfernen" }).click();
+ await expect(page.locator("[data-action]")).toHaveCount(0);
+ await page.getByRole("button", { name: "Beispiel anzeigen" }).click();
+ await expect(kontur).toHaveAttribute("d", /^M /);
+ expect(fehler).toEqual([]);
+});
+
+test("entfernte Inline-Stile und Prioritäten bleiben nach dem Aufräumen korrekt", 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.aufraeumen = 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.aufraeumen());
+ 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("vollständig geschrumpfte Schatten verhindern die Darstellung nicht", async ({ page }) => {
+ const fehler = [];
+ page.on("pageerror", (error) => fehler.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 = "schatten";
+ 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("#schatten [data-squircle-fill]")).toHaveCount(1);
+ await expect(page.locator("#schatten [data-squircle-shadow]")).toHaveCount(0);
+ expect(fehler).toEqual([]);
+});
+
+test("ein komplett ersetztes style-Attribut bleibt nach dem Aufräumen erhalten", async ({ page }) => {
+ await page.goto("/");
+ await page.evaluate(async () => {
+ const { applySquircle } = await import("/dist/dom/index.js");
+ const element = document.createElement("div");
+ element.id = "ersetzte-stile";
+ element.style.cssText = "width:160px;height:80px;background:black;border:1px solid black;border-radius:16px;padding:4px";
+ document.body.append(element);
+ window.aufraeumen = applySquircle(element);
+ });
+ const element = page.locator("#ersetzte-stile");
+ 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.aufraeumen());
+ 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..a907571 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,140 +1,101 @@
-# 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.
+# Architektur
+
+## Paketgrenzen
+
+- `squircles`: reine Geometrie ohne DOM-Zugriffe und Laufzeitabhängigkeiten.
+- `squircles/dom`: CSS-basierte Darstellung mit `applySquircle`.
+- `squircles/react`: Komponenten und Hooks, React als optionale Peer-Abhängigkeit.
+- `squircles/svelte`: Action mit Aktualisierung und Aufräumen, ohne Svelte-Import.
+- `src/css/`: strukturelle Leser für berechnete CSS-Werte, in Node testbar.
+
+Die englischen Namen der veröffentlichten API und der CSS-Eigenschaften bleiben
+zur Kompatibilität bestehen. Kommentare, Beispiele und Fehlermeldungen sind deutsch.
+
+## Geometrie und Kontur
+
+Die Geometrie folgt Figmas veröffentlichter kubischer Bézier-Konstruktion:
+`smoothing: 0` ergibt Kreisbögen, `1` vollständig geglättete Übergänge.
+Jede Ecke erhält einen proportionalen Anteil der benachbarten Seiten.
+Reicht der Platz nicht aus, wird die Glättung begrenzt. `preserveSmoothing`
+komprimiert stattdessen die ersten Kontrollpunkte.
+
+Eine Kontur entsteht als SVG-Stroke auf dem um eine halbe Konturbreite
+versetzten Pfad. Eine separat berechnete innere Maske entfällt: Das bloße
+Verkleinern eines Eckradius ergäbe keinen exakten parallelen Bézier-Versatz.
+Quellen und Danksagungen stehen in `NOTICE`.
+
+## Darstellungsebenen
+
+`Squircle` rendert nur sein natives Element. Ein Effekt bindet `applySquircle`
+an dieses Element. Die Darstellungsebenen liegen außerhalb von Reacts Abgleich.
+Ein Wechsel von `as` entfernt die alte Anbindung und misst das neue Element.
+
+| Ebene | Stapelposition | Aufgabe |
+| --- | --- | --- |
+| `[data-squircle-shadow]` | -3 | Versetzte, weichgezeichnete Form pro äußerem Schatten |
+| `[data-squircle-fill]` | -2 | Kopierter Hintergrund mit geglättetem Clip-Pfad |
+| `[data-squircle-stroke]` | -1 | SVG-Kontur auf dem geglätteten Pfad |
+
+Alle Ebenen ignorieren Zeigereingaben. Ihr negativer Versatz um die Konturbreite
+gleicht den Unterschied zwischen Innen- und Rahmenfläche aus. Die originale
+Kontur wird transparent, behält aber ihre Breite, damit sich das Layout nicht
+verschiebt. Das Element selbst bleibt standardmäßig unbeschnitten, sodass
+Schatten und Fokusmarkierungen sichtbar bleiben.
+
+Die SVG-Kontur überdeckt den Rand der Hintergrundfläche. Bei halbtransparenten
+Konturen kann dieser durchscheinen; `SquircleSurface` zeichnet Füllung und Kontur
+gemeinsam auf einem SVG-Pfad und vermeidet diesen Fall.
+
+## CSS lesen und wiederherstellen
+
+Zum Lesen werden eigene Überschreibungen kurz aufgehoben. Übergänge bleiben
+währenddessen deaktiviert, damit das Lesen keine Übergänge oder dauernde
+Neuberechnungen auslöst. Änderungen werden pro Animationsframe gebündelt;
+eigene Mutationseinträge werden anschließend verworfen.
+
+Pro Element werden die ursprünglichen und die zuletzt angewendeten Inline-Stile
+gespeichert. Abweichungen gegenüber dem angewendeten Zustand werden in die
+ursprünglichen Stile übernommen. Dadurch bleiben nachträgliche Änderungen durch
+Frameworks beim Neuzeichnen und Aufräumen erhalten. Eine Zuweisung genau des
+bereits von der Bibliothek gesetzten Werts ist davon nicht unterscheidbar.
+
+Eine Anbindung pro Element ist vorgesehen. Aufräumen ist idempotent und entfernt
+Beobachter, Ereignisbehandlung, ausstehende Frames und eigene Ebenen.
+
+## Größenmessung
+
+DOM und React verwenden dieselbe Messung. `ResizeObserver` liefert logische
+Inline- und Blockmaße, die anhand von `writing-mode` in physische Breite und Höhe
+umgerechnet werden. Das umfasst `vertical-*` und `sideways-*`.
+Auf 1/64 Pixel gerundete Maße vermeiden Änderungen durch minimale Messabweichungen.
+
+Fehlt `ResizeObserver`, werden die Abmessungen beim Start und bei
+Fenstergrößenänderungen gemessen. Sonstige Layoutänderungen benötigen dann einen
+Polyfill. Feste Größen umgehen die Messung in den React-Hooks vollständig.
+Flächen mit Breite oder Höhe null ergeben keinen gültigen sichtbaren Pfad.
+
+## Grenzen
+
+- `outline` und innere Schatten folgen weiterhin der normalen CSS-Rundung.
+- Nur durchgezogene Konturen anhand der oberen Rahmenwerte werden gezeichnet.
+- Elliptische Radien werden auf den einbeschriebenen Kreis reduziert.
+- Leere HTML-Elemente wie `img` und `input` erhalten direkt einen Clip-Pfad.
+ Dieser beschneidet auch ihre eigene Kontur und ihren Schatten.
+- `backdrop-filter` bleibt ohne `clipContent` rechteckig.
+- Schattenlisten werden als einzelne Ebenen zusammengesetzt. Innere Schatten
+ werden nicht nachgezeichnet; in gemischten Listen verschwinden sie beim
+ Unterdrücken des ursprünglichen Schattens.
+- Änderungen ausschließlich an Vorfahren oder Stylesheets benötigen ein
+ beobachtetes Ereignis am Element, etwa eine Größen- oder Klassenänderung.
+- Hintergrundübergänge werden weitergereicht; Geometrie- und Konturänderungen
+ werden zum Zeitpunkt der Aktualisierung neu gezeichnet.
+- Bruchteile physischer Pixel können weiterhin leicht unterschiedliche
+ Rasterisierung an gegenüberliegenden Kanten ergeben.
+
+## Prüfung und Bilder
+
+`npm run check` prüft Typen, Geometrie, CSS-Leser, serverseitiges React-Rendering
+und den Paketinhalt. `npm run test:browser` prüft die DOM-Lebenszyklen,
+Stiländerungen, Schreibrichtungen und den Playground in Chromium.
+`npm run screenshots` fotografiert den Playground und rendert den Schatten in
+einen transparenten PNG-Rand. Die Bildbreiten im README sind explizit begrenzt.
diff --git a/docs/integration.md b/docs/integration.md
new file mode 100644
index 0000000..cf5e1d9
--- /dev/null
+++ b/docs/integration.md
@@ -0,0 +1,150 @@
+# Integration
+
+## CSS als Ausgangspunkt
+
+Die DOM-, React- und Svelte-Anbindungen verwenden dieselbe Darstellung.
+`border-radius` und die folgenden CSS-Variablen bestimmen die Form:
+
+```css
+.karte {
+ 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%);
+}
+```
+
+Prozentuale Radien werden pro Achse aufgelöst. Bei elliptischen CSS-Ecken wird
+der einbeschriebene Kreis verwendet. Die obere Kontur bestimmt Breite und Farbe
+aller Seiten. Äußere Schatten unterstützen auch `oklch()` und `color()`.
+Innere Schatten lassen sich nicht mit äußeren Schatten kombinieren; bei einer
+gemischten Liste unterdrückt die Ersatzdarstellung den ursprünglichen Schatten.
+
+Die optionalen Werte `radius`, `smoothing`, `preserveSmoothing` und `clipContent`
+überschreiben das CSS. `clipContent` beschneidet auch Kinder und außenliegende
+Fokusmarkierungen; auf Bedienelementen sollte es deshalb deaktiviert bleiben.
+
+Änderungen der eigenen `class`- und `style`-Attribute, der Größe, des Hover- und
+Fokuszustands sowie Beginn und Ende eigener CSS-Übergänge lösen eine Aktualisierung
+aus. Änderungen ausschließlich an Vorfahren, Stylesheets oder Medienabfragen
+werden ohne eines dieser Ereignisse nicht automatisch erkannt.
+
+## React und Next.js
+
+```tsx
+import { Squircle } from "squircles/react";
+
+export function Karte() {
+ return (
+
+
+
Alle Eigenschaften des nativen Elements bleiben erhalten.
+
+ );
+}
+```
+
+`as` akzeptiert auch eigene Komponenten, die ihre Ref an ein HTML-Element
+weiterreichen, etwa Next.js `Link`. Im App Router benötigt die aufrufende Datei
+`"use client"`. Die Geometriefunktionen können auch auf dem Server laufen.
+`Squircle` rendert dort zunächst nur das native Element; CSS liefert die Rundung.
+
+`useSquirclePath` und `useSquircleClipPath` messen eine eigene Ref. Mit der Option
+`size: { width, height }` funktionieren sie ohne Größenbeobachtung und bereits
+beim serverseitigen Rendern. Leere Flächen ergeben einen leeren Pfad bzw. `none`.
+
+### Bestehende Komponenten und halbtransparente Konturen
+
+`SquircleSurface` zeichnet Füllung und Kontur gemeinsam als SVG-Pfad:
+
+```tsx
+import { SquircleSurface } from "squircles/react";
+
+
+```
+
+Das Elternelement benötigt `position: relative`, transparente eigene Flächen
+und Konturen sowie Text oberhalb des SVG, etwa über `position: relative` am `span`.
+`width` und `height` ermöglichen auch hier eine feste Größe ohne Messung.
+
+## Svelte
+
+Die Action verwendet den [Lebenszyklus mit `update` und `destroy`](https://svelte.dev/docs/svelte-action).
+Sie importiert keine Svelte-Laufzeit und eignet sich für Svelte 4 und 5.
+
+```svelte
+
+
+
+
+```
+
+Neue Optionen ersetzen die bisherigen. Das Entfernen des Elements räumt
+Beobachter und Darstellung auf. Für Svelte ab 5.29 lässt sich die DOM-Funktion
+auch direkt als [Attachment](https://svelte.dev/docs/svelte/@attach) verwenden:
+
+```svelte
+
+
+
+```
+
+## Vue und andere Frameworks
+
+`applySquircle` benötigt ein gemountetes HTML-Element und liefert eine
+Aufräumfunktion. Beispiel für Vue 3:
+
+```vue
+
+
+
+
+
+```
+
+CSS-Änderungen am Element werden automatisch übernommen. Für neue
+Geometrieoptionen die bisherige Anbindung aufräumen und erneut anwenden.
+Die Aufräumfunktion darf mehrfach aufgerufen werden. Nachträgliche
+Inline-Stiländerungen bleiben beim Neuzeichnen und Aufräumen erhalten.
+Pro Element darf nur eine Anbindung gleichzeitig aktiv sein.
+
+## Migration von 0.1
+
+Die Gestaltung von `Squircle` erfolgt seit 0.2 im CSS:
+
+| Frühere Eigenschaft | CSS oder Ersatz |
+| --- | --- |
+| `fill` | `background` |
+| `stroke` | `border-color` |
+| `strokeWidth` | `border-width` und `border-style: solid` |
+| `radius` | `border-radius` oder weiterhin die Option |
+| `smoothing` | `--squircle-smoothing` oder weiterhin die Option |
+| `surfaceClassName`, `surfaceStyle` | `[data-squircle-fill]` und `[data-squircle-stroke]` |
+
+`SquircleSurface` unterstützt weiterhin die SVG-Gestaltungseigenschaften.
diff --git a/docs/media/playground.png b/docs/media/playground.png
index cb693f1ab6a1d3ff4d64b80a88239365116deacc..cc001c5c513a4d3e4439a4c25efe0d60af62005e 100644
GIT binary patch
literal 109482
zcmeEuWn5L;*X|+|6)9xapA}P6*l5UVz5J4&F4g;h?q@+cmS2&A&3x?7JsDX6t^;t8?8R;&T%|Re;>ca3)3G{n&Lg}2kh4{+>pmL{c$ld
z)8ijf!ZN_=aMvzv&WVz*4e;K4c^^wmXvatpW8og>8iXUX{RDEBuG$?dv=c(@*k4!
zs|$9b_mS1-H<9pu^~FzPsGB}0M;i?IV^a7@NCQiEJ8Bv9G*~}>GO4(AQ0eV`al!8H
z@jZcG^_i2bXLrMT1;hv+Q6%FnGFeg6pw<`m&%5cW6A*}_zh0PVm$II?cf7xSbUxt=
zx+8)a^j8W)>Sm38l8rw;{TFCix3%Xlh{om7zbJ@l4<>nr{Z{32Ozv}y%fRWcD!ek#pzXpl!o+og@3iG<=Q|GIXS8<+|BSDDk7q`S@+{_50x^HqNqxj=U2FXT5(<*vkFoaU_yA
z8FkWuMU=!uA&?=+exTITRwO*==vOxDh0j6?3zx!}9VU%eaqM-W#rytov?=
zNfU<@AAaF7iF7~SP86*yDY282mj3;5Iqlmr89`3$iG7
zmeNGc$0{}#=}r#diV6url@9Z~qx+`IX7aE{SCS+AA41)cVwxo{s>2YgR-Q+rkLV7Y
zr8<@ea!0?|6TtiBad6HRd$@qATHI`p8JVf1_xS!*#}hg7s^ymB$t5M+I_0VsdZO#O0c3134;@y9${pB8C+R;BisO