diff --git a/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/.openspec.yaml b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/.openspec.yaml new file mode 100644 index 0000000..96db9a4 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-15 diff --git a/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/design.md b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/design.md new file mode 100644 index 0000000..70aa34f --- /dev/null +++ b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/design.md @@ -0,0 +1,75 @@ +## Context + +The Home TUI paints colored surfaces by wrapping `TextChunk`s in a background: + +- `highlighted()` in `src/home-tui.ts` wraps every chunk that does **not** already carry a background with the accent fill, and leaves chunks that do (the state rail/dot) untouched. +- `filledLines()` in `src/home-tui.ts` wraps every chunk with a fill **unconditionally**; it backs the detail's identity zone (accent) and the new-worktree input well. + +Facts are built with foreground-only chunks. Both the fold's `fact()` and the zone's `zoneFact()` colour a warning value with `theme.yellow`. On an accent fill that ink measures ~1.26:1 (dark), ~1.21:1 (light), ~1.03:1 (neutral) — unreadable — whereas the same yellow as ink on the canvas is 9.63:1. Palette definitions live in `src/tui-theme.ts`; `chipText` is documented as "text drawn on top of colored chips". Motivation is in `proposal.md`. + +The two fill paths disagree on one rule: `highlighted` already lets a chunk keep its own background, `filledLines` does not. A chip that must survive a fill therefore only works in the fold today. + +## Goals / Non-Goals + +**Goals:** + +- Warnings on filled surfaces render as a filled chip (fill + contrasting ink), preserving the warning signal while making the value legible. +- A dedicated warning fill/ink pair per palette that guarantees the ink contrasts with the fill, and the fill stays distinguishable from the accent fill. +- One shared mechanism so the fold and the detail zone render warnings identically. + +**Non-Goals:** + +- Changing which conditions raise a warning, or the independence of the underlying observations. +- Changing warnings on plain surfaces (the detail's remaining facts keep yellow ink). +- Introducing chips in TUI surfaces that have no warn-on-fill today (`runs-browser`, `specs-browser`). +- Reworking the accent fill, the palette at large, or the selected-row highlight design. + +## Decisions + +### D1: Warnings are context-sensitive — ink on plain surfaces, fill on filled surfaces + +A warning keeps `yellow` **ink** where the surface is transparent (yellow on canvas is 9.63:1). Where the surface is a fill, the warning becomes a **chip**: `warning` fill + `warningInk` text. This keeps the existing "warning = yellow" vocabulary while removing the illegible combination, instead of abandoning yellow or dimming the accent. + +*Alternative considered:* keep yellow ink but lighten/darken it per surface — rejected because no single ink clears 4.5:1 against both the blue accent and the canvas across palettes. + +### D2: A dedicated palette pair, not the palette's `yellow` + +The palette's `yellow` is tuned as ink on the canvas; as a fill on light it is a dark olive that clears contrast with nothing reasonable: + +| fill | ink | ink/fill | fill/accent | verdict | +|---|---|---|---|---| +| `#E0AF68` (dark yellow) | `#0A0E1A` | 9.63 | 2.01 | satisfies both, on dark and light | +| `#8C6C3E` (light yellow) | `#000000` | 4.33 | 1.21 | fails ink/fill on light | +| `#8C6C3E` | `#E1E2E7` (`chipText` on light) | 3.75 | 1.21 | fails ink/fill on light | + +So the change adds two palette fields, a warning fill and a warning ink, and chooses values per palette so the ink clears 4.5:1 against the fill and the fill stays distinguishable from the accent fill. The dark and light palettes use the amber fill `#E0AF68` with near-black ink `#0A0E1A`; neutral already passes with `#B59B3A` + `#000000` and may keep it or adopt the same pair. Because the chip's ink is **not** `chipText` (near-white on light), the new ink field is excluded from `PaletteColor` alongside `chipText` so it is never treated as a fill target. + +*Alternative considered:* reuse `chipText` as the chip ink — rejected, it fails on light (3.75). *Alternative considered:* brighten the light palette's `yellow` in place — rejected, it is also used as ink elsewhere on light surfaces and would regress those. + +### D3: One chip over the whole value, not per token + +The whole warning value renders as one chip (the current yellow already spans the whole value: `pipeline · live · 1m 53s`). No attempt to chip only the `live`/`uncertain` token; a per-token chip would split one observation into mixed surfaces and complicate truncation. + +### D4: Share the "preserve an existing background" rule between both fillers + +`highlighted` already preserves a chunk's own background; `filledLines` does not. Extract that rule into one shared helper and apply it in both, so a chip survives either filler. This is additive for `filledLines`: the detail zone's rows and the input wall's rows currently carry no chunk-level backgrounds, so only the new chips change behaviour. + +### D5: A single `warnChip` helper produces the chunk + +One helper builds `bg(warning)(fg(warningInk)(value))` and is used by the fold's `fact()` (writer, unknown dirt, unknown changes) and the detail zone's `zoneFact()` (non-known linked PR). The plain detail facts keep passing `theme.yellow` to their existing colour parameter. + +## Risks / Trade-offs + +- **A saturated chip can overpower the row** → chip the value only, keep its label in the ordinary chip text, and keep the chip hue far from the accent blue; a single value per row is chipped. +- **A long `unknown (reason)` value becomes a long amber bar** → the value is already truncated to the fact column; the chip follows the truncated text and its length, so it never exceeds the row. +- **The shared escape could let unrelated backgrounds survive a fill** → verified that the only chunks in filled rows today carry foreground only, so the escape changes nothing until a chip exists. +- **The chip reverses a deliberate, test-locked contract** → the change is intentional and recorded in the spec; the affected test is updated as part of the work rather than worked around. +- **Light/neutral aesthetics** → the pair is chosen against measured contrast, not by eye, and is covered by a palette test. + +## Migration Plan + +None required: the change is presentation-only with no persisted data, public API, or configuration surface. Rollback restores the palette fields and the two call sites. + +## Open Questions + +- Whether other TUI surfaces (runs/specs browsers) should later adopt the warning chip if a warn-on-fill appears there. Deferrable; adding it there would not change this spec or approach. diff --git a/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/proposal.md b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/proposal.md new file mode 100644 index 0000000..4cc96bd --- /dev/null +++ b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/proposal.md @@ -0,0 +1,27 @@ +## Why + +Home's selected-row fold and the worktree detail's identity zone paint a solid accent fill behind every fact. Warning values — the managed writer's live/uncertain liveness, an unknown dirt or changes observation, and an unavailable PR — keep their yellow ink on that fill, where yellow-on-accent measures about 1.26:1 in the dark palette and 1.21:1 in the light palette. The warning is signalled at the cost of the text being effectively unreadable, so an operator can see that something needs attention but cannot read what it says. + +## What Changes + +- A warning value rendered on a filled surface (the selected row's fold and the worktree detail's filled zone) SHALL render as a filled chip: the warning color becomes the background and a contrasting ink carries the text, so the warning signal is preserved and the value is legible. +- A warning value rendered on a plain, unfilled surface (the detail's remaining facts) SHALL keep its yellow ink, which is already legible against the terminal background. Warnings become context-sensitive: ink on plain surfaces, fill on filled surfaces. +- The warning chip's fill SHALL be guaranteed to contrast with its text across the dark, light, and neutral palettes. This requires a dedicated warning-chip color pair rather than reusing the palette's `yellow`, which is tuned as ink on a light canvas and is too dark to serve as a fill there. +- This reverses the current deliberate presentation contract that a warning "stays yellow on the accent fill". + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `home-launcher`: warning observations shown on the accent-filled fold and the detail's filled zone render as a contrasting filled chip instead of yellow ink, preserving both the warning signal and legibility; warnings on the detail's plain facts keep their yellow ink. + +## Impact + +- `src/tui-theme.ts`: add a warning-chip fill/ink pair to the dark, light, and neutral palettes, chosen so the ink meets contrast against the fill (and the fill stays distinguishable from the accent fill). +- `src/home-tui.ts`: render warning values as chips in the selected-row fold (`inlineDetailLines`) and in the detail zone (`detailLines`), and let `filledLines` preserve a chunk's own background the way `highlighted` already does. +- `test/home-tui.test.ts`: replace the "stays yellow on the accent fill" expectation with the chip contract, and cover the two contexts (filled chip vs plain yellow ink). +- `test/tui-theme.test.ts`: cover the warning-chip pair's contrast in each palette. diff --git a/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/specs/home-launcher/spec.md b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/specs/home-launcher/spec.md new file mode 100644 index 0000000..4c739b4 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/specs/home-launcher/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Warning observations stay legible on filled surfaces + +A warning observation rendered on a filled surface SHALL render as a filled chip: the warning color becomes the chip's background and a contrasting ink carries its text, so the warning stays visible and its value stays legible against the surrounding fill. A warning observation rendered on a plain, unfilled surface SHALL retain its yellow ink. The warning chip's fill and ink SHALL be chosen per palette so the ink contrasts with the fill and the fill remains distinguishable from the accent fill. In particular, the selected row's inline detail (a managed writer's live or uncertain liveness, an unknown working-tree dirt observation, an unknown changes observation) and the worktree detail's filled identity zone (a linked-PR observation that is not known) SHALL use the chip, while the detail's remaining plain observations SHALL keep yellow ink. The conditions that raise a warning and the independence of the underlying observations SHALL NOT change, and no additional observation query SHALL be introduced. + +#### Scenario: Live writer in the selected row's inline detail + +- **WHEN** a selected worktree holds a live or uncertain managed writer claim and its inline detail renders on the accent fill +- **THEN** the writer value renders on the warning chip fill with contrasting ink, legible against the accent fill + +#### Scenario: Unknown observation in the selected row's inline detail + +- **WHEN** the selected worktree's working-tree dirt or changes observation is unknown +- **THEN** the unknown value renders on the warning chip fill with contrasting ink instead of yellow ink on the accent fill + +#### Scenario: Unavailable linked PR in the detail's filled zone + +- **WHEN** the worktree detail's filled identity zone shows a linked-PR observation that is not known +- **THEN** the value renders on the warning chip fill with contrasting ink + +#### Scenario: Plain facts keep yellow ink + +- **WHEN** a warning observation renders outside any filled surface, among the detail's remaining plain facts +- **THEN** it keeps its yellow ink and does not render as a chip + +#### Scenario: Warning chip contrasts across palettes + +- **WHEN** the warning chip renders under the dark, light, or neutral palette +- **THEN** its ink contrasts legibly with its fill and its fill remains distinguishable from the accent fill diff --git a/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/tasks.md b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/tasks.md new file mode 100644 index 0000000..000c463 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-accent-fill-warning-chips/tasks.md @@ -0,0 +1,22 @@ +## 1. Palette tokens + +- [x] 1.1 Add `warning` (fill) and `warningInk` fields to the `Palette` type and to the dark, light, and neutral palettes in `src/tui-theme.ts`, excluding `warningInk` from `PaletteColor`; verify with `bun run typecheck`. +- [x] 1.2 Pick values per palette so the warning ink clears 4.5:1 against the warning fill and the fill stays distinguishable from the accent fill (dark/light: `#E0AF68` + `#0A0E1A`; neutral: `#B59B3A` + `#000000` or the same pair); verify with a contrast test added in `test/tui-theme.test.ts` (`bun test test/tui-theme.test.ts`). + +## 2. Rendering + +- [x] 2.1 Extract the "preserve a chunk's own background" rule into one shared helper in `src/home-tui.ts` and use it from both `highlighted()` and `filledLines()`; verify existing Home tests still pass with `bun test test/home-tui.test.ts`. +- [x] 2.2 Add a `warnChip` helper that builds the warning fill + ink chunk (`bg(warning)(fg(warningInk)(value))`); verify it via the fold tests in group 3. +- [x] 2.3 Apply the chip to the fold's warning facts in `inlineDetailLines` (managed writer, unknown working-tree dirt, unknown changes); verify the selected-row fold shows the value on the warning fill with `warningInk` text. +- [x] 2.4 Apply the chip to the detail zone's non-known linked-PR value in `detailLines`, and leave the detail's plain observations on yellow ink; verify via the tests in group 3. + +## 3. Tests + +- [x] 3.1 Replace the "zone's PR warning stays yellow on the accent fill" expectation in `test/home-tui.test.ts` with the chip contract (warning fill background + `warningInk` foreground); verify with `bun test test/home-tui.test.ts`. +- [x] 3.2 Add a fold scenario asserting a live/uncertain managed writer value renders on the warning chip; verify with `bun test test/home-tui.test.ts`. +- [x] 3.3 Add coverage that an unknown dirt or changes value in the fold renders on the warning chip, and that a warning on a plain detail fact keeps yellow ink; verify with `bun test test/home-tui.test.ts`. + +## 4. Verification + +- [x] 4.1 Run `bun run typecheck` and the full `bun test` suite; verify both pass with no failures. +- [x] 4.2 Confirm in a rendered Home frame (or captured spans) that the writer value is legible as a chip under the dark, light, and neutral palettes and that non-warning facts are unchanged. diff --git a/openspec/specs/home-launcher/spec.md b/openspec/specs/home-launcher/spec.md index 1428af2..a7ac9a9 100644 --- a/openspec/specs/home-launcher/spec.md +++ b/openspec/specs/home-launcher/spec.md @@ -231,3 +231,32 @@ Home SHALL supply its current on-demand PR evidence to the shared worktree indic - **WHEN** the operator lands again on a previously observed merged worktree and the new lookup is pending or resolves unknown - **THEN** Home uses the ordinary worktree color rules until it has a known merged observation again + +### Requirement: Warning observations stay legible on filled surfaces + +A warning observation rendered on a filled surface SHALL render as a filled chip: the warning color becomes the chip's background and a contrasting ink carries its text, so the warning stays visible and its value stays legible against the surrounding fill. A warning observation rendered on a plain, unfilled surface SHALL retain its yellow ink. The warning chip's fill and ink SHALL be chosen per palette so the ink contrasts with the fill and the fill remains distinguishable from the accent fill. In particular, the selected row's inline detail (a managed writer's live or uncertain liveness, an unknown working-tree dirt observation, an unknown changes observation) and the worktree detail's filled identity zone (a linked-PR observation that is not known) SHALL use the chip, while the detail's remaining plain observations SHALL keep yellow ink. The conditions that raise a warning and the independence of the underlying observations SHALL NOT change, and no additional observation query SHALL be introduced. + +#### Scenario: Live writer in the selected row's inline detail + +- **WHEN** a selected worktree holds a live or uncertain managed writer claim and its inline detail renders on the accent fill +- **THEN** the writer value renders on the warning chip fill with contrasting ink, legible against the accent fill + +#### Scenario: Unknown observation in the selected row's inline detail + +- **WHEN** the selected worktree's working-tree dirt or changes observation is unknown +- **THEN** the unknown value renders on the warning chip fill with contrasting ink instead of yellow ink on the accent fill + +#### Scenario: Unavailable linked PR in the detail's filled zone + +- **WHEN** the worktree detail's filled identity zone shows a linked-PR observation that is not known +- **THEN** the value renders on the warning chip fill with contrasting ink + +#### Scenario: Plain facts keep yellow ink + +- **WHEN** a warning observation renders outside any filled surface, among the detail's remaining plain facts +- **THEN** it keeps its yellow ink and does not render as a chip + +#### Scenario: Warning chip contrasts across palettes + +- **WHEN** the warning chip renders under the dark, light, or neutral palette +- **THEN** its ink contrasts legibly with its fill and its fill remains distinguishable from the accent fill diff --git a/src/home-tui.ts b/src/home-tui.ts index 737aa38..ef1a365 100644 --- a/src/home-tui.ts +++ b/src/home-tui.ts @@ -1831,9 +1831,9 @@ export class HomeLauncher { const used = chunks.reduce((total, chunk) => total + displayWidth(typeof chunk === "string" ? chunk : (chunk as { text: string }).text), 0) const filler = bg(theme.accent)(fg(theme.chipText)(" ".repeat(Math.max(0, width - used)))) // Chunks that already carry a background keep it — the state-color rail - // cell rides the accent fill instead of being repainted by it. - const hasBg = (chunk: TextChunk) => typeof chunk !== "string" && (chunk as { bg?: unknown }).bg !== undefined - return new StyledText(chunks.map((chunk) => (hasBg(chunk) ? chunk : bg(theme.accent)(chunk))).concat(filler)) + // cell rides the accent fill instead of being repainted by it, and a + // warning chip keeps its own fill. + return new StyledText(chunks.map((chunk) => withFill(chunk, theme.accent)).concat(filler)) } private previewContent(width: number): StyledText { @@ -1866,7 +1866,7 @@ export class HomeLauncher { const line = (chunks: TextChunk[]) => this.highlighted([rail, ...chunks], width) const lines: StyledText[] = [] const fact = (label: string, value: string, warn = false) => - lines.push(line([raw(indent), fg(theme.chipText)(label.padEnd(9, " ")), raw(" "), fg(warn ? theme.yellow : theme.chipText)(truncate(value, Math.max(8, w - 10)))])) + lines.push(line([raw(indent), fg(theme.chipText)(label.padEnd(9, " ")), raw(" "), warn ? warnChip(truncate(value, Math.max(8, w - 10))) : fg(theme.chipText)(truncate(value, Math.max(8, w - 10)))])) if (row.kind === "worktree") { const worktree = row.worktree fact("branch", worktree.detached ? "detached HEAD" : (worktree.branch ?? "(no branch)")) @@ -1974,8 +1974,9 @@ export class HomeLauncher { ] // The zone's fact-row rhythm: a nine-column label, one space, the honest // value. - const zoneFact = (label: string, value: string, color = theme.chipText) => { - zoneRows.push([fg(theme.chipText)(label.padEnd(9, " ")), raw(" "), fg(color)(truncate(value, Math.max(8, width - 11)))]) + const zoneFact = (label: string, value: string, warn = false) => { + const text = truncate(value, Math.max(8, width - 11)) + zoneRows.push([fg(theme.chipText)(label.padEnd(9, " ")), raw(" "), warn ? warnChip(text) : fg(theme.chipText)(text)]) } zoneFact("branch", worktree.detached ? "detached HEAD" : (worktree.branch ?? "(no branch)")) // PR evidence rides the same on-demand observation the row fired on @@ -1983,9 +1984,9 @@ export class HomeLauncher { // "no PR" and a merged PR never reads as completed work. const prEvidence = this.prEvidence.get(worktree.path) if (prEvidence && prEvidence !== "checking") { - zoneFact("pr", prObservationText(prEvidence), prEvidence.availability === "known" ? theme.chipText : theme.yellow) + zoneFact("pr", prObservationText(prEvidence), prEvidence.availability !== "known") } else { - zoneFact("pr", "checking…", theme.chipText) + zoneFact("pr", "checking…") } zoneRows.push([]) lines.push(...filledLines(zoneRows, width, theme.accent)) @@ -2341,6 +2342,30 @@ function slugFromName(name: string): string { ) } +/** Whether a chunk already carries its own background. */ +function hasBackground(chunk: TextChunk): boolean { + return typeof chunk !== "string" && (chunk as { bg?: unknown }).bg !== undefined +} + +/** + * Paints a chunk with a surface fill unless it already carries its own + * background, so a chip riding that surface (a warning value's amber) is not + * repainted by the surface it sits on. + */ +function withFill(chunk: TextChunk, fill: string): TextChunk { + return hasBackground(chunk) ? chunk : bg(fill)(chunk) +} + +/** + * A warning value riding a filled surface: the warning color becomes the + * chip's fill and a contrasting ink carries the text, so the warning stays + * visible where yellow ink on the fill would be illegible. On a plain surface + * a warning keeps its yellow ink instead. + */ +function warnChip(value: string): TextChunk { + return bg(theme.warning)(fg(theme.warningInk)(value)) +} + /** * A filled well: each row is a full-width painted strip. The span runs two * columns past the text column — one into each padding gutter — so the fill @@ -2355,7 +2380,7 @@ function filledLines(rows: TextChunk[][], width: number, fill: string): StyledTe if (row.length === 0) return new StyledText([bg(fill)(fg(fill)(" ".repeat(span)))]) const used = insetW + row.reduce((total, chunk) => total + displayWidth(typeof chunk === "string" ? chunk : (chunk as { text: string }).text), 0) const pad = Math.max(0, span - used) - return new StyledText([bg(fill)(fg(fill)(" ".repeat(insetW))), ...row.map((chunk) => bg(fill)(chunk)), bg(fill)(fg(fill)(" ".repeat(pad)))]) + return new StyledText([bg(fill)(fg(fill)(" ".repeat(insetW))), ...row.map((chunk) => withFill(chunk, fill)), bg(fill)(fg(fill)(" ".repeat(pad)))]) }) } diff --git a/src/tui-theme.ts b/src/tui-theme.ts index 3799418..a323661 100644 --- a/src/tui-theme.ts +++ b/src/tui-theme.ts @@ -29,9 +29,23 @@ export type Palette = { faint: string /** Text drawn on top of colored chips (selected permission buttons). */ chipText: string -} - -export type PaletteColor = Exclude + /** + * The warning chip's fill, painted behind a warning value that rides a + * filled surface (the selected row's fold, the detail's identity zone). + * Deliberately the amber across palettes: a warning fill is a solid block, + * so its own hue carries it, and the same amber clears contrast with its + * ink in every palette — unlike `yellow`, which is tuned as ink on the + * canvas and is too dark to serve as a fill on a light background. + */ + warning: string + /** + * Text drawn on the warning chip. Never `chipText`: that is near-white on + * the light palette and fails against the amber fill. + */ + warningInk: string +} + +export type PaletteColor = Exclude const darkPalette: Palette = { bg: "transparent", @@ -52,6 +66,8 @@ const darkPalette: Palette = { dim: "#565F89", faint: "#3B4261", chipText: "#0A0E1A", + warning: "#E0AF68", + warningInk: "#0A0E1A", } const lightPalette: Palette = { @@ -73,6 +89,8 @@ const lightPalette: Palette = { dim: "#6172B0", faint: "#9DA3C2", chipText: "#E1E2E7", + warning: "#E0AF68", + warningInk: "#0A0E1A", } // When the terminal never answers the background query there is nothing safe @@ -97,6 +115,8 @@ const neutralPalette: Palette = { dim: "#7A7A7A", faint: "#616161", chipText: "#000000", + warning: "#E0AF68", + warningInk: "#0A0E1A", } // Module-level on purpose: one TUI exists per convoy process, and a mutable diff --git a/test/home-tui.test.ts b/test/home-tui.test.ts index 015e0f1..3fda862 100644 --- a/test/home-tui.test.ts +++ b/test/home-tui.test.ts @@ -43,7 +43,7 @@ import type { PrObservation } from "../src/pr-observations" import type { BoardSource } from "../src/board-refresh" import type { BoardSnapshot } from "../src/board-cache" import type { LocalActiveChange } from "../src/checkout-openspec" -import { theme } from "../src/tui-theme" +import { paletteForMode, setTheme, theme } from "../src/tui-theme" import { versionDetails } from "../src/version" import type { BoardWorktree } from "../src/control-board" @@ -769,12 +769,13 @@ describe("worktree detail sections and observations", () => { try { await openDetail(session) const spans = session.captureSpans() - // The folder basename, path, branch, and the linked PR share one accent - // fill; the rest of the observed facts stay plain. + // The folder basename, path, branch, and the linked PR share one filled + // zone — the PR's unavailable value rides its own warning chip inside it + // — while the rest of the observed facts stay plain. for (const needle of ["add-widget", "/wt/add-widget", "feat/add-widget", "pr"]) { const row = spans.lines.find((line) => line.spans.some((span) => span.text.includes(needle)))! expect(row).toBeDefined() - const filled = row.spans.filter((span) => span.bg.a > 0 && sameColor(span.bg, accentBg())) + const filled = row.spans.filter((span) => span.bg.a > 0 && (sameColor(span.bg, accentBg()) || sameColor(span.bg, paletteColor(theme.warning)))) const filledWidth = filled.reduce((total, span) => total + span.text.length, 0) expect(filledWidth).toBeGreaterThan(60) } @@ -791,7 +792,7 @@ describe("worktree detail sections and observations", () => { } }) - test("the zone's PR warning stays yellow on the accent fill", async () => { + test("the zone's PR warning rides the warning chip on the accent fill", async () => { const session = await openHome({ height: 60 }) try { await openDetail(session) @@ -800,9 +801,10 @@ describe("worktree detail sections and observations", () => { const row = spans.lines.find((line) => line.spans.some((span) => span.text.includes(reason)))! expect(row).toBeDefined() const warning = row.spans.find((span) => span.text.includes(reason))! - // The warning keeps its own color on the accent fill, and the row still - // rides the zone. - expect(sameColor(warning.fg, paletteColor(theme.yellow))).toBe(true) + // The value rides the warning fill with the chip ink, so it stays legible + // on the accent fill; the label keeps the ordinary chip text. + expect(sameColor(warning.bg, paletteColor(theme.warning))).toBe(true) + expect(sameColor(warning.fg, paletteColor(theme.warningInk))).toBe(true) expect(row.spans.some((span) => span.bg.a > 0 && sameColor(span.bg, accentBg()))).toBe(true) const label = row.spans.find((span) => span.text.trimEnd() === "pr")! expect(sameColor(label.fg, paletteColor(theme.chipText))).toBe(true) @@ -811,6 +813,125 @@ describe("worktree detail sections and observations", () => { } }) + test("a live managed writer value rides the warning chip in the selected row's fold", async () => { + const busy = worktree({ + path: "/wt/writing-fold", + branch: "feat/writing-fold", + writer: { + kind: "known", + value: { kind: "authoring", owner: "ses_f74d79c70ffeW9TrnXMN1pHBjq", pid: 4242, startedAt: Date.now() - 300_000, heartbeatAt: Date.now(), liveness: "live" }, + collectedAt: 0, + }, + }) + const session = await openHome({ height: 48, worktrees: [worktree({ path: mainPath, branch: "main", main: true }), busy] }) + try { + session.press("down") // New leads the list + await session.renderOnce() + session.press("down") // the writing worktree row — its fold unfolds + await session.renderOnce() + const spans = session.captureSpans() + const row = spans.lines.find((line) => line.spans.some((span) => span.text.trimEnd() === "writer"))! + expect(row).toBeDefined() + const value = row.spans.find((span) => span.text.includes("authoring"))! + expect(sameColor(value.bg, paletteColor(theme.warning))).toBe(true) + expect(sameColor(value.fg, paletteColor(theme.warningInk))).toBe(true) + } finally { + await closeHome(session) + } + }) + + test("an unknown dirt value rides the warning chip in the selected row's fold", async () => { + const unknownDirt = worktree({ + path: "/wt/unknown-dirt", + branch: "feat/unknown-dirt", + dirt: { kind: "unknown", reason: "the working tree could not be read", collectedAt: 0 }, + }) + const session = await openHome({ height: 48, worktrees: [worktree({ path: mainPath, branch: "main", main: true }), unknownDirt] }) + try { + session.press("down") // New leads the list + await session.renderOnce() + session.press("down") // the row whose dirt is unknown — its fold unfolds + await session.renderOnce() + const spans = session.captureSpans() + const row = spans.lines.find((line) => line.spans.some((span) => span.text.trimEnd() === "state"))! + expect(row).toBeDefined() + const value = row.spans.find((span) => span.text.includes("unknown"))! + expect(sameColor(value.bg, paletteColor(theme.warning))).toBe(true) + expect(sameColor(value.fg, paletteColor(theme.warningInk))).toBe(true) + } finally { + await closeHome(session) + } + }) + + test("a warning on a plain detail fact keeps its yellow ink", async () => { + const busy = worktree({ + path: "/wt/writing-plain", + branch: "feat/writing-plain", + writer: { + kind: "known", + value: { kind: "authoring", owner: "ses_f74d79c70ffeW9TrnXMN1pHBjq", pid: 4242, startedAt: Date.now() - 300_000, heartbeatAt: Date.now(), liveness: "live" }, + collectedAt: 0, + }, + }) + const session = await openHome({ height: 48, worktrees: [worktree({ path: mainPath, branch: "main", main: true }), busy] }) + try { + session.press("down") // New leads the list + await session.renderOnce() + session.press("down") + await session.renderOnce() + session.press("return") // open the detail: the writer fact is now plain + await session.renderOnce() + const spans = session.captureSpans() + const row = spans.lines.find((line) => line.spans.some((span) => span.text.includes("authoring")))! + expect(row).toBeDefined() + const value = row.spans.find((span) => span.text.includes("authoring"))! + expect(value.bg.a).toBe(0) + expect(sameColor(value.fg, paletteColor(theme.yellow))).toBe(true) + } finally { + await closeHome(session) + } + }) + + test("the writer chip renders with each palette's warning colors", async () => { + const original = theme + try { + for (const mode of ["dark", "light", null] as const) { + const palette = paletteForMode(mode) + setTheme(palette) + const busy = worktree({ + path: "/wt/palette-writer", + branch: "feat/palette-writer", + writer: { + kind: "known", + value: { kind: "authoring", owner: "ses_f74d79c70ffeW9TrnXMN1pHBjq", pid: 4242, startedAt: Date.now() - 300_000, heartbeatAt: Date.now(), liveness: "live" }, + collectedAt: 0, + }, + }) + const session = await openHome({ height: 48, worktrees: [worktree({ path: mainPath, branch: "main", main: true }), busy] }) + try { + session.press("down") // New leads the list + await session.renderOnce() + session.press("down") // the writing row — its fold unfolds + await session.renderOnce() + const spans = session.captureSpans() + const row = spans.lines.find((line) => line.spans.some((span) => span.text.trimEnd() === "writer"))! + expect(row).toBeDefined() + const value = row.spans.find((span) => span.text.includes("authoring"))! + // The warning value rides the palette's own warning fill and ink ... + expect(sameColor(value.bg, paletteColor(palette.warning))).toBe(true) + expect(sameColor(value.fg, paletteColor(palette.warningInk))).toBe(true) + // ... while a non-warning chunk on the same fill keeps the chip text. + const label = row.spans.find((span) => span.text.trimEnd() === "writer")! + expect(sameColor(label.fg, paletteColor(palette.chipText))).toBe(true) + } finally { + await closeHome(session) + } + } + } finally { + setTheme(original) + } + }) + test("the Runs empty line sits flush with its section heading", async () => { const session = await openHome({ height: 60 }) try { diff --git a/test/tui-theme.test.ts b/test/tui-theme.test.ts index 09c73d2..4318689 100644 --- a/test/tui-theme.test.ts +++ b/test/tui-theme.test.ts @@ -7,6 +7,30 @@ import { displayWidth, formatAgo, formatCount, formatElapsed, formatMoney, forma // real reply but degrade to undefined (→ static palettes) on any shape change. const fakeRenderer = (themeModeState: unknown) => ({ themeModeState }) as unknown as CliRenderer +/** WCAG relative luminance of a #rrggbb color. */ +function luminance(hex: string): number { + const channels = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255) + const linear = channels.map((channel) => (channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4))) + return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]! +} + +/** WCAG contrast ratio between two #rrggbb colors. */ +function contrastRatio(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) + return (hi! + 0.05) / (lo! + 0.05) +} + +/** + * Largest per-channel distance between two colors, in 0..1 — the separation a + * solid chip keeps from the fill it rides even when both hues are saturated + * and share a luminance. + */ +function maxChannelDelta(a: string, b: string): number { + const channels = (hex: string) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)) + const [left, right] = [channels(a), channels(b)] + return Math.max(...left.map((value, index) => Math.abs(value - right[index]!))) / 255 +} + describe("palette derivation from the terminal background", () => { test("measures wide and combined graphemes in terminal cells", () => { expect(displayWidth("ascii")).toBe(5) @@ -87,6 +111,16 @@ describe("palette derivation from the terminal background", () => { expect(palette.bg).toBe("transparent") } }) + + // The warning chip carries its own ink on a filled surface, so the ink must + // clear AA against the chip fill in every palette, and the fill must stay + // separable from the accent it rides. + test("the warning chip's ink stays legible across every palette", () => { + for (const palette of [paletteForMode("dark"), paletteForMode("light"), paletteForMode(null)]) { + expect(contrastRatio(palette.warningInk, palette.warning)).toBeGreaterThanOrEqual(4.5) + expect(maxChannelDelta(palette.warning, palette.accent)).toBeGreaterThanOrEqual(0.25) + } + }) }) describe("padBetween", () => {