diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 704d705..bd7135a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: pull_request: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index a42f632..00524fc 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -3,6 +3,7 @@ name: Deploy demo site on: push: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index cfb2c41..b3ea235 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -3,6 +3,7 @@ name: PR title check on: pull_request: types: [opened, edited, synchronize, reopened] + workflow_dispatch: jobs: check: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 881f068..011623b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: permissions: id-token: write diff --git a/.github/workflows/sync-readme.yml b/.github/workflows/sync-readme.yml index 26151bd..fc8a88d 100644 --- a/.github/workflows/sync-readme.yml +++ b/.github/workflows/sync-readme.yml @@ -10,6 +10,7 @@ on: - scripts/sync-readme.sh - vite.config.ts - README.md + workflow_dispatch: permissions: contents: write @@ -39,7 +40,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add README.md - git commit -m "docs: sync README code blocks [skip ci]" + git commit -m "docs: sync README code blocks" git push || { echo "::error::README is out of sync and could not push fix to branch" exit 1 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 66e338a..807a28a 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,5 +3,13 @@ "ignorePatterns": [], "sortTailwindcss": { "stylesheet": "./website/src/styles/global.css" - } + }, + "overrides": [ + { + "files": ["**/*.md"], + "options": { + "embeddedLanguageFormatting": "off" + } + } + ] } diff --git a/README.md b/README.md index baaabcf..cb436ca 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,83 @@ # @klinking/squircle -Tailwind CSS v4 squircle (superellipse) corner utilities with visual radius correction. - [![npm version](https://img.shields.io/npm/v/@klinking/squircle.svg)](https://www.npmjs.com/package/@klinking/squircle) +We're all excited about `corner-shape: squircle`, but we're in a pickle right now. Squircle corners look _better_ (natch), but at the same `border-radius`, they look _itty bitty_ compared to regular rounded corners. You're saying to yourself: "Who cares! I'll just crank up the border-radius until it look good and be done with it!" Then you see your site in Safari, and now your rounded corners are just _massive_. That's because Safari ain't supportin' no squircles yet. Now you gotta manually eyeball what border-radius kinda looks the same as the squircle and throw in an `@supports` rule and then your head explodes (why, head, why you explode?). Well… what if I told you you could eat your squircle and have your border-radius too? Read on, child. + > **[Interactive Demo →](https://dogmar.github.io/squircle)** -## Install +## Contents + + + +- [Requirements](#requirements) +- [Install & setup](#install--setup) +- [Utilities](#utilities) +- [Configuring theme tokens](#configuring-theme-tokens) +- [CSS function: `squircle-radius()`](#css-function-squircle-radius) +- [How the radius correction works](#how-the-radius-correction-works) +- [Browser support & fallback strategy](#browser-support--fallback-strategy) +- [Why it called "squircle" when it use "superellipse()"?](#why-it-called-squircle-when-it-use-superellipse) +- [Alternatives considered](#alternatives-considered) +- [Should you install or copy/paste?](#should-you-install-or-copypaste) +- [FAQ](#faq) +- [Copy/paste source](#copypaste-source) +- [Prior art & credits](#prior-art--credits) +- [License](#license) + + +## Requirements + +- **Tailwind CSS v4+.** The CSS utilities use v4's `@utility` and `--value()` APIs. The JS plugin is API-compatible with v3 but only tested and declared against v4 — see [FAQ](#faq) if you want to try it on v3. +- **Modern browsers** for the squircle shape itself. Unsupported browsers get a clean `border-radius` fallback that matches visual size of rounding; see [Browser support](#browser-support--fallback-strategy) for the feature-by-feature matrix. +- **Optional:** [`tailwind-merge`](https://github.com/dcastil/tailwind-merge) v2+ if your project already uses it (extra config below). +- **Optional:** CSS `@function` support if you use the standalone [`squircle-radius()`](#css-function-squircle-radius) — experimental. + + + +## Install & setup ```bash npm install @klinking/squircle ``` -## Usage +Then pick one of two integration paths. But don't pick wrong, else the Integration Ogre might… oh wait, no, just pick the one that suits your needs, they're essentially the same, but one allows more customization, in case my vars and classes conflict with ur existing vars and classes. -**CSS import** (recommended): +### Path A: CSS import (recommended) ```css +@import "tailwindcss"; @import "@klinking/squircle/tw-utils.css"; ``` -**JS plugin** (alternative): +That's it. All `squircle-*` classes are available. This path uses Tailwind v4's `@utility` directive, so everything is generated at build time with zero runtime cost. + +### Path B: JS plugin (for customization) + +Use this if you want to change the class prefix or the `--squircle-amt` CSS variable name: ```css +@import "tailwindcss"; @plugin "@klinking/squircle/tw-plugin"; ``` -**tw-merge** (optional — if you use tailwind-merge): +Or with options: + +```css +@import "tailwindcss"; +@plugin "@klinking/squircle/tw-plugin" { + prefix: sq; /* use `sq-md`, `sq-t-lg`, etc. */ + amt-var: --my-amt; /* use `--my-amt` instead of `--squircle-amt` */ +} +``` + +See [Configuring theme tokens](#configuring-theme-tokens) for what else you can customize. + +### tailwind-merge (optional) + +If your project already uses [`tailwind-merge`](https://github.com/dcastil/tailwind-merge) to de-duplicate conflicting classes, pull in the squircle conflict config so `rounded-lg squircle-md` resolves the way you'd expect: ```js import { squircleMergeConfig } from "@klinking/squircle/tw-merge-cfg"; @@ -37,65 +88,290 @@ const twMerge = extendTailwindMerge(squircleMergeConfig, { }); ``` -## What it does +## Utilities + +| Utility | Equivalent | Description | +| ---------------- | -------------- | --------------------------------- | +| `squircle-*` | `rounded-*` | All corners | +| `squircle-t-*` | `rounded-t-*` | Top corners | +| `squircle-r-*` | `rounded-r-*` | Right corners | +| `squircle-b-*` | `rounded-b-*` | Bottom corners | +| `squircle-l-*` | `rounded-l-*` | Left corners | +| `squircle-s-*` | `rounded-s-*` | Inline-start corners (logical) | +| `squircle-e-*` | `rounded-e-*` | Inline-end corners (logical) | +| `squircle-tl-*` | `rounded-tl-*` | Top-left corner | +| `squircle-tr-*` | `rounded-tr-*` | Top-right corner | +| `squircle-br-*` | `rounded-br-*` | Bottom-right corner | +| `squircle-bl-*` | `rounded-bl-*` | Bottom-left corner | +| `squircle-ss-*` | `rounded-ss-*` | Start-start corner (logical) | +| `squircle-se-*` | `rounded-se-*` | Start-end corner (logical) | +| `squircle-es-*` | `rounded-es-*` | End-start corner (logical) | +| `squircle-ee-*` | `rounded-ee-*` | End-end corner (logical) | +| `squircle-amt-*` | — | Superellipse exponent (default 2) | -CSS `corner-shape: superellipse()` makes corners follow a superellipse curve instead of a circular arc. But at the same `border-radius` value, superellipse corners look visually smaller. This package auto-adjusts the radius so `squircle-lg` visually matches `rounded-lg`. +### What values are accepted? -The adjusted radius is wrapped in a `@supports (corner-shape: superellipse(2))` rule, so browsers without support simply use the original `border-radius` unchanged. This means your corners will look visually consistent regardless of browser — no sudden changes when support lands, no broken fallbacks. Since browser support for `corner-shape` is still not universal, this gives you consistent visual border-radius forever. +Values are validated strictly so typos fail at build time instead of producing invalid CSS: + +- **`squircle-*` and its variants** accept the same theme values as `rounded-*` (`sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `full`, plus anything you add to `@theme`) and arbitrary lengths like `squircle-[16px]`. Non-length arbitraries (`[50%]`, `[foo]`) and paren refs (`squircle-(--my-radius)`) are rejected — use a theme key instead (see [Configuring theme tokens](#configuring-theme-tokens)). +- **`squircle-amt-*`** accepts bare numbers (`squircle-amt-2`), arbitrary numbers (`squircle-amt-[3.5]`), and theme values. Unit-bearing arbitraries (`[1em]`) and paren refs (`(--my-amt)`) are rejected. + +### What does `squircle-amt-*` control? + +The value is the `K` parameter passed to `superellipse(K)`, which controls how square the corner shape is: + +- **2** — the classic squircle (this package's default), same as the `squircle` keyword. Values greater than 2 get more and more square as they increase, becoming visually indistinguishable from a perfect square around 10 or higher. +- **1** — ordinary ellipse (same as the `round` keyword). The _classic_. Just like standard `border-radius`, no squircling at all. Why are you even here? +- **0** — straight bevel (same as the `bevel` keyword) +- **Negative values** — concave "scooped out" corners (`-1` = `scoop`, `-∞` = `notch`) + +See the [MDN reference for `superellipse()`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/superellipse) for the full spec. + +## Configuring theme tokens + +Everything that `squircle-*` and `squircle-amt-*` accept is driven by Tailwind's `@theme` block, so configuration is standard Tailwind — no special knobs. + +### Custom radius sizes + +Any `--radius-*` token you define works automatically: + +```css +@theme { + --radius-hero: 2.5rem; + --radius-blob: 48px; +} +``` + +```html +
+
+``` + +### Default superellipse amount + +`--squircle-amt` is a regular CSS custom property — set it anywhere it'll be in scope and it overrides the default of `2` for every `squircle-*` and `squircle-amt-*` utility beneath it: + +```css +:root { + --squircle-amt: 3; +} + +/* or scoped to a subtree: */ +.hero { + --squircle-amt: 2.5; +} +``` + +Individual elements can still override with `squircle-amt-*` classes. + +### Referencing a runtime CSS variable + +Paren refs like `squircle-(--my-radius)` or `squircle-amt-(--my-amt)` are intentionally rejected (poor things). Tailwind can't distinguish them from unit-typo brackets like `squircle-amt-[1em]` at the validation level, so allowing one means allowing the other. Thread the var through a theme key instead (or, y'know, fork this repo, or tell me I'm wrong, and maybe I'll change): + +```css +@theme { + --radius-hero: var(--hero-radius); + --squircle-amt-hero: var(--hero-squircle-amt); +} +``` + +```html +
+``` + +Tailwind resolves the theme key, which reads your underlying CSS variable — you get the runtime indirection, the validator still catches typos. + +### JS plugin options + +If you installed via Path B, three options tune the emitted output: + +| Option | Default | Effect | +| --------- | ------------------ | ------------------------------------------------------------------ | +| `prefix` | `"squircle"` | Class prefix. `prefix: "sq"` → `sq-md`, `sq-t-lg` | +| `amt-var` | `"--squircle-amt"` | CSS variable name for the `K` parameter passed to `superellipse()` | +| `r-var` | `"--squircle-r"` | CSS variable name for the intermediate corrected-radius variable | + +All three are exposed as kebab-case inside the `@plugin` block and as camelCase (`amtVar`, `rVar`) when requiring the plugin from JavaScript. + +## CSS function: `squircle-radius()` + +> ⚠️ **Experimental.** CSS `@function` is in [CSS Values 5](https://drafts.csswg.org/css-values-5/#custom-functions) and enabled behind a flag in recent Chrome. Check current support on [caniuse](https://caniuse.com/?search=%40function). For the same correction in today's browsers, use the Tailwind utilities — they expand to inline `calc()` that has been supported for years. + +For the footure. Less total CSS than all those tailwind utilities. So beautiful. So utterly currently unusable. + +```css +@import "@klinking/squircle/squircle-radius.css"; + +.card { + --squircle-amt: 2; + border-radius: squircle-radius(1rem, var(--squircle-amt)); + corner-shape: superellipse(var(--squircle-amt)); +} +``` + +Arguments: + +- `--radius` — the target `` (what you'd have passed to `border-radius`) +- `--squircle-amt` — the `K` value you're passing to `superellipse()` + +The parameters are deliberately untyped so relative units (`em`, `rem`, container queries, etc.) resolve at the call site, not at function-definition time — matching how CSS custom properties normally propagate. + +**Heads up:** this doesn't supply the uncorrected fallback for browsers that have `@function` but lack `corner-shape`. By the time `@function` support is widespread, `corner-shape` probably will be too, so ¯\\\_(ツ)\_/¯. + +## How the radius correction works + +A superellipse at the same outer `border-radius` as a circular arc pokes further into the corner. The fix is to scale the radius up by some maths, so the _apparent_ roundness matches what you'd get from `rounded-*`. That is, the distance from the corner to the maximum pokage will match for both the superelliptical corner and the circular corner. The correction formula: $$r' = r \cdot \frac{1 - 2^{-\frac{1}{2}}}{1 - 2^{-\frac{1}{n}}}$$ -where $n = 2^K$ and $K$ is the CSS `superellipse()` parameter. +where $n = 2^K$ and $K$ is the value you pass to `superellipse(K)` (same K as [`squircle-amt-*`](#what-does-squircle-amt--control)). + +### Worked example: `squircle-md` + +With the default Tailwind `--radius-md: 0.375rem` and the default `--squircle-amt: 2` (so `K = 2`, `n = 4`): + +$$r' = 0.375\text{rem} \cdot \frac{1 - 2^{-1/2}}{1 - 2^{-1/4}} \approx 0.375\text{rem} \cdot 1.840 \approx 0.690\text{rem}$$ + +So `.squircle-md` compiles to roughly: + +```css +.squircle-md { + border-radius: 0.375rem; /* fallback: matches rounded-md visually */ + @supports (corner-shape: superellipse(2)) { + --squircle-r: calc(0.375rem * (1 - pow(2, -0.5)) / (1 - pow(2, -0.25))); + border-radius: var(--squircle-r); /* ≈ 0.690rem, compensated */ + corner-shape: superellipse(2); + } +} +``` + +The browser does the actual `calc()` at render time using native [`pow()` and `calc()`](https://caniuse.com/?search=pow) — there's no build-time float math in the emitted CSS. + +The adjusted radius is wrapped in a `@supports (corner-shape: superellipse(2))` rule, so browsers without support simply use the original `border-radius` unchanged. This means your corners will look visually consistent regardless of browser — no sudden changes when support lands, no broken fallbacks. Since browser support for `corner-shape` is still not universal, this gives you consistent visual border-radius forever. See the [interactive demo](https://dogmar.github.io/squircle) for a visual explanation. -## Utilities +## Browser support & fallback strategy -| Utility | Equivalent | Description | -| ---------------- | -------------- | ----------------------------------- | -| `squircle-*` | `rounded-*` | All corners | -| `squircle-t-*` | `rounded-t-*` | Top corners | -| `squircle-r-*` | `rounded-r-*` | Right corners | -| `squircle-b-*` | `rounded-b-*` | Bottom corners | -| `squircle-l-*` | `rounded-l-*` | Left corners | -| `squircle-tl-*` | `rounded-tl-*` | Top-left corner | -| `squircle-tr-*` | `rounded-tr-*` | Top-right corner | -| `squircle-br-*` | `rounded-br-*` | Bottom-right corner | -| `squircle-bl-*` | `rounded-bl-*` | Bottom-left corner | -| `squircle-amt-*` | — | Superellipse exponent (default 2) | +### Support matrix + +| Feature | Used for | Support | +| -------------------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------- | +| [`corner-shape: superellipse()`](https://caniuse.com/?search=corner-shape) | The squircle shape itself | New; fallback to plain `border-radius` in unsupported browsers | +| [`@supports`](https://caniuse.com/css-supports-api) | Gating the correction | Universal for years | +| [`pow()` / `calc()`](https://caniuse.com/?search=pow) | The correction math | Widely supported (Safari 16.4+, Chrome 112+, Firefox 118+) | +| [Logical properties](https://caniuse.com/css-logical-props) | `squircle-s/e/ss/se/es/ee-*` | Widely supported | +| [CSS `@function`](https://caniuse.com/?search=%40function) | Optional `squircle-radius()` helper | Experimental; Chrome flag only | +| [CSS custom properties](https://caniuse.com/css-variables) | Theme tokens, `--squircle-amt`, `--squircle-r` | Universal | + +The Tailwind utilities depend on rows 1–4 and row 6. Only `corner-shape` itself is "new" — everything else is shipped broadly. The standalone `@function` helper is the only genuinely experimental piece. + +### Fallback strategy + +The corrected radius is wrapped in `@supports (corner-shape: superellipse(2))`, so browsers that don't know about `corner-shape` skip the entire block and fall back to the plain `border-radius` declaration above — no `corner-shape`, no squircle, just a regular rounded corner at your original theme radius. Ship `squircle-*` today without worrying about Safari: unsupported browsers show `rounded-*`-equivalent corners now, and the squircle shape lights up automatically when support lands, without any visual jump in the already-shipped radius. + +## Why it called "squircle" when it use "superellipse()"? + +Cuz ain't no one, not even a clanker want to type supperlips over and over again. See? I couldn't even type it _once_ without mussin' it up. + +## Alternatives considered + +- **Just use `corner-shape: superellipse()` directly.** Works fine, but at the same `border-radius` the corners read as smaller than `rounded-*` — so swapping one for the other breaks your visual hierarchy and you end up eyeballing compensation for every component. This package is that eyeballing, solved once. +- **JS squircle libraries** (e.g. Figma Squircle). SVG-based, not native CSS, they carry a runtime cost and don't compose with Tailwind's utility model. +- **Write the `@utility` block in your own project.** Totally reasonable — it's ~100 lines of CSS. See ["Should you install or copy/paste?"](#should-you-install-or-copypaste) for when that's the right call. +- **Wait for `corner-shape` to land everywhere and skip the correction.** I mean, sure. You'll just need to get used to how `border-radius` effects superellipseseses. Go for it. Though if you keep using the border-radiuses you know and love, it makes it easier to do the math on getting nested corners to snug up nicely. + +## Should you install or copy/paste? + +Both are first-class. The copy/paste block is right below, and it's honestly maybe ~100 lines of CSS. -All `squircle-*` utilities accept the same values as `rounded-*` (`sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `full`, arbitrary `[16px]`). +**Copy/paste if:** -`squircle-amt-*` accepts a number (`squircle-amt-[2]`, `squircle-amt-[3.5]`). Higher values = more square. +- Honestly, I recommend it. Let's be real, I'm probably not going to make many updates to this library, and why expose yourself to some future security risk when I die and Vladimir Jong Un trojan-horses this thing. +- You want zero runtime/build dependencies. +- You want to tweak the formula, the utility names, or the value validation yourself. +- You're not sure you'll want updates — the CSS is short and the math won't change. -## Copy/Paste +**Install if:** -If you'd rather not add a dependency, copy the source directly: +- You want upgrades when the formula tightens, the value validation changes, or the utility surface grows. +- You want the JS plugin form (custom `prefix`, `amt-var`, `r-var`). +- You use `tailwind-merge` and want the conflict config maintained for you. +- You want the standalone [`squircle-radius()`](#css-function-squircle-radius) CSS function for non-Tailwind use. -### tw-utils.css +## FAQ + +### Does this work in Safari/Firefox/Chrome today? + +Partially, at time of writing — recent Chrome ships `corner-shape`, Safari and Firefox are still catching up. Check [caniuse](https://caniuse.com/?search=corner-shape) for the current state. Either way you're fine: in a browser without support, you get a plain `border-radius` at the pre-correction value, which visually matches `rounded-*`. No broken layouts, no visible fallback weirdness. + +### Does it work with Tailwind v3? + +The **CSS utilities** (`tw-utils.css`) are v4-only — they use `@utility` and `--value()`, which don't exist in v3. + +The **JS plugin** uses only APIs that exist in both v3 and v4 (`plugin.withOptions`, `matchUtilities`, `type: "length" | "number"`, `theme()`), so it's likely to work in v3 via a `tailwind.config.js`-style registration — but it's not currently tested or declared against v3. Tracked in [#26](https://github.com/dogmar/squircle/pull/26). + +### Why do my corners look smaller with `corner-shape: superellipse` without this? + +At the same `border-radius`, a squircle pokes further into the corner, so less of the box edge is rounded off. The fix is to scale the radius up so the visual roundness matches `rounded-*` — see [How the radius correction works](#how-the-radius-correction-works). + +### Does this add runtime JS? + +No. Everything is static CSS — the Tailwind utilities expand at build time into declarations with a native `calc()` the browser evaluates. The JS plugin also runs at build time only. Zero JS ships to the browser. + +### What happens once `corner-shape` is universal? + +Nothing you need to do. The correction lives inside `@supports (corner-shape: superellipse(2))`, so it activates exactly when the shape does. Once the browser ships support, the shape applies _and_ the compensated radius applies, at the same moment. Your layout is identical before and after. + +### Do I need `tailwind-merge`? + +Only if your project already uses it. The extra config (`squircleMergeConfig`) exists so `rounded-lg squircle-md` de-duplicates the way you'd expect — otherwise tailwind-merge doesn't know `squircle-*` and `rounded-*` occupy the same slot. + +### What's the difference between the utilities and the `squircle-radius()` function? + +The utilities expand to inline `calc()` at build time — they work in any browser that supports `calc()` + `pow()` (most current ones) and degrade to plain `border-radius` where `corner-shape` isn't supported. + +The `@function` form runs the same math at CSS runtime via CSS Values 5's `@function` — which is [experimental](https://caniuse.com/?search=%40function) (Chrome behind a flag, nothing else yet). Use the utilities unless you're specifically building for a non-Tailwind setup. + +### Can I use a different `squircle-amt` for each corner? + +No. `corner-shape` is declared once per element, so all four corners share the same K. You can still mix per-corner _radii_ (`squircle-tl-lg squircle-br-sm`), but the squircle-ness is uniform across the element. + +### Why is the tone of this README all over the place? + +Because I made Claude write most of it, got mad at claude, re-wrote a lot of stuff myself, then got tired and let Claude win. + +### Did you just let Claude write this? + +Kinda. Honestly I wrote the basic tailwind utilities by hand using a weird cobbled together formula I just kinda eyeballed to work for most values anyone would actually want to use for the `superellipse()` param. But then I thought, hey, robots are good at math, maybe they can make the formula _actually_ **correct**. And they could! The robots _could_ make a right formula. I was so happy. I cried tears of joy for days and days. So many tears I drowned my robot. And now I'll never code again. Alas. + +## Copy/paste source + +If you'd rather not add a dependency, copy the source directly. Click to expand each file. + +
+tw-utils.css — the Tailwind utilities + ```css /* ── Squircle utilities ─────────────────────────────────────── */ /* squircle-amt-[n] sets the superellipse amount (default 2) */ /* squircle-* mirrors rounded-* variants: all, t, r, b, l, s, e, tl, tr, br, bl, ss, se, es, ee */ @utility squircle-amt-* { - --squircle-amt: --value(--squircle-amt-*, number); + --squircle-amt: --value(--squircle-amt-*, number, [number]); @supports (corner-shape: superellipse(2)) { corner-shape: superellipse(var(--squircle-amt)); } } @utility squircle-* { - border-radius: --value(--radius-*); + border-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); } @@ -104,13 +380,10 @@ If you'd rather not add a dependency, copy the source directly: /* --- Per-side physical variants --- */ @utility squircle-t-* { - border-top-left-radius: --value(--radius-*); - border-top-right-radius: --value(--radius-*); + border-top-left-radius: --value(--radius-*, [length]); + border-top-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-top-right-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -118,13 +391,10 @@ If you'd rather not add a dependency, copy the source directly: } @utility squircle-r-* { - border-top-right-radius: --value(--radius-*); - border-bottom-right-radius: --value(--radius-*); + border-top-right-radius: --value(--radius-*, [length]); + border-bottom-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-right-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -132,13 +402,10 @@ If you'd rather not add a dependency, copy the source directly: } @utility squircle-b-* { - border-bottom-left-radius: --value(--radius-*); - border-bottom-right-radius: --value(--radius-*); + border-bottom-left-radius: --value(--radius-*, [length]); + border-bottom-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-bottom-left-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -146,13 +413,10 @@ If you'd rather not add a dependency, copy the source directly: } @utility squircle-l-* { - border-top-left-radius: --value(--radius-*); - border-bottom-left-radius: --value(--radius-*); + border-top-left-radius: --value(--radius-*, [length]); + border-bottom-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-bottom-left-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -162,13 +426,10 @@ If you'd rather not add a dependency, copy the source directly: /* --- Per-side logical variants --- */ @utility squircle-s-* { - border-start-start-radius: --value(--radius-*); - border-end-start-radius: --value(--radius-*); + border-start-start-radius: --value(--radius-*, [length]); + border-end-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-start-radius: var(--squircle-r); border-end-start-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -176,13 +437,10 @@ If you'd rather not add a dependency, copy the source directly: } @utility squircle-e-* { - border-start-end-radius: --value(--radius-*); - border-end-end-radius: --value(--radius-*); + border-start-end-radius: --value(--radius-*, [length]); + border-end-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-end-radius: var(--squircle-r); border-end-end-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -192,45 +450,33 @@ If you'd rather not add a dependency, copy the source directly: /* --- Per-corner physical variants --- */ @utility squircle-tl-* { - border-top-left-radius: --value(--radius-*); + border-top-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-top-left-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-top-left-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } @utility squircle-tr-* { - border-top-right-radius: --value(--radius-*); + border-top-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-top-right-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-top-right-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } @utility squircle-br-* { - border-bottom-right-radius: --value(--radius-*); + border-bottom-right-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-bottom-right-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-bottom-right-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } @utility squircle-bl-* { - border-bottom-left-radius: --value(--radius-*); + border-bottom-left-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-bottom-left-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-bottom-left-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } @@ -238,55 +484,48 @@ If you'd rather not add a dependency, copy the source directly: /* --- Per-corner logical variants --- */ @utility squircle-ss-* { - border-start-start-radius: --value(--radius-*); + border-start-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-start-start-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-start-start-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } @utility squircle-se-* { - border-start-end-radius: --value(--radius-*); + border-start-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-start-end-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-start-end-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } @utility squircle-es-* { - border-end-start-radius: --value(--radius-*); + border-end-start-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-end-start-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-end-start-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } @utility squircle-ee-* { - border-end-end-radius: --value(--radius-*); + border-end-end-radius: --value(--radius-*, [length]); @supports (corner-shape: superellipse(2)) { - border-end-end-radius: calc( - --value(--radius- *) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + border-end-end-radius: calc(--value(--radius-*, [length]) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } } ``` + -### tw-plugin.js +
+ +
+tw-plugin.mjs — the JS plugin -```js + +````js import plugin from "tailwindcss/plugin"; const DEFAULT_AMOUNT_VAR_NAME = "--squircle-amt"; const DEFAULT_AMT_CSS = `var(${DEFAULT_AMOUNT_VAR_NAME}, 2)`; @@ -331,9 +570,11 @@ function usesIntermediateVar(suffix) { //#region src/tw-plugin.ts const squircle = plugin.withOptions((options = {}) => ({ matchUtilities, theme }) => { const amtVar = options.amtVar ?? options["amt-var"] ?? "--squircle-amt"; + const rVar = options.rVar ?? options["r-var"] ?? "--squircle-r"; const prefix = options.prefix ?? "squircle"; const radiusValues = theme("borderRadius"); const amtCss = `var(${amtVar}, 2)`; + const rCss = `var(${rVar})`; const cornerShape = getCornerShape(amtVar); matchUtilities({ [`${prefix}-amt`]: (value) => ({ [amtVar]: value, @@ -344,8 +585,8 @@ const squircle = plugin.withOptions((options = {}) => ({ matchUtilities, theme } if (usesIntermediateVar(suffix)) matchUtilities({ [name]: (value) => ({ ...Object.fromEntries(props.map((p) => [p, value])), [SUPPORTS_RULE]: { - "--squircle-r": correctedRadius(value, amtCss), - ...Object.fromEntries(props.map((p) => [p, "var(--squircle-r)"])), + [rVar]: correctedRadius(value, amtCss), + ...Object.fromEntries(props.map((p) => [p, rCss])), "corner-shape": cornerShape } }) }, { @@ -374,7 +615,10 @@ export { squircle as default }; //# sourceMappingURL=tw-plugin.mjs.map``` -### tw-merge-cfg.js +
+ +
+tw-merge-cfg.mjs — the tailwind-merge config ```js @@ -428,10 +672,15 @@ export { squircleMergeConfig }; //# sourceMappingURL=tw-merge-cfg.mjs.map``` -## Browser Support +
+ +## Prior art & credits -`corner-shape: superellipse()` is a new CSS property. Check [caniuse](https://caniuse.com/?search=corner-shape) for current browser support. In unsupported browsers, the corners degrade gracefully to regular `border-radius` — the superellipse shape is simply ignored. +- **CSS Backgrounds 4** — the [`corner-shape` spec](https://drafts.csswg.org/css-backgrounds-4/#corner-shape-value) defines the `superellipse()` family of corner curves and their maths. +- **MDN** — the [`superellipse()` reference](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/superellipse) has the clearest plain-language walkthrough of what K values produce. +- **Tailwind CSS v4** — the `@utility` / `--value()` API this package is built on. ## License MIT +```` diff --git a/package/scripts/generate-squircle-css.ts b/package/scripts/generate-squircle-css.ts index ca340bc..5673d1b 100644 --- a/package/scripts/generate-squircle-css.ts +++ b/package/scripts/generate-squircle-css.ts @@ -1,5 +1,4 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -9,15 +8,18 @@ import { correctedRadius, isComment, usesIntermediateVar, + SUPPORTS_RULE, } from "../src/variants"; -import { SUPPORTS_RULE } from "../src/variants"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const formula = correctedRadius("--value(--radius- *)"); +// Support arbitrary, bare, and theme values in one --value() call. +// https://tailwindcss.com/docs/adding-custom-styles#functional-utilities +const value = "--value(--radius-*, [length])"; +const formula = correctedRadius(value); function multiPropUtility(name: string, props: string[]) { - const fallbacks = props.map((p) => ` ${p}: --value(--radius-*);`).join("\n"); + const fallbacks = props.map((p) => ` ${p}: ${value};`).join("\n"); const corrected = props.map((p) => ` ${p}: var(--squircle-r);`).join("\n"); return `\ @utility ${name} { @@ -33,7 +35,7 @@ ${corrected} function singlePropUtility(name: string, prop: string) { return `\ @utility ${name} { - ${prop}: --value(--radius-*); + ${prop}: ${value}; ${SUPPORTS_RULE} { ${prop}: ${formula}; corner-shape: ${getCornerShape()}; @@ -50,7 +52,7 @@ function generateCss(): string { /* squircle-* mirrors rounded-* variants: all, t, r, b, l, s, e, tl, tr, br, bl, ss, se, es, ee */ @utility squircle-amt-* { - --squircle-amt: --value(--squircle-amt-*, number); + --squircle-amt: --value(--squircle-amt-*, number, [number]); ${SUPPORTS_RULE} { corner-shape: superellipse(var(--squircle-amt)); } @@ -79,5 +81,9 @@ const distDir = join(__dirname, "..", "dist"); mkdirSync(distDir, { recursive: true }); const outPath = join(distDir, "tw-utils.css"); writeFileSync(outPath, output); -execFileSync("npx", ["vp", "fmt", outPath], { stdio: "inherit" }); -console.log(`Generated ${outPath}`); +console.log(`Generated ${outPath} (skipping fmt)`); + +const radiusSrc = join(__dirname, "..", "src", "squircle-radius.css"); +const radiusDest = join(distDir, "squircle-radius.css"); +copyFileSync(radiusSrc, radiusDest); +console.log(`Copied ${radiusDest}`); diff --git a/package/src/__snapshots__/squircle-css.test.ts.snap b/package/src/__snapshots__/squircle-css.test.ts.snap index ac68048..5809a77 100644 --- a/package/src/__snapshots__/squircle-css.test.ts.snap +++ b/package/src/__snapshots__/squircle-css.test.ts.snap @@ -5,10 +5,7 @@ exports[`squircle.css utilities > squircle-b-md snapshot 1`] = ` border-bottom-left-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - var(--radius-md) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-bottom-left-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -20,7 +17,7 @@ exports[`squircle.css utilities > squircle-bl-md snapshot 1`] = ` ".squircle-bl-md { border-bottom-left-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-bottom-left-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-bottom-left-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" @@ -30,7 +27,7 @@ exports[`squircle.css utilities > squircle-br-md snapshot 1`] = ` ".squircle-br-md { border-bottom-right-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-bottom-right-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-bottom-right-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" @@ -41,10 +38,7 @@ exports[`squircle.css utilities > squircle-e-md snapshot 1`] = ` border-start-end-radius: var(--radius-md); border-end-end-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - var(--radius-md) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-end-radius: var(--squircle-r); border-end-end-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -56,7 +50,7 @@ exports[`squircle.css utilities > squircle-ee-md snapshot 1`] = ` ".squircle-ee-md { border-end-end-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-end-end-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-end-end-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" @@ -66,7 +60,7 @@ exports[`squircle.css utilities > squircle-es-md snapshot 1`] = ` ".squircle-es-md { border-end-start-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-end-start-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-end-start-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" @@ -77,10 +71,7 @@ exports[`squircle.css utilities > squircle-l-md snapshot 1`] = ` border-top-left-radius: var(--radius-md); border-bottom-left-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - var(--radius-md) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-bottom-left-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -92,10 +83,7 @@ exports[`squircle.css utilities > squircle-md snapshot 1`] = ` ".squircle-md { border-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - var(--radius-md) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); } @@ -107,10 +95,7 @@ exports[`squircle.css utilities > squircle-r-md snapshot 1`] = ` border-top-right-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - var(--radius-md) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-right-radius: var(--squircle-r); border-bottom-right-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -123,10 +108,7 @@ exports[`squircle.css utilities > squircle-s-md snapshot 1`] = ` border-start-start-radius: var(--radius-md); border-end-start-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - var(--radius-md) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-start-start-radius: var(--squircle-r); border-end-start-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -138,7 +120,7 @@ exports[`squircle.css utilities > squircle-se-md snapshot 1`] = ` ".squircle-se-md { border-start-end-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-start-end-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-start-end-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" @@ -148,7 +130,7 @@ exports[`squircle.css utilities > squircle-ss-md snapshot 1`] = ` ".squircle-ss-md { border-start-start-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-start-start-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-start-start-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" @@ -159,10 +141,7 @@ exports[`squircle.css utilities > squircle-t-md snapshot 1`] = ` border-top-left-radius: var(--radius-md); border-top-right-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - --squircle-r: calc( - var(--radius-md) * (1 - pow(2, -0.5)) / - (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) - ); + --squircle-r: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); border-top-left-radius: var(--squircle-r); border-top-right-radius: var(--squircle-r); corner-shape: superellipse(var(--squircle-amt, 2)); @@ -174,7 +153,7 @@ exports[`squircle.css utilities > squircle-tl-md snapshot 1`] = ` ".squircle-tl-md { border-top-left-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-top-left-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-top-left-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" @@ -184,7 +163,7 @@ exports[`squircle.css utilities > squircle-tr-md snapshot 1`] = ` ".squircle-tr-md { border-top-right-radius: var(--radius-md); @supports (corner-shape: superellipse(2)) { - border-top-right-radius: calc( var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2)))) ); + border-top-right-radius: calc(var(--radius-md) * (1 - pow(2, -0.5)) / (1 - pow(2, -1 * pow(2, -1 * var(--squircle-amt, 2))))); corner-shape: superellipse(var(--squircle-amt, 2)); } }" diff --git a/package/src/squircle-css.test.ts b/package/src/squircle-css.test.ts index 20c22d5..84246f5 100644 --- a/package/src/squircle-css.test.ts +++ b/package/src/squircle-css.test.ts @@ -49,4 +49,61 @@ describe("squircle.css utilities", () => { expect(css).toMatchSnapshot(); }); } + + describe("arbitrary values", () => { + it("squircle-[1rem] emits literal length in fallback and calc", async () => { + const css = await compileCss(["squircle-[1rem]"]); + expect(css).toContain("border-radius: 1rem"); + expect(css).toContain("calc(1rem *"); + }); + + it("squircle-[50%] is rejected (only [length] arbitraries allowed)", async () => { + const css = await compileCss(["squircle-[50%]"]); + expect(css).not.toContain(".squircle-"); + }); + + it("squircle-(--my-radius) is rejected (use a theme value to reference a var)", async () => { + const css = await compileCss(["squircle-(--my-radius)"]); + expect(css).not.toContain(".squircle-"); + }); + + it("squircle-[foo] is rejected", async () => { + const css = await compileCss(["squircle-[foo]"]); + expect(css).not.toContain(".squircle-"); + }); + + for (const [suffix, props] of Object.entries(VARIANTS)) { + if (!suffix) continue; + const className = `squircle-${suffix}-[8px]`; + it(`${className} emits literal length on ${props.join(", ")}`, async () => { + const css = await compileCss([className]); + for (const prop of props) { + expect(css).toContain(`${prop}: 8px`); + } + expect(css).toContain("calc(8px *"); + }); + } + + it("squircle-amt-[4.5] accepts arbitrary bare number", async () => { + const css = await compileCss(["squircle-amt-[4.5]"]); + expect(css).toContain("--squircle-amt: 4.5"); + expect(css).toContain("corner-shape: superellipse(var(--squircle-amt))"); + }); + + it("squircle-amt-[1em] is rejected (unit-bearing values are not numbers)", async () => { + const css = await compileCss(["squircle-amt-[1em]"]); + expect(css).not.toContain("squircle-amt-"); + }); + + it("squircle-amt-[foo] is rejected", async () => { + const css = await compileCss(["squircle-amt-[foo]"]); + expect(css).not.toContain("squircle-amt-"); + }); + + it("squircle-amt-(--my-amt) is rejected (use a theme value to reference a var)", async () => { + const css = await compileCss(["squircle-amt-(--my-amt)"]); + expect(css).not.toContain("squircle-amt-"); + }); + }); + }); diff --git a/package/src/squircle-radius.test.ts b/package/src/squircle-radius.test.ts new file mode 100644 index 0000000..c376acb --- /dev/null +++ b/package/src/squircle-radius.test.ts @@ -0,0 +1,26 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { correctedRadius } from "./variants"; + +const distPath = join(import.meta.dirname, "..", "dist", "squircle-radius.css"); + +describe("squircle-radius.css ships", () => { + it("is copied into dist during build", () => { + expect(existsSync(distPath)).toBe(true); + }); +}); + +describe("@function squircle-radius() body", () => { + const css = existsSync(distPath) ? readFileSync(distPath, "utf-8") : ""; + + it("declares the @function with the documented signature", () => { + expect(css).toContain("@function squircle-radius(--radius, --squircle-amt)"); + }); + + it("uses the same correction formula as the utilities", () => { + const strip = (s: string) => s.replace(/\s+/g, ""); + const expected = correctedRadius("var(--radius)", "var(--squircle-amt)"); + expect(strip(css)).toContain(strip(expected)); + }); +}); diff --git a/package/src/tw-plugin.test.ts b/package/src/tw-plugin.test.ts index 5a336d6..fa7e8b2 100644 --- a/package/src/tw-plugin.test.ts +++ b/package/src/tw-plugin.test.ts @@ -47,6 +47,44 @@ describe("plugin.ts utilities", () => { expect(css).toMatchSnapshot(); }); } + + describe("arbitrary and invalid values", () => { + it("squircle-[1rem] emits literal length", async () => { + const css = await compilePlugin(["squircle-[1rem]"]); + expect(css).toContain("border-radius: 1rem"); + expect(css).toContain("calc(1rem *"); + }); + + it("squircle-[50%] is rejected (only lengths allowed)", async () => { + const css = await compilePlugin(["squircle-[50%]"]); + expect(css).not.toContain(".squircle-"); + }); + + it("squircle-(--my-radius) is rejected (use a theme value to reference a var)", async () => { + const css = await compilePlugin(["squircle-(--my-radius)"]); + expect(css).not.toContain(".squircle-"); + }); + + it("squircle-[foo] is rejected", async () => { + const css = await compilePlugin(["squircle-[foo]"]); + expect(css).not.toContain(".squircle-"); + }); + + it("squircle-amt-[1em] is rejected (unit-bearing values are not numbers)", async () => { + const css = await compilePlugin(["squircle-amt-[1em]"]); + expect(css).not.toContain("squircle-amt-"); + }); + + it("squircle-amt-[foo] is rejected", async () => { + const css = await compilePlugin(["squircle-amt-[foo]"]); + expect(css).not.toContain("squircle-amt-"); + }); + + it("squircle-amt-(--my-amt) is rejected (use a theme value to reference a var)", async () => { + const css = await compilePlugin(["squircle-amt-(--my-amt)"]); + expect(css).not.toContain("squircle-amt-"); + }); + }); }); describe("plugin.ts custom options", () => { @@ -82,6 +120,27 @@ describe("plugin.ts custom options", () => { expect(css).toContain("superellipse(var(--se-amt))"); }); + it("custom r-var changes the intermediate CSS variable name", async () => { + const css = await compilePlugin(["squircle-md"], "r-var: --se-r;"); + expect(css).toContain("--se-r: calc("); + expect(css).toContain("border-radius: var(--se-r)"); + expect(css).not.toContain("--squircle-r"); + }); + + it("custom r-var applies to multi-prop side variants", async () => { + const css = await compilePlugin(["squircle-t-md"], "r-var: --se-r;"); + expect(css).toContain("--se-r: calc("); + expect(css).toContain("border-top-left-radius: var(--se-r)"); + expect(css).toContain("border-top-right-radius: var(--se-r)"); + }); + + it("single-prop corner variants don't emit the intermediate var at all", async () => { + const css = await compilePlugin(["squircle-tl-md"], "r-var: --se-r;"); + expect(css).not.toContain("--se-r"); + expect(css).not.toContain("--squircle-r"); + expect(css).toContain("border-top-left-radius: calc("); + }); + it("both options together", async () => { const opts = "prefix: round;\namt-var: --round-amt;"; const css = await compilePlugin(["round-md"], opts); diff --git a/package/src/tw-plugin.ts b/package/src/tw-plugin.ts index 54eb97e..6e68e2b 100644 --- a/package/src/tw-plugin.ts +++ b/package/src/tw-plugin.ts @@ -2,11 +2,12 @@ import plugin from "tailwindcss/plugin"; import { DEFAULT_AMT, DEFAULT_AMOUNT_VAR_NAME, + DEFAULT_R_VAR_NAME, + SUPPORTS_RULE, correctedRadius, getCornerShape, usesIntermediateVar, variantEntries, - SUPPORTS_RULE, } from "./variants"; export interface SquirclePluginOptions { @@ -14,6 +15,10 @@ export interface SquirclePluginOptions { amtVar?: string; /** @plugin CSS alias for amtVar */ "amt-var"?: string; + /** CSS custom property name for the intermediate corrected radius (default: "--squircle-r") */ + rVar?: string; + /** @plugin CSS alias for rVar */ + "r-var"?: string; /** Class name prefix for utilities (default: "squircle") */ prefix?: string; } @@ -23,10 +28,12 @@ const squircle: ReturnType> = // eslint-disable-next-line @typescript-eslint/unbound-method ({ matchUtilities, theme }) => { const amtVar = options.amtVar ?? options["amt-var"] ?? DEFAULT_AMOUNT_VAR_NAME; + const rVar = options.rVar ?? options["r-var"] ?? DEFAULT_R_VAR_NAME; const prefix = options.prefix ?? "squircle"; const radiusValues = theme("borderRadius"); const amtCss = `var(${amtVar}, ${DEFAULT_AMT})`; + const rCss = `var(${rVar})`; const cornerShape = getCornerShape(amtVar); matchUtilities( @@ -50,8 +57,8 @@ const squircle: ReturnType> = [name]: (value: string) => ({ ...Object.fromEntries(props.map((p) => [p, value])), [SUPPORTS_RULE]: { - "--squircle-r": correctedRadius(value, amtCss), - ...Object.fromEntries(props.map((p) => [p, "var(--squircle-r)"])), + [rVar]: correctedRadius(value, amtCss), + ...Object.fromEntries(props.map((p) => [p, rCss])), "corner-shape": cornerShape, }, }), diff --git a/package/src/variants.ts b/package/src/variants.ts index 2299397..8817e69 100644 --- a/package/src/variants.ts +++ b/package/src/variants.ts @@ -1,5 +1,6 @@ export const DEFAULT_AMT = 2 as const; export const DEFAULT_AMOUNT_VAR_NAME = "--squircle-amt" as const; +export const DEFAULT_R_VAR_NAME = "--squircle-r" as const; export const DEFAULT_AMT_CSS = `var(${DEFAULT_AMOUNT_VAR_NAME}, ${DEFAULT_AMT})` as const; export const getCornerShape = (varName: string = DEFAULT_AMOUNT_VAR_NAME) => diff --git a/package/vite.config.ts b/package/vite.config.ts index 80cab1b..5d90ba5 100644 --- a/package/vite.config.ts +++ b/package/vite.config.ts @@ -23,9 +23,13 @@ export default defineConfig({ command: "vp test run squircle-css", dependsOn: ["build"], }, + "test:radius": { + command: "vp test run squircle-radius", + dependsOn: ["build"], + }, test: { command: "echo 'All tests passed'", - dependsOn: ["test:plugin", "test:css"], + dependsOn: ["test:plugin", "test:css", "test:radius"], }, build: { command: "vp pack && tsx scripts/generate-squircle-css.ts", diff --git a/scripts/sync-readme.sh b/scripts/sync-readme.sh index 9527ce7..6d7f8fc 100755 --- a/scripts/sync-readme.sh +++ b/scripts/sync-readme.sh @@ -37,8 +37,47 @@ sync_file() { rm -f "$REPO_ROOT/.sync-block.tmp" } +sync_toc() { + local readme="$REPO_ROOT/README.md" + local tmp="$REPO_ROOT/README.md.tmp" + local tocfile="$REPO_ROOT/.sync-toc.tmp" + local begin="" + local end="" + + # Generate a bulleted TOC from top-level (##) headings, skipping the + # "Contents" heading itself. Anchor rules match GitHub's slugger: + # lowercase, strip non-alphanumeric (except spaces and hyphens), spaces → hyphens. + awk ' + /^## / { + heading = substr($0, 4) + if (heading == "Contents") next + anchor = tolower(heading) + gsub(/[^a-z0-9 -]/, "", anchor) + gsub(/ /, "-", anchor) + print "- [" heading "](#" anchor ")" + } + ' "$readme" > "$tocfile" + + awk -v begin="$begin" -v end="$end" -v blockfile="$tocfile" ' + $0 == begin { + print + while ((getline line < blockfile) > 0) print line + close(blockfile) + skip = 1 + next + } + $0 == end { print; skip = 0; next } + skip { next } + { print } + ' "$readme" > "$tmp" + + mv "$tmp" "$readme" + rm -f "$tocfile" +} + sync_file "dist/tw-utils.css" "css" "package/dist/tw-utils.css" sync_file "dist/tw-merge-cfg.mjs" "js" "package/dist/tw-merge-cfg.mjs" sync_file "dist/tw-plugin.mjs" "js" "package/dist/tw-plugin.mjs" +sync_toc echo "README synced." diff --git a/vite.config.ts b/vite.config.ts index a66c9ce..9342707 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ dependsOn: ["fmt", "lint", "@klinking/squircle#test", "website#build"], }, "sync-readme": { - command: "bash scripts/sync-readme.sh", + command: "bash scripts/sync-readme.sh && vp fmt README.md", dependsOn: ["@klinking/squircle#build"], }, },