@polderlabs/bizar-dash 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. package/vite.config.ts +24 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * cli/dashboard/watcher.mjs
3
+ *
4
+ * chokidar-backed file watcher. Translates low-level fs events into the
5
+ * shape the dashboard's WebSocket layer expects.
6
+ *
7
+ * The watcher ignores initial add events (the UI doesn't need a "you just
8
+ * started, here are 200 files that already existed" flood on connect).
9
+ */
10
+ import chokidar from 'chokidar';
11
+
12
+ /**
13
+ * @param {object} opts
14
+ * @param {string[]} opts.paths - files or directories to watch
15
+ * @param {(event: 'add'|'change'|'unlink', path: string) => void} opts.onChange
16
+ * @param {object} [opts.options] - extra chokidar options
17
+ */
18
+ export function createWatcher({ paths, onChange, options = {} }) {
19
+ if (!Array.isArray(paths) || paths.length === 0) {
20
+ throw new Error('createWatcher requires a non-empty paths array');
21
+ }
22
+ if (typeof onChange !== 'function') {
23
+ throw new Error('createWatcher requires an onChange callback');
24
+ }
25
+
26
+ const watcher = chokidar.watch(paths, {
27
+ ignoreInitial: true,
28
+ persistent: true,
29
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
30
+ // Ignore: any path segment named `node_modules` (anywhere), and any
31
+ // file/dir whose IMMEDIATE basename starts with `.` (covers .DS_Store,
32
+ // .swp, .bak, etc.). We must NOT walk the whole path — the watched
33
+ // root `~/.config/opencode/agents` contains the segment `.config`
34
+ // which would otherwise match and silently disable the watch.
35
+ ignored: (p) => {
36
+ const s = String(p);
37
+ const parts = s.split(/[\\/]/);
38
+ // Walk all segments — `node_modules` is fine to match anywhere.
39
+ if (parts.includes('node_modules')) return true;
40
+ // Last segment is the basename — check that ONLY, so `.config`
41
+ // in parent dirs doesn't disqualify the watched root.
42
+ const basename = parts[parts.length - 1] || '';
43
+ return basename.startsWith('.');
44
+ },
45
+ ...options,
46
+ });
47
+
48
+ const safe = (event, p) => {
49
+ try {
50
+ onChange(event, p);
51
+ } catch (err) {
52
+ // A faulty onChange must not crash the watcher
53
+ console.error('[dashboard watcher] onChange error:', err);
54
+ }
55
+ };
56
+
57
+ watcher.on('add', (p) => safe('add', p));
58
+ watcher.on('change', (p) => safe('change', p));
59
+ watcher.on('unlink', (p) => safe('unlink', p));
60
+ watcher.on('error', (err) => {
61
+ console.error('[dashboard watcher] chokidar error:', err);
62
+ });
63
+
64
+ return {
65
+ /** @returns {chokidar.FSWatcher} */
66
+ start() {
67
+ return watcher;
68
+ },
69
+ async stop() {
70
+ try {
71
+ await watcher.close();
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ },
76
+ /** Force a synthetic broadcast — useful after a self-mutation. */
77
+ poke(event = 'change', path = '<synthetic>') {
78
+ safe(event, path);
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,316 @@
1
+ // src/App.tsx — root shell. Wires data + contexts + tab routing.
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Topbar, TABS } from './components/Topbar';
5
+ import { ModalProvider } from './components/Modal';
6
+ import { ToastProvider, useToast } from './components/Toast';
7
+ import { SearchModal } from './components/SearchModal';
8
+ import { api } from './lib/api';
9
+ import { Ws } from './lib/ws';
10
+ import {
11
+ applyTheme,
12
+ applyThemeTokens,
13
+ type Settings,
14
+ type SettingsResponse,
15
+ type Snapshot,
16
+ type WsMessage,
17
+ type WsStatus,
18
+ type ProjectRecord,
19
+ type SearchResult,
20
+ } from './lib/types';
21
+ import { Overview } from './views/Overview';
22
+ import { Chat } from './views/Chat';
23
+ import { Agents } from './views/Agents';
24
+ import { Plans } from './views/Plans';
25
+ import { Tasks } from './views/Tasks';
26
+ import { Config } from './views/Config';
27
+ import { SettingsView } from './views/Settings';
28
+ import { Mods } from './views/Mods';
29
+ import { Schedules } from './views/Schedules';
30
+ import { Spinner } from './components/Spinner';
31
+ import './styles/main.css';
32
+
33
+ type ViewProps = {
34
+ snapshot: Snapshot;
35
+ settings: Settings;
36
+ activeTab: string;
37
+ setActiveTab: (id: string) => void;
38
+ refreshSnapshot: () => Promise<void>;
39
+ };
40
+
41
+ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
42
+ overview: Overview,
43
+ chat: Chat,
44
+ agents: Agents,
45
+ plans: Plans,
46
+ tasks: Tasks,
47
+ config: Config,
48
+ settings: SettingsView,
49
+ mods: Mods,
50
+ schedules: Schedules,
51
+ };
52
+
53
+ const VERSION = 'v3.0.0';
54
+
55
+ export function App() {
56
+ return (
57
+ <ToastProvider>
58
+ <ModalProvider>
59
+ <Shell />
60
+ </ModalProvider>
61
+ </ToastProvider>
62
+ );
63
+ }
64
+
65
+ function Shell() {
66
+ const toast = useToast();
67
+ const [activeTab, setActiveTab] = useState<string>('overview');
68
+ const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
69
+ const [settings, setSettings] = useState<Settings | null>(null);
70
+ const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
71
+ const [bootError, setBootError] = useState<string | null>(null);
72
+ const [searchOpen, setSearchOpen] = useState(false);
73
+ const wsRef = useRef<Ws | null>(null);
74
+
75
+ // Apply theme tokens
76
+ useEffect(() => {
77
+ if (settings?.theme) {
78
+ applyTheme(settings.theme);
79
+ applyThemeTokens(settings.theme);
80
+ }
81
+ }, [settings?.theme]);
82
+
83
+ // System-mode listener
84
+ useEffect(() => {
85
+ if (settings?.theme?.mode !== 'system') return;
86
+ const mql = window.matchMedia('(prefers-color-scheme: light)');
87
+ const handler = () => applyTheme(settings.theme.mode);
88
+ mql.addEventListener('change', handler);
89
+ return () => mql.removeEventListener('change', handler);
90
+ }, [settings?.theme?.mode]);
91
+
92
+ // Initial fetch
93
+ useEffect(() => {
94
+ let cancelled = false;
95
+ Promise.all([
96
+ api.get<Snapshot>('/snapshot').catch(() => null),
97
+ api.get<SettingsResponse>('/settings').catch(() => null),
98
+ ])
99
+ .then(([snap, set]) => {
100
+ if (cancelled) return;
101
+ if (snap) setSnapshot(snap);
102
+ if (set?.data) setSettings(set.data);
103
+ if (!snap && !set) setBootError('Dashboard server unreachable.');
104
+ if (set?.data?.ui?.defaultTab) setActiveTab(set.data.ui.defaultTab);
105
+ })
106
+ .catch((err) => {
107
+ if (cancelled) return;
108
+ const msg = (err as Error)?.message ?? 'unknown error';
109
+ setBootError(msg);
110
+ toast.error(`Failed to load: ${msg}`);
111
+ });
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ }, [toast]);
116
+
117
+ // WebSocket lifecycle
118
+ useEffect(() => {
119
+ const ws = new Ws();
120
+ wsRef.current = ws;
121
+ const offStatus = ws.onStatus((s) => setWsStatus(s));
122
+ const offMsg = ws.on((msg: WsMessage) => {
123
+ if (msg.type === 'snapshot' && 'data' in msg && msg.data) {
124
+ setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...msg.data }));
125
+ } else if (msg.type === 'change') {
126
+ const m = msg;
127
+ const file = m.path?.split('/').pop() || m.path || '';
128
+ toast.info(`File changed: ${file}`, 2500);
129
+ api
130
+ .get<Snapshot>('/snapshot')
131
+ .then((s) => setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s })))
132
+ .catch(() => undefined);
133
+ } else if (msg.type === 'tasks:change') {
134
+ const m = msg;
135
+ setSnapshot((cur) => {
136
+ if (!cur) return cur;
137
+ const tasks = (cur.tasks || []).map((t) =>
138
+ t.id === m.task.id ? m.task : t,
139
+ );
140
+ const exists = tasks.some((t) => t.id === m.task.id);
141
+ return { ...cur, tasks: exists ? tasks : [m.task, ...tasks] };
142
+ });
143
+ } else if (msg.type === 'tasks:delete') {
144
+ const m = msg;
145
+ setSnapshot((cur) =>
146
+ cur
147
+ ? { ...cur, tasks: (cur.tasks || []).filter((t) => t.id !== m.id) }
148
+ : cur,
149
+ );
150
+ } else if (msg.type === 'settings:change') {
151
+ const m = msg;
152
+ if (m.settings) setSettings(m.settings);
153
+ } else if (msg.type === 'project:change') {
154
+ // Refresh the whole snapshot to pick up the new active project
155
+ api
156
+ .get<Snapshot>('/snapshot')
157
+ .then((s) => setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s })))
158
+ .catch(() => undefined);
159
+ } else if (msg.type === 'agents:change' || msg.type === 'schedules:change') {
160
+ api
161
+ .get<Snapshot>('/snapshot')
162
+ .then((s) => setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s })))
163
+ .catch(() => undefined);
164
+ }
165
+ });
166
+ return () => {
167
+ offStatus();
168
+ offMsg();
169
+ ws.close();
170
+ };
171
+ }, [toast]);
172
+
173
+ // Keyboard shortcuts
174
+ useEffect(() => {
175
+ const map: Record<string, string> = {};
176
+ TABS.forEach((t, i) => {
177
+ map[String(i + 1)] = t.id;
178
+ });
179
+ const handler = (e: KeyboardEvent) => {
180
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
181
+ e.preventDefault();
182
+ setSearchOpen(true);
183
+ return;
184
+ }
185
+ if (e.key === '/' && !e.metaKey && !e.ctrlKey) {
186
+ const t = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
187
+ if (t !== 'input' && t !== 'textarea' && !((e.target as HTMLElement)?.isContentEditable)) {
188
+ e.preventDefault();
189
+ setSearchOpen(true);
190
+ return;
191
+ }
192
+ }
193
+ const t = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
194
+ if (
195
+ t === 'input' ||
196
+ t === 'textarea' ||
197
+ (e.target as HTMLElement)?.isContentEditable ||
198
+ e.metaKey ||
199
+ e.ctrlKey ||
200
+ e.altKey
201
+ )
202
+ return;
203
+ const id = map[e.key];
204
+ if (id) {
205
+ e.preventDefault();
206
+ setActiveTab(id);
207
+ }
208
+ };
209
+ document.addEventListener('keydown', handler);
210
+ return () => document.removeEventListener('keydown', handler);
211
+ }, []);
212
+
213
+ const View = VIEW_MAP[activeTab];
214
+
215
+ const refreshSnapshot = useMemo(
216
+ () => async () => {
217
+ try {
218
+ const s = await api.get<Snapshot>('/snapshot');
219
+ setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s }));
220
+ } catch (err) {
221
+ toast.error(`Refresh failed: ${(err as Error).message}`);
222
+ }
223
+ },
224
+ [toast],
225
+ );
226
+
227
+ const refreshProjects = async () => {
228
+ try {
229
+ const r = await api.get<{ projects: ProjectRecord[]; active: string | null }>('/projects');
230
+ setSnapshot((cur) =>
231
+ cur ? { ...cur, projects: r.projects || [], activeProject: r.projects?.find((p) => p.id === r.active) || null } : cur,
232
+ );
233
+ } catch (err) {
234
+ toast.error(`Projects refresh failed: ${(err as Error).message}`);
235
+ }
236
+ };
237
+
238
+ const onActivateProject = async (id: string) => {
239
+ try {
240
+ await api.post(`/projects/${encodeURIComponent(id)}/activate`);
241
+ // WS will fire project:change and refresh; but also reflect locally
242
+ setSnapshot((cur) => {
243
+ if (!cur) return cur;
244
+ return {
245
+ ...cur,
246
+ activeProject: cur.projects.find((p) => p.id === id) || null,
247
+ };
248
+ });
249
+ } catch (err) {
250
+ toast.error(`Activate failed: ${(err as Error).message}`);
251
+ }
252
+ };
253
+
254
+ const onSearchSelect = (r: SearchResult) => {
255
+ const t = r.type;
256
+ if (t === 'agent') setActiveTab('agents');
257
+ else if (t === 'task') setActiveTab('tasks');
258
+ else if (t === 'mod') setActiveTab('mods');
259
+ else if (t === 'schedule') setActiveTab('schedules');
260
+ else if (t === 'project') {
261
+ const id = (r.item as ProjectRecord).id;
262
+ onActivateProject(id);
263
+ } else if (t === 'command') {
264
+ // No command page; just toast
265
+ toast.info(`/${(r.item as { name: string }).name} — run from the TUI`, 2500);
266
+ }
267
+ };
268
+
269
+ return (
270
+ <div className="app" data-layout={settings?.ui?.layout || 'topnav'}>
271
+ <Topbar
272
+ activeTab={activeTab}
273
+ onTabChange={setActiveTab}
274
+ wsStatus={wsStatus}
275
+ version={VERSION}
276
+ activeProject={snapshot?.activeProject || null}
277
+ projects={snapshot?.projects || []}
278
+ onProjectChange={onActivateProject}
279
+ onProjectsRefresh={refreshProjects}
280
+ onOpenSearch={() => setSearchOpen(true)}
281
+ />
282
+ <main className="content">
283
+ {bootError && (
284
+ <div className="boot-error">
285
+ <h2>Dashboard unavailable</h2>
286
+ <p>{bootError}</p>
287
+ <p className="boot-error-hint">
288
+ Make sure the Bizar dashboard server is running. Try{' '}
289
+ <code>bizar-dash start</code> in your terminal.
290
+ </p>
291
+ </div>
292
+ )}
293
+ {!bootError && (!snapshot || !settings) && (
294
+ <div className="loading">
295
+ <Spinner size="lg" />
296
+ <p>Loading Bizar…</p>
297
+ </div>
298
+ )}
299
+ {snapshot && settings && View && (
300
+ <View
301
+ snapshot={snapshot}
302
+ settings={settings}
303
+ activeTab={activeTab}
304
+ setActiveTab={setActiveTab}
305
+ refreshSnapshot={refreshSnapshot}
306
+ />
307
+ )}
308
+ </main>
309
+ <SearchModal
310
+ open={searchOpen}
311
+ onClose={() => setSearchOpen(false)}
312
+ onSelect={onSearchSelect}
313
+ />
314
+ </div>
315
+ );
316
+ }
@@ -0,0 +1,55 @@
1
+ // src/components/Button.tsx — typed button with variants + sizes.
2
+
3
+ import { type ButtonHTMLAttributes, forwardRef } from 'react';
4
+ import { cn } from '../lib/utils';
5
+
6
+ export type ButtonVariant =
7
+ | 'primary'
8
+ | 'secondary'
9
+ | 'ghost'
10
+ | 'danger'
11
+ | 'accent';
12
+ export type ButtonSize = 'sm' | 'md' | 'lg';
13
+
14
+ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
15
+ variant?: ButtonVariant;
16
+ size?: ButtonSize;
17
+ iconOnly?: boolean;
18
+ loading?: boolean;
19
+ };
20
+
21
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
22
+ function Button(
23
+ {
24
+ variant = 'secondary',
25
+ size = 'md',
26
+ iconOnly = false,
27
+ loading = false,
28
+ disabled,
29
+ className,
30
+ children,
31
+ ...rest
32
+ },
33
+ ref,
34
+ ) {
35
+ return (
36
+ <button
37
+ ref={ref}
38
+ type={rest.type ?? 'button'}
39
+ disabled={disabled || loading}
40
+ className={cn(
41
+ 'btn',
42
+ `btn-${variant}`,
43
+ `btn-size-${size}`,
44
+ iconOnly && 'btn-icon',
45
+ loading && 'btn-loading',
46
+ className,
47
+ )}
48
+ {...rest}
49
+ >
50
+ {loading ? <span className="btn-spinner" aria-hidden /> : null}
51
+ {children}
52
+ </button>
53
+ );
54
+ },
55
+ );
@@ -0,0 +1,40 @@
1
+ // src/components/Card.tsx — wrapper card.
2
+
3
+ import type { HTMLAttributes, ReactNode } from 'react';
4
+ import { cn } from '../lib/utils';
5
+
6
+ export type CardProps = HTMLAttributes<HTMLDivElement> & {
7
+ variant?: 'elevated' | 'outlined' | 'filled';
8
+ interactive?: boolean;
9
+ children?: ReactNode;
10
+ };
11
+
12
+ export function Card({
13
+ variant = 'elevated',
14
+ interactive = false,
15
+ className,
16
+ children,
17
+ ...rest
18
+ }: CardProps) {
19
+ return (
20
+ <div
21
+ className={cn(
22
+ 'card',
23
+ `card-${variant}`,
24
+ interactive && 'card-interactive',
25
+ className,
26
+ )}
27
+ {...rest}
28
+ >
29
+ {children}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ export function CardTitle({ children, className }: { children: ReactNode; className?: string }) {
35
+ return <h3 className={cn('card-title', className)}>{children}</h3>;
36
+ }
37
+
38
+ export function CardMeta({ children, className }: { children: ReactNode; className?: string }) {
39
+ return <div className={cn('card-meta', className)}>{children}</div>;
40
+ }
@@ -0,0 +1,30 @@
1
+ // src/components/EmptyState.tsx — friendly empty / error placeholder.
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { Inbox } from 'lucide-react';
5
+ import { cn } from '../lib/utils';
6
+
7
+ export type EmptyStateProps = {
8
+ icon?: ReactNode;
9
+ title: string;
10
+ message?: ReactNode;
11
+ action?: ReactNode;
12
+ className?: string;
13
+ };
14
+
15
+ export function EmptyState({
16
+ icon,
17
+ title,
18
+ message,
19
+ action,
20
+ className,
21
+ }: EmptyStateProps) {
22
+ return (
23
+ <div className={cn('empty-state', className)}>
24
+ <div className="empty-icon">{icon ?? <Inbox size={32} />}</div>
25
+ <div className="empty-title">{title}</div>
26
+ {message && <div className="empty-message">{message}</div>}
27
+ {action && <div className="empty-action">{action}</div>}
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,137 @@
1
+ // src/components/Modal.tsx — modal context + provider + portal.
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ import { X } from 'lucide-react';
13
+
14
+ export type ModalProps = {
15
+ title?: string;
16
+ children: ReactNode;
17
+ footer?: ReactNode;
18
+ onClose?: () => void;
19
+ width?: number;
20
+ };
21
+
22
+ export type ModalApi = {
23
+ open: (props: ModalProps) => string;
24
+ close: (id?: string) => void;
25
+ };
26
+
27
+ const ModalContext = createContext<ModalApi | null>(null);
28
+
29
+ export function useModal(): ModalApi {
30
+ const ctx = useContext(ModalContext);
31
+ if (!ctx) {
32
+ return { open: () => '', close: () => undefined };
33
+ }
34
+ return ctx;
35
+ }
36
+
37
+ type OpenModal = ModalProps & { id: string };
38
+
39
+ export function ModalProvider({ children }: { children: ReactNode }) {
40
+ const [stack, setStack] = useState<OpenModal[]>([]);
41
+
42
+ const close = useCallback((id?: string) => {
43
+ setStack((cur) => {
44
+ if (!id) return [];
45
+ const idx = cur.findIndex((m) => m.id === id);
46
+ if (idx === -1) return cur;
47
+ // close only the top-most matching
48
+ if (idx !== cur.length - 1) return cur;
49
+ return cur.slice(0, -1);
50
+ });
51
+ }, []);
52
+
53
+ const open = useCallback((props: ModalProps) => {
54
+ const id = `m${Math.random().toString(36).slice(2, 9)}`;
55
+ setStack((cur) => [...cur, { ...props, id }]);
56
+ return id;
57
+ }, []);
58
+
59
+ // Escape key — close the topmost
60
+ useEffect(() => {
61
+ if (stack.length === 0) return;
62
+ const handler = (e: KeyboardEvent) => {
63
+ if (e.key === 'Escape') {
64
+ e.stopPropagation();
65
+ close();
66
+ }
67
+ };
68
+ document.addEventListener('keydown', handler);
69
+ return () => document.removeEventListener('keydown', handler);
70
+ }, [stack.length, close]);
71
+
72
+ return (
73
+ <ModalContext.Provider value={{ open, close }}>
74
+ {children}
75
+ {typeof document !== 'undefined' &&
76
+ createPortal(
77
+ <div className="modal-stack" aria-hidden={stack.length === 0}>
78
+ {stack.map((m, idx) => (
79
+ <ModalShell
80
+ key={m.id}
81
+ modal={m}
82
+ onClose={() => {
83
+ m.onClose?.();
84
+ close(m.id);
85
+ }}
86
+ depth={idx}
87
+ />
88
+ ))}
89
+ </div>,
90
+ document.body,
91
+ )}
92
+ </ModalContext.Provider>
93
+ );
94
+ }
95
+
96
+ function ModalShell({
97
+ modal,
98
+ onClose,
99
+ depth,
100
+ }: {
101
+ modal: OpenModal;
102
+ onClose: () => void;
103
+ depth: number;
104
+ }) {
105
+ return (
106
+ <div
107
+ className="modal-backdrop"
108
+ onClick={(e) => {
109
+ if (e.target === e.currentTarget) onClose();
110
+ }}
111
+ style={depth > 0 ? { background: 'rgba(0,0,0,0.4)' } : undefined}
112
+ >
113
+ <div
114
+ className="modal"
115
+ role="dialog"
116
+ aria-modal="true"
117
+ style={modal.width ? { maxWidth: modal.width } : undefined}
118
+ >
119
+ {modal.title && (
120
+ <header className="modal-header">
121
+ <h2 className="modal-title">{modal.title}</h2>
122
+ <button
123
+ type="button"
124
+ className="icon-btn"
125
+ aria-label="Close"
126
+ onClick={onClose}
127
+ >
128
+ <X size={16} />
129
+ </button>
130
+ </header>
131
+ )}
132
+ <div className="modal-body">{modal.children}</div>
133
+ {modal.footer && <footer className="modal-footer">{modal.footer}</footer>}
134
+ </div>
135
+ </div>
136
+ );
137
+ }