Skip to content

Light and system themes (supersedes #41) - #46

Merged
AltanS merged 12 commits into
mainfrom
feat/light-and-system-theme-takeover
Jul 29, 2026
Merged

Light and system themes (supersedes #41)#46
AltanS merged 12 commits into
mainfrom
feat/light-and-system-theme-takeover

Conversation

@AltanS

@AltanS AltanS commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Supersedes #41. The first four commits are @konpyl's, carried verbatim — this branch adds the review fixes on top and merges main in, so it lands as a merge commit and his authorship survives in history.

What ships

Collie has been hardcoded dark since 0.1.0. This unpins it: light-dark() tokens, a System mode that follows the OS, a pre-paint anti-flash script (same-origin file, so script-src 'self' needs no change), and a three-way control in Settings → Appearance. System is the default, and a System user needs no JavaScript at all for a correct first paint — the CSS does the resolving.

The pane mirror is deliberately different: it renders in dark space under every theme and light mode inverts the whole <pre>, because agents emit absolute truecolor authored for black that nothing downstream can re-theme. ADR 0002 has the measurements.

What changed during review

ADR 0002's rule 2 was factually wrong. It claimed color-scheme: dark on an element does not flip an inherited light-dark() token, and concluded "tokens cannot express dark-space regardless of theme; literals can." Verified in Chromium that resolution is element-scoped, per spec — with the root pinned light, --muted-foreground resolves to its light half outside the <pre> and its dark half inside. Nothing rendered wrong (the literals are byte-exact matches for those dark halves) but an ADR is the artifact that closes an option off permanently, and this one closed it with a mechanism that doesn't exist. Rewritten as what it is: a convention, with the real reason. The same false claim was repeated in ansi-output.tsx, a test comment, and CLAUDE.md — all corrected.

The ADR's colour-form table was structurally blind to codex. It counted all 38;5;n as "256-colour", hiding palette slots 0–15 inside the unthemeable cube. Codex emits 38;5;1/38;5;3/38;5;6 and zero basic codes, so the table could not see the one harness that uses themeable colour. Split by index, and re-measured per harness rather than generalising from four Claude panes:

harness truecolor themeable
opencode 100% (508 seqs) 0%
pi 89% 5%
claude 79% 1%
codex 7% 59%

Codex doesn't reopen the decision, and the ADR now records why: herdr answers no OSC 10/OSC 11, so codex falls back to dark, and Collie couldn't answer anyway from downstream of the PTY. Those herdr-side facts live in HERDR_API.md where verified protocol facts belong.

Measured values that didn't match their comments. --background light is oklch(0.97) = #f5f5f5, not #ffffff; --muted-foreground light is oklch(0.48) = #5d5d5d, not #8a8a8a. The boot splash used the white pair under a comment claiming it matched, so the page visibly stepped from white to rgb(245) on hand-off to React and the caption sat at 3.45:1 — in a branch whose whole thesis was measuring exactly that. Now 6.04:1 and no step.

One spelling in the mirror. lib/ansi.ts emitted theme tokens for inverse video while the muted glyphs beside it used a literal, under a rule saying not to mix. Literals throughout — identical pixels.

Appearance lives in Settings only. It originally also put a cycling icon in every header; a control you meet three times per screen implies you're meant to keep reaching for it, and System already does the situational flip on its own. ThemeToggle and cycleTheme are deleted rather than left as test-only API.

Docs

New README section Dark mode / light mode covering what isn't guessable: the preference is per-device rather than bridge-wide, the mirror inverts rather than re-colours, and — the one that actually bites — an agent set to a light theme is unreadable in Collie either way, because that's a property of what the agent emits.

Known, shipping deliberately

  • iOS installed PWA in light mode: status-bar glyphs stay white and can vanish. No media form, no runtime API. Documented.
  • Diffs and inverse-video regions render as dark slabs in light. Per the ADR.
  • Codex's real-output colour profile is unmeasured (its auth was stale during testing) — recorded in the ADR as the thing that would justify revisiting.
  • The CSS filter's scroll cost over a long <pre> is unprofiled on a phone. First thing to check if anyone reports light-mode scroll jank.

Verification

  • 440 bridge tests, 1105 web tests, both typechecks clean, scripts/check-version.sh ✓ — all re-run after merging main in, not before
  • Built and run live on the deployment host at 0.20.0+ff0cf1d
  • 0.19.0 → 0.20.0: main shipped 0.19.0 as the journal adapters while this sat open

🤖 Generated with Claude Code

konpyl and others added 12 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.
…s to HERDR_API

Rule 2 claimed `color-scheme: dark` on an element does NOT flip an inherited light-dark() token, and
that "tokens cannot express dark-space regardless of theme". Both are false: resolution is
element-scoped per spec, verified in Chromium — with the root pinned light, --muted-foreground
resolves to its light half outside the <pre> and its dark half inside. The literals in the mirror
are byte-exact matches for those dark halves, so either spelling renders identically.

Nothing rendered wrong, because the literals equal what the tokens resolve to. But an ADR is the
artifact that closes an option off permanently, and this one closed it off with a mechanism that
does not exist — the next person to read it would "fix" working code to match a rule that is wrong.
Rule 2 is now stated as what it is: a convention, with the real reason (literals sit beside
truecolor nothing can re-theme, and say so at the point of use).

Also:

- The colour-form table counted all of `38;5` as "256-colour", hiding palette slots 0-15 inside the
  unthemeable cube. Codex emits `38;5;1`/`38;5;3`/`38;5;6` and zero basic codes, so the table was
  structurally unable to see the one harness that uses themeable colour. Split by index, and
  measured per harness rather than generalising from four claude panes: opencode 100% truecolor,
  pi 89%, claude 79%, codex 7%/59% themeable (chrome only, n=29, stale auth — flagged as the thing
  that would justify revisiting).
- Records why codex does not reopen the decision: herdr answers no OSC 10/11, so codex falls back
  to dark, and Collie could not answer anyway from downstream of the PTY.
- Records the light-themed-agent limitation (unreadable both ways, 1.57:1 / 1.47:1) as pre-existing
  rather than caused here, with the per-pane-override shape the fix would take.
- Closes off two alternatives that were re-derived from scratch in review: "set the agent to a light
  theme and don't invert", and per-harness colour maps. If per-harness knowledge is ever wired in,
  the payload is one bit, not a colour map.
- Drops the stale "Shipped in: 0.18.0"; the version belongs to the release commit.

The herdr-side facts (no OSC reply, SGR relayed verbatim, so herdr's own [theme] governs its UI and
not what a client receives) move to HERDR_API.md where verified protocol facts live, with a pointer
from the ADR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… their tokens, and 0.20.0

Follows the ADR 0002 correction. Three things.

**One spelling inside the <pre>.** lib/ansi.ts emitted var(--background)/var(--foreground) for
inverse video while the muted glyphs beside it used a literal, under a rule that said not to mix.
Both render the same pixels — the literals ARE those tokens' dark halves, byte-exact — so this is a
no-op visually and a consistency fix in fact. The comments that justified the split cited a browser
behaviour that does not exist; they now say what the rule really is.

**Splash and browser-chrome colours that claimed to match their tokens and did not.**
--background's light half is oklch(0.97) = #f5f5f5, not #ffffff, and --muted-foreground's is
oklch(0.48) = #5d5d5d, not #8a8a8a. The boot splash used the white pair under a comment saying it
matched, so the page visibly stepped from white to rgb(245) when React took over, and the caption
sat at 3.45:1 — in a branch whose whole thesis was measuring exactly that. Now the real rasterized
values: 6.04:1, and no step on hand-off. theme-color's light meta had the same #ffffff, which put
Android's URL bar a shade off the page it sits above.

**0.19.0 -> 0.20.0.** main shipped 0.19.0 as the journal adapters while this branch was open, so the
number was taken. Nothing else about the release changes.

Also drops `prp/` from .gitignore — a personal scratch directory belongs in .git/info/exclude, not
in the shared ignore file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule said a token inside the <pre> 'resolves against the root, then inverts to its opposite'.
It does not — resolution is element-scoped and the <pre> sets color-scheme: dark, so a token there
resolves to its dark half, which is exactly what the literals already are. Corrected to state the
rule without the mechanism, per this file's own convention: short, normative, argument in the ADR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pattern was `node_modules/`, and a trailing slash matches directories only. A node_modules
SYMLINK — which is what you get pointing a scratch worktree at the real install — slips straight
past it and `git add -A` commits it. Dropping the slash matches both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rules were bundled. The dark:-variant one is a real correctness trap — it tracks the root theme,
which is backwards in inverted space, and it fails silently. The literals-not-tokens one is a
consistency convention: both spellings render identical pixels, and ansi-output.test.tsx enforces it
without anyone reading CLAUDE.md. Only the first earns a slot; the argument for both is in ADR 0002.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y default

The theme control shipped in two places: a labelled three-way in Settings and a cycling icon in
every header (dashboard, space, pane). Meeting a control three times per screen implies you are
meant to keep reaching for it — but appearance is set once, and the default already does the
situational flip on its own, because System follows the phone. Settings keeps it; the header
drops it.

Removes ThemeToggle and useTheme's cycleTheme with it rather than leaving them exported and
uncalled — the three-way in Settings sets a value directly and never needed the cycle. The
module-scoped state still earns its place: it is what lets the OS listener outlive the idle lock
unmounting the router, and it is why a control in Settings alone reaches the whole app.

Documents it in the README under "Dark mode / light mode", covering the parts that are not
guessable: the preference is per device rather than bridge-wide, the mirror inverts instead of
re-colouring (with the reason and a link to ADR 0002), and — the one that actually bites — an agent
set to a LIGHT theme is unreadable in Collie either way, because that is a property of what the
agent emits, not of how Collie renders it.

No version bump: this folds into the unreleased 0.20.0, whose CHANGELOG entry is updated in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entries cited 7a7a13e/513d368/bb1f087/26736f1 — SHAs from a pre-rebase version of the branch
that are not in this history, so every link was dead. Repointed to the real commits, and the two
entries added during review now carry theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	CHANGELOG.md
#	herdr-plugin.toml
#	package.json
#	web/package.json
@AltanS
AltanS merged commit 0bc4a41 into main Jul 29, 2026
1 check passed
@AltanS AltanS mentioned this pull request Jul 29, 2026
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.

2 participants