Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .claude/skills/shadcn-astro/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<name>.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) | `<details>` + the native `name` attribute — see `AccordionItem.astro` |
| Dialog (focus trap, Esc, inert background, portal) | native `<dialog>` + `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 `<a>` 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 `<Name>.variants.ts`, component in `<Name>.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` — `<details name={name}>` with `<summary>` as the trigger.

What maps directly:

- `type="single"` ⇒ the native `name` attribute. Browsers close sibling `<details>` 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 `<summary>` elements are plain tab stops.
Tab still reaches every item, so nothing is unreachable.
- **The height transition.** Animating `<details>` 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.
8 changes: 7 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -49,7 +55,7 @@
},
"overrides": [
{
"files": ["functions/**"],
"files": ["functions/**", "tools/**"],
"rules": {
"no-console": "off"
}
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
],
},
},
);
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -23,13 +24,15 @@
"@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"
},
"devDependencies": {
"@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",
Expand Down
72 changes: 66 additions & 6 deletions plan/03-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<details>`, `<dialog>`, popover attribute), (2) CSS-only, (3) a small inline `<script>` with no dependencies — only when 1–2 can't deliver, and always progressive-enhancement (content readable without JS).
- **Accessibility is part of the primitive**, not the caller's job: focus-visible styles, aria wiring, keyboard behavior.
- Polymorphism where shadcn uses `asChild`: an `as` prop (e.g. Button renders `a` when `href` is passed) — replaces legacy's separate `LinkButton`.
Expand Down Expand Up @@ -51,8 +51,68 @@ 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 `<dialog>`), 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 `<details>`, so Enter/Space are the browser's; Carousel buttons focusable; icons are `aria-hidden` with text alternatives where needed. **Met by a different mechanism — see "Carousel arrows are not tab stops" below.**
- [x] Zero client JS for a page using only Button/Card/Badge/Input/Accordion — verified by building such a page: 0 `<script>` tags, 0 `.js` files in `dist`. The whole styleguide, Dialog and Carousel included, ships 835 bytes of inlined script.
- [x] `ui/primitives/README.md` conventions written; `shadcn-astro` skill present and self-consistent.
- [x] `pnpm check && pnpm build` green.

### Two real bugs this phase surfaced

Both were silent, and both would have spread through every page had they not been caught here.

1. **`cn()` was dropping font sizes.** Tailwind builds `text-*` utilities from two namespaces
(`--text-*` sizes, `--color-*` colors), and the merge step only recognizes Tailwind's stock
scale — so it treated all `text-*` classes as one conflict group and kept only the last.
`cn("text-primary-foreground", "text-body")` collapsed to `text-body`, rendering every
primary button's label in body gray on Safety Yellow: **1.3:1**, measured in the browser.
`@/lib/cn` is now a configured merge that registers the §3 type scale as the font-size group;
the same buttons now measure 11.7:1, and `cn("text-small", "text-muted")` keeps both classes.
2. **`text-body` is a color, not a size** (§3's note, added in Phase 02): `body` names both, and
Tailwind resolves colors first. Sizes now go through `text-copy`; Button, Input, and Textarea
were using `text-body` for size and silently getting none.

### Deviations from this brief

- **`cn` comes from `@/lib/cn`, not `cnfast` directly.** Phase 01 correctly deleted `src/lib/cn.ts`
when it was a bare `export { cn } from "cnfast"` — there was nothing there. This phase needs a
*configured* merge, and `cnfast` exposes no global configuration hook: `cn` is the unconfigured
default and `createCn(config)` returns a new function, so the configuration has to live in a
module that components import. The file is back for that reason alone, and its docstring says so.
Without it, `cn("text-primary-foreground", "text-copy")` collapses to one class and every primary
button's label renders at about 1.3:1 — the bug recorded below. D7's intent (one sanctioned source
for `cn`, no `clsx`/`tailwind-merge` sprawl) is unchanged; the source is this module, which wraps
the package.

- **Icons come from `@tabler/icons`**, inlined at build (ADR 0002), not `astro-icon` +
`@iconify-json/tabler`. Two fewer dependencies; icon names are Tabler's own. The package's
`exports` map rewrites every subpath including `package.json`, so `src/lib/icon.ts` locates the
icons directory through a known icon file instead of the manifest.
- **CVA recipes live in sibling `*.variants.ts` files**, because Astro forbids exporting values
from a component (`astro/no-exports-from-components`). This is also what lets one component
reuse another's recipe. Documented as a convention in the README and the skill.
- **Card sub-parts are separate components** (`CardHeader`, `CardTitle`, …) rather than named
slots, matching shadcn's composition model.
- **`Skeleton` is included** (the calendar's loading state needs it in Phase 07); no `Spinner`,
since nothing in the site has a use for one.
- **Carousel arrows are not tab stops.** The brief's keyboard criterion asks for focusable arrow
buttons with `aria-hidden` icons. Instead the arrows are `aria-hidden="true" tabindex="-1"` and
the track itself is the tab stop: `role="region"` with an accessible name and `tabindex="0"`,
scrolled with the arrow keys. Two focusable controls that only duplicate what the arrow keys
already do on the focused track are noise for a keyboard user, and every slide is in the DOM and
reachable by tab regardless. The criterion is met in substance — keyboard-operable, icons hidden
— by a different mechanism, which is why it is recorded here rather than rewritten in place.
- **`no-noninteractive-tabindex` allows `role="region"`.** That pattern is the reason above; an
`overflow` container is not focusable by default, so without `tabindex="0"` its content is
keyboard-unreachable (WCAG 2.2 SC 2.1.1). The rule ships allowing only `tabpanel`. Scoped to
that one extra role in `eslint.config.ts`.
- **`Field.variants.ts` is shared by `Input` and `Textarea`.** They differ only in height versus
vertical padding, so the invalid/disabled/transition treatment has one definition — the reuse
the sibling-variants convention exists for, and what a `Select` will compose in a later phase.
- **`Button` gained a `pocket` variant.** Carousel arrows and the Dialog close were hand-built
icon buttons; the Dialog's came out at `p-1`, below the 44px minimum the shared recipe exists to
enforce. Both now compose `Button`, so touch target and focus behavior have one owner.
- **`tools/checks/cn-font-size-group.mjs` guards the font-size group.** The `cn` bug below was
found by measuring contrast in a browser, and a comment was the only thing stopping the next
`--text-*` token from reintroducing it. `pnpm check` now fails on divergence in either
direction.
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading