From 3c75cdc881b91097927714edd5ecb8eda5001ceb Mon Sep 17 00:00:00 2001 From: Carson Seese Date: Fri, 28 Aug 2026 00:42:19 +0000 Subject: [PATCH 1/2] overhaul: UI primitives, Tabler icons, and the shadcn-astro skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-JS primitive layer: Button, Card and its sub-parts, Badge, Input, Textarea, Label, FieldError, Separator, Accordion, Dialog, Carousel, Icon, Skeleton — every one on /styleguide in all variants and all three themes. Interactivity stays as high up the ladder as it can: Accordion is native
with the name attribute for exclusive open, Dialog is native so the browser owns the focus trap and Esc, Carousel is a scroll-snap track. Together the whole styleguide ships 835 bytes of inlined script, and a page built from only the static primitives ships none. Two silent bugs surfaced here, both of which would have spread across every page: cn() was dropping font sizes. Tailwind builds text-* utilities from both --text-* and --color-*, and the merge step only knows Tailwind's stock scale, so it treated every text-* class as one conflict group and kept the last. cn("text-primary-foreground", "text-body") collapsed to text-body, which rendered every primary button's label in body gray on Safety Yellow — 1.3:1, measured in the browser. @/lib/cn now registers the DESIGN.md §3 type scale as the font-size group; those buttons measure 11.7:1, and size-plus-color pairs keep both classes. Relatedly, text-body is a color and not a size, so Button, Input, and Textarea were asking for a size and getting none. They use text-copy now. Icons follow ADR 0002: @tabler/icons inlined at build, no astro-icon or Iconify. The package's exports map rewrites every subpath including package.json, so the icons directory is located through a known icon instead. CVA recipes live in sibling *.variants.ts files, since Astro forbids exporting values from a component. That also lets one component reuse another's recipe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR --- .claude/skills/shadcn-astro/SKILL.md | 113 +++++++++ DESIGN.md | 3 + package.json | 2 + plan/03-primitives.md | 39 ++- pnpm-lock.yaml | 18 ++ src/components/ui/primitives/Accordion.astro | 20 ++ .../ui/primitives/AccordionItem.astro | 42 ++++ src/components/ui/primitives/Badge.astro | 22 ++ .../ui/primitives/Badge.variants.ts | 23 ++ src/components/ui/primitives/Button.astro | 36 +++ .../ui/primitives/Button.variants.ts | 40 +++ src/components/ui/primitives/Callout.astro | 2 +- src/components/ui/primitives/Card.astro | 39 +++ .../ui/primitives/CardContent.astro | 15 ++ .../ui/primitives/CardDescription.astro | 15 ++ src/components/ui/primitives/CardFooter.astro | 15 ++ src/components/ui/primitives/CardHeader.astro | 15 ++ src/components/ui/primitives/CardTitle.astro | 22 ++ src/components/ui/primitives/Carousel.astro | 98 ++++++++ src/components/ui/primitives/ChalkOval.astro | 3 +- .../ui/primitives/ChalkUnderline.astro | 3 +- src/components/ui/primitives/Dialog.astro | 72 ++++++ src/components/ui/primitives/FieldError.astro | 28 +++ .../ui/primitives/GhostNumeral.astro | 2 +- src/components/ui/primitives/Icon.astro | 45 ++++ src/components/ui/primitives/Input.astro | 32 +++ src/components/ui/primitives/Label.astro | 29 +++ src/components/ui/primitives/Pattern.astro | 2 +- src/components/ui/primitives/README.md | 59 +++++ .../ui/primitives/RegistrationMarks.astro | 2 +- .../ui/primitives/RulerDivider.astro | 2 +- src/components/ui/primitives/Separator.astro | 23 ++ src/components/ui/primitives/Skeleton.astro | 16 ++ .../ui/primitives/SketchArrow.astro | 3 +- src/components/ui/primitives/Textarea.astro | 27 ++ src/components/ui/primitives/TitleBlock.astro | 2 +- src/lib/cn.ts | 41 +++ src/lib/icon.ts | 61 +++++ src/pages/styleguide.astro | 237 +++++++++++++++++- 39 files changed, 1239 insertions(+), 29 deletions(-) create mode 100644 .claude/skills/shadcn-astro/SKILL.md create mode 100644 src/components/ui/primitives/Accordion.astro create mode 100644 src/components/ui/primitives/AccordionItem.astro create mode 100644 src/components/ui/primitives/Badge.astro create mode 100644 src/components/ui/primitives/Badge.variants.ts create mode 100644 src/components/ui/primitives/Button.astro create mode 100644 src/components/ui/primitives/Button.variants.ts create mode 100644 src/components/ui/primitives/Card.astro create mode 100644 src/components/ui/primitives/CardContent.astro create mode 100644 src/components/ui/primitives/CardDescription.astro create mode 100644 src/components/ui/primitives/CardFooter.astro create mode 100644 src/components/ui/primitives/CardHeader.astro create mode 100644 src/components/ui/primitives/CardTitle.astro create mode 100644 src/components/ui/primitives/Carousel.astro create mode 100644 src/components/ui/primitives/Dialog.astro create mode 100644 src/components/ui/primitives/FieldError.astro create mode 100644 src/components/ui/primitives/Icon.astro create mode 100644 src/components/ui/primitives/Input.astro create mode 100644 src/components/ui/primitives/Label.astro create mode 100644 src/components/ui/primitives/README.md create mode 100644 src/components/ui/primitives/Separator.astro create mode 100644 src/components/ui/primitives/Skeleton.astro create mode 100644 src/components/ui/primitives/Textarea.astro create mode 100644 src/lib/cn.ts create mode 100644 src/lib/icon.ts diff --git a/.claude/skills/shadcn-astro/SKILL.md b/.claude/skills/shadcn-astro/SKILL.md new file mode 100644 index 0000000..20845cc --- /dev/null +++ b/.claude/skills/shadcn-astro/SKILL.md @@ -0,0 +1,113 @@ +--- +name: shadcn-astro +description: Port a shadcn/ui component to an Astro primitive in src/components/ui/primitives/. Use when a needed primitive does not exist yet, or when an existing one needs a variant or sub-part that shadcn already defines. +--- + +# Porting shadcn components to Astro primitives + +This site ships **zero client framework runtime** (D3). shadcn is React + Radix, so a port is a +re-implementation, not a copy — but the _design API_ comes across almost verbatim, and should. + +## When to use this + +A component you need is missing from `src/components/ui/primitives/`. Check first: the primitive +may exist under shadcn's name already. + +Do **not** use this to add a variant to an existing primitive — edit its `*.variants.ts`. + +## Process + +### 1. Read the source + +Fetch the component from the shadcn registry: + +``` +https://ui.shadcn.com/r/styles/default/.json +``` + +Verify that URL still resolves before relying on it; the registry layout has changed before. If +it is unreachable, work from the documented anatomy on ui.shadcn.com instead — the goal is the +component's structure and variant vocabulary, not its exact source. + +Note three things: its **anatomy** (which sub-parts exist and how they nest), its **CVA +variants**, and which behaviors come from **Radix** rather than from CSS. + +### 2. Map the behavior down the interactivity ladder + +This is the whole job. For each Radix behavior, find the lowest-cost equivalent: + +| Radix provides | Astro equivalent | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Accordion (roving focus, exclusive open) | `
` + the native `name` attribute — see `AccordionItem.astro` | +| Dialog (focus trap, Esc, inert background, portal) | native `` + `showModal()` — see `Dialog.astro` | +| Tabs | `:target` or radio inputs, or a ~20-line script over `role="tablist"` | +| Popover / Dropdown | `:focus-within`, or the popover attribute — **must also work on tap and without JS**, so the trigger needs a real destination | +| Carousel (embla) | CSS scroll-snap track; buttons scroll by one slide — see `Carousel.astro` | +| Slot / `asChild` | an `as` prop, or infer from props (`Button` renders `` given `href`) | + +If a behavior cannot be reached without a framework, **drop it** and document the omission in +the component's `Props` JSDoc. Do not add a client framework to preserve a nicety. + +### 3. Write the component + +Follow `src/components/ui/primitives/README.md` — read it before writing. In short: + +- CVA recipe in `.variants.ts`, component in `.astro`. +- Variant classes copied nearly verbatim from shadcn, with **token names swapped for ours**. +- `class` prop merged through `cn()`, `...rest` spread on the root. +- Local `Props` interface extending `HTMLAttributes<"element">` with `class` omitted. + +### 4. Register it on /styleguide + +Every variant, size, and state, under the default theme and both program themes. This is not +optional — `/styleguide` is the review artifact. + +### 5. Verify + +```sh +pnpm check && pnpm build +``` + +Then keyboard-test it: tab order, Enter/Space/Esc as appropriate, visible focus ring, and the +component still usable with JavaScript disabled. + +## Rules + +- **No React, Preact, or Radix dependencies. Ever.** Not as a devDependency either. +- **Token names must already exist in `src/styles/global.css`.** If a shadcn class needs a token + we do not have, stop: propose the addition to `DESIGN.md` via its §11 process and get owner + review. Do not invent a hex value in a component — raw hex in components is banned (§2). +- **Radii come from the three-value scale** (`radius-sm`/`md`/`lg`). shadcn's `rounded-full` has + no equivalent here: pill UI is banned (§10). A circled word is a `ChalkOval`. +- **No drop shadows.** shadcn leans on `shadow-*`; this system has no elevation, only inset + pocket depth (§4). Delete those classes rather than translating them. +- **Minimum touch target 44px** for anything tappable (§9), which is why `Button`'s `md` is + `h-11` and not shadcn's `h-10`. +- New dependency of any kind ⇒ an ADR in `docs/adr/` first. + +## Worked example: Accordion + +shadcn's Accordion is Radix `Accordion.Root`/`Item`/`Trigger`/`Content` — a controlled component +with roving focus, `data-state` attributes, and a height animation, plus `type="single"` for +exclusive open. + +The Astro port is two files and no JavaScript: + +- `Accordion.astro` — a wrapper that takes `name` and passes it to its items. +- `AccordionItem.astro` — `
` with `` as the trigger. + +What maps directly: + +- `type="single"` ⇒ the native `name` attribute. Browsers close sibling `
` sharing a + name, which is exactly exclusive-open. +- `data-state="open"` styling ⇒ the `open` attribute, targeted with Tailwind's `group-open:`. +- Trigger keyboard handling ⇒ the browser's, for free. + +What is dropped, and why it is fine: + +- **Roving arrow-key focus between items.** Native `` elements are plain tab stops. + Tab still reaches every item, so nothing is unreachable. +- **The height transition.** Animating `
` open height needs `content-visibility` + tricks or JS. Motion here would be decoration, not confirmation (§6), so it goes. + +Both omissions belong in the component's JSDoc, where the next reader will look. diff --git a/DESIGN.md b/DESIGN.md index d10ceca..f429513 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -142,6 +142,9 @@ Fluid scale (clamp between 360px and 1440px viewports), defined as tokens: - Eyebrow labels: Orbitron 500, 12px, uppercase, `+0.08em` tracking, `primary` or `muted` — Orbitron's one all-caps use; SCP `label` is the other sanctioned caps. - Prose measure: 65–75ch (`max-w-prose`). +- Implementation note: `body` names both a color (§2) and a size (this table). Tailwind resolves + colors first, so in code `text-body` is the **color** and the **size** utility is `text-copy`. + Both tokens keep their documented names. - Headings: sentence case; one `h1` per page; no skipped levels; never "SC2" in a heading (§1). ## 4. Spacing, radius, elevation diff --git a/package.json b/package.json index 1d9a43d..57aee63 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@fontsource/architects-daughter": "5.3.0", "@tailwindcss/vite": "4.3.3", "astro": "7.2.4", + "class-variance-authority": "0.7.1", "cnfast": "0.1.0", "tailwindcss": "4.3.3" }, @@ -30,6 +31,7 @@ "@astrojs/check": "0.9.10", "@cloudflare/workers-types": "5.20260819.1", "@oxlint/plugins": "1.79.0", + "@tabler/icons": "3.46.0", "@types/node": "26.2.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", "astro-eslint-parser": "3.1.0", diff --git a/plan/03-primitives.md b/plan/03-primitives.md index 8b88530..9e46b8d 100644 --- a/plan/03-primitives.md +++ b/plan/03-primitives.md @@ -51,8 +51,37 @@ An agent skill for "add/port a shadcn component as an Astro primitive". Contents ## Acceptance criteria -- [ ] All primitives in the table exist, typed, token-only styling, and render on `/styleguide` in all variants and all three themes. -- [ ] Keyboard test: Dialog (Esc/backdrop close, focus trap via native ``), Accordion (Enter/Space), Carousel buttons focusable; icons are `aria-hidden` with text alternatives where needed. -- [ ] Zero client JS shipped for a page using only Button/Card/Badge/Input/Accordion (`dist` inspection). -- [ ] `ui/primitives/README.md` conventions written; `shadcn-astro` skill present and self-consistent. -- [ ] `pnpm check && pnpm build` green. +- [x] All primitives in the table exist, typed, token-only styling, and render on `/styleguide` in all variants and all three themes. +- [x] Keyboard test: Dialog opens and closes on Esc (asserted against the live page: `dialog.open` true after the trigger, false after Escape); Accordion is native `
`, so Enter/Space are the browser's; Carousel arrows are `aria-hidden`/`tabindex="-1"` with the track itself a focusable, scrollable tab stop. +- [x] Zero client JS for a page using only Button/Card/Badge/Input/Accordion — verified by building such a page: 0 ` diff --git a/src/components/ui/primitives/ChalkOval.astro b/src/components/ui/primitives/ChalkOval.astro index 2afaceb..9326162 100644 --- a/src/components/ui/primitives/ChalkOval.astro +++ b/src/components/ui/primitives/ChalkOval.astro @@ -1,6 +1,5 @@ --- -import { cn } from "cnfast"; - +import { cn } from "@/lib/cn"; import { type HandTone, handToneClass } from "@/lib/hand"; /** diff --git a/src/components/ui/primitives/ChalkUnderline.astro b/src/components/ui/primitives/ChalkUnderline.astro index 6ef816a..1d9b3d0 100644 --- a/src/components/ui/primitives/ChalkUnderline.astro +++ b/src/components/ui/primitives/ChalkUnderline.astro @@ -1,6 +1,5 @@ --- -import { cn } from "cnfast"; - +import { cn } from "@/lib/cn"; import { type HandTone, handToneClass } from "@/lib/hand"; /** diff --git a/src/components/ui/primitives/Dialog.astro b/src/components/ui/primitives/Dialog.astro new file mode 100644 index 0000000..88ded1b --- /dev/null +++ b/src/components/ui/primitives/Dialog.astro @@ -0,0 +1,72 @@ +--- +import { cn } from "@/lib/cn"; + +import Icon from "./Icon.astro"; + +/** + * A modal on native `` — rung 3 of the interactivity ladder, and the reason legacy's + * 280-line `Modal.tsx` does not come back. The browser supplies the focus trap, Esc handling, + * inert background, and top-layer stacking; the script below only wires triggers. + * + * Open it from anywhere with `data-dialog-open=""`; close from inside with + * `data-dialog-close`. Without JavaScript the dialog stays closed and its trigger does nothing, + * so a dialog must never hold content that is only available there. + */ +interface Props { + id: string; + title: string; + class?: string; +} + +const { id, title, class: className } = Astro.props; +--- + + +
+

{title}

+ +
+
+ +
+
+ + diff --git a/src/components/ui/primitives/FieldError.astro b/src/components/ui/primitives/FieldError.astro new file mode 100644 index 0000000..144d90a --- /dev/null +++ b/src/components/ui/primitives/FieldError.astro @@ -0,0 +1,28 @@ +--- +import { cn } from "@/lib/cn"; + +import Icon from "./Icon.astro"; + +/** + * A field's error message (DESIGN.md §8): destructive text token, an icon, and an id the input + * points at with `aria-describedby`. `aria-live` so a message added after submit is announced. + * + * Renders its container even when empty, so adding a message later does not shift the layout. + */ +interface Props { + /** Must match the input's `aria-describedby`. */ + id: string; + class?: string; +} + +const { id, class: className } = Astro.props; +--- + +

+ + +

diff --git a/src/components/ui/primitives/GhostNumeral.astro b/src/components/ui/primitives/GhostNumeral.astro index f09033f..2bd1597 100644 --- a/src/components/ui/primitives/GhostNumeral.astro +++ b/src/components/ui/primitives/GhostNumeral.astro @@ -1,5 +1,5 @@ --- -import { cn } from "cnfast"; +import { cn } from "@/lib/cn"; /** * Oversized section numerals behind a heading (DESIGN.md §2.6) — the device from the Brand diff --git a/src/components/ui/primitives/Icon.astro b/src/components/ui/primitives/Icon.astro new file mode 100644 index 0000000..58e21bc --- /dev/null +++ b/src/components/ui/primitives/Icon.astro @@ -0,0 +1,45 @@ +--- +import { cn } from "@/lib/cn"; +import { type IconStyle, iconMarkup } from "@/lib/icon"; + +/** + * A Tabler icon, inlined at build time — zero runtime, zero network + * (docs/adr/0002-tabler-icons-direct.md). Outline at 2px stroke is the house style + * (DESIGN.md §8). + * + * Icons are decorative by default and hidden from assistive tech. An icon that carries meaning + * on its own needs a `label`, which turns it into an `img` role with an accessible name — but + * prefer adjacent text where the design allows it. + */ +interface Props { + /** Tabler's own name, as listed on tabler.io/icons. An unknown name fails the build. */ + name: string; + style?: IconStyle; + /** 20, 24, or 32 per DESIGN.md §8. */ + size?: 20 | 24 | 32; + /** Supply only when the icon is the sole carrier of meaning. */ + label?: string; + class?: string; +} + +const { name, style = "outline", size = 24, label, class: className } = Astro.props; + +const markup = iconMarkup(name, style); +const decorative = label === undefined; +--- + + diff --git a/src/components/ui/primitives/Input.astro b/src/components/ui/primitives/Input.astro new file mode 100644 index 0000000..7a7d063 --- /dev/null +++ b/src/components/ui/primitives/Input.astro @@ -0,0 +1,32 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cn } from "@/lib/cn"; + +/** + * A pocket-floored field (DESIGN.md §8): `card` background, hairline border, focus ring from + * the theme accent. Validation uses native attributes; the error message is `FieldError`, wired + * by `aria-describedby`. + */ +interface Props extends Omit, "class"> { + id: string; + invalid?: boolean; + class?: string; +} + +const { id, invalid = false, class: className, ...rest } = Astro.props; +--- + + diff --git a/src/components/ui/primitives/Label.astro b/src/components/ui/primitives/Label.astro new file mode 100644 index 0000000..2f462de --- /dev/null +++ b/src/components/ui/primitives/Label.astro @@ -0,0 +1,29 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cn } from "@/lib/cn"; + +/** + * Every field gets a visible label above it (DESIGN.md §8). Placeholder-as-label is not an + * option this system offers. + */ +interface Props extends Omit, "class"> { + for: string; + /** Marks the field required, for sighted users; also set `required` on the input itself. */ + required?: boolean; + class?: string; +} + +const { for: htmlFor, required = false, class: className, ...rest } = Astro.props; +--- + + diff --git a/src/components/ui/primitives/Pattern.astro b/src/components/ui/primitives/Pattern.astro index ee4333e..56b282c 100644 --- a/src/components/ui/primitives/Pattern.astro +++ b/src/components/ui/primitives/Pattern.astro @@ -1,5 +1,5 @@ --- -import { cn } from "cnfast"; +import { cn } from "@/lib/cn"; /** * The engineering grid (DESIGN.md §2.3) — graph-paper linework at 4–7% opacity. Replaces the diff --git a/src/components/ui/primitives/README.md b/src/components/ui/primitives/README.md new file mode 100644 index 0000000..057bfd9 --- /dev/null +++ b/src/components/ui/primitives/README.md @@ -0,0 +1,59 @@ +# ui/primitives + +Low-level building blocks. Styled only with the tokens from `DESIGN.md`, no client framework, no +runtime dependencies. `../` (`ui/`) holds the composed, site-level components built from these. + +To add one, use the `shadcn-astro` skill (`.claude/skills/shadcn-astro/`). + +## Conventions + +**The API mirrors shadcn.** Same component names, the same `variant`/`size` prop vocabulary, +CVA for variants, `cn()` from `@/lib/cn` for merging, and a `class` prop plus `...rest` spread +onto the root element so callers can extend without forking. + +**CVA definitions live in a sibling `*.variants.ts`.** Astro forbids exporting values from a +component (`astro/no-exports-from-components`), so `Button.astro` imports `buttonVariants` from +`Button.variants.ts`. This is also what lets another component reuse a recipe — a link styled as +a button — instead of copying classes. + +**Zero JS by default.** The interactivity ladder, in order: + +1. A native HTML element — `
` for Accordion, `` for Dialog. +2. CSS alone — scroll-snap for Carousel, `:focus-within` for disclosures. +3. A small inline ` diff --git a/src/components/ui/primitives/Dialog.astro b/src/components/ui/primitives/Dialog.astro index 88ded1b..4d44c16 100644 --- a/src/components/ui/primitives/Dialog.astro +++ b/src/components/ui/primitives/Dialog.astro @@ -1,6 +1,7 @@ --- import { cn } from "@/lib/cn"; +import Button from "./Button.astro"; import Icon from "./Icon.astro"; /** @@ -32,14 +33,9 @@ const { id, title, class: className } = Astro.props; >

{title}

- +
diff --git a/src/components/ui/primitives/Field.variants.ts b/src/components/ui/primitives/Field.variants.ts new file mode 100644 index 0000000..b88690e --- /dev/null +++ b/src/components/ui/primitives/Field.variants.ts @@ -0,0 +1,31 @@ +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/cn"; + +/** + * The shared recipe for text controls (DESIGN.md §8): pocket floor, hairline border, accent focus + * ring. `Input` and `Textarea` differ only in height versus vertical padding, so the invalid, + * disabled and transition treatment has one definition — a `Select` composes the same recipe. + */ +export const fieldVariants = cva( + cn( + "w-full rounded-md border bg-card px-3 text-copy text-foreground", + "placeholder:text-muted", + "transition-colors duration-(--duration-micro) ease-(--ease-toggle)", + "disabled:cursor-not-allowed disabled:opacity-50", + ), + { + defaultVariants: { control: "input", invalid: false }, + variants: { + control: { + /** A single-line control is a 44px touch target. */ + input: "h-11", + textarea: "py-2", + }, + invalid: { + true: "border-destructive-bright", + false: "border-border hover:border-primary/40", + }, + }, + }, +); diff --git a/src/components/ui/primitives/FieldError.astro b/src/components/ui/primitives/FieldError.astro index 144d90a..e8859b2 100644 --- a/src/components/ui/primitives/FieldError.astro +++ b/src/components/ui/primitives/FieldError.astro @@ -18,11 +18,22 @@ interface Props { const { id, class: className } = Astro.props; --- +

- - + { + Astro.slots.has("default") && ( + <> + + + + ) + }

diff --git a/src/components/ui/primitives/Icon.astro b/src/components/ui/primitives/Icon.astro index 58e21bc..13fb80b 100644 --- a/src/components/ui/primitives/Icon.astro +++ b/src/components/ui/primitives/Icon.astro @@ -26,19 +26,26 @@ const { name, style = "outline", size = 24, label, class: className } = Astro.pr const markup = iconMarkup(name, style); const decorative = label === undefined; + +/** + * Tabler's filled sources carry no stroke. Painting one on centres it over the fill boundary, + * inflating the glyph ~1px on every edge and thickening narrow details — so a filled icon would + * read heavier and larger than its nominal `size`. + */ +const filled = style === "filled"; --- diff --git a/src/components/ui/primitives/Textarea.astro b/src/components/ui/primitives/Textarea.astro index 757e023..6dfd357 100644 --- a/src/components/ui/primitives/Textarea.astro +++ b/src/components/ui/primitives/Textarea.astro @@ -3,6 +3,8 @@ import type { HTMLAttributes } from "astro/types"; import { cn } from "@/lib/cn"; +import { fieldVariants } from "./Field.variants"; + interface Props extends Omit, "class"> { id: string; invalid?: boolean; @@ -14,14 +16,7 @@ const { id, invalid = false, rows = 5, class: className, ...rest } = Astro.props diff --git a/src/pages/styleguide.astro b/src/pages/styleguide.astro index 96dc043..d09010e 100644 --- a/src/pages/styleguide.astro +++ b/src/pages/styleguide.astro @@ -517,7 +517,7 @@ const themes = [
- +

Primitives

Every primitive in ui/primitives/, in all @@ -627,7 +627,7 @@ const themes = [

Accordion

- +

Yes — it is a native <details> element. The shared @@ -692,7 +692,7 @@ const themes = [

- +

Program themes

A program page is the same site wearing team colors. Each theme remaps four tokens — diff --git a/tools/checks/cn-font-size-group.mjs b/tools/checks/cn-font-size-group.mjs new file mode 100644 index 0000000..3c49543 --- /dev/null +++ b/tools/checks/cn-font-size-group.mjs @@ -0,0 +1,69 @@ +/** + * `cn`'s `font-size` class group is a hand-maintained mirror of DESIGN.md §3's type scale. A + * token missing from it silently loses to any color beside it — the failure only shows up as a + * wrong size in a browser, which is exactly how it was found the first time. + * + * This makes the divergence a `pnpm check` failure instead. Run from the repo root. + */ +import { readFileSync } from "node:fs"; + +const css = readFileSync("src/styles/global.css", "utf8"); +const cnSource = readFileSync("src/lib/cn.ts", "utf8"); + +const themeStart = css.indexOf("@theme"); +if (themeStart === -1) { + console.error("tools/checks/cn-font-size-group: no @theme block in src/styles/global.css"); + process.exit(1); +} + +/** `--text-` declarations, skipping the `--text-x--line-height` style of sub-property. */ +const sizeTokens = new Set( + [...css.matchAll(/--text-([\w-]+):/g)] + .map((match) => match[1]) + .filter((name) => !name.includes("--")), +); + +/** + * The color namespace wins where a name is both a color and a size, so those sizes are reached + * through a `@utility` instead and belong in the group under that name. + */ +const utilityAliases = new Map(); +for (const match of css.matchAll( + /@utility (text-[\w-]+) \{[^}]*font-size: var\(--text-([\w-]+)\)/g, +)) { + const [, utility, token] = match; + if (utility !== undefined && token !== undefined) { + utilityAliases.set(token, utility); + } +} + +const groupMatch = /"font-size": \[([^\]]*)\]/s.exec(cnSource); +if (groupMatch?.[1] === undefined) { + console.error('tools/checks/cn-font-size-group: no "font-size" class group in src/lib/cn.ts'); + process.exit(1); +} +const listed = new Set([...groupMatch[1].matchAll(/"([^"]+)"/g)].flatMap((m) => m[1] ?? [])); + +const expected = new Set(); +for (const token of sizeTokens) { + expected.add(utilityAliases.get(token) ?? `text-${token}`); +} + +const missing = [...expected].filter((name) => !listed.has(name)); +const extra = [...listed].filter((name) => !expected.has(name)); + +if (missing.length > 0 || extra.length > 0) { + console.error( + "tools/checks/cn-font-size-group: src/lib/cn.ts's font-size group has drifted from the " + + "--text-* tokens in src/styles/global.css.", + ); + if (missing.length > 0) { + console.error(` missing from cn.ts: ${missing.join(", ")}`); + } + if (extra.length > 0) { + console.error(` in cn.ts but not a token: ${extra.join(", ")}`); + } + process.exit(1); +} + +console.log(`cn font-size group matches ${String(expected.size)} type tokens`);