From b798342b5e02e18853cd95252d3f82dfee2b02e8 Mon Sep 17 00:00:00 2001 From: Sylvain Gougouzian Date: Mon, 13 Jul 2026 20:34:45 +0200 Subject: [PATCH] feat(tui): marine theme system with five ocean palettes Replace the generic themes with deep-sea, dawn, storm, lagoon and polar-night, plus nautical single-cell iconography (anchor, helm, swell spinner, wave dividers). deep-sea is the default; an unknown stored theme name falls back to it. --- src/__tests__/theme.test.ts | 91 +++++++++++++ src/tui/theme.tsx | 251 +++++++++++++++++------------------- 2 files changed, 209 insertions(+), 133 deletions(-) create mode 100644 src/__tests__/theme.test.ts diff --git a/src/__tests__/theme.test.ts b/src/__tests__/theme.test.ts new file mode 100644 index 0000000..29743b6 --- /dev/null +++ b/src/__tests__/theme.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { + getCurrentTheme, + getThemeNames, + getThemes, + setThemeByName, + symbols, + t, +} from "../tui/theme"; + +const REQUIRED_FG = [ + "primary", + "secondary", + "dim", + "accent", + "success", + "warning", + "error", + "thinking", + "muted", +] as const; +const REQUIRED_BG = [ + "header", + "panel", + "panelAlt", + "status", + "input", + "thinking", +] as const; + +const HEX = /^#[0-9a-fA-F]{6}$/; + +describe("Marine theme registry", () => { + it("ships exactly the five marine themes", () => { + expect(getThemeNames()).toEqual([ + "deep-sea", + "dawn", + "storm", + "lagoon", + "polar-night", + ]); + }); + + it("every theme fills the ThemeColors contract with valid hex", () => { + for (const theme of getThemes()) { + for (const key of REQUIRED_FG) { + expect(theme.colors.fg[key]).toMatch(HEX); + } + for (const key of REQUIRED_BG) { + expect(theme.colors.bg[key]).toMatch(HEX); + } + } + }); + + it("defaults to deep-sea", () => { + expect(getCurrentTheme().name).toBe("deep-sea"); + }); + + it("switches to a known theme", () => { + const theme = setThemeByName("lagoon"); + expect(theme?.name).toBe("lagoon"); + expect(getCurrentTheme().name).toBe("lagoon"); + // restore default for other tests + setThemeByName("deep-sea"); + }); + + it("falls back to deep-sea for an unknown (removed) theme name", () => { + setThemeByName("deep-sea"); + const result = setThemeByName("dracula"); + expect(result).toBeNull(); + // active theme is unchanged — the deep-sea default is retained + expect(getCurrentTheme().name).toBe("deep-sea"); + expect(t().fg.accent).toBe("#4dd0e1"); + }); +}); + +describe("Nautical symbols", () => { + it("uses single-cell nautical glyphs", () => { + expect([...symbols.bullet]).toHaveLength(1); + expect([...symbols.divider]).toHaveLength(1); + expect([...symbols.arrow]).toHaveLength(1); + expect([...symbols.gear]).toHaveLength(1); + }); + + it("has a multi-frame swell spinner", () => { + expect(symbols.spinner.length).toBeGreaterThan(1); + for (const frame of symbols.spinner) { + expect([...frame]).toHaveLength(1); + } + }); +}); diff --git a/src/tui/theme.tsx b/src/tui/theme.tsx index be85aac..265e818 100644 --- a/src/tui/theme.tsx +++ b/src/tui/theme.tsx @@ -25,129 +25,155 @@ export interface Theme { colors: ThemeColors; } +// Marine palettes. Semantic mapping is kept consistent across every theme: +// accent = sea/sky blue-teal · success = seaweed green · +// warning = lighthouse amber · error = coral red · thinking = bioluminescence const themes: Theme[] = [ { - name: "dark", + name: "deep-sea", colors: { fg: { - primary: "#e0e0e0", - secondary: "#8a8a8a", - dim: "#555555", - accent: "#45b7d1", - success: "#6bcb77", - warning: "#ffc93c", + primary: "#dceefb", + secondary: "#7fa8c9", + dim: "#3d5a73", + accent: "#4dd0e1", + success: "#6fcf97", + warning: "#f2c14e", error: "#ff6b6b", - thinking: "#a29bfe", - muted: "#636e72", + thinking: "#9d8cf0", + muted: "#567a94", }, bg: { - header: "#0d1117", - panel: "#161b22", - panelAlt: "#1c2333", - status: "#0d1117", - input: "#161b22", - thinking: "#1a1530", + header: "#061620", + panel: "#0a2230", + panelAlt: "#0e2c3d", + status: "#061620", + input: "#0a2230", + thinking: "#0c1e2e", }, }, }, { - name: "light", + name: "dawn", colors: { fg: { - primary: "#1a1a2e", - secondary: "#6c7a89", - dim: "#a0aec0", - accent: "#2b6cb0", - success: "#276749", - warning: "#b7791f", - error: "#c53030", - thinking: "#553c9a", - muted: "#718096", + primary: "#123047", + secondary: "#4a708c", + dim: "#9db8c9", + accent: "#0277bd", + success: "#2e8b57", + warning: "#b8860b", + error: "#c0392b", + thinking: "#5b4b9c", + muted: "#6d8ba0", }, bg: { - header: "#f7fafc", - panel: "#edf2f7", - panelAlt: "#e2e8f0", - status: "#f7fafc", - input: "#edf2f7", - thinking: "#eef0f7", + header: "#eef4f2", + panel: "#e3ecec", + panelAlt: "#d6e3e3", + status: "#eef4f2", + input: "#e3ecec", + thinking: "#e8eef2", }, }, }, { - name: "nord", + name: "storm", colors: { fg: { - primary: "#eceff4", - secondary: "#616e88", - dim: "#4c566a", - accent: "#88c0d0", - success: "#a3be8c", - warning: "#ebcb8b", - error: "#bf616a", - thinking: "#b48ead", - muted: "#7b88a1", + primary: "#e6ebef", + secondary: "#8b9caa", + dim: "#4a5a68", + accent: "#7fb3c9", + success: "#7fb891", + warning: "#f4a259", + error: "#e85d5d", + thinking: "#b0a4d4", + muted: "#6b7b89", }, bg: { - header: "#2e3440", - panel: "#3b4252", - panelAlt: "#434c5e", - status: "#2e3440", - input: "#3b4252", - thinking: "#353c4a", + header: "#12181d", + panel: "#1b242c", + panelAlt: "#232f39", + status: "#12181d", + input: "#1b242c", + thinking: "#18222b", }, }, }, { - name: "dracula", + name: "lagoon", colors: { fg: { - primary: "#f8f8f2", - secondary: "#6272a4", - dim: "#44475a", - accent: "#bd93f9", - success: "#50fa7b", - warning: "#f1fa8c", - error: "#ff5555", - thinking: "#ff79c6", - muted: "#8394a2", + primary: "#e0f7f4", + secondary: "#6fb3ab", + dim: "#2f5f5a", + accent: "#26c6da", + success: "#4dd6a0", + warning: "#ffd166", + error: "#ff7a6b", + thinking: "#b088e0", + muted: "#4f8a83", }, bg: { - header: "#282a36", - panel: "#2d303e", - panelAlt: "#343746", - status: "#282a36", - input: "#2d303e", - thinking: "#2a2d3b", + header: "#041c1f", + panel: "#06282b", + panelAlt: "#093437", + status: "#041c1f", + input: "#06282b", + thinking: "#08262a", }, }, }, { - name: "tokyonight", + name: "polar-night", colors: { fg: { - primary: "#c0caf5", - secondary: "#565f89", - dim: "#414868", - accent: "#7aa2f7", - success: "#9ece6a", - warning: "#e0af68", - error: "#f7768e", - thinking: "#bb9af7", - muted: "#787c99", + primary: "#eaf1f8", + secondary: "#7d97b3", + dim: "#3b4d63", + accent: "#7fb0d4", + success: "#8fc0a9", + warning: "#e8c37e", + error: "#e07a8a", + thinking: "#a99cd6", + muted: "#63788f", }, bg: { - header: "#1a1b26", - panel: "#1f2335", - panelAlt: "#24283b", - status: "#1a1b26", - input: "#1f2335", - thinking: "#1e2030", + header: "#0a0f18", + panel: "#10161f", + panelAlt: "#161e2a", + status: "#0a0f18", + input: "#10161f", + thinking: "#0e1521", }, }, }, ]; +// deep-sea is the default (index 0) — the fallback below mirrors it. +const FALLBACK: ThemeColors = { + fg: { + primary: "#dceefb", + secondary: "#7fa8c9", + dim: "#3d5a73", + accent: "#4dd0e1", + success: "#6fcf97", + warning: "#f2c14e", + error: "#ff6b6b", + thinking: "#9d8cf0", + muted: "#567a94", + }, + bg: { + header: "#061620", + panel: "#0a2230", + panelAlt: "#0e2c3d", + status: "#061620", + input: "#0a2230", + thinking: "#0c1e2e", + }, +}; + let currentThemeIndex = 0; export function getThemes(): Theme[] { @@ -159,41 +185,18 @@ export function getCurrentTheme(): Theme { if (theme) return theme; const fallback = themes[0]; if (fallback) return fallback; - return { - name: "dark", - colors: { - fg: { - primary: "#e0e0e0", - secondary: "#8a8a8a", - dim: "#555555", - accent: "#45b7d1", - success: "#6bcb77", - warning: "#ffc93c", - error: "#ff6b6b", - thinking: "#a29bfe", - muted: "#636e72", - }, - bg: { - header: "#0d1117", - panel: "#161b22", - panelAlt: "#1c2333", - status: "#0d1117", - input: "#161b22", - thinking: "#1a1530", - }, - }, - }; + return { name: "deep-sea", colors: FALLBACK }; } export function setThemeByName(name: string): Theme | null { - const idx = themes.findIndex((t) => t.name === name); + const idx = themes.findIndex((theme) => theme.name === name); if (idx === -1) return null; currentThemeIndex = idx; return themes[idx] ?? null; } export function getThemeNames(): string[] { - return themes.map((t) => t.name); + return themes.map((theme) => theme.name); } export function t(): ThemeColors { @@ -201,37 +204,19 @@ export function t(): ThemeColors { if (theme) return theme.colors; const fallback = themes[0]; if (fallback) return fallback.colors; - return { - fg: { - primary: "#e0e0e0", - secondary: "#8a8a8a", - dim: "#555555", - accent: "#45b7d1", - success: "#6bcb77", - warning: "#ffc93c", - error: "#ff6b6b", - thinking: "#a29bfe", - muted: "#636e72", - }, - bg: { - header: "#0d1117", - panel: "#161b22", - panelAlt: "#1c2333", - status: "#0d1117", - input: "#161b22", - thinking: "#1a1530", - }, - }; + return FALLBACK; } +// Nautical iconography. Every glyph is a single terminal cell wide so it can be +// dropped into existing layouts without shifting column alignment. export const symbols = { - bullet: "●", - empty: "○", - divider: "·", - dot: "•", - arrow: "→", - spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + bullet: "⚓", // anchor — active marker + empty: "○", // buoy ring — inactive + divider: "~", // wave — inline separator + dot: "◦", // small buoy + arrow: "»", // course / heading + spinner: ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "▆", "▅", "▄", "▃", "▂"], // swell check: "✓", cross: "✗", - gear: "⚙", + gear: "⎈", // helm wheel }; -- 2.51.2