Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
636b7af
fix(web): stop the space and tab chip rows painting over each other
konpyl Jul 28, 2026
59bcfe1
feat(web): light and system theme
konpyl Jul 28, 2026
78425bd
docs: ADR 0002 — the light terminal mirror is inverted, not re-themed
konpyl Jul 28, 2026
0c589bc
release: 0.19.0
konpyl Jul 28, 2026
2f4d691
feat(bridge): keep two timestamps per pane, so the dashboard can sort…
konpyl Jul 28, 2026
da4f44c
feat(web): triage the dashboard by attention then recency, and stop c…
konpyl Jul 28, 2026
6786ca1
docs: ADR 0003 — one shared "seen", and only Collie's own reads count
konpyl Jul 28, 2026
29ede4d
release: 0.20.0
konpyl Jul 28, 2026
f9000cb
fix(bridge): don't let a cross-site GET clear your unseen agents
konpyl Jul 28, 2026
f8064c1
docs: record the CSRF fix in the 0.20.0 entry
konpyl Jul 28, 2026
4cca8db
feat(web): fold the long tails in the "Switch pane" sheet, shells inc…
konpyl Jul 28, 2026
6754742
docs: note the Switch pane folds in 0.20.0
konpyl Jul 28, 2026
8a8a4c9
fix(web): let the tab survive truncation, and stop every row restatin…
konpyl Jul 28, 2026
1a1100d
fix(web): make the card shape mean something, and stop line 2 repeati…
konpyl Jul 28, 2026
5c04453
fix(web): stop the hollow status ring reading as a notch cut out of t…
konpyl Jul 28, 2026
dab7e05
fix(web): light --accent was byte-identical to --background, so "you …
konpyl Jul 28, 2026
50a068f
docs: correct the --accent comment, and record the UX sweep's fixes i…
konpyl Jul 28, 2026
22d4a5f
feat(web): say what's going on inside each tab and space, not just "b…
konpyl Jul 28, 2026
472277f
docs: note the tab/space status dots in 0.20.0
konpyl Jul 28, 2026
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.
71 changes: 71 additions & 0 deletions .adr/0003-one-shared-seen.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
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/
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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 `<button>` doesn't inherit `text-transform` from its `<h2>` (6dc6ded)
- A hollow status ring on the avatar's corner read as a notch cut out of the logo (55cb6b9)

## [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
Loading