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/README.md b/README.md index 625a6d1..f3c005b 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,19 @@ This repository is a collection of experiments and probes in web development, de ## Probes -- [CSS `overscroll-behavior` rubber banding: how to show the right color behind the site when you pull it](https://dev.to/a-dev/css-overscroll-behavior-rubber-banding-the-right-color-behind-the-page-when-you-pull-it-47mj): Explores overscroll behavior in web browsers, especially the "rubber banding" effect on touch devices. It compares browser implementations and shows how CSS can customize or disable the behavior. +- Site: [https://a-dev.github.io/probes/overscroll/](https://a-dev.github.io/probes/overscroll/)

+Article: [CSS `overscroll-behavior` rubber banding: how to show the right color behind the site when you pull it](https://dev.to/a-dev/css-overscroll-behavior-rubber-banding-the-right-color-behind-the-page-when-you-pull-it-47mj): Explores overscroll behavior in web browsers, especially the "rubber banding" effect on touch devices. It compares browser implementations and shows how CSS can customize or disable the behavior. +

+ Code: [src/pages/overscroll](./src/pages/overscroll) -- [Choreography of validation: how to make your auth form seamless and usable](https://dev.to/a-dev/choreography-of-validation-how-to-make-your-auth-form-seamless-and-usable-95b): Reward early, punish late. This is the main principle of good validation UX: find the right moment to tell users they messed up. And the right moment to tell them they're back on track. + --- +- Site: [https://a-dev.github.io/probes/validation/](https://a-dev.github.io/probes/validation/)

+[Choreography of validation: how to make your auth form seamless and usable](https://dev.to/a-dev/choreography-of-validation-how-to-make-your-auth-form-seamless-and-usable-95b): Reward early, punish late. This is the main principle of good validation UX: find the right moment to tell users they messed up. And the right moment to tell them they're back on track.

Code: [src/pages/validation](./src/pages/validation) + +--- + +- Site: [https://a-dev.github.io/probes/corner-shape/](https://a-dev.github.io/probes/corner-shape/)

CSS `corner-shape` generator help you make your corners round, squircle, or concave. Two fallback options are provided for browsers that don't support the property yet.

Code: [src/pages/corner-shape](./src/pages/corner-shape) + +--- ...more coming soon. 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..a01d9dd --- /dev/null +++ b/src/pages/corner-shape/_components/baseline.astro @@ -0,0 +1,133 @@ + + +
+
+
+ Browser support of corner-shape +
+ + Support data didn't load. Check the Web Platform Status dashboard. + +
+
+ + 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..e63af1f --- /dev/null +++ b/src/pages/corner-shape/_components/card-properties.astro @@ -0,0 +1,145 @@ +--- +import { DIMENSIONS } from "../_lib/controls"; + +import SliderField from "./slider-field.astro"; +--- + +
+ Size and text → +
+
+
+ + +
+ +
+ +
+ { + DIMENSIONS.map((dimension) => ( + + )) + } +
+
+
+ + + + 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..c22b769 --- /dev/null +++ b/src/pages/corner-shape/_components/corner-fields.astro @@ -0,0 +1,61 @@ +--- +import { + DEFAULT_RADIUS, + DEFAULT_SHAPE, + RADIUS_RANGE, + SHAPE_RANGE, +} from "../_lib/controls"; + +import PresetToggle from "./preset-toggle.astro"; +import SliderField from "./slider-field.astro"; + +// The three fields describing one corner: a preset, the exponent behind it, and the radius +// it bends through. The fallback radius is derived from those, never typed. Identical for +// all four corners or one, so only the name suffix and the spoken labels change. +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`; +--- + + + + + Superellipse + + + + 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..f732ed1 --- /dev/null +++ b/src/pages/corner-shape/_components/generator.astro @@ -0,0 +1,34 @@ +--- +import OutputSection from "./output-section.astro"; +import ShapeSettings from "./shape-settings.astro"; +import Stage from "./stage.astro"; + +// One form holds the whole tool: the preview, every control, and the CSS it emits, so +// `_lib/generator.ts` can serialise it on every input event instead of reading controls +// one by one. The DOM stays the single source of truth. +--- + + +
+
+ + +
+ + + + diff --git a/src/pages/corner-shape/_components/head.astro b/src/pages/corner-shape/_components/head.astro new file mode 100644 index 0000000..9f15947 --- /dev/null +++ b/src/pages/corner-shape/_components/head.astro @@ -0,0 +1,229 @@ +
+
+
CSS tool
+

Corner-shape generator

+
+

+ Adjust the sliders until you're happy with how the corners look, then copy + the CSS. Two fallback options: default{" "} + border-radius recomputed for equivalent + corner area (different numbers, same look), or exact clip-path curve tracing. +

+
+ In depth→ +
+

+ 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/output-section.astro b/src/pages/corner-shape/_components/output-section.astro new file mode 100644 index 0000000..ff5e29a --- /dev/null +++ b/src/pages/corner-shape/_components/output-section.astro @@ -0,0 +1,299 @@ +--- +import Toggle from "./toggle.astro"; + +// The take-away. The order is the point: the fallback declaration comes first so every +// browser gets something, and the `@supports` block only reaches those that can honour the +// real shape. `_lib/emit.ts` builds the text; this is the frame around it. +--- + +
+

+  
+
+ { + /* The two labels are stacked on top of each other so the swap can cross-fade; + `visibility` keeps the inactive one out of the accessible name. */ + } + +
+
+ { + /* Styled after moderncss.dev's pure-CSS checkbox: the native input keeps all of + its behaviour, `appearance: none` takes away only the paint, and a `::before` + clipped into a tick draws the checked state. Everything sizes in `em`, so the + box grows with the label's font-size and nothing needs to be re-measured. */ + } + + + +
+
+
+ + diff --git a/src/pages/corner-shape/_components/preset-toggle.astro b/src/pages/corner-shape/_components/preset-toggle.astro new file mode 100644 index 0000000..0ba0419 --- /dev/null +++ b/src/pages/corner-shape/_components/preset-toggle.astro @@ -0,0 +1,34 @@ +--- +import { PRESETS } from "../_lib/controls"; + +import Toggle from "./toggle.astro"; + +// The named stops on the shape scale, as one six-position switch. Picking a stop writes its +// number into the shape field, and the field writes back: a stop stays lit only while the +// number matches, so dragging off `2` un-checks Squircle and the thumb hides. +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; + /** Value of the stop that starts lit. */ + value: string; + class?: string; +} + +const { name, target, legend, value, class: className } = Astro.props; +--- + + ({ + label: preset.label, + value: preset.value, + presetFor: target, + }))} +/> 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..f21a6c7 --- /dev/null +++ b/src/pages/corner-shape/_components/shape-settings.astro @@ -0,0 +1,115 @@ +--- +import { CORNER_LABELS } from "../_lib/controls"; +import CardProperties from "./card-properties.astro"; +import CornerFields from "./corner-fields.astro"; +import Toggle from "./toggle.astro"; + +// The corners. Both sets of controls stay in the markup and the mode switch shows one of +// them, so the form always serialises a complete answer for whichever mode is active. +--- + +
+
+
Controls
+ +
+ + +
+ +
+ +
+ { + CORNER_LABELS.map(({ id, label }) => ( +
+ {label} + +
+ )) + } +
+
+ 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..f80a490 --- /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/form.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..9d857e7 --- /dev/null +++ b/src/pages/corner-shape/_components/stage.astro @@ -0,0 +1,138 @@ +--- +import Toggle from "./toggle.astro"; + +// The card, plus the switch deciding which tier draws it. Neither tier is a mockup — the +// enhanced card is the real declaration, the fallback card the real generated CSS — so +// flipping between them shows what a browser without support actually loses. +--- + +
+
+
Preview
+ +
+ + +
+ + 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..2ce6064 --- /dev/null +++ b/src/pages/corner-shape/_components/support-warning.astro @@ -0,0 +1,26 @@ + + + diff --git a/src/pages/corner-shape/_components/toggle.astro b/src/pages/corner-shape/_components/toggle.astro new file mode 100644 index 0000000..6c4df19 --- /dev/null +++ b/src/pages/corner-shape/_components/toggle.astro @@ -0,0 +1,214 @@ +--- +// A radio group that reads as one switch: a track with a thumb that slides to the option +// you picked. The count is all that changes between two options and six, and the inputs +// stay real radios, so a toggle needs no script of its own. +export interface ToggleOption { + label: string; + value: string; + /** Spoken/hover text, for labels the track may be too narrow to show in full. */ + title?: string; + /** `data-preset-for` — the shape field this option writes into, if it drives one. */ + presetFor?: string; +} + +interface Props { + name: string; + legend?: string; + options: ToggleOption[]; + value?: string; + size?: "s" | "m"; + block?: boolean; + class?: string; +} + +const { + name, + legend, + options, + value, + size = "s", + block = false, + class: className, +} = Astro.props; +--- + +
+ {Boolean(legend) && {legend}} +
+ { + options.map((option, index) => ( + + + + + )) + } +
+
+ diff --git a/src/pages/corner-shape/_docs/SPEC.md b/src/pages/corner-shape/_docs/SPEC.md new file mode 100644 index 0000000..5f8235b --- /dev/null +++ b/src/pages/corner-shape/_docs/SPEC.md @@ -0,0 +1,123 @@ +# corner-shape generator — current state + +A one-page generator for the CSS `corner-shape` property at `/corner-shape`: drag sliders, watch a live preview, copy the CSS. What makes it more than a syntax formatter is the **fallback** — `corner-shape` is Chromium-only, so the generator computes a fallback that matches the enhanced shape as closely as older properties allow, instead of guessing at a radius. The maths behind that is in [`fallback-algorithm.md`](./fallback-algorithm.md). + +No React. One `
` holds the whole tool; a vanilla `input` listener re-serialises it and repaints from the result, so the DOM is the single source of truth. + +## Page + +``` +head.astro title, intro, folded "In depth" explainer +support-warning.astro shown only when the browser lacks corner-shape +generator.astro + stage.astro preview card, Enhanced/Fallback toggle, value readout + shape-settings.astro card properties, All/Individual mode, corner fields + output-section.astro emitted CSS, copy button, exact toggle, selector, unit +baseline.astro +``` + +Single column, `max-width: 83rem`. Above `54rem` the stage and controls sit side by side and the stage is sticky; the output block spans the full width below them. + +## Render pipeline + +`_lib/generator.ts` on every `input` / `change` / `resize`, coalesced to one run per animation frame (a drag outruns paint, and measuring the card forces layout): + +1. `readValues()` — `FormData` over the form. +2. `frameCard()` — write `--size`, `--ratio`, `--p`, `--fs` to `documentElement.style`. +3. Measure the laid-out card, then `limitRadii()` re-hangs every radius slider's `max` at half the card's shorter side. If a radius had to shrink, the values are re-read. +4. `makeState()` — build `GeneratorState`: shapes, radii, derived fallback radii, unit, selector, exact flag. +5. Paint: custom properties onto `:root`, `data-*` on the stage, then the emitted CSS into the + output block (skipped when the text is unchanged, since re-highlighting rebuilds the block). + +Custom properties driving the preview live in `_styles/vars.css` with their server-rendered starting values: `--size`, `--ratio`, `--p`, `--fs`, `--r-*`, `--fb-r-*`, `--shape-*`, `--preview-clip`. + +`_lib/form.ts` owns the control layer and knows nothing about shapes: element lookups done once, `data-value-for` / `data-bind` keeping each field's hidden input, slider and text box in step, preset checks, radius ceilings, and the `--progress` each slider paints its played track from. + +## Controls + +| Control | Type | Range / step | Default | +| ---------------- | ------------------- | ------------------- | -------------------------- | +| Preview text | textarea + Clear | — | a sentence on the property | +| Size | slider + number | `0.1–1`, `0.01` | `0.6` of the frame | +| Aspect ratio | slider + number | `0.25–4`, `0.05` | `1` | +| Padding | slider + number | `0–30%`, `0.5` | `10%` | +| Font size | slider + number | `0.75–4rem`, `0.05` | `1rem` | +| Corner mode | All / Individual | — | All | +| Preset | 6-position toggle | six keywords | Squircle | +| Superellipse `s` | slider + text | `-3–5`, `0.1` | `2` | +| Radius | slider + number | `0–18rem`, `0.01` | `1rem` | +| Preview tier | Enhanced / Fallback | — | Enhanced | +| Exact fallback | checkbox | — | off | +| Selector | text | — | `.card` | +| Unit | rem / px | — | rem | + +Both corner modes stay in the markup and the mode switch shows one of them, so the form always serialises a complete answer for whichever mode is active. In Individual mode the preset, shape and radius fields repeat in a 2×2 grid mapped to the physical corners — shape and radius always travel together; there is no separate link toggle. + +Presets write their number into the shape field and stay lit only while it matches, so dragging off `2` un-checks Squircle. `notch` and `square` are `±infinity` and unreachable by dragging: picking one pins the slider to its end, and the shape field is a text input so it can hold `infinity` at all. The radius slider's `18rem` maximum is a server-side guess overwritten on the first render. + +**The fallback radius is not a control.** It is derived from `(r, s)` and only ever appears in the generated CSS. + +## Output + +Corner properties only — the size and padding sliders are staging for judging corners, and everything emitted is box-independent. `_lib/emit.ts` builds one string: the fallback declaration first so every browser gets something, then an `@supports` block for browsers that can honour the real shape. `px` output multiplies rem by 16; equal corners collapse through the `border-radius` shorthand rules. + +**Default tier** — a radius tuned to remove the same corner area (squircle at `1rem`): + +```css +.card { + border-radius: 0.583rem; +} + +@supports (corner-shape: squircle) { + .card { + border-radius: 1rem; + + corner-shape: squircle; + } +} +``` + +Concave shapes get the typed radius plus a comment saying it is a placeholder, not a match. + +**Exact tier** (opt-in) — the base rule carries a clip path and the `@supports` block removes it with `clip-path: none`. `bevel` and `notch` emit `polygon()`, everything else `shape()`; all-round and all-square need no path at all and fall back to the radius. Radii are clamped before emission. The trade-off is stated in a comment in the output: a clip path also cuts off `box-shadow`, `outline`, and anything the border draws outside it. That is why the exact tier is opt-in rather than the default. + +## Preview + +One card, A/B toggled between tiers so both land on the same pixels — which is how a half-pixel radius difference becomes visible. Neither tier is a mockup: the enhanced card carries the real declaration, the fallback card the real generated CSS. The card is sized as a fraction of a fixed dotted frame rather than in rem, so dragging to a wide flat box makes radius clamping engage; it is sized, not `scale()`d, because a transform would shrink the radius being judged. + +An inline script in `` sets `data-corner-shape="supported"` or `"unsupported"` on `` from `CSS.supports("corner-shape: squircle")` before paint, so there is no flash. When unsupported, the enhanced state is synthesized from the fitter's `clip-path`, the warning banner explains that, and the fitter runs every frame instead of only when the exact tier is on. + +The figure is `aria-hidden` — everything it shows is stated by the controls and the output. Sliders carry `aria-valuetext` (`squircle`, `superellipse(2.4)`), preset groups are radio groups in fieldsets, and the copy button reports success in a polite live region. + +## Files + +``` +src/pages/corner-shape/ + index.astro full page, per repo convention + _components/*.astro markup + component-local