Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .adr/0002-invert-the-light-terminal-mirror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 0002 — The light terminal mirror is inverted, not re-themed

- **Status:** Accepted
- **Date:** 2026-07-28
- **Shipped in:** 0.18.0
- **Trail:** measurements below were taken against live panes on 2026-07-28; the counts and contrast
figures are reproducible with the method each one names

## Context

Adding light mode meant deciding what the pane mirror does. The obvious answer, and the one this
work carried for most of its life, was: give the mirror a light ANSI palette. Define
`--ansi-0…15` twice — VS Code's Dark+ set for dark, its light-theme set for light — have
`lib/ansi.ts` emit `var(--ansi-N)` instead of literal hex, and let CSS swap them. That was built and
tested before anyone measured whether it would matter.

It doesn't, much. Counting SGR forms in four live panes:

| pane | truecolor (`38;2`) | 256-colour (`38;5`) | basic (`30–37`) | bright (`90–97`) |
| --- | --- | --- | --- | --- |
| 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 codes anywhere.** Claude Code emits truecolor almost exclusively, and
truecolor names an absolute sRGB value — there is no palette slot to redirect. The 16 themeable
slots the design was built around are very nearly unreachable in practice.

The consequence on a white ground was measured on a real pane: of 13 distinct rendered colours,
eight fell below 3:1, including a `●` at **1.0:1** (`#ffffff` on white) and Monokai's foreground
`#f8f8f2` at **1.07:1**. A light mirror built the obvious way is not a dimmer light mirror; it is an
unreadable one.

This is faithful, incidentally. Run the same agent in a real light terminal and it looks equally
bad, because the agent chose its colours assuming a dark background. Fidelity is not the goal — the
mirror is the app's primary reading surface.

Three ways out were considered. Keeping the mirror dark works and is what an IDE does with an
embedded terminal, but it puts a permanent dark slab in a light app. Clamping absolute colours to a
luminance floor misrepresents what the program actually emitted, and the mapping is arbitrary.
Neither preserves the syntax highlighting an agent's output depends on to be scannable.

## Decision

**Render the mirror in dark space under every theme, and invert it in light.** The `<pre>` carries
`filter: invert(1) hue-rotate(180deg)`, reset to `none` under the `dark:` variant. The `hue-rotate`
is what makes this more than a negative: it approximately restores hue after the inversion flips
lightness, so green stays green and syntax highlighting survives.

Three rules follow, and all three are load-bearing:

1. **Everything inside the `<pre>` is authored for a dark ground** — the ANSI palette, the
find-match highlight, the muted rule glyphs. A `dark:` variant inside the mirror is a bug: it
tracks the root theme, which is exactly backwards in inverted space.
2. **Colours inside the `<pre>` are literals, not theme tokens.** `color-scheme: dark` on the
element does *not* flip an inherited `light-dark()` token — Chrome resolves those against the
root's scheme — so `bg-background` yields white on a light page and the filter turns it black.
Tokens cannot express "dark-space regardless of theme"; literals can.
3. **The filter is scoped to the `<pre>` alone.** The interactive blocks (prompt-select, wizard,
preview, multi-select) are siblings, not children, so they keep normal app theming.

`--ansi-0…15` therefore has **one** set of values, the dark one. `lib/ansi.ts` still emits
`var(--ansi-N)` rather than literal hex: the variables remain the seam where indexed colour is
defined once, and both spellings of an indexed colour (`31m` and `38;5;1`) route through them.

## Consequences

Measured on the same pane, light against dark, sampling rendered pixels:

| | background | min | median | max |
| --- | --- | --- | --- | --- |
| dark (unchanged) | `#0a0a0a` | 1.34 | 7.46 | 21.0 |
| light (inverted) | `#f5f5f5` | 1.43 | 6.73 | 18.69 |

The light profile tracks the dark one almost exactly — light mode inherits whatever readability the
agent designed for, instead of fighting it. (The sub-2 values are antialiasing edges, present in
both.)

What it costs:

- **Colours are approximations.** `hue-rotate` is a linear matrix, not a true hue rotation, so
saturated colours shift. The mirror shows an agent's palette *interpreted*, not reproduced.
- **A trap for future contributors.** Every instinct — use the token, add a `dark:` variant — is
wrong inside the `<pre>`, and wrong in a way that type-checks and often still passes a
computed-style test. `components/ansi-output.test.tsx` guards rules 1 and 2 explicitly.

The sharpest edge is **cancelling the filter**, which the find highlight does so its yellow isn't
reinterpreted as brown. Re-applying `invert + hue-rotate` cancels it — but only for colours the
element sets *itself*. The current match gets away with it because `text-black` pins its text
(black → invert → white → invert → black). Applying the same cancellation to a non-current match,
which sets no text colour, sent its *inherited* text light → dark → light and rendered it
invisible on its own highlight. It looked symmetrical and was not. Cancel the filter only on an
element that fully specifies its own foreground and background.
- **The light ANSI palette was deleted.** VS Code's light terminal set was chosen, verified against
upstream (including catching that `ansiGreen` had moved from `#00BC00` to `#107C10`), and then
made unreachable by this decision. It is in the git history if the premise ever changes.
- **Unmeasured scroll cost.** A CSS filter over a long `<pre>` — panes run to thousands of lines —
has not been profiled on a phone.

**What would justify revisiting this:** agents emitting indexed colour again, or a terminal palette
protocol that lets the client supply the ground. Either restores the premise that a re-themed
palette can work, and the honest answer then is a real light palette, not a filter. A measured
scroll regression on a mid-range phone would also reopen it — in favour of keeping the mirror dark,
not of re-theming it.
1 change: 1 addition & 0 deletions .adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ A superseded ADR is never deleted or edited into agreement with the present. Mar
| # | Decision | Status |
| --- | --- | --- |
| [0001](./0001-one-managed-front-door.md) | Collie manages exactly one front door | Accepted |
| [0002](./0002-invert-the-light-terminal-mirror.md) | The light terminal mirror is inverted, not re-themed | Accepted |
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ Thumbs.db
# the fix commit and its CHANGELOG line are the durable record.
.screenshot-bugs/
docs/bugs/

# Local tooling scratch (browser-automation captures)
.playwright-mcp/

# Design + review artifacts (kept locally, not part of the repo)
prp/
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ All notable changes to Collie are recorded here. The format follows
`version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by
`scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy.

## [0.19.0] - 2026-07-29

### Added
- **Light and system themes.** Collie followed your phone's appearance from this release on; pick Light or Dark explicitly from Settings, or cycle System → Light → Dark with the new header button (513d368)
- ANSI slots 0–15 are now CSS variables (`--ansi-*`), so indexed terminal colour is defined in one place and reaches the mirror through both `31m` and `38;5;1` spellings (513d368)

### Changed
- The pane mirror renders in dark space under every theme and light mode inverts it, because agents emit truecolor almost exclusively and no palette can re-theme an absolute colour — [ADR 0002](.adr/0002-invert-the-light-terminal-mirror.md) (bb1f087)
- In light, the page is a step off white with cards staying white, so the dashboard's hierarchy no longer rests on a single hairline — and the mirror's edge stops showing a seam (513d368)
- MINOR, not MAJOR: pre-1.0, purely additive, no config or API break. Defaulting to your phone's appearance is the feature working as designed, and Settings pins it either way

### Fixed
- **The space and tab chip rows overlapped each other on the space screen** — both strips were missing `shrink-0` inside the route's flex scroller, so they collapsed to 16px around 32px chips and the tab row painted over the space row. Pre-dates this release (7a7a13e)
- Three `role="alert"` warnings (incomplete multi-select, wizard, preview) used a hardcoded yellow that measured ~2:1 on white; they use the status palette now (513d368)
- An off notification switch was unreadable in light — a white thumb on a 1.09:1 track, legible only by its shadow. It carries an outline now (513d368)
- Focus rings were drawn at half strength, 1.77:1 in light and 1.87:1 in dark; both are full strength now (513d368)
- Small muted text (section labels, the build stamp, the terminal status line, the `(n)` counts) fell under 3:1 in light — light `--muted-foreground` had no headroom left for the `/70` and `opacity-60` modifiers stacked on it, so it was darkened and the modifiers dropped (513d368)
- Header controls had 20px touch targets; the Settings gear, the new theme button and the Settings back button are all 44px, with no change to how they look (513d368)

## [0.18.0] - 2026-07-28

### Added
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ the unit name; the Herdr action runs from anywhere.
`Tab`, `Escape`, `Enter`, `Backspace`. `PageUp`/`Home`/`End`/`Delete` are unsupported.
- Pane output is rendered as **React text nodes** (never `innerHTML`); the ANSI parser only derives
colors/weights. Keep it that way — it's the XSS boundary. Strict CSP + same-origin gate stay.
- **Inside the mirror `<pre>`, author for a dark ground: literal colors, never theme tokens, never a
`dark:` variant.** The mirror renders in dark space under every theme and light mode inverts it —
[ADR 0002](./.adr/0002-invert-the-light-terminal-mirror.md). Both instincts fail *silently* here (a
token resolves against the root, then inverts to its opposite); `ansi-output.test.tsx` guards it.

## Security posture (don't regress)

Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "herdr.collie"
name = "Collie"
version = "0.18.0"
version = "0.19.0"
min_herdr_version = "0.7.0"
description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale"
platforms = ["linux", "macos"]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie",
"version": "0.18.0",
"version": "0.19.0",
"private": true,
"license": "MIT",
"description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale",
Expand Down
29 changes: 25 additions & 4 deletions web/index.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
<!doctype html>
<html lang="en" class="dark">
<!-- No theme class here: `color-scheme: light dark` in index.css follows the OS, and
public/theme-init.js stamps `light`/`dark` before first paint only when the user has pinned
one. The cascade decides, not the markup. --><html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" content="#0a0a0a" />
<!-- Blocking, and first: the pin class must be on <html> before anything paints. Same-origin,
so `script-src 'self'` already allows it. -->
<script src="/theme-init.js"></script>
<!-- Browser chrome follows the OS for System users. useTheme rewrites these at runtime for
someone who has pinned the opposite of their OS. -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
Expand All @@ -24,6 +32,18 @@
seamless. Sprite math matches .dog-gallop in index.css: dog-gallop.png is a 768×128 strip of
six 128px frames, stepped with steps(6). -->
<style>
/* index.css has not loaded at this point, so the splash declares `color-scheme` itself —
otherwise light-dark() below has nothing to resolve against and every value falls to its
light branch. These three rules mirror index.css exactly; keep them in step. */
:root {
color-scheme: light dark;
}
:root.light {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
.boot-splash {
position: fixed;
inset: 0;
Expand All @@ -32,8 +52,9 @@
align-items: center;
justify-content: center;
gap: 0.75rem;
background: #0a0a0a;
color: #a1a1a1;
/* Matches --background / --muted-foreground so the hand-off to React is seamless. */
background: light-dark(#ffffff, #0a0a0a);
color: light-dark(#8a8a8a, #a1a1a1);
font: 500 0.875rem/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.boot-splash__dog {
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie-web",
"version": "0.18.0",
"version": "0.19.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
26 changes: 26 additions & 0 deletions web/public/theme-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Applies a pinned theme to <html> before first paint, so an explicit Light/Dark choice never
// flashes the other one on a cold load.
//
// Deliberately NOT part of the bundle: it has to run before the module graph loads. A same-origin
// <script src> is already permitted by the app's `script-src 'self'` CSP (bridge/server.ts), so
// this needs no policy change — which is the whole reason it's a file rather than an inline script.
//
// Users on System — the default — don't need this at all: `color-scheme: light dark` in index.css
// gets their first paint right with no JavaScript whatsoever. This is only for the pinned case.
//
// It does exactly one thing: add the pin class. Removing a stale class and reconciling the
// theme-color metas belong to hooks/use-theme.ts at runtime. Pre-paint DOM mutation is how a
// four-line script turns into a liability.
//
// Storage is a BARE string, not JSON — hooks/use-theme.ts must agree. JSON.stringify would write
// `"dark"` *with* the quote characters, the strict compare below would reject it, and the anti-flash
// would silently stop firing with nothing failing a test.
(function () {
try {
var t = localStorage.getItem("collie:theme:v1");
if (t === "dark" || t === "light") document.documentElement.classList.add(t);
} catch (e) {
// Safari private mode throws on localStorage. Falling through leaves `color-scheme: light dark`
// in charge, which follows the OS — the right default anyway.
}
})();
2 changes: 1 addition & 1 deletion web/src/components/action-sheet-rows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function RenameView({
onChange={(e) => onLabelChange(e.target.value)}
onKeyDown={onInputKeyDown}
placeholder={placeholder}
className="h-11 rounded-lg border border-border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
className="h-11 rounded-lg border border-border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
<Button onClick={onSave} disabled={saving || !canSave} className="h-11">
Expand Down
7 changes: 6 additions & 1 deletion web/src/components/agent-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { setStatus } from "@/lib/status";
import { ChatMessageList, type ChatMessageListHandle } from "@/components/ui/chat/chat-message-list";
import { BottomSheet } from "@/components/ui/sheet";
import { AppHeader } from "@/components/app-header";
import { ThemeToggle } from "@/components/theme-control";
import { AnsiOutput } from "@/components/ansi-output";
import { parseAnsi } from "@/lib/ansi";
import { splitLines } from "@/lib/blocks";
Expand Down Expand Up @@ -528,6 +529,10 @@ export function AgentChat({
rightLead={
agent ? (
<>
{/* The pane is where you actually stare at the mirror, so the situational flip the
theme control exists for bites hardest here. Leads the cluster rather than
trailing it — the status pill stays the rightmost thing on every pane screen. */}
<ThemeToggle />
{agent.agentSessionId && (
<button
type="button"
Expand Down Expand Up @@ -734,7 +739,7 @@ export function AgentChat({
vanish with the stripped input box). Sits directly above the composer, as it did in the
TUI. Verbatim text — a React text node, so no XSS surface. */}
{statusLine && (
<div className="truncate border-t border-border/40 px-3 py-1 font-mono text-[11px] leading-tight text-muted-foreground/80">
<div className="truncate border-t border-border/40 px-3 py-1 font-mono text-[11px] leading-tight text-muted-foreground">
{statusLine}
</div>
)}
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/agent-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function AgentList({
g.accent ? "text-status-blocked" : "text-muted-foreground",
)}
>
{g.label} <span className="opacity-60">({members.length})</span>
{g.label} <span className="text-muted-foreground">({members.length})</span>
</h2>
<div className="flex flex-col gap-2">
{members.map((a) => (
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/agent-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function Section({
)}
>
<span aria-hidden="true" className={cn("size-1.5 shrink-0 rounded-full", dot)} />
{label} <span className="opacity-60">({count})</span>
{label} <span className="text-muted-foreground">({count})</span>
</h3>
{children}
</section>
Expand Down
47 changes: 47 additions & 0 deletions web/src/components/ansi-output.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";

import { AnsiOutput } from "./ansi-output";

const ESC = "\x1b";

// The mirror renders in DARK space under every theme, and the light theme inverts it wholesale
// (.adr/0002). These guard the two ways that arrangement silently breaks.
describe("terminal mirror colour space", () => {
function mirror(text: string) {
const { container } = render(<AnsiOutput text={text} />);
return container.querySelector("pre")!;
}

it("inverts in light and leaves dark alone", () => {
const pre = mirror("hello");
expect(pre.className).toContain("[filter:invert(1)_hue-rotate(180deg)]");
// Without the dark: reset the filter would apply in BOTH themes and dark would render inverted.
expect(pre.className).toContain("dark:[filter:none]");
});

// The bug this exists to prevent: `bg-background` looks like the tidy, idiomatic choice, but an
// inherited light-dark() token resolves against the ROOT's colour-scheme, not this element's — so
// on a light page it yields white, which the filter then inverts to black. A black mirror on a
// white app, and every test that only checks computed styles still passes.
it("uses literal dark-space colours, never theme tokens", () => {
const pre = mirror("hello");
expect(pre.className).toContain("bg-[#0a0a0a]");
expect(pre.className).toContain("text-[#fafafa]");
expect(pre.className).not.toMatch(/\bbg-background\b/);
expect(pre.className).not.toMatch(/\btext-foreground\b/);
});

it("keeps muted rule glyphs on a literal dark-space grey", () => {
const pre = mirror("├────────────┤\n");
const span = [...pre.querySelectorAll("span")].find((s) => s.textContent?.includes("─"));
expect(span).toBeDefined();
expect(span!.style.color).toBe("rgb(161, 161, 161)"); // #a1a1a1, --muted-foreground's dark half
});

it("emits palette variables for indexed colour so the 16 slots stay themeable", () => {
const pre = mirror(`${ESC}[31mred${ESC}[0m`);
const span = [...pre.querySelectorAll("span")].find((s) => s.textContent === "red");
expect(span!.style.color).toBe("var(--ansi-1)");
});
});
Loading