Skip to content

overhaul: 03 primitives - #17

Open
CS-5 wants to merge 2 commits into
overhaul/02-design-systemfrom
overhaul/03-primitives
Open

overhaul: 03 primitives#17
CS-5 wants to merge 2 commits 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

This was referenced Aug 28, 2026

CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

/simplify pass — quality review (reuse · simplification · efficiency · altitude)

Quality only — no correctness findings here; a separate /code-review pass follows.


1. The field recipe is copy-pasted, against the convention this PR itself establishes

src/components/ui/primitives/Input.astro:22-29 and Textarea.astro:17-24

  class={cn(
    "h-11 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",
    invalid ? "border-destructive-bright" : "border-border hover:border-primary/40",

Five of the six lines are byte-identical between the two files — including the whole invalid ternary and the transition tokens. Only h-11 px-3 vs px-3 py-2 differs.

This is the one place in the PR that does the thing its own README warns against. src/components/ui/primitives/README.md: "CVA definitions live in a sibling *.variants.ts … This is also what lets another component reuse a recipe … instead of copying classes." DESIGN.md §8 governs field focus/invalid/disabled treatment, so any change there is now a two-file edit — three once Select lands — and the two will diverge silently because nothing links them.

Fix: a Field.variants.ts exporting fieldVariants with a control: "input" | "textarea" variant (for the h-11 vs py-2 difference) and an invalid boolean. Both components become cn(fieldVariants({ control, invalid }), className). That also removes two of the four repetitions of "transition-colors duration-(--duration-micro) ease-(--ease-toggle)" — the others are Button.variants.ts:13 and AccordionItem.astro:29.


2. Icon buttons are hand-built rather than composed from Button, which ships an icon size in this same PR

src/components/ui/primitives/Carousel.astro:49 (and again at :58)

          class="pocket pocket-interactive text-foreground grid size-11 place-items-center"
          data-carousel-prev

Button.variants.ts:35 already defines icon: "size-11" with the shared focus/disabled/transition base. The same geometry then gets re-derived at Dialog.astro:37 — where it came out as rounded-md p-1, below the 44px minimum the shared recipe exists to enforce (DESIGN.md §9) — and again at Navbar.astro:133 and Footer.astro:66/:77 in PR #19. Six ad-hoc icon buttons across the stack, one already violating the rule.

The shadcn-astro skill added in this PR directs exactly the right move: "Do not use this to add a variant to an existing primitive — edit its *.variants.ts."

Fix: extend Button.variants.ts with a pocket variant and render <Button variant="pocket" size="icon">, so touch-target and focus behavior have one definition. Worth doing in this PR specifically, since it's the one that would otherwise set the precedent the app shell then follows.


3. A prop and a data attribute nothing reads, with a doc comment that's actively wrong

src/components/ui/primitives/Accordion.astro:11,18

  /** Shared name for exclusive-open behavior. Omit for independently-toggling items. */
  name?: string;
...
<div class={cn("divide-y divide-border", className)} data-accordion-name={name}>

data-accordion-name is never read anywhere in the stack — grepped through overhaul/05-app-shell, this line is its only occurrence. Astro can't push a prop into slotted children, so exclusive-open comes entirely from name on each <details>, which is why styleguide.astro writes name="sg-faq" four times: once uselessly on <Accordion>, once per item.

The comment claims the wrapper's name produces the grouping. It doesn't, and that will send the next caller looking for a bug in the wrong file.

Fix: drop the prop and the attribute. Accordion becomes a pure layout wrapper and the doc comment says "pass the same name to each AccordionItem to make the group exclusive-open."


4. A failed acceptance criterion was rewritten rather than recorded as a deviation

plan/03-primitives.md:55

The brief's keyboard criterion read:

Carousel buttons focusable; icons are aria-hidden with text alternatives where needed

This PR ships arrows that are aria-hidden="true" tabindex="-1" (Carousel.astro:47-64) and rewrote the criterion to:

Carousel arrows are aria-hidden/tabindex="-1" with the track itself a focusable, scrollable tab stop

— then checked it [x]. The icons clause was dropped from the line entirely.

The decision itself is defensible; a scrollable track as the tab stop is a legitimate pattern. The problem is where it's recorded. This PR has a "Deviations from this brief" section listing four other departures, and this one isn't in it — so a reviewer of the stack sees a satisfied criterion instead of a decision to weigh. The precedent is that any phase can retro-fit its own acceptance text, and Phase 06's visual-review gate leans on these boxes.

Fix: restore criterion 55 verbatim, mark it per the real behavior, and move the arrow decision into "Deviations from this brief" with the reasoning currently embedded in the criterion. Same mechanism the PR already used correctly for the CVA-sibling-file and Tabler-icon departures.


5. cn's font-size group is a hand-maintained mirror of the type tokens, enforced only by a comment

src/lib/cn.ts:24-40

The merge config lists the DESIGN.md §3 scale as string literals, and the doc comment states the invariant it cannot enforce:

A new size token in global.css must be added here too, or it will silently lose to any color beside it.

The failure mode is the one this PR just spent a bug on — a size class silently dropped by the merge, found only by measuring in a browser (the PR's own note: "1.3:1, measured in the browser"). The next --text-* token added in Phases 06–08 reintroduces it, in whatever component happens to combine that size with a color. A comment is the weakest available enforcement for an invariant that fails invisibly.

Fix: make divergence a pnpm check failure — a ~15-line script in tools/checks/ extracting --text-* names from global.css and diffing them against the array. That's exactly the pattern PR #19 later uses for .lycheeignore via tools/checks/stale-link-ignores.mjs, so the machinery is already agreed on. Failing that, generate the array from the same parsed token map suggested in the PR #16 comment.


Checked and cleared

  • Efficiency: nothing to report, and two things worth confirming. src/lib/icon.ts reads Tabler SVGs off disk at build time through a module-level Map cache, and @tabler/icons is a devDependency — only referenced glyphs enter the build, none of the package ships. class-variance-authority and cnfast are build-time only: the built site contains zero JS files, the only client script being the ~700-byte inlined Navbar toggle from PR overhaul: 05 app shell #19. Carousel and Dialog both delegate (one document-level listener each, no scroll/resize handlers anywhere in the stack).
  • The .claude/skills/shadcn-astro/SKILL.md interactivity ladder and the *.variants.ts convention are good calls; findings 1 and 2 are both cases of the PR not yet following its own new rules, not disagreements with them.

Generated by Claude Code

@CS-5 CS-5 mentioned this pull request Aug 28, 2026

@CS-5 CS-5 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness review of the primitives layer. I built the branch and inspected the generated HTML/CSS to check each of these rather than reasoning from the source alone; pnpm check and pnpm build are both clean on the branch, so everything below is runtime/behavioral.

Two look blocking:

  • Carousel's scoped <style> never matches its slides. Astro requires the component's scope attribute on the child, and slotted content doesn't have it — so no snap alignment, no slide sizing, and itemBasis is dead. The component's headline feature doesn't work.
  • FieldError's empty:hidden can never match, because the always-rendered <Icon> means the <p> is never :empty. An empty placeholder renders a bare red warning triangle.

The rest: disabled silently does nothing on Button's anchor path; Accordion's name prop never reaches its items (so the wrapper's documented exclusive-open is a no-op, and the shadcn-astro skill's worked example describes behavior that isn't there); Button can't type target/rel on the href path, which every external link button will need; filled icons get a 2px stroke painted over the fill; the Carousel arrows opt out of the global reduced-motion switch; and /styleguide now numbers its sections 1–6, 8, 7.

Nothing here argues with the architecture — the ladder, native <dialog>, and the cn() font-size fix all look right, and the text-body/text-copy split does resolve the way the docs claim (verified in the compiled CSS).


Generated by Claude Code

Comment thread src/components/ui/primitives/Carousel.astro Outdated
Comment thread src/components/ui/primitives/FieldError.astro
Comment thread src/components/ui/primitives/Button.astro Outdated
Comment thread src/components/ui/primitives/Accordion.astro Outdated
Comment thread src/components/ui/primitives/Button.astro
Comment thread src/components/ui/primitives/Icon.astro Outdated
Comment thread src/components/ui/primitives/Carousel.astro
Comment thread src/pages/styleguide.astro Outdated
@CS-5
CS-5 force-pushed the overhaul/03-primitives branch from 3e795d7 to 883ccf3 Compare August 28, 2026 15:06
@CS-5
CS-5 force-pushed the overhaul/03-primitives branch from 883ccf3 to 3214019 Compare August 28, 2026 15:21

CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

src/lib/cn.ts is back in this phase — flagging because it reverses part of 235aa2f

The cleanup deleted it as "just re-exporting a package we should import directly." That reading is exactly right at Phase 01, where the file was five lines:

export { cn } from "cnfast";

It stops being true here. Phase 03 turned it into a configured merge, and cnfast has no global configuration hook — checked its type surface:

declare const cn: ClassNameFunction;
declare const createCn: (config: ConfigExtension | ((c: AnyConfig) => AnyConfig)) => ClassNameFunction;

cn is the unconfigured default; createCn(config) returns a new function. So the configuration has to live in a module that components import — there is no way to import cn from cnfast directly and still have it.

Without it, cn("text-primary-foreground", "text-copy") collapses to one class, because the merge treats every text-* as one conflict group: it knows Tailwind's stock scale, not --text-* semantic names. That renders every primary button's label in body gray on Safety Yellow — 1.3:1, which is the bug in this PR's "Two real bugs this phase surfaced" section.

What I did

  • Phase 02 imports cn from cnfast directly in all nine primitives. Nothing there combines a size with a color through cn, so no configuration is needed and your rationale holds unchanged.
  • Phase 03 reintroduces src/lib/cn.ts as createCn({ extend: { classGroups: { "font-size": [...] } } }) and points all 34 consumers at it. Its docstring leads with why it exists rather than claiming to be a sanctioned re-export, and plan/03 records it under "Deviations from this brief".
  • tools/checks/cn-font-size-group.mjs makes the token list drifting from global.css a pnpm check failure, since that is the invariant the file carries.

D7's intent — one source for cn, no clsx/tailwind-merge sprawl — is unchanged; the source is this module, which wraps the package.

If you would rather not have the file

The only alternative that keeps the fix is calling createCn at each site, which is worse. The options I see are: keep it as is; rename it so the filename says "configured" (src/lib/tw.ts, src/styles/cn.ts); or accept the unconfigured merge and drop the text-* size tokens from the type scale so the conflict cannot arise — that last one is a DESIGN.md §3 change, not a code change.

Happy to take any of those. Left as is for now because it is the only option that preserves current behavior.


Generated by Claude Code

@CS-5
CS-5 force-pushed the overhaul/03-primitives branch from 3214019 to 5c9981c Compare August 28, 2026 15:35
@CS-5
CS-5 force-pushed the overhaul/03-primitives branch from 5c9981c to 9f5eb18 Compare August 28, 2026 16:09
CS-5 and others added 2 commits August 28, 2026 16:13
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
Four primitives had behavior that was declared but never ran.

Carousel's scoped `[data-carousel-track] > *` was rewritten by Astro to require
this component's own scope attribute, which slotted slides never carry — so
`scroll-snap-align`, `flex: 0 0 100%` and the `flex-basis` media rule matched
nothing. Slides collapsed to content width, nothing snapped, and `itemBasis` was
dead. `:global(*)` on the child escapes the scope while keeping `define:vars`.

FieldError's `empty:hidden` could never match: `:empty` requires no child nodes
and the `<Icon>` was unconditional, so a placeholder rendered a bare red alert
triangle. The icon is now gated on slot content.

Button spread `disabled` onto the `<a>` path, where the attribute is invalid and
`:disabled` never matches — a disabled link rendered at full opacity, fully
clickable, and typechecked cleanly. It maps to `aria-disabled` (which the
variants already style) and the `href` is dropped. `Props` also extended only
`HTMLAttributes<"button">`, so every external link button the site needs failed
`astro check`; it now picks up `download`/`hreflang`/`rel`/`target`.

Accordion's `name` became an unused `data-accordion-name` with no consumer, so
the wrapper's documented exclusive-open was a no-op — the styleguide worked only
because it repeated `name` on each item. Dropped the prop; the JSDoc and the
`shadcn-astro` worked example now say where `name` belongs.

Icon emitted `stroke`/`stroke-width` unconditionally, but Tabler's filled sources
carry no stroke, so filled glyphs inflated ~1px on every edge and thickened
narrow details.

Carousel's explicit `behavior: "smooth"` bypassed the reduced-motion
`scroll-behavior: auto !important` in global.css; the track's `scroll-smooth`
class supplies it instead. The styleguide's section numerals ran 1–6, 8, 7.

Quality: `Field.variants.ts` holds the recipe `Input` and `Textarea` were
copy-pasting — five of six lines byte-identical, which is the reuse the
sibling-variants convention exists for. `Button` gained a `pocket` variant so the
Carousel arrows and the Dialog close compose it rather than hand-building icon
buttons; the Dialog's was `p-1`, below the 44px minimum the shared recipe
enforces. `tools/checks/cn-font-size-group.mjs` makes `cn`'s font-size group
drifting from the `--text-*` tokens a `pnpm check` failure instead of a comment —
that invariant fails invisibly, and it already cost this phase a 1.3:1 contrast
bug.

The brief's carousel keyboard criterion had been rewritten in place to describe
what shipped. Restored, with the arrow decision recorded under "Deviations from
this brief" alongside the rest.

Newly-live jsx-a11y rules (see PR #15) caught two real violations: `href="#"` on
the styleguide's demo links, and `tabindex="0"` on the carousel track. The track
is a keyboard-reachable scroll container, so it is `role="region"` with a name,
and that one role is added to `no-noninteractive-tabindex`'s allowlist —
dropping the tabindex would make the slides keyboard-unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
@CS-5
CS-5 force-pushed the overhaul/03-primitives branch from 9f5eb18 to 4aae3a1 Compare August 28, 2026 16:14
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.

2 participants