Compare commits

..
8 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 2c8956e634 fix(maestro): reset pending state when kill dialog transitions to force mode
ci / web (push) Successful in 38s
ci / flake (push) Successful in 3m51s
ci / rust (push) Has been cancelled
The onConfirm handler in Sidebar resolves (not rejects) when the daemon
refuses an unforced kill — it mutates state to switch to force mode and
returns. That left `pending` stuck at true in the KillDialog, so the
"Force kill" button rendered disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:24:34 +01:00
Miguel Palhas f2c0882c36 Merge remote-tracking branch 'origin/main' into xylophone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m40s
ci / rust (push) Has been cancelled
# Conflicts:
#	.claude/settings.local.json
2026-09-10 17:17:32 +01:00
Miguel PalhasandClaude Opus 4.6 c170cd9d60 fix(maestro): restore sidebar footer, fix spawn modal and session card regressions
Post-React-migration regressions: spawn submit button moved to modal
footer, sidebar footer restored (help/theme/palette/status/spawn),
session card stripped of redundant Tailwind, action buttons use proper
leaf-act/leaf-actions CSS structure for hover-only visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:17:22 +01:00
Miguel PalhasandClaude Opus 4.6 ebf943bf30 chore(maestro): stop tracking settings.local.json
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:50:31 +01:00
Miguel PalhasandClaude Opus 5 645aa2dea0 fix(maestro): drop border and background on composer stop button
ci / web (push) Failing after 6s
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:48:02 +01:00
Miguel Palhas 3a2513c78d Merge branch 'tuba-2'
ci / web (push) Successful in 34s
ci / flake (push) Successful in 5m32s
ci / rust (push) Successful in 8m1s
2026-09-10 16:39:27 +01:00
Miguel PalhasandClaude Opus 4.6 142e9a4870 chore(maestro): update local tool permissions
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:24 +01:00
Miguel PalhasandClaude Opus 4.6 e5c17606d0 fix(maestro): update pnpmDeps hash for tailwindcss 4.3.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:35:37 +01:00
15 changed files with 238 additions and 86 deletions
-25
View File
@@ -1,25 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo test:*)",
"Bash(source:*)",
"Bash(git remote get-url:*)",
"Bash(git push:*)",
"Bash(git checkout:*)",
"Bash(git -C /home/naps62/tea/maestro merge grazioso-2 --no-edit)",
"Bash(git -C /home/naps62/tea/maestro push)",
"Bash(curl:*)"
]
}
}
+3
View File
@@ -15,3 +15,6 @@ worktrees/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
# Per-machine Claude Code tool permissions
.claude/settings.local.json
+1 -1
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
hash = "sha256-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ=";
};
buildPhase = ''
+1
View File
@@ -314,6 +314,7 @@ function Shell() {
activeId={focusedSessionId}
openIds={openIds}
previewId={previewedSessionId}
linkStore={linkStore}
onSelect={(id) => focusSession(id)}
onPreview={preview}
onSpawn={openPlainSpawn}
+7 -1
View File
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { TOGGLE_HELP_EVENT } from "../../uiEvents.js";
import { groupedCommands } from "./model.js";
export function HelpOverlay() {
@@ -18,8 +19,13 @@ export function HelpOverlay() {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
};
const onToggle = () => setOpen((o) => !o);
window.addEventListener("keydown", onKeydown);
return () => window.removeEventListener("keydown", onKeydown);
document.addEventListener(TOGGLE_HELP_EVENT, onToggle);
return () => {
window.removeEventListener("keydown", onKeydown);
document.removeEventListener(TOGGLE_HELP_EVENT, onToggle);
};
}, [open]);
if (!open) return null;
@@ -71,4 +71,33 @@ describe("KillDialog", () => {
await waitFor(() => expect(kill.disabled).toBe(false));
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
});
it("resets pending when force changes, so the force button is clickable", async () => {
// Simulate the Sidebar flow: onConfirm resolves (parent switches to
// force mode via rerender), but pending was set to true before the call.
const onConfirm = vi.fn(() => Promise.resolve());
const { rerender } = render(
<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />,
);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(kill.disabled).toBe(true);
// Parent re-renders with force=true (the daemon refused the unforced kill).
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
rerender(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 1, untracked_files: 0, unpushed_commits: 0 }}
force
reason="worktree is dirty"
onConfirm={onConfirm}
onCancel={vi.fn()}
/>,
);
const forceBtn = screen.getByRole("button", { name: "Force kill" }) as HTMLButtonElement;
expect(forceBtn.disabled).toBe(false);
});
});
@@ -34,6 +34,11 @@ export function KillDialog(props: KillDialogProps) {
// asked for the kill, so Enter confirms it.
useEffect(() => confirmRef.current?.focus(), []);
// When the dialog transitions from normal → force mode, the previous
// onConfirm resolved without error (it mutated parent state and returned),
// so `pending` is still true. Reset it so the force button is clickable.
useEffect(() => setPending(false), [force]);
const handleConfirm = () => {
setPending(true);
setError(null);
+6 -6
View File
@@ -62,7 +62,7 @@ export function SessionCard(props: SessionCardProps) {
return (
<a
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
className={`session-card tree-leaf no-underline ${rowFill(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
@@ -84,7 +84,7 @@ export function SessionCard(props: SessionCardProps) {
}}
>
<ActivityRail activity={s.activity} />
<span className="flex min-w-0 items-center gap-sp-2">
<span className="card-line1">
{renaming ? (
<RenameField
session={s}
@@ -93,17 +93,17 @@ export function SessionCard(props: SessionCardProps) {
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-fs-13 ${
className={`leaf-name min-w-0 flex-1 truncate text-fs-13 ${
isProvisionalName(s) ? "text-muted" : "text-ink"
}`}
>
{name}
</span>
)}
<span className="flex flex-none items-center gap-[2px]">
<span className="leaf-actions">
<button
type="button"
className="leaf-clone text-faint hover:text-ink"
className="leaf-act leaf-clone"
title="Spawn similar"
aria-label={`Spawn similar to ${name}`}
onClick={(e) => {
@@ -116,7 +116,7 @@ export function SessionCard(props: SessionCardProps) {
</button>
<button
type="button"
className="leaf-kill text-faint hover:text-danger"
className="leaf-act leaf-kill"
title="Kill session"
aria-label={`Kill ${name}`}
onClick={(e) => {
+45 -31
View File
@@ -2,9 +2,15 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../../contexts/ThemeContext.js";
import type { Session } from "../../types.js";
import { Sidebar } from "./Sidebar.js";
/** Wrap children in ThemeProvider so useTheme works in the footer. */
function Wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
afterEach(() => {
cleanup();
vi.useRealTimers();
@@ -36,7 +42,7 @@ function tree(): HTMLElement {
describe("SessionCard layout", () => {
it("renders two lines: the name, then a meta line naming the program", () => {
render(<Sidebar sessions={[session("alpha")]} />);
render(<Wrapper><Sidebar sessions={[session("alpha")]} /></Wrapper>);
const row = card("alpha");
expect(row.textContent).toContain("alpha");
@@ -52,7 +58,7 @@ describe("SessionCard layout", () => {
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
render(<Sidebar sessions={[fresh, held]} />);
render(<Wrapper><Sidebar sessions={[fresh, held]} /></Wrapper>);
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
@@ -60,13 +66,15 @@ describe("SessionCard layout", () => {
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
render(
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>
</Wrapper>,
);
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
@@ -80,13 +88,15 @@ describe("SessionCard layout", () => {
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
render(
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>
</Wrapper>,
);
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
@@ -96,12 +106,14 @@ describe("SessionCard layout", () => {
it("dims a provisional name and leaves a settled one alone", () => {
render(
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>
</Wrapper>,
);
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
@@ -113,7 +125,7 @@ describe("keyboard walk", () => {
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
it("j and k step the highlight through the cards", () => {
render(<Sidebar sessions={two} />);
render(<Wrapper><Sidebar sessions={two} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
expect(card("a").dataset.highlighted).toBe("true");
@@ -124,7 +136,7 @@ describe("keyboard walk", () => {
});
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
render(<Sidebar sessions={two} />);
render(<Wrapper><Sidebar sessions={two} /></Wrapper>);
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
fireEvent.click(header);
@@ -142,7 +154,7 @@ describe("keyboard walk", () => {
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
const onSelect = vi.fn();
const onFocusPane = vi.fn();
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
render(<Wrapper><Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "Enter" });
@@ -154,7 +166,7 @@ describe("keyboard walk", () => {
it("previews the session the walk lands on", () => {
const onPreview = vi.fn();
render(<Sidebar sessions={two} onPreview={onPreview} />);
render(<Wrapper><Sidebar sessions={two} onPreview={onPreview} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
expect(onPreview).toHaveBeenCalledWith("a");
@@ -162,7 +174,7 @@ describe("keyboard walk", () => {
it("x asks to kill the highlighted session and r renames it", () => {
const onKill = vi.fn();
render(<Sidebar sessions={two} onKill={onKill} />);
render(<Wrapper><Sidebar sessions={two} onKill={onKill} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "x" });
@@ -178,7 +190,9 @@ describe("context menu", () => {
const onSpawnSimilar = vi.fn();
const onKill = vi.fn();
render(
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
<Wrapper>
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />
</Wrapper>,
);
fireEvent.contextMenu(card("alpha"));
@@ -191,7 +205,7 @@ describe("context menu", () => {
it("renames in place: Enter commits the field, Escape cancels", () => {
const onRename = vi.fn();
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
render(<Wrapper><Sidebar sessions={[session("alpha")]} onRename={onRename} /></Wrapper>);
fireEvent.contextMenu(card("alpha"));
fireEvent.click(screen.getByText("Rename…"));
@@ -207,7 +221,7 @@ describe("context menu", () => {
describe("search", () => {
it("filters the tree down to matching sessions", () => {
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
render(<Wrapper><Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} /></Wrapper>);
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
@@ -219,7 +233,7 @@ describe("search", () => {
describe("empty state", () => {
it("offers a spawn when there are no sessions at all", () => {
const onSpawn = vi.fn();
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
render(<Wrapper><Sidebar sessions={[]} onSpawn={onSpawn} /></Wrapper>);
fireEvent.click(screen.getByText("+ Spawn session"));
expect(onSpawn).toHaveBeenCalled();
+91 -1
View File
@@ -10,8 +10,11 @@ import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT } from "../../uiEvents.js";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, requestToggleHelp } from "../../uiEvents.js";
import { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
import type { DaemonLinkStoreApi, DaemonLinkState } from "../daemon/DaemonLink.js";
import { formatRelease } from "../daemon/DaemonLink.js";
import { useTheme, PALETTES, type Palette } from "../../contexts/ThemeContext.js";
import { FolderNode } from "./FolderNode.js";
import { KillDialog } from "../dialogs/KillDialog.js";
import { SessionCard } from "./SessionCard.js";
@@ -43,6 +46,8 @@ export interface SidebarProps {
/** Tab: hand the keyboard back to the pane. */
onFocusPane?(): void;
onHelp?(): void;
/** The daemon-link store, for the status indicator in the footer. */
linkStore?: DaemonLinkStoreApi;
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
onKill?(session: Session): void;
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
@@ -349,6 +354,10 @@ export function Sidebar(props: SidebarProps) {
}}
/>
)}
<SidebarFooter
onSpawn={() => props.onSpawn?.()}
linkStore={props.linkStore}
/>
</aside>
);
}
@@ -396,3 +405,84 @@ const EMPTY_STORE = {
setState: () => {},
subscribe: () => () => {},
} as unknown as CatalogStoreApi;
// ---------------------------------------------------------------------------
// Sidebar footer: help, theme toggle, palette picker, status dot, spawn.
// Mirrors the vanilla catalog.ts footer that the React migration dropped.
// ---------------------------------------------------------------------------
function SidebarFooter({
onSpawn,
linkStore,
}: {
onSpawn(): void;
linkStore?: DaemonLinkStoreApi;
}) {
const { theme, toggleTheme, palette, setPalette } = useTheme();
return (
<div className="sidebar-footer">
<button
type="button"
className="btn btn-ghost btn-icon help-btn"
title="Keyboard shortcuts (?)"
aria-label="Keyboard shortcuts"
onClick={requestToggleHelp}
>
?
</button>
<button
type="button"
className="btn btn-ghost btn-icon"
title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
onClick={toggleTheme}
>
{theme === "dark" ? "☀" : "☾"}
</button>
<select
className="palette-select"
value={palette}
aria-label="Colour palette"
onChange={(e) => setPalette(e.currentTarget.value as Palette)}
>
{PALETTES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
{linkStore && <StatusDot store={linkStore} />}
<button
type="button"
className="btn btn-primary spawn-btn"
title="Spawn form: n — fast-forward spawn: N"
onClick={onSpawn}
>
+ Spawn
</button>
</div>
);
}
/** Daemon status indicator: a coloured dot whose tooltip names the release. */
function StatusDot({ store }: { store: DaemonLinkStoreApi }) {
const state = useStore(store, (s: DaemonLinkState) => s.state);
const build = useStore(store, (s: DaemonLinkState) => s.build);
const hostname = useStore(store, (s: DaemonLinkState) => s.hostname);
return (
<button
type="button"
className="status-chip btn btn-ghost btn-icon"
data-state={state}
aria-label={`Daemon ${state}`}
>
<span className="status-dot" />
<span className="status-tip">
<span>{hostname ?? "maestro"}</span>
<span className="text-faint">{formatRelease(build)}</span>
</span>
</button>
);
}
+15 -11
View File
@@ -104,9 +104,11 @@ export interface SpawnFormProps {
/** Fired whenever any value changes, once the form's initial seed settles. */
onChange?(values: SpawnFormValues): void;
onSuccess(session: Session): void;
/** When true, hides the built-in submit button (the modal provides its own). */
externalSubmit?: boolean;
}
export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
export function SpawnForm({ seed, onChange, onSuccess, externalSubmit }: SpawnFormProps) {
const prefill = useRef(seed ?? {}).current;
const formRef = useRef<HTMLFormElement>(null);
@@ -240,7 +242,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [, setSubmitting] = useState(false);
const [ready, setReady] = useState(false);
const probeTokenRef = useRef(0);
@@ -620,6 +622,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
return (
<form
id="spawn-form"
ref={formRef}
onSubmit={onSubmit}
onKeyDown={onFormKeyDown}
@@ -839,15 +842,16 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
<p role="alert" aria-live="polite" className={`m-0 text-fs-13 text-danger ${error ? "rounded-r-2 bg-danger-tint px-sp-3 py-sp-2" : "min-h-[1.2em]"}`}>
{error}
</p>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-r-1 bg-accent px-sp-4 py-sp-2 text-fs-13 font-semibold text-accent-ink disabled:opacity-60"
>
Spawn
</button>
</div>
{!externalSubmit && (
<div className="flex justify-end">
<button
type="submit"
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
>
Spawn
</button>
</div>
)}
</form>
);
}
+9 -2
View File
@@ -64,16 +64,23 @@ export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) {
</button>
</header>
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
<SpawnForm seed={seed} onSuccess={handleSuccess} />
<SpawnForm seed={seed} onSuccess={handleSuccess} externalSubmit />
</div>
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
<button
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-13 text-ink"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
form="spawn-form"
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
>
Spawn
</button>
</div>
</div>
</div>,
@@ -1094,6 +1094,8 @@
width: 40px;
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
color: var(--danger);
}
+16 -8
View File
@@ -26,15 +26,23 @@ export type Palette = (typeof PALETTES)[number];
const PALETTE_KEY = "maestro-palette";
function storedTheme(): Theme | null {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
try {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
} catch {
return null;
}
}
function storedPalette(): Palette | null {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
try {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
} catch {
return null;
}
}
function systemPrefersLight(): boolean {
@@ -89,7 +97,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [palette]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem(THEME_KEY, t);
try { localStorage.setItem(THEME_KEY, t); } catch { /* test env */ }
setPinnedTheme(t);
}, []);
@@ -98,7 +106,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [theme, setTheme]);
const setPalette = useCallback((p: Palette) => {
localStorage.setItem(PALETTE_KEY, p);
try { localStorage.setItem(PALETTE_KEY, p); } catch { /* test env */ }
setPaletteState(p);
}, []);
+8
View File
@@ -56,3 +56,11 @@ export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}
/** Toggle the help overlay from a button click (the `?` key is handled by
* HelpOverlay's own command set). */
export const TOGGLE_HELP_EVENT = "maestro-toggle-help";
export function requestToggleHelp(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_HELP_EVENT));
}