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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user