Repo: cnx-design-system → publishes @departmnt/ui (private, GitHub Packages)
Stack: Next.js · React · TypeScript · CSS Modules · Radix Themes (+ headless Radix primitives for bespoke) · pnpm
Tooling: Biome (lint/format) · Vitest (tests) — matches the client repos
Goal: Replace Figma as the delivery mechanism. Design decisions live here as code + tokens and ship to portals as a versioned package.
This supersedes the original Build Plan. It reflects every decision made during setup:
the three-tier token system, the Radix Themes-first hybrid, full 12-step brand gradients,
the [data-brand] theming model, and Portal Design Studio — a living tool + mirrored docs
attached to every phase.
Two repos, one direction of flow:
cnx-design-system/ ← this repo (design system, you own it)
publishes → @departmnt/ui (private npm via GitHub Packages)
client/dev repos ← consume it
import → @departmnt/ui@x.y.z
When a new version is released, the dev runs pnpm update @departmnt/ui. Nothing bleeds
between repos. The dev owns GitHub Packages auth + the deploy pipeline.
We standardise on Radix Themes as the styled base, customised through its token system, and drop to headless Radix primitives + CSS Modules only for bespoke components. This matches how hlls-portal and Genesis actually shipped (the cnx master was headless; the client portals used Themes — we unify on Themes).
Every component is built at the lowest-effort tier that achieves the design:
| Tier | When | Mechanism |
|---|---|---|
| 1 — Token override | Standard component, brand restyle | Override Radix Themes CSS variables per [data-brand] (accent/gray scale, radius, fonts) |
| 2 — Wrap + CSS Module | Themes component needs visual tweaks | Render the Themes component, pass className, layer a CSS Module |
| 3 — Headless + CSS Module | Bespoke / signature component | Build on @radix-ui/react-* headless primitive, style fully from tokens |
Default to Tier 1. Escalate only when the design demands it. Radix Themes' layout/typography
primitives (Box, Flex, Grid, Text, Heading) mean we do not hand-build a generic
primitives layer — /primitives is reserved for genuinely bespoke base elements.
tokens/tokens.json)| Tier | Prefix | Job |
|---|---|---|
| Global | --global-* |
Raw, brand-agnostic values (spacing, radius, type scale, motion, z-index, sizes) |
| Brand | --brand-* + Radix scales |
Per-client values, switched by [data-brand] |
| Component | --button-*, --card-*… |
Aliases components reference (for Tier 2/3 work) |
Component CSS may only reference the component tier. Full rules in /CLAUDE.md.
Each brand declares a seed in tokens.json (e.g. accentSeed: "#bf3414"). The token build
generates, per brand:
--accent-1…12) + 12-step alpha (--accent-a1…a12)--accent-contrast, --accent-surface, --accent-indicator, --accent-track--gray-1…12 + alpha), tuned to the brand--default-font-family and appearance (light/dark)Edit one seed → the whole scale regenerates.
[data-brand] and Radix Themes compose[data-brand] stays the single switch the dev sets. It selects the brand's generated scales,
font, and appearance. Our <DepartmntTheme> provider (a thin wrapper over Radix <Theme>)
reads the brand and applies the matching appearance/radius.
<html data-brand="hlls"> ← dev sets this (unchanged from today)
└─ tokens.css: [data-brand="hlls"] { --accent-1…12, --gray-1…12, fonts… }
└─ <DepartmntTheme> ← wraps app in Radix <Theme appearance="dark" …>
└─ Radix components consume --accent-* → render in H.LLS orange
Specificity note (already solved in hlls): Radix's .radix-themes wrapper redefines
--accent-* itself, so plain :root overrides can lose. The token build emits brand overrides
on :root, .radix-themes (and the [data-brand] selector) so the override always wins — the
dev never thinks about it. Dev integration becomes: import '@departmnt/ui/styles.css' +
set data-brand + wrap in <DepartmntTheme>.
Every phase ships two renderings of one source: a generated Markdown doc, and a view inside
Portal Design Studio — an internal Next.js app (/studio, not published). Both read from
a generated manifest, so they can never drift.
A build:manifest script digests the source files into normalised JSON (manifest/*.json):
┌─ tokens.json ──────────────┐ (read directly — already JSON)
source files ──► ├─ components/**/*.tsx + ts ──┤ (static analysis via ts-morph) ──► manifest/*.json
└─ SCREENS.md / MODULES.md ───┘ (parsed)
│
┌───────────────────────────────────┴───────────────┐
▼ ▼
docs/*.md (metadata only) Portal Design Studio (manifest + live TSX imports)
SCREENS.md / MODULES.md parsed into entries.Key distinction: the manifest carries metadata only (names, props, variants, docs — enough
for search, labels, and the .md files). For the live visual, the Studio (a real Next app)
imports the actual .tsx and mounts it. Markdown can't execute code, so it relies on the
manifest alone.
To know which prop combinations to mount, the Studio uses a hybrid:
variant × size × state from the prop unions.<Name>.preview.tsx — added only for components needing bespoke setup
(specific children, context, data). Like a lightweight Storybook story.The manifest records which mode each component uses. This is a rule in NAMING.md/CLAUDE.md.
Every view has a global brand switcher (toggles data-brand) so all brands render live.
| Phase | Studio view | Shows | Mirrored doc |
|---|---|---|---|
| 1 | Foundations | 12-step accent + gray swatches per brand; type specimens; spacing/radius/shadow/motion samples | TOKENS.md |
| 2 | Theme Lab | Live controls for Radix Theme vars (accent seed, gray, radius, scaling, appearance) with Themes components re-rendering instantly — a working demo of the Tier 1→2→3 spectrum; bespoke-primitive gallery | customization section of NAMING.md |
| 3 | Lookbook | Every component, all variants + states, props/usage panel, search | COMPONENTS.md |
| 4 | Flow Map | Interactive screen graph (nodes=screens, edges=navigation), status badges, click → modules used | SCREENS.md |
| 5 | Module Preview | Each white-label module rendered fully functional, stepping through internal flow states, with theme editor | MODULES.md |
| 5.5 | Colour Map (view or Theme Lab tab) | Radix colour plumbing visualised: scale steps → semantic vars → component parts (+hover), driving the DEPARTMNT styling layer | TOKENS.md addendum |
| 6 | Builder | Page Builder — Flow Map × Module Preview: screens with slots, module instances with fields, export brand.config.json |
per-client configs |
The Studio shell stands up in Phase 1 (with Foundations + the manifest pipeline); each later phase adds its route. Phases 7 (internal deploy + backend) and 8 (public self-serve) extend the same Studio rather than adding views.
Each phase ships: code/artifacts · a Studio view · a mirrored
.md· updated manifest. Don't skip ahead — each phase depends on the one before it.
Repo, folder structure, package.json, tsconfig.json, CLAUDE.md, .gitignore, toolchain
(Biome + Vitest), build pipeline (tsup → ESM/CJS/types). GitHub Packages auth pending (dev's job).
Goal: finish the token foundation and stand up Portal Design Studio.
scripts/build-tokens.js → build-tokens.ts (run via tsx); keep pnpm build:tokens.tokens/tokens.ts (typed constants) alongside tokens.css.tokens.json; generate full 12-step accent + gray scales per brand
(neutral, departmnt, hlls); emit Radix-mapped variables on :root, .radix-themes + [data-brand].build:manifest) and the Studio shell.TOKENS.md (generated).v0.1.0.Concerns: token names are permanent. Three-tier names are the source of truth; Radix variable names are a mapping target, not a rename.
Goal: stand up the styled base and the customization pattern. (NAMING.md already written.)
@radix-ui/themes; import its CSS at the package root.<DepartmntTheme> (wraps Radix <Theme>, reads [data-brand], applies appearance/radius).NAMING.md./primitives = bespoke-only.NAMING.md (customization section).default and custom are live; inverted is deferred — it's disabled in
the Theme Lab profile picker and brands set to inverted fall back to default for now. The
accent-as-surface repaint needs per-component direction before it reads correctly (the
generated [data-color-profile='inverted'] rule stays in place, just not surfaced yet).Concerns: don't hand-build primitives Themes provides. Customise via variables + className,
never by forking Themes' internals.
Goal: build the component set at the lowest viable tier.
/index.ts.<Name>.preview.tsx only when auto-extraction can't capture the examples.COMPONENTS.md (generated).Deviations from the original spec (v1, recorded):
.tsx to
statically analyse yet. The single source is a hand-authored registry, studio/lib/catalog.ts,
read by both the Lookbook and scripts/build-components-doc.ts (→ COMPONENTS.md). Switch to
ts-morph extraction once bespoke Tier-2/3 components exist in /components.Concerns: Radix handles behaviour/ARIA — only style the surface. Figma MCP rate-limits on free plans; a Dev seat is needed for active sprints.
Goal: a living map of the product.
docs/SCREENS.md (Screen · Route · Modules used · Status). Routes mirror Next.js files.SCREENS.md.Concerns: assign ownership or it goes stale. Don't map micro-states (loading/empty/error) as screens.
Goal: page-level compositions, themed at runtime.
<DepartmntTheme> + CSS-variable overrides (not a hand-rolled theme prop).version:module script.MODULES.md (inventory, versions, required brand vars).Concerns: the brand contract is "which Theme variables a brand sets" — keep it minimal. Version old modules, never delete. Mini-flows are self-contained (data comes in as props).
Goal: modules and components render 1:1 with Figma. Radix Themes natively recolours many surfaces/controls as the accent changes; the Figma design uses a simplified mapping — mostly monochrome cards on a neutral page, accent reserved for key actions. The DEPARTMNT styling layer is a thin, generated layer over Radix Themes that quietens the default theming so output matches Figma without hand-styling each component.
Steps, in order:
--color-background,
--color-panel-solid, --color-surface, --color-overlay, accent solid/contrast) →
component parts (Card bg, Button solid/soft/hover, TextField border…), with hover-state rows.
Interactive: hover a step → highlight everything it paints; hover a part → highlight its source
chain. Candidate render: React Flow (already in the studio) or a three-column linked table.
This is the briefing tool for simplifying the mapping and for colour profiles.role → Radix var(s) as pure data (drives the Colour Map view AND the generated CSS).build-tokens.ts: a [data-brand] .radix-themes block that repoints
Radix's semantic + component vars at the DEPARTMNT roles (first instance already shipped:
per-brand --card-bg). Components stay stock Radix Themes; only the paint simplifies.inverted colour profile (accent as chrome/surface) — far cleaner once the layer
gives a predictable surface-painting baseline.Concerns: never edit Radix vars outside the generated layer; the roles data is the single source (Colour Map view + CSS both read it).
Goal: Flow Map × Module Preview combined — assemble real screens from modules, per brand.
Chunked delivery (each independently shippable):
studio/lib/renderers.tsx, consumed by Module
Preview + Builder so module refinements propagate everywhere); slots/toggles/
defaultModules on flow screens; builder config model (studio/lib/builder.ts: ModuleInstance,
ScreenConfig, PortalConfig, LINKED_MODULES rules, localStorage persistence per brand).flows.ts edges).brand.config.json.studio/lib/flows.ts: each screen node gains slots — ordered,
named regions (e.g. header, content[], footer) that accept module instances. Mobile-first:
content slots stack vertically like the Figma page template.{ module: slug, version, props } — the Fields tab (Module
Preview Control Rail) becomes the real per-module content form; each module gets a fields
schema in the registry so forms are generated, not hand-built.clients/<slug>/brand.config.json — brand tokens + screens + slot contents +
version-pinned modules. This config is the contract with the dev's deploy pipeline.Concerns: keep module props JSON-serialisable (they already are — presentational/host-driven). Version-pin modules in the config. Dev owns the deploy pipeline.
Goal: Studio runs on our internal server and saves — tokens, preferences, portal builds.
tokens.json, modules.ts, flows.ts, layers, fields) are already pure data —
they become DB-backed with the same shapes; generated docs/CSS stay build artifacts.Goal: a public deployment with limited functionality, self-served. Two stages:
The library ships as one package (@departmnt/ui) to GitHub Packages; the dev pulls it into
client portals. Notes from the Phase 5 audit:
@changesets/cli) over the
earlier "version folders per module" idea — one import path, standard tooling, the dev pins a
package version per client. Module-level history lives in the changelog. (Confirm with dev.)@radix-ui/themes/styles.css (dep of this
package) → @departmnt/ui/tokens.css → @departmnt/ui/styles.css. Document in README; consider
a single @departmnt/ui/all.css that imports all three.@departmnt/ui/modules is a client entry (use client re-added post-build by
scripts/mark-client.ts); root/tokens entries stay server-safe.react/react-dom peer; @radix-ui/themes ships as a dependency (intentional —
modules need it at runtime)./primitives note: bespoke-only; standard layout/type comes from Radix Themes.<Name>.preview.tsx only when needed.| Phase | Add |
|---|---|
| 1 | tsx (run .ts scripts); ts-morph (manifest static analysis); a Radix-scale generator for 12-step gradients |
| 2 | @radix-ui/themes |
| 1–6 | Portal Design Studio runs on the existing next + react dev deps |
The existing @radix-ui/react-* packages stay — Radix Themes builds on them, and they're used
directly for Tier-3 bespoke components.
cnx-design-system/
├── CLAUDE.md ← read every session
├── tokens/
│ ├── tokens.json ← source of truth (three-tier + brand seeds)
│ ├── tokens.css ← generated; import in all CSS / consumed by Radix Themes
│ └── tokens.ts ← generated; typed constants for logic + Studio
├── primitives/ ← bespoke base elements only (Themes covers the rest)
├── components/ ← Radix Themes (Tier 1/2) or headless (Tier 3) + CSS Modules
│ └── <Name>/
│ ├── index.tsx
│ ├── styles.module.css
│ ├── types.ts
│ └── <Name>.preview.tsx ← optional (hybrid preview convention)
├── modules/
│ └── <Name>/
│ ├── v1.0.0/ v1.1.0/ ← never deleted
│ └── index.ts ← always exports latest
├── studio/ ← Portal Design Studio (internal Next.js app, NOT published)
├── portal/ ← Builder code (reused by Studio's Builder view)
├── clients/
│ └── <slug>/brand.config.json
├── manifest/ ← generated JSON; feeds docs + Studio
├── docs/
│ ├── BUILD-PLAN.md ← this file
│ ├── NAMING.md TOKENS.md COMPONENTS.md
│ ├── SCREENS.md MODULES.md CHANGELOG.md
│ └── DEPLOYMENT.md
└── scripts/
├── build-tokens.ts
├── build-manifest.ts
└── version-module.ts
You (Claude.ai) GitHub Developer (Claude Code)
─────────────── ────── ───────────────────────
Describe the change → Issue created → Reads issue + Figma (MCP)
Builds at the right tier
Review in Studio ← PR + Studio view ← Opens PR
Approve → Merge to main → Published to npm
Config committed → Deploy pipeline runs
Now: studio app (preview + builder), localStorage config, single shared instance, no auth/persistence. Foundations (@departmnt/ui: tokens, primitives, components, modules) stay the render lib for every surface.
Same monorepo, three clean layers — not a forked "studio v2":
@departmnt/ui) — the published render lib. Untouched by app churn; packageable for internal dev use and the public product alike.studio/) — dev/QA previewer (Foundations, Lookbook, Module Preview). Keep lean; delete/repurpose views freely — it's internal, low-stakes.app/ = @departmnt/app) beside studio/, consuming @departmnt/ui. Different IA/auth/persistence/audience than the studio, so it gets its own package rather than mutating studio.PortalPreview, render registry) get extracted into a shared workspace lib once they settle; v1 may copy into the app and refactor later.PortalConfig with DB rows.users, orgs, portals(brand, config jsonb, status); normalise screens/instances later.loadPortalConfig/save* → API/RPC with debounced autosave.@departmnt/ui./[org]/[portal] → subdomain → custom domains.@departmnt/ui is the single render source for builder preview + public runtime.Sequence: P1 unblocks all → P2/P3 make portals persist + go live → P4 quality → P5 monetize.