Skip to content

overhaul: 03 primitives - #17

Open
CS-5 wants to merge 1 commit into
overhaul/02-design-systemfrom
overhaul/03-primitives
Open

overhaul: 03 primitives#17
CS-5 wants to merge 1 commit into
overhaul/02-design-systemfrom
overhaul/03-primitives

Conversation

@CS-5

@CS-5 CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Layer 3 of the overhaul stack, on overhaul/02-design-system. plan/03-primitives.md.

The zero-JS primitive layer, plus the agent skill for adding more.

What's here

Button (5 variants × 4 sizes, renders <a> given href), Card with CardHeader/Title/Description/Content/Footer, Badge (7 tones incl. the four sponsor tiers), Input, Textarea, Label, FieldError, Separator, Accordion + AccordionItem, Dialog, Carousel, Icon, Skeleton. Every one is on /styleguide in all variants and states, and the theme section at the bottom now renders the full set under data-theme="frc" and "fll".

Interactivity stays as high up the ladder as it can go: Accordion is native <details> with the name attribute for exclusive open, Dialog is native <dialog> so the browser owns the focus trap and Esc, Carousel is a CSS scroll-snap track. The whole styleguide — Dialog and Carousel included — ships 835 bytes of inlined script, and a page built from only the static primitives ships zero <script> tags and zero .js files.

Also here: src/components/ui/primitives/README.md (the conventions) and .claude/skills/shadcn-astro/SKILL.md (how to port a shadcn component, with the Radix→native mapping table and the Accordion worked through end to end, including what gets dropped and why).

⚠️ Two silent bugs this phase surfaced

Both would have spread through every page built after this one.

1. cn() was dropping font sizes.

Tailwind builds text-* utilities from two token namespaces — --text-* for sizes, --color-* for colors — and the merge step only recognizes Tailwind's stock scale (text-sm, text-lg), not text-h4 or text-small. So it treated every text-* class as a single conflict group and kept only the last one:

cn("text-primary-foreground", "text-body")  →  "text-body"
cn("text-small", "text-muted")              →  "text-muted"   // size gone

The first line is the damaging one. Every primary button's label rendered in body gray on Safety Yellow — I measured it in the browser at 1.3:1. @/lib/cn is now a configured merge (cnfast takes a tailwind-merge config) that registers the §3 type scale as the font-size group. Same buttons now measure 11.7:1, and size-plus-color pairs keep both classes while two colors or two sizes still resolve to the last.

The tradeoff worth flagging: adding a size token to global.css now means adding one line to cn.ts, or it silently loses to any color next to it. That's called out in cn.ts, the primitives README, and the skill.

2. text-body is a color, not a size. Phase 02 established this (body names both, Tailwind resolves colors first), but Button, Input, and Textarea were all using text-body intending the size and silently getting none. They use text-copy now.

Deviations from the phase brief

  • Icons come from @tabler/icons, inlined at build per ADR 0002 — not astro-icon + @iconify-json/tabler. Worth knowing: that package's exports map is "./*": "./icons/*", which rewrites every subpath including package.json, so src/lib/icon.ts locates the icons directory through a known icon file rather than the manifest. An unknown icon name throws at build with a pointer to tabler.io/icons.
  • CVA recipes live in sibling *.variants.ts files. Astro forbids exporting values from a component (astro/no-exports-from-components), so buttonVariants can't live in Button.astro. This turns out better than shadcn's arrangement: another component can import the recipe rather than copying classes.
  • Card sub-parts are separate components, not named slots, matching shadcn's composition model.
  • Skeleton included, no Spinner — the calendar's loading state needs the former in Phase 07; nothing needs the latter.

Verified

  • pnpm check && pnpm build green.
  • Dialog asserted against the live page: dialog.open is true after the trigger, false after Escape.
  • Button contrast and computed font sizes read out of the browser after the cn() fix (11.71:1; 14px / 16.85px / 18.70px for sm/md/lg, the fluid clamp working).
  • Zero-JS claim verified by building a page using only Button/Card/Badge/Input/Accordion and grepping the output.

Generated by Claude Code

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
<details> with the name attribute for exclusive open, Dialog is native
<dialog> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant