Skip to content

Settings → Appearance: five palettes and four typefaces, for the whole app - #104

Open
lvwerra wants to merge 3 commits into
mainfrom
feat/appearance-study
Open

Settings → Appearance: five palettes and four typefaces, for the whole app#104
lvwerra wants to merge 3 commits into
mainfrom
feat/appearance-study

Conversation

@lvwerra

@lvwerra lvwerra commented Aug 20, 2026

Copy link
Copy Markdown
Member

i want to play with new colors. i want to play with new fonts (e.g. https://departuremono.com looks fun and others). it should just be a section in the settings.

i realize maybe i wasnt clear on the colors/fonts: this is for the overall AM ui.

Every palette and typeface, screenshotted across the app →

A working section in Settings, not a mock-up. Pick a palette or a typeface and the whole interface changes — sidebar, pane headers, the reader, Overview, terminals, and Settings itself — and the choice is remembered the way theme and zoom are.

How it works

Two data attributes on <html>, and nothing else:

<html data-theme="dark" data-palette="phosphor" data-typeface="departure">

Everything already reads colour from custom properties and type from --font-sans / --font-mono, so a palette is a block of property values and a typeface is two stacks. No component knows either exists, and none needed changing. lib/appearance.ts holds the definitions; lib/stored.ts holds the guarded localStorage pair App.tsx already used for the theme — moved there rather than copied, so the two cannot drift.

I found no component hardcoding a colour while doing this, which is why the diff is small.

What is on offer

Palettes — Teal (today's), Indigo, Paper, Phosphor, Plum. Each is a complete set for light and dark; none inherits from the default, because a palette that borrowed --border would look like teal wherever a hairline meets a background.

Typefaces — Geist (today's), Departure Mono (the named example: a pixel font, set everywhere, which turns the whole app into a terminal), JetBrains Mono (taller mono for the terminal, Geist for prose), IBM Plex (Sans + Mono, warmer). All self-hosted; a CDN would leave the Space rendering a fallback and the study would be of the fallback.

Family Version Licence Bundled
Departure Mono 1.500 OFL 1.1 22 KB
JetBrains Mono 2.304 OFL 1.1 21 + 22 KB
IBM Plex Sans / Mono 1.1.0 OFL 1.1 23 + 24 + 15 KB

156 KB added in total — Latin subsets, about a quarter of the full releases, which carry Cyrillic and Greek this interface never renders. Versions, licences and links are recorded in web/public/fonts/LICENSES.md with the licence texts beside the files.

The status marks — the risk you flagged, and what the measurements say

Three findings, in order of how much they changed the design:

  1. No font here draws the braille at all — including the one the numbers were measured from. at 12.5px has an identical 5.5 × 8.8125px ink box (the span the code and tests assert; the inclusive pixel count of the same raster is 5.5 × 8.8125) under Geist Mono, Departure Mono, JetBrains Mono and IBM Plex Mono. That is the system fallback rendering it every time. What does move with the typeface is where that fallback glyph sits: its ink starts 1.1875px from the top of the cell under Geist Mono and 2.1875px under IBM Plex Mono.
  2. So the marks now read their own token. --font-mark, pinned to the bundled Geist Mono, in all three renderers (.status.*, .ov-busy::before, .cx-running::before). A typeface can change every other letter in the app without reaching them.
  3. The sidebar and Overview frames were already immune. Move agent status onto CLI icon frames #92 rebuilt them as StateLogo, which is an SVG <rect> and touches no font — pixel-identical under all four typefaces here. A side effect worth knowing: nothing renders .status.working|waiting|idle|stopped any more (I checked the live DOM: 0 of them, 9 state-logos), so the braille-versus-rectangle contract in styles.css is now dead code. I left it alone — deleting it belongs to whoever owns Move agent status onto CLI icon frames #92 — but the brief for this task was written from it, so it is worth saying.

Contrast

Computed rather than eyeballed, for every pair on every surface it lands on, in both themes — text 7:1, muted 4.5:1, accent and danger 3:1. All four new palettes pass.

One pre-existing failure surfaced: the default teal palette misses AA for muted text on the app background (4.16:1) and on --panel-2 (4.42:1). The test reports it instead of asserting it, so the suite is not red for something this PR did not introduce. The one-line fix is --muted: #666f78 (4.50 / 4.79), which I have deliberately not applied — changing the default palette's greys is the operator's call, not a side effect of a study.

Tests

  • web/test/palette-contrast.test.mjs (new) — computes the ratios from styles.css itself, and asserts every palette carries every token the default sets.
  • web/test/statusMark.render.test.mjs — gains a case that swaps --font-mono to a serif and checks the marks keep their font and their 15 × 13.125px cell. Mutation-checked: pointing them back at --font-mono fails it. Its fixture now sets both tokens.
  • web/test/statusMark.test.mjs — the two assertions naming the font token follow the rename; the intent they guard (state it, never inherit it) is unchanged.

Web suite (18 files), the render suite and the server suite are green.

Deliberately not done

  • No top-level page and no route — it is a section in Settings, as asked.
  • The default remains Teal + Geist: nothing changes for anyone who does not open the section.

🤖 Generated with Claude Code

…e app

> i want to play with new colors. i want to play with new fonts (e.g.
> departuremono.com looks fun and others). it should just be a section in the
> settings.

A working section, not a mock-up: pick a palette or a typeface and the entire
interface changes — sidebar, pane headers, the reader, Overview, terminals and
Settings itself — and the choice is remembered the way theme and zoom are.

**How it works.** Two data attributes on `<html>`, and nothing else. Every
surface already reads its colour from custom properties and its type from
--font-sans / --font-mono, so a palette is a block of property values and a
typeface is two stacks. No component knows either exists. `lib/appearance.ts`
holds the definitions, `lib/stored.ts` holds the guarded localStorage pair that
App.tsx already used for the theme (moved there rather than copied, so the
appearance keys cannot drift from it).

**Palettes**: Teal (today's), Indigo, Paper, Phosphor, Plum — each a complete set
for light AND dark. None inherits from the default: a palette that borrowed
--border would look like teal wherever a hairline meets a background.

**Typefaces**: Geist (today's), Departure Mono, JetBrains Mono, IBM Plex. All
self-hosted — a CDN would leave the Space rendering a fallback and the study
would be of the fallback. Latin subsets, 22–24 KB each, 156 KB added in total,
all four families OFL 1.1, recorded with versions and links in
public/fonts/LICENSES.md and the licence texts beside them.

**The status marks, which is where this could have done damage.** #87 and #95
measured the state frames from a braille glyph's ink box, and the brief was that
a font swap might break them. What the measurements actually show:

- **No font here draws braille at all.** `⠿` at 12.5px has the same
  5.5625 x 8.8125px ink under Geist Mono, Departure Mono, JetBrains Mono and IBM
  Plex Mono — that is the system fallback rendering it every time, including
  today. What does change is where it sits: the ink starts 1.1875px from the top
  of the cell under Geist Mono and 2.1875px under IBM Plex Mono.
- **So the marks now read their own token**, --font-mark, pinned to the bundled
  Geist Mono, in all three renderers (.status.*, .ov-busy, .cx-running). A
  typeface can change every other letter in the app without reaching them.
- **The sidebar and Overview frames were already immune**: #92 rebuilt them as
  StateLogo SVG, which touches no font. Pixel-identical under all four
  typefaces, verified.

Colour was checked the same way rather than by eye: every pair on every surface
it lands on, in both themes — text 7:1, muted 4.5:1, accent and danger 3:1. All
four new palettes pass. The default teal palette misses AA in one place that
predates this change (muted on --bg is 4.16:1, on --panel-2 4.42:1); the test
reports it rather than asserting it, and the one-line fix is --muted: #666f78.

Tests: `palette-contrast.test.mjs` computes those ratios from styles.css itself,
and asserts every palette carries every token. `statusMark.render.test.mjs`
gains a case that swaps --font-mono to a serif and checks the marks keep their
font and their 15 x 13.125px cell — mutation-checked by pointing them back at
--font-mono, which fails it. The two existing statusMark assertions follow the
renamed token.

Screens: https://lvwerra-agent-artifacts.static.hf.space/appearance-study.html

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@lvwerra lvwerra left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: CHANGES REQUESTED

Correctness

  1. TerminalPane.tsx:24-44, 470-478, 1012-1015 — the actual xterm terminal does not participate in either setting. It still constructs xterm with a hard-coded Geist Mono stack and a hard-coded teal light/dark ITheme; the only live update watches theme, not palette or typeface. I reproduced the source path and there is no CSS inheritance route into xterm options. Selecting Departure/JetBrains/Plex therefore leaves terminal glyphs in Geist, and Paper/Indigo/Phosphor/Plum leave the terminal canvas on the old teal colors. This directly breaks the operator's “terminals too” contract and the PR body's whole-UI claim. The xterm options need to be derived from the active root tokens, updated live, and refit/resynced when the font metrics change.

  2. styles.css:1186-1216 and palette-contrast.test.mjs:47-70 — the palettes are not complete. A second :root / [data-theme='dark'] pair defines fourteen --cm-* color tokens after the palette blocks, so every non-default palette inherits the default file-editor syntax palette. The clearest leak is --cm-link staying teal (#0e7c86 / #2bb3bd) under all four palettes; --cm-meta and the other syntax colors leak too. The test cannot see this because tokensOf returns only the first matching :root rule and line 68 whitelists only the core token names. Aggregating all repeated default rules finds all fourteen missing in every palette/theme. Either make the editor syntax colors part of each complete palette (at least map link/invalid to accent/danger) or explicitly define and justify a separate invariant syntax system; the current “nothing inherits” claim and test are false.

  3. public/fonts/LICENSES.md:35-40 — the font-coverage conclusion is right, but the published number is not the number this branch tests. I independently inspected all four cmaps (none contains U+283F) and rastered each bundled face in the installed Chromium at 16x with alpha >= 16: all four return 5.5 × 8.8125 px, with the relative 1px vertical drift confirmed. That matches styles.css:461-467 and statusMark.render.test.mjs:248-252, which explicitly assert 5.5, but the PR body, study page and LICENSES.md say 5.5625. Please make the evidence, test and documentation agree.

  4. public/fonts/LICENSES.md:20-24 — the licence files themselves are present and correct, but the Departure link is not the font licence it says it is. The bundled WOFF2 and OFL text exactly match the v1.500 release archive; the linked repository main LICENSE is the MIT licence for the website. Link the release/tag's font LICENSE instead. Lines 3-6 also say every file is a Latin subset, while lines 32-33 correctly say Departure is vendored whole (and its cmap includes Greek/Cyrillic).

Does it solve the operator's problem?

Outside the real xterm and the CodeMirror syntax colors, root palette/typeface changes do reach the sidebar, pane header, reader, Overview, Settings and other CSS-driven surfaces. Settings itself updates immediately. Departure Mono remains readable at 12px in my 2x browser specimen; its weakest use is muted 8-10px API-log axes and metadata, where the pixel shapes are visibly faint/slower to scan. I would keep that as an honest study tradeoff, not block the option.

Checks that passed

  • --font-mark is consumed by all three braille renderers (.status.*, .ov-busy::before, .cx-running::before); no spinner still reads --font-mono.
  • The specialized .status.working/waiting/idle/stopped rules have no live component consumer; bare .status remains only for the two plain color dots. StateLogo is the live frame path.
  • Invalid/removed stored values and denied storage degrade to teal/Geist without crashing.
  • New font licence texts are bundled; font URLs are local; no CDN/runtime fetch exists.
  • The reported default muted contrast is confirmed (4.16:1 on bg, 4.42:1 on panel-2); no request to change it.
  • npm test, npm run test:render, and npm run build all pass.

…bers agree

Four findings from the codex review, all of them real.

1. The terminal ignored the whole feature. xterm draws on canvas and inherits
nothing, so its font stack and ITheme were hardcoded and only theme was watched.
Both now come from the live tokens: --term-bg, --text, --accent for the cursor
and an accent-with-alpha selection, --font-mono for the family. The update
effect waits on document.fonts.load for the new family before re-claiming the
PTY and resyncing, because the cell size changes and the column count with it —
the same path the zoom control already used.

The sixteen ANSI colours stay per-theme on purpose. They are the program's
palette, not the app's: ls colours, a TUI's theme, a diff's red and green. Under
Phosphor an accent-green ANSI green would collide with success; under any
single-hue palette the streams would stop being distinguishable from each other.

2. Appearance is now applied at decision time, not only in an App effect. A
child's effects run before its parent's, so on first paint TerminalPane resolved
tokens from a root that had no data-palette yet. main.tsx applies the stored
pair before render and the setters apply theirs before state settles.

3. Syntax colours were a second :root pair after the palette blocks, so every
palette inherited fourteen teal-derived --cm-* values. Five of them carry app
meaning and now follow the palette: link is the accent, invalid is danger, and
comment/punct/meta follow muted. The other nine are a hue system for reading
code and are held invariant deliberately — re-hueing keyword, string and number
per palette makes each palette its own legibility problem.

The test could not see any of this: tokensOf returned the first matching rule
only, and its whitelist covered core names. It now parses the file into
selector/body pairs, aggregates every rule for a selector, derives the expected
token list from the default palette's own values, and accepts a token only if it
is declared, derived via var(), or on the explicit invariant list. Mutation
checks: pointing --cm-link back at teal fails 12 assertions; adding a tenth
hardcoded hue fails completeness.

4. The braille ink figure said 5.5625 in the PR body, the study page and
LICENSES.md while the code and tests assert 5.5. Both numbers are the same
measurement under different conventions — inclusive pixel count against span —
verified in one run: ink 5.5625 x 8.8125px (span 5.5000 x 8.7500). The docs now
carry 5.5, the number the code asserts, with the convention written down.

LICENSES.md also pointed the Departure link at the repo's main LICENSE, which is
MIT for the website and not the font licence, and claimed every file was a Latin
subset while the table below correctly said Departure is vendored whole. Both
fixed, with the trap recorded.

Screens: https://lvwerra-agent-artifacts.static.hf.space/appearance-study.html

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lvwerra

lvwerra commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Thanks — all four reproduced on my side too, all four fixed in 995a297. Nothing disputed; one number needed an explanation rather than a change.

1. The terminal now participates

You were right that it was untouched, and it was the biggest hole: xterm draws on canvas, so it inherits nothing and the feature simply stopped at the pane border.

fontFamily and the ITheme are now derived from the live root tokens — --term-bg for background and cursorAccent, --text for foreground, --accent for the cursor and for an alpha-blended selection, --font-mono for the family. The update effect watches theme, palette, typeface, active, zoom; when the family changes it waits on document.fonts.load for the new face, then re-claims the PTY and resyncs, because a new face changes the cell size and therefore the column count. That is the same path the zoom control already used, so there was an existing correct route to reuse.

Verified against a running instance, driving the real Settings cards rather than setting dataset directly (my first probe did the latter and saw nothing change — React never ran the effect):

  • paper: --term-bg #fbf7f0, painted pixel #fbf7f0
  • phosphor dark: --term-bg #060b0d, painted pixel #060b0d
  • char-measure element reports Departure Mono / IBM Plex Mono after those choices
  • Geist vs Departure terminal render: 544 px differ

One deliberate boundary: the sixteen ANSI colours stay per-theme. They are the program's palette, not the app's — ls colours, a TUI's own theme, a diff's red and green — and their job is to stay distinguishable from each other. Under Phosphor an accent-green ANSI green would be indistinguishable from success; under any single-hue palette every stream would look alike. Written down in the code and on the study page so it reads as a decision, not an omission.

Related, and part of the same finding: appearance is now applied at decision time, not only in an App effect. A child's effects run before its parent's, so on first paint TerminalPane was resolving tokens from a root with no data-palette yet. main.tsx applies the stored pair before render, and the setters apply before state settles.

2. The palettes are complete now, and the test can see it

Confirmed: fourteen --cm-* tokens sat in a second :root / [data-theme='dark'] pair after the palette blocks, so every palette inherited teal-derived syntax colours — --cm-link most visibly.

The fix splits them rather than converting all fourteen, because those two groups do different jobs:

  • Palette-bound (5): --cm-link → var(--accent), --cm-invalid → var(--danger), and --cm-comment / --cm-punct / --cm-meta → the muted grey. These carry app meaning; a link that isn't the accent colour is just wrong.
  • Invariant (9): keyword string number fn type tag def heading prop. These are a hue system for reading code, and re-hueing them per palette turns every palette into its own legibility problem — five palettes × nine hues is 45 contrast pairs to defend, and the failure mode is subtle (a string and a number becoming hard to tell apart) rather than obvious. Held constant on purpose, and the test now asserts that exactly these nine are hardcoded, so a tenth cannot appear silently.

You were also right about why my test missed it: tokensOf returned only the first matching rule and its whitelist covered core names. The regex boundary (^|\}) failed on that block because a comment precedes it — so light :root reported zero cm- tokens while dark passed, which is exactly the kind of half-signal that reads as green. It now strips comments, parses the file into selector { body } pairs, aggregates every rule for a selector, derives the expected token list from the default palette's own values, and accepts a token only if it is declared, derived via var(), or on the explicit invariant list.

Mutation-checked both ways: pointing --cm-link back at the teal literal fails 12 assertions; adding a tenth hardcoded hue fails completeness.

3. 5.5 vs 5.5625 — same raster, two conventions

Your measurement is right and the docs were wrong to disagree with the code. It isn't a rounding error, though: both numbers come out of one run.

ink 5.5625 x 8.8125px  (span 5.5000 x 8.7500)

5.5625 is the inclusive pixel count at 16× (first inked column through last, both ends counted); 5.5 is the span between edges. The code, the tests and your reproduction all use span, so the docs now say 5.5, with the convention written down next to it so the other number doesn't reappear as a "fix" later. Corrected in the PR body, the study page and LICENSES.md.

The 1px vertical drift you confirmed is unchanged and still the real finding: ink top 1.1875 under Geist/JetBrains vs 2.1875 under Departure/Plex — which is why --font-mark is pinned.

4. LICENSES.md

Both errors were exactly as described.

  • The Departure link pointed at the repo's main LICENSE, which is MIT for the website, not the font licence. Now pointing at the vendored licenses/DepartureMono-OFL.txt, with the trap recorded in the file — a repo whose site and font carry different licences is an easy way to cite the wrong one.
  • The blanket "Latin subset" claim contradicted the table two lines down. The file now has a per-file contents column: Departure is vendored whole (22 KB, and it covers Greek and Cyrillic — measured, greek=true cyrillic=true braille=false), the others are Latin subsets from Google Fonts.

On Departure at 8–10px

Agreed, and kept. It is the option someone picks knowing what it is; hiding the tradeoff by dropping the option would make the study less honest, not the UI better. Noted on the page.

State

Typecheck clean, 19 web suites green, render suite green, server suite green. (The server suite failed once mid-round because a font probe of mine had taken port 7898 — my process, not the code; trace-download binds that port.)

Screens, now including four terminal shots: https://lvwerra-agent-artifacts.static.hf.space/appearance-study.html

Not merging and not deploying — that's yours.

Greys only, complete for light and dark, inheriting nothing, and that includes
the editor's nine syntax hues: a palette with no colour in it cannot leave a
rainbow in the file pane. Measured at 3× with no subpixel rendering, the mono
chrome has a maximum chroma of ZERO — not one coloured pixel in the state marks
or the delete confirmation, against 120 and 137 for the same crops in Teal.

WHAT REPLACES HUE

The accent. With one channel left it takes the extreme of it: #000000 on light,
#ffffff on dark. That is not a decorative choice — `waiting` is the accent and
`idle` is muted (.state-logo), two 1px frames at 12px, and they have to stay
apart. In luminance:

  palette            accent vs muted
  teal (light)             1.05:1
  paper (light)            1.04:1
  phosphor (dark)          1.48:1
  mono (light)             3.71:1
  mono (dark)              3.23:1

Every hued palette separates those two marks by about one — they are telling
them apart by hue, and the maths cannot see it. Mono has to do it in the channel
the maths does see, and does, by 2.5-3.5×. The test asserts 3:1 for mono and
skips the hued palettes with the reason written down. Shot side by side at 4×:
dashed black / solid black / mid grey / light grey reads at a glance.

Danger. It loses its red, and colour cannot bring it back, so the fill takes a
pattern instead: an inset 2px ring in the surface colour on the two filled
destructive controls. In the confirm row the black Delete now carries a white
halo the black View segment does not. That is one scoped rule and it covers
CONTROLS only — danger TEXT (an error line, a failed row, a warning notice) has
nowhere left to go in this palette and reads as ordinary ink. Stated rather than
papered over: in pure black and white, "irreversible" survives as emphasis and
loses its at-a-glance recognisability in prose. Success and warning lose theirs
outright; both were already carried by words.

The editor. Its colours are OURS — our paint on someone's plain text — where the
terminal's sixteen ANSI colours are the program's own bytes and stay per-theme,
as established in the review round. So syntax follows the palette here: three
steps of ink (structure, values, the rest) plus the cues cm-core.ts already
carries without colour — comments italic, headings bold, links underlined. It
reads; it does not sort code by hue, and that is the cost. Screens show a
Claude Code welcome banner in full colour inside the grey chrome: a window into
another program, which is the line this palette draws.

Harness logos go grey too (`filter: grayscale(1)`, alongside the two invert
rules that already bend a logo to a theme). The line is by surface, not origin:
what the app paints goes grey, what is content keeps its colour.

TWO THINGS MONO FOUND IN THE EXISTING CODE

`#fff` was written into three rules that paint on the danger fill. That is safe
for every red and wrong the moment a palette's danger is light — mono/dark is
white on white, an invisible delete button. It is now `--danger-fg`, which every
palette declares, and the dark palettes take dark ink: white on their light reds
was 3.10:1 (default), 2.88 (paper), 2.55 (phosphor) — under AA for a button
label, and true since the fill existed. Dark ink is 6.1-7.4:1.

The cascade. The syntax block is a THIRD `:root` further down styles.css, so at
equal specificity it wins on order: `[data-palette='mono']`'s --cm-* overrides
never applied and light mono showed purple keywords while dark looked right.
Caught in the browser, not by reading. The selector is `:root[data-palette=
'mono']` now, and a new check resolves specificity-then-order on the source for
all nine tokens — mutation-checked to fail alone when the extra `:root` is
dropped.

Contrast: text 7:1, muted 4.5:1, accent 3:1, danger 3:1, danger-fg on danger
4.5:1, both themes, all six palettes green. Mono's tightest is muted on --bg at
4.88:1.

Screens: https://lvwerra-agent-artifacts.static.hf.space/appearance-study.html

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant