diff --git a/docs/skills/pr-review-decision.md b/docs/skills/pr-review-decision.md index 08c21df..a6db2bf 100644 --- a/docs/skills/pr-review-decision.md +++ b/docs/skills/pr-review-decision.md @@ -215,7 +215,7 @@ Time-ordered list with owner + dependency. Strike through items that completed a - Hyperlinks: full URLs, never bare `#11` ``` -Use full markdown links for every PR / commit reference: `[PR #11](https://github.com///pull/11)`, not `PR #11`. Same for table cells (`[#11](url)`) and English-prose mentions (`taken in [#12](url)`). To avoid nested-bracket bugs when running replace*all, scrub any pre-existing `[PR #11 - title](url)` style links to `[Pull request 11 — title](url)` \_first*, then replace_all `PR #11` → linked form. +Use full markdown links for every PR / commit reference: `[PR #11](https://github.com///pull/11)`, not `PR #11`. Same for table cells (`[#11](url)`) and English-prose mentions (`taken in [#12](url)`). To avoid nested-bracket bugs when running `replace_all`, scrub any pre-existing `[PR #11 - title](url)` style links to `[Pull request 11 — title](url)` first, then run `replace_all` on `PR #11` → linked form. ### Step 6 — Bilingual comment drafting diff --git a/src/tui/app.tsx b/src/tui/app.tsx index a811536..b6e021e 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -39,6 +39,7 @@ import { getTuiThemeNames, hasTuiTheme, mergeTuiThemes, + resolveTuiTranscriptRailColor, resolveTuiTheme, type StepCliTuiThemeColors, type StepCliTuiThemeName, @@ -1095,7 +1096,7 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { setStatus({ tone: "accent", label: "Theme", - detail: `Current ${themeName}. Available: ${availableThemesLabel}. Use /theme .`, + detail: `Current ${themeName}. Available: ${availableThemesLabel}. Use /theme or /theme ${DEFAULT_TUI_THEME_NAME} to recover the default theme.`, }); return true; } @@ -1104,7 +1105,7 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { setStatus({ tone: "warning", label: "Theme", - detail: `Unknown theme "${requestedThemeName}". Available: ${availableThemesLabel}.`, + detail: `Unknown theme "${requestedThemeName}". Available: ${availableThemesLabel}. Use /theme ${DEFAULT_TUI_THEME_NAME} to recover the default theme.`, }); return false; } @@ -1501,7 +1502,7 @@ const TranscriptPane = React.memo(function TranscriptPane(input: { {summaryLines.length > 0 ? ( - + {" "} SUMMARY{" "} @@ -2157,42 +2158,43 @@ function resolveTranscriptBadgeStyle( textColor: string; railColor: string; } { + const railColor = resolveTuiTranscriptRailColor(theme); switch (tone) { case "accent": return { backgroundColor: theme.userBadge, - textColor: theme.accent, - railColor: theme.accent, + textColor: theme.foreground, + railColor, }; case "brand": return { backgroundColor: theme.assistantBadge, - textColor: theme.brand, - railColor: theme.brand, + textColor: theme.foreground, + railColor, }; case "success": return { backgroundColor: theme.toolBadge, - textColor: theme.success, - railColor: theme.success, + textColor: theme.foreground, + railColor, }; case "warning": return { backgroundColor: theme.systemBadge, - textColor: theme.warning, - railColor: theme.warning, + textColor: theme.foreground, + railColor, }; case "danger": return { backgroundColor: theme.systemBadge, - textColor: theme.danger, - railColor: theme.danger, + textColor: theme.foreground, + railColor, }; case "muted": return { backgroundColor: theme.systemBadge, - textColor: theme.muted, - railColor: theme.line, + textColor: theme.foreground, + railColor, }; } } diff --git a/src/tui/theme.test.ts b/src/tui/theme.test.ts new file mode 100644 index 0000000..0c89a2b --- /dev/null +++ b/src/tui/theme.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { getBuiltinTuiThemes, resolveTuiTranscriptRailColor } from "./theme.js"; + +function hexToRgb(hex: string): [number, number, number] { + const normalized = hex.trim().replace(/^#/, ""); + const expanded = + normalized.length === 3 + ? normalized + .split("") + .map((character) => `${character}${character}`) + .join("") + : normalized.slice(0, 6); + + return [ + Number.parseInt(expanded.slice(0, 2), 16) / 255, + Number.parseInt(expanded.slice(2, 4), 16) / 255, + Number.parseInt(expanded.slice(4, 6), 16) / 255, + ]; +} + +function linearize(channel: number): number { + return channel <= 0.03928 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(hex: string): number { + const [red, green, blue] = hexToRgb(hex).map(linearize); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function contrastRatio(left: string, right: string): number { + const [lighter, darker] = [ + relativeLuminance(left), + relativeLuminance(right), + ].sort((first, second) => second - first); + return (lighter + 0.05) / (darker + 0.05); +} + +describe("builtin TUI themes", () => { + const themes = getBuiltinTuiThemes(); + + it("keeps foreground and muted text readable on core surfaces", () => { + for (const theme of themes) { + expect( + contrastRatio(theme.colors.foreground, theme.colors.canvas), + `${theme.name} foreground vs canvas`, + ).toBeGreaterThanOrEqual(7); + expect( + contrastRatio(theme.colors.foreground, theme.colors.panel), + `${theme.name} foreground vs panel`, + ).toBeGreaterThanOrEqual(7); + expect( + contrastRatio(theme.colors.foreground, theme.colors.inputBackground), + `${theme.name} foreground vs inputBackground`, + ).toBeGreaterThanOrEqual(7); + expect( + contrastRatio(theme.colors.muted, theme.colors.canvas), + `${theme.name} muted vs canvas`, + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(theme.colors.muted, theme.colors.panel), + `${theme.name} muted vs panel`, + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(theme.colors.foreground, theme.colors.selection), + `${theme.name} foreground vs selection`, + ).toBeGreaterThanOrEqual(4.5); + } + }); + + it("keeps transcript rails and badges visible against panel surfaces", () => { + for (const theme of themes) { + const transcriptRailColor = resolveTuiTranscriptRailColor(theme.colors); + + expect( + contrastRatio(transcriptRailColor, theme.colors.panel), + `${theme.name} transcript rail vs panel`, + ).toBeGreaterThanOrEqual(3); + expect( + contrastRatio(transcriptRailColor, theme.colors.panelAlt), + `${theme.name} transcript rail vs panelAlt`, + ).toBeGreaterThanOrEqual(3); + expect( + contrastRatio(transcriptRailColor, theme.colors.inputBackground), + `${theme.name} transcript rail vs inputBackground`, + ).toBeGreaterThanOrEqual(3); + expect( + contrastRatio(theme.colors.foreground, theme.colors.assistantBadge), + `${theme.name} badge foreground vs assistantBadge`, + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(theme.colors.foreground, theme.colors.userBadge), + `${theme.name} badge foreground vs userBadge`, + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(theme.colors.foreground, theme.colors.toolBadge), + `${theme.name} badge foreground vs toolBadge`, + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(theme.colors.foreground, theme.colors.systemBadge), + `${theme.name} badge foreground vs systemBadge`, + ).toBeGreaterThanOrEqual(4.5); + } + }); +}); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index a6005f0..c961c9a 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -48,7 +48,7 @@ export type StepCliTuiThemeName = string; const BUILTIN_TUI_THEME_COLORS = { default: { foreground: "#eef6ff", - muted: "#7d93ab", + muted: "#8eaac8", accent: "#62d8ff", brand: "#4da3ff", success: "#58d6a6", @@ -59,7 +59,7 @@ const BUILTIN_TUI_THEME_COLORS = { panelAlt: "#112033", inputBackground: "#1b2431", selection: "#173556", - line: "#234462", + line: "#688fb1", assistantBadge: "#102846", userBadge: "#0b223d", toolBadge: "#0d2b36", @@ -67,7 +67,7 @@ const BUILTIN_TUI_THEME_COLORS = { }, sage: { foreground: "#eef4df", - muted: "#818274", + muted: "#a7b7a2", accent: "#bde038", brand: "#a3ab78", success: "#bde038", @@ -78,7 +78,7 @@ const BUILTIN_TUI_THEME_COLORS = { panelAlt: "#16515c", inputBackground: "#18363b", selection: "#245258", - line: "#506266", + line: "#8ca7aa", assistantBadge: "#1a3940", userBadge: "#17343a", toolBadge: "#233d35", @@ -97,7 +97,7 @@ const BUILTIN_TUI_THEME_COLORS = { panelAlt: "#2b1d3d", inputBackground: "#251c35", selection: "#382353", - line: "#5a4675", + line: "#8874b0", assistantBadge: "#2a1846", userBadge: "#113a37", toolBadge: "#3c3712", @@ -116,7 +116,7 @@ const BUILTIN_TUI_THEME_COLORS = { panelAlt: "#0c2a62", inputBackground: "#0f2446", selection: "#163972", - line: "#1c4d8e", + line: "#5a8fda", assistantBadge: "#0a2752", userBadge: "#0a3448", toolBadge: "#093b3b", @@ -135,7 +135,7 @@ const BUILTIN_TUI_THEME_COLORS = { panelAlt: "#0c1a2d", inputBackground: "#102033", selection: "#18304d", - line: "#35516e", + line: "#607d9d", assistantBadge: "#0a1c32", userBadge: "#0e2739", toolBadge: "#122631", @@ -211,6 +211,14 @@ export function resolveTuiTheme( ); } +export function resolveTuiTranscriptRailColor( + colors: StepCliTuiThemeColors, +): string { + // Transcript rails are structural markers, so keep them tied to the + // contrast-checked line color instead of tone colors that may be too dark. + return colors.line; +} + export function isValidTuiThemeName(value: string): boolean { return /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i.test(value.trim()); }