@1agh/maude 0.45.2 → 0.47.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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -25,7 +25,12 @@ import {
25
25
  useThread,
26
26
  } from '@assistant-ui/react';
27
27
 
28
- import { flattenSelectOptions, parseConfigOptions, resolvePersistedPick } from './acp-capabilities.js';
28
+ import {
29
+ flattenSelectOptions,
30
+ modeBlocksEdits,
31
+ parseConfigOptions,
32
+ resolvePersistedPick,
33
+ } from './acp-capabilities.js';
29
34
  import { parseUsage } from './acp-usage.js';
30
35
  import {
31
36
  activityLabel,
@@ -147,6 +152,17 @@ const SUGGESTIONS = [
147
152
  '/design:new Pricing "a 3-tier pricing page"',
148
153
  ];
149
154
 
155
+ // DDR-185 — shared "did a genuinely new item arrive" check for the
156
+ // permission/elicitation effects below: both need the identical shape (diff
157
+ // the incoming list's ids against the previous snapshot, fire onNew only for
158
+ // a real arrival, never for a resolution/removal), just against a different
159
+ // ref and a different onNew callback.
160
+ function fireOnNewArrival(prevIdsRef, list, onNew) {
161
+ const hasNew = list.some((item) => !prevIdsRef.current.has(item.id));
162
+ prevIdsRef.current = new Set(list.map((item) => item.id));
163
+ if (hasNew) onNew?.();
164
+ }
165
+
150
166
  function safeStorageGet(key, fallback) {
151
167
  try {
152
168
  return localStorage.getItem(key) ?? fallback;
@@ -208,6 +224,42 @@ function modeFootnote(modes) {
208
224
  return NO_PROMPT_MODE_IDS.has(modes.currentModeId) ? `${name} — no prompts` : `${name} — you'll be asked`;
209
225
  }
210
226
 
227
+ // The loud, top-of-thread alert shown ONLY while the current mode blocks edits
228
+ // (`modeBlocksEdits`, DDR-184 #3 — the quiet composer footnote above isn't enough
229
+ // when a user typing "change X" silently gets nothing back).
230
+ // Offers a one-click hop to the least-privilege mode that CAN edit (Accept Edits
231
+ // if advertised, else Manual) so the fix is right there, not buried in the picker.
232
+ function ModeBanner({ modes, onSetMode }) {
233
+ if (!modeBlocksEdits(modes)) return null;
234
+ const current = modes.availableModes.find((m) => m.id === modes.currentModeId);
235
+ const name = current?.name || modes.currentModeId;
236
+ const isPlan = modes.currentModeId === 'plan';
237
+ const ids = new Set(modes.availableModes.map((m) => m.id));
238
+ const target = ids.has('acceptEdits') ? 'acceptEdits' : ids.has('default') ? 'default' : null;
239
+ const targetName = target && (modes.availableModes.find((m) => m.id === target)?.name || target);
240
+ return (
241
+ <div className="chat-mode-banner" role="alert" data-testid="chat-mode-banner">
242
+ <span className="chat-mode-banner-icon" aria-hidden="true">⏸</span>
243
+ <span className="chat-mode-banner-text">
244
+ <b>{name}</b>{' — '}
245
+ {isPlan
246
+ ? "Claude is planning and won't edit files or run tools."
247
+ : 'edits and tools without a standing approval are denied — nothing will change.'}
248
+ </span>
249
+ {target ? (
250
+ <button
251
+ type="button"
252
+ className="chat-mode-banner-cta"
253
+ onClick={() => onSetMode(target)}
254
+ data-testid="chat-mode-banner-switch"
255
+ >
256
+ Switch to {targetName}
257
+ </button>
258
+ ) : null}
259
+ </div>
260
+ );
261
+ }
262
+
211
263
  // ── pasted-path / URL → inline chip (Claude-Code-style attachment badge) ──
212
264
  // When the whole clipboard is a single path or URL, we collapse it to a short
213
265
  // literal token ([image-1] / [file-1] / [link-1]) in the textarea and stash the
@@ -1445,6 +1497,8 @@ function ChatThread({
1445
1497
  designRel,
1446
1498
  transcriptView,
1447
1499
  onSetTranscriptView,
1500
+ onPermissionRequest,
1501
+ onElicitationRequest,
1448
1502
  }) {
1449
1503
  // Live session capabilities (feature-acp-panel-dynamic-claude-code-
1450
1504
  // capabilities) — `caps` is THIS chat's own connection's mode roster +
@@ -1578,9 +1632,29 @@ function ChatThread({
1578
1632
  // Post-turn-end continuation (the tail the client used to drop — RCA F2).
1579
1633
  const [bgParts, setBgParts] = useState([]);
1580
1634
  useEffect(() => conn.onBackground(setBgParts), [conn]);
1635
+ // DDR-185 — surface a "Maude needs your input" attention ping to the parent
1636
+ // (app.jsx) whenever a GENUINELY NEW permission/elicitation request arrives,
1637
+ // not on every list churn (a request resolving/timing out also updates the
1638
+ // list — that must NOT re-fire the notification). Read via a ref, not the
1639
+ // effect's own dependency array, so a parent re-render that produces a new
1640
+ // callback identity doesn't force conn.onPermission/onElicitation to
1641
+ // re-subscribe.
1642
+ const attentionCbRef = useRef({ onPermissionRequest, onElicitationRequest });
1643
+ useEffect(() => {
1644
+ attentionCbRef.current = { onPermissionRequest, onElicitationRequest };
1645
+ }, [onPermissionRequest, onElicitationRequest]);
1646
+
1581
1647
  // Pending permission requests (Milestone B) — one card at a time, oldest first.
1582
1648
  const [pendingPermissions, setPendingPermissions] = useState([]);
1583
- useEffect(() => conn.onPermission(setPendingPermissions), [conn]);
1649
+ const prevPermissionIdsRef = useRef(new Set());
1650
+ useEffect(
1651
+ () =>
1652
+ conn.onPermission((list) => {
1653
+ fireOnNewArrival(prevPermissionIdsRef, list, attentionCbRef.current.onPermissionRequest);
1654
+ setPendingPermissions(list);
1655
+ }),
1656
+ [conn]
1657
+ );
1584
1658
  const activePermission = pendingPermissions[0] || null;
1585
1659
  const respondPermission = useCallback(
1586
1660
  (decision) => activePermission && conn.respondPermission(activePermission.id, decision),
@@ -1589,7 +1663,15 @@ function ChatThread({
1589
1663
  // Pending elicitation requests (feature-acp-ask-user-question) — same
1590
1664
  // one-at-a-time, oldest-first treatment as pendingPermissions above.
1591
1665
  const [pendingElicitations, setPendingElicitations] = useState([]);
1592
- useEffect(() => conn.onElicitation(setPendingElicitations), [conn]);
1666
+ const prevElicitationIdsRef = useRef(new Set());
1667
+ useEffect(
1668
+ () =>
1669
+ conn.onElicitation((list) => {
1670
+ fireOnNewArrival(prevElicitationIdsRef, list, attentionCbRef.current.onElicitationRequest);
1671
+ setPendingElicitations(list);
1672
+ }),
1673
+ [conn]
1674
+ );
1593
1675
  const activeElicitation = pendingElicitations[0] || null;
1594
1676
  const respondElicitation = useCallback(
1595
1677
  (response) => activeElicitation && conn.respondElicitation(activeElicitation.id, response),
@@ -1627,6 +1709,9 @@ function ChatThread({
1627
1709
  transcriptView={transcriptView}
1628
1710
  onSetTranscriptView={onSetTranscriptView}
1629
1711
  />
1712
+ {/* DDR-184 #3 — loud alert when the current mode can't edit (plan / dontAsk),
1713
+ so a user typing "change X" isn't silently ignored. Above the feed. */}
1714
+ <ModeBanner modes={caps.modes} onSetMode={onSetMode} />
1630
1715
  <ThreadPrimitive.Root className="chat-thread">
1631
1716
  <ThreadPrimitive.Viewport className="chat-feed" autoScroll>
1632
1717
  <ThreadPrimitive.Empty>
@@ -1687,6 +1772,8 @@ export default function ChatPanel({
1687
1772
  hidden = false,
1688
1773
  onBusyChange,
1689
1774
  onFinished,
1775
+ onPermissionRequest,
1776
+ onElicitationRequest,
1690
1777
  }) {
1691
1778
  // Model/effort/mode picks — persisted across sessions per config id (never a
1692
1779
  // fixed model/effort pair; see loadPersistedPicks). Read live by each open
@@ -2185,6 +2272,8 @@ export default function ChatPanel({
2185
2272
  designRel={designRel}
2186
2273
  transcriptView={transcriptView}
2187
2274
  onSetTranscriptView={setTranscriptView}
2275
+ onPermissionRequest={onPermissionRequest}
2276
+ onElicitationRequest={onElicitationRequest}
2188
2277
  />
2189
2278
  );
2190
2279
  })
@@ -385,6 +385,203 @@ function WhisperModelCard() {
385
385
  );
386
386
  }
387
387
 
388
+ // feature-scene-aware-keyframes — the video-analysis frame-selection engine, the
389
+ // sibling of the Subtitles section. Persists `keyframeEngine` to /_api/generate/prefs.
390
+ const KEYFRAME_ENGINES = [
391
+ { id: 'auto', label: 'Auto', note: 'Use the best available: Gemma scout → ffmpeg → blind.' },
392
+ { id: 'gemma', label: 'Gemma scout', note: 'Semantic action beats. Needs Apple Silicon + mlx-vlm + a model.' },
393
+ { id: 'ffmpeg', label: 'ffmpeg scene-detect', note: 'Scene cuts + endpoints. Needs ffmpeg. No model download.' },
394
+ { id: 'blind', label: 'Blind (even-spaced)', note: 'Chromium fallback — works with neither ffmpeg nor Gemma.' },
395
+ ];
396
+
397
+ function KeyframeEngineCard() {
398
+ const [engine, setEngine] = useState(null);
399
+ const [busy, setBusy] = useState(false);
400
+ const [status, setStatus] = useState(null);
401
+
402
+ useEffect(() => {
403
+ fetch('/_api/generate/prefs')
404
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
405
+ .then((d) => setEngine(d?.keyframeEngine || 'auto'))
406
+ .catch(() => setEngine('auto'));
407
+ }, []);
408
+
409
+ async function choose(id) {
410
+ if (id === engine) return;
411
+ const prev = engine;
412
+ setEngine(id);
413
+ setBusy(true);
414
+ setStatus(null);
415
+ try {
416
+ const res = await fetch('/_api/generate/prefs', {
417
+ method: 'POST',
418
+ headers: { 'content-type': 'text/plain' },
419
+ body: JSON.stringify({ keyframeEngine: id }),
420
+ });
421
+ if (!res.ok) throw new Error(await res.text());
422
+ setStatus({ ok: true, msg: 'Saved.' });
423
+ } catch (err) {
424
+ setEngine(prev);
425
+ setStatus({ ok: false, msg: err && err.message ? err.message : 'save failed' });
426
+ } finally {
427
+ setBusy(false);
428
+ }
429
+ }
430
+
431
+ return (
432
+ <div className="st-provider-card">
433
+ <div className="st-provider-hd">
434
+ <span className="st-provider-name">Scene-aware keyframes — analysis engine</span>
435
+ </div>
436
+ <div className="st-provider-notes">
437
+ How Maude picks frames when it analyzes a video (`/design:video-analyze`, `/design:reel`).
438
+ Scene-aware beats a blind frame rate. This is an explicit choice — a chosen-but-unavailable
439
+ engine tells you how to fix it; <strong>Auto</strong> just uses the best you have installed.
440
+ </div>
441
+ <div className="st-engine-radios" role="radiogroup" aria-label="Keyframe engine">
442
+ {KEYFRAME_ENGINES.map((e) => (
443
+ <label key={e.id} className={'st-engine-radio' + (engine === e.id ? ' is-selected' : '')}>
444
+ <input
445
+ type="radio"
446
+ name="keyframe-engine"
447
+ value={e.id}
448
+ checked={engine === e.id}
449
+ disabled={busy || engine === null}
450
+ onChange={() => choose(e.id)}
451
+ />
452
+ <span className="st-engine-radio-body">
453
+ <span className="st-engine-radio-label">{e.label}</span>
454
+ <span className="st-engine-radio-note">{e.note}</span>
455
+ </span>
456
+ </label>
457
+ ))}
458
+ </div>
459
+ {status && (
460
+ <div
461
+ className="st-provider-status"
462
+ style={{ color: status.ok ? 'var(--accent)' : 'var(--danger, #e5484d)' }}
463
+ >
464
+ {status.msg}
465
+ </div>
466
+ )}
467
+ </div>
468
+ );
469
+ }
470
+
471
+ // The MODEL half of the Gemma scout — one click, mirroring WhisperModelCard. The
472
+ // RUNTIME (mlx-vlm) is a manual `pip install` the app can't do, so the download is
473
+ // gated on mlxVlmAvailable and the card says which tier will actually run.
474
+ function GemmaModelCard() {
475
+ const [state, setState] = useState(null); // { models, downloading, mlxVlmAvailable, ffmpegAvailable }
476
+ const [err, setErr] = useState(null);
477
+
478
+ const load = useCallback(() => {
479
+ fetch('/_api/generate/keyframe-model')
480
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
481
+ .then(setState)
482
+ .catch((e) => setErr(e && e.message ? e.message : 'failed to load models'));
483
+ }, []);
484
+
485
+ useEffect(() => {
486
+ load();
487
+ }, [load]);
488
+
489
+ useEffect(() => {
490
+ if (!state?.downloading) return undefined;
491
+ const t = setInterval(load, 1500);
492
+ return () => clearInterval(t);
493
+ }, [state?.downloading, load]);
494
+
495
+ async function download(id) {
496
+ setErr(null);
497
+ try {
498
+ const res = await fetch('/_api/generate/keyframe-model', {
499
+ method: 'POST',
500
+ headers: { 'content-type': 'text/plain' },
501
+ body: JSON.stringify({ id }),
502
+ });
503
+ if (!res.ok) throw new Error(await res.text());
504
+ load();
505
+ } catch (e) {
506
+ setErr(e && e.message ? e.message : 'download failed');
507
+ }
508
+ }
509
+
510
+ const dl = state?.downloading;
511
+ const mlx = state?.mlxVlmAvailable;
512
+ const ff = state?.ffmpegAvailable;
513
+ const pctOf = (r, t) => (t > 0 ? Math.min(100, Math.round((r / t) * 100)) : 0);
514
+ const activeTier = mlx ? 'Gemma scout' : ff ? 'ffmpeg scene-detect' : 'blind (even-spaced)';
515
+
516
+ return (
517
+ <div className="st-provider-card">
518
+ <div className="st-provider-hd">
519
+ <span className="st-provider-name">Gemma scout model (optional)</span>
520
+ </div>
521
+ <div className="st-provider-notes">
522
+ The <strong>Gemma</strong> tier adds semantic action-beat detection on top of scene cuts. It
523
+ needs an <strong>Apple-Silicon Mac</strong> and the <code>mlx-vlm</code> runtime
524
+ (<code>pip install mlx-vlm</code> — a manual step Maude can’t do for you). Downloading a model
525
+ below is the one-click half. Models live in your HuggingFace cache, never committed.
526
+ </div>
527
+ {state && (
528
+ <div className="st-provider-notes">
529
+ On this machine the active tier is <strong>{activeTier}</strong>
530
+ {!mlx && ff && ' — install mlx-vlm + download a model to unlock the Gemma scout.'}
531
+ {!mlx && !ff && ' — install ffmpeg for scene-aware frames, or mlx-vlm for the Gemma scout.'}
532
+ </div>
533
+ )}
534
+ {err && (
535
+ <div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
536
+ {err}
537
+ </div>
538
+ )}
539
+ {dl?.error && (
540
+ <div className="st-provider-status" style={{ color: 'var(--danger, #e5484d)' }}>
541
+ Download of {dl.id} failed: {dl.error}
542
+ </div>
543
+ )}
544
+ {state === null && !err && <div className="st-settings-intro">Loading…</div>}
545
+ <div className="st-model-list">
546
+ {(state?.models || []).map((m) => {
547
+ const busy = dl && !dl.error && dl.id === m.id;
548
+ return (
549
+ <div key={m.id} className="st-model-row">
550
+ <div className="st-model-info">
551
+ <span className="st-model-label">
552
+ {m.label}
553
+ <span className="st-model-size">~{Math.round(m.sizeMB / 100) / 10} GB</span>
554
+ </span>
555
+ <span className="st-engine-radio-note">{m.note}</span>
556
+ {busy && (
557
+ <span className="st-model-progress">Downloading… {pctOf(dl.received, dl.total)}%</span>
558
+ )}
559
+ </div>
560
+ <div className="st-model-actions">
561
+ {m.downloaded ? (
562
+ <span className="st-provider-configured">
563
+ <Icon name="check" size={12} /> ready
564
+ </span>
565
+ ) : (
566
+ <button
567
+ type="button"
568
+ className="st-btn"
569
+ disabled={!mlx || !!(dl && !dl.error)}
570
+ title={mlx ? '' : 'Install mlx-vlm first (pip install mlx-vlm)'}
571
+ onClick={() => download(m.id)}
572
+ >
573
+ {busy ? 'Downloading…' : mlx ? 'Download' : 'Needs mlx-vlm'}
574
+ </button>
575
+ )}
576
+ </div>
577
+ </div>
578
+ );
579
+ })}
580
+ </div>
581
+ </div>
582
+ );
583
+ }
584
+
388
585
  // feature-unified-settings-modal — one modal for every Maude preference, laid
389
586
  // out as a left vertical tab rail + an internally-scrolling pane. Categories:
390
587
  const TABS = [
@@ -393,6 +590,7 @@ const TABS = [
393
590
  { id: 'layout', label: 'Layout' },
394
591
  { id: 'ai-generation', label: 'AI generation' },
395
592
  { id: 'subtitles', label: 'Subtitles' },
593
+ { id: 'video', label: 'Video' },
396
594
  ];
397
595
  const SETTINGS_TAB_STORE = 'mdcc-settings-tab';
398
596
 
@@ -820,6 +1018,19 @@ export default function SettingsPanel({
820
1018
  <TranscriptionEngineCard />
821
1019
  <WhisperModelCard />
822
1020
  </section>
1021
+
1022
+ {/* Video — scene-aware keyframe engine. Kept mounted so GemmaModelCard's
1023
+ download poll survives a tab switch (like Subtitles above). */}
1024
+ <section
1025
+ role="tabpanel"
1026
+ id="st-spanel-video"
1027
+ aria-labelledby="st-stab-video"
1028
+ hidden={tab !== 'video'}
1029
+ >
1030
+ <div className="st-rp-hd">Video</div>
1031
+ <KeyframeEngineCard />
1032
+ <GemmaModelCard />
1033
+ </section>
823
1034
  </div>
824
1035
  </div>
825
1036
  </div>
@@ -69,3 +69,14 @@ export function resolvePersistedPick(availableValues, savedValue, defaultValue)
69
69
  if (savedValue != null && set.has(savedValue)) return savedValue;
70
70
  return defaultValue ?? null;
71
71
  }
72
+
73
+ // Modes where the agent CANNOT apply an edit and there's no interactive approve
74
+ // path to unblock it (DDR-184 #3). `plan` = "no actual tool execution"; `dontAsk`
75
+ // denies anything without a standing pre-approval (and Maude's default allow-list
76
+ // only covers edits + `maude`, so an off-list step is silently denied). The
77
+ // ChatPanel ModeBanner reads this to decide whether to show the loud alert. Keyed
78
+ // off the mode id, never a label, so it stays truthful as the roster evolves.
79
+ export const NO_EDIT_MODE_IDS = new Set(['plan', 'dontAsk']);
80
+ export function modeBlocksEdits(modes) {
81
+ return !!modes?.currentModeId && NO_EDIT_MODE_IDS.has(modes.currentModeId);
82
+ }
@@ -404,6 +404,10 @@
404
404
  .st-rp-hd { font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-2); }
405
405
  /* Stage H (INV-3) edit-scope strip — local (this element) vs shared (edits N places). */
406
406
  .st-scope { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); font-size: var(--type-xs); border-bottom: 1px solid var(--border-subtle); flex: none; animation: st-pop var(--dur-soft) var(--ease-out); }
407
+ /* feature-4 detach-component — inline action on the Shared badge. */
408
+ .st-scope-detach { margin-left: auto; padding: 1px 8px; border-radius: var(--radius-xs); font-size: var(--type-xs); background: var(--bg-2); color: var(--fg-1); border: 1px solid var(--border-subtle); cursor: pointer; }
409
+ .st-scope-detach:hover { background: var(--bg-3); color: var(--fg-0); }
410
+ .st-scope-detach:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
407
411
  .st-scope-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
408
412
  .st-scope--local { color: var(--fg-2); }
409
413
  .st-scope--local .st-scope-dot { background: var(--fg-3); }
@@ -664,6 +668,30 @@
664
668
  .st-layer--row:hover .st-layer-eye, .st-layer--row:focus-within .st-layer-eye { opacity: 1; }
665
669
  .st-layer-eye:hover { color: var(--fg-0); background: var(--bg-3); }
666
670
  .st-layer-eye:focus-visible { outline: none; color: var(--accent); box-shadow: 0 0 0 2px var(--accent-tint); }
671
+ /* feature-4 T7 — synthetic group row (unstamped wrapper): a muted, italic
672
+ structural stand-in. Not selectable, not draggable, no eye — it exists only
673
+ to restore real nesting in the tree. The folder icon reads as "container". */
674
+ .st-layer.is-group { cursor: default; color: var(--fg-3); }
675
+ .st-layer.is-group .st-layer-label { font-style: italic; }
676
+ .st-layer.is-group .st-layer-type { opacity: 0.7; }
677
+ .st-layer.is-group:hover { background: var(--bg-2); }
678
+ /* feature-4 T7a — component-instance rows (Figma purple). ◆ = the component's
679
+ own frame root, ◇ = an inner member. The purple is deliberately Figma-adjacent
680
+ so the vocabulary transfers. */
681
+ .st-layer.is-instance { color: var(--layer-instance, #a78bfa); }
682
+ .st-layer.is-instance .st-layer-label { color: var(--layer-instance, #a78bfa); }
683
+ .st-layer.is-instance .st-layer-type { color: var(--layer-instance, #a78bfa); opacity: 0.75; }
684
+ .st-layer-inst-glyph { flex: none; width: 14px; text-align: center; font-size: 10px; color: var(--layer-instance, #a78bfa); }
685
+ .st-layer.is-sel.is-instance { box-shadow: inset 2px 0 0 var(--layer-instance, #a78bfa); }
686
+ /* feature-4 T7b — locked rows: padlock stays visible when locked; dimmed label. */
687
+ .st-layer-lock { flex: none; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 0; color: var(--fg-3); cursor: pointer; padding: 0; border-radius: var(--radius-xs); opacity: 0; }
688
+ .st-layer--row:hover .st-layer-lock, .st-layer--row:focus-within .st-layer-lock { opacity: 1; }
689
+ .st-layer-lock:hover { color: var(--fg-0); background: var(--bg-3); }
690
+ .st-layer-lock:focus-visible { outline: none; color: var(--accent); box-shadow: 0 0 0 2px var(--accent-tint); }
691
+ .st-layer.is-locked .st-layer-lock { opacity: 1; color: var(--fg-1); }
692
+ .st-layer.is-locked .st-layer-label { opacity: 0.6; }
693
+ /* feature-4 T7c — inline rename input matches the row's mono type. */
694
+ .st-layer-rename { flex: 1 1 auto; min-width: 40px; font: inherit; color: var(--fg-0); background: var(--bg-1); border: 1px solid var(--accent); border-radius: var(--radius-xs); padding: 0 4px; outline: none; }
667
695
  /* a hidden layer stays dim + keeps its eye visible (so it can be re-shown) */
668
696
  .st-layer.is-hidden { opacity: 0.45; }
669
697
  .st-layer.is-hidden .st-layer-eye { opacity: 1; color: var(--fg-2); }
@@ -1141,6 +1169,17 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1141
1169
  .st-cp-makeflex:hover:not(:disabled) { border-color: var(--accent); color: var(--fg-0); }
1142
1170
  .st-cp-makeflex:disabled { opacity: 0.5; cursor: default; }
1143
1171
 
1172
+ /* feature-3-web-artboards T5 — Grid section's track-list editor. */
1173
+ .st-cp-gridtracks { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-1) 0; }
1174
+ .st-cp-gridtracks + .st-cp-gridtracks { margin-top: var(--space-2); border-top: 1px solid var(--border-subtle); padding-top: var(--space-2); }
1175
+ .st-cp-gridtracks-hd { display: flex; align-items: center; justify-content: space-between; }
1176
+ .st-cp-gridtracks-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: var(--tracking-wide); color: var(--fg-2); }
1177
+ .st-cp-gridtracks-add { min-height: 22px; padding: 0 var(--space-2); font-size: 10px; }
1178
+ .st-cp-gridtrack-row { display: flex; align-items: center; gap: var(--space-1); }
1179
+ .st-cp-gridtrack-row .st-cp-nsel { flex: 1 1 auto; }
1180
+ .st-cp-gridtrack-remove { flex: 0 0 auto; min-height: 22px; min-width: 22px; padding: 0; justify-content: center; color: var(--fg-2); }
1181
+ .st-cp-gridtrack-remove:hover:not(:disabled) { color: var(--status-error); }
1182
+
1144
1183
  /* provenance legend */
1145
1184
  .st-cp-legend { display: flex; flex-wrap: wrap; gap: var(--space-3); padding-top: var(--space-1); border-top: 1px solid var(--border-subtle); }
1146
1185
  .st-cp-legend span { display: inline-flex; align-items: center; gap: var(--space-1); font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); }
@@ -1907,4 +1907,45 @@
1907
1907
  .brand-up-nextstep { margin: var(--space-4) 0 0; font-size: var(--type-sm); color: var(--fg-1); line-height: var(--lh-md); }
1908
1908
  .brand-up-nextstep code { font-family: var(--font-mono); background: var(--bg-3); padding: 1px 4px; border-radius: var(--radius-xs); }
1909
1909
  .brand-up-actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); }
1910
+
1911
+ /* DDR-184 #3 — loud top-of-thread alert while the current mode can't edit
1912
+ (Plan / Don't-Ask). Warn-toned so it reads as "you're blocked", distinct
1913
+ from the neutral usage banner. One-click hop back to an editing mode. */
1914
+ .chat-mode-banner {
1915
+ display: flex;
1916
+ align-items: center;
1917
+ gap: var(--space-2);
1918
+ margin: 0 var(--space-4) var(--space-2);
1919
+ padding: var(--space-2) var(--space-3);
1920
+ border: 1px solid var(--warn, hsl(38 92% 50%));
1921
+ border-radius: var(--radius-sm);
1922
+ background: var(--bg-2);
1923
+ color: var(--fg-1);
1924
+ font-size: var(--type-xs);
1925
+ line-height: var(--lh-md);
1926
+ }
1927
+ .chat-mode-banner-icon {
1928
+ flex-shrink: 0;
1929
+ color: var(--warn, hsl(38 92% 50%));
1930
+ }
1931
+ .chat-mode-banner-text {
1932
+ flex: 1 1 auto;
1933
+ }
1934
+ .chat-mode-banner-text b {
1935
+ color: var(--fg-0);
1936
+ }
1937
+ .chat-mode-banner-cta {
1938
+ flex-shrink: 0;
1939
+ appearance: none;
1940
+ cursor: pointer;
1941
+ border: 1px solid var(--border-default);
1942
+ border-radius: var(--radius-xs);
1943
+ background: var(--bg-3);
1944
+ color: var(--fg-0);
1945
+ font-size: var(--type-xs);
1946
+ padding: 2px var(--space-2);
1947
+ }
1948
+ .chat-mode-banner-cta:hover {
1949
+ background: var(--bg-4);
1950
+ }
1910
1951
  }
@@ -170,9 +170,11 @@ interface AlignTarget {
170
170
  /**
171
171
  * Resolve every selection to its current world-space box, gated on
172
172
  * absolute/fixed positioning — align/distribute/tidy-up write `left`/`top`,
173
- * which is only meaningful for out-of-flow elements (this codebase
174
- * deliberately never does an implicit convert-to-absolute-on-drag; see the
175
- * Stage-D dogfood notes). Returns `null` (all-or-nothing) if any selection
173
+ * which is only meaningful for out-of-flow elements. This lane still never does
174
+ * an IMPLICIT convert-to-absolute-on-drag (see the Stage-D dogfood notes); the
175
+ * EXPLICIT context-menu "Convert children to absolute position" (feature-4 T8,
176
+ * DDR-188) is the opt-in path to flatten a flow layout first. Returns `null`
177
+ * (all-or-nothing) if any selection
176
178
  * doesn't resolve, or isn't out-of-flow, or has a non-numeric `left`/`top`
177
179
  * (e.g. positioned via `right`/`bottom` instead — a known v1 limit).
178
180
  */