Aesthetic target: warm editorial, following the Claude design language. A tinted cream canvas, serif display headlines at regular weight, a coral accent used scarcely, and dark warm-ink surfaces for product chrome. Deliberately warm and humanist where most AI products reach for cool blue and slate — the cream tint is the differentiator, so nothing in the system is pure white or a cool gray.
Color, type, radius, shadow and motion are delivered entirely through the token layer in web/src/index.css; component code never names a hex value.
Three ramps are declared as --color-<ramp>-<step> theme variables; semantic tokens alias
onto them.
| Ramp | Character | Semantic role |
|---|---|---|
| cream | warm neutral, 50 (canvas) → 950 (ink) | Backbone — canvas, surfaces, borders, and the whole text scale |
| coral | warm muted orange-red | Primary / brand — CTAs, links, focus ring, brand accents |
| ink | warm near-black, 800–950 | Dark product surfaces — code chrome, featured tiers, dark mode |
The trinity is cream + coral + dark ink. There is no fourth surface tone.
--color-surface equals --color-bg. Cards read as regions bounded by a 1px
--color-border hairline rather than as lighter rectangles floating on a darker page. This
is the system's "color-block first, shadow rare" rule: depth comes from the cream-to-dark
contrast between bands, not from elevation. Shadows exist but stay faint and are rarely used.
Spark's study needs five mutually distinguishable framing accents (calm, zoomies, silly,
challenge, science). The surface trinity admits no fourth tone, so the five resolve as
content accents, never surfaces: --color-frame-* plus a matching -tint. Each is
desaturated and warmed to sit on cream, and coral itself is deliberately withheld from the
set so the brand CTA stays scarce and unambiguous. This is the one place the system carries
more than three hues, and it is scoped to content markers.
| Token | Light | Dark | Notes |
|---|---|---|---|
primary |
#b55839 |
coral-400 #d28d74 |
fill and link text — see §5 for why it is not #cc785c |
bg / surface |
cream-50 #faf9f5 |
ink-950 #181715 |
identical by design; cards use borders |
surface-2 / -3 |
cream-100 / cream-200 |
ink-800 |
soft bands, card fills, inset areas |
surface-dark* |
ink-950/900/800 |
same | product chrome in both modes, not a dark-mode-only affordance |
text |
cream-950 #141413 |
cream-50 |
warm dark, never pure black |
text-body |
cream-800 #3d3d3a |
cream-100 |
running paragraph text |
text-muted |
cream-700 #6c6a64 |
cream-500 |
sub-headings, secondary labels |
text-subtle |
cream-600 #8e8b82 |
cream-600 |
captions and placeholders — UI/large only |
border |
cream-400 #e6dfd8 |
#34312c |
one elevation step, not an ink line |
accent |
#377e70 |
#7fcfc0 |
teal companion, used sparingly |
Dark mode is not an inversion of a separate palette — it moves onto the same warm-ink
surfaces the light theme already uses for product chrome, so both modes share one vocabulary.
Coral holds its hue rather than shifting cool. The one structural flip is on-primary: white
on the deeper coral fill in light, near-black on the lighter coral fill in dark, so the label
stays legible either way.
The display/body split is the brand voice and is treated as unbreakable.
- Display — serif, weight 400.
--font-displaywalks"Tiempos Headline", "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, "Times New Roman", serif. Copernicus and StyreneB are licensed Anthropic typefaces, and the app ships no web fonts (offline-friendly PWA), so the stack resolves to the closest widely-installed faces. - Body — humanist sans, weight 400/500. Inter first, then the platform UI sans.
- Negative tracking is mandatory on display sizes (
--tracking-display-xl…-sm, -1.5px → -0.3px). A serif headline without it reads as off-brand. - Display weight never exceeds 400. For more emphasis, go up a size — never to bold.
The four display steps are exposed as utilities (display-xl / -lg / -md / -sm) plus
eyebrow for uppercase captions. Each bundles family + size + weight + tracking, because
those four always travel together; exposing them as one class stops call sites re-deriving
the combination and drifting into bold sans headings.
- Spacing: Tailwind's default 4px scale, unmodified.
- Radius: hierarchical, per the design language —
xs 4 / sm 6 / md 8 / lg 12 / xl 16 / pill 9999. Controls takemd, content cardslg, marquee containersxl. - Shadow: faint and rare (
--shadow-xs…-lg). Depth is carried by surface contrast. - Motion: color and border transitions on interactive states; keyframe animation exists
only in Spark's timer/mic/loader, gated behind
prefers-reduced-motion.
Ratios computed against the rendered pairings, canvas #faf9f5. Targets: 4.5:1 normal
text, 3:1 large text / UI.
| Pairing (light) | Ratio | Result |
|---|---|---|
text #141413 on canvas |
17.50 | ✅ AAA |
text-body #3d3d3a on canvas |
10.34 | ✅ AAA |
text-muted #6c6a64 on canvas |
5.13 | ✅ AA |
text-subtle #8e8b82 on canvas |
3.23 | ✅ large/UI only (intended use) |
white on primary #b55839 |
4.75 | ✅ AA |
primary as link text on canvas |
4.51 | ✅ AA |
white on primary-hover #a9583e |
5.06 | ✅ AA |
accent #377e70 on canvas |
4.55 | ✅ AA |
success #3d804d on canvas |
4.54 | ✅ AA |
warning #956c0c on canvas |
4.50 | ✅ AA |
danger #c64545 on canvas |
4.59 | ✅ AA |
white on danger |
4.84 | ✅ AA |
frame-calm #3f7e70 |
4.50 | ✅ AA |
frame-zoomies #a46424 |
4.50 | ✅ AA |
frame-silly #b2537a |
4.52 | ✅ AA |
frame-challenge #a04a3c |
5.63 | ✅ AA |
frame-science #5a6f9c |
4.76 | ✅ AA |
white on each frame-* (rank badges) |
4.74 – 5.93 | ✅ AA |
The Claude design language specifies button-primary as white on coral #cc785c. That
pairing measures 3.28:1 — it fails AA for the 14px/500 label the same spec puts on it.
Coral as inline link text on cream measures 3.11:1 and fails likewise.
Resolution: --color-primary is set one lightness step deeper at #b55839, holding hue
and saturation constant so the coral character is unchanged. The literal brand value survives
as --color-coral-brand and is used only where nothing sits on it — the focus ring and
decorative tints. The companion teal, success, warning and three of the five vibe accents were
darkened the same way, by lightness only.
This is a deliberate, documented departure: on a platform whose participants include people we cannot screen for vision, a brand hex is not worth a failing contrast ratio.
One canonical implementation per primitive (see COMPONENT_AUDIT.md and COMPONENT_MAP.md). Notable decisions:
BadgevsChipsplit by interactivity, not appearance.Badgeis a read-only<span>for status;Chipis a real<button>witharia-pressedfor anything selectable. Because the split is semantic, neither can absorb the other — which is what had previously let Spark grow its own.spark-chip.ChipGroupowns single-select. Generic in the option type, so a group overSparkFramecannot be handed a stray string. Layout is a second axis:wraprenders an inline pill row,stacka full-width option list. Omittingvaluemakes it an action group. One component answers "pick one from a closed set" in every presentation.ScaleControlis the single anchored 1–N rating instrument. It was previously hand-rolled twice — a local helper inCueStepand inline markup inReflectStep— which let two renderings of the same research instrument drift apart.SectionHeadercovers both the icon-led form (Admin, Settings) and the eyebrow-led editorial form (Spark flow steps); they are the same thing, a titled section boundary.CardwithonClickrenders a<button>, not a clickable<div>, so the interactive form is focusable and Enter/Space-activated by construction.framingOf()resolves an untrusted frame string to a framing definition in one place. TheFRAMINGS[x] ?? FRAMINGS.calmpartial-index-plus-ad-hoc-default had been re-typed in four files.- No Spark styling exception.
spark.csspreviously held 528 lines scoped under.spark-zone, including its own palette and a shim aliasing--color-*to bare names. It is now ~150 lines of keyframe animation, which is the one thing utilities cannot express. - All status color lives behind
success/warning/danger/info/primarytokens. No raw Tailwind palette classes (yellow-500,blue-100,gray-500, …) and no inlinestyle={{ color: … }}remain in component code.