docs/BUILD-PLAN.md

DEPARTMNT Design System — Build Plan v2

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.


1. Architecture

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.


2. The Radix Themes-first hybrid

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.


3. Tokens & theming

3.1 Three tiers (source of truth: 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.

3.2 Full 12-step gradients

Each brand declares a seed in tokens.json (e.g. accentSeed: "#bf3414"). The token build generates, per brand:

  • a 12-step accent scale (--accent-1…12) + 12-step alpha (--accent-a1…a12)
  • Radix's derived solids: --accent-contrast, --accent-surface, --accent-indicator, --accent-track
  • a 12-step gray scale (--gray-1…12 + alpha), tuned to the brand
  • --default-font-family and appearance (light/dark)

Edit one seed → the whole scale regenerates.

3.3 How [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>.


4. Portal Design Studio + living docs

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.

4.1 The manifest pipeline

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)
  • Tokens (JSON → manifest): trivial; token values land in the manifest, so docs and the Foundations view render real swatches.
  • Components/modules/primitives (TSX + TS → manifest): static analysis extracts metadata — name, prop types, variant/size/state unions, usage JSDoc. Code can't be "read" into a picture.
  • Registries (Markdown → manifest): 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.

4.2 Preview convention (hybrid)

To know which prop combinations to mount, the Studio uses a hybrid:

  • Auto-extract by default — render every variant × size × state from the prop unions.
  • Co-located <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.

4.3 Studio views per phase

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.


5. Phases

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.

Phase 0 — Repo setup ✅ DONE

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).

Phase 1 — Tokens + Studio shell (≈90% done; close-out)

Goal: finish the token foundation and stand up Portal Design Studio.

  • Rename scripts/build-tokens.js → build-tokens.ts (run via tsx); keep pnpm build:tokens.
  • Generate tokens/tokens.ts (typed constants) alongside tokens.css.
  • Add brand seeds to 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 the manifest pipeline (build:manifest) and the Studio shell.
  • Studio view: Foundations. Mirrored doc: TOKENS.md (generated).
  • Tag 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.

Phase 2 — Radix Themes foundation + naming

Goal: stand up the styled base and the customization pattern. (NAMING.md already written.)

  • Add @radix-ui/themes; import its CSS at the package root.
  • Build <DepartmntTheme> (wraps Radix <Theme>, reads [data-brand], applies appearance/radius).
  • Wire the token → Themes variable mapping; verify all brands render.
  • Document the customization spectrum (Tier 1→2→3) and the preview convention in NAMING.md.
  • /primitives = bespoke-only.
  • Studio view: Theme Lab. Mirrored doc: NAMING.md (customization section).
  • Colour profiles: 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.

Phase 3 — Components & patterns

Goal: build the component set at the lowest viable tier.

  • Per component: pick Tier 1/2/3, build, expose typed props, add usage comment, export via /index.ts.
  • Add a <Name>.preview.tsx only when auto-extraction can't capture the examples.
  • One component per PR. Variants exhaustive (match Figma).
  • Studio view: Lookbook. Mirrored doc: COMPONENTS.md (generated).
  • Includes the per-component colour breakdown (which token paints each part) — moved here from Theme Lab as it belongs with the component catalogue.

Deviations from the original spec (v1, recorded):

  1. Catalogue-as-source, not ts-morph static analysis (§4.1). We're Radix Themes-first, so the catalogued components are external Radix Themes components — there's no in-repo .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.
  2. Lookbook colour breakdown is read-only. The interactive tiered remap selector lives in Theme Lab's custom profile (where the custom-profile state machinery is). The Lookbook shows each component's token usage (part → token → resolved swatch + root badge) without editing, to avoid duplicating that state. Promote it to an editor here if/when needed.

Concerns: Radix handles behaviour/ARIA — only style the surface. Figma MCP rate-limits on free plans; a Dev seat is needed for active sprints.

Phase 4 — Screens & flows

Goal: a living map of the product.

  • Maintain docs/SCREENS.md (Screen · Route · Modules used · Status). Routes mirror Next.js files.
  • Studio view: Flow Map. Mirrored doc: SCREENS.md.

Concerns: assign ownership or it goes stale. Don't map micro-states (loading/empty/error) as screens.

Phase 5 — White-label modules

Goal: page-level compositions, themed at runtime.

  • Modules themed via <DepartmntTheme> + CSS-variable overrides (not a hand-rolled theme prop).
  • Independent versioning via version folders + git tags; version:module script.
  • Studio view: Module Preview. Mirrored doc: MODULES.md (inventory, versions, required brand vars).
  • Module Preview layout: left = Standard/Advanced tabs + module list; centre = mobile device frame; right = info bar (currently module name + description, sized to match the left column).
  • TODO — info-bar fields (deferred): the right info bar will gain editable fields that populate the selected module's props and render them live in the device frame (a per-module content form driven by each module's prop interface). Build this after the module set settles.

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).

Phase 5.5 — DEPARTMNT styling layer (do BEFORE the inverted profile)

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:

  1. Colour Map view (visualise before tweaking). A Studio view/tab that shows Radix's colour plumbing end-to-end: scale steps (1–12 + alpha) → semantic vars (--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.
  2. Define the DEPARTMNT semantic roles — the small set Figma actually uses (~6–8: page, card, media-placeholder, text-primary, text-muted, line, action-bg/fg, status). Record the mapping role → Radix var(s) as pure data (drives the Colour Map view AND the generated CSS).
  3. Generate the layer in 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.
  4. 1:1 audit pass — per module, side-by-side against the Figma frame (spacing 8px grid, type scale from the Text Styles card, radius, pill buttons, grey media placeholders). Fix in the layer, not in per-module styles.
  5. Then the 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).

Phase 6 — Page Builder

Goal: Flow Map × Module Preview combined — assemble real screens from modules, per brand.

Chunked delivery (each independently shippable):

  1. Rails ✅ — shared module render registry (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).
  2. Builder view v1 ✅ — screen list → device frame rendering the slot stack → add/remove/reorder modules; feature toggles on toggle screens.
  3. Flow Rail + navigation ✅ — position-indicator strip (screens along the current path, active highlighted, status dots correlating with Flow Map) + clickable navigation between screens in the frame (module CTAs + chrome follow flows.ts edges).
  4. Linked modules + export ✅ — LINKED_MODULES auto-populate with undo notice (e.g. placing Ticketing adds an Event Reminder to the Hub); export brand.config.json.
  5. Fields live-edit ✅ — the Control Rail Fields tab edits the selected module instance's props live (per-module field schema in the registry).
  • Screens have slots. Extend 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 instances are data. { 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.
  • Builder view: pick a screen from the flow → device frame renders its slot stack → click a slot to add/reorder modules → edit fields → live preview. Brand switcher applies throughout.
  • Export: clients/<slug>/brand.config.json — brand tokens + screens + slot contents + version-pinned modules. This config is the contract with the dev's deploy pipeline.
  • Studio view: Builder. Nothing persists yet (preview-only) — persistence is Phase 7.

Concerns: keep module props JSON-serialisable (they already are — presentational/host-driven). Version-pin modules in the config. Dev owns the deploy pipeline.

Phase 7 — Internal deploy + backend

Goal: Studio runs on our internal server and saves — tokens, preferences, portal builds.

  • Deploy Studio to an internal host (auth-gated; never public at this phase).
  • Backend for: brand token sets, saved colour profiles (replaces localStorage), Builder configs, module version registry. Candidate: Supabase/Postgres (matches the client portals' stack).
  • Registries (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.

Phase 8 — Public self-serve (limited)

Goal: a public deployment with limited functionality, self-served. Two stages:

  1. Theme preview — brand input → live theme preview only.
  2. Limited portal building — assemble a portal from a restricted module set.
  • Requires: auth/rate limits, stripped-down views, hard separation from client configs.

5b. Packaging & versioning (for the dev's pnpm workflow)

The library ships as one package (@departmnt/ui) to GitHub Packages; the dev pulls it into client portals. Notes from the Phase 5 audit:

  • Versioning: prefer single-package semver + changelog (e.g. @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.)
  • CSS contract: consumers must load, in order: @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.
  • RSC boundary: @departmnt/ui/modules is a client entry (use client re-added post-build by scripts/mark-client.ts); root/tokens entries stay server-safe.
  • Peers: react/react-dom peer; @radix-ui/themes ships as a dependency (intentional — modules need it at runtime).

6. CLAUDE.md deltas (apply in Phase 2)

  1. Rule #1 reworded: "Build on Radix Themes by default; drop to headless Radix primitives + CSS Modules for bespoke components."
  2. Add the customization-spectrum rule (Tier 1→2→3; default to lowest).
  3. Token rule extended: three-tier tokens are the source of truth and map onto Radix Themes variables; never hardcode, never edit Radix vars outside the generated mapping layer.
  4. /primitives note: bespoke-only; standard layout/type comes from Radix Themes.
  5. Preview convention: auto-extract by default; add <Name>.preview.tsx only when needed.

7. Dependencies to add (by phase)

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.


8. File map

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

9. The ongoing loop (once phases are live)

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

10. Preview/Builder → fully self-serve

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.

Repo strategy (decided)

Same monorepo, three clean layers — not a forked "studio v2":

  • Foundations (@departmnt/ui) — the published render lib. Untouched by app churn; packageable for internal dev use and the public product alike.
  • Internal studio (studio/) — dev/QA previewer (Foundations, Lookbook, Module Preview). Keep lean; delete/repurpose views freely — it's internal, low-stakes.
  • Public self-serve app — a new workspace package (e.g. 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.
  • Reusable builder pieces (config model, editors, PortalPreview, render registry) get extracted into a shared workspace lib once they settle; v1 may copy into the app and refactor later.

P1 — Persistence & auth

  • Backend (Supabase: Postgres + Auth). Replace localStorage PortalConfig with DB rows.
  • Schema v1: users, orgs, portals(brand, config jsonb, status); normalise screens/instances later.
  • Auth: email/OAuth → org membership; gate builder behind login.
  • Swap loadPortalConfig/save* → API/RPC with debounced autosave.

P2 — Multi-tenant

  • Org-scoped portals (RLS by org_id). Brand seeds DB-backed (extends token system).
  • Roles: owner/editor/viewer.
  • Asset uploads (avatars/flyers/images) → storage bucket + CDN; replace data-URL previews.

P3 — Publish pipeline

  • Config = source of truth → separate public runtime app renders it, consuming @departmnt/ui.
  • Draft vs published; "Publish" writes an immutable versioned snapshot (rollback).
  • Routing: /[org]/[portal] → subdomain → custom domains.

P4 — Builder maturity

  • Undo/redo, autosave state, validation, empty states.
  • Screen/flow editing (add/remove/reorder screens, not just modules).
  • Live data: analytics events, signup/form submissions → backend.
  • Media library, link validation, richer field types.

P5 — Self-serve GTM

  • Onboarding wizard (brand → first portal); template gallery; duplicate portal.
  • Billing (Stripe): plans, seats, usage limits.
  • Domain connect, SEO/meta, share links.

Cross-cutting

  • @departmnt/ui is the single render source for builder preview + public runtime.
  • Tests (module smoke, config schema), error tracking, changesets versioning for the lib.

Sequence: P1 unblocks all → P2/P3 make portals persist + go live → P4 quality → P5 monetize.