Skip to content

Commit ae1055b

Browse files
committed
fix(tui): render light theme with readable roles
1 parent c9a643e commit ae1055b

21 files changed

Lines changed: 783 additions & 365 deletions

‎docs/TUI.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,10 @@ titlebar row: the model label sits right-aligned in the top rule as
134134
`profile · model · effort · mode` (empty segments omitted), and a
135135
compact `mcp !` sits immediately left of it when any MCP server still needs
136136
authorization (`/mcp` is the surface that names them), painted in
137-
`UI.warning` (sand, `#d1ad7d`) — the same role `plugin !` uses. Orange is
138-
not spent on these standing marks. The brand
137+
`UI.warning` — the same role `plugin !` uses. Warning is caution gold in the
138+
dark theme and muted plum (`#655275`) in the light theme; a bronze warning on
139+
cream is perceptually indistinguishable from machine emphasis, while plum stays
140+
separate without spending action orange or failure red. The brand
139141
lockup sits at the left of the bottom rule with the working directory and git
140142
branch at its right (`AppShell.promptTopRule` / `promptBottomRule`,
141143
`src/tui/shell/internals.ts`). Context occupancy rides that bottom rule as a percent:
@@ -239,6 +241,13 @@ Ongoing, non-decision status uses the bronze/sand/ember chrome ramp and green
239241
The one deliberate exception is diff removals, where orange is content (the
240242
removed line), not a decision marker, and no decision-marker shares that row.
241243

244+
The shell selects its final theme before constructing or mounting renderables.
245+
An explicit `light` or `dark` setting wins; otherwise `COLORFGBG` wins when it
246+
identifies the terminal background, then macOS appearance is read synchronously,
247+
and unknown environments fall back to dark. The macOS read has a 500 ms process
248+
timeout. Production does not query OSC 11 or switch themes after mount, so the
249+
first frame cannot mix palettes across transcript, prompt, borders, or overlays.
250+
242251
## The live task list panel
243252

244253
**Parked pending rebuild.** `formatChromeZones` (`src/tui/chrome-state.ts`)
@@ -418,7 +427,7 @@ framed content in the shell, and their body is shaped rather than merely
418427
listed (`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the
419428
subject in the action color — the only Breakthrough Orange on the card. The
420429
overlay host border and title use calm dim chrome (`UI.textDim`); consequence
421-
impact in the description zone paints `UI.warning` (sand), not orange. A
430+
impact in the description zone paints `UI.warning`, not orange. A
422431
blank row separates the subject from context. Choices are deliberately small:
423432
each one is a bare, single-line action name (`Reject`, `Accept once`, the
424433
scope's label) with no consequence text folded into the row. A scope's hint

‎src/tui/diff-rows.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { toolCallRow } from "./diff";
1111
import { withTestRenderer, type Harness } from "./harness";
1212
import { appendStreamRow } from "./shell/chrome";
1313
import { createAppShell } from "./shell/index";
14-
import { DIFF_FG } from "./stream";
14+
import { UI } from "./theme";
1515
import { toolResultRow } from "./mcp-view";
1616

1717
const WIDE = { width: 100, height: 30 } as const;
@@ -78,8 +78,8 @@ describe("diff transcript rows", () => {
7878
const spans = spansWithHex(h);
7979
const del = spans.find((s) => s.text.includes("-") && s.text.length <= 2);
8080
const add = spans.find((s) => s.text.includes("+") && s.text.length <= 2);
81-
expect(del?.fg).toBe(DIFF_FG.del);
82-
expect(add?.fg).toBe(DIFF_FG.add);
81+
expect(del?.fg).toBe(UI.action);
82+
expect(add?.fg).toBe(UI.done);
8383
}, WIDE);
8484
});
8585

@@ -97,13 +97,13 @@ describe("diff transcript rows", () => {
9797
const changedAdded = spans.find((s) => s.text.includes("product(a,"));
9898
const shared = spans.filter((s) => s.text.includes("const"));
9999

100-
expect(changedRemoved?.fg).toBe(DIFF_FG.del);
101-
expect(changedAdded?.fg).toBe(DIFF_FG.add);
100+
expect(changedRemoved?.fg).toBe(UI.action);
101+
expect(changedAdded?.fg).toBe(UI.done);
102102
// Bold attribute distinguishes the changed tokens inside the line.
103103
expect(defined(changedRemoved).attributes).toBeGreaterThan(0);
104104
// "const" is shared by both sides, so it stays in the context tone.
105105
expect(shared.length).toBeGreaterThan(0);
106-
expect(shared.every((s) => s.fg === DIFF_FG.context)).toBe(true);
106+
expect(shared.every((s) => s.fg === UI.textDim)).toBe(true);
107107
}, WIDE);
108108
});
109109

‎src/tui/diff.test.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
toolCallRow,
1212
type DiffLine,
1313
} from "./diff.js";
14-
import { DIFF_FG, isDiffRow, isMarkdownRow } from "./stream.js";
14+
import { isDiffRow, isMarkdownRow } from "./stream.js";
15+
import { UI } from "./theme.js";
1516

1617
const textOf = (line: DiffLine): string => line.map((seg) => seg.text).join("");
1718

@@ -91,19 +92,19 @@ describe("renderDiff", () => {
9192
const delBody = defined(lines[0]).slice(2);
9293
const addBody = defined(lines[1]).slice(2);
9394
const delChanged = delBody
94-
.filter((s) => s.fg === DIFF_FG.del)
95+
.filter((s) => s.fg === UI.action)
9596
.map((s) => s.text)
9697
.join("");
9798
const addChanged = addBody
98-
.filter((s) => s.fg === DIFF_FG.add)
99+
.filter((s) => s.fg === UI.done)
99100
.map((s) => s.text)
100101
.join("");
101102
expect(delChanged).toContain("bar");
102103
expect(addChanged).toContain("baz");
103104
expect(delChanged).not.toContain("const");
104105
expect(addChanged).not.toContain("const");
105106
expect(
106-
delBody.some((s) => s.text.includes("const") && s.fg === DIFF_FG.context),
107+
delBody.some((s) => s.text.includes("const") && s.fg === UI.textDim),
107108
).toBe(true);
108109
});
109110

@@ -118,7 +119,7 @@ describe("renderDiff", () => {
118119
const lines = renderDiff("a b c", "a x c", 40);
119120
const changed = defined(lines[0])
120121
.slice(2)
121-
.filter((s) => s.fg === DIFF_FG.del)
122+
.filter((s) => s.fg === UI.action)
122123
.map((s) => s.text.trim())
123124
.filter(Boolean);
124125
expect(changed).toEqual(["b"]);
@@ -130,26 +131,26 @@ describe("renderDiff", () => {
130131
expect(
131132
defined(removed)
132133
.slice(1)
133-
.every((s) => s.fg === DIFF_FG.del),
134+
.every((s) => s.fg === UI.action),
134135
).toBe(true);
135136
expect(
136137
defined(added)
137138
.slice(1)
138-
.every((s) => s.fg === DIFF_FG.add),
139+
.every((s) => s.fg === UI.done),
139140
).toBe(true);
140141
});
141142

142143
test("context rows take the muted context tone", () => {
143144
const lines = renderDiff("a\nb", "a\nB", 40);
144-
expect(defined(lines[0]).every((s) => s.fg === DIFF_FG.context)).toBe(true);
145+
expect(defined(lines[0]).every((s) => s.fg === UI.textDim)).toBe(true);
145146
});
146147

147148
test("line-number column always uses the muted context tone", () => {
148149
const lines = renderDiff("a\nb", "a\nB", 40);
149150
expect(lines.map((line) => defined(line[0]).fg)).toEqual([
150-
DIFF_FG.context,
151-
DIFF_FG.context,
152-
DIFF_FG.context,
151+
UI.textDim,
152+
UI.textDim,
153+
UI.textDim,
153154
]);
154155
});
155156

‎src/tui/diff.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import { describeToolCall } from "./tool-formatter.js";
1616
// The one wrap implementation: a diff row soft-wraps by the same column rules
1717
// as every other row, so a wide glyph cannot overflow the gutter here alone.
1818
import { wrapRanges } from "./view/height.js";
19-
import { DIFF_FG, type StreamRow } from "./stream.js";
19+
import type { StreamRow } from "./stream.js";
20+
import { UI } from "./theme.js";
2021
import { toolArgsView } from "./tool-args.js";
2122

2223
export type DiffRowKind = "add" | "del" | "context";
@@ -139,9 +140,9 @@ const GUTTER: Record<DiffRowKind, string> = {
139140
};
140141

141142
function rowColor(kind: DiffRowKind): string {
142-
if (kind === "add") return DIFF_FG.add;
143-
if (kind === "del") return DIFF_FG.del;
144-
return DIFF_FG.context;
143+
if (kind === "add") return UI.done;
144+
if (kind === "del") return UI.action;
145+
return UI.textDim;
145146
}
146147

147148
// Attach each row's position in the old/new file before any collapsing, so a
@@ -227,7 +228,7 @@ export function wordDiffSegments(
227228
const m = other.length;
228229
while (i < n && j < m) {
229230
if (self[i] === other[j]) {
230-
out.push({ text: requireDiffLine(self, i), fg: DIFF_FG.context });
231+
out.push({ text: requireDiffLine(self, i), fg: UI.textDim });
231232
i++;
232233
j++;
233234
} else if (lcsCell(lcs, i + 1, j) >= lcsCell(lcs, i, j + 1)) {
@@ -351,7 +352,7 @@ export function renderDiff(
351352
? [
352353
{
353354
text: idx === 0 ? numCol : " ".repeat(numColWidth),
354-
fg: DIFF_FG.context,
355+
fg: UI.textDim,
355356
},
356357
]
357358
: []),
@@ -453,7 +454,7 @@ export function editDiffView(
453454
[
454455
{
455456
text: `… ${all.length - MAX_DIFF_LINES} more diff lines`,
456-
fg: DIFF_FG.context,
457+
fg: UI.textDim,
457458
},
458459
],
459460
]

‎src/tui/lockup.test.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { describe, expect, test } from "bun:test";
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { rgbToHex, type CapturedSpan } from "@opentui/core";
3+
import { defined } from "../../tests/helpers/defined.js";
24

35
import {
46
LOCKUP_FADE_MS,
@@ -13,7 +15,9 @@ import {
1315
STALL_BLINK_CYCLE_MS,
1416
type RampPhase,
1517
} from "./ramp";
16-
import { UI } from "./theme";
18+
import { withTestRenderer } from "./harness";
19+
import { createAppShell } from "./shell/index";
20+
import { corbitsLight, setTheme, UI } from "./theme";
1721

1822
const idle = (nowMs: number): LockupInput => ({
1923
nowMs,
@@ -40,6 +44,28 @@ const live = (
4044

4145
const still = (nowMs = 0) => lockupCells(idle(nowMs));
4246

47+
function colorHex(color: CapturedSpan["fg"]): string {
48+
return rgbToHex(color).toLowerCase().slice(0, 7);
49+
}
50+
51+
function luminance(hex: string): number {
52+
const channel = (offset: number): number => {
53+
const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
54+
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
55+
};
56+
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
57+
}
58+
59+
function contrast(foreground: string, background: string): number {
60+
const fg = luminance(foreground);
61+
const bg = luminance(background);
62+
return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05);
63+
}
64+
65+
afterEach(() => {
66+
setTheme("corbits-dark");
67+
});
68+
4369
describe("brand lockup", () => {
4470
test("idle is the wordmark alone", () => {
4571
const cells = still();
@@ -69,6 +95,32 @@ describe("brand lockup", () => {
6995
expect(lockupWidth(input)).toBe(lockupCells(input).length);
7096
});
7197

98+
test("light wordmark renders with readable paint-time chrome", async () => {
99+
setTheme("corbits-light");
100+
await withTestRenderer(async (h) => {
101+
const shell = createAppShell(h.renderer, {
102+
terminal: { columns: 80, rows: 24 },
103+
wireKeys: false,
104+
});
105+
try {
106+
await h.renderOnce();
107+
await h.renderOnce();
108+
const wordmark = h
109+
.captureSpans()
110+
.lines.flatMap((line) => line.spans)
111+
.find((span) => span.text.includes(LOCKUP_WORDMARK));
112+
const painted = defined(wordmark, "rendered wordmark span");
113+
const foreground = colorHex(painted.fg);
114+
const background = colorHex(painted.bg);
115+
expect(foreground).toBe(corbitsLight.textDim);
116+
expect(background).toBe(corbitsLight.ground);
117+
expect(contrast(foreground, background)).toBeGreaterThanOrEqual(4.5);
118+
} finally {
119+
shell.dispose();
120+
}
121+
});
122+
});
123+
72124
test("the wordmark stays chrome-dim", () => {
73125
for (const cell of still()) {
74126
expect(cell.fg).toBe(UI.textDim);

‎src/tui/lockup.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ export const LOCKUP_FADE_MS = 240;
4848
* Fade ramps, faintest first. A terminal has no alpha, so a transition steps
4949
* through the warm dim tones toward its resting tone instead of blending.
5050
*/
51-
const WORDMARK_FADE = [UI.textFaint, UI.textDim] as const;
52-
const PHASE_FADE = [UI.textFaint, UI.textDim, UI.text] as const;
51+
const wordmarkFade = (): readonly string[] => [UI.textFaint, UI.textDim];
52+
const phaseFade = (): readonly string[] => [UI.textFaint, UI.textDim, UI.text];
5353

5454
export interface LockupInput {
5555
readonly nowMs: number;
@@ -105,7 +105,7 @@ export function lockupCells(input: LockupInput): readonly MarkCell[] {
105105

106106
const progress = fadeProgress(input);
107107
const cells: MarkCell[] = [];
108-
const textTone = toneAt(live ? PHASE_FADE : WORDMARK_FADE, progress);
108+
const textTone = toneAt(live ? phaseFade() : wordmarkFade(), progress);
109109
for (const char of label) {
110110
cells.push({ char, fg: textTone });
111111
}

0 commit comments

Comments
 (0)