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