Lossless Changelog
product

Three modes, and the palette we actually use

The app looked like a stranger next to its siblings. Twice I fixed the wrong layer before looking at the screen.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 5 (1M context)

Three modes, and the palette we actually use

Why Care?

corpora-builder is the fourth surface in a family of tools built by the same people for the same work. It did not look like any of them. It shipped navy and cyan; its siblings render violet and electric magenta on near-black.

That is worth a ship note on its own. What makes it worth reading is that the fix took three attempts, and the first two were confident, well-argued, and aimed at the wrong layer.

The corpora source browser in dark mode: near-black page, monospace chrome, an electric-magenta Sources pill, bordered cards holding source titles in Inter with rows of chips beneath, and one amber-outlined card for a file that would not parse

What's New?

  • Three modes — light, dark, vibrant — switched from a cycle button in the header. Dark is the default.

  • The family palette. Electric magenta on near-black, from augment-it/packages/theme/theme.css.

  • The house token architecture: named palette → theme bindings → consumers, with data-theme and data-mode as independent axes.

  • A monospace instrument panel wrapped around one reading surface — chrome in JetBrains Mono, source titles and excerpts in Inter.

  • design-drift.mjs — five checks, wired into scripts/check.sh as a blocking rung.

Added in the 2026-08-23 revision:

  • A workspace switcher and a one-icon mode toggle in the header, ported from augment-it and the splash pages respectively.

  • An app mark — a leaning stack of paper — standing where the wordmark was, with a different treatment per mode rather than a tint.

  • A theme-aware favicon, which is the last surface where a mode can still be answered at runtime.

The three attempts

One: renaming tokens, which changed nothing

The first pass renamed this app's tokens to match augment-it's spelling. --ink → --color-text, --line → --color-border, 39 call sites, no colour changes. The argument was sound: patterns travel this tree by copy-from rather than by shared package, so the vocabulary is the interface, and a component cannot be copied across a rename.

It was also invisible. A rename changes no pixels — that is what makes it safe — and reporting it as convergence was simply wrong. The screenshot that came back looked identical to the one before, because it was.

Two: keeping a palette nothing renders

The second pass added borders and density but kept navy/cyan/amber, on the reasoning that it was the family brand inherited from memopop-ai/packages/shared-styles.

No shipping app consumes that package. That fact was already written in this repo's own token file — memopop-native does not import shared-styles despite its CLAUDE.md saying it does; it draws violet out of roughly 700 hardcoded literals and two locally-reinvented token sets. augment-it draws electric magenta. Both live surfaces are one violet-magenta family. shared-styles is a fourth palette that nothing paints.

The file documented the drift and then treated the drifted-from package as authoritative anyway. Twice.

Three: reading the convention instead of an app

The real error was underneath both. The reference being followed was augment-it's DESIGN.md — which is an elaboration of the house system, with federation vocabulary layered on top. The convention itself lives in astro-knots/context-v/blueprints/Maintain-Themes-Mode-Across-CSS-Tailwind.md and the theme-system skill, and it says three things the elaboration obscures:

The conventionWhat had been built
Layersthree: named palette → theme bindings → consumersfour tiers, with --fx-* as its own
Axesdata-theme × data-mode, independentone data-mode, collapsed
Modesthree, and vibrant is dark-basedtwo

Collapsing the two axes costs nothing until a second brand exists, and then it costs everything — which is exactly why the theme here is called labs and not didi. Converging the didi.sh brand onto this palette is separate, deliberate work, and the axis exists so it does not happen by accident during a restyle.

Three modes, and vibrant is not light

The same browser in light mode: warm paper background, deep-violet Sources pill, white cards with visible borders, and the same amber-outlined parse-error card

Light is warm paper and deep violet — not white and grey. The blueprint's argument for three modes is stakeholder management: nerds pick dark, traditionalists pick light, design-forward stakeholders pick vibrant, and the toggle ends the argument before it starts.

The same browser in vibrant mode: violet-tinted near-black background rather than white, louder magenta pill, and warmer violet card borders, showing that vibrant is dark-based

Vibrant is dark-based. The blueprint names the inverse — vibrant inheriting light's white background — as the classic error, so vibrant gets its own violet-tinted near-black void ramp rather than borrowing dark's greys.

The header cycles rather than toggles. A two-state toggle cannot reach a third mode, and three modes with no way to reach two of them is dead code.

Under the hood

The one deliberate divergence

augment-it is monospace throughout; it operates records. This app is read — 845 source titles and excerpts scanned for meaning — and mono costs scanning speed across a list that long. So chrome is --font-body (JetBrains Mono, like the family) and the reading surface alone is --font-reading (Inter).

That is the only exception, it is named in DESIGN.md, and the role names are descriptive rather than --font-sans / --font-mono per the blueprint's §2.4 — "heading-1" means nothing to anyone outside the code.

Rendering found what reading could not

Three careful readings of the CSS missed this. Headless Chrome, against a fixture page carrying the real tokens.css and the real markup, found it in one shot:

--color-surface: var(--color__graphite-700);   /* #232634 */
--color-border:  var(--color__graphite-800);   /* #232634 */

Identical. Every card drawn as border: 1px solid var(--color-border) over var(--color-surface) painted nothing in dark mode. It is augment-it's open gate A22 — its own contrast pass measures --color-border at 1.2–1.5:1 against a 3:1 requirement for control boundaries — and it travelled here with the palette. --fx-card-border now points at graphite-850, a darker hairline that reads as a seam and leaves the border free for hover to move. Light and vibrant need no override.

The harness costs about a minute and needs no backend. It is now the habit.

Five checks, and two bugs in the checks

app/scripts/design-drift.mjs, zero dependencies, blocking in check.sh:

D1no component reads a Tier-1 name
D2no colour literal below Tier 1
D3a higher step number is never lighter
D4DESIGN.md agrees with tokens.css on every colour Tier 2 uses
D5no component reads a token that does not exist

Each was verified by injecting its violation and confirming it fires. That found two bugs in the checks themselves:

  • D2 anchored on the string "Tier 2" and matched it inside the file's own header comment, so on its first run it flagged all 35 primitives.

  • D4 only walked numeric ramp steps, which meant it had never checked the accents — the colours a reader of DESIGN.md most needs to be right. It now covers all 40 named colours Tier 2 points at.

D3 found two real inversions in the upstream palette: paper-200 is lighter than paper-100, and void-700 is darker than void-900. Both are augment-it's A19 class — a step number that lies is worse than no name, because an agent will reason from it. They are listed in KNOWN_UPSTREAM with the reason rather than suppressed, so the Tier-1 names stay byte-identical across the two repos.

D5 is the one worth copying. A var() naming nothing paints nothing, silently, and the CSS is syntactically perfect — augment-it counted 33 of them.

Added 2026-08-23 — the header, and a mark

Two surfaces the three-mode contract had not reached yet. Neither is its own ship: they are the same architecture applied twice more, and the interesting part is where a token swap turned out not to be enough.

The corpora window in dark mode: a leaning paper-stack mark where the wordmark was, search and domain filter across the header, a Reach Edu dropdown and moon icon top right, strategy and topic chips grouped by type, and a list of 832 sources

The workspace stopped being a label

The header read corpora reach-edu (reach-edu) — a slug printed twice, with a bucket name leaking into the product, and no way to see or change which corpus you were in.

It is now the switcher from augment-it/shell/src/WorkspaceSwitcher.svelte, port rather than dependency: same trigger, same listbox, same interaction contract, same top-right position so someone who uses both apps does not have to look in two places. The trigger carries the display name only; the slug appears solely inside the dropdown, where an operator goes when they need to know exactly what they are pointed at.

Two smaller corrections fell out of it. display_name had been defaulting to the slug — that is where (reach-edu) came from — and is now humanised, with CORPORA_WORKSPACE_NAME still winning because no rule turns ncad-forge into NCAD-Forge. And /api/meta stopped shipping a pre-rendered label: a server that formats a string has made a layout decision on the client's behalf.

One mode icon that says what comes next

The three glyphs are the canonical ones from ai-labs/splash/src/components/ModeToggle.astro. The splash pages show all three and glow the active one; here it is a straight swap. A three-button group is right on a marketing page where the modes are part of the pitch — in a dense tool it is three targets to do one thing.

Which makes the tooltip load-bearing, because an icon that changes on click without saying what it becomes is a guessing game:

Dark mode · click for Vibrant

Generated from the cycle rather than written out, and the test asserts that structurally, so it survives someone reordering the modes.

The mark

A leaning stack of paper, chosen from ten drafts. The lean is the point — the brief was "a pile of paper that's a bit not straightly aligned", and an upright stack is a ream rather than a pile somebody has been working through. It stands where the wordmark was, with corpora kept as the accessible label.

The same header mark rendered three times: cool paper sheets with a magenta top sheet in dark, full ink sheets on white in light, and glowing magenta sheets in vibrant

Three treatments, not three tints — and this is where the theme system earned its keep and also found its edge:

sheetstop sheetglow
darkcool paperelectric magentanone
lightfull inkdeep magentanone
vibranthalo → loud magentaloud magentaa real drop-shadow

Light is a genuine re-cut, not a re-point. Outline art inverts badly: white-on-dark becomes grey-on-white and loses most of its contrast. Everywhere else in this app a mode is one indirection; here the drawing itself had to change, which is worth knowing before someone assumes tokens make artwork mode-agnostic.

Two line counts, one silhouette. Fifteen edges is right at icon size and mud in a header — at 26px they land about 1.3px apart and merge into a grey block. The small cut subsamples the same jittered curve, so outline, lean and top face are identical and only density changes.

And the icon drafts had a bug that every contact sheet hid: the chosen one overflowed its own viewBox, so the bottom of the stack was being clipped in every render shown. Caught by doing the arithmetic while lifting it into a component, not by looking at it.

What the packaged icon cannot do

The favicon is now static/favicon.svg, carrying both palettes and switching on prefers-color-scheme — which an SVG favicon can do and a .ico cannot. It also closes a 404 that had been sitting in the console.

The dock icon gets no such luxury. A .icns is bitmaps with no theme awareness, so it takes one baked palette; three variants are cut and dark is the one to ship. Worth stating plainly, because "the theme handles it" stops being true at exactly the boundary where the OS starts drawing instead of the browser.

What's Next

  • A brand-kit page. The blueprint expects one; there is nowhere in-app to see the palette.

  • Contrast measurement. augment-it verifies all 108 text-on-surface pairs; this repo inherits the numbers without re-measuring them against its own surface pairings, and A22 is proof that inheriting is not the same as checking.

  • The didi.sh theme. The axis is free and the work is not started.

  • Cut the real dock icon from mark-dark.svg. The drafts exist; the .icns does not.

  • app/DESIGN.md — the local contract, and the worked example of the layering

  • astro-knots/context-v/blueprints/Maintain-Themes-Mode-Across-CSS-Tailwind.md — the convention

  • ai-labs/context-v/reminders/Load-The-Theme-System-Before-Touching-Tokens.md — so this costs one session and not two

  • context-v/specs/Header-Chrome.md — the header work added in the revision

  • app/src-tauri/icons/drafts/ — the ten drafts the mark was chosen from

  • changelog/2026-08-22_04.md — the surface this restyles