ADR-0007: A single design token system
Edit this pageStatus: 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:
| Measure | Count |
|---|---|
className attributes | 912 |
style={{ … }} attributes | 183 |
Inline colour strings (hsl(var()), rgb(), hex) | 307 |
| Tailwind colour utilities in the whole app | 12 |
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 makegit blameless useful for a while. - Tailwind v4's
@themeis 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
#3b82f6is visibly wrong. - Severity is consistent everywhere, so
highis 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.