diff --git a/.claude/skills/shadcn-astro/SKILL.md b/.claude/skills/shadcn-astro/SKILL.md new file mode 100644 index 0000000..05ac6cd --- /dev/null +++ b/.claude/skills/shadcn-astro/SKILL.md @@ -0,0 +1,115 @@ +--- +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 layout wrapper, nothing more. Astro cannot push a prop into slotted + children, so a wrapper `name` prop could only be read back by script; the caller passes the + same `name` to each item instead. +- `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/.oxlintrc.json b/.oxlintrc.json index 0a0c905..da07d38 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,12 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "extends": ["./tools/lint/nkzw/oxlintrc.json"], + "env": { + "browser": true, + "builtin": true, + "es2024": true, + "node": true + }, "plugins": ["typescript", "unicorn", "oxc", "import"], "options": { "typeAware": true, @@ -49,7 +55,7 @@ }, "overrides": [ { - "files": ["functions/**"], + "files": ["functions/**", "tools/**"], "rules": { "no-console": "off" } diff --git a/AGENTS.md b/AGENTS.md index 447f693..fab842d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ edit, and feeds lint failures back to you. Do not reach for the other toolchain ## Rules -- `cn` comes from `cnfast` only. `clsx`, `classnames`, `tailwind-merge` are banned imports. +- `cn` comes from `@/lib/cn` only — a `cnfast` merge configured with the DESIGN.md §3 type scale + (see the docstring). `clsx`, `classnames`, `tailwind-merge` are banned imports. - No new dependencies without an ADR in `docs/adr/`. - No client-side frameworks, no framework islands. - Content changes go in `src/content/` — see `docs/content.md`. 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/docs/tooling.md b/docs/tooling.md index 7a7655e..a0c63a6 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -148,9 +148,10 @@ against a commented interface and a commented type literal and carried each comm Two deliberate exceptions live in `.oxlintrc.json`'s `overrides`: -- `no-console` is off under `functions/**`. A Cloudflare Worker's console is its log stream — - `wrangler tail` and the dashboard read nothing else — so the rule's purpose (keeping debug - logging out of a shipped bundle) does not apply. +- `no-console` is off under `functions/**` and `tools/**`. A Cloudflare Worker's console is its + log stream — `wrangler tail` and the dashboard read nothing else — and a CLI check script's + console is how it reports to the developer running it. Neither is the shipped-debug-logging case + the rule guards. Everywhere else it stays an error. - Upstream's own `.ts` override is kept, which turns off the correctness rules TypeScript already covers (`no-undef`, `no-redeclare`, …). That is the config's speed principle, not a gap. diff --git a/eslint.config.ts b/eslint.config.ts index 5f9d018..5d77ce7 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -58,6 +58,17 @@ export default defineConfig( * off the whole family. See docs/adr/0001-toolchain-split.md. */ "@typescript-eslint/no-unsafe-return": "off", + /** + * A keyboard-reachable scroll container is a real pattern: an `overflow` region is not + * focusable by default, so without `tabindex="0"` its content is unreachable by keyboard + * (WCAG 2.2 SC 2.1.1). `role="region"` with an accessible name is how that container is + * named; the rule only allows `tabpanel` out of the box. Scoped to that one role — every + * other non-interactive element keeps the error. + */ + "astro/jsx-a11y/no-noninteractive-tabindex": [ + "error", + { roles: ["tabpanel", "region"], tags: [] }, + ], }, }, ); diff --git a/package.json b/package.json index 1d9a43d..d159e10 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "fmt": "oxfmt --ignore-path .gitignore && prettier --write \"**/*.{astro,md}\"", "fmt:check": "oxfmt --check --ignore-path .gitignore && prettier --check \"**/*.{astro,md}\"", "knip": "knip", - "check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check && pnpm run knip" + "check": "pnpm run typecheck && pnpm run lint && pnpm run fmt:check && pnpm run knip && pnpm run check:tokens", + "check:tokens": "node tools/checks/cn-font-size-group.mjs" }, "dependencies": { "@astrojs/sitemap": "3.7.3", @@ -23,6 +24,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 +32,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..0c25be9 100644 --- a/plan/03-primitives.md +++ b/plan/03-primitives.md @@ -9,7 +9,7 @@ ## Conventions (write these into `src/components/ui/primitives/README.md`) -- **API mirrors shadcn**: same component names, `variant`/`size` prop vocabulary, CVA (`class-variance-authority`) for variants, `cn()` from `cnfast` for merging, `class` prop + `...rest` spread onto the root element so callers can extend. +- **API mirrors shadcn**: same component names, `variant`/`size` prop vocabulary, CVA (`class-variance-authority`) for variants, `cn()` from `@/lib/cn` for merging, `class` prop + `...rest` spread onto the root element so callers can extend. - **Zero JS by default.** Interactivity ladder: (1) native HTML (`
`, ``, popover attribute), (2) CSS-only, (3) a small inline ` 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..4d44c16 --- /dev/null +++ b/src/components/ui/primitives/Dialog.astro @@ -0,0 +1,68 @@ +--- +import { cn } from "@/lib/cn"; + +import Button from "./Button.astro"; +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/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 new file mode 100644 index 0000000..e8859b2 --- /dev/null +++ b/src/components/ui/primitives/FieldError.astro @@ -0,0 +1,39 @@ +--- +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; +--- + + +

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

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..13fb80b --- /dev/null +++ b/src/components/ui/primitives/Icon.astro @@ -0,0 +1,52 @@ +--- +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; + +/** + * 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/Input.astro b/src/components/ui/primitives/Input.astro new file mode 100644 index 0000000..23c80bf --- /dev/null +++ b/src/components/ui/primitives/Input.astro @@ -0,0 +1,27 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cn } from "@/lib/cn"; + +import { fieldVariants } from "./Field.variants"; + +/** + * 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 `