ADR-0007: A single design token system

Edit this page

Status: Proposed Date: 2026-08-14

Context

Loomscope's interface works but is not coherent, and an audit of the UI layer gives the reason in numbers.

app/globals.css defines seven custom properties — --background, --foreground, --muted, --muted-foreground, --border, --primary, --primary-foreground — as HSL channel triplets, so every consumer has to write hsl(var(--border)) by hand. There is no --card, no --accent, no --destructive, no --radius, and nothing for spacing or typography.

Tailwind v4 is installed but no tailwind.config.* exists and there is no @theme block. The stylesheet contains a single @import "tailwindcss";. The seven variables are therefore never bridged into Tailwind's theme, which means border-border, bg-muted and text-muted-foreground simply do not exist as utilities.

The consequence shows up everywhere:

MeasureCount
className attributes912
style={{ … }} attributes183
Inline colour strings (hsl(var()), rgb(), hex)307
Tailwind colour utilities in the whole app12

That is roughly 25 inline colours for every Tailwind colour utility. Colour is not styled through the design system; it is pasted at the call site. 44 distinct hex values exist, 86% of them inside app/(app)/topology/[id]/TopologyCanvas.tsx, and the same StatusPill component has been copy-pasted into both discovery/page.tsx and topology/page.tsx with slightly different palettes.

Dark mode is worse than incomplete — it is actively broken. It is triggered only by @media (prefers-color-scheme: dark), and app/(app)/layout.tsx:29 paints the header rgba(255, 255, 255, 0.85) unconditionally. Every hex map in the app is light-mode-only. A user with a dark OS gets light text on light chrome in places.

There are also no primitives to hold any of this: no Button, Input, Card, Table, Dialog or Select component exists, and there is no cn() helper, so class composition is manual string concatenation.

Decision

Adopt one token system, expressed once, consumed everywhere through Tailwind.

1. Tokens live in @theme. Tailwind v4 reads design tokens from a @theme block in CSS and generates utilities from them. Declaring --color-border there produces border-border, bg-border and friends automatically. This removes the hsl(var(--…)) incantation entirely.

2. The palette is stated in full, not minimally. Surface, foreground, muted, border, primary, plus the semantic set the application actually needs and currently open-codes: success, warning, danger, info. Semantic colour is separate from the brand accent, because severity is information, not decoration.

3. Themes are token-level and support all three states. A bare :root carries the complete light palette. @media (prefers-color-scheme: dark) guarded as :root:not([data-theme="light"]) redefines only the tokens. :root[data-theme="dark"] redefines them again so an explicit toggle wins in both directions. No component ever declares a colour inside a media query.

4. Entity and severity colours become tokens too. The TONES map in TopologyCanvas.tsx — thirteen entity types, each a border/background/ foreground triple — is real design information trapped in a component. It moves into tokens so the canvas, the pills and any future legend agree.

5. Introduce the missing primitives. Button, Card, Table, Input, Select, and a cn() helper. The 244 px-3 / 246 py-2 occurrences are not ad-hoc — they are one table-cell idiom repeated at ~900 call sites. A Table primitive collapses them.

6. Migrate incrementally, worst first. TopologyCanvas.tsx (67 literals), then pill.tsx (18), then the duplicated StatusPill pair, then the remaining pages. Each step is mechanical and independently shippable.

Alternatives considered

Adopt shadcn/ui wholesale. Rejected for now. It would bring a coherent primitive set, but it also brings Radix, class-variance-authority, tailwind-merge and a conversion of every existing page. The dependency weight is real for an air-gapped product, and the current components are not bad — they are uncoordinated. We take shadcn's token conventions without its dependency tree, leaving the door open to adopt it later.

Keep inline styles, just centralise the values. Rejected. Exporting a COLORS object from a TypeScript module would deduplicate the literals but keeps colour out of the CSS cascade, meaning no dark mode without a JavaScript theme context, no :hover/:focus variants, and a re-render for every theme change.

Leave it alone. Rejected, but it was considered — this is polish, not correctness. It is included because dark mode is currently broken rather than absent, and because a security product that looks unfinished has a credibility cost with exactly the audience Loomscope is aimed at.

Consequences

Accepted costs.

  • A large, mechanical diff touching most files under app/(app)/. It will make git blame less useful for a while.
  • Tailwind v4's @theme is beta, in a beta release of Tailwind. A breaking change upstream costs us a migration.
  • Tokenising entity colours means the topology canvas can no longer be restyled by editing one map — it becomes a CSS change instead.

Gains.

  • Dark mode works, including a real toggle, rather than half-applying.
  • Colour becomes reviewable: a pull request adding #3b82f6 is visibly wrong.
  • Severity is consistent everywhere, so high is the same colour in a table, a pill and a graph node.
  • Around 900 duplicated spacing declarations collapse into primitives.

Verification. The pass is done when: no .tsx file under app/ or components/ contains a hex, rgb() or hsl() literal; every colour comes from a token; and both themes are checked against WCAG AA contrast.