@1agh/maude 0.45.2 → 0.46.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 (54) hide show
  1. package/apps/studio/acp/bridge.ts +43 -1
  2. package/apps/studio/annotations-snap.ts +50 -10
  3. package/apps/studio/api.ts +344 -14
  4. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  5. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  6. package/apps/studio/bin/_png-playwright.mjs +38 -4
  7. package/apps/studio/bin/_pw-launch.mjs +52 -0
  8. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  9. package/apps/studio/bin/_smart-frames.mjs +419 -0
  10. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  11. package/apps/studio/bin/smart-frames.sh +30 -0
  12. package/apps/studio/canvas-edit.ts +302 -3
  13. package/apps/studio/canvas-lib.tsx +203 -11
  14. package/apps/studio/canvas-shell.tsx +167 -28
  15. package/apps/studio/client/app.jsx +678 -176
  16. package/apps/studio/client/panels/ChatPanel.jsx +45 -1
  17. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  18. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  19. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  20. package/apps/studio/dist/client.bundle.js +1398 -1398
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/dom-selection.ts +20 -0
  24. package/apps/studio/export-dialog.tsx +138 -18
  25. package/apps/studio/exporters/pdf.ts +332 -18
  26. package/apps/studio/exporters/png.ts +45 -3
  27. package/apps/studio/footage/schema.ts +17 -1
  28. package/apps/studio/generation/gemma-models.ts +224 -0
  29. package/apps/studio/generation/prefs.ts +43 -0
  30. package/apps/studio/http.ts +213 -7
  31. package/apps/studio/print/marks.ts +113 -0
  32. package/apps/studio/print/units.ts +269 -0
  33. package/apps/studio/print-overlay-content.tsx +132 -0
  34. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  35. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  36. package/apps/studio/test/annotations-snap.test.ts +56 -0
  37. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  38. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  39. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  40. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  41. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  42. package/apps/studio/test/exporters/png.test.ts +49 -1
  43. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  44. package/apps/studio/test/print-marks.test.ts +113 -0
  45. package/apps/studio/test/print-units.test.ts +173 -0
  46. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  47. package/apps/studio/use-chrome-visibility.tsx +19 -0
  48. package/apps/studio/use-element-resize.tsx +21 -2
  49. package/apps/studio/use-snap-guides.tsx +73 -5
  50. package/apps/studio/use-spacing-handles.tsx +9 -5
  51. package/apps/studio/whats-new.json +28 -0
  52. package/cli/commands/design.mjs +9 -1
  53. package/package.json +8 -8
  54. package/plugins/design/dependencies.json +35 -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,
@@ -208,6 +213,42 @@ function modeFootnote(modes) {
208
213
  return NO_PROMPT_MODE_IDS.has(modes.currentModeId) ? `${name} — no prompts` : `${name} — you'll be asked`;
209
214
  }
210
215
 
216
+ // The loud, top-of-thread alert shown ONLY while the current mode blocks edits
217
+ // (`modeBlocksEdits`, DDR-184 #3 — the quiet composer footnote above isn't enough
218
+ // when a user typing "change X" silently gets nothing back).
219
+ // Offers a one-click hop to the least-privilege mode that CAN edit (Accept Edits
220
+ // if advertised, else Manual) so the fix is right there, not buried in the picker.
221
+ function ModeBanner({ modes, onSetMode }) {
222
+ if (!modeBlocksEdits(modes)) return null;
223
+ const current = modes.availableModes.find((m) => m.id === modes.currentModeId);
224
+ const name = current?.name || modes.currentModeId;
225
+ const isPlan = modes.currentModeId === 'plan';
226
+ const ids = new Set(modes.availableModes.map((m) => m.id));
227
+ const target = ids.has('acceptEdits') ? 'acceptEdits' : ids.has('default') ? 'default' : null;
228
+ const targetName = target && (modes.availableModes.find((m) => m.id === target)?.name || target);
229
+ return (
230
+ <div className="chat-mode-banner" role="alert" data-testid="chat-mode-banner">
231
+ <span className="chat-mode-banner-icon" aria-hidden="true">⏸</span>
232
+ <span className="chat-mode-banner-text">
233
+ <b>{name}</b>{' — '}
234
+ {isPlan
235
+ ? "Claude is planning and won't edit files or run tools."
236
+ : 'edits and tools without a standing approval are denied — nothing will change.'}
237
+ </span>
238
+ {target ? (
239
+ <button
240
+ type="button"
241
+ className="chat-mode-banner-cta"
242
+ onClick={() => onSetMode(target)}
243
+ data-testid="chat-mode-banner-switch"
244
+ >
245
+ Switch to {targetName}
246
+ </button>
247
+ ) : null}
248
+ </div>
249
+ );
250
+ }
251
+
211
252
  // ── pasted-path / URL → inline chip (Claude-Code-style attachment badge) ──
212
253
  // When the whole clipboard is a single path or URL, we collapse it to a short
213
254
  // literal token ([image-1] / [file-1] / [link-1]) in the textarea and stash the
@@ -1627,6 +1668,9 @@ function ChatThread({
1627
1668
  transcriptView={transcriptView}
1628
1669
  onSetTranscriptView={onSetTranscriptView}
1629
1670
  />
1671
+ {/* DDR-184 #3 — loud alert when the current mode can't edit (plan / dontAsk),
1672
+ so a user typing "change X" isn't silently ignored. Above the feed. */}
1673
+ <ModeBanner modes={caps.modes} onSetMode={onSetMode} />
1630
1674
  <ThreadPrimitive.Root className="chat-thread">
1631
1675
  <ThreadPrimitive.Viewport className="chat-feed" autoScroll>
1632
1676
  <ThreadPrimitive.Empty>
@@ -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
+ }
@@ -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
  }