feat(ui): terminal, charts and low-contrast text follow the selected theme

- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
This commit is contained in:
2026-09-24 09:50:44 +07:00
parent 6353e88c9e
commit 8ea40260ad
36 changed files with 919 additions and 635 deletions
@@ -0,0 +1,71 @@
/**
* @file terminalTheme.test.ts
* @description Tests for the xterm theme builder: CSS-variable RGB triplets
* become hex, status/accent map to the ANSI hues, bright twins are lighter,
* and the probe resolves a named dark theme's variables.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { buildTerminalTheme, terminalThemeFor } from "../terminalTheme";
const vars: Record<string, string> = {
"--surface-0": " 17 17 19",
"--surface-3": "39 42 45",
"--fg-primary": "237 238 240",
"--fg-secondary": "176 180 186",
"--fg-muted": "105 110 119",
"--border-light": "67 72 78",
"--accent": "0 144 255",
"--status-success": "61 214 140",
"--status-danger": "255 149 146",
"--status-warning": "255 202 22",
};
describe("buildTerminalTheme", () => {
it("maps theme tokens onto the xterm palette", () => {
const t = buildTerminalTheme((n) => vars[n] ?? "");
expect(t.background).toBe("#111113");
expect(t.foreground).toBe("#edeef0");
expect(t.cursor).toBe("#edeef0");
expect(t.selectionBackground).toBe("#43484e");
expect(t.red).toBe("#ff9592");
expect(t.green).toBe("#3dd68c");
expect(t.yellow).toBe("#ffca16");
expect(t.blue).toBe("#0090ff");
expect(t.black).toBe("#43484e");
expect(t.brightBlack).toBe("#696e77");
expect(t.brightWhite).toBe("#edeef0");
});
it("makes each bright twin lighter than its base colour", () => {
const t = buildTerminalTheme((n) => vars[n] ?? "");
const sum = (hex: string) =>
[1, 3, 5].reduce((a, i) => a + parseInt(hex.slice(i, i + 2), 16), 0);
for (const c of ["red", "green", "yellow", "blue", "magenta", "cyan"] as const) {
const base = t[c]!;
const bright = t[`bright${c[0]!.toUpperCase()}${c.slice(1)}` as keyof typeof t] as string;
expect(sum(bright)).toBeGreaterThan(sum(base));
}
});
it("leaves a colour unset when its variable is missing, so xterm keeps its default", () => {
const t = buildTerminalTheme(() => "");
expect(t.background).toBeUndefined();
expect(t.red).toBeUndefined();
});
});
describe("terminalThemeFor", () => {
it("resolves variables on a dark probe scoped to the theme id, then removes it", () => {
const style = document.createElement("style");
style.textContent = `.dark { --surface-0: 1 2 3; } .dark[data-theme="darcula"] { --surface-0: 30 31 32; }`;
document.head.appendChild(style);
try {
expect(terminalThemeFor("default").background).toBe("#010203");
expect(terminalThemeFor("darcula").background).toBe("#1e1f20");
expect(document.body.children).toHaveLength(0);
} finally {
style.remove();
}
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* @file terminalTheme.ts
* @description The xterm colour theme for lane terminals. The terminal is
* ALWAYS dark — Claude Code's TUI picks its own colours for a dark background,
* so a light terminal would make its text unreadable — and is painted with the
* palette of the DARK theme the user picked (`themeFor.dark`). Colours are read
* from the CSS variables in `index.css` on a hidden probe carrying
* `class="dark" data-theme=<id>`, so no hex is duplicated here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ITheme } from "@xterm/xterm";
/** Reads one CSS variable (an RGB triplet like `17 17 19`) by name. */
export type VarReader = (name: string) => string;
function toHex(triplet: string): string | undefined {
const parts = triplet
.trim()
.split(/[\s,]+/)
.map(Number);
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return undefined;
return "#" + parts.map((n) => Math.round(n).toString(16).padStart(2, "0")).join("");
}
/** Mix a hex colour toward white by `amount` (0..1) — the ANSI "bright" twin. */
function lighten(hex: string | undefined, amount: number): string | undefined {
if (!hex) return undefined;
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return (
"#" +
rgb
.map((c) =>
Math.round(c + (255 - c) * amount)
.toString(16)
.padStart(2, "0")
)
.join("")
);
}
/**
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
* theme (darcula's keyword is orange), and a program printing "magenta" should
* get magenta.
*/
export function buildTerminalTheme(read: VarReader): ITheme {
const v = (name: string) => toHex(read(name));
const red = v("--status-danger");
const green = v("--status-success");
const yellow = v("--status-warning");
const blue = v("--accent");
const magenta = "#c678dd";
const cyan = "#56b6c2";
return {
background: v("--surface-0"),
foreground: v("--fg-primary"),
cursor: v("--fg-primary"),
cursorAccent: v("--surface-0"),
selectionBackground: v("--border-light"),
black: v("--border-light"),
red,
green,
yellow,
blue,
magenta,
cyan,
white: v("--fg-secondary"),
brightBlack: v("--fg-muted"),
brightRed: lighten(red, 0.25),
brightGreen: lighten(green, 0.25),
brightYellow: lighten(yellow, 0.25),
brightBlue: lighten(blue, 0.25),
brightMagenta: lighten(magenta, 0.25),
brightCyan: lighten(cyan, 0.25),
brightWhite: v("--fg-primary"),
};
}
/** Resolve the dark theme `id`'s variables on a hidden probe and build the theme. */
export function terminalThemeFor(id: string): ITheme {
const probe = document.createElement("div");
probe.className = "dark";
if (id !== "default") probe.dataset.theme = id;
probe.style.display = "none";
document.body.appendChild(probe);
try {
const style = getComputedStyle(probe);
return buildTerminalTheme((name) => style.getPropertyValue(name));
} finally {
probe.remove();
}
}
+9 -9
View File
@@ -2805,7 +2805,7 @@ export const STATUS_CONFIG: Record<
// Green: the agent is actively running a tool / doing work.
working: {
labelKey: "common:status.working",
color: "text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
},
@@ -2813,21 +2813,21 @@ export const STATUS_CONFIG: Record<
// on user input - attention may be required.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
},
// Violet: the agent finished cleanly.
completed: {
labelKey: "common:status.completed",
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
},
// Red: the agent ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-400",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
},
@@ -3013,28 +3013,28 @@ export const SESSION_STATUS_CONFIG: Record<
// Green: the session is live and running.
active: {
labelKey: "common:status.active",
color: "text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
},
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
},
// Violet: the session ended cleanly (SessionEnd).
completed: {
labelKey: "common:status.completed",
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
},
// Red: the session ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-400",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
},
@@ -3044,7 +3044,7 @@ export const SESSION_STATUS_CONFIG: Record<
// Muted slate distinguishes "given up / faded out" from yellow Waiting
// (attention required).
labelKey: "common:status.abandoned",
color: "text-slate-400",
color: "text-slate-600 dark:text-slate-400",
bg: "bg-slate-500/10 border-slate-500/20",
dot: "bg-slate-400",
},