Light and system themes (supersedes #41) - #46
Merged
Conversation
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
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #41. The first four commits are @konpyl's, carried verbatim — this branch adds the review fixes on top and merges
mainin, 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, soscript-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: darkon an element does not flip an inheritedlight-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-foregroundresolves 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 inansi-output.tsx, a test comment, andCLAUDE.md— all corrected.The ADR's colour-form table was structurally blind to codex. It counted all
38;5;nas "256-colour", hiding palette slots 0–15 inside the unthemeable cube. Codex emits38;5;1/38;5;3/38;5;6and 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: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 inHERDR_API.mdwhere verified protocol facts belong.Measured values that didn't match their comments.
--backgroundlight isoklch(0.97)=#f5f5f5, not#ffffff;--muted-foregroundlight isoklch(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.tsemitted 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.
ThemeToggleandcycleThemeare 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
mediaform, no runtime API. Documented.<pre>is unprofiled on a phone. First thing to check if anyone reports light-mode scroll jank.Verification
scripts/check-version.sh ✓— all re-run after mergingmainin, not before0.20.0+ff0cf1d0.19.0 → 0.20.0: main shipped 0.19.0 as the journal adapters while this sat open🤖 Generated with Claude Code