This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is the GeekLego v2 system: 2-tier tokens (primitives → semantics) + ShadCN/Radix components. It replaces the old 3-tier (primitive → semantic → component-token) architecture. The 2-tier cut has landed and the Token Editor
app/has been rebuilt on v2 — see "Repo state" below. The reference implementation for every v2 rule in this file is the Button slice at components/v2/Button/ — read it before building anything.This file is the authoritative source for v2 rules. Read it together with the reference component above.
GeekLego is an open-source, design-system-first React component library built on Tailwind CSS v4 + ShadCN/Radix UI.
It ships:
- A 2-tier design system —
design-system/v2/— primitives (geeklego's brand palette/scales, unchanged) aliased to a standard ShadCN/Tailwind semantic vocabulary (--primary,--background,--border,--ring, …). Components consume the semantics directly. - A component library —
components/v2/— ShadCN-pattern React components (cvavariants +cn()+ Radix primitives) styled with standard semantic utilities (bg-primary text-primary-foreground). - An AI generation skill —
component-builder-v2— generates new v2 components following the rules below. - A token editor → cockpit (
app/) — edit primitives + semantics, manage themes, export to CSS / IR / design.md. (Being adapted from 3-tier; see brief §4.)
Design-system-first means: the token system (primitives + semantics) is the source of truth. Components are an expression of it — you never hardcode a value or invent vocabulary. You consume the standard semantics, and only reach for namespaced --ext-* tokens for genuine brand-custom variants.
Why standard ShadCN vocabulary? Industry-standard, in every LLM's training set, native Tailwind v4 utilities, paste-and-go. The only geeklego-specific layer is the primitive palette (the brand seam) — the semantic interface is deliberately standard.
The §7 decision gate PASSED, the §7.5 2-tier cut executed (2026-06-21), and the Token Editor app/ was rebuilt on v2 (2026-06-22). v2 is the only live system. The old 3-tier components, component-token block, and old skill are deleted; the Token Editor now runs against design-system/v2/.
| Path | System | Status |
|---|---|---|
design-system/v2/, components/v2/ |
v2 — 2-tier ShadCN/Radix | Active. All new work goes here. |
design-system/geeklego.css |
DELETED in the cut. | v2 imports design-system/v2/index.css only — there is no geeklego.css to fall back to. |
design-system/v2-defaults/ |
factory-reset baseline — pristine copies of primitives.css, semantics.css, themes/dark.css |
Source of truth for "reset to defaults" / export diffs. Don't edit by hand as part of normal token work — edit design-system/v2/. |
components/utils/ |
shared helpers (keyboard/accessibility/security/i18n/StructuredData) | Kept per brief §5 — pending a Radix-redundancy audit. Survivors of that audit stay. |
components/index.ts |
package barrel | Re-exports all v2/* components (the published surface) + the kept utils/*. Every new v2 component must be added here so pnpm build bundles it. Old 3-tier + catalog re-exports were removed in the cut. |
components/{atoms,molecules,organisms}/, components/catalog.ts, scripts/catalog.ts |
old 3-tier components + catalog | DELETED in the cut. (Backups in the session scratchpad.) |
.claude/skills/component-builder/ |
old 3-tier skill | DELETED. Use component-builder-v2. |
app/ (Token Editor cockpit) |
v2 — rebuilt | Active. Flat GeeklegoTokensV2 model; reads/writes design-system/v2/ via a live inline plugin in vite.config.mts (not a standalone token-api). Run with npm run dev. |
Current rules:
- Build all new components under
components/v2/<Name>/. The old atom/molecule/organism dirs are gone — there is nothing to import from them. - v2 imports
design-system/v2/index.cssonly — neverdesign-system/geeklego.css. - ESLint now enforces v2 import discipline via
no-restricted-importsin eslint.config.mjs (bans**/{atoms,molecules,organisms}/**and directgeeklego.cssimports).npm run lintis a real gate. AGENTS.mdis refreshed for v2 (operational supplement to this file).DESIGN_SYSTEM.mdwas deleted; the gitignoreddocs/notes still describe 3-tier — ignore them.- Shipped v2 components (verify with
ls components/v2/— the list moves as work lands): Button (the reference leaf), Input (a styled leaf), Dialog & Popover (compound, on Radix primitives), and Combobox + Command (thePopover+cmdkrecipe), plus shared helpers incomponents/v2/lib/(cn.ts). Radix deps in use:@radix-ui/react-slot(powersasChild),@radix-ui/react-dialog,@radix-ui/react-popover, andcmdk. The cockpit's own UI lives inapp/src/editor-ds/and is separate from the publishedcomponents/v2/library.
Package manager is pnpm (the node_modules tree is pnpm-built; pnpm-lock.yaml is the live lockfile; package.json pins packageManager: pnpm@…). Install packages with pnpm add <pkg> — never npm install: npm's installer crashes on this pnpm tree (Cannot read properties of null (reading 'matches')), and a preinstall guard (only-allow pnpm) now hard-blocks npm/yarn. Running scripts works under either (npm run dev ≡ pnpm run dev); the table below uses pnpm. There is no test script — run Vitest directly.
| Task | Command |
|---|---|
| Install a dependency | pnpm add <pkg> (npm install — see above) |
| Token Editor cockpit (dev) | pnpm run dev (Vite, serves app/) |
| Storybook (component dev) | pnpm run storybook (port 6006) |
| Both at once | pnpm run dev:all |
| Type-check | npx tsc --noEmit |
Lint (JS/TS — enforces v2 import rules; --max-warnings 0) |
pnpm run lint |
| Lint CSS | pnpm run lint-css (stylelint over design-system/v2/**) |
| Run all tests | npx vitest run (config: vitest.config.ts — app/src/**/*.test.ts + scripts/**/*.test.ts) |
| Run one test file | npx vitest run scripts/validate-tokens.test.ts |
| Watch a test | npx vitest scripts/validate-tokens.test.ts |
| Validate token chain | pnpm run validate-tokens (must exit 0) |
| Build library (JS + CSS) | pnpm run build (tsup bundle + build:css) |
| Build CSS only | pnpm run build:css (compiles design-system/v2/index.css → dist/geeklego.css) |
| Export IR / design.md | pnpm run export-ir · pnpm run export-design-md |
| Build Storybook | pnpm run build-storybook (passes — .storybook/main.ts globs only components/v2/**, so the legacy stories/** incl. the broken Configure.mdx is excluded) |
Tier 1 — PRIMITIVES geeklego's own brand palette/scales, UNCHANGED (the @theme block + :root mirror)
--color-brand-900 --color-accent-500 --spacing-4 --radius-lg --text-base
↓ aliased by
Tier 2 — SEMANTICS ShadCN / Tailwind STANDARD vocabulary (the interface components consume)
--primary / --primary-foreground --background / --foreground
--secondary --muted --accent --destructive
--border --input --ring --card --popover --radius
COMPONENTS standard Tailwind v4 utilities, zero custom vocabulary:
className="bg-primary text-primary-foreground border-input ring-ring"
THEMES a theme = a set of Tier-2 overrides → .dark { --primary: …; --background: … }
BRAND FORKS re-point Tier-2 semantics at different primitives
CUSTOM VARIANTS --ext-<component>-<variant>-* (namespaced, OUTSIDE the core semantic set)
primitive → semantic → utility. Never skip and never hardcode:
- Every Tier-2 semantic aliases a Tier-1 primitive (
--primary: var(--color-brand-900)). Never a raw value. - Every
--ext-*custom-variant token also aliases a primitive (--ext-button-gamified-bg: var(--color-accent-500)). Never a raw value. - Components reference semantics only, via standard utilities. They never reference a primitive directly and never hardcode.
- There is no component-token tier in v2. Do not write
--button-*-style component-token blocks. If you find yourself wanting one, you want either a standard utility or an--ext-*token.
design-system/v2/
├── index.css ← entrypoint. @imports the three below, in order (order matters)
├── primitives.css ← Tier 1. @import "tailwindcss" + the @theme palette/scales. Copied unchanged from the brand source.
├── semantics.css ← Tier 2. :root semantic aliases + @theme inline registration + the --ext-* block
└── themes/
└── dark.css ← dark theme = Tier-2 overrides under [data-theme="dark"], .dark
How the semantic layer wires up (see semantics.css):
:root { --primary: var(--color-brand-900); … }— raw semantic vars, each aliasing a primitive. This is the themeable layer.@theme inline { --color-primary: var(--primary); … }— registers each semantic as a Tailwind v4 color utility. Theinlinekeyword is required: it makes Tailwind emit utilities that resolve thevar()at runtime, so theme overrides (.dark { --primary: … }) re-theme live without a rebuild. Radius works the same way (--radius→--radius-sm/md/lg/xlviacalc()).--ext-*custom-variant tokens get their own:rootblock and their own@theme inlineblock, kept structurally separate from the core semantics so brand variants never pollute the themeable set.
Themes (themes/dark.css) override only Tier-2 vars and support both selectors: [data-theme="dark"] (the Token Editor / Storybook switch) and .dark (ShadCN convention). Always write both.
--background --foreground · --primary --primary-foreground · --secondary --secondary-foreground · --muted --muted-foreground · --accent --accent-foreground · --destructive --destructive-foreground · --border · --input · --ring · --card --card-foreground · --popover --popover-foreground · --radius.
geeklego extends ShadCN (deliberate, documented):
- Status —
--success--success-foreground·--warning--warning-foreground·--info--info-foreground. Feedback/status semantics beyond ShadCN's lone--destructive; bucketed under Status in the cockpit. - Sidebar —
--sidebar-bg--sidebar-foreground--sidebar-border--sidebar-accent--sidebar-accent-foreground--sidebar-ring. A dedicated navigation-chrome surface. Naming deviation: the panel-surface key is--sidebar-bg, not ShadCN's--sidebar, so the whole group is uniformly suffixed (bg/foreground/border/…). Internally consistent across component + variants + dark + defaults; bucketed under Layout.
These map to Tailwind utilities bg-*, text-*, border-*, ring-*, rounded-*. Do not invent new core semantics. If geeklego genuinely needs a semantic ShadCN doesn't define (e.g. status/info, data-series for charts), extend the standard set deliberately, document it as "geeklego extends ShadCN," and add it to this list — don't smuggle it in via a component.
No atom/molecule/organism tiers. No level folders. No catalog. Flat: components/v2/<ComponentName>/. Atomic design survives as a composition discipline (decompose → plan the tree → build leaves first → compose), not as a taxonomy. Import discipline is enforced by ESLint (eslint.config.mjs), not folder structure.
The canonical shape, proven by Button:
cvavariants in a separate<name>-variants.tsfile — base classes +variants+defaultVariants. Variant classes are standard semantic utilities.cn()=clsx+tailwind-merge(from components/v2/lib/cn.ts) — merges variant output with consumerclassName(last-wins, so overrides compose cleanly).forwardRef+displayName. PlainforwardRef— notmemo(forwardRef)(that was a 3-tier rule; dropped).asChildvia Radix<Slot>for polymorphic rendering (e.g. a link styled as a button).- Radix primitives for anything with a11y/keyboard/portal/focus surface — see the Radix-first rule below.
"use client";at the top when the component uses hooks/refs/interactivity.
// components/v2/Button/Button.tsx — the reference
"use client";
import { forwardRef } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "../lib/cn";
import { buttonVariants } from "./button-variants";
import type { ButtonProps } from "./Button.types";
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
},
);
Button.displayName = "Button";// button-variants.ts — core variants use ONLY standard semantics; `gamified` uses ONLY --ext-* tokens
export const buttonVariants = cva(
["inline-flex items-center justify-center …",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50"].join(" "),
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background text-foreground hover:bg-muted",
// custom variant — namespaced --ext-* utilities only:
gamified: "bg-ext-button-gamified-bg text-ext-button-gamified-foreground …",
},
size: { sm: "h-8 px-3 text-xs", md: "h-10 px-4 text-sm", lg: "h-11 px-6 text-base", icon: "size-10 p-0" },
},
defaultVariants: { variant: "default", size: "md" },
},
);// Button.types.ts — extend the native element attrs + VariantProps
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariantProps {
asChild?: boolean;
}Before hand-rolling any component with a11y, keyboard nav, focus trapping, portals, or aria-activedescendant behavior, check whether @radix-ui/react-* already provides the primitive (Dialog, Popover, Tabs, Tooltip, Select, Dropdown, …) and build on it. Radix delivers the focus trap, escape dismiss, click-outside, roving tabindex, and ARIA wiring natively — do not reimplement them. This replaces the old hand-rolled mechanisms (cloneElement injection, __slot markers, the utils/keyboard hooks).
When a component has slots (Card → Header/Content/Footer; Dialog → Trigger/Content), use the ShadCN sub-component-export pattern — separate sub-components exported as named exports (often each wrapping a Radix primitive part). This is the same shape as the old Object.assign(Card, {Header}) compound slots, restyled. Prefer the Radix primitive's built-in context over a hand-rolled createContext.
Button shipped with 4 files (TSX, types, variants, stories). v2 does not mandate the old fixed 5-file set (README + mock-data.json were 3-tier requirements). For a new component, write:
<ComponentName>/
├── <ComponentName>.tsx ← implementation (forwardRef + cn + Radix)
├── <ComponentName>.types.ts ← Props interface (native attrs + VariantProps + asChild?)
├── <component-name>-variants.ts ← cva variants (omit only for a component with no variants)
└── <ComponentName>.stories.tsx ← Storybook stories
Add a README.md when the component's API/a11y genuinely warrants docs (compound components, anything with non-obvious keyboard behavior). Don't generate boilerplate READMEs or mock-data.json by reflex.
Shared helpers live in components/v2/lib/ (e.g. cn.ts).
Standard semantic Tailwind utilities via className. No inline styles. No hardcoded values. No arbitrary color/size literals.
// Correct — standard ShadCN/Tailwind v4 utilities, generated from the semantic @theme inline layer
<button className="bg-primary text-primary-foreground border-input ring-ring rounded-md" />
// Correct — custom variant via namespaced --ext-* utilities
<button className="bg-ext-button-gamified-bg text-ext-button-gamified-foreground" />
// Wrong — inline styles, hardcoded hex/px, or bare arbitrary values (bg-[#6366f1], h-[40px])Note the v2 difference: because semantics are registered as real Tailwind utilities (@theme inline), you write bg-primary, not bg-[var(--primary)]. The var()-wrapper arbitrary-value pattern was a 3-tier workaround and is no longer needed for semantic tokens. (Bare shadow-[var(--ext-…)] arbitrary values are acceptable only where no registered utility exists, as in the gamified shadow.)
Inline style is acceptable only for genuine runtime-dynamic values that can't be a class — and only via CSS-custom-property injection (style={{ '--x': value }} consumed by a className), SVG presentation props, or data-driven positioning (tooltip coords, etc.). If the value would be var(--token), it's a className.
React 19 · TypeScript 5.7+ · Tailwind CSS v4 (@theme + @import "tailwindcss") · Vite 6 · Storybook 10 · Vitest 4 · Playwright 1.58 · lucide-react.
Component layer deps: @radix-ui/react-* (per-component — install when you build the component, not up front), class-variance-authority, clsx, tailwind-merge. @radix-ui/react-slot is already installed (powers asChild).
No other styling libraries beyond this set. The 3-tier ban on cva/cn()/clsx is reversed in v2 — those are now the required pattern. Still no styled-components, emotion, or CSS modules.
ShadCN is not a dependency. Its CLI copies source into your repo; we hand-write off the ShadCN pattern instead (the CLI fights geeklego's token setup). Use relative imports between v2 files — never package paths.
| Thing | Convention | Example |
|---|---|---|
| Component folder/file | PascalCase | Button/, Button.tsx |
| Variants file | kebab-case | button-variants.ts |
| Component export | named, matching folder | export const Button |
| Core semantic tokens | ShadCN standard kebab-case | --primary, --primary-foreground, --ring |
| Custom-variant tokens | --ext-<component>-<variant>-<property> |
--ext-button-gamified-bg |
| TS interfaces | PascalCase + Props |
ButtonProps |
| Story titles | v2/<Name> |
v2/Button |
Each story file imports the v2 stylesheet directly so the slice is self-contained:
import "../../../design-system/v2/index.css"; (the only stylesheet — the old 3-tier geeklego.css is gone).
Cover: Default, Variants (core/standard only), Sizes, each custom --ext-* variant, Disabled, asChild, and DarkMode (wrap in <div data-theme="dark" className="dark max-w-2xl …"> — set both selectors, and keep max-w-2xl so the dark surface is bounded). See Button.stories.tsx.
Brand-specific variants (e.g. Button's gamified) are the one place v2 departs from pure ShadCN vocabulary. Keep them contained:
- Token name:
--ext-<component>-<variant>-<property>, defined in the--ext-*block ofsemantics.css(its own:root+@theme inline), separate from the core semantic set. - The token still chains to a primitive — never a raw value.
- In the
cvavariants, a custom variant uses only its--ext-*utilities and none of the core semantics — this structural separation is what keeps the themeable layer clean. (Button'sgamifiedis the canary: watch whether this stays disciplined at variant #5, not just #1.)
Build/check the v2 slice through the real pipeline before declaring done:
npx tsc --noEmit— type-check.- Build the v2 story via Storybook/Vite to confirm the full Tailwind pipeline resolves the chain (
--color-brand-900 → --primary → bg-primary), the--ext-*utilities emit, and the dark override fires for both[data-theme="dark"]and.dark. pnpm run lint(ESLint flat config,--max-warnings 0) — also enforces v2 import discipline. The repo baseline is 0 warnings; any new warning fails the gate.
Storybook HMR caveat (new-file gotcha): Tailwind v4's
@tailwindcss/vitecontent scan under the Storybook dev server does not reliably pick up classes in a brand-new component file — a page reload won't fix it either. If a utility you just wrote appears "missing" in the running Storybook (e.g. a rule absent from the live stylesheet), restart Storybook (stop + start) before concluding the code is wrong; the standalonepnpm run build:cssandpnpm run build-storybookalways emit it correctly. Also note: Tailwind v4rotate-180sets the CSSrotateproperty, nottransform— verify rotation viagetComputedStyle(el).rotate, not.transform.
pnpm run validate-tokens runs scripts/validate-tokens.ts, now adapted to 2-tier (primitive→semantic chain checks; no component-tier passes). It must exit 0. Both broken-ref passes (CSS-chain and component-TSX) allowlist framework-injected runtime vars (tw-*, radix-*, e.g. --radix-accordion-content-height), so Radix runtime vars used in keyframes/inline styles don't trip the chain check.
- Never hardcode a value. Every value chains primitive → semantic (→
--ext-*). - Never write a component-token tier (no
--button-*-style blocks). v2 is 2-tier. - Never reference a primitive directly from a component. Components consume semantics only.
- Never invent core semantic vocabulary ad hoc. Extend the standard set deliberately and document it.
- Never edit, import from, or extend the old 3-tier code (
components/{atoms,molecules,organisms}/,design-system/geeklego.css). - Never use the old
component-builderskill. Usecomponent-builder-v2. - Never reimplement focus trap / escape / click-outside / roving tabindex / portals by hand when a Radix primitive provides it. Radix-first.
- Never use inline
stylefor a value that could be a className (var(--token)→ use the utility). - Never use bare arbitrary literals (
bg-[#6366f1],h-[40px]). Use registered utilities;var()arbitraries only where no utility exists. - Never put
--ext-*custom variants inside the core semantic block — they get their own separate block. - Never write a DarkMode story without both
data-theme="dark"and.dark(andmax-w-2xl). - Never reference Shadcn by name in shipped code/docs, and never wire in its CLI/
components.json— we hand-write off the pattern. - Never use package import paths between v2 files — relative imports only.
- Read components/v2/Button/ first — it's the reference for every rule here.
- Build under
components/v2/<Name>/with the file set above. - Check whether a Radix primitive exists before building anything with an a11y/keyboard/portal surface.
- Style with standard semantic utilities (
bg-primary,border-input,ring-ring). - Use
cvafor variants,cn()to merge,forwardRef+displayName. - Use
asChild+ RadixSlotfor polymorphic rendering. - Add semantics to
semantics.css(both:rootand@theme inline) if a genuinely new core semantic is justified. - Add
--ext-*tokens (own block) for brand variants, chained to a primitive. - Write a DarkMode story and confirm theming re-themes live.
- Verify through
tsc --noEmit+ a scoped Storybook/Vite build +npm run lint. - Author new v2 components via the
component-builder-v2skill once it's validated.
- 2-tier integrity — primitive → semantic, never skip, never hardcode. No component-token tier.
- Standard semantic vocabulary — consume ShadCN/Tailwind names; only
--ext-*for brand variants. - Radix-first — use the primitive if it exists; don't re-roll a11y.
- ShadCN component pattern —
cva+cn+forwardRef+Slot. - Theme-awareness — every semantic works in light and dark (
@theme inlinemakes overrides live). - v2 isolation — never entangle with the frozen 3-tier code at root.
Project: GeekLego v2 — open-source, design-system-first component library
Architecture: 2-tier tokens (primitives → standard ShadCN semantics) + ShadCN/Radix components
Stack: React 19 + TypeScript 5.7 + Tailwind CSS v4 + Radix UI + Storybook 10 + Vite 6
Reference component: components/v2/Button/ · This file is the authoritative source for v2 rules.
Last updated: 2026-06-22