Settings → Appearance: five palettes and four typefaces, for the whole app - #104
Settings → Appearance: five palettes and four typefaces, for the whole app#104lvwerra wants to merge 3 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES REQUESTED
Correctness
-
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.
-
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.
-
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.
-
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>
|
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 participatesYou 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.
Verified against a running instance, driving the real Settings cards rather than setting
One deliberate boundary: the sixteen ANSI colours stay per-theme. They are the program's palette, not the app's — 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 2. The palettes are complete now, and the test can see itConfirmed: fourteen The fix splits them rather than converting all fourteen, because those two groups do different jobs:
You were also right about why my test missed it: Mutation-checked both ways: pointing 3. 5.5 vs 5.5625 — same raster, two conventionsYour 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. 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 The 1px vertical drift you confirmed is unchanged and still the real finding: ink top 4.
|
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>
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: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.tsholds the definitions;lib/stored.tsholds the guardedlocalStoragepair 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
--borderwould 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.
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.mdwith 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:
⠿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.--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.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|stoppedany more (I checked the live DOM: 0 of them, 9state-logos), so the braille-versus-rectangle contract instyles.cssis 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 fromstyles.cssitself, and asserts every palette carries every token the default sets.web/test/statusMark.render.test.mjs— gains a case that swaps--font-monoto a serif and checks the marks keep their font and their 15 × 13.125px cell. Mutation-checked: pointing them back at--font-monofails 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
🤖 Generated with Claude Code