From 6fc80d6959b0a2b07deccd19c04721a3d237ac56 Mon Sep 17 00:00:00 2001 From: a-dev Date: Sun, 2 Aug 2026 21:03:29 +0400 Subject: [PATCH 1/7] [WIP] add corner-shape generator draft --- .agents/skills/ponytail/SKILL.md | 121 +++++++ .claude/launch.json | 9 + .claude/settings.json | 2 +- .claude/skills/ponytail | 1 + CLAUDE.md | 1 + astro.config.mjs | 14 + bun.lock | 20 ++ package.json | 3 + skills-lock.json | 11 + .../corner-shape/_components/baseline.astro | 23 ++ .../corner-shape/_components/baseline.css | 29 ++ .../_components/card-properties.astro | 38 +++ .../_components/card-properties.css | 19 ++ .../_components/corner-fields.astro | 76 +++++ .../corner-shape/_components/generator.astro | 26 ++ .../corner-shape/_components/generator.css | 24 ++ src/pages/corner-shape/_components/head.astro | 69 ++++ src/pages/corner-shape/_components/head.css | 95 ++++++ .../_components/output-section.astro | 41 +++ .../_components/output-section.css | 99 ++++++ .../_components/preset-chips.astro | 38 +++ .../_components/shape-settings.astro | 56 ++++ .../_components/shape-settings.css | 66 ++++ .../_components/slider-field.astro | 63 ++++ .../corner-shape/_components/stage.astro | 61 ++++ src/pages/corner-shape/_components/stage.css | 127 ++++++++ .../_components/support-warning.astro | 12 + .../_components/support-warning.css | 15 + src/pages/corner-shape/_lib/controls.ts | 74 +++++ src/pages/corner-shape/_lib/emit.test.ts | 65 ++++ src/pages/corner-shape/_lib/emit.ts | 161 +++++++++ src/pages/corner-shape/_lib/generator.ts | 196 +++++++++++ .../corner-shape/_lib/superellipse.test.ts | 74 +++++ src/pages/corner-shape/_lib/superellipse.ts | 245 ++++++++++++++ src/pages/corner-shape/corner-shape.css | 307 ++++++++++++++++++ src/pages/corner-shape/index.astro | 39 +++ 36 files changed, 2319 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/ponytail/SKILL.md create mode 100644 .claude/launch.json create mode 120000 .claude/skills/ponytail create mode 100644 skills-lock.json create mode 100644 src/pages/corner-shape/_components/baseline.astro create mode 100644 src/pages/corner-shape/_components/baseline.css create mode 100644 src/pages/corner-shape/_components/card-properties.astro create mode 100644 src/pages/corner-shape/_components/card-properties.css create mode 100644 src/pages/corner-shape/_components/corner-fields.astro create mode 100644 src/pages/corner-shape/_components/generator.astro create mode 100644 src/pages/corner-shape/_components/generator.css create mode 100644 src/pages/corner-shape/_components/head.astro create mode 100644 src/pages/corner-shape/_components/head.css create mode 100644 src/pages/corner-shape/_components/output-section.astro create mode 100644 src/pages/corner-shape/_components/output-section.css create mode 100644 src/pages/corner-shape/_components/preset-chips.astro create mode 100644 src/pages/corner-shape/_components/shape-settings.astro create mode 100644 src/pages/corner-shape/_components/shape-settings.css create mode 100644 src/pages/corner-shape/_components/slider-field.astro create mode 100644 src/pages/corner-shape/_components/stage.astro create mode 100644 src/pages/corner-shape/_components/stage.css create mode 100644 src/pages/corner-shape/_components/support-warning.astro create mode 100644 src/pages/corner-shape/_components/support-warning.css create mode 100644 src/pages/corner-shape/_lib/controls.ts create mode 100644 src/pages/corner-shape/_lib/emit.test.ts create mode 100644 src/pages/corner-shape/_lib/emit.ts create mode 100644 src/pages/corner-shape/_lib/generator.ts create mode 100644 src/pages/corner-shape/_lib/superellipse.test.ts create mode 100644 src/pages/corner-shape/_lib/superellipse.ts create mode 100644 src/pages/corner-shape/corner-shape.css create mode 100644 src/pages/corner-shape/index.astro diff --git a/.agents/skills/ponytail/SKILL.md b/.agents/skills/ponytail/SKILL.md new file mode 100644 index 0000000..6e2b4b9 --- /dev/null +++ b/.agents/skills/ponytail/SKILL.md @@ -0,0 +1,121 @@ +--- +name: ponytail +description: > + Forces the laziest solution that actually works, simplest, shortest, most + minimal. Channels a senior dev who has seen everything: question whether the + task needs to exist at all (YAGNI), reach for the standard library before + custom code, native platform features before dependencies, one line before + fifty. Supports intensity levels: lite, full (default), ultra. Use on ANY + coding task: writing, adding, refactoring, fixing, reviewing, or designing + code, and choosing libraries or dependencies. Also use whenever the user + says "ponytail", "be lazy", "lazy mode", "simplest solution", "minimal + solution", "yagni", "do less", or "shortest path", or complains about + over-engineering, bloat, boilerplate, or unnecessary dependencies. Do NOT + use for non-coding requests (general knowledge, prose, translation, + summaries, recipes). +argument-hint: "[lite|full|ultra]" +license: MIT +--- + +# Ponytail + +You are a lazy senior developer. Lazy means efficient, not careless. You have +seen every over-engineered codebase and been paged at 3am for one. The best +code is the code never written. + +## Persistence + +ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if +unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. +Switch: `/ponytail lite|full|ultra`. + +## The ladder + +Stop at the first rung that holds: + +1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) +2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop. +3. **Stdlib does it?** Use it. +4. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. +5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +6. **Can it be one line?** One line. +7. **Only then:** the minimum code that works. + +The ladder is a reflex, not a research project — but it runs _after_ you +understand the problem, not instead of it. Read the task and the code it +touches first, trace the real flow end to end, then climb. Two rungs work → +take the higher one and move on. The first lazy solution that works is the +right one — once you actually know what the change has to touch. + +**Bug fix = root cause, not symptom.** A report names a symptom. Before you +edit, grep every caller of the function you're about to touch. The lazy fix IS +the root-cause fix: one guard in the shared function is a smaller diff than a +guard in every caller — and patching only the path the ticket names leaves +every sibling caller still broken. Fix it once, where all callers route through. + +## Rules + +- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. +- No boilerplate, no scaffolding "for later", later can scaffold for itself. +- Deletion over addition. Boring over clever, clever is what someone decodes at 3am. +- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug. +- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default. +- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm. +- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path (`# ponytail: global lock, per-account locks if throughput matters`). + +## Output + +Code first. Then at most three short lines: what was skipped, when to add it. +No essays, no feature tours, no design notes. If the explanation is longer +than the code, delete the explanation, every paragraph defending a +simplification is complexity smuggled back in as prose. Explanation the user +explicitly asked for (a report, a walkthrough, per-phase notes) is not debt, +give it in full, the rule is only against unrequested prose. + +Pattern: `[code] → skipped: [X], add when [Y].` + +## Intensity + +| Level | What change | +| --------- | --------------------------------------------------------------------------------------------------------------------------- | +| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. | +| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. | +| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. | + +Example: "Add a cache for these API responses." + +- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class." +- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short." +- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate." + +## When NOT to be lazy + +Never simplify away: input validation at trust boundaries, error handling +that prevents data loss, security measures, accessibility basics, anything +explicitly requested. User insists on the full version → build it, no +re-arguing. + +Never lazy about understanding the problem. The ladder shortens the +solution, never the reading. Trace the whole thing first — every file the +change touches, the actual flow — before picking a rung. Laziness that skips +comprehension to ship a small diff is the dangerous kind: it dresses up as +efficiency and ships a confident wrong fix. Read fully, then be lazy. + +Hardware is never the ideal on paper: a real clock drifts, a real sensor +reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not +just less code, the physical world needs tuning a minimal model can't see. + +Lazy code without its check is unfinished. Non-trivial logic (a branch, a +loop, a parser, a money/security path) leaves ONE runnable check behind, the +smallest thing that fails if the logic breaks: an `assert`-based +`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no +fixtures, no per-function suites unless asked. Trivial one-liners need no +test, YAGNI applies to tests too. + +## Boundaries + +Ponytail governs what you build, not how you talk (pair with Caveman for +terse prose). "stop ponytail" / "normal mode": revert. Level persists until +changed or session end. + +The shortest path to done is the right path. diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..4628fe4 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,9 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "url": "http://localhost:4321" + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json index 3dbd131..2c3e622 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,4 +7,4 @@ "Bash(git push --no-verify *)" ] } -} \ No newline at end of file +} diff --git a/.claude/skills/ponytail b/.claude/skills/ponytail new file mode 120000 index 0000000..0cd1139 --- /dev/null +++ b/.claude/skills/ponytail @@ -0,0 +1 @@ +../../.agents/skills/ponytail \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9831838..01f892a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ Node `>=22.12.0`. - `.css` — page styles, imported into the `.astro` file - `_article.md` — the explainer (`_` prefix keeps it out of routing) - `_components/` — React island(s) + logic, only if interactive +- `_lib/` — framework-free logic for probes that do not need a React island `src/pages/index.astro` is an intentionally empty landing page; the probe list lives in `README.md`. diff --git a/astro.config.mjs b/astro.config.mjs index 58f1bbd..756583d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -20,6 +20,20 @@ export default defineConfig({ weights: ["400 700"], styles: ["normal"], }, + { + provider: fontProviders.google(), + name: "Atkinson Hyperlegible Next", + cssVariable: "--font-atkinson", + weights: ["200 800"], + styles: ["normal"], + }, + { + provider: fontProviders.google(), + name: "Atkinson Hyperlegible Mono", + cssVariable: "--font-atkinson-mono", + weights: ["200 800"], + styles: ["normal"], + }, ], integrations: [react()], diff --git a/bun.lock b/bun.lock index ec6c566..dcf043c 100644 --- a/bun.lock +++ b/bun.lock @@ -7,10 +7,12 @@ "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/react": "^6.0.2", + "@speed-highlight/core": "^1.2.20", "@tanstack/react-form": "^1.33.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "astro": "^7.1.6", + "baseline-status": "^1.1.1", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "^7.0.2", @@ -289,6 +291,12 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.6.0", "", {}, "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], + + "@lit/task": ["@lit/task@1.0.3", "", { "dependencies": { "@lit/reactive-element": "^1.0.0 || ^2.0.0" } }, "sha512-1gJGJl8WON+2j0y9xfcD+XsS1rvcy3XDgsIhcdUW++yTR8ESjZW6o7dn8M8a4SZM8NnJe6ynS2cKWwsbfLOurg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -491,6 +499,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.20", "", {}, "sha512-biXn20UJkNJ9cB35lPXJKfgUl1NQfhbApt78oePVjC0gB4+hlJyjkUz1hoLHLk7dEl+mSbW0VtX4C2IlRIDoHA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.4", "", { "bin": { "intent": "./bin/intent.js" } }, "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw=="], @@ -535,6 +545,8 @@ "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -641,6 +653,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], + "baseline-status": ["baseline-status@1.1.1", "", { "dependencies": { "@lit/task": "^1.0.0", "lit": "^3.1.3" } }, "sha512-l7L5D3MQA/+082HNaN2vEeodO20wVC6SctPCPmediuxHlGkBxQcbo2fYfTB+4FYg4QbRTTig4xI5Tyy1aRCJ3A=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -921,6 +935,12 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "lit": ["lit@3.3.3", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw=="], + + "lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="], + + "lit-html": ["lit-html@3.3.3", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA=="], + "loader-utils": ["loader-utils@3.3.1", "", {}, "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg=="], "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], diff --git a/package.json b/package.json index cbc7b45..49522b1 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,19 @@ "lint": "oxlint && stylelint \"src/**/*.css\"", "lint:fix": "oxlint --fix && stylelint --fix \"src/**/*.css\"", "preview": "astro preview", + "test": "vitest run", "typecheck": "oxlint --type-aware", "update:interactive": "npx npm-check-updates --interactive -u --packageManager bun --format group --cooldown 2" }, "dependencies": { "@astrojs/check": "^0.9.10", "@astrojs/react": "^6.0.2", + "@speed-highlight/core": "^1.2.20", "@tanstack/react-form": "^1.33.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "astro": "^7.1.6", + "baseline-status": "^1.1.1", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "^7.0.2", diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..9eb616a --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "ponytail": { + "source": "dietrichgebert/ponytail", + "sourceType": "github", + "skillPath": "skills/ponytail/SKILL.md", + "computedHash": "210fa0831f649ce9f8a305f7b7c8cf2a0dd09ac4dd53067109941b96e7811d4e" + } + } +} diff --git a/src/pages/corner-shape/_components/baseline.astro b/src/pages/corner-shape/_components/baseline.astro new file mode 100644 index 0000000..94d9a39 --- /dev/null +++ b/src/pages/corner-shape/_components/baseline.astro @@ -0,0 +1,23 @@ +--- +import "./baseline.css"; +--- + +
+
+

Browser support

+

What the fallback costs you

+

+ No border-radius value bends inward, so a scoop or + a notch has no rounded equivalent at all. The exact tier draws them properly, + but a clip path also cuts off box shadows, outlines, and any border sitting + outside it. +

+
+ + Support data didn't load. Check the Web Platform Status dashboard. + +
+ + diff --git a/src/pages/corner-shape/_components/baseline.css b/src/pages/corner-shape/_components/baseline.css new file mode 100644 index 0000000..fd3b502 --- /dev/null +++ b/src/pages/corner-shape/_components/baseline.css @@ -0,0 +1,29 @@ +.support-panel { + display: grid; + grid-template-columns: 1fr minmax(16rem, 1fr); + gap: 2rem; + align-items: center; + + margin-top: 1rem; + padding: 1.25rem; + border-top: 1px solid var(--color-border); + + background: var(--color-transparent); +} + +.support-panel-text { + color: var(--color-secondary); +} + +/* The widget ships its own card; the panel around it is already that card. */ +.baseline-status { + padding: 0; + border: 0; + border-radius: 0; +} + +@media (width <= 48rem) { + .support-panel { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/corner-shape/_components/card-properties.astro b/src/pages/corner-shape/_components/card-properties.astro new file mode 100644 index 0000000..d48768f --- /dev/null +++ b/src/pages/corner-shape/_components/card-properties.astro @@ -0,0 +1,38 @@ +--- +import { DIMENSIONS } from "../_lib/controls"; + +import SliderField from "./slider-field.astro"; +import "./card-properties.css"; + +// Everything about the card that is not a corner. It is folded away by default because +// the defaults are already a fair test — you only open it when the shape you are judging +// needs a different box to be fair to it. +--- + +
+ Card size and text +
+ + +
+ { + DIMENSIONS.map((dimension) => ( + + )) + } +
+
+
diff --git a/src/pages/corner-shape/_components/card-properties.css b/src/pages/corner-shape/_components/card-properties.css new file mode 100644 index 0000000..afb5a18 --- /dev/null +++ b/src/pages/corner-shape/_components/card-properties.css @@ -0,0 +1,19 @@ +.card-properties { + grid-area: properties; +} + +.card-properties-summary { + cursor: pointer; + font-size: 1.14rem; + font-weight: 700; +} + +.card-properties-content { + padding-top: 1rem; +} + +.dimension-grid { + display: grid; + gap: 0.75rem; + margin-bottom: 1.25rem; +} diff --git a/src/pages/corner-shape/_components/corner-fields.astro b/src/pages/corner-shape/_components/corner-fields.astro new file mode 100644 index 0000000..3ab4d3f --- /dev/null +++ b/src/pages/corner-shape/_components/corner-fields.astro @@ -0,0 +1,76 @@ +--- +import { + DEFAULT_FALLBACK_RADIUS, + DEFAULT_RADIUS, + DEFAULT_SHAPE, + RADIUS_RANGE, + SHAPE_RANGE, +} from "../_lib/controls"; + +import PresetChips from "./preset-chips.astro"; +import SliderField from "./slider-field.astro"; + +// The four fields that describe one corner: a preset, the exponent behind it, the radius +// it bends through, and the plain radius a browser without `corner-shape` gets instead. +// Identical whether it drives all four corners at once (`all`) or a single one, so the +// only thing that changes is the field-name suffix and how the labels read aloud. +interface Props { + /** `all`, or one of `tl` / `tr` / `br` / `bl`. */ + target: string; + /** Spoken name of the corner, e.g. "Top left". Unused when target is `all`. */ + label?: string; +} + +const { target, label = "" } = Astro.props; + +const isAll = target === "all"; +const shapeLabel = isAll ? "Shape" : `${label} shape`; +const radiusLabel = isAll ? "Radius" : `${label} radius`; +--- + + + + + Shape s + + + + Radius + + + diff --git a/src/pages/corner-shape/_components/generator.astro b/src/pages/corner-shape/_components/generator.astro new file mode 100644 index 0000000..9d67e0e --- /dev/null +++ b/src/pages/corner-shape/_components/generator.astro @@ -0,0 +1,26 @@ +--- +import CardProperties from "./card-properties.astro"; +import OutputSection from "./output-section.astro"; +import ShapeSettings from "./shape-settings.astro"; +import Stage from "./stage.astro"; +import "./generator.css"; + +// One form holds the whole tool: the preview, every control, and the CSS it emits. That +// is deliberate — `_lib/generator.ts` never reads a control directly, it serialises this +// form on every input event and renders from the result, so the DOM stays the single +// source of truth and there is no second copy of the state to drift. +--- + +
+
+ + + +
+ + + + + diff --git a/src/pages/corner-shape/_components/generator.css b/src/pages/corner-shape/_components/generator.css new file mode 100644 index 0000000..cdc003e --- /dev/null +++ b/src/pages/corner-shape/_components/generator.css @@ -0,0 +1,24 @@ +/* The three-panel frame the tool sits in: the stage runs down the left as one tall + column so it can stay in view while you work, and the two control panels stack beside + it. Below 48rem there is no room for that, so everything becomes one column and the + properties panel — folded shut by default — leads. */ + +.workbench { + display: grid; + grid-template-areas: + "stage properties" + "stage settings"; + grid-template-columns: minmax(0, 1.05fr) minmax(19rem, 0.95fr); + gap: 1rem; + align-items: start; +} + +@media (width <= 48rem) { + .workbench { + grid-template-areas: + "properties" + "stage" + "settings"; + grid-template-columns: 1fr; + } +} diff --git a/src/pages/corner-shape/_components/head.astro b/src/pages/corner-shape/_components/head.astro new file mode 100644 index 0000000..78ab1c3 --- /dev/null +++ b/src/pages/corner-shape/_components/head.astro @@ -0,0 +1,69 @@ +--- +import "./head.css"; +--- + +
+
+
CSS tool
+

Corner-shape generator

+
+

+ Drag the sliders until the corners look right, copy the CSS with fallback. + Two tiers of fallback are available: the default one is a border-radius that removes the same amount of corner as the superellipse does, and the exact + one traces the real curve with clip-path. +

+
+ In more detail → +
+

+ The{" "} + corner-shape property specifies the curve applied within the corner region defined by{ + " " + } + border-radius. The value inside{" "} + superellipse() acts as an exponent (k = 2s): 0 produces a straight bevel, 1 gives + you a regular circular curve, 2 matches Apple's iOS squircle, + and negative values make the corner curve inward rather than outward. The + scale runs out at both ends: below -3 the spec's -infinity takes over as notch, above 5 its infinity takes + over as square. Every step doubles k, and the curve + flattens so fast toward straight lines that cranking the exponent higher + barely moves it (we're talking less than a pixel of difference). +

+

+ If the newer syntax isn't supported, you can fall back in one of two + ways. By default you fall back to good old border-radius, which plays nicely with borders, outlines, and shadows, but it can + only round corners outward (no concave curves). The exact fallback{" "} + uses clip-path to trace the actual curve. It reproduces + the shape exactly, but then cuts off anything rendered outside it, including + your shadows and outlines. +

+

+ For outward-curving corners, the generator calculates a border-radius{" "} + that removes the same amount of corner area as the superellipse would, so + both versions look visually equivalent. Concave curves can't be replicated + with border-radius, so the generator just + keeps whatever radius you typed and warns you about it. When you're in + exact mode with a custom curve, the generator pieces it together from + Bézier segments, splitting them over and over until the fit is within + 0.25% of the radius. That precision holds for every shape you can dial + up, all the way to superellipse(-3). +

+
+
+
diff --git a/src/pages/corner-shape/_components/head.css b/src/pages/corner-shape/_components/head.css new file mode 100644 index 0000000..810d589 --- /dev/null +++ b/src/pages/corner-shape/_components/head.css @@ -0,0 +1,95 @@ +.page-head { + display: grid; + gap: 1.5rem; + margin-bottom: clamp(2rem, 4vw, 3rem); +} + +.page-title { + margin: 0; + + font-size: clamp(2.4rem, 6.6vw, 5.4rem); + font-weight: 800; + line-height: 0.9; + color: var(--color-blue-energy-500); + text-shadow: 0 -7px 0 + color-mix(var(--color-blue-energy-100) clamp(10%, calc((100vw / 20px) * 1%), 80%), transparent); + letter-spacing: -0.02em; +} + +.page-intro { + font-size: 1.22rem; + font-weight: 500; + line-height: 1.4; + color: var(--color-text-secondary); + text-wrap: balance; + + & .code { + font-weight: 700; + } +} + +.about { + padding-inline-start: 1rem; + border-left: 3px solid var(--color-border); +} + +.about-summary { + cursor: pointer; + width: fit-content; + font-size: 1.1rem; + + &::marker { + content: ""; + } +} + +.about-content { + container-type: inline-size; + column-width: 36ch; + column-fill: balance; + column-gap: 4.4rem; + + padding-top: 0.75rem; + + font-size: 1.1rem; + font-weight: 400; + line-height: 1.44; + color: var(--color-text-secondary); + + a, + a .code { + transition: all 0.2s ease-in; + } + + & a:has(.code) { + text-decoration-color: var(--color-code); + text-decoration-thickness: 2.5px; + text-underline-offset: 0.16em; + } + + & a:has(.code):has(:hover) { + text-decoration-color: var(--color-blue-energy-400); + } + + & a:hover .code { + color: var(--color-blue-energy-400); + } + + strong { + color: var(--color-text-primary); + } +} + +.about-paragraph { + orphans: 2; + break-inside: auto; + margin-bottom: 0.44rem; + + &:last-child { + margin-bottom: 0; + } +} + +.about-link { + color: var(--color-accent); +} diff --git a/src/pages/corner-shape/_components/output-section.astro b/src/pages/corner-shape/_components/output-section.astro new file mode 100644 index 0000000..74e4a1b --- /dev/null +++ b/src/pages/corner-shape/_components/output-section.astro @@ -0,0 +1,41 @@ +--- +import "./output-section.css"; + +// The take-away. The order is the point: the fallback declaration comes first so every +// browser gets something, and the `@supports` block that follows only reaches browsers +// that can honour the real shape. `_lib/emit.ts` builds the text, this is the frame +// around it plus the two things you might want to change about it — the selector it is +// written for and the unit its lengths are in. +--- + +
+
+
+

Output

+

+ Fallback first, enhancement after +

+
+ +
+ +
+
Unit
+ + +
+
+

+
+

+
diff --git a/src/pages/corner-shape/_components/output-section.css b/src/pages/corner-shape/_components/output-section.css new file mode 100644 index 0000000..f0225a0 --- /dev/null +++ b/src/pages/corner-shape/_components/output-section.css @@ -0,0 +1,99 @@ +.output-section { + display: grid; + grid-template-columns: minmax(12rem, 0.35fr) minmax(0, 1fr); + margin-top: 1rem; +} + +.output-controls { + padding: 1rem; + border-right: 1px solid var(--color-divider); +} + +.output-options { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; +} + +.selector-field { + display: grid; + flex-grow: 1; + gap: 0.25rem; +} + +.unit-field { + display: grid; + gap: 0.25rem; +} + +.copy-css-button { + width: 100%; +} + +.copy-status { + min-height: 1.5rem; + padding-top: 0.35rem; + + font-size: 0.8rem; + font-weight: 700; + color: var(--color-accent); +} + +.css-output { + overflow: auto; + + min-height: 12rem; + max-height: 32rem; + padding: 1.25rem; + + font-family: var(--font-atkinson-mono), ui-monospace, monospace; + font-size: 1rem; + font-weight: 500; + line-height: 1.55; + color: var(--color-text); + tab-size: 2; + + background: var(--color-transparent); +} + +/* Speed-highlight's own class names, mapped onto the page palette. */ +.css-output .shj-numbers { + display: none; +} + +.css-output .shj-syn-cmnt { + font-style: italic; + color: var(--color-syntax-comment); +} + +.css-output .shj-syn-kwd, +.css-output .shj-syn-oper { + color: var(--color-syntax-keyword); +} + +.css-output .shj-syn-num, +.css-output .shj-syn-class { + color: var(--color-syntax-number); +} + +.css-output .shj-syn-str, +.css-output .shj-syn-insert { + color: var(--color-syntax-string); +} + +.css-output .shj-syn-func, +.css-output .shj-syn-var, +.css-output .shj-syn-type { + color: var(--color-syntax-function); +} + +@media (width <= 48rem) { + .output-section { + grid-template-columns: 1fr; + } + + .output-controls { + border-right: 0; + border-bottom: 1px solid var(--color-divider); + } +} diff --git a/src/pages/corner-shape/_components/preset-chips.astro b/src/pages/corner-shape/_components/preset-chips.astro new file mode 100644 index 0000000..4c1831d --- /dev/null +++ b/src/pages/corner-shape/_components/preset-chips.astro @@ -0,0 +1,38 @@ +--- +import { DEFAULT_SHAPE, PRESETS } from "../_lib/controls"; + +// The named stops on the shape scale. Picking one writes its number into the shape field +// it points at, and the field writes back: a preset stays lit only while the number still +// matches it, so dragging the slider off `2` quietly un-checks Squircle. +interface Props { + /** Radio group name — one group per shape field. */ + name: string; + /** `data-bind` key of the shape field this group drives. */ + target: string; + legend: string; + class?: string; +} + +const { name, target, legend, class: className } = Astro.props; +--- + +
+ {legend} +
+ { + PRESETS.map(({ label, value }) => ( + + )) + } +
+
diff --git a/src/pages/corner-shape/_components/shape-settings.astro b/src/pages/corner-shape/_components/shape-settings.astro new file mode 100644 index 0000000..bc44f1c --- /dev/null +++ b/src/pages/corner-shape/_components/shape-settings.astro @@ -0,0 +1,56 @@ +--- +import { CORNER_LABELS } from "../_lib/controls"; + +import CornerFields from "./corner-fields.astro"; +import "./shape-settings.css"; + +// The corners. Two sets of the same controls live in the markup at once and the mode +// switch shows one of them — keeping both mounted means the form always serialises a +// complete answer for whichever mode is active, so nothing has to be reconstructed when +// you flip between them. +--- + +
+
+

Controls

+

Shape the card

+
+ +
+ Corner mode +
+ + +
+
+ +
+ +
+ +
+ { + CORNER_LABELS.map(({ id, label }) => ( +
+ {label} + +
+ )) + } +
+
diff --git a/src/pages/corner-shape/_components/shape-settings.css b/src/pages/corner-shape/_components/shape-settings.css new file mode 100644 index 0000000..ff2530d --- /dev/null +++ b/src/pages/corner-shape/_components/shape-settings.css @@ -0,0 +1,66 @@ +.shape-settings { + grid-area: settings; +} + +.section-head, +.mode-control, +.corner-controls, +.corner-grid { + margin-bottom: 0.7rem; +} + +.mode-control, +.preset-group { + min-width: 0; + padding: 0; + border: 0; +} + +.preset-group, +.shape-control { + margin-bottom: 0.5rem; +} + +/* Both sets of controls stay in the DOM and the mode switch shows one of them, so the + form always serialises a complete answer for whichever mode is active. */ +.corner-grid { + display: none; + grid-template-columns: 1fr; + gap: 0.5rem; +} + +.generator-form[data-mode="individual"] .all-controls { + display: none; +} + +.generator-form[data-mode="individual"] .individual-controls { + display: grid; +} + +.corner-group { + min-width: 0; + padding: 0.6rem 0; + border: 0; + border-top: 1px solid var(--color-group-border); +} + +.corner-group-legend { + padding-inline: 0.25rem; + font-size: 0.85rem; + color: var(--color-group-title); +} + +/* Four corners' worth of presets in the space one set had, so the chips give up padding + rather than wrapping to another line. */ +.compact-presets .chip-group { + gap: 0.15rem; +} + +.compact-presets .chip { + padding-inline: 0.4rem; +} + +.corner-controls .fallback-field, +.corner-group .fallback-field { + margin-bottom: 0; +} diff --git a/src/pages/corner-shape/_components/slider-field.astro b/src/pages/corner-shape/_components/slider-field.astro new file mode 100644 index 0000000..18418aa --- /dev/null +++ b/src/pages/corner-shape/_components/slider-field.astro @@ -0,0 +1,63 @@ +--- +// One number, three inputs. The hidden input is the only one the form serialises; the +// range is the fast way to reach a value and the text box the precise one, and +// `_lib/generator.ts` keeps the pair mirrored through `data-bind`. +interface Props { + /** Form field name, also the `data-bind` key the two visible inputs sync through. */ + name: string; + /** Spoken name of the control, used to build both aria labels. */ + label: string; + min: number | string; + max: number | string; + step: number | string; + value: number | string; + /** Appended to the value input's aria label, e.g. "rem". */ + unit?: string; + /** `text` accepts `infinity`, which a number input would reject. */ + type?: "number" | "text"; + ariaValueText?: string; + class?: string; +} + +const { + name, + label, + min, + max, + step, + value, + unit, + type = "number", + ariaValueText, + class: className, +} = Astro.props; + +const isText = type === "text"; +--- + +
+ {label} + + + +
diff --git a/src/pages/corner-shape/_components/stage.astro b/src/pages/corner-shape/_components/stage.astro new file mode 100644 index 0000000..dfef127 --- /dev/null +++ b/src/pages/corner-shape/_components/stage.astro @@ -0,0 +1,61 @@ +--- +import "./stage.css"; + +// The card itself, plus the two switches that decide which tier draws it. Nothing here +// is a preview of a screenshot: the enhanced card is the real `corner-shape` declaration +// and the fallback card is the real generated CSS, so flipping between them shows exactly +// what a browser without support would lose. `_lib/generator.ts` drives the whole thing +// through `data-` attributes on the section and custom properties on the root. +--- + +
+
+
+

Preview

+

+ Flip between the two, same box +

+
+
+
+ + +
+ +
+
+ + +
diff --git a/src/pages/corner-shape/_components/stage.css b/src/pages/corner-shape/_components/stage.css new file mode 100644 index 0000000..8e74f67 --- /dev/null +++ b/src/pages/corner-shape/_components/stage.css @@ -0,0 +1,127 @@ +/* State selectors intentionally follow the styles they override. */ +/* stylelint-disable no-descending-specificity */ + +.stage { + position: sticky; + top: 1rem; + + overflow: hidden; + grid-area: stage; + + width: 100%; +} + +.stage-head { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; + + padding-block: 1rem; +} + +.stage-actions { + display: grid; + gap: 0.45rem; + justify-items: end; + margin-left: auto; +} + +/* A chip whose checkbox stays visible, so its checked state has to read as "on" rather + than "selected" — a soft fill instead of the solid accent the other chips take. */ +.exact-toggle { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.exact-toggle:has(.exact-toggle-input:checked) { + border-color: var(--color-accent); + color: var(--color-accent); + background: var(--color-accent-soft); +} + +.preview-card { + /* Share of the frame's width the card takes: whichever side the ratio makes longer is + the one that fills the frame, so the card grows to the edges and never past them. + Sizing the box beats scaling it — a transform would shrink the radius and the text + along with it, and the radius is the thing being judged. */ + --fill: calc(var(--size) * min(1, var(--ratio))); + + overflow: hidden; + display: grid; + place-items: center; + + box-sizing: border-box; + aspect-ratio: var(--ratio); + inline-size: calc(100% * var(--fill)); + + /* Percentage padding resolves against the frame, not the card, so it goes through the + same share to end up a percentage of the card itself. */ + padding: calc(var(--p) * var(--fill)); + border-radius: var(--r-tl) var(--r-tr) var(--r-br) var(--r-bl); + + font-size: var(--fs); + line-height: 1.35; + color: var(--color-card-text); + text-align: center; + + background: linear-gradient(145deg, var(--color-card-start), var(--color-card-end)); + + corner-shape: var(--shape-tl) var(--shape-tr) var(--shape-br) var(--shape-bl); +} + +.stage[data-preview="fallback"] .preview-card { + border-radius: var(--fb-r-tl) var(--fb-r-tr) var(--fb-r-br) var(--fb-r-bl); + clip-path: none; + + corner-shape: round; +} + +/* A clip path carries the whole outline, so the radius has to get out of its way. */ +.stage[data-preview="fallback"][data-exact="true"] .preview-card, +:root[data-corner-shape="unsupported"] + .stage[data-preview="enhanced"][data-clip="path"] + .preview-card { + border-radius: 0; + clip-path: var(--preview-clip); +} + +/* All-square needs no clip path and no radius either. All-round, the other case with no + clip path, falls through to the rule above it and keeps its border-radius. */ +:root[data-corner-shape="unsupported"] + .stage[data-preview="enhanced"][data-square="true"] + .preview-card { + border-radius: 0; +} + +.preview-wrapper { + overflow: hidden; + display: grid; + place-items: center; + + padding: 0.75rem; + + background: + radial-gradient(circle at center, var(--color-dot) 0.8px, var(--color-transparent) 0.6px) 0 0 / + 0.5rem 0.5rem, + var(--color-transparent); +} + +.value-readout { + overflow: hidden; + display: block; + + padding-block: 1rem; + + font-size: 1.2rem; + font-weight: 500; + white-space: pre; +} + +@media (width <= 48rem) { + .stage { + position: static; + } +} diff --git a/src/pages/corner-shape/_components/support-warning.astro b/src/pages/corner-shape/_components/support-warning.astro new file mode 100644 index 0000000..e846cba --- /dev/null +++ b/src/pages/corner-shape/_components/support-warning.astro @@ -0,0 +1,12 @@ +--- +import "./support-warning.css"; +--- + + diff --git a/src/pages/corner-shape/_components/support-warning.css b/src/pages/corner-shape/_components/support-warning.css new file mode 100644 index 0000000..5ad5302 --- /dev/null +++ b/src/pages/corner-shape/_components/support-warning.css @@ -0,0 +1,15 @@ +.support-warning { + display: none; + + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border-left: 2px solid var(--color-warning-border); + + color: var(--color-warning-text); + + background: var(--color-transparent); +} + +:root[data-corner-shape="unsupported"] .support-warning { + display: block; +} diff --git a/src/pages/corner-shape/_lib/controls.ts b/src/pages/corner-shape/_lib/controls.ts new file mode 100644 index 0000000..57c69b0 --- /dev/null +++ b/src/pages/corner-shape/_lib/controls.ts @@ -0,0 +1,74 @@ +// Every control the generator draws is described once, here, so the markup stays a loop +// over data instead of four hand-copied corners. Nothing in this file knows how a control +// is rendered — only what it means and what range of numbers it accepts. + +import type { Corner } from "./emit"; + +// The card is measured against the square frame it sits in, not in rem: `size` is the +// fraction of that frame it fills (1 leaves the 0.5rem the frame is padded with) and +// `ratio` stretches it from a wide bar to a tall one without ever letting it escape. +export const DIMENSIONS = [ + { + name: "size", + label: "Size", + min: 0.1, + max: 1, + step: 0.01, + value: 0.6, + unit: "fraction of the frame", + }, + { + name: "ratio", + label: "Aspect ratio", + min: 0.25, + max: 4, + step: 0.05, + value: 1, + unit: "width divided by height", + }, + { + name: "padding", + label: "Padding", + min: 0, + max: 30, + step: 0.5, + value: 10, + unit: "percent", + }, + { + name: "font-size", + label: "Font size", + min: 0.75, + max: 4, + step: 0.05, + value: 1, + unit: "rem", + }, +] as const; + +// The four corners, in the order `border-radius` writes them. +export const CORNER_LABELS: { id: Corner; label: string }[] = [ + { id: "tl", label: "Top left" }, + { id: "tr", label: "Top right" }, + { id: "br", label: "Bottom right" }, + { id: "bl", label: "Bottom left" }, +]; + +// The named stops on the shape scale. `s` is an exponent, so the two ends are the +// keywords the spec hands out once the curve has flattened into a straight line. +export const PRESETS = [ + { label: "Notch", value: "-infinity" }, + { label: "Scoop", value: "-1" }, + { label: "Bevel", value: "0" }, + { label: "Round", value: "1" }, + { label: "Squircle", value: "2" }, + { label: "Square", value: "infinity" }, +] as const; + +export const DEFAULT_SHAPE = "2"; +export const DEFAULT_RADIUS = "1"; +export const DEFAULT_FALLBACK_RADIUS = "0.583"; + +// Shape and radius take the same two ranges everywhere they appear. +export const SHAPE_RANGE = { min: -3, max: 5, step: 0.1 } as const; +export const RADIUS_RANGE = { min: 0, max: 8, step: 0.05 } as const; diff --git a/src/pages/corner-shape/_lib/emit.test.ts b/src/pages/corner-shape/_lib/emit.test.ts new file mode 100644 index 0000000..d7ea1bf --- /dev/null +++ b/src/pages/corner-shape/_lib/emit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; + +import { emitCss, exactClipPath, type GeneratorState } from "./emit"; + +const state: GeneratorState = { + selector: ".card", + unit: "rem", + width: 12, + height: 12, + shapes: { tl: 2, tr: 2, br: 2, bl: 2 }, + radii: { tl: 1, tr: 1, br: 1, bl: 1 }, + fallbackRadii: { tl: 0.583, tr: 0.583, br: 0.583, bl: 0.583 }, + exact: false, +}; + +const uniform = (s: number): GeneratorState => ({ + ...state, + shapes: { tl: s, tr: s, br: s, bl: s }, +}); + +describe("clip path emission", () => { + test("cuts a notch out of every corner", () => { + // The two lines per corner have to land on the edges and detour through the inner + // corner. Collapsing them onto the box corner leaves a path that draws a plain + // rectangle, which is what shipped, because a rectangle looks deliberate. + const path = exactClipPath(uniform(-Infinity))!; + expect(path).toContain("1rem 1rem"); + expect(path).toContain("calc(100% - 1rem) 1rem"); + expect(path.match(/\n/g)).toHaveLength(13); + }); + + test("draws a bevel as the eight-point polygon", () => { + const path = exactClipPath(uniform(0))!; + expect(path.startsWith("polygon(")).toBe(true); + expect(path.match(/\n/g)).toHaveLength(9); + }); + + test("leaves round and square to border-radius", () => { + expect(exactClipPath(uniform(1))).toBeNull(); + expect(exactClipPath(uniform(Infinity))).toBeNull(); + }); + + test("writes zero offsets as 0 and 100%", () => { + const path = exactClipPath(uniform(0))!; + expect(path).not.toContain("0rem"); + expect(path).not.toContain("100% - 0"); + }); +}); + +describe("css emission", () => { + test("converts to px on request", () => { + expect(emitCss({ ...state, unit: "px" })).toContain("border-radius: 16px"); + }); + + test("says so when no radius can approximate the shape", () => { + expect(emitCss(uniform(-1))).toContain("bends inward"); + }); + + test("shortens four equal corners to one value", () => { + expect(emitCss(state)).toContain("corner-shape: squircle;"); + expect(emitCss({ ...state, shapes: { ...state.shapes, tr: 0 } })).toContain( + "corner-shape: squircle bevel squircle squircle;", + ); + }); +}); diff --git a/src/pages/corner-shape/_lib/emit.ts b/src/pages/corner-shape/_lib/emit.ts new file mode 100644 index 0000000..6bfa904 --- /dev/null +++ b/src/pages/corner-shape/_lib/emit.ts @@ -0,0 +1,161 @@ +import { clampRadii, fallbackRatio, fitSuperellipse, type Point } from "./superellipse"; + +export const CORNERS = ["tl", "tr", "br", "bl"] as const; +export type Corner = (typeof CORNERS)[number]; +export type CornerValues = Record; + +export type GeneratorState = { + selector: string; + unit: "rem" | "px"; + width: number; + height: number; + shapes: CornerValues; + radii: CornerValues; + fallbackRadii: CornerValues; + exact: boolean; +}; + +const KEYWORDS = new Map([ + [-Infinity, "notch"], + [-1, "scoop"], + [0, "bevel"], + [1, "round"], + [2, "squircle"], + [Infinity, "square"], +]); + +export function shapeValue(s: number): string { + return KEYWORDS.get(s) ?? `superellipse(${Number(s.toFixed(1))})`; +} + +export function computedFallback(radius: number, s: number): number { + return s < 0 ? radius : radius * fallbackRatio(s); +} + +function decimal(value: number): string { + return Number(value.toFixed(3)).toString(); +} + +function length(value: number, unit: GeneratorState["unit"]): string { + const scaled = decimal(unit === "px" ? value * 16 : value); + return scaled === "0" ? "0" : `${scaled}${unit}`; +} + +function shorthand(values: string[]): string { + if (values.every((value) => value === values[0])) return values[0]; + if (values[0] === values[2] && values[1] === values[3]) return values.slice(0, 2).join(" "); + if (values[1] === values[3]) return values.slice(0, 3).join(" "); + return values.join(" "); +} + +export function declarationValues(state: GeneratorState): { + radius: string; + shape: string; +} { + return { + radius: shorthand(CORNERS.map((corner) => length(state.radii[corner], state.unit))), + shape: shorthand(CORNERS.map((corner) => shapeValue(state.shapes[corner]))), + }; +} + +type PhysicalCorner = "tl" | "tr" | "br" | "bl"; + +function pointExpression( + corner: PhysicalCorner, + point: Point, + radius: number, + unit: GeneratorState["unit"], +): string { + const direct = (scale: number) => length(radius * scale, unit); + const far = (scale: number) => { + const offset = direct(scale); + return offset === "0" ? "100%" : `calc(100% - ${offset})`; + }; + if (corner === "tl") return `${direct(point.x)} ${direct(point.y)}`; + if (corner === "tr") return `${far(point.y)} ${direct(point.x)}`; + if (corner === "br") return `${far(point.x)} ${far(point.y)}`; + return `${direct(point.y)} ${far(point.x)}`; +} + +const A = { x: 0, y: 1 }; +const B = { x: 1, y: 0 }; +const OUTER = { x: 0, y: 0 }; +const INNER = { x: 1, y: 1 }; + +// Only `square` collapses both endpoints onto the box corner. A notch still enters and +// leaves on the edges, it just detours through the inner corner on the way. +function endpoint(s: number, which: "a" | "b"): Point { + return s === Infinity ? OUTER : which === "a" ? A : B; +} + +function polygonPath(state: GeneratorState, radii: CornerValues): string { + const points: string[] = []; + for (const corner of CORNERS) { + const s = state.shapes[corner]; + const add = (point: Point) => + points.push(pointExpression(corner, point, radii[corner], state.unit)); + add(endpoint(s, "a")); + if (s === -Infinity) add(INNER); + if (s !== Infinity) add(B); + } + return `polygon(\n ${points.join(",\n ")}\n )`; +} + +function shapePath(state: GeneratorState, radii: CornerValues): string { + const start = pointExpression("tl", endpoint(state.shapes.tl, "b"), radii.tl, state.unit); + const commands = [`from ${start}`]; + for (const corner of ["tr", "br", "bl", "tl"] as const) { + const radius = radii[corner]; + const s = state.shapes[corner]; + commands.push(`line to ${pointExpression(corner, endpoint(s, "a"), radius, state.unit)}`); + if (s === Infinity) continue; + if (s === 0) { + commands.push(`line to ${pointExpression(corner, B, radius, state.unit)}`); + } else if (s === -Infinity) { + commands.push(`line to ${pointExpression(corner, INNER, radius, state.unit)}`); + commands.push(`line to ${pointExpression(corner, B, radius, state.unit)}`); + } else if (s === -1) { + commands.push( + `curve to ${pointExpression(corner, B, radius, state.unit)} with ${pointExpression(corner, INNER, radius, state.unit)}`, + ); + } else { + for (const segment of fitSuperellipse(s)) { + commands.push( + `curve to ${pointExpression(corner, segment.end, radius, state.unit)} with ${pointExpression(corner, segment.control1, radius, state.unit)} / ${pointExpression(corner, segment.control2, radius, state.unit)}`, + ); + } + } + } + commands.push("close"); + return `shape(\n ${commands.join(",\n ")}\n )`; +} + +export function exactClipPath(state: GeneratorState): string | null { + const shapes = CORNERS.map((corner) => state.shapes[corner]); + if (shapes.every((s) => s === 1) || shapes.every((s) => s === Infinity)) return null; + const [tl, tr, br, bl] = clampRadii( + CORNERS.map((corner) => state.radii[corner]) as [number, number, number, number], + state.width, + state.height, + ); + const radii = { tl, tr, br, bl }; + return shapes.every((s) => s === -Infinity || s === 0 || s === Infinity) + ? polygonPath(state, radii) + : shapePath(state, radii); +} + +export function emitCss(state: GeneratorState): string { + const fallback = CORNERS.map((corner) => length(state.fallbackRadii[corner], state.unit)); + const { radius: radiusValue, shape } = declarationValues(state); + const fallbackValue = shorthand(fallback); + const clipPath = exactClipPath(state); + const concave = CORNERS.find((corner) => state.shapes[corner] < 0); + const selector = state.selector.trim() || ".card"; + + const base = + state.exact && clipPath + ? `/* a clip path also cuts off box-shadow, outline, and anything the border\n draws outside it */\n${selector} {\n clip-path: ${clipPath};\n}` + : `${selector} {\n${concave ? ` /* ${shapeValue(state.shapes[concave])} bends inward and border-radius only bends outward, so\n this radius is a placeholder, not a match. Switch on the exact\n fallback if you need the real shape. */\n` : ""} border-radius: ${fallbackValue};\n}`; + + return `${base}\n\n@supports (corner-shape: ${shape}) {\n ${selector} {\n${state.exact && clipPath ? ` clip-path: none;\n` : ""} border-radius: ${radiusValue};\n\n corner-shape: ${shape};\n }\n}`; +} diff --git a/src/pages/corner-shape/_lib/generator.ts b/src/pages/corner-shape/_lib/generator.ts new file mode 100644 index 0000000..03ff947 --- /dev/null +++ b/src/pages/corner-shape/_lib/generator.ts @@ -0,0 +1,196 @@ +import { highlightElement } from "@speed-highlight/core"; + +import { + CORNERS, + computedFallback, + declarationValues, + emitCss, + exactClipPath, + shapeValue, + type Corner, + type GeneratorState, +} from "./emit"; + +const form = document.querySelector("#generator-form")!; +const stage = document.querySelector("#stage")!; +const card = document.querySelector("#preview-card")!; +const readout = document.querySelector("#value-readout")!; +const output = document.querySelector("#css-output")!; +const copyStatus = document.querySelector("#copy-status")!; +const unit = document.querySelector("#unit")!; +const unitToggle = document.querySelector("#toggle-unit")!; +const overrides = new Set(); + +function number(data: Record, name: string): number { + return Number(data[name]); +} + +function text(value: FormDataEntryValue | undefined): string { + return typeof value === "string" ? value : ""; +} + +function shape(data: Record, name: string): number { + const value = text(data[name]); + if (value === "infinity") return Infinity; + if (value === "-infinity") return -Infinity; + return Number(value); +} + +function values(make: (corner: Corner) => T): Record { + return Object.fromEntries(CORNERS.map((corner) => [corner, make(corner)])) as Record; +} + +// Everything that decides how big the card is, applied before anything measures it. +function frameCard(data: Record): void { + const style = document.documentElement.style; + style.setProperty("--size", String(number(data, "size"))); + style.setProperty("--ratio", String(number(data, "ratio"))); + style.setProperty("--p", `${number(data, "padding")}%`); + style.setProperty("--fs", `${number(data, "font-size")}rem`); +} + +// The card takes its size from the frame it sits in rather than from rem sliders, so the +// only way to know what the radii have to clamp against is to ask the laid-out box. +function cardSize(): { width: number; height: number } { + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); + const box = card.getBoundingClientRect(); + return { width: box.width / rem, height: box.height / rem }; +} + +function makeState( + data: Record, + size: { width: number; height: number }, +): GeneratorState { + const individual = data.mode === "individual"; + const shapes = values((corner) => shape(data, individual ? `shape-${corner}` : "shape-all")); + const radii = values((corner) => number(data, individual ? `radius-${corner}` : "radius-all")); + const fallbackRadii = values((corner) => { + const key = individual ? `fb-${corner}` : "fb-all"; + return overrides.has(key) ? number(data, key) : computedFallback(radii[corner], shapes[corner]); + }); + return { + selector: text(data.selector), + unit: data.unit === "px" ? "px" : "rem", + width: size.width, + height: size.height, + shapes, + radii, + fallbackRadii, + exact: data.exact === "on", + }; +} + +function setFallbackFields(state: GeneratorState, individual: boolean): void { + const fields = individual ? CORNERS : (["all"] as const); + for (const field of fields) { + const corner = field === "all" ? "tl" : field; + const input = form.elements.namedItem(`fb-${field}`) as HTMLInputElement; + if (!overrides.has(`fb-${field}`)) + input.value = Number(state.fallbackRadii[corner].toFixed(3)).toString(); + } +} + +function render(data: Record): void { + frameCard(data); + const state = makeState(data, cardSize()); + const individual = data.mode === "individual"; + const style = document.documentElement.style; + for (const corner of CORNERS) { + style.setProperty(`--r-${corner}`, `${state.radii[corner]}rem`); + style.setProperty(`--fb-r-${corner}`, `${state.fallbackRadii[corner]}rem`); + style.setProperty(`--shape-${corner}`, shapeValue(state.shapes[corner])); + } + for (const input of form.querySelectorAll(".shape-control .range-input")) { + const value = shape(data, input.dataset.bind!); + input.setAttribute("aria-valuetext", shapeValue(value)); + } + const clip = exactClipPath({ ...state, unit: "rem" }); + style.setProperty("--preview-clip", clip ?? "none"); + stage.dataset.preview = text(data.preview); + stage.dataset.exact = String(state.exact && Boolean(clip)); + // No clip path means one of two things, and they need opposite treatment: every corner + // is round (border-radius already draws it) or every corner is square (nothing to draw). + stage.dataset.clip = clip ? "path" : "none"; + stage.dataset.square = String(CORNERS.every((corner) => state.shapes[corner] === Infinity)); + card.textContent = text(data.text) || " "; + const declarations = declarationValues(state); + readout.textContent = `border-radius: ${declarations.radius};\ncorner-shape: ${declarations.shape};`; + setFallbackFields(state, individual); + output.textContent = emitCss(state); + void highlightElement(output, "css", "multiline", { hideLineNumbers: true }); + form.dataset.mode = individual ? "individual" : "all"; +} + +function syncPair(target: HTMLInputElement): void { + const binding = target.dataset.bind; + if (!binding) return; + const hidden = form.querySelector(`[data-value-for="${binding}"]`)!; + const peers = form.querySelectorAll(`[data-bind="${binding}"]`); + let value = target.value.trim().toLowerCase().replace("∞", "infinity"); + if (value === "+infinity") value = "infinity"; + const finite = Number(value); + if (Number.isFinite(finite)) { + const range = [...peers].find((peer) => peer.type === "range"); + const clamped = range + ? Math.min(Number(range.max), Math.max(Number(range.min), finite)).toString() + : value; + value = clamped; + for (const peer of peers) peer.value = clamped; + } else if (value === "infinity" || value === "-infinity") { + for (const peer of peers) { + peer.value = peer.type === "range" ? (value.startsWith("-") ? peer.min : peer.max) : value; + } + } else { + return; + } + hidden.value = value; +} + +function selectPreset(target: HTMLInputElement): void { + const binding = target.dataset.presetFor; + if (!binding) return; + const numberInput = form.querySelector( + `[data-bind="${binding}"]:not([type="range"])`, + )!; + numberInput.value = target.value; + syncPair(numberInput); +} + +function updatePresetStates(data: Record): void { + for (const preset of form.querySelectorAll("[data-preset-for]")) { + preset.checked = text(data[preset.dataset.presetFor!]) === preset.value; + } +} + +form.addEventListener("input", (event) => { + const target = event.target as HTMLInputElement; + selectPreset(target); + syncPair(target); + if (target.name.startsWith("fb-")) overrides.add(target.name); + const binding = target.dataset.bind ?? target.dataset.presetFor; + if (binding?.startsWith("shape-") || binding?.startsWith("radius-")) { + overrides.delete(`fb-${binding.split("-").at(-1)}`); + } + const data = Object.fromEntries(new FormData(form)); + updatePresetStates(data); + render(data); +}); + +document.querySelector("#copy-css")!.addEventListener("click", async () => { + await navigator.clipboard.writeText(output.textContent ?? ""); + copyStatus.textContent = "Copied."; + setTimeout(() => (copyStatus.textContent = ""), 2000); +}); + +// A frame that grows with the viewport moves the point where the radii start clamping, +// and the clamp is baked into the emitted clip path. +window.addEventListener("resize", () => render(Object.fromEntries(new FormData(form)))); + +unitToggle.addEventListener("click", () => { + unit.value = unit.value === "rem" ? "px" : "rem"; + unitToggle.textContent = unit.value; + unitToggle.ariaLabel = `Unit: ${unit.value}. Switch to ${unit.value === "rem" ? "px" : "rem"}`; + unit.dispatchEvent(new Event("input", { bubbles: true })); +}); + +render(Object.fromEntries(new FormData(form))); diff --git a/src/pages/corner-shape/_lib/superellipse.test.ts b/src/pages/corner-shape/_lib/superellipse.test.ts new file mode 100644 index 0000000..07d1f33 --- /dev/null +++ b/src/pages/corner-shape/_lib/superellipse.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; + +import { + clampRadii, + fallbackRatio, + fitSegment, + fitSuperellipse, + gamma, + quadrantArea, + segmentDeviation, +} from "./superellipse"; + +describe("superellipse geometry", () => { + test("matches the closed-form reference values", () => { + expect(gamma(1.5)).toBeCloseTo(0.8862269, 7); + expect(quadrantArea(2)).toBeCloseTo(Math.PI / 4, 12); + expect(quadrantArea(1)).toBeCloseTo(0.5, 12); + expect(fallbackRatio(1)).toBe(1); + expect(fallbackRatio(2)).toBeCloseTo(0.5831, 4); + expect(fallbackRatio(3)).toBeCloseTo(0.3168, 4); + }); + + test("fits a circle with the textbook cubic", () => { + const circle = fitSegment(1, 0, Math.PI / 2); + expect(circle.control1.x).toBeCloseTo(0, 6); + expect(1 - circle.control1.y).toBeCloseTo(0.552285, 6); + expect(segmentDeviation(1, 0, Math.PI / 2, circle)).toBeLessThan(0.0004); + }); + + test("keeps adaptive fits and radii within bounds", () => { + // The whole slider range, not just the convex half. Deep concave used to miss by a + // mile here — s = -2.5 sat at 0.98% and s = -3 at 8.56% — because the deviation was + // read radially and blew up near the edges, not because the curves could not be fitted. + for (const s of [...Array.from({ length: 33 }, (_, i) => -3 + i * 0.25), -2.9, -2.1, 3.5]) { + for (const segment of fitSuperellipse(s)) { + expect( + segmentDeviation(s, segment.startAngle, segment.endAngle, segment), + ).toBeLessThanOrEqual(0.0025); + } + } + expect(clampRadii([10, 10, 10, 10], 12, 8)).toEqual([4, 4, 4, 4]); + }); + + test("does not pay for the concave range in segments", () => { + // Converging is half of it: a deviation reading that over-reports still splits, and + // the emitted CSS carries every segment. s = -3 used to ship 22 per corner and miss + // tolerance anyway. Twelve is the worst case across the range now. + for (let s = -3; s < 0; s += 0.1) { + expect(fitSuperellipse(s).length).toBeLessThanOrEqual(12); + } + }); + + test("keeps measuring concave fits instead of returning NaN", () => { + // A concave fit can bulge outside the corner box, where the radial reading used to go + // NaN. NaN compares false against the tolerance, so the adaptive split gave up after + // one segment and shipped a curve nobody had measured. + for (let s = -3; s < 0; s += 0.25) { + for (const segment of fitSuperellipse(s)) { + expect(segmentDeviation(s, segment.startAngle, segment.endAngle, segment)).not.toBeNaN(); + } + } + expect(fitSuperellipse(-2).length).toBeGreaterThan(1); + }); + + test("represents scoop exactly as a quadratic", () => { + let deviation = 0; + for (let index = 0; index <= 1000; index += 1) { + const t = index / 1000; + const bezier = { x: t ** 2, y: (1 - t) ** 2 }; + deviation = Math.max(deviation, Math.abs(Math.sqrt(bezier.x) + Math.sqrt(bezier.y) - 1)); + } + expect(deviation).toBeLessThan(1e-9); + }); +}); diff --git a/src/pages/corner-shape/_lib/superellipse.ts b/src/pages/corner-shape/_lib/superellipse.ts new file mode 100644 index 0000000..b37ad10 --- /dev/null +++ b/src/pages/corner-shape/_lib/superellipse.ts @@ -0,0 +1,245 @@ +export type Point = { x: number; y: number }; +export type Cubic = { + start: Point; + control1: Point; + control2: Point; + end: Point; + startAngle: number; + endAngle: number; +}; + +const LANCZOS = [ + 0.9999999999998099, 676.5203681218851, -1259.1392167224028, 771.3234287776531, -176.6150291621406, + 12.5073432786869, -0.13857109526572, 0.000009984369578, 0.0000001505632735, +]; + +export function gamma(z: number): number { + if (z < 0.5) return Math.PI / (Math.sin(Math.PI * z) * gamma(1 - z)); + const shifted = z - 1; + const sum = LANCZOS.slice(1).reduce( + (value, coefficient, index) => value + coefficient / (shifted + index + 1), + LANCZOS[0], + ); + const t = shifted + 7.5; + return Math.sqrt(2 * Math.PI) * t ** (shifted + 0.5) * Math.exp(-t) * sum; +} + +export function quadrantArea(k: number): number { + if (k === Infinity) return 1; + if (k === 0) return 0; + return gamma(1 + 1 / k) ** 2 / gamma(1 + 2 / k); +} + +export function fallbackRatio(s: number): number { + if (s === 1) return 1; + if (s === Infinity) return 0; + return Math.sqrt((1 - quadrantArea(2 ** s)) / (1 - Math.PI / 4)); +} + +function pointAt(s: number, angle: number): Point { + const power = 2 / 2 ** s; + return { + x: 1 - Math.cos(angle) ** power, + y: 1 - Math.sin(angle) ** power, + }; +} + +function tangentAt(s: number, angle: number): Point { + const power = 2 / 2 ** s; + const epsilon = 1e-8; + const a = Math.min(Math.PI / 2 - epsilon, Math.max(epsilon, angle)); + const tangent = { + x: power * Math.cos(a) ** (power - 1) * Math.sin(a), + y: -power * Math.sin(a) ** (power - 1) * Math.cos(a), + }; + const length = Math.hypot(tangent.x, tangent.y); + return { x: tangent.x / length, y: tangent.y / length }; +} + +function solve(a: Point, b: Point, value: Point): [number, number] { + const determinant = a.x * b.y - a.y * b.x; + return [ + (value.x * b.y - value.y * b.x) / determinant, + (a.x * value.y - a.y * value.x) / determinant, + ]; +} + +export function fitSegment(s: number, startAngle: number, endAngle: number): Cubic { + const start = pointAt(s, startAngle); + const end = pointAt(s, endAngle); + const tangentStart = tangentAt(s, startAngle); + const tangentEnd = tangentAt(s, endAngle); + const midpoint = pointAt(s, (startAngle + endAngle) / 2); + const target = { + x: (8 * midpoint.x - 4 * start.x - 4 * end.x) / 3, + y: (8 * midpoint.y - 4 * start.y - 4 * end.y) / 3, + }; + const [startHandle, endHandle] = solve( + tangentStart, + { x: -tangentEnd.x, y: -tangentEnd.y }, + target, + ); + return { + start, + control1: { + x: start.x + startHandle * tangentStart.x, + y: start.y + startHandle * tangentStart.y, + }, + control2: { + x: end.x - endHandle * tangentEnd.x, + y: end.y - endHandle * tangentEnd.y, + }, + end, + startAngle, + endAngle, + }; +} + +function cubicPoint(segment: Cubic, t: number): Point { + const u = 1 - t; + return { + x: + u ** 3 * segment.start.x + + 3 * u ** 2 * t * segment.control1.x + + 3 * u * t ** 2 * segment.control2.x + + t ** 3 * segment.end.x, + y: + u ** 3 * segment.start.y + + 3 * u ** 2 * t * segment.control1.y + + 3 * u * t ** 2 * segment.control2.y + + t ** 3 * segment.end.y, + }; +} + +// The curve itself, as a polyline fine enough that measuring against it is the same as +// measuring against the real thing. Bisect in φ until every chord is shorter than +// `chord`, which puts samples where the curve actually turns rather than where φ happens +// to be evenly spaced — φ crowds the diagonal at low `k` and the edges at high `k`, and +// either way a fixed φ step leaves the interesting part unsampled. +function curveOutline(s: number, chord = 0.002): Point[] { + const first = pointAt(s, 0); + const outline = [first]; + const walk = (start: number, end: number, from: Point, to: Point, depth: number) => { + if (depth >= 40 || Math.hypot(to.x - from.x, to.y - from.y) <= chord) { + outline.push(to); + return; + } + const midpoint = (start + end) / 2; + const at = pointAt(s, midpoint); + walk(start, midpoint, from, at, depth + 1); + walk(midpoint, end, at, to, depth + 1); + }; + walk(0, Math.PI / 2, first, pointAt(s, Math.PI / 2), 0); + return outline; +} + +// How far one point strays from the curve: the nearest point on the outline, full stop. +// This used to compare radii at a matched polar angle, which is only trustworthy while +// the curve runs across the ray it is measured along. Deep concave corners run *down* +// that ray — near the edge the true radius falls from 1 to 0.92 within one ULP of the +// axis — so the old reading turned a 0.15% miss into 8.5% and sent the adaptive split +// chasing rounding noise. Nearest-point has no such blind spot, in either direction, and +// it needs no special case for a fit that bulges outside the corner box. +function distanceToOutline(outline: Point[], point: Point): number { + let best = Infinity; + for (let index = 1; index < outline.length; index += 1) { + const from = outline[index - 1]; + const to = outline[index]; + const runX = to.x - from.x; + const runY = to.y - from.y; + const lengthSquared = runX * runX + runY * runY; + const along = + lengthSquared === 0 + ? 0 + : Math.min( + 1, + Math.max(0, ((point.x - from.x) * runX + (point.y - from.y) * runY) / lengthSquared), + ); + const gapX = from.x + along * runX - point.x; + const gapY = from.y + along * runY - point.y; + best = Math.min(best, gapX * gapX + gapY * gapY); + } + return Math.sqrt(best); +} + +function deviationFrom(outline: Point[], segment: Cubic): number { + let maximum = 0; + for (let index = 1; index < 40; index += 1) { + maximum = Math.max(maximum, distanceToOutline(outline, cubicPoint(segment, index / 40))); + } + return maximum; +} + +export function segmentDeviation( + s: number, + startAngle: number, + endAngle: number, + segment = fitSegment(s, startAngle, endAngle), +): number { + return deviationFrom(curveOutline(s), segment); +} + +// One drag of the shape slider asks for the same curve eight times: four corners, fitted +// once for the live preview and again for the emitted CSS. The fit only depends on `s`, +// and measuring against the outline costs real milliseconds at the concave end, so keep +// recent answers. Bounded, because a drag sweeps through a lot of distinct values. +const fits = new Map(); +const FIT_CACHE_LIMIT = 64; + +export function fitSuperellipse(s: number, tolerance = 0.0025): Cubic[] { + if (s === 0) { + return [ + { + start: { x: 0, y: 1 }, + control1: { x: 1 / 3, y: 2 / 3 }, + control2: { x: 2 / 3, y: 1 / 3 }, + end: { x: 1, y: 0 }, + startAngle: 0, + endAngle: Math.PI / 2, + }, + ]; + } + const key = `${s}|${tolerance}`; + const cached = fits.get(key); + // Copied on the way out so a caller reordering the segments cannot poison the cache. + if (cached) return cached.slice(); + // Built once and measured against, rather than rebuilt per segment: it is the same + // curve every time, and the recursion below asks about it a few hundred times. + const outline = curveOutline(s); + const result: Cubic[] = []; + const split = (start: number, end: number, depth: number) => { + const segment = fitSegment(s, start, end); + // Depth 6 clears the whole slider range. Nothing in s ∈ [-3, 5] reaches the cap — + // the worst case left is 0.249% at s = -2, and convex shapes still finish by depth 3. + if (depth < 6 && deviationFrom(outline, segment) > tolerance) { + const midpoint = (start + end) / 2; + split(start, midpoint, depth + 1); + split(midpoint, end, depth + 1); + } else { + result.push(segment); + } + }; + split(0, Math.PI / 2, 0); + if (fits.size >= FIT_CACHE_LIMIT) { + const oldest = fits.keys().next().value; + if (oldest !== undefined) fits.delete(oldest); + } + fits.set(key, result); + return result.slice(); +} + +export function clampRadii( + radii: [number, number, number, number], + width: number, + height: number, +): [number, number, number, number] { + const [topLeft, topRight, bottomRight, bottomLeft] = radii; + const factor = Math.min( + 1, + width / (topLeft + topRight || 1), + height / (topRight + bottomRight || 1), + width / (bottomLeft + bottomRight || 1), + height / (topLeft + bottomLeft || 1), + ); + return radii.map((radius) => radius * factor) as [number, number, number, number]; +} diff --git a/src/pages/corner-shape/corner-shape.css b/src/pages/corner-shape/corner-shape.css new file mode 100644 index 0000000..64573c8 --- /dev/null +++ b/src/pages/corner-shape/corner-shape.css @@ -0,0 +1,307 @@ +/* State selectors intentionally follow their shared control styles. */ +/* stylelint-disable no-descending-specificity */ +@layer reset, ui; + +@import url("../../shared/styles/reset.css") layer(reset); + +/* palette, DO NOT CHANGE PALETTE COLORS */ + +/* https://coolors.co/f2bc43-eadbce-2a593e-4a90e2-d67a81 */ +:root { + --color-sunflower-gold-20: oklch(99.8% 0.008 84.44deg); + --color-sunflower-gold-50: oklch(97.45% 0.021 85.94deg); + --color-sunflower-gold-100: oklch(95.23% 0.043 86.59deg); + --color-sunflower-gold-200: oklch(90.38% 0.083 85.72deg); + --color-sunflower-gold-300: oklch(86.02% 0.12 85.04deg); + --color-sunflower-gold-400: oklch(81.94% 0.147 82.91deg); + --color-sunflower-gold-500: oklch(77.88% 0.16 78.05deg); + --color-sunflower-gold-600: oklch(66.22% 0.135 78.6deg); + --color-sunflower-gold-700: oklch(53.69% 0.108 79.13deg); + --color-sunflower-gold-800: oklch(40.37% 0.08 80.06deg); + --color-sunflower-gold-900: oklch(26.21% 0.05 83.69deg); + --color-sunflower-gold-950: oklch(21.45% 0.04 88.06deg); + --color-almond-cream-50: oklch(96.36% 0.009 67.72deg); + --color-almond-cream-100: oklch(92.79% 0.018 64.92deg); + --color-almond-cream-200: oklch(85.44% 0.035 64.5deg); + --color-almond-cream-300: oklch(77.9% 0.054 62.21deg); + --color-almond-cream-400: oklch(70.53% 0.074 61.99deg); + --color-almond-cream-500: oklch(63.21% 0.093 60.44deg); + --color-almond-cream-600: oklch(53.75% 0.078 60.81deg); + --color-almond-cream-700: oklch(43.87% 0.061 60.52deg); + --color-almond-cream-800: oklch(33.64% 0.043 62.67deg); + --color-almond-cream-900: oklch(22.41% 0.025 63.25deg); + --color-almond-cream-950: oklch(18.6% 0.018 58.26deg); + --color-hunter-green-50: oklch(96.8% 0.012 162.05deg); + --color-hunter-green-100: oklch(93.5% 0.025 160.58deg); + --color-hunter-green-200: oklch(86.9% 0.047 160.95deg); + --color-hunter-green-300: oklch(80.46% 0.072 159.56deg); + --color-hunter-green-400: oklch(74.13% 0.097 158.06deg); + --color-hunter-green-500: oklch(67.77% 0.117 156.25deg); + --color-hunter-green-600: oklch(57.75% 0.099 156.46deg); + --color-hunter-green-700: oklch(47.05% 0.078 156.88deg); + --color-hunter-green-800: oklch(35.69% 0.055 157.68deg); + --color-hunter-green-900: oklch(23.58% 0.033 157.24deg); + --color-hunter-green-950: oklch(19.42% 0.023 159.66deg); + --color-blue-energy-50: oklch(95.7% 0.015 247.99deg); + --color-blue-energy-100: oklch(91.23% 0.033 252.47deg); + --color-blue-energy-200: oklch(82.69% 0.067 251.39deg); + --color-blue-energy-300: oklch(74.07% 0.102 252.29deg); + --color-blue-energy-400: oklch(65.72% 0.136 253.33deg); + --color-blue-energy-500: oklch(57.93% 0.169 255.2deg); + --color-blue-energy-600: oklch(49.4% 0.141 254.93deg); + --color-blue-energy-700: oklch(40.54% 0.113 254.58deg); + --color-blue-energy-800: oklch(31.12% 0.081 253.83deg); + --color-blue-energy-900: oklch(20.66% 0.046 253.72deg); + --color-blue-energy-950: oklch(17.44% 0.035 251.91deg); + --color-dusty-rose-50: oklch(95.17% 0.015 7.49deg); + --color-dusty-rose-100: oklch(90.5% 0.03 12.62deg); + --color-dusty-rose-200: oklch(81.07% 0.063 13.6deg); + --color-dusty-rose-300: oklch(71.88% 0.099 14.24deg); + --color-dusty-rose-400: oklch(63.19% 0.137 16.66deg); + --color-dusty-rose-500: oklch(55.57% 0.171 20.23deg); + --color-dusty-rose-600: oklch(47.35% 0.143 19.86deg); + --color-dusty-rose-700: oklch(38.76% 0.113 19.25deg); + --color-dusty-rose-800: oklch(29.67% 0.081 19.25deg); + --color-dusty-rose-900: oklch(19.92% 0.046 17.63deg); + --color-dusty-rose-950: oklch(16.66% 0.034 14.48deg); +} + +/* One family per job, so a colour tells you what it is before you read the label: + almond cream is every surface and every neutral text tone, blue energy is + anything you can operate plus the card it draws, hunter green means code, and + dusty rose is reserved for the few places that have to interrupt you. Steps + track the lightness ordering the page had before, so contrast stays put. */ +/* stylelint-disable-next-line no-duplicate-selectors -- kept apart from the palette above */ +:root { + --color-text-primary: var(--color-sunflower-gold-950); + --color-text-secondary: var(--color-sunflower-gold-900); + --color-accent: var(--color-blue-energy-600); + --color-page: var(--color-sunflower-gold-20); + --color-muted: var(--color-almond-cream-700); + --color-kicker: var(--color-dusty-rose-600); + --color-warning-border: var(--color-dusty-rose-500); + --color-warning-text: var(--color-dusty-rose-700); + --color-border: var(--color-almond-cream-200); + --color-divider: var(--color-almond-cream-100); + --color-surface: var(--color-sunflower-gold-50); + --color-dot: var(--color-almond-cream-300); + --color-card-text: var(--color-sunflower-gold-50); + --color-card-start: var(--color-blue-energy-600); + --color-card-end: var(--color-blue-energy-800); + --color-label: var(--color-almond-cream-700); + + /* Two jobs that pull opposite ways: the ring has to stand off the page, the + soft fill has to stay light enough for accent text to sit on it. */ + --color-focus: var(--color-blue-energy-500); + --color-accent-soft: var(--color-blue-energy-100); + --color-control-bg: var(--color-sunflower-gold-50); + --color-secondary: var(--color-almond-cream-600); + --color-group-border: var(--color-almond-cream-200); + --color-group-title: var(--color-almond-cream-900); + --color-syntax-comment: var(--color-almond-cream-600); + --color-syntax-keyword: var(--color-dusty-rose-600); + --color-syntax-number: var(--color-sunflower-gold-700); + --color-syntax-string: var(--color-hunter-green-700); + --color-syntax-function: var(--color-blue-energy-600); + --color-transparent: oklch(0% 0 0deg / 0%); + --color-code: var(--color-hunter-green-700); + + /* Live state, not design tokens. Colours are the only thing this subproject keeps in a + custom property by choice; everything below is here because `_lib/generator.ts` + rewrites it on `documentElement.style` on every input event, and these values are + only the starting position the page is server-rendered in. + + --size share of the preview frame the card fills, 0.1 to 1 + --ratio card width divided by card height + --p card padding as a percentage — resolved against the frame, so + `.preview-card` scales it back down to the card itself + --fs card font size, in rem + --r-tl/tr/br/bl per-corner border-radius of the enhanced tier + --fb-r-* per-corner border-radius of the plain fallback tier, one per corner + --shape-* per-corner `corner-shape` value: a keyword or `superellipse()` + --preview-clip the exact fallback's outline as a clip-path, or `none` when the + shape needs no path (all-round, all-square) */ + --size: 0.6; + --ratio: 1; + --p: 10%; + --fs: 1rem; + --r-tl: 1rem; + --r-tr: 1rem; + --r-br: 1rem; + --r-bl: 1rem; + --fb-r-tl: 0.583rem; + --fb-r-tr: 0.583rem; + --fb-r-br: 0.583rem; + --fb-r-bl: 0.583rem; + --shape-tl: squircle; + --shape-tr: squircle; + --shape-br: squircle; + --shape-bl: squircle; + --preview-clip: none; + + min-width: 20rem; + + font-family: var(--font-atkinson), system-ui, sans-serif; + color: var(--color-text-primary); + + color-scheme: light; + accent-color: var(--color-accent); + background: var(--color-page); +} + +@layer reset { + p sup { + font-size: 0.8em; + line-height: 0; + } +} + +/* Everything below is shared across the page's components. A component's own stylesheet + holds only its layout; anything two of them would otherwise copy lives here, unlayered + rules in those files beating these by virtue of the cascade layer. */ +@layer ui { + .page { + max-width: 83rem; + margin-inline: auto; + padding: clamp(1.25rem, 4vw, 3rem) 1.5rem 4rem; + } + + .section-title { + font-size: 1.15rem; + } + + .eyebrow { + font-size: 0.72rem; + font-weight: 800; + color: var(--color-kicker); + text-transform: uppercase; + } + + .code { + font-family: var(--font-atkinson-mono), ui-monospace, monospace; + font-size: 1.04em; + font-weight: 500; + color: var(--color-code); + letter-spacing: -0.04em; + } + + .panel { + padding: 1rem; + } + + /* Form fields */ + + .field { + display: grid; + margin-bottom: 0.65rem; + } + + .field-label { + margin-bottom: 0.25rem; + font-size: 0.78rem; + font-weight: 700; + color: var(--color-label); + } + + .field-hint { + display: block; + font-weight: 400; + color: var(--color-secondary); + } + + .text-input { + min-width: 0; + padding: 0.32rem 0.5rem; + border: 0; + border-bottom: 1px solid var(--color-group-border); + + font-size: 0.8rem; + color: inherit; + + background: var(--color-transparent); + } + + .range-input { + width: 100%; + } + + /* A slider and its typed value, side by side under one label. */ + .control-pair { + display: grid; + grid-template-columns: minmax(0, 1fr) 4.5rem; + gap: 0.3rem 0.5rem; + align-items: center; + + min-width: 0; + padding: 0; + border: 0; + } + + .control-pair .field-label { + grid-column: 1 / -1; + } + + /* Chips: a radio or checkbox wearing its label as the whole hit area, the input itself + taken out of the flow. Focus has to be borrowed from it by the label. */ + .chip-group { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + + min-width: 0; + padding: 0; + border: 0; + } + + .chip { + cursor: pointer; + + position: relative; + + padding: 0.42rem 0.62rem; + border: 1px solid var(--color-border); + border-radius: 0.35rem; + + font-size: 0.75rem; + font-weight: 700; + + background: var(--color-control-bg); + } + + .chip-input { + position: absolute; + opacity: 0; + } + + .chip:has(.chip-input:checked) { + border-color: var(--color-accent); + color: var(--color-surface); + background: var(--color-accent); + } + + .button { + cursor: pointer; + + padding: 0.55rem 0.8rem; + border: 1px solid var(--color-accent); + border-radius: 0.35rem; + + font-weight: 750; + color: var(--color-surface); + + background: var(--color-accent); + } + + .button-ghost { + color: var(--color-accent); + background: var(--color-control-bg); + } + + .text-input:focus-visible, + .range-input:focus-visible, + .button:focus-visible, + .chip:has(:focus-visible) { + outline: 3px solid var(--color-focus); + outline-offset: 2px; + } +} diff --git a/src/pages/corner-shape/index.astro b/src/pages/corner-shape/index.astro new file mode 100644 index 0000000..dcd215f --- /dev/null +++ b/src/pages/corner-shape/index.astro @@ -0,0 +1,39 @@ +--- +import { Font } from "astro:assets"; + +import Baseline from "./_components/baseline.astro"; +import Generator from "./_components/generator.astro"; +import Head from "./_components/head.astro"; +import SupportWarning from "./_components/support-warning.astro"; +import "./corner-shape.css"; + +const baseUrl = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/`; +--- + + + + + + + + + + + CSS corner-shape generator | @a-dev/probes + + + +
+ + + + +
+ + From 2aeae40334bb408aa51515f3d7010caf46811cea Mon Sep 17 00:00:00 2001 From: a-dev Date: Mon, 3 Aug 2026 01:30:57 +0400 Subject: [PATCH 2/7] [WIP]: colors, improve design, texts --- .../corner-shape/_components/baseline.astro | 22 +-- .../corner-shape/_components/baseline.css | 71 ++++++++- src/pages/corner-shape/_components/head.astro | 50 +++--- src/pages/corner-shape/_components/head.css | 70 ++++++++- .../_components/output-section.astro | 12 +- .../_components/output-section.css | 35 ++++- .../corner-shape/_components/stage.astro | 9 +- src/pages/corner-shape/corner-shape.css | 148 ++---------------- src/pages/corner-shape/vars.css | 143 +++++++++++++++++ 9 files changed, 363 insertions(+), 197 deletions(-) create mode 100644 src/pages/corner-shape/vars.css diff --git a/src/pages/corner-shape/_components/baseline.astro b/src/pages/corner-shape/_components/baseline.astro index 94d9a39..3d8f454 100644 --- a/src/pages/corner-shape/_components/baseline.astro +++ b/src/pages/corner-shape/_components/baseline.astro @@ -3,15 +3,8 @@ import "./baseline.css"; ---