diff --git a/CHANGELOG.md b/CHANGELOG.md index 47142f0e2..e437a178c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 A project whose directory has been moved or deleted says so and leaves the list for good. With nothing closed yet the button is the picker it always was. +- **Save a ready-to-edit template for a custom theme.** Appearance now puts a + template beside the theme importer, so making a theme can start from a valid + JSON file instead of the documentation alone. The example names every + supported app and terminal color, uses valid values throughout, and is ready + to rename, recolor and import back into lich. Both sides go through the same + native dialogs the rest of lich uses, so the template lands where you choose + it instead of somewhere you have to go find afterwards. - **Merge past a rule that is holding a pull request back.** Once GitHub answers that a rule on the base branch stands in the way, gh refuses the merge without ever calling GitHub — so there was nothing lich could do with that pull request @@ -74,6 +81,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 whole exchange in the order it happened — verdicts, comments on the pull request itself, and the threads whose lines the branch has since rewritten, which no diff can show. Settled threads fold themselves away behind a count. +- **Appearance can import custom color themes.** lich still ships the bundled + Light and Dark themes, but Settings › Appearance now accepts theme JSON files + saved under the app config directory. Imported themes can recolor both the + interface tokens and the terminal palette, can be selected independently for + the app and terminal, and can be removed without touching the bundled themes. + Re-importing an existing id asks before replacing it, and a deleted selected + theme returns both pickers to their automatic system behavior. - **Review comments, handed to the session as one prompt.** A diff could give the session a *reference* — a file, a line range — but never an instruction, so anything you actually wanted changed had to be typed out by hand while diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 000000000..f9c6a6e36 --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,100 @@ +# Theme JSON + +lich ships bundled `light` and `dark` themes and accepts user-imported JSON themes. +The Appearance settings can save a valid dark starter template that uses only hex +colors; it is `themes/template.json`, embedded in the binary, and it names every +supported color. +Imported themes are stored under the user config directory: + +- Production: `/lich/themes/.json` +- Dev mode (`LICH_DEV`): `/lich/themes-dev/.json` + +The file name is managed by lich on import. A stored custom theme whose file name +does not match `.json` is ignored. + +## Import Shape + +```json +{ + "id": "tokyo-night", + "name": "Tokyo Night", + "scheme": "dark", + "app": { + "background": "oklch(0.18 0.05 276)", + "foreground": "oklch(0.92 0.03 270)", + "card": "oklch(0.22 0.05 276)", + "card-foreground": "oklch(0.92 0.03 270)", + "popover": "oklch(0.22 0.05 276)", + "popover-foreground": "oklch(0.92 0.03 270)", + "primary": "oklch(0.75 0.13 255)", + "primary-foreground": "oklch(0.14 0.04 276)", + "secondary": "oklch(0.27 0.05 276)", + "secondary-foreground": "oklch(0.88 0.04 270)", + "muted": "oklch(0.25 0.04 276)", + "muted-foreground": "oklch(0.65 0.05 270)", + "accent": "oklch(0.32 0.07 276)", + "accent-foreground": "oklch(0.92 0.03 270)", + "destructive": "oklch(0.7 0.18 25)", + "border": "oklch(0.95 0.02 270 / 12%)", + "input": "oklch(0.95 0.02 270 / 16%)", + "ring": "oklch(0.75 0.13 255)", + "chart-1": "oklch(0.75 0.13 255)", + "chart-2": "oklch(0.78 0.13 150)", + "chart-3": "oklch(0.82 0.13 85)", + "chart-4": "oklch(0.73 0.15 320)", + "chart-5": "oklch(0.76 0.14 25)", + "sidebar": "oklch(0.16 0.04 276)", + "sidebar-foreground": "oklch(0.92 0.03 270)", + "sidebar-primary": "oklch(0.75 0.13 255)", + "sidebar-primary-foreground": "oklch(0.14 0.04 276)", + "sidebar-accent": "oklch(0.27 0.05 276)", + "sidebar-accent-foreground": "oklch(0.92 0.03 270)", + "sidebar-border": "oklch(0.95 0.02 270 / 12%)", + "sidebar-ring": "oklch(0.75 0.13 255)" + }, + "terminal": { + "background": "#1a1b26", + "foreground": "#c0caf5", + "cursor": "#c0caf5", + "selectionBackground": "#33467c" + } +} +``` + +`origin` is optional in imported JSON. The backend overwrites it with `custom` +before saving. + +## Validation Rules + +- `id` is required, must match `^[a-z0-9][a-z0-9._-]{0,63}$`, and cannot be + `light`, `dark`, `system`, or `match`. Windows device names (`con`, `prn`, + `aux`, `nul`, `com1`-`com9`, `lpt1`-`lpt9`) are rejected too: a theme id names + a file, and those still address a device even with an extension. +- `name` is required, cannot be blank, and cannot exceed 128 characters. +- `scheme` must be `light` or `dark`. +- `app` is required and must include every token shown in the example. +- `terminal.background` and `terminal.foreground` are required. +- Optional terminal tokens are: `cursor`, `cursorAccent`, `selectionBackground`, + `selectionForeground`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, + `cyan`, `white`, `brightBlack`, `brightRed`, `brightGreen`, `brightYellow`, + `brightBlue`, `brightMagenta`, `brightCyan`, `brightWhite`. +- Unknown app or terminal tokens are rejected. +- No color value can be blank or exceed 128 characters. + +Each color block takes what its consumer actually parses: + +- **`app`** values become CSS custom properties, so they accept hex + (`#rgb` through `#rrggbbaa`), a CSS color name, or a color function — + `rgb()`, `rgba()`, `hsl()`, `hsla()`, `oklch()`, `oklab()`, `lab()`, `lch()`, + `color()`. Anything that could name a resource or defer a value — `url()`, + `image-set()`, `var()`, `attr()`, `color-mix()` — is rejected. +- **`terminal`** values must be hex. xterm parses hex directly; every other form + goes through a round-trip that fails on translucency and is swallowed into a + silent fallback, which would apply a theme only halfway with no error shown. + +Import uses lich's native file picker. Re-importing a theme whose `id` already +exists asks for confirmation before replacing the stored file; cancelling the +confirmation leaves the existing theme unchanged. + +The template download uses the native save dialog, so the file lands where you +choose it; the dialog confirms replacing an existing file. diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 04146d87e..69817e20e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -27,9 +27,10 @@ state first. ### Color -Zinc, achromatic, by decision — there is **no brand accent hue**. The palette is defined once in -`src/index.css` (`@theme` + `:root`/`.dark`, oklch). Never hardcode a hex or oklch value in a component; always -go through a token. +The bundled light/dark themes are zinc and achromatic — there is **no brand accent hue** by default. Palettes are +theme JSON data applied to the tokens mapped in `src/index.css` (`@theme` + CSS variables). Never hardcode a hex or +oklch value in a component; always go through a token. +The importable theme JSON contract lives in `docs/themes.md`. Surface roles (dark values shown; light is the mirror): @@ -120,7 +121,8 @@ Short specs; the code is the detail. All follow the idiom above. ## Fixed by decision — do not "fix" these -- **Zinc, no accent hue.** Adding a brand color is a design change, not a tweak. +- **Bundled themes stay zinc, no accent hue.** A chromatic palette belongs in a custom theme JSON, not in component + classes. - **The layout.** Top tabs · left session sidebar · terminal · right dock · footer. The reskin is skin, not structure. - **Semantic color** and the **diff language badges** — they carry meaning. diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx index 80edc7469..1fcda85e4 100644 --- a/frontend/src/components/FooterBar.tsx +++ b/frontend/src/components/FooterBar.tsx @@ -104,7 +104,7 @@ export function FooterBar({ dock, onDock }: FooterBarProps) { const attachFile = async () => { try { - const file = await ProjectService.PickFile() + const file = await ProjectService.PickFile("Attach File") if (file && sessionId) { void TerminalService.Write(sessionId, `${file} `) } diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index 0158dd5c9..9badc2f9c 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -21,7 +21,6 @@ import { copyToastMessage, COPY_TOAST_DURATION_MS } from "@/lib/terminal/copy-to import { computeGrid } from "@/lib/terminal/term-fit" import { mouseEncodingSequence } from "@/lib/terminal/term-modes" import { useSettings } from "@/providers/settings" -import type { ResolvedTheme } from "@/providers/settings" import type { SessionKind } from "@/lib/session/sessions" import "@xterm/xterm/css/xterm.css" @@ -106,13 +105,6 @@ function fitTerminal(term: Terminal, container: HTMLElement): void { } } -// Light matches the app's light-gray canvas (--background) rather than pure -// white, which glared; the dark foreground keeps CLI output high-contrast. -const TERMINAL_COLORS: Record = { - dark: { background: "#06070f", foreground: "#e5e7eb" }, - light: { background: "#e8e8ea", foreground: "#1f2328" }, -} - // ensureFontLoaded blocks until a font is available. The renderer measures // cell metrics at open, so bundled fonts (@font-face) must be loaded first; // system fonts resolve as no-ops and failures fall back to monospace. @@ -176,6 +168,7 @@ export function TerminalView({ visible, }: TerminalViewProps) { const { font, terminalFontSize, resolvedTerminalTheme } = useSettings() + const terminalColors = resolvedTerminalTheme.terminal const containerRef = useRef(null) const liveRef = useRef(null) // False until the mount effect's async setup builds the first terminal. @@ -195,11 +188,11 @@ export function TerminalView({ const visibleRef = useRef(visible) const fontRef = useRef(font) const fontSizeRef = useRef(terminalFontSize) - const themeRef = useRef(resolvedTerminalTheme) + const themeRef = useRef(terminalColors) visibleRef.current = visible fontRef.current = font fontSizeRef.current = terminalFontSize - themeRef.current = resolvedTerminalTheme + themeRef.current = terminalColors // In-terminal search (Ctrl+F). The open flag mirrors into a ref so the // terminal's key handler — wired once at creation — reads the live value. @@ -247,7 +240,7 @@ export function TerminalView({ cursorBlink: true, scrollback: SCROLLBACK_LINES, allowProposedApi: true, - theme: TERMINAL_COLORS[themeRef.current], + theme: themeRef.current, }) const serialize = new SerializeAddon() term.loadAddon(serialize) @@ -600,7 +593,7 @@ export function TerminalView({ useEffect(() => { const live = liveRef.current if (live) { - live.term.options.theme = TERMINAL_COLORS[resolvedTerminalTheme] + live.term.options.theme = resolvedTerminalTheme.terminal } }, [resolvedTerminalTheme]) @@ -612,7 +605,7 @@ export function TerminalView({
{searchOpen && (
diff --git a/frontend/src/components/pulls/PullsList.tsx b/frontend/src/components/pulls/PullsList.tsx index 0f10242f6..ce369a128 100644 --- a/frontend/src/components/pulls/PullsList.tsx +++ b/frontend/src/components/pulls/PullsList.tsx @@ -143,10 +143,8 @@ export function PullsList({
- {/* The settings screen's SegmentedControl is built for a full-width - pane; four options with counts wrap in a column this narrow, so this - is the same idiom at list density — one bordered track, the chosen - option filled. */} + {/* Four counted filters need a dense local track so they stay on one + line in this narrow column. */}
= { - system: { label: "System", icon: }, - light: { label: "Light", icon: }, - dark: { label: "Dark", icon: }, -} - -const TERMINAL_THEME_LABELS: Record = { - match: { label: "Match app", icon: }, - light: { label: "Light", icon: }, - dark: { label: "Dark", icon: }, -} - -const THEME_OPTIONS = THEMES.map((value) => ({ value, ...THEME_LABELS[value] })) - -const TERMINAL_THEME_OPTIONS = TERMINAL_THEMES.map((value) => ({ - value, - ...TERMINAL_THEME_LABELS[value], -})) +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { + bundledThemes, + customThemes, + MATCH_TERMINAL_THEME, + SYSTEM_THEME, + THEME_TEMPLATE_FILENAME, + themeSelectItems, +} from "@/lib/themes" +import { errorText } from "@/lib/utils" // Appearance holds every look-and-feel control, split into an Interface group // (theme, zoom) and a Terminal group (background, text size, font) so the two // concerns read apart instead of as one flat list. The group label supplies the // context, so the block titles drop their "Interface"/"Terminal" prefix. export function AppearanceSettings() { + const [themePendingRemoval, setThemePendingRemoval] = useState(null) + const [themePendingOverwrite, setThemePendingOverwrite] = useState<{ + path: string + theme: ThemeDefinition + } | null>(null) + const [importedThemesOpen, setImportedThemesOpen] = useState(false) const { + themes, theme, setTheme, + importTheme, + removeTheme, zoom, setZoom, terminalFontSize, @@ -65,17 +75,191 @@ export function AppearanceSettings() { terminalTheme, setTerminalTheme, } = useSettings() + const importedThemes = customThemes(themes) + const hasCustomThemes = importedThemes.length > 0 + + const onImportTheme = async () => { + try { + const path = await ProjectService.PickFile("Import Theme") + if (!path) return + const result = await importTheme(path, false) + if (result.needsOverwrite) { + setThemePendingOverwrite({ path, theme: result.theme }) + return + } + toast.success(`Imported theme: ${result.theme.name}`) + } catch (error) { + toast.error(`Theme import failed: ${errorText(error)}`) + } + } + + const onOverwriteTheme = async () => { + if (!themePendingOverwrite) return + try { + const result = await importTheme(themePendingOverwrite.path, true) + toast.success(`Imported theme: ${result.theme.name}`) + setThemePendingOverwrite(null) + } catch (error) { + toast.error(`Theme import failed: ${errorText(error)}`) + } + } + + const onRemoveTheme = async () => { + if (!themePendingRemoval) { + return + } + try { + await removeTheme(themePendingRemoval.id) + toast.success(`Removed theme: ${themePendingRemoval.name}`) + setThemePendingRemoval(null) + } catch (error) { + toast.error(`Theme removal failed: ${errorText(error)}`) + } + } + + const onDownloadThemeTemplate = async () => { + try { + const path = await ProjectService.PickSaveFile("Save Theme Template", THEME_TEMPLATE_FILENAME) + if (!path) return + await Themes.SaveTemplate(path) + toast.success(`Saved theme template to ${path}`) + } catch (error) { + toast.error(`Theme template failed: ${errorText(error)}`) + } + } return ( <> - - + +
+ + + + void onDownloadThemeTemplate()} + /> + } + > + + + Download Theme Template + +
+ {hasCustomThemes && ( +
+ + {importedThemesOpen && ( +
+ {importedThemes.map((item) => ( +
+ + + {item.name} + + {item.id} + + + + setThemePendingRemoval(item)} + /> + } + > + + + {`Remove ${item.name}`} + +
+ ))} +
+ )} +
+ )} + setThemePendingRemoval(null)} + title="Remove imported theme?" + description={ + <> + Delete {themePendingRemoval?.name}? This + removes the imported theme file from lich. + + } + > + + + setThemePendingOverwrite(null)} + title="Replace imported theme?" + description={ + <> + A theme with the id{" "} + {themePendingOverwrite?.theme.id} already + exists. Import anyway? This permanently deletes the previous theme. + + } + > + +
} - title="Appearance" - description="Background color of the terminal. Match app keeps it in sync with the interface theme so text stays legible." + title="Theme" + description="Match app keeps the terminal in sync with the interface theme so text stays legible." > - + items={themeSelectItems(themes, MATCH_TERMINAL_THEME, "Match app")} + onValueChange={(value) => value && setTerminalTheme(value as TerminalTheme)} + > + + + + + + Automatic + Match app + + + + ) } + +interface ThemeOptionsProps { + themes: readonly ThemeDefinition[] +} + +function ThemeOptions({ themes }: ThemeOptionsProps) { + const bundled = bundledThemes(themes) + const custom = customThemes(themes) + return ( + <> + + Bundled + {bundled.map((item) => ( + + {item.name} + + ))} + + {custom.length > 0 && ( + + Custom + {custom.map((item) => ( + + {item.name} + + ))} + + )} + + ) +} diff --git a/frontend/src/components/settings/SegmentedControl.tsx b/frontend/src/components/settings/SegmentedControl.tsx deleted file mode 100644 index 581257f9a..000000000 --- a/frontend/src/components/settings/SegmentedControl.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import type { ReactNode } from "react" -import { cn } from "@/lib/utils" - -interface SegmentedOption { - value: T - label: string - icon?: ReactNode -} - -export function SegmentedControl({ - value, - onChange, - options, - ariaLabel, -}: { - value: T - onChange: (value: T) => void - options: ReadonlyArray> - ariaLabel: string -}) { - return ( -
- {options.map((option) => { - const active = option.value === value - return ( - - ) - })} -
- ) -} diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 66baa8b5f..0ce7895e3 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -11,7 +11,7 @@ export function Toaster(props: ToasterProps) { const { resolvedTheme } = useSettings() return ( + terminal: Record +} + +/** internal/themes.ImportResult — import outcome before an optional overwrite. */ +export interface ThemeImportResult { + theme: ThemeDefinition + needsOverwrite: boolean +} + /** internal/patchnotes.Group — one "### Added/Changed/Fixed" block of a release. */ export interface PatchNotesGroup { label: string diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index 9ff99401d..aeb9bbcd1 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -25,6 +25,8 @@ import type { ReviewEvent, StoredProject, StoredSession, + ThemeDefinition, + ThemeImportResult, Worktree, } from "./api-types" @@ -128,7 +130,10 @@ export const ProjectService = { Home: () => call("project.Home", []), /** Whether a stored project's directory is still there, before reopening it. */ Exists: (path: string) => call("project.Exists", [path]), - PickFile: () => call("project.PickFile", []), + PickFile: (title: string) => call("project.PickFile", [title]), + /** Native save dialog seeded with defaultName. "" when the user cancels. */ + PickSaveFile: (title: string, defaultName: string) => + call("project.PickSaveFile", [title, defaultName]), Branch: (path: string) => call("project.Branch", [path]), Diff: (path: string) => call("project.Diff", [path]), DiffText: (path: string) => call("project.DiffText", [path]), @@ -297,3 +302,15 @@ export const Providers = { /** Every known provider with its install state (binary found on PATH). */ Detect: () => call("providers.Detect", []), } + +export const Themes = { + /** Bundled themes plus user-imported themes from the config dir. */ + List: () => call("themes.List", []), + /** Import a picked theme file, reporting an existing id before replacement. */ + Import: (path: string, overwrite: boolean) => + call("themes.Import", [path, overwrite]), + /** Remove a user-imported theme. Bundled themes are rejected by the backend. */ + Remove: (id: string) => call("themes.Remove", [id]), + /** Write the bundled starter theme to a destination the user picked. */ + SaveTemplate: (path: string) => call("themes.SaveTemplate", [path]), +} diff --git a/frontend/src/lib/themes.test.ts b/frontend/src/lib/themes.test.ts new file mode 100644 index 000000000..c248fa380 --- /dev/null +++ b/frontend/src/lib/themes.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest" +import type { ThemeDefinition } from "./api-types" +import { + APP_COLOR_TOKENS, + applyAppTheme, + BUNDLED_THEMES, + bundledThemes, + customThemes, + DEFAULT_TERMINAL_THEME, + DEFAULT_THEME, + mergeImportedTheme, + mergeThemes, + reconcileThemeSelections, + resolveTerminalTheme, + resolveTheme, + selectionsAfterThemeRemoval, + SYSTEM_THEME, + THEME_TEMPLATE_FILENAME, + themeSelectItems, +} from "./themes" + +function customTheme(id: string): ThemeDefinition { + const base = BUNDLED_THEMES[0] + return { + ...base, + id, + name: "Custom", + origin: "custom", + app: { ...base.app, background: "#101010" }, + terminal: { ...base.terminal, background: "#111111" }, + } +} + +describe("themes", () => { + it("keeps bundled themes first and merges custom themes", () => { + const themes = mergeThemes([customTheme("custom")]) + expect(themes.map((theme) => theme.id)).toEqual(["light", "dark", "custom"]) + }) + + it("returns only imported custom themes", () => { + const themes = mergeThemes([customTheme("custom-a"), customTheme("custom-b")]) + expect(customThemes(themes).map((theme) => theme.id)).toEqual(["custom-a", "custom-b"]) + }) + + it("returns only bundled themes", () => { + const themes = mergeThemes([customTheme("custom")]) + expect(bundledThemes(themes).map((theme) => theme.id)).toEqual(["light", "dark"]) + }) + + it("merges an imported theme by id", () => { + const original = customTheme("custom") + const replacement = { ...original, name: "Replacement" } + const themes = mergeImportedTheme(mergeThemes([original]), replacement) + expect(themes).toHaveLength(3) + expect(themes[2].name).toBe("Replacement") + }) + + it("resolves system to the bundled light or dark theme", () => { + expect(resolveTheme(SYSTEM_THEME, BUNDLED_THEMES, false).id).toBe("light") + expect(resolveTheme(SYSTEM_THEME, BUNDLED_THEMES, true).id).toBe("dark") + }) + + it("falls back to the system scheme when a stored custom theme is missing", () => { + expect(resolveTheme("missing", BUNDLED_THEMES, false).id).toBe("light") + expect(resolveTheme("missing", BUNDLED_THEMES, true).id).toBe("dark") + }) + + it("resolves terminal match to the app theme", () => { + const appTheme = customTheme("custom") + expect(resolveTerminalTheme("match", appTheme, BUNDLED_THEMES)).toBe(appTheme) + expect(resolveTerminalTheme("dark", appTheme, BUNDLED_THEMES).id).toBe("dark") + expect(resolveTerminalTheme("missing", appTheme, BUNDLED_THEMES)).toBe(appTheme) + }) + + it("reconciles missing stored selections after themes load", () => { + expect( + reconcileThemeSelections( + { theme: "missing-app", terminalTheme: "missing-terminal" }, + BUNDLED_THEMES, + ), + ).toEqual({ theme: DEFAULT_THEME, terminalTheme: DEFAULT_TERMINAL_THEME }) + expect( + reconcileThemeSelections({ theme: "dark", terminalTheme: "light" }, BUNDLED_THEMES), + ).toEqual({ theme: "dark", terminalTheme: "light" }) + }) + + it("resets only selections that use a removed theme", () => { + expect( + selectionsAfterThemeRemoval("custom", { + theme: "custom", + terminalTheme: "custom", + }), + ).toEqual({ theme: DEFAULT_THEME, terminalTheme: DEFAULT_TERMINAL_THEME }) + expect( + selectionsAfterThemeRemoval("other", { theme: "custom", terminalTheme: "dark" }), + ).toEqual({ theme: "custom", terminalTheme: "dark" }) + }) + + it("applies every app token as a CSS variable", () => { + const values = new Map() + const root = { + style: { + setProperty: (key: string, value: string) => values.set(key, value), + }, + } as unknown as HTMLElement + applyAppTheme(BUNDLED_THEMES[0], root) + for (const token of APP_COLOR_TOKENS) { + expect(values.get(`--${token}`)).toBe(BUNDLED_THEMES[0].app[token]) + } + }) + + it("keeps bundled themes on the same app token set", () => { + const tokens = [...APP_COLOR_TOKENS].sort() + for (const theme of BUNDLED_THEMES) { + expect(Object.keys(theme.app).sort()).toEqual(tokens) + } + }) + + it("maps every selectable value to the label the trigger shows", () => { + const themes = mergeThemes([customTheme("purple-night")]) + const items = themeSelectItems(themes, SYSTEM_THEME, "System") + + expect(items[SYSTEM_THEME]).toBe("System") + expect(items.light).toBe("Light") + expect(items["purple-night"]).toBe("Custom") + expect(Object.keys(items)).toHaveLength(themes.length + 1) + }) + + // The template itself is a Go asset now — internal/themes proves it parses, + // carries every token and survives the real validator. All that is left here + // is the name the save dialog is seeded with. + it("names the template file the save dialog offers", () => { + expect(THEME_TEMPLATE_FILENAME).toBe("lich-theme-template.json") + }) +}) diff --git a/frontend/src/lib/themes.ts b/frontend/src/lib/themes.ts new file mode 100644 index 000000000..874ccca37 --- /dev/null +++ b/frontend/src/lib/themes.ts @@ -0,0 +1,154 @@ +import type { ThemeDefinition } from "@/lib/api-types" +import darkTheme from "../../../themes/dark.json" +import lightTheme from "../../../themes/light.json" + +export const SYSTEM_THEME = "system" +export const MATCH_TERMINAL_THEME = "match" +export const DEFAULT_THEME = SYSTEM_THEME +export const DEFAULT_TERMINAL_THEME = MATCH_TERMINAL_THEME +export const LIGHT_THEME_ID = "light" +export const DARK_THEME_ID = "dark" +export const BUNDLED_THEME_ORIGIN = "bundled" +export const CUSTOM_THEME_ORIGIN = "custom" +export const DARK_THEME_SCHEME = "dark" +export const THEME_TEMPLATE_FILENAME = "lich-theme-template.json" + +export type Theme = typeof SYSTEM_THEME | string +export type TerminalTheme = typeof MATCH_TERMINAL_THEME | string +export type ResolvedTheme = ThemeDefinition + +const BUNDLED = [lightTheme, darkTheme].map((theme) => + normalizeTheme(theme as unknown as ThemeDefinition), +) +const BUNDLED_ORDER = new Map(BUNDLED.map((theme, index) => [theme.id, index])) + +export const APP_COLOR_TOKENS = Object.keys(BUNDLED[0].app) +export const BUNDLED_THEMES = mergeThemes(BUNDLED) + +export function mergeThemes( + themes: readonly ThemeDefinition[] | null | undefined, +): ThemeDefinition[] { + const byId = new Map() + for (const theme of BUNDLED) { + byId.set(theme.id, normalizeTheme(theme)) + } + for (const theme of themes ?? []) { + const normalized = normalizeTheme(theme) + byId.set(normalized.id, normalized) + } + return [...byId.values()].sort((a, b) => { + if (a.origin !== b.origin) { + return a.origin === BUNDLED_THEME_ORIGIN ? -1 : 1 + } + if (a.origin === BUNDLED_THEME_ORIGIN) { + return (BUNDLED_ORDER.get(a.id) ?? 0) - (BUNDLED_ORDER.get(b.id) ?? 0) + } + return a.name.localeCompare(b.name) + }) +} + +export function resolveTheme( + selected: Theme, + themes: readonly ThemeDefinition[], + systemDark: boolean, +): ThemeDefinition { + const systemID = systemDark ? DARK_THEME_ID : LIGHT_THEME_ID + // mergeThemes always seeds the bundled pair, so the last fallback only + // covers a caller that passed a list which never went through it. + const systemTheme = themes.find((theme) => theme.id === systemID) ?? BUNDLED[0] + if (selected === SYSTEM_THEME) { + return systemTheme + } + return themes.find((theme) => theme.id === selected) ?? systemTheme +} + +export function resolveTerminalTheme( + selected: TerminalTheme, + appTheme: ThemeDefinition, + themes: readonly ThemeDefinition[], +): ThemeDefinition { + if (selected === MATCH_TERMINAL_THEME) { + return appTheme + } + return themes.find((theme) => theme.id === selected) ?? appTheme +} + +export function applyAppTheme(theme: ThemeDefinition, root: HTMLElement): void { + for (const token of APP_COLOR_TOKENS) { + root.style.setProperty(`--${token}`, theme.app[token]) + } +} + +export function customThemes(themes: readonly ThemeDefinition[]): ThemeDefinition[] { + return themes.filter((theme) => theme.origin === CUSTOM_THEME_ORIGIN) +} + +export function bundledThemes(themes: readonly ThemeDefinition[]): ThemeDefinition[] { + return themes.filter((theme) => theme.origin === BUNDLED_THEME_ORIGIN) +} + +// A Select trigger renders the raw value unless the Root is handed the +// value→label map: the items only register themselves once the popup has been +// opened, so without this the closed trigger reads "system", not "System". +export function themeSelectItems( + themes: readonly ThemeDefinition[], + automaticID: string, + automaticLabel: string, +): Record { + const items: Record = { [automaticID]: automaticLabel } + for (const theme of themes) { + items[theme.id] = theme.name + } + return items +} + +export function mergeImportedTheme( + themes: readonly ThemeDefinition[], + imported: ThemeDefinition, +): ThemeDefinition[] { + return mergeThemes([...themes, imported]) +} + +export interface ThemeSelections { + theme: Theme + terminalTheme: TerminalTheme +} + +export function reconcileThemeSelections( + selections: ThemeSelections, + themes: readonly ThemeDefinition[], +): ThemeSelections { + const ids = new Set(themes.map((item) => item.id)) + return { + theme: + selections.theme === SYSTEM_THEME || ids.has(selections.theme) + ? selections.theme + : DEFAULT_THEME, + terminalTheme: + selections.terminalTheme === MATCH_TERMINAL_THEME || ids.has(selections.terminalTheme) + ? selections.terminalTheme + : DEFAULT_TERMINAL_THEME, + } +} + +export function selectionsAfterThemeRemoval( + removedID: string, + selections: ThemeSelections, +): ThemeSelections { + return { + theme: selections.theme === removedID ? DEFAULT_THEME : selections.theme, + terminalTheme: + selections.terminalTheme === removedID ? DEFAULT_TERMINAL_THEME : selections.terminalTheme, + } +} + +function normalizeTheme(theme: ThemeDefinition): ThemeDefinition { + return { + id: theme.id, + name: theme.name, + scheme: theme.scheme, + origin: theme.origin, + app: { ...theme.app }, + terminal: { ...theme.terminal }, + } +} diff --git a/frontend/src/providers/settings.tsx b/frontend/src/providers/settings.tsx index 6bba77bda..42f2ad809 100644 --- a/frontend/src/providers/settings.tsx +++ b/frontend/src/providers/settings.tsx @@ -10,7 +10,27 @@ import { type Hotkeys, } from "@/lib/hotkeys" import { zoomIntent } from "@/lib/terminal/zoom-keys" -import { parseBoolPref, parseEnumPref, parseNumberPref, readPref, writePref } from "@/lib/prefs" +import { parseBoolPref, parseNumberPref, readPref, writePref } from "@/lib/prefs" +import { Themes as ThemeRPC } from "@/lib/rpc" +import { + applyAppTheme, + BUNDLED_THEMES, + DARK_THEME_SCHEME, + DEFAULT_TERMINAL_THEME, + DEFAULT_THEME, + mergeImportedTheme, + mergeThemes, + reconcileThemeSelections, + resolveTerminalTheme, + resolveTheme, + selectionsAfterThemeRemoval, + SYSTEM_THEME, +} from "@/lib/themes" +import type { TerminalTheme, Theme, ResolvedTheme } from "@/lib/themes" +import type { ThemeDefinition, ThemeImportResult } from "@/lib/api-types" + +export type { TerminalTheme, Theme, ResolvedTheme } from "@/lib/themes" +export { DEFAULT_TERMINAL_THEME, DEFAULT_THEME } from "@/lib/themes" const FONT_STORAGE_KEY = "lich.terminal.font" const TERMINAL_FONT_SIZE_STORAGE_KEY = "lich.terminal.fontSize" @@ -23,20 +43,6 @@ const CONTEXT_USAGE_STORAGE_KEY = "lich.footer.contextUsage" // fontconfig, so it must be offered explicitly alongside the system fonts. export const DEFAULT_FONT = "FiraCode Nerd Font Mono" -// THEMES drives both the persisted value and the Appearance picker options. -// "system" follows the OS color scheme live. -export const THEMES = ["system", "light", "dark"] as const -export type Theme = (typeof THEMES)[number] -export const DEFAULT_THEME: Theme = "system" - -// TERMINAL_THEMES: "match" tracks the resolved app theme; the others force it. -export const TERMINAL_THEMES = ["match", "light", "dark"] as const -export type TerminalTheme = (typeof TERMINAL_THEMES)[number] -export const DEFAULT_TERMINAL_THEME: TerminalTheme = "match" - -// A theme resolved to a concrete color scheme (system/match already applied). -export type ResolvedTheme = "light" | "dark" - export const ZOOM_MIN = 0.5 export const ZOOM_MAX = 2 export const ZOOM_STEP = 0.1 @@ -72,10 +78,13 @@ const readTerminalFontSize = (): number => clampTerminalFontSize, ) -const readTheme = (): Theme => parseEnumPref(readPref(THEME_STORAGE_KEY), THEMES, DEFAULT_THEME) +// Both preferences are read raw: the stored id is an open value, and the only +// check worth making is membership, which reconcileThemeSelections applies once +// the theme list lands. Until then an unknown id resolves to the system theme. +const readTheme = (): Theme => readPref(THEME_STORAGE_KEY) ?? DEFAULT_THEME const readTerminalTheme = (): TerminalTheme => - parseEnumPref(readPref(TERMINAL_THEME_STORAGE_KEY), TERMINAL_THEMES, DEFAULT_TERMINAL_THEME) + readPref(TERMINAL_THEME_STORAGE_KEY) ?? DEFAULT_TERMINAL_THEME const readZoom = (): number => parseNumberPref(readPref(ZOOM_STORAGE_KEY), DEFAULT_ZOOM, clampZoom) @@ -92,7 +101,11 @@ interface SettingsValue { /** Color theme applied to the whole app via the `.dark` class on . */ theme: Theme setTheme: (theme: Theme) => void - /** Theme resolved to a concrete scheme (system already mapped to the OS). */ + /** Bundled and imported themes available to the app. */ + themes: ThemeDefinition[] + importTheme: (path: string, overwrite: boolean) => Promise + removeTheme: (id: string) => Promise + /** Theme resolved to concrete colors (system already mapped to the OS). */ resolvedTheme: ResolvedTheme /** UI zoom factor applied to the whole app (1 = 100%). */ zoom: number @@ -116,13 +129,45 @@ const SettingsContext = createContext(null) export function SettingsProvider({ children }: { children: ReactNode }) { const [font, setFontState] = useState(readFont) const [terminalFontSize, setTerminalFontSizeState] = useState(readTerminalFontSize) + const [themes, setThemes] = useState(BUNDLED_THEMES) + const [themesLoaded, setThemesLoaded] = useState(false) const [theme, setThemeState] = useState(readTheme) - const [resolvedTheme, setResolvedTheme] = useState("light") + const [resolvedTheme, setResolvedTheme] = useState(BUNDLED_THEMES[0]) const [zoom, setZoomState] = useState(readZoom) const [terminalTheme, setTerminalThemeState] = useState(readTerminalTheme) const [hotkeys, setHotkeys] = useState(loadHotkeys) const [showContextUsage, setShowContextUsageState] = useState(readContextUsage) + useEffect(() => { + let cancelled = false + ThemeRPC.List() + .then((loaded) => { + if (!cancelled) { + setThemes(mergeThemes(loaded)) + setThemesLoaded(true) + } + }) + .catch((error) => { + console.warn("[settings] failed to load custom themes", error) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!themesLoaded) return + const reconciled = reconcileThemeSelections({ theme, terminalTheme }, themes) + if (reconciled.theme !== theme) { + setThemeState(reconciled.theme) + writePref(THEME_STORAGE_KEY, reconciled.theme) + } + if (reconciled.terminalTheme !== terminalTheme) { + setTerminalThemeState(reconciled.terminalTheme) + writePref(TERMINAL_THEME_STORAGE_KEY, reconciled.terminalTheme) + } + }, [theme, terminalTheme, themes, themesLoaded]) + const setFont = useCallback((next: string) => { setFontState(next) writePref(FONT_STORAGE_KEY, next) @@ -160,6 +205,35 @@ export function SettingsProvider({ children }: { children: ReactNode }) { writePref(TERMINAL_THEME_STORAGE_KEY, next) }, []) + const importTheme = useCallback( + async (path: string, overwrite: boolean) => { + const result = await ThemeRPC.Import(path, overwrite) + if (!result.needsOverwrite) { + setThemes((prev) => mergeImportedTheme(prev, result.theme)) + setTheme(result.theme.id) + } + return result + }, + [setTheme], + ) + + const removeTheme = useCallback( + async (id: string) => { + await ThemeRPC.Remove(id) + setThemes((prev) => mergeThemes(prev.filter((item) => item.id !== id))) + const next = selectionsAfterThemeRemoval(id, { theme, terminalTheme }) + if (next.theme !== theme) { + setThemeState(next.theme) + writePref(THEME_STORAGE_KEY, next.theme) + } + if (next.terminalTheme !== terminalTheme) { + setTerminalThemeState(next.terminalTheme) + writePref(TERMINAL_THEME_STORAGE_KEY, next.terminalTheme) + } + }, + [theme, terminalTheme], + ) + const setHotkey = useCallback((id: HotkeyId, combo: Combo) => { setHotkeys((prev) => { const next = { ...prev, [id]: combo } @@ -181,20 +255,25 @@ export function SettingsProvider({ children }: { children: ReactNode }) { writePref(CONTEXT_USAGE_STORAGE_KEY, next) }, []) - // Toggle the `.dark` class on and track the resolved scheme. For - // "system", follow the OS scheme and keep following it live. + // Apply the resolved theme's CSS variables and toggle `.dark` for existing + // dark variants. For "system", follow the OS scheme and keep following it + // live. useEffect(() => { const media = window.matchMedia("(prefers-color-scheme: dark)") const apply = () => { - const dark = theme === "dark" || (theme === "system" && media.matches) - document.documentElement.classList.toggle("dark", dark) - setResolvedTheme(dark ? "dark" : "light") + const resolved = resolveTheme(theme, themes, media.matches) + applyAppTheme(resolved, document.documentElement) + document.documentElement.classList.toggle( + DARK_THEME_SCHEME, + resolved.scheme === DARK_THEME_SCHEME, + ) + setResolvedTheme(resolved) } apply() - if (theme !== "system") return + if (theme !== SYSTEM_THEME) return media.addEventListener("change", apply) return () => media.removeEventListener("change", apply) - }, [theme]) + }, [theme, themes]) // Scale the app by moving the root font size: every Tailwind spacing and type // utility resolves in rem (--spacing is 0.25rem, --text-* are rem), so one @@ -251,8 +330,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { } }, [zoomBy, setZoom]) - const resolvedTerminalTheme: ResolvedTheme = - terminalTheme === "match" ? resolvedTheme : terminalTheme + const resolvedTerminalTheme = resolveTerminalTheme(terminalTheme, resolvedTheme, themes) return ( maxThemeFileSize { + return nil, fmt.Errorf("theme file must be a regular file no larger than %d bytes", maxThemeFileSize) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read theme file: %w", err) + } + return data, nil +} + +func encodeTheme(theme Theme) ([]byte, error) { + data, err := json.MarshalIndent(theme, "", " ") + if err != nil { + return nil, fmt.Errorf("encode theme: %w", err) + } + return append(data, '\n'), nil +} + +func writeNewTheme(path string, data []byte) (bool, error) { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if os.IsExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("create theme: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + _ = os.Remove(path) + return false, fmt.Errorf("write theme: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return false, fmt.Errorf("close theme: %w", err) + } + return true, nil +} + +// Remove deletes a custom theme. Bundled and reserved ids cannot be removed. +func (s *Service) Remove(id string) error { + if s.initErr != nil { + return s.initErr + } + if err := validateID(id); err != nil { + return err + } + if _, ok := reserved[id]; ok { + return fmt.Errorf("theme %q is bundled or reserved", id) + } + if err := os.Remove(s.path(id)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove theme: %w", err) + } + return nil +} + +func (s *Service) customThemes() ([]Theme, error) { + entries, err := os.ReadDir(s.dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read themes dir: %w", err) + } + themes := make([]Theme, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(s.dir, entry.Name())) + if err != nil { + slog.Warn("themes: read custom theme", "file", entry.Name(), "err", err) + continue + } + var theme Theme + if err := json.Unmarshal(data, &theme); err != nil { + slog.Warn("themes: parse custom theme", "file", entry.Name(), "err", err) + continue + } + theme.Origin = OriginCustom + if err := validateCustom(theme); err != nil { + slog.Warn("themes: invalid custom theme", "file", entry.Name(), "err", err) + continue + } + if theme.ID+".json" != entry.Name() { + slog.Warn("themes: theme filename mismatch", "file", entry.Name(), "id", theme.ID) + continue + } + themes = append(themes, theme) + } + sort.Slice(themes, func(i, j int) bool { + return strings.ToLower(themes[i].Name) < strings.ToLower(themes[j].Name) + }) + return themes, nil +} + +func (s *Service) path(id string) string { + return filepath.Join(s.dir, id+".json") +} + +func validateCustom(theme Theme) error { + if err := validateID(theme.ID); err != nil { + return err + } + if _, ok := reserved[theme.ID]; ok { + return fmt.Errorf("theme id %q is bundled or reserved", theme.ID) + } + return validateTheme(theme) +} + +func validateTheme(theme Theme) error { + if strings.TrimSpace(theme.Name) == "" { + return fmt.Errorf("theme name is required") + } + if utf8.RuneCountInString(theme.Name) > themeNameMaxLength { + return fmt.Errorf("theme name cannot exceed %d characters", themeNameMaxLength) + } + if theme.Scheme != SchemeLight && theme.Scheme != SchemeDark { + return fmt.Errorf("theme scheme must be %q or %q", SchemeLight, SchemeDark) + } + if err := validateColors("app", theme.App, appTokens, appColorValue, true); err != nil { + return err + } + if err := validateColors("terminal", theme.Terminal, terminalTokens, terminalColorValue, false); err != nil { + return err + } + for _, key := range []string{"background", "foreground"} { + if strings.TrimSpace(theme.Terminal[key]) == "" { + return fmt.Errorf("terminal.%s is required", key) + } + } + return nil +} + +func validateID(id string) error { + if len(id) > themeIDMaxLength || !idPattern.MatchString(id) { + return fmt.Errorf("theme id %q must be lowercase letters, digits, dots, underscores or dashes", id) + } + if _, ok := windowsDeviceNames[id]; ok { + return fmt.Errorf("theme id %q is a reserved Windows device name", id) + } + return nil +} + +func validateColors( + group string, + colors map[string]string, + allowed map[string]struct{}, + value *regexp.Regexp, + requireAll bool, +) error { + if colors == nil { + return fmt.Errorf("%s colors are required", group) + } + for key, color := range colors { + if _, ok := allowed[key]; !ok { + return fmt.Errorf("unknown %s color %q", group, key) + } + if err := validateColorValue(group, key, color, value); err != nil { + return err + } + } + if requireAll { + for key := range allowed { + if _, ok := colors[key]; !ok { + return fmt.Errorf("%s.%s is required", group, key) + } + } + } + return nil +} + +func validateColorValue(group, key, value string, pattern *regexp.Regexp) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("%s.%s cannot be empty", group, key) + } + if len(trimmed) > colorValueMaxLength || !pattern.MatchString(trimmed) { + return fmt.Errorf("%s.%s is not an accepted color value: %q", group, key, value) + } + return nil +} + +func cloneTheme(theme Theme) Theme { + return Theme{ + ID: theme.ID, + Name: theme.Name, + Scheme: theme.Scheme, + Origin: theme.Origin, + App: cloneMap(theme.App), + Terminal: cloneMap(theme.Terminal), + } +} + +func cloneMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} diff --git a/internal/themes/themes_test.go b/internal/themes/themes_test.go new file mode 100644 index 000000000..ebee29ca0 --- /dev/null +++ b/internal/themes/themes_test.go @@ -0,0 +1,602 @@ +package themes + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + + themeassets "github.com/omartelo/lich/themes" +) + +func customTheme(id string) Theme { + theme := cloneTheme(bundledThemes[0]) + theme.ID = id + theme.Name = "Custom" + theme.Origin = OriginCustom + return theme +} + +func rawTheme(t *testing.T, theme Theme) string { + t.Helper() + data, err := json.Marshal(theme) + if err != nil { + t.Fatalf("marshal theme: %v", err) + } + return string(data) +} + +func rawThemeFile(t *testing.T, raw string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "theme.json") + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("write source theme: %v", err) + } + return path +} + +func importTheme(t *testing.T, s *Service, theme Theme) (ImportResult, error) { + t.Helper() + return s.Import(rawThemeFile(t, rawTheme(t, theme)), false) +} + +func TestListIncludesBundledThemes(t *testing.T) { + s := NewInDir(t.TempDir()) + themes, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(themes) != 2 { + t.Fatalf("got %d themes, want 2", len(themes)) + } + if themes[0].ID != "light" || themes[0].Origin != OriginBundled { + t.Fatalf("first bundled theme = %#v", themes[0]) + } + if themes[1].ID != "dark" || themes[1].Origin != OriginBundled { + t.Fatalf("second bundled theme = %#v", themes[1]) + } +} + +func TestImportPersistsCustomTheme(t *testing.T) { + dir := t.TempDir() + s := NewInDir(dir) + result, err := importTheme(t, s, customTheme("solar")) + if err != nil { + t.Fatalf("Import: %v", err) + } + if result.Theme.ID != "solar" || result.Theme.Origin != OriginCustom || result.NeedsOverwrite { + t.Fatalf("import result = %#v", result) + } + if _, err := os.Stat(filepath.Join(dir, "solar.json")); err != nil { + t.Fatalf("stat saved theme: %v", err) + } + listed, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(listed) != 3 || listed[2].ID != "solar" { + t.Fatalf("listed themes = %#v", listed) + } +} + +func TestImportRejectsReservedID(t *testing.T) { + s := NewInDir(t.TempDir()) + _, err := importTheme(t, s, customTheme("dark")) + if err == nil { + t.Fatal("Import reserved id succeeded") + } +} + +func TestImportRejectsMalformedJSON(t *testing.T) { + s := NewInDir(t.TempDir()) + if _, err := s.Import(rawThemeFile(t, "{"), false); err == nil { + t.Fatal("Import malformed JSON succeeded") + } +} + +func TestImportRejectsInvalidID(t *testing.T) { + tests := []struct { + name string + id string + }{ + {name: "empty", id: ""}, + {name: "uppercase", id: "Bad"}, + {name: "leading dash", id: "-bad"}, + {name: "too long", id: strings.Repeat("a", 65)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewInDir(t.TempDir()) + if _, err := importTheme(t, s, customTheme(tt.id)); err == nil { + t.Fatal("Import invalid id succeeded") + } + }) + } +} + +func TestImportRejectsInvalidMetadata(t *testing.T) { + tests := []struct { + name string + mutate func(*Theme) + }{ + { + name: "missing name", + mutate: func(theme *Theme) { + theme.Name = " " + }, + }, + { + name: "invalid scheme", + mutate: func(theme *Theme) { + theme.Scheme = "dim" + }, + }, + { + name: "name too long", + mutate: func(theme *Theme) { + theme.Name = strings.Repeat("a", themeNameMaxLength+1) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + theme := customTheme("invalid-metadata") + tt.mutate(&theme) + s := NewInDir(t.TempDir()) + if _, err := importTheme(t, s, theme); err == nil { + t.Fatal("Import invalid metadata succeeded") + } + }) + } +} + +func TestImportRejectsMissingToken(t *testing.T) { + theme := customTheme("missing-token") + delete(theme.App, "background") + s := NewInDir(t.TempDir()) + _, err := importTheme(t, s, theme) + if err == nil { + t.Fatal("Import without required token succeeded") + } +} + +func TestImportRejectsInvalidColorValues(t *testing.T) { + tests := []struct { + name string + mutate func(*Theme) + }{ + { + name: "unknown app color", + mutate: func(theme *Theme) { + theme.App["unknown"] = "#fff" + }, + }, + { + name: "empty app color", + mutate: func(theme *Theme) { + theme.App["background"] = " " + }, + }, + { + name: "unsafe app color", + mutate: func(theme *Theme) { + theme.App["foreground"] = "red; color: blue" + }, + }, + { + name: "app color naming a resource", + mutate: func(theme *Theme) { + theme.App["foreground"] = "url(http://example.test/pixel.png)" + }, + }, + { + name: "app color deferring a value", + mutate: func(theme *Theme) { + theme.App["foreground"] = "var(--leaked)" + }, + }, + { + name: "non-hex terminal color", + mutate: func(theme *Theme) { + theme.Terminal["cursor"] = "oklch(0.7 0.1 280)" + }, + }, + { + name: "unknown terminal color", + mutate: func(theme *Theme) { + theme.Terminal["unknown"] = "#fff" + }, + }, + { + name: "empty terminal color", + mutate: func(theme *Theme) { + theme.Terminal["cursor"] = " " + }, + }, + { + name: "unsafe terminal color", + mutate: func(theme *Theme) { + theme.Terminal["cursor"] = "red{}" + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + theme := customTheme("invalid-color") + tt.mutate(&theme) + s := NewInDir(t.TempDir()) + if _, err := importTheme(t, s, theme); err == nil { + t.Fatal("Import invalid color succeeded") + } + }) + } +} + +func TestImportRejectsMissingTerminalRequiredColors(t *testing.T) { + for _, key := range []string{"background", "foreground"} { + t.Run(key, func(t *testing.T) { + theme := customTheme("missing-terminal") + delete(theme.Terminal, key) + s := NewInDir(t.TempDir()) + if _, err := importTheme(t, s, theme); err == nil { + t.Fatal("Import without required terminal color succeeded") + } + }) + } +} + +func TestImportReturnsDirectoryError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "themes") + if err := os.WriteFile(path, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write dir blocker: %v", err) + } + s := NewInDir(path) + if _, err := importTheme(t, s, customTheme("dir-error")); err == nil { + t.Fatal("Import with file as themes dir succeeded") + } +} + +func TestImportRequiresOverwriteConfirmation(t *testing.T) { + dir := t.TempDir() + s := NewInDir(dir) + original := customTheme("replace-me") + original.Name = "Original" + if _, err := importTheme(t, s, original); err != nil { + t.Fatalf("import original: %v", err) + } + + replacement := customTheme("replace-me") + replacement.Name = "Replacement" + source := rawThemeFile(t, rawTheme(t, replacement)) + result, err := s.Import(source, false) + if err != nil { + t.Fatalf("probe replacement: %v", err) + } + if !result.NeedsOverwrite || result.Theme.Name != "Replacement" { + t.Fatalf("probe result = %#v", result) + } + listed, _ := s.List() + if listed[2].Name != "Original" { + t.Fatalf("probe overwrote original: %#v", listed[2]) + } + + result, err = s.Import(source, true) + if err != nil || result.NeedsOverwrite { + t.Fatalf("confirmed replacement = %#v, %v", result, err) + } + listed, _ = s.List() + if listed[2].Name != "Replacement" { + t.Fatalf("confirmed replacement not installed: %#v", listed[2]) + } +} + +func TestImportRejectsInvalidSourceFiles(t *testing.T) { + s := NewInDir(t.TempDir()) + if _, err := s.Import(t.TempDir(), false); err == nil { + t.Fatal("Import directory succeeded") + } + large := rawThemeFile(t, strings.Repeat("x", maxThemeFileSize+1)) + if _, err := s.Import(large, false); err == nil { + t.Fatal("Import oversized file succeeded") + } +} + +func TestServiceInitErrorPropagates(t *testing.T) { + want := os.ErrPermission + s := &Service{initErr: want} + if _, err := s.List(); err != want { + t.Fatalf("List init error = %v", err) + } + if _, err := s.Import("ignored", false); err != want { + t.Fatalf("Import init error = %v", err) + } + if err := s.Remove("custom"); err != want { + t.Fatalf("Remove init error = %v", err) + } +} + +func TestDefaultDirSeparatesDevThemes(t *testing.T) { + t.Setenv("LICH_DEV", "") + production, err := defaultDir() + if err != nil { + t.Fatalf("defaultDir production: %v", err) + } + if filepath.Base(production) != "themes" || filepath.Base(filepath.Dir(production)) != "lich" { + t.Fatalf("production dir = %q", production) + } + + t.Setenv("LICH_DEV", "1") + development, err := defaultDir() + if err != nil { + t.Fatalf("defaultDir development: %v", err) + } + if filepath.Base(development) != "themes-dev" || filepath.Base(filepath.Dir(development)) != "lich" { + t.Fatalf("development dir = %q", development) + } + if got := New().dir; got != development { + t.Fatalf("New dir = %q, want %q", got, development) + } +} + +func TestRemoveDeletesOnlyCustomTheme(t *testing.T) { + dir := t.TempDir() + s := NewInDir(dir) + if _, err := importTheme(t, s, customTheme("removable")); err != nil { + t.Fatalf("Import: %v", err) + } + if err := s.Remove("removable"); err != nil { + t.Fatalf("Remove custom: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "removable.json")); !os.IsNotExist(err) { + t.Fatalf("saved theme still exists: %v", err) + } + if err := s.Remove("light"); err == nil { + t.Fatal("Remove bundled theme succeeded") + } + if err := s.Remove("Bad ID"); err == nil { + t.Fatal("Remove invalid id succeeded") + } + if err := s.Remove("not-there"); err != nil { + t.Fatalf("Remove missing theme: %v", err) + } +} + +func TestListSortsCustomThemesByName(t *testing.T) { + dir := t.TempDir() + s := NewInDir(dir) + zeta := customTheme("zeta") + zeta.Name = "Zeta" + alpha := customTheme("alpha") + alpha.Name = "alpha" + if _, err := importTheme(t, s, zeta); err != nil { + t.Fatalf("Import zeta: %v", err) + } + if _, err := importTheme(t, s, alpha); err != nil { + t.Fatalf("Import alpha: %v", err) + } + themes, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + got := []string{themes[0].ID, themes[1].ID, themes[2].ID, themes[3].ID} + want := []string{"light", "dark", "alpha", "zeta"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("theme order = %v, want %v", got, want) + } +} + +func TestListSkipsInvalidCustomTheme(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("{"), 0o600); err != nil { + t.Fatalf("write bad theme: %v", err) + } + s := NewInDir(dir) + themes, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(themes) != 2 { + t.Fatalf("got %d themes, want bundled only", len(themes)) + } +} + +func TestListSkipsMissingTerminalAndNonThemeEntries(t *testing.T) { + dir := t.TempDir() + missingTerminal := customTheme("missing-terminal") + missingTerminal.Terminal = nil + if err := os.WriteFile( + filepath.Join(dir, "missing-terminal.json"), + []byte(rawTheme(t, missingTerminal)), + 0o600, + ); err != nil { + t.Fatalf("write missing-terminal theme: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignored"), 0o600); err != nil { + t.Fatalf("write non-theme file: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "nested.json"), 0o700); err != nil { + t.Fatalf("make directory entry: %v", err) + } + themes, err := NewInDir(dir).List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(themes) != 2 { + t.Fatalf("got %d themes, want bundled only", len(themes)) + } +} + +func TestListSkipsFilenameMismatch(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "wrong.json"), []byte(rawTheme(t, customTheme("right"))), 0o600); err != nil { + t.Fatalf("write mismatched theme: %v", err) + } + s := NewInDir(dir) + themes, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(themes) != 2 { + t.Fatalf("got %d themes, want bundled only", len(themes)) + } +} + +func TestListReturnsDirectoryError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "themes") + if err := os.WriteFile(path, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write dir blocker: %v", err) + } + s := NewInDir(path) + if _, err := s.List(); err == nil { + t.Fatal("List with file as themes dir succeeded") + } +} + +func TestBundledThemesAreCloned(t *testing.T) { + s := NewInDir(t.TempDir()) + first, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + first[0].App["background"] = "changed" + first[0].Terminal["foreground"] = "changed" + + second, err := s.List() + if err != nil { + t.Fatalf("List again: %v", err) + } + if second[0].App["background"] == "changed" || second[0].Terminal["foreground"] == "changed" { + t.Fatalf("bundled theme was mutated: %#v", second[0]) + } +} + +func TestBootCSSMatchesBundledThemes(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "index.css")) + if err != nil { + t.Fatalf("read index.css: %v", err) + } + css := string(data) + selectors := []string{":root", ".dark"} + for i, theme := range bundledThemes { + t.Run(theme.ID, func(t *testing.T) { + got := cssVariables(t, css, selectors[i]) + delete(got, "radius") + if !reflect.DeepEqual(got, theme.App) { + t.Fatalf("%s boot variables differ from bundled JSON\nCSS: %#v\nJSON: %#v", selectors[i], got, theme.App) + } + }) + } +} + +func cssVariables(t *testing.T, css, selector string) map[string]string { + t.Helper() + blockPattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`) + match := blockPattern.FindStringSubmatch(css) + if match == nil { + t.Fatalf("CSS selector %q not found", selector) + } + variables := make(map[string]string) + propertyPattern := regexp.MustCompile(`(?m)^\s*--([a-z0-9-]+):\s*([^;]+);`) + for _, property := range propertyPattern.FindAllStringSubmatch(match[1], -1) { + variables[property[1]] = strings.TrimSpace(property[2]) + } + return variables +} + +func TestBundledThemesPassValidation(t *testing.T) { + for _, theme := range bundledThemes { + t.Run(theme.ID, func(t *testing.T) { + if err := validateTheme(theme); err != nil { + t.Fatalf("bundled theme %q is invalid: %v", theme.ID, err) + } + }) + } +} + +// The template ships as the answer to "what shape does a theme have", so it has +// to survive the same validator an imported file meets — not just parse. +func TestBundledTemplateIsImportable(t *testing.T) { + var template Theme + if err := json.Unmarshal(themeassets.Template, &template); err != nil { + t.Fatalf("parse template: %v", err) + } + if template.Origin != "" { + t.Fatalf("template ships an origin: %q", template.Origin) + } + template.Origin = OriginCustom + if err := validateCustom(template); err != nil { + t.Fatalf("template is not importable: %v", err) + } + if !reflect.DeepEqual(keySet(template.App), appTokens) { + t.Fatalf("template app tokens = %#v, want %#v", keySet(template.App), appTokens) + } + if !reflect.DeepEqual(keySet(template.Terminal), terminalTokens) { + t.Fatalf("template terminal tokens = %#v, want every supported one", keySet(template.Terminal)) + } +} + +func TestSaveTemplateWritesEveryColor(t *testing.T) { + path := filepath.Join(t.TempDir(), "template.json") + if err := NewInDir(t.TempDir()).SaveTemplate(path); err != nil { + t.Fatalf("SaveTemplate: %v", err) + } + written, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read saved template: %v", err) + } + if !reflect.DeepEqual(written, themeassets.Template) { + t.Fatal("saved template differs from the bundled asset") + } +} + +func TestSaveTemplateRejectsEmptyDestination(t *testing.T) { + if err := NewInDir(t.TempDir()).SaveTemplate(" "); err == nil { + t.Fatal("SaveTemplate with no destination succeeded") + } +} + +func TestSaveTemplateReportsWriteFailure(t *testing.T) { + dir := t.TempDir() + if err := NewInDir(dir).SaveTemplate(filepath.Join(dir, "missing", "template.json")); err == nil { + t.Fatal("SaveTemplate into a missing directory succeeded") + } +} + +func TestImportRejectsWindowsDeviceNames(t *testing.T) { + for _, id := range []string{"con", "nul", "aux", "com1", "lpt9"} { + t.Run(id, func(t *testing.T) { + if _, err := importTheme(t, NewInDir(t.TempDir()), customTheme(id)); err == nil { + t.Fatalf("Import of Windows device name %q succeeded", id) + } + }) + } +} + +// The documented example is the shape users copy from, so it answers to the +// validator like any other imported theme. +func TestDocumentedExampleIsImportable(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "docs", "themes.md")) + if err != nil { + t.Fatalf("read docs/themes.md: %v", err) + } + block := regexp.MustCompile("(?s)```json\n(.*?)```").FindSubmatch(data) + if block == nil { + t.Fatal("docs/themes.md has no JSON example") + } + var example Theme + if err := json.Unmarshal(block[1], &example); err != nil { + t.Fatalf("parse documented example: %v", err) + } + example.Origin = OriginCustom + if err := validateCustom(example); err != nil { + t.Fatalf("documented example is not importable: %v", err) + } + if !reflect.DeepEqual(keySet(example.App), appTokens) { + t.Fatalf("documented app tokens = %#v, want %#v", keySet(example.App), appTokens) + } +} diff --git a/main.go b/main.go index 4f5bf9dd9..a955346ca 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "github.com/omartelo/lich/internal/store" "github.com/omartelo/lich/internal/system" "github.com/omartelo/lich/internal/terminal" + "github.com/omartelo/lich/internal/themes" ) // The frontend is embedded into the binary and served over the loopback @@ -102,6 +103,7 @@ func main() { dispatcher.Register("store", db) dispatcher.Register("system", system.New(env)) dispatcher.Register("providers", providers.New()) + dispatcher.Register("themes", themes.New()) dispatcher.Deny("store.Close") term.Mount("/rpc/", dispatcher) term.Mount("/events", hub) diff --git a/themes/assets.go b/themes/assets.go new file mode 100644 index 000000000..4d7627884 --- /dev/null +++ b/themes/assets.go @@ -0,0 +1,15 @@ +// Package themes owns the canonical bundled theme definitions. +package themes + +import _ "embed" + +var ( + //go:embed light.json + Light []byte + //go:embed dark.json + Dark []byte + // Template is the starter theme handed to a user who wants to write one. + // It names every supported color, so it doubles as the shape reference. + //go:embed template.json + Template []byte +) diff --git a/themes/dark.json b/themes/dark.json new file mode 100644 index 000000000..44b67b423 --- /dev/null +++ b/themes/dark.json @@ -0,0 +1,43 @@ +{ + "id": "dark", + "name": "Dark", + "scheme": "dark", + "origin": "bundled", + "app": { + "background": "oklch(0.141 0.005 285.823)", + "foreground": "oklch(0.985 0 0)", + "card": "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + "popover": "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + "primary": "oklch(0.92 0.004 286.32)", + "primary-foreground": "oklch(0.21 0.006 285.885)", + "secondary": "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "muted": "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + "accent": "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + "destructive": "oklch(0.704 0.191 22.216)", + "border": "oklch(1 0 0 / 10%)", + "input": "oklch(1 0 0 / 15%)", + "ring": "oklch(0.552 0.016 285.938)", + "chart-1": "oklch(0.871 0.006 286.286)", + "chart-2": "oklch(0.552 0.016 285.938)", + "chart-3": "oklch(0.442 0.017 285.786)", + "chart-4": "oklch(0.37 0.013 285.805)", + "chart-5": "oklch(0.274 0.006 286.033)", + "sidebar": "oklch(0.21 0.006 285.885)", + "sidebar-foreground": "oklch(0.985 0 0)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.274 0.006 286.033)", + "sidebar-accent-foreground": "oklch(0.985 0 0)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.552 0.016 285.938)" + }, + "terminal": { + "background": "#06070f", + "foreground": "#e5e7eb" + } +} diff --git a/themes/light.json b/themes/light.json new file mode 100644 index 000000000..06ee45d1a --- /dev/null +++ b/themes/light.json @@ -0,0 +1,43 @@ +{ + "id": "light", + "name": "Light", + "scheme": "light", + "origin": "bundled", + "app": { + "background": "oklch(0.94 0.003 286.32)", + "foreground": "oklch(0.21 0.006 285.885)", + "card": "oklch(0.98 0.002 286.32)", + "card-foreground": "oklch(0.21 0.006 285.885)", + "popover": "oklch(0.98 0.002 286.32)", + "popover-foreground": "oklch(0.21 0.006 285.885)", + "primary": "oklch(0.21 0.006 285.885)", + "primary-foreground": "oklch(0.985 0 0)", + "secondary": "oklch(0.92 0.003 286.32)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "muted": "oklch(0.935 0.003 286.32)", + "muted-foreground": "oklch(0.505 0.016 285.938)", + "accent": "oklch(0.9 0.004 286.32)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + "destructive": "oklch(0.577 0.245 27.325)", + "border": "oklch(0.888 0.004 286.32)", + "input": "oklch(0.888 0.004 286.32)", + "ring": "oklch(0.6 0.015 286.067)", + "chart-1": "oklch(0.871 0.006 286.286)", + "chart-2": "oklch(0.552 0.016 285.938)", + "chart-3": "oklch(0.442 0.017 285.786)", + "chart-4": "oklch(0.37 0.013 285.805)", + "chart-5": "oklch(0.274 0.006 286.033)", + "sidebar": "oklch(0.98 0.002 286.32)", + "sidebar-foreground": "oklch(0.21 0.006 285.885)", + "sidebar-primary": "oklch(0.21 0.006 285.885)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.9 0.004 286.32)", + "sidebar-accent-foreground": "oklch(0.21 0.006 285.885)", + "sidebar-border": "oklch(0.888 0.004 286.32)", + "sidebar-ring": "oklch(0.6 0.015 286.067)" + }, + "terminal": { + "background": "#e8e8ea", + "foreground": "#1f2328" + } +} diff --git a/themes/template.json b/themes/template.json new file mode 100644 index 000000000..ee2f528a1 --- /dev/null +++ b/themes/template.json @@ -0,0 +1,62 @@ +{ + "id": "purple-night", + "name": "Purple Night", + "scheme": "dark", + "app": { + "background": "#15111f", + "foreground": "#f4efff", + "card": "#1f1930", + "card-foreground": "#f4efff", + "popover": "#241d38", + "popover-foreground": "#f4efff", + "primary": "#b794f6", + "primary-foreground": "#15111f", + "secondary": "#2d2442", + "secondary-foreground": "#efe7ff", + "muted": "#261f36", + "muted-foreground": "#b8a9d9", + "accent": "#3a2d57", + "accent-foreground": "#f4efff", + "destructive": "#ff5c8a", + "border": "#4a3a68", + "input": "#554178", + "ring": "#c4a7ff", + "chart-1": "#b794f6", + "chart-2": "#8be9fd", + "chart-3": "#50fa7b", + "chart-4": "#ffb86c", + "chart-5": "#ff79c6", + "sidebar": "#1b1628", + "sidebar-foreground": "#f4efff", + "sidebar-primary": "#b794f6", + "sidebar-primary-foreground": "#15111f", + "sidebar-accent": "#2d2442", + "sidebar-accent-foreground": "#f4efff", + "sidebar-border": "#4a3a68", + "sidebar-ring": "#c4a7ff" + }, + "terminal": { + "background": "#15111f", + "foreground": "#f4efff", + "cursor": "#c4a7ff", + "cursorAccent": "#15111f", + "selectionBackground": "#4a3a68", + "selectionForeground": "#ffffff", + "black": "#15111f", + "red": "#ff5c8a", + "green": "#50fa7b", + "yellow": "#ffb86c", + "blue": "#8be9fd", + "magenta": "#bd93f9", + "cyan": "#80ffea", + "white": "#f4efff", + "brightBlack": "#6f5a95", + "brightRed": "#ff7aa8", + "brightGreen": "#69ff94", + "brightYellow": "#ffd18a", + "brightBlue": "#a4f0ff", + "brightMagenta": "#d5b7ff", + "brightCyan": "#a0fff1", + "brightWhite": "#ffffff" + } +}