Skip to content

Light and system themes - #41

Open
konpyl wants to merge 4 commits into
AltanS:mainfrom
konpyl:feat/light-and-system-theme
Open

Light and system themes#41
konpyl wants to merge 4 commits into
AltanS:mainfrom
konpyl:feat/light-and-system-theme

Conversation

@konpyl

@konpyl konpyl commented Jul 28, 2026

Copy link
Copy Markdown

Collie has been hardcoded to dark since 0.1.0 — class="dark" on <html>, with a full light token
set sitting unused in index.css the whole time. This unpins it, adds a System mode that follows the
OS, and puts a control in two places.

Four commits, each readable on its own:

7a7a13e A pre-existing bug, split out — the space and tab chip rows painted over each other
513d368 The feature
bb1f087 ADR 0002 + the CLAUDE.md rule that links to it
26736f1 Release 0.18.0

The interesting decision

The design originally gave the mirror a light ANSI palette — define --ansi-0…15 twice, emit
var(--ansi-N) from lib/ansi.ts, let CSS swap them. Built, tested, and then measured against real
panes:

pane truecolor 256-colour basic bright
w2Z:p1 446 7 0 0
wJ:p1 150 6 0 0
w14:p1 461 6 0 0
w18:pD 109 6 0 0

Zero basic and zero bright ANSI codes. Claude Code emits truecolor almost exclusively, and
truecolor names an absolute sRGB value — no palette can redirect it. On a white ground, eight of
thirteen distinct colours in a real pane fell below 3:1, including a at 1.0:1 and Monokai's
foreground at 1.07:1.

So the mirror now renders in dark space under every theme and light inverts it with
invert(1) hue-rotate(180deg), which preserves hue well enough to keep syntax highlighting legible.
Sampling rendered pixels, light tracks dark almost exactly — median 6.73 vs 7.46. The reasoning, the
three rules it imposes, and what would justify revisiting it are in
ADR 0002.

lib/ansi.ts still emits var(--ansi-N) for indexed colour, and does so via both spellings —
color256() resolves 0–15 through the same table, so theming 31m but not 38;5;1 would render the
same logical colour two different ways in one pane.

Notes for review

  • No CSP change. The anti-flash script is a same-origin file, which script-src 'self' already
    permits. System users get a correct first paint from CSS alone, with no JS at all.
  • One token block, not two. Verified first that color-mix() resolves a light-dark() argument,
    since the app-wide border-border/60 and outline-ring/50 base rules depend on it.
  • Contrast figures are measured, not modelled — rasterized through a canvas to resolve
    oklch()/light-dark(), composited against the real ancestor stack. Three rounds of
    browser-driven UX review across 390/768/1280 × both themes found 13 further defects (an unreadable
    off-switch, half-strength focus rings, every alpha-modified muted text under 3:1); two of those
    were regressions introduced by the previous round's fixes.
  • Known cost, deliberately not fixed: diffs and inverse-video regions become dark slabs in light.
    Legibility survives; only visual weight inverts. Documented in the ADR — greying it is worse.
  • iOS status bar is unresolved. apple-mobile-web-app-status-bar-style: black-translucent renders
    white glyphs, which will vanish on a light page in an installed PWA. There's no media form and no
    runtime API, and switching to default changes the layout contract with viewport-fit=cover. Needs
    a real device.
  • 26736f1 is droppable. If you'd rather cut releases yourself, drop the release commit and the
    other three still apply cleanly.

bun run build clean; 1064 web tests and 318 root tests pass; scripts/check-version.sh ✓ at every
commit on the branch, not just the tip.

🤖 Generated with Claude Code

konpyl added 4 commits July 29, 2026 07:25
Both strips are direct children of the space route's `flex min-h-0 flex-1
flex-col overflow-y-auto` scroller with no `shrink-0`, so they were
flex-shrunk vertically: 16px and 17px containers around 32px chips, whose
natural need is 48px. The rows then overlapped by 15px — the space pills
rendered sliced in half under the tab row, and a short label like `code`
was reduced to its two rounded end-caps.

Predates the theme work (reproduced against the shipped 0.17.0), and it
shows in both themes. Split out from that branch so it can be read on its
own.
Collie has been hardcoded to dark since 0.1.0 (`class="dark"` on <html>).
Unpin it and let the cascade decide.

Cascade. Every themed value is one `light-dark()` declaration keyed on
`color-scheme`, so System needs no JavaScript at all and there is no second
dark block to drift. Verified first that `color-mix()` resolves a
`light-dark()` argument, which the app-wide `border-border/60` and
`outline-ring/50` base rules depend on.

First paint. public/theme-init.js applies an explicit pin before paint. A
same-origin file, so `script-src 'self'` already allows it — no CSP change.
It stores a BARE string; useTheme deliberately diverges from
use-display-prefs' JSON encoding to match, because a strict compare against
a JSON-quoted value would fail silently and take the anti-flash with it.

useTheme owns what CSS cannot: the pin class (bidirectionally — a stale
class is why Dark to System would otherwise do nothing until a reload), the
theme-color metas, and the OS listener. Module-scoped so the three controls
agree and the listener outlives the idle lock unmounting the router.

The mirror renders in dark space and light inverts it (ADR 0002 follows).
Agents emit truecolor almost exclusively — 446 sequences in a live pane,
zero basic ANSI — and truecolor names an absolute colour no palette can
re-theme. ansi.ts still emits var(--ansi-N) for indexed colour, via both
spellings: color256() resolves 0-15 through the same table, so theming one
and not the other renders the same logical colour two ways.

Light surfaces are a ladder (page rgb 235 / card 255) rather than stock
shadcn's all-white, which rests the whole dashboard hierarchy on one 1.26:1
hairline. It also lands exactly on the mirror's inverted background, so the
seam at the pre's edge disappears.

Contrast work, all measured in a browser rather than modelled: light
--status-* retuned against the worst ground they land on (a translucent chip
over the pane header, not white — that took three attempts); --muted-
foreground darkened and every /70, /80 and opacity-60 modifier on small text
dropped, since no token value rescues a /70; focus rings to full strength
(they were 1.77:1); the off-switch given an outline (it was a white thumb on
a 1.09:1 track); header controls to 44px boxes without negative margins,
which overlap their neighbours and overflow the page.

Version bump rides with the release commit, so every commit on this branch
passes scripts/check-version.sh.
The decision closes off an option someone will absolutely re-propose ("why
not just give the mirror a light ANSI palette?"), and the answer is a
measurement rather than a preference: four live panes emit 446/150/461/109
truecolor sequences and zero basic ANSI codes.

Records the three rules that follow — dark-space authoring, literals not
tokens, filter scoped to the pre — and the sharp edge that cost a blocker
during review: cancelling the filter is only safe on an element that fully
specifies its own foreground, or inherited text double-inverts to invisible.

Carries the short normative rule in CLAUDE.md linking here. The ADR states
its own measurements and method rather than citing a design trail, so it
stands alone.
@konpyl
konpyl force-pushed the feat/light-and-system-theme branch from 26736f1 to 0c589bc Compare July 29, 2026 03:30
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