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). -->