atjam — Design System Plan#
A translate-to-UI design specification. Direction: Massimo Vignelli — making the coordination legible as a system. The lineage to execute against is the Vignelli canon (the 1972 NYC subway diagram; the National Park Service Unigrid; Knoll) carried by a warmer living hand (Michael Bierut / Pentagram school).
This document is for a designer AND the developer who implements it. It states decisions, not moods. Where a genuine fork exists, it's flagged [fork].
0. The one idea everything serves#
atjam is a diagram of a coordination structure, not a place that hosts work. Like the subway map, it throws away the territory (the work lives on other PDSes, in other apps) and draws only the relationships: jam → round → signup → submission, and organizer → invitation → participant. Every design decision below exists to make those relationships legible at a glance.
The visual signature that expresses this is the round-as-transit-line (§3). If we build nothing else distinctive, we build that.
1. Principles (derived from the ethos)#
- Legibility over decoration. Show the structure; subtract the rest. If an element doesn't clarify a relationship or a state, it goes.
- Design is one. A small, disciplined kit — three typefaces, one neutral ramp, a handful of signal colors, one grid. The visual system mirrors the code's discipline (a few named centers, one rule with one home).
- The grid is the law. Everything snaps to it. Consistency is the warmth of a system — it tells the user the ground is solid.
- Color is signal, not flavor. Chroma means something (state, the brand mark) or it isn't used.
- Show the work. Validation visible even when everything passes; state derived and labeled; "who invited whom" in the open. The design never hides the system's reasoning — it presents it calmly.
- One warm gesture. The monospace voice and a paper-and-ink tone keep this a song club with excellent signage, not a transit authority. Protect it.
2. Foundations (tokens)#
Implement as Tailwind theme tokens (tailwind.config / CSS custom properties).
The app is dark-mode-aware today, so every token has a light + dark value.
2.1 Color#
Keep the discipline brutal: one neutral ramp, three state signals, one brand accent. Nothing else carries hue.
Neutral — "paper & ink" (warm the current cool stone slightly toward paper)
| Token | Light | Dark | Use |
|---|---|---|---|
paper |
#FAFAF7 |
#15140F |
page background |
panel |
#F2F1EC |
#1C1B16 |
Notice / muted panels |
line |
#E2E0D8 |
#2A2823 |
hairlines, borders, grid rules |
ink-3 |
#8A877D |
#8A877D |
meta / captions |
ink-2 |
#54514A |
#B9B6AC |
secondary text |
ink |
#1A1A16 |
#F5F4EF |
primary text, headings |
The shift from Tailwind
stone(cool gray) to a warm paper/ink is the single cheapest warmth injection. It costs nothing structurally and reads as authored rather than default.
State signals — bold, flat, confident (subway-line saturation, not pastel). Used for the lifecycle: a signal color (line/dot/text) and a tint (badge bg).
| State | Signal | Tint (light/dark) | Meaning |
|---|---|---|---|
open |
#1F9D57 (green) |
#E2F3E8 / #10301F |
signups + submissions accepted |
in-progress |
#C8791A (amber) |
#F7ECD9 / #33260F |
signups closed, submissions open |
closed |
#54514A (graphite) |
#ECEAE3 / #26241E |
round over |
Brand accent — "signal red" (Vignelli's red; the energy/"life" lever)
| Token | Value | Use |
|---|---|---|
signal |
#E5341C |
logotype mark, links, focus ring, the "you are here" dot, primary action emphasis |
One bold red, used sparingly, is what makes a Vignelli system feel alive rather than austere. It is the only chromatic indulgence; spend it carefully.
[fork] Brand accent could instead be a deep cobalt (#1B43C0) if red reads
too urgent for a creative-community tone. Red = energy; blue = calm authority.
Recommendation: red.
2.2 Typography#
The "basic three" (Vignelli ran on a basic six; we need less):
| Role | Recommended (paid) | Orthodox alt | Free fallback |
|---|---|---|---|
| Grotesque (UI, headings) | Söhne (Klim) — Helvetica with a soul | Neue Haas Grotesk | Inter / Geist Sans |
| Mono (the voice of technical truth) | Berkeley Mono | — | Commit Mono / IBM Plex Mono |
| Editorial serif (optional, hero only) | ABC Marfa / Lyon | Bodoni | Source Serif |
- Grotesque carries the system: nav, labels, body, data. Söhne is the recommendation specifically because it solves the "Helvetica reads as un-designed default" risk — it's the warm grotesque.
- Mono is the soul. It already marks identifiers (DIDs, NSIDs, at-URIs). Codify it: monospace = machine truth. Anything the protocol literally stores verbatim is set in mono. This is atjam's signature texture; lean in.
- Editorial serif is optional — reserve for the home hero headline only, if at all. Keep the kit small.
Type scale (1.250 major-third, 16px base):
| Token | Size / line-height | Use |
|---|---|---|
display |
39px / 1.1, tight tracking | hero headline |
h1 |
31px / 1.15 | round / jam title |
h2 |
25px / 1.2 | section if needed |
label |
11px / 1, uppercase, +0.08em tracking | section headers (already the house style — keep) |
body |
16px / 1.55 | prose |
small |
13px / 1.5 | meta, list items |
mono |
13px / 1.5 | identifiers, technical values |
Weights: Grotesque in Regular (400) and Medium (500/Kräftig) only — no light, no bold. Discipline.
2.3 Space & grid#
- Base unit: 8px. Spacing scale:
4, 8, 12, 16, 24, 32, 48, 64, 96. - Measure: content column max
44remfor reading (round detail, prose); the home/jam list and the transit diagram may use56rem. (Today everything ismax-w-3xl/48rem — widen the round page so the timeline breathes.) [fork] keep one global width vs. two — recommend two. - The Unigrid: one modular grid, generous and consistent. Detail metadata
(Join policy / State, milestone rows) uses a 2-column
[max-content_1fr]definition grid (already in use on the round page — formalize it everywhere). - Section rhythm: vertical space between major sections is a single constant
(
48px/ space-12). No bespoke margins. Hairlinelinerules separate sections instead of whitespace-only gaps where density helps legibility.
3. The signature component: the round as a transit line#
This is the hero idea and the thing no competitor looks like. It turns atjam's most abstract concept — state derived from milestones — into its identity.
Concept. A round's life is a single transit line. Milestones are stations. The current moment is the "you are here" dot.
open ───────●─────────────○──────────────○
(you are here) signup- submission-
deadline deadline
Mar 3 in 4 days Mar 24
Encoding
- The line = the round's lifecycle, left (created) → right (over).
- Stations = milestones, drawn in their known order: signup-deadline,
submission-deadline, plus
closing-event/resultsif present. - Station state:
- passed milestone → filled dot,
ink-3 - current segment → the
signalred "you are here" dot, pulsing once on load (the only motion in the app) - future milestone → hollow ring,
line
- passed milestone → filled dot,
- The segment the round is currently in is tinted with the state color (open green / in-progress amber / closed graphite) — so the lifecycle state IS the colored stretch of track. This unifies StatePill and the diagram: the pill is just the name of the colored segment.
- Each station labels its
milestone.label,formatDateTime(date), andtimeUntil(date)(all three helpers already exist inlib/format.ts). - Join-mode is not a line color (we reserve color for state). It's a
glyph + word at the line's origin:
◇ open/◈ hosted/⬡ network(see §6 iconography). The transit metaphor stays honest: one line, colored by where-in-time you are; the mode is the line's "label" like a route letter.
Layout. Horizontal on ≥640px; rotates to a vertical line on mobile
(stations stack, line runs top→bottom). This replaces the current bare
"Milestones" list and the separate State explainer with one figure.
Reuse. Drives the round header. A miniature version (just the colored
segment + dot, no labels) becomes the new StatePill on cards.
4. Color-as-signal: the resolved decision#
There were two things competing for color: state (temporal) and join-mode (structural). Resolved:
- State owns chroma. It's what users scan for ("can I still join?"). Green / amber / graphite, as the colored track segment and the pill.
- Join-mode owns form. Encoded by a geometric glyph + label, in
ink, never by hue. Three glyphs, learned once. signalred owns identity & action. The mark, links, focus, the "you are here" dot, primary buttons' emphasis. Never used for state.
Total palette a user must decode: 3 state colors + 1 brand red + neutrals. That's the Vignelli discipline.
5. Iconography#
A single geometric family, built on the 8px grid, 1.5px stroke, no fills except state dots. Vignelli did signage; pictograms belong here. Concept-level set (a designer draws the finals):
Record types (the nouns of the protocol)
jam— an enclosing bracket/room:⌂a simple roofline or a square frame.round— a circle with a notch (a clock/cycle). The round is a cycle.signup— a checkmark entering a frame / a figure stepping in.submission— an arrow into a tray, or a card with a corner fold (the payload pointer).invitation— an envelope reduced to an open chevron / a key.
Join-modes (the three glyphs used everywhere mode appears)
open—◇open diamond (anyone).hosted—◈diamond with a center dot (the single host).network—⬡hexagon / linked nodes (the chain).
States — not icons; the colored station treatment from §3.
Deliver as an SVG sprite; sizes 16 / 20 / 24. Keep them line-weight-matched to the mono so they sit beside identifiers without clashing.
6. Component spec (mapped to what exists)#
The Move-3/4 refactor already named the centers this reskin flows through —
StatePill and Notice are single definitions, so changing them changes the
whole app. That's the seam. Component-by-component intent:
| Component (file) | Vignelli treatment |
|---|---|
App shell (root.tsx) |
Fixed top rule under the header; logotype in grotesque Medium + signal red mark; alpha tag stays but set in mono. Footer = one hairline, mono links, tabular. Generous paper margins. |
| Logotype | atjam set tight in the grotesque, lowercase, with a single red geometric mark (a station dot). The mark is the favicon. |
StatePill (state-pill.tsx) |
Becomes a mini transit segment: a short colored rule + dot + uppercase label text in the state's signal color. Tint background optional. One definition → propagates everywhere. |
Notice (notice.tsx) |
panel bg, hairline line border, no rounded-2xl softness — 2px radius max (Vignelli is square-ish). Left-aligned, generous padding, body text. |
| Transit line (NEW) | §3. Replaces the Milestones list + State explainer on the round page. |
Round card (home _index, jam, scenarios) |
A gridded row: date (mono) · organizer (ActorName) · mini-StatePill · title (h-scale) · 2-line assignment. Hairline separators, not boxes. Tabular alignment so a list of rounds reads like a well-set timetable. |
Round detail (round.$did.$rkey) |
Apply the Unigrid: title block → transit line → join-policy (2-col dl) → assignment → the relationship sections (signups / submissions / invitations) as tables, not loose lists. Section labels in label style (already there). |
| Relationship tables (signups, submissions, invitations) | Set as real tabular data: aligned columns (actor · role/validation · timestamp). The invitations "X invited by Y" becomes a 2-column relation table — legible as a graph. Invalid signups stay behind the disclosure (Move-4 polish) — keep. |
Forms (signup-button, submit-form, invite-form) |
Square inputs, hairline borders, label-style field captions, mono for fields that accept identifiers (at-URI, handle/DID — already mono). One primary button style (see below). The useRecordWrite status drives a single, consistent inline status line. |
| Buttons | Primary = solid ink (dark) fill, paper text, 2px radius; hover lightens one step. Emphasis/primary-action variant uses signal red. Secondary = hairline outline. No shadows, no gradients. Disabled = line. |
ActorName (actor-name.tsx) |
Handle in grotesque; DID fallback in mono ink-3. Optionally prefix a tiny identity dot. Keep HandleMap plumbing. |
| Validation badge | valid = small green dot + word; invalid = amber dot + word, reason in title (keep). Square, mono-adjacent. |
Structured fields (structured-field) |
The generic \(type + key/value renderer becomes a clean 2-col definition table; `\)typein monoink-3` as a caption. |
7. Motion#
Vignelli is stillness. UI needs the minimum:
- The "you are here" dot pulses once on load (the single signature motion).
- State/route transitions: 120ms ease, opacity/position only. No bounce, no scale, no decorative animation.
revalidateafter a write: the affected section fades its update in. That's it.
8. Voice & copy#
Already on-ethos — protect it. Register: declarative, plainspoken, technically honest. "Same prompt. Same deadline. Different people, different work." "atjam coordinates — it doesn't host." Rules:
- Lowercase, calm, no marketing exclamation.
- Mono = verbatim machine truth. Identifiers, NSIDs, URIs.
- Explain the system's reasoning in plain words (the join-policy panel, the validation hints) — never hide it.
9. Engineering patterns#
A design system is code, and it rots without engineering discipline. These
patterns are the same principle as the lexicon work — name the center, give it
one home — applied to the UI layer. The stack is Tailwind v4 (CSS-first),
React Router 7, React 19, Vite. Tokens live in CSS, not a tailwind.config.
9.1 Token architecture — two tiers, themed at the root#
The single biggest structural fix: today every component writes
bg-stone-100 dark:bg-stone-900 — the theme is duplicated across hundreds of
sites and will drift. Replace it with semantic tokens that flip at the root,
so components never write dark: again.
Two tiers: raw primitives → semantic tokens. In v4 you register the
semantic layer with @theme inline (the inline keyword makes utilities emit
var(--…) instead of a baked hex, which is what enables a runtime theme flip):
/* app/styles/app.css */
@import "tailwindcss";
/* tier 1 — primitives + semantic vars, flipped per theme (plain CSS vars) */
:root {
--paper: #FAFAF7; --panel: #F2F1EC; --line: #E2E0D8;
--ink: #1A1A16; --ink-2: #54514A; --ink-3: #8A877D;
--signal: #E5341C;
--state-open: #1F9D57; --state-progress: #C8791A; --state-closed: #54514A;
}
[data-theme="dark"] {
--paper: #15140F; --panel: #1C1B16; --line: #2A2823;
--ink: #F5F4EF; --ink-2: #B9B6AC; --ink-3: #8A877D;
/* signal + state hues hold across themes; only neutrals invert */
}
/* tier 2 — register with Tailwind so utilities exist AND flip at runtime */
@theme inline {
--color-paper: var(--paper); --color-panel: var(--panel);
--color-line: var(--line); --color-ink: var(--ink);
--color-ink-2: var(--ink-2); --color-ink-3: var(--ink-3);
--color-signal: var(--signal);
--color-state-open: var(--state-open);
--color-state-progress: var(--state-progress);
--color-state-closed: var(--state-closed);
--radius-sharp: 2px;
--font-sans: "Söhne", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Berkeley Mono", ui-monospace, "SF Mono", monospace;
--text-label: 0.6875rem; /* 11px */
--text-label--line-height: 1;
--text-label--letter-spacing: 0.08em;
--text-label--font-weight: 500;
}
Now bg-paper, text-ink-2, border-line, rounded-sharp, font-mono, and
text-label are real utilities that resolve correctly in both themes. Rule:
components use semantic utilities only — never a raw stone-* / hex / dark:.
9.2 Theme control (data-attribute, SSR-safe)#
Drive theme from data-theme on <html>, defaulting to prefers-color-scheme,
optionally overridable. Redefine the dark: variant to key off the attribute so
the rare manual dark branch still works:
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
Avoid the flash-of-wrong-theme: set the attribute before paint with a tiny inline
script in root.tsx's <head> (runs before hydration):
<script dangerouslySetInnerHTML={{ __html:
`try{document.documentElement.dataset.theme=localStorage.theme||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light')}catch(e){}`
}} />
9.3 Class composition — cn() + typed variants#
The brittle string-templating in Notice (${className ? ` ${className}`…})
silently fails when an override conflicts with a base utility. Fix with the
standard pair:
// app/lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
tailwind-merge dedupes conflicting utilities (a later p-6 correctly beats a
base p-4). For multi-variant primitives (Button, StatePill, Notice), use
tailwind-variants (it bundles tailwind-merge + slots) — typed variants
instead of if/else className soup:
import { tv } from "tailwind-variants";
export const button = tv({
base: "rounded-sharp px-4 py-2 text-sm font-medium disabled:opacity-50",
variants: {
tone: {
solid: "bg-ink text-paper hover:bg-ink-2",
signal: "bg-signal text-paper hover:opacity-90",
outline: "border border-line text-ink hover:bg-panel",
},
},
defaultVariants: { tone: "solid" },
});
Add asChild (a Radix Slot) on Button so it can render as a <Link> without
duplicating styles. Components stay presentational; data/logic stays in
loaders + hooks (useRecordWrite, Eligibility) — the split already established.
9.4 One home for the state visual language#
The open/in-progress/closed visual mapping is consumed by StatePill, the
transit line, and round cards. It must live in one typed map — the design
analog of lifting Eligibility out of the view:
// app/lib/round-visuals.ts
import type { Round } from "@atjam/lexicons";
export const STATE_VISUALS: Record<Round.RoundState,
{ label: string; signal: string; track: string; tint: string }> = {
open: { label: "open", signal: "text-state-open", track: "bg-state-open", tint: "bg-state-open/10" },
"in-progress": { label: "in-progress", signal: "text-state-progress", track: "bg-state-progress", tint: "bg-state-progress/10" },
closed: { label: "closed", signal: "text-state-closed", track: "bg-state-closed", tint: "bg-state-closed/10" },
};
A one-line vitest asserts every RoundState has an entry — so when a fourth
state is ever added, the test fails instead of the UI silently dropping a color.
The same pattern for JOIN_MODE_GLYPHS: Record<JoinMode, …>.
9.5 Typography as a system#
- Self-host the grotesque + mono (
@font-face,font-display: swap,<link rel="preload">the two weights actually used, subset to Latin). No FOUT, no third-party request, no layout shift. - The type scale is
@theme--text-*tokens (§9.1) → real utilities (text-label,text-display). Kill magic numbers: no moretext-[11px] uppercase tracking-[0.08em]retyped per site — it'stext-label. - Optional thin
<Heading level>/<Text variant>components if the same scale combos recur; don't add them until they do (avoid the false center).
9.6 Icon system#
8 icons (5 record types + 3 mode glyphs). Ship as a single generated SVG sprite with a typed accessor:
export function Icon({ name, size = 20 }: { name: IconName; size?: number }) {
return <svg width={size} height={size} aria-hidden><use href={`/icons.svg#${name}`} /></svg>;
}
IconName is a union generated from the sprite manifest so a typo is a type
error. Stroke 1.5px, matched to the mono so icons sit beside identifiers. For
this count, inline typed components are an acceptable alternative — but the sprite
keeps the markup clean and cache-friendly.
9.7 Layout primitives#
Enforce the 8px rhythm and the Unigrid structurally, not by memory:
- A
Sectionwrapper (consistent vertical rhythm + thelabel-style header). - A
DefinitionGridfor the[max-content_1fr]metadata pattern (already hand-written on the round page — name it once). - Keep it to these two; resist a full
Stack/Boxlayout kit (one app doesn't justify it).
9.8 Accessibility (non-negotiable, baked into primitives)#
- Focus uses one token:
focus-visible:ring-2 ring-signal ring-offset-2. - Color is never the only signal — state always carries label + (in the diagram) position; mode carries a glyph + word. Already true; keep it.
- Reduced motion: the "you are here" pulse is wrapped in
motion-safe:/ gated byprefers-reduced-motion. - The transit line is a list, not a picture: render as a semantic
<ol>of milestones (date + label + relative time as text); the line/dots are presentationalaria-hiddendecoration over it. Screen readers get the timetable; sighted users get the diagram. - Contrast: every semantic pairing meets WCAG AA in both themes (verify the
dark
--ink-2/--ink-3on--panel).
9.9 The living style guide (/styleguide)#
Add a noindex route that renders every primitive in every variant and
state — Button tones, StatePill × 3 states, Notice, the transit line at each
milestone count, the icon set, the type scale, the color tokens in both themes.
This is the "show the work" ethos applied to the system itself, it mirrors the
existing /scenarios and /test-plan catalog pages, and it doubles as the
visual-review + regression surface. Cheaper and more on-brand than Storybook.
9.10 Testing the system#
- Pure logic (the
STATE_VISUALScompleteness check, variant resolution,timeUntil) → vitest, fast. - Visual regression → Playwright screenshots of
/styleguide(light + dark). jsdom is not a browser — don't assert pixels there. (Adds Playwright to the stack; worth it once the diagram exists.) - Lint guardrails: a CI grep/ESLint rule forbidding
dark:, raw#hex, andstone-*insideapp/components— the machine enforces "semantic tokens only."
9.11 Governance — what keeps it from drifting#
- Name the center. A value used 3+ times becomes a token or a component; a one-off stays inline. (Same rule as the code review.)
- No raw hex /
stone-*/dark:in components (lint-enforced, §9.10). - Tokens are append-mostly; renaming a semantic token is a deliberate, repo-wide change, not a casual one.
9.12 What NOT to build yet (false centers)#
- No published
@atjam/uipackage. There is one consumer (the web app). Extract a package only when a second surface appears (a native client, a second site). Until then the components live inapp/components/ui/. - No Storybook — the
/styleguideroute covers it at a fraction of the cost. - No CSS-in-JS / runtime styling lib — Tailwind v4 tokens +
tailwind-variantscover variants with zero runtime style cost.
9.13 Directory shape#
app/
styles/app.css # tokens (§9.1), @custom-variant, font-face
lib/cn.ts # cn() helper
lib/round-visuals.ts # STATE_VISUALS, JOIN_MODE_GLYPHS (one home)
components/ui/ # primitives: button, pill, notice, field, icon, text
components/ # composites: transit-line, round-card, relationship-table
routes/styleguide.tsx # living gallery
10. Rollout (translate-to-UI, incremental, ships continuously)#
The codebase is Tailwind + React Router with the centers already named, so this lands in phases without a big-bang rewrite. Each phase is shippable.
- Phase 0 — Tokens & theming. The
@theme inlinetoken layer + themed CSS vars +data-themecontrol + no-FOUC script (§9.1–9.2); self-host the grotesque + mono. Swapstone-*/dark:usages to semantic utilities. Addcn()+tailwind-variants(§9.3). (Visible warmth win, low risk.) - Phase 1 — Primitives. Reskin
StatePill,Notice, addButtonand theSTATE_VISUALSmap (§9.4); formalize table/list styles. Single definitions → the whole app shifts. Stand up/styleguide(§9.9) alongside. - Phase 2 — The transit line. Build the hero component; replace the round
page's Milestones list + State explainer. Reuse
format.tshelpers. - Phase 3 — Layout to the grid. Round detail → Unigrid; relationship lists → tables; round cards → timetable rows. Widen the round page measure.
- Phase 4 — Iconography. SVG sprite: 5 record types + 3 mode glyphs. Wire the mode glyphs into the transit line and join-policy.
- Phase 5 — Identity. Logotype + red mark + favicon; home hero typeset; optional editorial serif headline.
Phases 0–1 alone make the app feel designed. Phase 2 makes it atjam.
11. For the designer#
- Hire intent: the Vignelli method (he died 2014). Best living hand is the Pentagram/Bierut school — systematic rigor with warmth. A systems-literate brand/product designer who works in grids and type, not a "UI kit" decorator.
- Read first: The Vignelli Canon (free PDF, Vignelli's own); the NPS Unigrid system; the 1972 NYC subway diagram.
- Deliverables to ask for: the type + color tokens as a spec; the transit- line component in Figma (desktop + mobile, all three states, all milestone counts); the icon family as SVG; a one-page round detail comp on the Unigrid; the round-card timetable.
- The brief in one line: Excellent signage for a song club — make the coordination legible as a system, and spend the red to keep it alive.
Open forks (decide with the designer)#
- Brand accent: signal red (energy) vs. cobalt (calm authority). → red.
- Page width: one global measure vs. reading-44rem + list/diagram-56rem. → two.
- Editorial serif: include a serif for the hero headline, or grotesque-only. → optional; start grotesque-only.
- State vs. join-mode color: resolved here as state=chroma, mode=glyph — confirm this holds once the transit line is prototyped.