diff --git a/.adr/0002-invert-the-light-terminal-mirror.md b/.adr/0002-invert-the-light-terminal-mirror.md new file mode 100644 index 0000000..c755d23 --- /dev/null +++ b/.adr/0002-invert-the-light-terminal-mirror.md @@ -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 `
` 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 `
` 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 `
` 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 `
` 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 `
`, 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 `
` — 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.
diff --git a/.adr/README.md b/.adr/README.md
index 918e742..befebdc 100644
--- a/.adr/README.md
+++ b/.adr/README.md
@@ -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 |
diff --git a/.gitignore b/.gitignore
index 13dc60f..b8f4a42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9534331..587b171 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index c6ff729..b7269aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 `
`, 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)
 
diff --git a/herdr-plugin.toml b/herdr-plugin.toml
index f145bd4..0b322d5 100644
--- a/herdr-plugin.toml
+++ b/herdr-plugin.toml
@@ -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"]
diff --git a/package.json b/package.json
index 46645f6..70e37b2 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/web/index.html b/web/index.html
index c1eb354..98269ea 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,12 +1,20 @@
 
-
+
   
     
     
-    
+    
+    
+    
+    
+    
     
     
     
@@ -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). -->