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