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/0003-one-shared-seen.md b/.adr/0003-one-shared-seen.md
new file mode 100644
index 0000000..f166cfc
--- /dev/null
+++ b/.adr/0003-one-shared-seen.md
@@ -0,0 +1,71 @@
+# 0003 — "Seen" is one shared fact, and Collie only trusts what happened in Collie
+
+Status: **Accepted** (2026-07-28)
+
+## Context
+
+The dashboard sorts the herd by attention and then by recency, and it surfaces a **Ready · unseen**
+section: agents that finished while you weren't looking. Both need to know two things per pane —
+when the agent last moved, and when *you* last looked at it.
+
+Herdr supplies neither. Its pane, tab, and workspace records carry **no timestamps of any kind**
+(see [`HERDR_API.md`](../HERDR_API.md)), so every notion of "when" in this feature is one Collie
+derives and owns. That forced two questions that would otherwise never have been asked out loud.
+
+**Where does "seen" live?** Collie is a phone UI for a herd you also drive from a desk, and the same
+person uses both. The bridge already persists exactly this kind of state — `snooze.json`,
+`notify-prefs.json` — bridge-wide rather than per-device, on the same reasoning: a notification
+fans out to every device, so muting it on one must mute it on all.
+
+**What counts as looking?** Herdr reports a `focused` flag per pane, so the bridge *could* see you
+working in a pane at the desk and count that as having seen it. That was considered and rejected
+during design.
+
+## Decision
+
+**One shared "seen", recorded bridge-side and persisted to the state dir.** `activity.json` holds
+`{activeAt, seenAt}` per pane, keyed by session name (pane ids are session-scoped and collide across
+sessions). Not per-device, not in `localStorage`.
+
+**Only what happens in Collie counts as seeing.** `seenAt` is stamped when a request reaches
+`/api/pane/:id` — opening the pane, replying, sending keys, reading its history. A Herdr focus at
+the desk does not stamp it, and neither does anything else the bridge merely observes.
+
+**"Seen" is a comparison, not a stored flag.** An agent is unread exactly when
+`status === "done" && activeAt > seenAt`. There is no read-receipt table and nothing to keep in
+sync: opening the pane bumps `seenAt` past `activeAt`, and the row leaves the section by itself.
+
+**A first sighting is seeded as already-seen** (`activeAt = seenAt = now`), so only transitions
+observed *after* Collie first saw a pane can mark it unread. This is the same rule the state engine
+already applies to notifications — a first sighting never fires a transition, so a fresh start
+doesn't notify for agents that were already blocked.
+
+## Consequences
+
+- **A second device agrees with the first.** An alert cleared on the phone is cleared on the laptop.
+  This is the whole point, and it's why per-device storage was rejected: the failure mode there is
+  an alert you already dealt with still shouting at you somewhere else.
+- **Two people sharing one bridge share one "seen".** Accepted. Collie's threat model is a personal
+  tailnet with one operator; a bridge is remote shell access, not a multi-tenant service.
+- **Working in a pane at the desk does not clear its Collie alert.** This is the deliberate cost.
+  Counting a Herdr focus would let a pane you merely clicked past silently clear an alert you never
+  read — a false negative on the one thing the dashboard exists to surface. A false *positive* (an
+  item still listed as unseen after you dealt with it at the desk) costs one tap; a false negative
+  costs a missed agent.
+- **The ledger writes on a debounce.** An open pane polls about once a second and each poll stamps
+  `seenAt`; in memory that's free, on disk it would be a write per second forever. Flushes are
+  capped at one per 10s plus one on shutdown, so an unclean kill can lose up to ten seconds of
+  precision — imperceptible in a feature whose finest unit is "just now".
+- **The state can be thrown away.** Delete `activity.json` and the next poll re-seeds every pane as
+  seen. Nothing else depends on it.
+
+### What would justify revisiting
+
+- Herdr starts reporting real per-pane activity timestamps — then `activeAt` should come from the
+  source rather than from Collie's own observation, and a bridge restart would stop being a
+  re-seed.
+- Collie grows genuine multi-user support (distinct identities, not just distinct devices). Then
+  "seen" becomes per-identity, and this ADR is superseded rather than amended.
+- Evidence that clearing-at-the-desk actually matters in practice — i.e. the false positives are
+  frequent and annoying enough to outweigh the missed-agent risk. That's a usage question, not a
+  design one, and it should be answered with usage.
diff --git a/.adr/README.md b/.adr/README.md
index 918e742..f20a1ad 100644
--- a/.adr/README.md
+++ b/.adr/README.md
@@ -43,3 +43,5 @@ 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 |
+| [0003](./0003-one-shared-seen.md) | "Seen" is one shared fact, and only Collie's own reads count | 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..1de2565 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,49 @@ 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.20.0] - 2026-07-29
+
+### Added
+- **The dashboard is triaged, not listed.** Needs you → Ready · unseen → Working → Recent; the first three are pinned, Recent sorts by when you last used each pane (3850d96)
+- **Ready · unseen** — agents that finished while you weren't looking. Opening one clears it, on every device (839d0f3)
+- Recent and Spaces fold and remember it; fold both and the page is the triaged herd and nothing else (3850d96)
+- The swipe-up **Switch pane** sheet folds its long tails too — Recent, and the bare **Shells** group that buried the agents underneath it (ed0f0fa)
+- Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (3850d96)
+- The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (839d0f3)
+- **Tab and space chips carry a status dot** — blocked / ready / working / idle, in the herd list's own palette. They only ever showed a dot for blocked before, so every other state read the same as every other (ba49eaf)
+
+### Changed
+- **Agent rows are titled `project · tab`, not "claude".** The pane's own name moves to the second line; the agent stays in the avatar (3850d96)
+- Spaces moved BELOW every agent section — it's a navigator, not a work queue (3850d96)
+- Only Collie's own reads count as seeing a pane; a Herdr focus at the desk does not — [ADR 0003](.adr/0003-one-shared-seen.md) (16201fe)
+- MINOR, not MAJOR: additive, no config or API break. An older bridge reports no timestamps and simply renders the previous dashboard, minus the one section that would be empty
+
+### Fixed
+- Marking a pane seen had made a read-level GET mutate state, so a cross-site `` at a guessed pane id could silently clear your unseen agents. Only a request carrying the app's own header counts now — caught in this release's security review, never shipped (c84c8bd)
+- **Light `--accent` was byte-identical to `--background`**, so "this is the current one" showed nothing in light mode — the open pane in the switcher, the current session, every `hover:bg-accent`. Predates this release; found by the UX sweep (a18e8a9)
+- Titles truncated away the tab — the only part that identifies a row — leaving several panes rendering the same `moonward_os · t…` (6dc6ded)
+- Section headings rendered at two different sizes and cases, because a `