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
@@ -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();
});
});