feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)

- Settings → Appearance: mode Dark / Light / System (follows the OS live) and
  a named theme remembered per mode — dark: Default, Darcula, Apple Dark;
  light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a
  CSS-variable override block keyed by `data-theme` on <html>; the sidebar
  quick toggle gains System and swaps between the two remembered themes.
- An inline boot script in index.html applies the stored mode and theme
  before first paint, so a reload no longer flashes the default colours.
- Diff +/- rows are easier to tell apart on every theme: a /20 status tint,
  a 3px status bar and a bold coloured marker; the changed-span emphasis no
  longer vanishes on light themes.
- #42: the Add-lane modal (Repo mode / "Current branch") asks
  POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside
  an existing lane resolved to that parent lane with created:false, so the
  modal closed and no new card appeared. When the folder really already has a
  lane, the modal now says which one instead of closing.

Closes #42
This commit is contained in:
2026-09-24 09:44:42 +07:00
parent 2ea2184372
commit 6cf8a82889
33 changed files with 1756 additions and 59 deletions
+2 -2
View File
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
@@ -208,7 +208,7 @@ client/
│ ├── hooks/
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
│ │ ├── useNotifications.ts # Browser push notification triggers
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
│ │
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
│ ├── App.tsx # Root component + router setup
+18
View File
@@ -1,6 +1,24 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<!-- Apply the saved appearance before first paint (same keys as
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
<script>
try {
var m = localStorage.getItem("theme");
var d =
m === "light"
? false
: m === "system"
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
: true;
var r = document.documentElement;
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
r.classList.toggle("dark", d);
r.setAttribute("data-mode", d ? "dark" : "light");
if (t && t !== "default") r.setAttribute("data-theme", t);
} catch (e) {}
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
+9 -6
View File
@@ -71,6 +71,7 @@ import {
ChevronUp,
ChevronDown,
Sun,
Monitor,
Moon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -177,7 +178,7 @@ interface SidebarProps {
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, setTheme, toggleTheme } = useTheme();
const { theme, mode, setMode, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle).
@@ -545,13 +546,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:theme")}
</p>
<div className="mt-2 grid grid-cols-2 gap-1">
{(["dark", "light"] as const).map((option) => {
const active = option === theme;
<div className="mt-2 grid grid-cols-3 gap-1">
{(["dark", "light", "system"] as const).map((option) => {
const active = option === mode;
return (
<button
key={option}
onClick={() => setTheme(option)}
onClick={() => setMode(option)}
aria-pressed={active}
aria-label={t(`nav:themeNames.${option}`)}
title={t(`nav:themeNames.${option}`)}
@@ -563,8 +564,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
>
{option === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
) : option === "light" ? (
<Sun className="w-3.5 h-3.5" />
) : (
<Monitor className="w-3.5 h-3.5" />
)}
</button>
);
@@ -0,0 +1,59 @@
/**
* @file Sidebar.theme.test.tsx
* @description Tests for the Sidebar's theme controls: the expanded panel's Dark / Light / System buttons and the collapsed one-click toggle, both writing through useTheme.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Sidebar } from "../Sidebar";
function renderSidebar(collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
</MemoryRouter>
);
}
describe("Sidebar theme controls", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});
it("expanded panel offers Dark, Light and System with Dark pressed by default", () => {
renderSidebar();
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Light" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking System sets the mode and presses only System", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "System" }));
expect(localStorage.getItem("theme")).toBe("system");
expect(screen.getByRole("button", { name: "System" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute("aria-pressed", "false");
});
it("clicking Light applies light mode to <html>", async () => {
const user = userEvent.setup();
renderSidebar();
await user.click(screen.getByRole("button", { name: "Light" }));
expect(localStorage.getItem("theme")).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("collapsed toggle flips to the opposite of the resolved mode", async () => {
const user = userEvent.setup();
localStorage.setItem("theme", "light");
renderSidebar(true);
await user.click(screen.getByRole("button", { name: "Switch to Dark" }));
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: "Switch to Light" })).toBeInTheDocument();
});
});
@@ -246,6 +246,8 @@ export function AddLaneModal({
cwd: repo,
title: name || undefined,
tool,
// THIS directory, not a lane on a parent of it (#42).
exact: true,
});
// Report the lane the INSTANT it exists server-side. Everything after
// this is post-creation setup that can fail on its own, and letting one
@@ -253,6 +255,18 @@ export function AddLaneModal({
// the list until some unrelated refetch happened to pick it up.
// Worktree mode below already works this way.
onAdded(result.lane);
// Nothing was created: closing now reads as success while no new card
// appears (#42). Say which lane already owns the folder instead.
if (!result.created) {
setError(
t("addLaneExists", {
id: result.lane.id,
title: result.lane.title || result.lane.cwd,
})
);
setBusy(false);
return;
}
// A session that booted on the vendor's API would keep talking to it
// for its whole life, so the router is applied before anything runs.
+13 -4
View File
@@ -45,7 +45,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
return (
<>
<Code text={text.slice(0, from)} lang={lang} />
<span className="rounded-sm bg-white/15">
<span className="rounded-sm bg-fg-primary/15">
<Code text={text.slice(from, to)} lang={lang} />
</span>
<Code text={text.slice(to)} lang={lang} />
@@ -120,12 +120,21 @@ function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
}
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
* separate from context at a glance on every theme (the /10 fill alone did not). */
function rowTint(type: LaneDiffLine["type"] | null) {
if (type === "add") return "bg-status-success/10";
if (type === "del") return "bg-status-danger/10";
if (type === "add")
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
return "";
}
function markerClass(type: LaneDiffLine["type"]) {
if (type === "add") return "font-bold text-status-success";
if (type === "del") return "font-bold text-status-danger";
return "text-fg-muted";
}
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
const at = hunk.header.split("@@")[1];
return (
@@ -153,7 +162,7 @@ function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className="select-none text-fg-muted">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph.get(i)} />
@@ -503,6 +503,7 @@ describe("AddLaneModal — repo mode (adopt)", () => {
cwd: "/Users/tester/projects/repo",
title: "main repo",
tool: "claude",
exact: true,
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
@@ -511,6 +512,28 @@ describe("AddLaneModal — repo mode (adopt)", () => {
// close right away like the old adopt flow did.
expect(onClose).toHaveBeenCalled();
});
it("says the folder already has a lane instead of closing silently (#42)", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 3, kind: "adopted", status: "idle", title: "SG - Services" }),
created: false,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText(/already has a lane.*#3.*SG - Services/)).toBeInTheDocument();
// The existing lane is selected so the user sees it, but nothing claims a new one.
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — folder browse", () => {
@@ -72,3 +72,89 @@ describe("LaneDiffView — side by side", () => {
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
});
});
function fileWith(lines: NonNullable<LaneChangeFile["hunks"]>[number]["lines"]): LaneChangeFile {
return { ...FILE, hunks: [{ ...FILE.hunks![0]!, lines }] };
}
const ROWS = fileWith([
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
{ type: "del", oldLine: 2, newLine: null, text: "const x = 1;" },
{ type: "add", oldLine: null, newLine: 2, text: "const x = 2;" },
]);
/** The unified row whose text contains `needle`. */
function rowOf(container: HTMLElement, needle: string): HTMLElement {
const row = [...container.querySelectorAll<HTMLElement>("div.flex.w-max")].find((r) =>
r.textContent?.includes(needle)
);
if (!row) throw new Error(`no row for ${needle}`);
return row;
}
describe("LaneDiffView — row styling", () => {
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const add = rowOf(container, "x = 2");
const del = rowOf(container, "x = 1");
const ctx = rowOf(container, "keep()");
expect(add.className).toContain("bg-status-success/20");
expect(add.className).toContain("--status-success");
expect(del.className).toContain("bg-status-danger/20");
expect(del.className).toContain("--status-danger");
expect(ctx.className).not.toMatch(/status-(success|danger)/);
});
it("colours the +/- marker bold in its status colour, context marker muted", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const marker = (row: HTMLElement) =>
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
const plus = marker(rowOf(container, "x = 2"));
const minus = marker(rowOf(container, "x = 1"));
const ctx = marker(rowOf(container, "keep()"));
expect(plus.textContent).toBe("+");
expect(plus.className).toContain("font-bold");
expect(plus.className).toContain("text-status-success");
expect(minus.textContent).toBe("-");
expect(minus.className).toContain("text-status-danger");
expect(ctx.className).toContain("text-fg-muted");
expect(ctx.className).not.toContain("font-bold");
});
it("emphasises only the changed span of a paired del/add", () => {
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
(e) => e.textContent
);
expect(emph).toEqual(["1", "2"]);
});
it("does not emphasise a line that changed completely", () => {
const { container } = render(
<LaneDiffView
file={fileWith([
{ type: "del", oldLine: 1, newLine: null, text: "abc" },
{ type: "add", oldLine: null, newLine: 1, text: "xyz" },
])}
layout="unified"
/>
);
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
});
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
render(
<LaneDiffView
file={fileWith([{ type: "add", oldLine: null, newLine: 1, text: "added()" }])}
layout="split"
/>
);
const rows = (id: string) => [
...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max"),
];
const [right] = rows("lane-diff-split-right");
const [left] = rows("lane-diff-split-left");
expect(right!.className).toContain("bg-status-success/20");
expect(left!.className).not.toMatch(/status-(success|danger)/);
});
});
@@ -0,0 +1,128 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance: the Dark / Light / System mode switch and
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
* is painted by the same tokens as the app — no hex is duplicated here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { Monitor, Moon, Palette, Sun } from "lucide-react";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
"intellij-light": "IntelliJ Light",
canvas: "Canvas",
};
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
{ mode: "dark", Icon: Moon },
{ mode: "light", Icon: Sun },
{ mode: "system", Icon: Monitor },
];
function Swatch({ mode, name }: { mode: Theme; name: string }) {
return (
<div
aria-hidden
className={`${mode === "dark" ? "dark" : ""} rounded-md border border-border bg-surface-0 p-2 text-fg-primary`}
data-mode={mode}
data-theme={name === "default" ? undefined : name}
>
<div className="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-accent" />
<span className="h-1.5 w-10 rounded-full bg-fg-primary/70" />
</div>
<div className="font-mono text-[10px] leading-tight">
<span className="text-syntax-keyword">const</span>{" "}
<span className="text-syntax-function">x</span>{" "}
<span className="text-syntax-operator">=</span>{" "}
<span className="text-syntax-string">"ok"</span>
</div>
<div className="flex gap-1">
<span className="h-1.5 flex-1 rounded-full bg-status-success/40" />
<span className="h-1.5 flex-1 rounded-full bg-status-danger/40" />
</div>
</div>
</div>
);
}
export function AppearanceSettings() {
const { t } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
return (
<section id="appearance" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Palette className="w-4 h-4 text-fg-muted" />
{t("appearance.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("appearance.description")}</p>
<div className="card p-5 space-y-5">
<div>
<p className="text-xs font-medium text-fg-secondary mb-2">{t("appearance.mode")}</p>
<div className="inline-flex rounded-lg border border-border bg-surface-2 p-0.5">
{MODES.map(({ mode: m, Icon }) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
aria-pressed={mode === m}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
mode === m
? "bg-accent/20 text-accent"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Icon className="w-3.5 h-3.5" />
{t(`appearance.modes.${m}`)}
</button>
))}
</div>
</div>
{(["dark", "light"] as const).map((group) => (
<div key={group}>
<p className="text-xs font-medium text-fg-secondary mb-2">
{t(`appearance.${group}Themes`)}
{theme === group && (
<span className="ml-2 text-fg-muted font-normal">{t("appearance.active")}</span>
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{THEMES[group].map((name) => {
const selected = themeFor[group] === name;
const label = THEME_LABELS[name] ?? t("appearance.default");
return (
<button
key={name}
type="button"
onClick={() => setThemeFor(group, name)}
aria-pressed={selected}
aria-label={`${t(`appearance.${group}Themes`)}: ${label}`}
className={`rounded-lg border p-1.5 text-left transition-colors ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<Swatch mode={group} name={name} />
<span className="block px-1 pt-1.5 text-xs text-fg-primary">{label}</span>
</button>
);
})}
</div>
</div>
))}
</div>
</section>
);
}
@@ -0,0 +1,60 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AppearanceSettings } from "../AppearanceSettings";
const root = document.documentElement;
describe("AppearanceSettings", () => {
beforeEach(() => {
localStorage.clear();
root.classList.remove("dark");
root.removeAttribute("data-theme");
root.removeAttribute("data-mode");
});
it("renders every theme of both modes, defaults selected", () => {
render(<AppearanceSettings />);
const pressed = screen
.getAllByRole("button", { pressed: true })
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(pressed).toHaveLength(3); // mode "Dark" + both Default cards
for (const name of ["Darcula", "Apple Dark", "Apple Light", "IntelliJ Light", "Canvas"]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
});
it("picking a dark theme applies it at once; a light pick waits for light mode", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /Darcula/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /Canvas/ }));
expect(root.dataset.theme).toBe("darcula");
await user.click(screen.getByRole("button", { name: /^Light$/ }));
expect(root.classList.contains("dark")).toBe(false);
expect(root.dataset.theme).toBe("canvas");
expect(screen.getByRole("button", { name: /Canvas/ })).toHaveAttribute("aria-pressed", "true");
});
it("the System mode button is pressed after choosing it", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
await user.click(screen.getByRole("button", { name: /System/ }));
expect(screen.getByRole("button", { name: /System/ })).toHaveAttribute("aria-pressed", "true");
expect(localStorage.getItem("theme")).toBe("system");
});
it("each swatch scopes its own mode and theme", () => {
const { container } = render(<AppearanceSettings />);
expect(container.querySelector('.dark[data-theme="darcula"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
});
});
+166 -2
View File
@@ -1,10 +1,10 @@
/**
* @file useTheme.test.ts
* @description Tests for useTheme — the dark/light mode hook.
* @description Tests for useTheme — the dark/light/system mode and per-mode named-theme hook.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTheme } from "../useTheme";
@@ -12,9 +12,12 @@ describe("useTheme", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-mode");
});
afterEach(() => {
document.documentElement.classList.remove("dark");
vi.unstubAllGlobals();
});
it("defaults to dark when localStorage is empty", () => {
@@ -46,4 +49,165 @@ describe("useTheme", () => {
expect(result.current.theme).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
it("system mode follows prefers-color-scheme, live", () => {
let dark = true;
const listeners = new Set<() => void>();
vi.stubGlobal("matchMedia", (query: string) => ({
get matches() {
return dark;
},
media: query,
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
}));
const { result } = renderHook(() => useTheme());
act(() => result.current.setMode("system"));
expect(result.current.mode).toBe("system");
expect(result.current.theme).toBe("dark");
expect(localStorage.getItem("theme")).toBe("system");
act(() => {
dark = false;
listeners.forEach((fn) => fn());
});
expect(result.current.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(document.documentElement.dataset.mode).toBe("light");
});
it("system mode without matchMedia falls back to dark", () => {
vi.stubGlobal("matchMedia", undefined);
localStorage.setItem("theme", "system");
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
});
it("each mode remembers its own named theme", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("light", "canvas"));
expect(document.documentElement.dataset.theme).toBe("darcula");
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
act(() => result.current.toggleTheme());
expect(result.current.theme).toBe("light");
expect(document.documentElement.dataset.theme).toBe("canvas");
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "canvas" });
});
it("the default theme carries no data-theme attribute", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("dark", "default"));
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("an unknown stored theme id falls back to default", () => {
localStorage.setItem("agenthub.theme.dark", "no-such-theme");
const { result } = renderHook(() => useTheme());
expect(result.current.themeFor.dark).toBe("default");
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("a light theme id is never applied in dark mode", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "canvas"));
expect(result.current.themeFor.dark).toBe("default");
expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
});
it("two hook instances stay in sync", () => {
const a = renderHook(() => useTheme());
const b = renderHook(() => useTheme());
act(() => a.result.current.setTheme("light"));
act(() => a.result.current.setThemeFor("light", "apple-light"));
expect(b.result.current.theme).toBe("light");
expect(b.result.current.themeFor.light).toBe("apple-light");
});
it("another tab's storage write updates a mounted hook", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
act(() => {
localStorage.setItem("theme", "light");
localStorage.setItem("agenthub.theme.light", "canvas");
window.dispatchEvent(new StorageEvent("storage", { key: "theme" }));
});
expect(result.current.theme).toBe("light");
expect(document.documentElement.dataset.theme).toBe("canvas");
expect(document.documentElement.dataset.mode).toBe("light");
});
it("disabled storage (throwing) falls back to dark/default and setters do not crash", () => {
const get = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("SecurityError");
});
const set = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError");
});
try {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
expect(result.current.mode).toBe("dark");
expect(result.current.themeFor).toEqual({ dark: "default", light: "default" });
expect(() => act(() => result.current.setMode("light"))).not.toThrow();
expect(() => act(() => result.current.setThemeFor("dark", "darcula"))).not.toThrow();
expect(() => act(() => result.current.toggleTheme())).not.toThrow();
expect(result.current.theme).toBe("dark");
} finally {
get.mockRestore();
set.mockRestore();
}
});
it("setThemeFor with an unknown id keeps the previous pick", () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setThemeFor("dark", "darcula"));
act(() => result.current.setThemeFor("dark", "no-such-theme"));
act(() => result.current.setThemeFor("light", ""));
expect(result.current.themeFor).toEqual({ dark: "darcula", light: "default" });
expect(localStorage.getItem("agenthub.theme.dark")).toBe("darcula");
expect(localStorage.getItem("agenthub.theme.light")).toBeNull();
expect(document.documentElement.dataset.theme).toBe("darcula");
});
it("an unknown stored mode falls back to dark", () => {
localStorage.setItem("theme", "blue");
const { result } = renderHook(() => useTheme());
expect(result.current.mode).toBe("dark");
expect(result.current.theme).toBe("dark");
expect(document.documentElement.dataset.mode).toBe("dark");
});
it.each([
[true, "light"],
[false, "dark"],
])("toggle from system (OS dark=%s) sets the explicit opposite: %s", (osDark, expected) => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: osDark,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
localStorage.setItem("theme", "system");
const { result } = renderHook(() => useTheme());
expect(result.current.mode).toBe("system");
act(() => result.current.toggleTheme());
expect(result.current.mode).toBe(expected);
expect(result.current.theme).toBe(expected);
expect(localStorage.getItem("theme")).toBe(expected);
});
it("unmount removes the matchMedia listener", () => {
const listeners = new Set<() => void>();
vi.stubGlobal("matchMedia", (query: string) => ({
matches: true,
media: query,
addEventListener: (_: string, fn: () => void) => listeners.add(fn),
removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
}));
const { unmount } = renderHook(() => useTheme());
expect(listeners.size).toBeGreaterThan(0);
unmount();
expect(listeners.size).toBe(0);
});
});
+99 -24
View File
@@ -1,60 +1,135 @@
/**
* @file useTheme.ts
* @description Dark/light mode state, backed by `localStorage` and the `dark`
* class on `<html>` that Tailwind's `darkMode: "class"` reads. Default is
* dark; there is no `prefers-color-scheme` fallback.
* @description Appearance state: a mode (`dark` | `light` | `system`) plus one
* named theme remembered PER mode, all backed by `localStorage`. Applies the
* `dark` class Tailwind's `darkMode: "class"` reads, `data-mode`, and a
* `data-theme` attribute (absent for `default`) on `<html>`; the palettes
* themselves are CSS-variable blocks in `index.css`. State is never cached in
* the module — every instance reads storage + `matchMedia` through
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
* another tab's change arrives through the `storage` event.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
export type Theme = "dark" | "light";
export type ThemeMode = Theme | "system";
const STORAGE_KEY = "theme";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark"],
light: ["default", "apple-light", "intellij-light", "canvas"],
} as const satisfies Record<Theme, readonly string[]>;
function readStoredTheme(): Theme {
const MODE_KEY = "theme";
const themeKey = (mode: Theme) => `agenthub.theme.${mode}`;
const CHANGE_EVENT = "agenthub-theme";
const SYSTEM_QUERY = "(prefers-color-scheme: dark)";
function read(key: string): string | null {
try {
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
return localStorage.getItem(key);
} catch {
return "dark";
return null;
}
}
function writeStoredTheme(theme: Theme): void {
function write(key: string, value: string): void {
try {
localStorage.setItem(STORAGE_KEY, theme);
localStorage.setItem(key, value);
} catch {
/* ignore quota / disabled storage */
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}
function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle("dark", theme === "dark");
function systemQuery(): MediaQueryList | undefined {
return typeof window.matchMedia === "function" ? window.matchMedia(SYSTEM_QUERY) : undefined;
}
function readMode(): ThemeMode {
const v = read(MODE_KEY);
return v === "light" || v === "system" ? v : "dark";
}
function readThemeFor(mode: Theme): string {
const v = read(themeKey(mode));
return v && (THEMES[mode] as readonly string[]).includes(v) ? v : "default";
}
// A primitive snapshot: stable by value, so useSyncExternalStore never loops.
function getSnapshot(): string {
const mode = readMode();
const resolved: Theme =
mode === "system" ? (systemQuery()?.matches === false ? "light" : "dark") : mode;
return [mode, resolved, readThemeFor("dark"), readThemeFor("light")].join("|");
}
function subscribe(onChange: () => void): () => void {
const mq = systemQuery();
window.addEventListener(CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
mq?.addEventListener?.("change", onChange);
return () => {
window.removeEventListener(CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
mq?.removeEventListener?.("change", onChange);
};
}
function apply(resolved: Theme, name: string): void {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.dataset.mode = resolved;
if (name === "default") root.removeAttribute("data-theme");
else root.dataset.theme = name;
}
/**
* Read/write the dashboard's active color theme.
* @returns the current theme, a setter for an explicit value, and a toggle.
* Read/write the dashboard's appearance.
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
* the user picked; `themeFor` — the named theme each mode remembers; setters
* for each, and `toggleTheme`, which sets the explicit opposite of `theme`.
*/
export function useTheme(): {
theme: Theme;
mode: ThemeMode;
themeFor: Record<Theme, string>;
setTheme: (theme: Theme) => void;
setMode: (mode: ThemeMode) => void;
setThemeFor: (mode: Theme, name: string) => void;
toggleTheme: () => void;
} {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
const [mode, resolved, dark, light] = useSyncExternalStore(subscribe, getSnapshot).split("|") as [
ThemeMode,
Theme,
string,
string,
];
const active = resolved === "dark" ? dark : light;
useEffect(() => {
applyTheme(theme);
}, [theme]);
useLayoutEffect(() => {
apply(resolved, active);
}, [resolved, active]);
const setTheme = useCallback((next: Theme) => {
writeStoredTheme(next);
setThemeState(next);
const setMode = useCallback((next: ThemeMode) => write(MODE_KEY, next), []);
const setThemeFor = useCallback((forMode: Theme, name: string) => {
if ((THEMES[forMode] as readonly string[]).includes(name)) write(themeKey(forMode), name);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
setMode(resolved === "dark" ? "light" : "dark");
}, [resolved, setMode]);
return { theme, setTheme, toggleTheme };
return {
theme: resolved,
mode,
themeFor: { dark, light },
setTheme: setMode,
setMode,
setThemeFor,
toggleTheme,
};
}
+1
View File
@@ -34,6 +34,7 @@
"addLaneBranchPlaceholder": "feat/my-feature",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
"addLaneProvisionFailed": "Worktree provisioning failed or timed out — the lane exists but auto-setup was skipped. Check the lane's git facts, then run agents/mcp setup manually.",
"addLaneRepoHint": "An existing git repo. The dashboard creates a new worktree for the lane, not a folder you pick.",
+2 -1
View File
@@ -54,7 +54,8 @@
"theme": "Theme",
"themeNames": {
"dark": "Dark",
"light": "Light"
"light": "Light",
"system": "System"
},
"throughput60s": "Throughput · last 60s",
"topEventTypes": "Top event types",
+14
View File
@@ -495,5 +495,19 @@
"testing": "Testing…",
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
"testFailed": "No answer ({{status}}): {{message}}"
},
"appearance": {
"title": "Appearance",
"description": "Colour mode and theme. Saved in this browser only.",
"mode": "Mode",
"modes": {
"dark": "Dark",
"light": "Light",
"system": "System"
},
"darkThemes": "Dark themes",
"lightThemes": "Light themes",
"active": "· in use now",
"default": "Default"
}
}
+1
View File
@@ -34,6 +34,7 @@
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
"addLaneProvisionFailed": "Tạo worktree thất bại hoặc quá thời gian chờ — lane vẫn tồn tại nhưng tự động thiết lập đã bị bỏ qua. Kiểm tra git facts của lane, rồi chạy thiết lập agents/mcp thủ công.",
"addLaneRepoHint": "Một repo git có sẵn. Dashboard tự tạo worktree mới cho lane, không phải thư mục bạn chọn.",
+2 -1
View File
@@ -54,7 +54,8 @@
"theme": "Giao diện",
"themeNames": {
"dark": "Tối",
"light": "Sáng"
"light": "Sáng",
"system": "Theo hệ thống"
},
"throughput60s": "Lưu lượng · 60s qua",
"topEventTypes": "Loại sự kiện phổ biến",
+14
View File
@@ -495,5 +495,19 @@
"testing": "Đang kiểm tra…",
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
"testFailed": "Không trả lời ({{status}}): {{message}}"
},
"appearance": {
"title": "Giao diện",
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
"mode": "Chế độ",
"modes": {
"dark": "Tối",
"light": "Sáng",
"system": "Theo hệ thống"
},
"darkThemes": "Theme tối",
"lightThemes": "Theme sáng",
"active": "· đang dùng",
"default": "Mặc định"
}
}
+176 -5
View File
@@ -24,8 +24,11 @@
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
*/
:root {
:root,
[data-mode="light"] {
/* Light theme — the CSS default, so it applies with no class on <html>.
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
page (the Settings appearance cards).
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
as it does in dark: the page is the DARKEST step and a card is brighter
than the page it sits on. The previous light block used the scale in its
@@ -73,7 +76,9 @@
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
--tint-base: 245 243 236; /* = surface-3 */
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
--tint-base: var(--surface-3);
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
@@ -87,7 +92,7 @@
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
--syn-plain: 31 30 27; /* = fg-primary */
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
}
@@ -116,7 +121,7 @@
--status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
--tint-base: 39 42 45; /* = surface-3 */
--tint-base: var(--surface-3);
/* Unchanged from the pastels this file has always drawn code in. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
@@ -128,11 +133,177 @@
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
--syn-tag: 253 164 175; /* rose-300 #fda4af */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
--syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */
}
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked in
* Settings → Appearance). Each overrides only what differs from its mode's
* default block above; `--tint-base` and light `--syn-plain` are derived there.
* Dark ones pair with `.dark`, light ones with `[data-mode="light"]`, so a
* theme can never paint over the wrong mode whatever order the attributes
* land in. Contrast is held to the defaults' own bar: fg-muted ≥4.5:1 on a
* light card, status labels ≥4.5:1 on their own /20 chip in light themes. */
.dark[data-theme="darcula"] {
--surface-0: 30 31 32; /* #1e1f20 */
--surface-1: 35 36 37; /* #232425 */
--surface-2: 39 41 42; /* #27292a */
--surface-3: 43 43 43; /* #2b2b2b */
--surface-4: 51 53 55; /* #333537 */
--surface-5: 60 63 65; /* #3c3f41 */
--border: 63 66 68; /* #3f4244 */
--border-light: 77 81 83; /* #4d5153 */
--accent: 53 146 196; /* #3592c4 */
--accent-hover: 74 163 214; /* #4aa3d6 */
--fg-primary: 220 220 220; /* #dcdcdc */
--fg-secondary: 169 183 198; /* #a9b7c6 */
--fg-muted: 140 146 150; /* #8c9296 */
--status-success: 126 194 135; /* #7ec287 */
--status-danger: 255 143 140; /* #ff8f8c */
--status-warning: 232 191 106; /* #e8bf6a */
--syn-comment: 128 128 128; /* #808080 */
--syn-string: 127 159 105; /* #7f9f69 */
--syn-number: 104 151 187; /* #6897bb */
--syn-keyword: 204 120 50; /* #cc7832 */
--syn-builtin: 136 136 198; /* #8888c6 */
--syn-function: 255 198 109; /* #ffc66d */
--syn-operator: 169 183 198; /* #a9b7c6 */
--syn-punctuation: 169 183 198; /* #a9b7c6 */
--syn-property: 168 139 192; /* #a88bc0 */
--syn-tag: 232 191 106; /* #e8bf6a */
--syn-add: 106 171 115; /* #6aab73 */
--syn-del: 255 107 104; /* #ff6b68 */
--syn-plain: 169 183 198; /* #a9b7c6 */
}
.dark[data-theme="apple-dark"] {
--surface-0: 22 22 24; /* #161618 */
--surface-1: 28 28 30; /* #1c1c1e */
--surface-2: 36 36 38; /* #242426 */
--surface-3: 44 44 46; /* #2c2c2e */
--surface-4: 54 54 56; /* #363638 */
--surface-5: 58 58 60; /* #3a3a3c */
--border: 56 56 58; /* #38383a */
--border-light: 72 72 74; /* #48484a */
--accent: 10 132 255; /* #0a84ff */
--accent-hover: 64 156 255; /* #409cff */
--fg-primary: 245 245 247; /* #f5f5f7 */
--fg-secondary: 174 174 178; /* #aeaeb2 */
--fg-muted: 142 142 147; /* #8e8e93 */
--status-success: 48 209 88; /* #30d158 */
--status-danger: 255 140 134; /* #ff8c86 */
--status-warning: 255 214 10; /* #ffd60a */
--syn-comment: 127 140 152; /* #7f8c98 */
--syn-string: 252 106 93; /* #fc6a5d */
--syn-number: 208 191 105; /* #d0bf69 */
--syn-keyword: 252 95 163; /* #fc5fa3 */
--syn-builtin: 93 216 255; /* #5dd8ff */
--syn-function: 103 183 164; /* #67b7a4 */
--syn-operator: 161 161 166; /* #a1a1a6 */
--syn-punctuation: 161 161 166; /* #a1a1a6 */
--syn-property: 181 140 240; /* #b58cf0 */
--syn-tag: 252 95 163; /* #fc5fa3 */
--syn-add: 48 209 88; /* #30d158 */
--syn-del: 255 105 97; /* #ff6961 */
--syn-plain: 229 229 234; /* #e5e5ea */
}
[data-mode="light"][data-theme="apple-light"] {
--surface-0: 233 233 238; /* #e9e9ee */
--surface-1: 239 239 244; /* #efeff4 */
--surface-2: 245 245 248; /* #f5f5f8 */
--surface-3: 251 251 253; /* #fbfbfd */
--surface-4: 253 253 254; /* #fdfdfe */
--surface-5: 255 255 255; /* #ffffff */
--border: 209 209 214; /* #d1d1d6 */
--border-light: 229 229 234; /* #e5e5ea */
--accent: 0 102 204; /* #0066cc */
--accent-hover: 0 85 179; /* #0055b3 */
--fg-primary: 29 29 31; /* #1d1d1f */
--fg-secondary: 81 81 84; /* #515154 */
--fg-muted: 110 110 115; /* #6e6e73 */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 6px rgb(0 0 0 / 0.06);
--status-success: 23 107 46; /* #176b2e */
--status-danger: 179 0 14; /* #b3000e */
--status-warning: 122 79 0; /* #7a4f00 */
--syn-comment: 93 108 121; /* #5d6c79 */
--syn-string: 196 26 22; /* #c41a16 */
--syn-number: 28 0 207; /* #1c00cf */
--syn-keyword: 155 35 147; /* #9b2393 */
--syn-builtin: 11 79 121; /* #0b4f79 */
--syn-function: 50 109 116; /* #326d74 */
--syn-operator: 75 75 80; /* #4b4b50 */
--syn-punctuation: 75 75 80; /* #4b4b50 */
--syn-property: 57 0 160; /* #3900a0 */
--syn-tag: 155 35 147; /* #9b2393 */
--syn-add: 31 122 54; /* #1f7a36 */
--syn-del: 197 0 15; /* #c5000f */
}
[data-mode="light"][data-theme="intellij-light"] {
--surface-0: 236 238 242; /* #eceef2 */
--surface-1: 241 242 245; /* #f1f2f5 */
--surface-2: 247 248 250; /* #f7f8fa */
--surface-3: 253 253 254; /* #fdfdfe */
--surface-4: 255 255 255; /* #ffffff */
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 47 106 227; /* #2f6ae3 */
--accent-hover: 39 88 192; /* #2758c0 */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 108 112 126; /* #6c707e */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
}
[data-mode="light"][data-theme="canvas"] {
--surface-0: 227 220 205; /* #e3dccd */
--surface-1: 232 226 212; /* #e8e2d4 */
--surface-2: 237 232 220; /* #ede8dc */
--surface-3: 243 239 230; /* #f3efe6 */
--surface-4: 247 244 237; /* #f7f4ed */
--surface-5: 250 248 243; /* #faf8f3 */
--border: 203 191 166; /* #cbbfa6 */
--border-light: 220 211 192; /* #dcd3c0 */
--accent: 122 82 48; /* #7a5230 */
--accent-hover: 102 63 34; /* #663f22 */
--fg-primary: 43 38 32; /* #2b2620 */
--fg-secondary: 94 85 74; /* #5e554a */
--fg-muted: 107 97 85; /* #6b6155 */
--status-success: 54 93 36; /* #365d24 */
--status-danger: 147 41 31; /* #93291f */
--status-warning: 109 76 0; /* #6d4c00 */
--syn-comment: 125 114 98; /* #7d7262 */
--syn-string: 77 107 31; /* #4d6b1f */
--syn-number: 154 91 0; /* #9a5b00 */
--syn-keyword: 138 59 18; /* #8a3b12 */
--syn-builtin: 47 94 110; /* #2f5e6e */
--syn-function: 122 90 0; /* #7a5a00 */
--syn-operator: 138 48 80; /* #8a3050 */
--syn-punctuation: 107 97 85; /* #6b6155 */
--syn-property: 59 102 96; /* #3b6660 */
--syn-tag: 142 47 47; /* #8e2f2f */
--syn-add: 77 107 31; /* #4d6b1f */
--syn-del: 159 47 35; /* #9f2f23 */
}
@layer base {
* {
scrollbar-width: thin;
+8 -1
View File
@@ -2297,7 +2297,14 @@ export const api = {
* location isn't tied to a specific project (e.g. Sagi's mascot lane).
* @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created.
*/
ensure: (body: { cwd?: string; title?: string; pipeline?: string; tool?: LaneTool }) =>
ensure: (body: {
cwd?: string;
title?: string;
pipeline?: string;
tool?: LaneTool;
/** Only a lane on THIS directory counts, not one on a parent of it. */
exact?: boolean;
}) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
method: "POST",
body: JSON.stringify(body),
+3 -3
View File
@@ -1037,7 +1037,7 @@ export function highlight(source: string, lang: string): Token[] {
* (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
* pastel one this file has always used, and light mode takes the same hues
* four steps darker, because a pastel keyword on a light code block is
* unreadable. Diff rows keep their faint ±10% tint, which works on both.
* unreadable. Diff rows take a /20 status tint, pre-mixed per theme.
*/
export function tokenClass(type: TokenType): string {
switch (type) {
@@ -1068,9 +1068,9 @@ export function tokenClass(type: TokenType): string {
case "boolean":
return "text-syntax-number"; // literals grouped with numbers by colour
case "diff-add":
return "text-syntax-add bg-emerald-500/10"; // green text + faint green row tint
return "text-syntax-add bg-status-success/20"; // green text + theme-aware green row tint
case "diff-del":
return "text-syntax-del bg-red-500/10"; // red text + faint red row tint
return "text-syntax-del bg-status-danger/20"; // red text + theme-aware red row tint
case "diff-meta":
return "text-syntax-keyword";
case "plain":
+5
View File
@@ -59,6 +59,7 @@ import { useEffect, useState, useCallback, useRef, useSyncExternalStore, Fragmen
import { useTranslation } from "react-i18next";
import {
DollarSign,
Palette,
Plus,
Pencil,
Trash2,
@@ -118,6 +119,7 @@ import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import type { ModelPricing, WSMessage } from "../lib/types";
// In-page navigation for the (dense) Settings screen. Each entry maps to a
@@ -128,6 +130,7 @@ const SETTINGS_SECTIONS: {
fallback?: string;
Icon: typeof DollarSign;
}[] = [
{ id: "appearance", labelKey: "appearance.title", Icon: Palette },
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
@@ -1080,6 +1083,8 @@ export function Settings() {
</div>
</div>
<AppearanceSettings />
{/* ─── MODEL PRICING ─── */}
<section id="pricing" className="scroll-mt-24">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
@@ -6945,6 +6945,52 @@ exports[`screen snapshots > Settings 1`] = `
<div
class="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1"
>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
type="button"
>
<svg
class="lucide lucide-palette w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="13.5"
cy="6.5"
fill="currentColor"
r=".5"
/>
<circle
cx="17.5"
cy="10.5"
fill="currentColor"
r=".5"
/>
<circle
cx="8.5"
cy="7.5"
fill="currentColor"
r=".5"
/>
<circle
cx="6.5"
cy="12.5"
fill="currentColor"
r=".5"
/>
<path
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
/>
</svg>
Appearance
</button>
<button
class="inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 bg-accent/15 border-accent/30 text-accent"
type="button"
@@ -7342,6 +7388,702 @@ exports[`screen snapshots > Settings 1`] = `
</div>
</div>
</div>
<section
class="scroll-mt-24"
id="appearance"
>
<h3
class="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1"
>
<svg
class="lucide lucide-palette w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="13.5"
cy="6.5"
fill="currentColor"
r=".5"
/>
<circle
cx="17.5"
cy="10.5"
fill="currentColor"
r=".5"
/>
<circle
cx="8.5"
cy="7.5"
fill="currentColor"
r=".5"
/>
<circle
cx="6.5"
cy="12.5"
fill="currentColor"
r=".5"
/>
<path
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
/>
</svg>
Appearance
</h3>
<p
class="text-xs text-fg-muted mb-4"
>
Colour mode and theme. Saved in this browser only.
</p>
<div
class="card p-5 space-y-5"
>
<div>
<p
class="text-xs font-medium text-fg-secondary mb-2"
>
Mode
</p>
<div
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
>
<button
aria-pressed="true"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
type="button"
>
<svg
class="lucide lucide-moon w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"
/>
</svg>
Dark
</button>
<button
aria-pressed="false"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
type="button"
>
<svg
class="lucide lucide-sun w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="4"
/>
<path
d="M12 2v2"
/>
<path
d="M12 20v2"
/>
<path
d="m4.93 4.93 1.41 1.41"
/>
<path
d="m17.66 17.66 1.41 1.41"
/>
<path
d="M2 12h2"
/>
<path
d="M20 12h2"
/>
<path
d="m6.34 17.66-1.41 1.41"
/>
<path
d="m19.07 4.93-1.41 1.41"
/>
</svg>
Light
</button>
<button
aria-pressed="false"
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
type="button"
>
<svg
class="lucide lucide-monitor w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="14"
rx="2"
width="20"
x="2"
y="3"
/>
<line
x1="8"
x2="16"
y1="21"
y2="21"
/>
<line
x1="12"
x2="12"
y1="17"
y2="21"
/>
</svg>
System
</button>
</div>
</div>
<div>
<p
class="text-xs font-medium text-fg-secondary mb-2"
>
Dark themes
<span
class="ml-2 text-fg-muted font-normal"
>
· in use now
</span>
</p>
<div
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"
>
<button
aria-label="Dark themes: Default"
aria-pressed="true"
class="rounded-lg border p-1.5 text-left transition-colors border-accent ring-2 ring-accent/40"
type="button"
>
<div
aria-hidden="true"
class="dark rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
data-mode="dark"
>
<div
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
>
<div
class="flex items-center gap-1.5"
>
<span
class="h-2 w-2 rounded-full bg-accent"
/>
<span
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
/>
</div>
<div
class="font-mono text-[10px] leading-tight"
>
<span
class="text-syntax-keyword"
>
const
</span>
<span
class="text-syntax-function"
>
x
</span>
<span
class="text-syntax-operator"
>
=
</span>
<span
class="text-syntax-string"
>
"ok"
</span>
</div>
<div
class="flex gap-1"
>
<span
class="h-1.5 flex-1 rounded-full bg-status-success/40"
/>
<span
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
/>
</div>
</div>
</div>
<span
class="block px-1 pt-1.5 text-xs text-fg-primary"
>
Default
</span>
</button>
<button
aria-label="Dark themes: Darcula"
aria-pressed="false"
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
type="button"
>
<div
aria-hidden="true"
class="dark rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
data-mode="dark"
data-theme="darcula"
>
<div
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
>
<div
class="flex items-center gap-1.5"
>
<span
class="h-2 w-2 rounded-full bg-accent"
/>
<span
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
/>
</div>
<div
class="font-mono text-[10px] leading-tight"
>
<span
class="text-syntax-keyword"
>
const
</span>
<span
class="text-syntax-function"
>
x
</span>
<span
class="text-syntax-operator"
>
=
</span>
<span
class="text-syntax-string"
>
"ok"
</span>
</div>
<div
class="flex gap-1"
>
<span
class="h-1.5 flex-1 rounded-full bg-status-success/40"
/>
<span
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
/>
</div>
</div>
</div>
<span
class="block px-1 pt-1.5 text-xs text-fg-primary"
>
Darcula
</span>
</button>
<button
aria-label="Dark themes: Apple Dark"
aria-pressed="false"
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
type="button"
>
<div
aria-hidden="true"
class="dark rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
data-mode="dark"
data-theme="apple-dark"
>
<div
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
>
<div
class="flex items-center gap-1.5"
>
<span
class="h-2 w-2 rounded-full bg-accent"
/>
<span
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
/>
</div>
<div
class="font-mono text-[10px] leading-tight"
>
<span
class="text-syntax-keyword"
>
const
</span>
<span
class="text-syntax-function"
>
x
</span>
<span
class="text-syntax-operator"
>
=
</span>
<span
class="text-syntax-string"
>
"ok"
</span>
</div>
<div
class="flex gap-1"
>
<span
class="h-1.5 flex-1 rounded-full bg-status-success/40"
/>
<span
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
/>
</div>
</div>
</div>
<span
class="block px-1 pt-1.5 text-xs text-fg-primary"
>
Apple Dark
</span>
</button>
</div>
</div>
<div>
<p
class="text-xs font-medium text-fg-secondary mb-2"
>
Light themes
</p>
<div
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"
>
<button
aria-label="Light themes: Default"
aria-pressed="true"
class="rounded-lg border p-1.5 text-left transition-colors border-accent ring-2 ring-accent/40"
type="button"
>
<div
aria-hidden="true"
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
data-mode="light"
>
<div
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
>
<div
class="flex items-center gap-1.5"
>
<span
class="h-2 w-2 rounded-full bg-accent"
/>
<span
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
/>
</div>
<div
class="font-mono text-[10px] leading-tight"
>
<span
class="text-syntax-keyword"
>
const
</span>
<span
class="text-syntax-function"
>
x
</span>
<span
class="text-syntax-operator"
>
=
</span>
<span
class="text-syntax-string"
>
"ok"
</span>
</div>
<div
class="flex gap-1"
>
<span
class="h-1.5 flex-1 rounded-full bg-status-success/40"
/>
<span
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
/>
</div>
</div>
</div>
<span
class="block px-1 pt-1.5 text-xs text-fg-primary"
>
Default
</span>
</button>
<button
aria-label="Light themes: Apple Light"
aria-pressed="false"
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
type="button"
>
<div
aria-hidden="true"
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
data-mode="light"
data-theme="apple-light"
>
<div
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
>
<div
class="flex items-center gap-1.5"
>
<span
class="h-2 w-2 rounded-full bg-accent"
/>
<span
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
/>
</div>
<div
class="font-mono text-[10px] leading-tight"
>
<span
class="text-syntax-keyword"
>
const
</span>
<span
class="text-syntax-function"
>
x
</span>
<span
class="text-syntax-operator"
>
=
</span>
<span
class="text-syntax-string"
>
"ok"
</span>
</div>
<div
class="flex gap-1"
>
<span
class="h-1.5 flex-1 rounded-full bg-status-success/40"
/>
<span
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
/>
</div>
</div>
</div>
<span
class="block px-1 pt-1.5 text-xs text-fg-primary"
>
Apple Light
</span>
</button>
<button
aria-label="Light themes: IntelliJ Light"
aria-pressed="false"
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
type="button"
>
<div
aria-hidden="true"
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
data-mode="light"
data-theme="intellij-light"
>
<div
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
>
<div
class="flex items-center gap-1.5"
>
<span
class="h-2 w-2 rounded-full bg-accent"
/>
<span
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
/>
</div>
<div
class="font-mono text-[10px] leading-tight"
>
<span
class="text-syntax-keyword"
>
const
</span>
<span
class="text-syntax-function"
>
x
</span>
<span
class="text-syntax-operator"
>
=
</span>
<span
class="text-syntax-string"
>
"ok"
</span>
</div>
<div
class="flex gap-1"
>
<span
class="h-1.5 flex-1 rounded-full bg-status-success/40"
/>
<span
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
/>
</div>
</div>
</div>
<span
class="block px-1 pt-1.5 text-xs text-fg-primary"
>
IntelliJ Light
</span>
</button>
<button
aria-label="Light themes: Canvas"
aria-pressed="false"
class="rounded-lg border p-1.5 text-left transition-colors border-border hover:border-border-light"
type="button"
>
<div
aria-hidden="true"
class=" rounded-md border border-border bg-surface-0 p-2 text-fg-primary"
data-mode="light"
data-theme="canvas"
>
<div
class="rounded bg-surface-3 p-2 shadow-[var(--shadow-card)] space-y-1.5"
>
<div
class="flex items-center gap-1.5"
>
<span
class="h-2 w-2 rounded-full bg-accent"
/>
<span
class="h-1.5 w-10 rounded-full bg-fg-primary/70"
/>
</div>
<div
class="font-mono text-[10px] leading-tight"
>
<span
class="text-syntax-keyword"
>
const
</span>
<span
class="text-syntax-function"
>
x
</span>
<span
class="text-syntax-operator"
>
=
</span>
<span
class="text-syntax-string"
>
"ok"
</span>
</div>
<div
class="flex gap-1"
>
<span
class="h-1.5 flex-1 rounded-full bg-status-success/40"
/>
<span
class="h-1.5 flex-1 rounded-full bg-status-danger/40"
/>
</div>
</div>
</div>
<span
class="block px-1 pt-1.5 text-xs text-fg-primary"
>
Canvas
</span>
</button>
</div>
</div>
</div>
</section>
<section
class="scroll-mt-24"
id="pricing"