@1agh/maude 0.43.0 → 0.45.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 (126) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -0,0 +1,828 @@
1
+ // SettingsPanel.jsx — BYOK AI-media generation settings (feature-ai-media-
2
+ // generation, DDR-16x). Manages provider API keys + enable toggles from the UI.
3
+ //
4
+ // Trust model (load-bearing): a key is POSTed to the PRIVILEGED main-origin
5
+ // /_api/generate/keys route and NEVER echoed back — GET returns only a
6
+ // { configured: [...] } presence list. So this panel shows a masked "••••
7
+ // configured" state, never the key value. The panel lives in the app shell
8
+ // (main origin); the untrusted canvas iframe can't reach any of these routes.
9
+ //
10
+ // Layout mirrors OnboardingWizard's density + the ExportDialog modal shell
11
+ // (st-scrim / st-dialog). Icons are local Lucide-line paths (IdentityBar
12
+ // precedent); colors are theme tokens only (no hardcoded hex).
13
+
14
+ import { useCallback, useEffect, useState } from 'react';
15
+
16
+ function Icon({ name, size = 16 }) {
17
+ const p = {
18
+ x: (
19
+ <>
20
+ <line x1="4" y1="4" x2="12" y2="12" />
21
+ <line x1="12" y1="4" x2="4" y2="12" />
22
+ </>
23
+ ),
24
+ key: (
25
+ <>
26
+ <circle cx="5.5" cy="5.5" r="3" />
27
+ <path d="M7.6 7.6 13 13" />
28
+ <line x1="11" y1="11" x2="12.5" y2="9.5" />
29
+ </>
30
+ ),
31
+ check: <polyline points="3.5 8.5 6.5 11.5 12.5 4.5" />,
32
+ external: (
33
+ <>
34
+ <path d="M6 3.5H3.2A.7.7 0 0 0 2.5 4.2v8.6a.7.7 0 0 0 .7.7h8.6a.7.7 0 0 0 .7-.7V10" />
35
+ <line x1="8" y1="8" x2="13" y2="3" />
36
+ <polyline points="9.5 3 13 3 13 6.5" />
37
+ </>
38
+ ),
39
+ cloud: <path d="M4.5 12h6a2.5 2.5 0 0 0 .2-5 3.5 3.5 0 0 0-6.7-1A2.75 2.75 0 0 0 4.5 12Z" />,
40
+ }[name];
41
+ return (
42
+ <svg
43
+ width={size}
44
+ height={size}
45
+ viewBox="0 0 16 16"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth="1.4"
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ aria-hidden="true"
52
+ >
53
+ {p}
54
+ </svg>
55
+ );
56
+ }
57
+
58
+ function ProviderCard({ provider, onChanged }) {
59
+ const [keyInput, setKeyInput] = useState('');
60
+ const [busy, setBusy] = useState(false);
61
+ const [status, setStatus] = useState(null); // { ok, msg }
62
+ const configured = provider.configured;
63
+
64
+ async function save() {
65
+ const key = keyInput.trim();
66
+ if (!key) return;
67
+ setBusy(true);
68
+ setStatus(null);
69
+ try {
70
+ const res = await fetch('/_api/generate/keys', {
71
+ method: 'POST',
72
+ headers: { 'content-type': 'text/plain' },
73
+ body: JSON.stringify({ provider: provider.id, key }),
74
+ });
75
+ if (!res.ok) throw new Error(await res.text());
76
+ const json = await res.json();
77
+ setKeyInput('');
78
+ setStatus({ ok: true, msg: json.configured ? 'Key saved.' : 'Saved.' });
79
+ onChanged();
80
+ } catch (err) {
81
+ setStatus({ ok: false, msg: err && err.message ? err.message : 'save failed' });
82
+ } finally {
83
+ setBusy(false);
84
+ }
85
+ }
86
+
87
+ async function remove() {
88
+ setBusy(true);
89
+ setStatus(null);
90
+ try {
91
+ const res = await fetch('/_api/generate/keys', {
92
+ method: 'DELETE',
93
+ headers: { 'content-type': 'text/plain' },
94
+ body: JSON.stringify({ provider: provider.id }),
95
+ });
96
+ if (!res.ok) throw new Error(await res.text());
97
+ setStatus({ ok: true, msg: 'Key removed.' });
98
+ onChanged();
99
+ } catch (err) {
100
+ setStatus({ ok: false, msg: err && err.message ? err.message : 'remove failed' });
101
+ } finally {
102
+ setBusy(false);
103
+ }
104
+ }
105
+
106
+ return (
107
+ <div className="st-provider-card">
108
+ <div className="st-provider-hd">
109
+ <span className="st-provider-name">{provider.label}</span>
110
+ <span className={'st-pill' + (provider.kind === 'local' ? ' is-local' : '')}>
111
+ {provider.kind === 'local' ? 'Local' : (
112
+ <>
113
+ <Icon name="cloud" size={12} /> Cloud
114
+ </>
115
+ )}
116
+ </span>
117
+ {configured && (
118
+ <span className="st-provider-configured">
119
+ <Icon name="check" size={12} /> configured
120
+ </span>
121
+ )}
122
+ </div>
123
+ <div className="st-provider-modalities">{provider.modalities.join(' · ')}</div>
124
+ {provider.notes && <div className="st-provider-notes">{provider.notes}</div>}
125
+ {provider.keyUrl && (
126
+ <a className="st-provider-keylink" href={provider.keyUrl} target="_blank" rel="noreferrer">
127
+ Get a key <Icon name="external" size={12} />
128
+ </a>
129
+ )}
130
+ {provider.auth === 'api-key' && (
131
+ <div className="st-provider-keyrow">
132
+ <input
133
+ className="input st-provider-keyinput"
134
+ type="password"
135
+ autoComplete="off"
136
+ spellCheck={false}
137
+ value={keyInput}
138
+ placeholder={configured ? '•••••••• configured — paste to replace' : 'paste API key'}
139
+ aria-label={`${provider.label} API key`}
140
+ onChange={(e) => setKeyInput(e.target.value)}
141
+ onKeyDown={(e) => {
142
+ if (e.key === 'Enter') save();
143
+ }}
144
+ />
145
+ <button type="button" className="st-btn" disabled={busy || !keyInput.trim()} onClick={save}>
146
+ <Icon name="key" size={13} /> Save
147
+ </button>
148
+ {configured && (
149
+ <button type="button" className="st-btn" disabled={busy} onClick={remove}>
150
+ Remove
151
+ </button>
152
+ )}
153
+ </div>
154
+ )}
155
+ {status && (
156
+ <div
157
+ className="st-provider-status"
158
+ style={{ color: status.ok ? 'var(--accent)' : 'var(--danger, #e5484d)' }}
159
+ >
160
+ {status.msg}
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ // Task 2.6 — the subtitle/transcription engine is an EXPLICIT user choice, never
168
+ // an automatic fallback. This radio persists to generation.transcription.provider
169
+ // (the /_api/generate/prefs route); `maude design transcribe` reads the same
170
+ // default. Local whisper is free/offline/no-key; the cloud engines need a key
171
+ // (managed in the provider cards above).
172
+ const TRANSCRIPTION_ENGINES = [
173
+ {
174
+ id: 'whisper',
175
+ label: 'Local whisper.cpp',
176
+ note: 'Free · offline · no key · runs on your hardware',
177
+ },
178
+ {
179
+ id: 'elevenlabs',
180
+ label: 'ElevenLabs Scribe',
181
+ note: 'Cloud · accuracy · needs an ElevenLabs key',
182
+ },
183
+ { id: 'groq', label: 'Groq Whisper', note: 'Cloud · fast · needs a Groq key' },
184
+ ];
185
+
186
+ function TranscriptionEngineCard() {
187
+ const [engine, setEngine] = useState(null); // null = loading
188
+ const [busy, setBusy] = useState(false);
189
+ const [status, setStatus] = useState(null);
190
+
191
+ useEffect(() => {
192
+ fetch('/_api/generate/prefs')
193
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
194
+ .then((d) => setEngine(d?.transcriptionProvider || 'whisper'))
195
+ .catch(() => setEngine('whisper'));
196
+ }, []);
197
+
198
+ async function choose(id) {
199
+ if (id === engine) return;
200
+ const prev = engine;
201
+ setEngine(id);
202
+ setBusy(true);
203
+ setStatus(null);
204
+ try {
205
+ const res = await fetch('/_api/generate/prefs', {
206
+ method: 'POST',
207
+ headers: { 'content-type': 'text/plain' },
208
+ body: JSON.stringify({ transcriptionProvider: id }),
209
+ });
210
+ if (!res.ok) throw new Error(await res.text());
211
+ setStatus({ ok: true, msg: 'Saved.' });
212
+ } catch (err) {
213
+ setEngine(prev); // revert on failure — no silent state drift
214
+ setStatus({ ok: false, msg: err && err.message ? err.message : 'save failed' });
215
+ } finally {
216
+ setBusy(false);
217
+ }
218
+ }
219
+
220
+ return (
221
+ <div className="st-provider-card">
222
+ <div className="st-provider-hd">
223
+ <span className="st-provider-name">Subtitles — transcription engine</span>
224
+ </div>
225
+ <div className="st-provider-notes">
226
+ Which engine turns audio into subtitles. This is an explicit choice — Maude never silently
227
+ switches engines; a chosen-but-unavailable engine tells you how to fix it.
228
+ </div>
229
+ <div className="st-engine-radios" role="radiogroup" aria-label="Transcription engine">
230
+ {TRANSCRIPTION_ENGINES.map((e) => (
231
+ <label key={e.id} className={'st-engine-radio' + (engine === e.id ? ' is-selected' : '')}>
232
+ <input
233
+ type="radio"
234
+ name="transcription-engine"
235
+ value={e.id}
236
+ checked={engine === e.id}
237
+ disabled={busy || engine === null}
238
+ onChange={() => choose(e.id)}
239
+ />
240
+ <span className="st-engine-radio-body">
241
+ <span className="st-engine-radio-label">{e.label}</span>
242
+ <span className="st-engine-radio-note">{e.note}</span>
243
+ </span>
244
+ </label>
245
+ ))}
246
+ </div>
247
+ {status && (
248
+ <div
249
+ className="st-provider-status"
250
+ style={{ color: status.ok ? 'var(--accent)' : 'var(--danger, #e5484d)' }}
251
+ >
252
+ {status.msg}
253
+ </div>
254
+ )}
255
+ </div>
256
+ );
257
+ }
258
+
259
+ // Task 2.7 (approach A) — one-click local whisper.cpp models. The engine binary
260
+ // is still a soft dep, but the MODEL half is one click: download a ggml model
261
+ // into a Maude-managed cache and `--provider whisper` auto-resolves it. Polls
262
+ // the download progress from the privileged /_api/generate/whisper-model route.
263
+ function WhisperModelCard() {
264
+ const [state, setState] = useState(null); // { models, downloading } | null
265
+ const [err, setErr] = useState(null);
266
+
267
+ const load = useCallback(() => {
268
+ fetch('/_api/generate/whisper-model')
269
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
270
+ .then(setState)
271
+ .catch((e) => setErr(e && e.message ? e.message : 'failed to load models'));
272
+ }, []);
273
+
274
+ useEffect(() => {
275
+ load();
276
+ }, [load]);
277
+
278
+ // Poll while a download is in flight.
279
+ useEffect(() => {
280
+ if (!state?.downloading) return undefined;
281
+ const t = setInterval(load, 1000);
282
+ return () => clearInterval(t);
283
+ }, [state?.downloading, load]);
284
+
285
+ async function download(id) {
286
+ setErr(null);
287
+ try {
288
+ const res = await fetch('/_api/generate/whisper-model', {
289
+ method: 'POST',
290
+ headers: { 'content-type': 'text/plain' },
291
+ body: JSON.stringify({ id }),
292
+ });
293
+ if (!res.ok) throw new Error(await res.text());
294
+ load();
295
+ } catch (e) {
296
+ setErr(e && e.message ? e.message : 'download failed');
297
+ }
298
+ }
299
+
300
+ async function remove(id) {
301
+ setErr(null);
302
+ try {
303
+ const res = await fetch('/_api/generate/whisper-model', {
304
+ method: 'DELETE',
305
+ headers: { 'content-type': 'text/plain' },
306
+ body: JSON.stringify({ id }),
307
+ });
308
+ if (!res.ok) throw new Error(await res.text());
309
+ load();
310
+ } catch (e) {
311
+ setErr(e && e.message ? e.message : 'remove failed');
312
+ }
313
+ }
314
+
315
+ const dl = state?.downloading;
316
+ const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
317
+
318
+ return (
319
+ <div className="st-provider-card">
320
+ <div className="st-provider-hd">
321
+ <span className="st-provider-name">Local subtitle models (whisper.cpp)</span>
322
+ </div>
323
+ <div className="st-provider-notes">
324
+ Download a model once and local subtitles work offline, free, with no key. Requires the
325
+ whisper.cpp binary (<code>brew install whisper-cpp</code>). Models are stored on this machine
326
+ only and never committed.
327
+ </div>
328
+ {err && (
329
+ <div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
330
+ {err}
331
+ </div>
332
+ )}
333
+ {dl?.error && (
334
+ <div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
335
+ Download of {dl.id} failed: {dl.error}
336
+ </div>
337
+ )}
338
+ {state === null && !err && <div className="st-settings-intro">Loading…</div>}
339
+ <div className="st-model-list">
340
+ {(state?.models || []).map((m) => {
341
+ const busy = dl && !dl.error && dl.id === m.id;
342
+ return (
343
+ <div key={m.id} className="st-model-row">
344
+ <div className="st-model-info">
345
+ <span className="st-model-label">
346
+ {m.label}
347
+ <span className={'st-pill' + (m.multilingual ? '' : ' is-local')}>
348
+ {m.multilingual ? 'multilingual' : 'English-only'}
349
+ </span>
350
+ <span className="st-model-size">~{m.sizeMB} MB</span>
351
+ </span>
352
+ <span className="st-engine-radio-note">{m.note}</span>
353
+ {busy && (
354
+ <span className="st-model-progress">
355
+ Downloading… {pctOf(dl.received, dl.total)}%
356
+ </span>
357
+ )}
358
+ </div>
359
+ <div className="st-model-actions">
360
+ {m.downloaded ? (
361
+ <>
362
+ <span className="st-provider-configured">
363
+ <Icon name="check" size={12} /> ready
364
+ </span>
365
+ <button type="button" className="st-btn" onClick={() => remove(m.id)}>
366
+ Remove
367
+ </button>
368
+ </>
369
+ ) : (
370
+ <button
371
+ type="button"
372
+ className="st-btn"
373
+ disabled={!!(dl && !dl.error)}
374
+ onClick={() => download(m.id)}
375
+ >
376
+ {busy ? 'Downloading…' : 'Download'}
377
+ </button>
378
+ )}
379
+ </div>
380
+ </div>
381
+ );
382
+ })}
383
+ </div>
384
+ </div>
385
+ );
386
+ }
387
+
388
+ // feature-unified-settings-modal — one modal for every Maude preference, laid
389
+ // out as a left vertical tab rail + an internally-scrolling pane. Categories:
390
+ const TABS = [
391
+ { id: 'appearance', label: 'Appearance' },
392
+ { id: 'canvas-view', label: 'Canvas & View' },
393
+ { id: 'layout', label: 'Layout' },
394
+ { id: 'ai-generation', label: 'AI generation' },
395
+ { id: 'subtitles', label: 'Subtitles' },
396
+ ];
397
+ const SETTINGS_TAB_STORE = 'mdcc-settings-tab';
398
+
399
+ // feature-configurable-panel-docking — the panels the Layout tab can dock. Order
400
+ // = display order. (Assistant is native-only; moving it is a no-op in the browser.)
401
+ const LAYOUT_PANELS = [
402
+ { id: 'tree', label: 'Files' },
403
+ { id: 'layers', label: 'Layers' },
404
+ { id: 'inspector', label: 'Inspector' },
405
+ { id: 'comments', label: 'Comments' },
406
+ { id: 'changes', label: 'Changes' },
407
+ { id: 'assistant', label: 'Assistant' },
408
+ ];
409
+
410
+ function SideToggle({ value, disabled, onChange }) {
411
+ return (
412
+ <span className="st-sidetoggle" role="radiogroup" aria-label="Panel side">
413
+ {['left', 'right'].map((s) => (
414
+ <button
415
+ key={s}
416
+ type="button"
417
+ role="radio"
418
+ aria-checked={value === s}
419
+ disabled={disabled}
420
+ className={'st-sidebtn' + (value === s ? ' is-active' : '')}
421
+ onClick={() => onChange(s)}
422
+ >
423
+ {s === 'left' ? 'Left' : 'Right'}
424
+ </button>
425
+ ))}
426
+ </span>
427
+ );
428
+ }
429
+
430
+ const LAYERS_MODE_OPTIONS = [
431
+ { id: 'separate', label: 'Separate panel', note: 'Layers docks on its own (default: left).' },
432
+ { id: 'in-inspector', label: 'Inside Inspector', note: 'Layers is a tab within the Inspector.' },
433
+ ];
434
+ function LayoutTab({ panelSide, onSetPanelSide, layersMode, onSetLayersMode }) {
435
+ return (
436
+ <>
437
+ <div className="st-provider-card">
438
+ <div className="st-provider-hd">
439
+ <span className="st-provider-name">Layers panel</span>
440
+ </div>
441
+ <div className="st-provider-notes">
442
+ Show Layers as its own dockable panel, or as a tab inside the Inspector.
443
+ </div>
444
+ <div className="st-engine-radios" role="radiogroup" aria-label="Layers mode">
445
+ {LAYERS_MODE_OPTIONS.map((o) => (
446
+ <label
447
+ key={o.id}
448
+ className={'st-engine-radio' + (layersMode === o.id ? ' is-selected' : '')}
449
+ >
450
+ <input
451
+ type="radio"
452
+ name="layers-mode"
453
+ checked={layersMode === o.id}
454
+ onChange={() => onSetLayersMode(o.id)}
455
+ />
456
+ <span className="st-engine-radio-body">
457
+ <span className="st-engine-radio-label">{o.label}</span>
458
+ <span className="st-engine-radio-note">{o.note}</span>
459
+ </span>
460
+ </label>
461
+ ))}
462
+ </div>
463
+ </div>
464
+ <div className="st-provider-card">
465
+ <div className="st-provider-hd">
466
+ <span className="st-provider-name">Panel positions</span>
467
+ </div>
468
+ <div className="st-provider-notes">
469
+ Dock each panel to the left or right side. Each side shows one panel at a time, with tabs
470
+ to switch between the panels docked there.
471
+ </div>
472
+ {LAYOUT_PANELS.map((p) => {
473
+ const disabled = p.id === 'layers' && layersMode !== 'separate';
474
+ return (
475
+ <div key={p.id} className={'st-pref-row' + (disabled ? ' is-disabled' : '')}>
476
+ <span className="st-pref-body">
477
+ <span className="st-pref-label">{p.label}</span>
478
+ {disabled && (
479
+ <span className="st-pref-note">
480
+ Layers is inside the Inspector — choose “Separate panel” above to dock it.
481
+ </span>
482
+ )}
483
+ </span>
484
+ <SideToggle
485
+ value={panelSide?.[p.id] || 'left'}
486
+ disabled={disabled}
487
+ onChange={(s) => onSetPanelSide(p.id, s)}
488
+ />
489
+ </div>
490
+ );
491
+ })}
492
+ </div>
493
+ </>
494
+ );
495
+ }
496
+
497
+ // A labeled switch row. The `checked`/`onChange` are owned by app.jsx state
498
+ // (single source of truth) so the modal and the View menu never diverge.
499
+ function PrefToggleRow({ label, note, checked, disabled, reason, onChange }) {
500
+ return (
501
+ <label className={'st-pref-row' + (disabled ? ' is-disabled' : '')}>
502
+ <span className="st-pref-body">
503
+ <span className="st-pref-label">{label}</span>
504
+ {(reason || note) && <span className="st-pref-note">{disabled && reason ? reason : note}</span>}
505
+ </span>
506
+ <input
507
+ type="checkbox"
508
+ className="st-switch"
509
+ role="switch"
510
+ aria-checked={!!checked}
511
+ checked={!!checked}
512
+ disabled={disabled}
513
+ onChange={(e) => onChange?.(e.target.checked)}
514
+ />
515
+ </label>
516
+ );
517
+ }
518
+
519
+ // Appearance — theme control. Reuses the transcription-engine radio density so
520
+ // the whole modal reads as one system. Wired to app.jsx `theme` + `onSetTheme`.
521
+ const THEME_OPTIONS = [
522
+ { id: 'light', label: 'Light', note: 'Bright shell chrome' },
523
+ { id: 'dark', label: 'Dark', note: 'Dim shell chrome' },
524
+ ];
525
+ // DDR-171 — the CSS-panel (Inspector) vocabulary. Advanced = raw CSS property
526
+ // names (the default, unchanged); Designer = Figma-familiar clusters (Fill,
527
+ // Stroke, Auto layout…). Same underlying controls + write path, just relabeled
528
+ // and regrouped. Persisted via the App-owned `maude-cp-mode` state, so this
529
+ // radio and the in-panel corner toggle stay in lockstep.
530
+ const CP_MODE_OPTIONS = [
531
+ { id: 'advanced', label: 'Advanced', note: 'Raw CSS property names — border-radius, flex-direction…' },
532
+ { id: 'designer', label: 'Designer', note: 'Figma vocabulary — Fill, Stroke, Corner radius, Auto layout…' },
533
+ ];
534
+ function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
535
+ return (
536
+ <>
537
+ <div className="st-provider-card">
538
+ <div className="st-provider-hd">
539
+ <span className="st-provider-name">Theme</span>
540
+ </div>
541
+ <div className="st-provider-notes">
542
+ Controls Maude’s own chrome — menubar, sidebar, canvas plane, minimap, zoom HUD. Artboards
543
+ keep their own design-system theme.
544
+ </div>
545
+ <div className="st-engine-radios" role="radiogroup" aria-label="Theme">
546
+ {THEME_OPTIONS.map((o) => (
547
+ <label key={o.id} className={'st-engine-radio' + (theme === o.id ? ' is-selected' : '')}>
548
+ <input
549
+ type="radio"
550
+ name="maude-theme"
551
+ value={o.id}
552
+ checked={theme === o.id}
553
+ onChange={() => onSetTheme?.(o.id)}
554
+ />
555
+ <span className="st-engine-radio-body">
556
+ <span className="st-engine-radio-label">{o.label}</span>
557
+ <span className="st-engine-radio-note">{o.note}</span>
558
+ </span>
559
+ </label>
560
+ ))}
561
+ </div>
562
+ </div>
563
+ <div className="st-provider-card">
564
+ <div className="st-provider-hd">
565
+ <span className="st-provider-name">Inspector vocabulary</span>
566
+ </div>
567
+ <div className="st-provider-notes">
568
+ How the CSS panel labels controls. You can also flip this from the small toggle in the
569
+ panel’s own corner — this choice is remembered.
570
+ </div>
571
+ <div className="st-engine-radios" role="radiogroup" aria-label="Inspector vocabulary">
572
+ {CP_MODE_OPTIONS.map((o) => (
573
+ <label key={o.id} className={'st-engine-radio' + (cpMode === o.id ? ' is-selected' : '')}>
574
+ <input
575
+ type="radio"
576
+ name="maude-cp-mode"
577
+ value={o.id}
578
+ checked={cpMode === o.id}
579
+ onChange={() => onSetCpMode?.(o.id)}
580
+ />
581
+ <span className="st-engine-radio-body">
582
+ <span className="st-engine-radio-label">{o.label}</span>
583
+ <span className="st-engine-radio-note">{o.note}</span>
584
+ </span>
585
+ </label>
586
+ ))}
587
+ </div>
588
+ </div>
589
+ </>
590
+ );
591
+ }
592
+
593
+ export default function SettingsPanel({
594
+ onClose,
595
+ initialTab,
596
+ theme,
597
+ onSetTheme,
598
+ cpMode,
599
+ onSetCpMode,
600
+ minimapVisible,
601
+ onToggleMinimap,
602
+ zoomCtlVisible,
603
+ onToggleZoomCtl,
604
+ annotationsVisible,
605
+ onToggleAnnotations,
606
+ autoOpenInspector,
607
+ onToggleAutoOpenInspector,
608
+ hasCanvas = false,
609
+ panelSide,
610
+ onSetPanelSide,
611
+ layersMode,
612
+ onSetLayersMode,
613
+ }) {
614
+ const [providers, setProviders] = useState(null); // null = loading
615
+ const [error, setError] = useState(null);
616
+ const [tab, setTab] = useState(() => {
617
+ if (initialTab && TABS.some((t) => t.id === initialTab)) return initialTab;
618
+ try {
619
+ const v = localStorage.getItem(SETTINGS_TAB_STORE);
620
+ if (v && TABS.some((t) => t.id === v)) return v;
621
+ } catch {}
622
+ return 'appearance';
623
+ });
624
+
625
+ useEffect(() => {
626
+ try {
627
+ localStorage.setItem(SETTINGS_TAB_STORE, tab);
628
+ } catch {}
629
+ }, [tab]);
630
+
631
+ const load = useCallback(() => {
632
+ fetch('/_api/generate/providers')
633
+ .then((r) => {
634
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
635
+ return r.json();
636
+ })
637
+ .then((d) => setProviders(Array.isArray(d?.providers) ? d.providers : []))
638
+ .catch((err) => setError(err && err.message ? err.message : 'failed to load providers'));
639
+ }, []);
640
+
641
+ useEffect(() => {
642
+ load();
643
+ }, [load]);
644
+
645
+ useEffect(() => {
646
+ function onKey(e) {
647
+ if (e.key === 'Escape') {
648
+ e.preventDefault();
649
+ onClose();
650
+ }
651
+ }
652
+ window.addEventListener('keydown', onKey);
653
+ return () => window.removeEventListener('keydown', onKey);
654
+ }, [onClose]);
655
+
656
+ // Vertical roving focus over the tab rail (native-menu parity).
657
+ function onRailKey(e) {
658
+ const idx = TABS.findIndex((t) => t.id === tab);
659
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
660
+ e.preventDefault();
661
+ setTab(TABS[(idx + 1) % TABS.length].id);
662
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
663
+ e.preventDefault();
664
+ setTab(TABS[(idx - 1 + TABS.length) % TABS.length].id);
665
+ } else if (e.key === 'Home') {
666
+ e.preventDefault();
667
+ setTab(TABS[0].id);
668
+ } else if (e.key === 'End') {
669
+ e.preventDefault();
670
+ setTab(TABS[TABS.length - 1].id);
671
+ }
672
+ }
673
+
674
+ // Minimap / zoom-controls are canvas-scoped — mirror the View menu's
675
+ // `disabled: !activePath || isSystem` gate and say why inline.
676
+ const canvasReason = !hasCanvas ? 'Open a canvas to use this.' : undefined;
677
+
678
+ return (
679
+ <div
680
+ className="st-scrim"
681
+ role="presentation"
682
+ onMouseDown={(e) => {
683
+ if (e.target === e.currentTarget) onClose();
684
+ }}
685
+ >
686
+ <div className="st-dialog is-settings" role="dialog" aria-modal="true" aria-label="Settings">
687
+ <div className="st-dialog-hd">
688
+ <span className="st-dialog-title">Settings</span>
689
+ <button type="button" className="st-iconbtn" aria-label="Close" onClick={onClose}>
690
+ <Icon name="x" size={15} />
691
+ </button>
692
+ </div>
693
+ <div className="st-settings-tabs">
694
+ <div
695
+ className="st-settings-rail"
696
+ role="tablist"
697
+ aria-orientation="vertical"
698
+ aria-label="Settings categories"
699
+ onKeyDown={onRailKey}
700
+ >
701
+ {TABS.map((t) => (
702
+ <button
703
+ key={t.id}
704
+ type="button"
705
+ role="tab"
706
+ id={`st-stab-${t.id}`}
707
+ aria-controls={`st-spanel-${t.id}`}
708
+ aria-selected={tab === t.id}
709
+ tabIndex={tab === t.id ? 0 : -1}
710
+ className={'st-settings-tab' + (tab === t.id ? ' is-active' : '')}
711
+ onClick={() => setTab(t.id)}
712
+ >
713
+ {t.label}
714
+ </button>
715
+ ))}
716
+ </div>
717
+ <div className="st-settings-pane">
718
+ {/* Appearance */}
719
+ <section
720
+ role="tabpanel"
721
+ id="st-spanel-appearance"
722
+ aria-labelledby="st-stab-appearance"
723
+ hidden={tab !== 'appearance'}
724
+ >
725
+ <div className="st-rp-hd">Appearance</div>
726
+ <AppearanceTab theme={theme} onSetTheme={onSetTheme} cpMode={cpMode} onSetCpMode={onSetCpMode} />
727
+ </section>
728
+
729
+ {/* Canvas & View — the persistent View-menu prefs, one canonical home. */}
730
+ <section
731
+ role="tabpanel"
732
+ id="st-spanel-canvas-view"
733
+ aria-labelledby="st-stab-canvas-view"
734
+ hidden={tab !== 'canvas-view'}
735
+ >
736
+ <div className="st-rp-hd">Canvas &amp; View</div>
737
+ <PrefToggleRow
738
+ label="Minimap"
739
+ note="Show the canvas minimap overlay."
740
+ checked={minimapVisible}
741
+ disabled={!hasCanvas}
742
+ reason={canvasReason}
743
+ onChange={() => onToggleMinimap?.()}
744
+ />
745
+ <PrefToggleRow
746
+ label="Zoom controls"
747
+ note="Show the floating zoom controls on the canvas."
748
+ checked={zoomCtlVisible}
749
+ disabled={!hasCanvas}
750
+ reason={canvasReason}
751
+ onChange={() => onToggleZoomCtl?.()}
752
+ />
753
+ <PrefToggleRow
754
+ label="Annotations"
755
+ note="Show annotation pins and overlays on the canvas."
756
+ checked={annotationsVisible}
757
+ onChange={() => onToggleAnnotations?.()}
758
+ />
759
+ <PrefToggleRow
760
+ label="Auto-open Inspector on select"
761
+ note="Open the Inspector automatically when you select an element."
762
+ checked={autoOpenInspector}
763
+ onChange={() => onToggleAutoOpenInspector?.()}
764
+ />
765
+ </section>
766
+
767
+ {/* Layout — dock panels left/right + Layers mode. */}
768
+ <section
769
+ role="tabpanel"
770
+ id="st-spanel-layout"
771
+ aria-labelledby="st-stab-layout"
772
+ hidden={tab !== 'layout'}
773
+ >
774
+ <div className="st-rp-hd">Layout</div>
775
+ <LayoutTab
776
+ panelSide={panelSide}
777
+ onSetPanelSide={onSetPanelSide}
778
+ layersMode={layersMode}
779
+ onSetLayersMode={onSetLayersMode}
780
+ />
781
+ </section>
782
+
783
+ {/* AI generation — BYOK provider keys (kept mounted so the fetch runs once). */}
784
+ <section
785
+ role="tabpanel"
786
+ id="st-spanel-ai-generation"
787
+ aria-labelledby="st-stab-ai-generation"
788
+ hidden={tab !== 'ai-generation'}
789
+ >
790
+ <div className="st-rp-hd">Provider keys</div>
791
+ <p className="st-settings-intro">
792
+ Bring your own API keys to generate images (and, soon, audio + video) inside Maude.
793
+ Keys are stored on this machine only — in your OS keychain or a private{' '}
794
+ <code>~/.config/maude/keys.json</code> (mode 0600) — sent straight to the provider,
795
+ and never committed, logged, or exposed to a canvas.
796
+ </p>
797
+ {error && (
798
+ <div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
799
+ {error}
800
+ </div>
801
+ )}
802
+ {providers === null && !error && <div className="st-settings-intro">Loading…</div>}
803
+ {providers?.map((p) => (
804
+ <ProviderCard key={p.id} provider={p} onChanged={load} />
805
+ ))}
806
+ {providers?.length === 0 && (
807
+ <div className="st-settings-intro">No providers registered.</div>
808
+ )}
809
+ </section>
810
+
811
+ {/* Subtitles — kept mounted so WhisperModelCard's download poll survives
812
+ a tab switch (unmounting mid-download would drop the interval). */}
813
+ <section
814
+ role="tabpanel"
815
+ id="st-spanel-subtitles"
816
+ aria-labelledby="st-stab-subtitles"
817
+ hidden={tab !== 'subtitles'}
818
+ >
819
+ <div className="st-rp-hd">Subtitles</div>
820
+ <TranscriptionEngineCard />
821
+ <WhisperModelCard />
822
+ </section>
823
+ </div>
824
+ </div>
825
+ </div>
826
+ </div>
827
+ );
828
+ }