@1agh/maude 0.58.3 → 0.59.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 (67) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +868 -214
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/dist/client.bundle.js +772 -772
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  21. package/apps/studio/exporters/video.ts +10 -0
  22. package/apps/studio/figma/assets.test.ts +92 -0
  23. package/apps/studio/figma/assets.ts +63 -9
  24. package/apps/studio/figma/codegen-client.test.ts +276 -0
  25. package/apps/studio/figma/codegen-client.ts +509 -0
  26. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  27. package/apps/studio/figma/codegen-fonts.ts +195 -0
  28. package/apps/studio/figma/codegen-values.test.ts +179 -0
  29. package/apps/studio/figma/codegen-values.ts +270 -0
  30. package/apps/studio/figma/endpoints.ts +73 -0
  31. package/apps/studio/figma/fig-decode.test.ts +702 -0
  32. package/apps/studio/figma/fig-decode.ts +617 -0
  33. package/apps/studio/figma/fig-kiwi.ts +410 -0
  34. package/apps/studio/figma/fig-zip.ts +270 -0
  35. package/apps/studio/figma/from-codegen.test.ts +408 -0
  36. package/apps/studio/figma/from-codegen.ts +1103 -0
  37. package/apps/studio/figma/sanitize.test.ts +69 -0
  38. package/apps/studio/figma/sanitize.ts +139 -47
  39. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  40. package/apps/studio/figma/tailwind-map.ts +545 -0
  41. package/apps/studio/figma/to-render.ts +25 -3
  42. package/apps/studio/figma/types.ts +6 -1
  43. package/apps/studio/http.ts +47 -0
  44. package/apps/studio/sync/asset-push.ts +346 -38
  45. package/apps/studio/sync/connection-state.ts +71 -3
  46. package/apps/studio/sync/index.ts +10 -1
  47. package/apps/studio/sync/status.ts +18 -0
  48. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  49. package/apps/studio/test/figma-explode.test.ts +438 -0
  50. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  51. package/apps/studio/test/import-figma.test.ts +192 -4
  52. package/apps/studio/test/sync-asset-push.test.ts +490 -47
  53. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  54. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  55. package/apps/studio/test/sync-status.test.ts +28 -0
  56. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  57. package/apps/studio/test/video-comp.test.ts +81 -1
  58. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  59. package/apps/studio/use-artboard-drag.tsx +37 -3
  60. package/apps/studio/video-comp.tsx +51 -0
  61. package/apps/studio/whats-new.json +71 -0
  62. package/cli/commands/design.mjs +7 -0
  63. package/cli/commands/kg.mjs +8 -1
  64. package/cli/commands/kg.test.mjs +24 -0
  65. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  66. package/cli/lib/figma-import-controls.test.mjs +70 -0
  67. package/package.json +8 -8
@@ -33,6 +33,7 @@ import { useTreeDrag } from './use-tree-drag.js';
33
33
  import ChatPanel from './panels/ChatPanel.jsx';
34
34
  import DiffView from './panels/DiffView.jsx';
35
35
  import GitPanel from './panels/GitPanel.jsx';
36
+ import SyncPanel from './panels/SyncPanel.jsx';
36
37
  import CloudBar from './panels/CloudBar.jsx';
37
38
  import IdentityBar from './panels/IdentityBar.jsx';
38
39
  import OnboardingWizard from './panels/OnboardingWizard.jsx';
@@ -44,6 +45,11 @@ import { FilePreview, sanitizeDisplayText } from './panels/file-preview.jsx';
44
45
  import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
45
46
  import TimelinePanel from './panels/TimelinePanel.jsx';
46
47
  import { parseCompTimeline } from './panels/timeline-parse.js';
48
+ import {
49
+ activeComp,
50
+ resolveCompTarget,
51
+ sanitizeArtboardText,
52
+ } from './panels/timeline-comp-target.js';
47
53
  import { durationFramesForDrop, probeMediaDuration } from './panels/timeline-media-cache.js';
48
54
  import GenerateDialog from './generate-dialog.jsx';
49
55
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
@@ -149,6 +155,7 @@ const DOCK_PANELS = [
149
155
  { id: 'inspector', label: 'Inspector' },
150
156
  { id: 'comments', label: 'Comments' },
151
157
  { id: 'changes', label: 'Changes' },
158
+ { id: 'sync', label: 'Sync' }, // feature-sync-progress-modal — linked projects only
152
159
  { id: 'assistant', label: 'Assistant' },
153
160
  ];
154
161
  const PANEL_SIDES_DEFAULTS = {
@@ -157,6 +164,7 @@ const PANEL_SIDES_DEFAULTS = {
157
164
  inspector: 'right',
158
165
  comments: 'right',
159
166
  changes: 'right',
167
+ sync: 'right',
160
168
  assistant: 'right',
161
169
  };
162
170
  const PANEL_SIDES_STORE = 'mdcc-panel-sides';
@@ -1559,7 +1567,12 @@ function ExportDialog({
1559
1567
  // never a silent truncation, never a surprise refusal.
1560
1568
  const EXPORT_SAFE_FRAMES = 900;
1561
1569
  const EXPORT_DEFAULT_CAP = 3600;
1562
- const exportComp = comps[0] || null;
1570
+ // The comp being exported — resolved to the artboard this dialog targets, not
1571
+ // to whichever comp mounted first (#75). It drives the long-comp consent
1572
+ // notice AND `options.maxFrames`, so a wrong pick both misstates the frame
1573
+ // count to the user and raises the server-side cap on behalf of a comp that
1574
+ // isn't the one leaving the machine.
1575
+ const exportComp = activeComp(comps, resolveCompTarget(comps, { artboardId: activeArtboardId }));
1563
1576
  const exportCompFrames = exportComp?.durationInFrames || 0;
1564
1577
  const exportCompFps = exportComp?.fps || 30;
1565
1578
  const exportIsHeavy = card.temporal && exportCompFrames > EXPORT_SAFE_FRAMES;
@@ -4687,6 +4700,8 @@ function StatusBar({
4687
4700
  onClearSelected,
4688
4701
  syncStatus,
4689
4702
  syncProject,
4703
+ syncOpen = false,
4704
+ onOpenSync,
4690
4705
  changesCount = 0,
4691
4706
  unpushed = 0,
4692
4707
  changesOpen = false,
@@ -4727,6 +4742,19 @@ function StatusBar({
4727
4742
  title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
4728
4743
  };
4729
4744
  })();
4745
+ // One body for both chip forms (button vs plain span) below.
4746
+ const syncSlotBody = syncSlot && (
4747
+ <>
4748
+ <span
4749
+ className={'st-sb-sync-dot' + (syncSlot.online ? ' is-online' : '')}
4750
+ aria-hidden="true"
4751
+ />
4752
+ <span className="lbl">hub sync</span>
4753
+ <span className="val" title={syncSlot.title}>
4754
+ {syncSlot.label}
4755
+ </span>
4756
+ </>
4757
+ );
4730
4758
 
4731
4759
  return (
4732
4760
  <footer className="st-statusbar" role="contentinfo" data-testid="statusbar">
@@ -4801,19 +4829,29 @@ function StatusBar({
4801
4829
 
4802
4830
  {/* P5 (Plan C) — always-on hub-sync slot (was notSyncable-only per DDR-060
4803
4831
  / 9.1-D). Now also surfaces the connection-state machine's queued/synced
4804
- counter for the common linked case. Solo projects render nothing. */}
4805
- {syncSlot && (
4806
- <span className="st-sb-slot st-sb-sync" role="group" aria-label="Hub sync">
4807
- <span
4808
- className={'st-sb-sync-dot' + (syncSlot.online ? ' is-online' : '')}
4809
- aria-hidden="true"
4810
- />
4811
- <span className="lbl">hub sync</span>
4812
- <span className="val" title={syncSlot.title}>
4813
- {syncSlot.label}
4832
+ counter for the common linked case. Solo projects render nothing.
4833
+ feature-sync-progress-modal — a BUTTON that toggles the per-file Sync
4834
+ panel (like the Changes chip toggles GitPanel); the plain-span form
4835
+ stays for servers/sessions that pass no handler. */}
4836
+ {syncSlot &&
4837
+ (onOpenSync ? (
4838
+ <button
4839
+ type="button"
4840
+ className={'st-sb-slot st-sb-sync st-sb-sync--btn' + (syncOpen ? ' is-open' : '')}
4841
+ onClick={onOpenSync}
4842
+ data-testid="open-sync"
4843
+ data-tip="Open Sync panel"
4844
+ data-tip-pos="top"
4845
+ aria-label="Open Sync panel"
4846
+ aria-pressed={syncOpen}
4847
+ >
4848
+ {syncSlotBody}
4849
+ </button>
4850
+ ) : (
4851
+ <span className="st-sb-slot st-sb-sync" role="group" aria-label="Hub sync">
4852
+ {syncSlotBody}
4814
4853
  </span>
4815
- </span>
4816
- )}
4854
+ ))}
4817
4855
 
4818
4856
  {/* Which release this is. Reading it used to mean probing production by
4819
4857
  hand — and the desktop, the browser and a cloud tab all serve the same
@@ -9495,6 +9533,10 @@ function App() {
9495
9533
  // tick, and after each git action — never on the per-edit WS path.
9496
9534
  const [remoteSync, setRemoteSync] = useState(null); // { remoteAhead, behind } | null
9497
9535
  const [changesOpen, setChangesOpen] = useState(false);
9536
+ // feature-sync-progress-modal — the per-file Sync panel; toggled from the
9537
+ // status-bar HUB SYNC chip. Only available while `syncStatus` is non-null
9538
+ // (a linked project), mirroring how `changes` gates on `gitStatus.repo`.
9539
+ const [syncPanelOpen, setSyncPanelOpen] = useState(false);
9498
9540
  const [diffTarget, setDiffTarget] = useState(null);
9499
9541
  const [search, setSearch] = useState('');
9500
9542
  const [systemData, setSystemData] = useState(null);
@@ -9738,18 +9780,6 @@ function App() {
9738
9780
  // Task 5 — seam transitions between series beats ({ afterIndex, dur }).
9739
9781
  const [timelineTransitions, setTimelineTransitions] = useState([]);
9740
9782
  const [timelineTotal, setTimelineTotal] = useState(0);
9741
- // On a multi-comp canvas the Timeline drives ONE comp (the parser scopes to it
9742
- // + reports its total). Match that total back to the announced comp id so the
9743
- // transport/scrub targets ONLY that comp — not every artboard on the canvas.
9744
- const timelineCompId = useMemo(() => {
9745
- if (!activeComps.length) return null;
9746
- const match = activeComps.find((c) => c.durationInFrames === timelineTotal);
9747
- return (match ?? activeComps[0]).id;
9748
- }, [activeComps, timelineTotal]);
9749
- const timelineCompIdRef = useRef(null);
9750
- useEffect(() => {
9751
- timelineCompIdRef.current = timelineCompId;
9752
- }, [timelineCompId]);
9753
9783
  // rca/issue-video-artboard-frame-reset-on-edit — last known playhead, read
9754
9784
  // from the `timeline-comps` handler below (which fires on every comp
9755
9785
  // (re)mount, incl. a ⌘R hard iframe reload) to re-seed the fresh Player
@@ -9769,6 +9799,34 @@ function App() {
9769
9799
  useEffect(() => {
9770
9800
  timelineArtboardIdRef.current = timelineArtboardId;
9771
9801
  }, [timelineArtboardId]);
9802
+ // On a multi-comp canvas the Timeline drives ONE comp: the one mounted in the
9803
+ // artboard whose rows the panel is drawing. Resolved by artboard identity
9804
+ // (announced by video-comp.tsx), NOT by duration — two comps of equal length
9805
+ // made the old duration match return the first artboard's Player, so scrub +
9806
+ // playback moved a comp the user wasn't even looking at (issue #75).
9807
+ const timelineCompId = useMemo(
9808
+ () =>
9809
+ resolveCompTarget(activeComps, {
9810
+ // Both signals, in confidence order: the artboard the ROW PARSER scoped
9811
+ // to (lexical, from the .tsx) then the viewport-active one canvas-lib
9812
+ // reports on pan (structural). See resolveCompTarget — when the two
9813
+ // disagree, the one that names a comp actually mounted there wins.
9814
+ artboardId: [timelineArtboardId, canvasActiveArtboard],
9815
+ total: timelineTotal,
9816
+ }),
9817
+ [activeComps, timelineArtboardId, canvasActiveArtboard, timelineTotal]
9818
+ );
9819
+ const timelineCompIdRef = useRef(null);
9820
+ useEffect(() => {
9821
+ timelineCompIdRef.current = timelineCompId;
9822
+ }, [timelineCompId]);
9823
+ // Frame math (drop position, split, overlay insert) must use the timebase of
9824
+ // the comp the Timeline is actually on — `activeComps[0].fps` silently used
9825
+ // the first artboard's (issue #75).
9826
+ const timelineFps = useMemo(
9827
+ () => activeComp(activeComps, timelineCompId)?.fps || 30,
9828
+ [activeComps, timelineCompId]
9829
+ );
9772
9830
  // feature-enhanced-video-editing (Task 3) — the timeline's single-select model.
9773
9831
  // Holds the selected clip's stableId (NOT a row index — DDR-150), so the
9774
9832
  // selection survives comp-clips refetches, reorders, and external file edits.
@@ -9876,8 +9934,8 @@ function App() {
9876
9934
  const anyRightPanelOpenRef = useRef(false);
9877
9935
  useEffect(() => {
9878
9936
  anyRightPanelOpenRef.current =
9879
- inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen;
9880
- }, [inspectorOpen, commentsPanelOpen, changesOpen, assistantOpen]);
9937
+ inspectorOpen || commentsPanelOpen || changesOpen || syncPanelOpen || assistantOpen;
9938
+ }, [inspectorOpen, commentsPanelOpen, changesOpen, syncPanelOpen, assistantOpen]);
9881
9939
  // The right dock holds exactly ONE panel (Changes / Inspector / Comments) at
9882
9940
  // a time — opening any panel REPLACES whatever was there. These two helpers
9883
9941
  // are the single source of that invariant; every open/toggle path routes
@@ -9899,6 +9957,7 @@ function App() {
9899
9957
  inspector: inspectorOpen,
9900
9958
  comments: commentsPanelOpen,
9901
9959
  changes: changesOpen,
9960
+ sync: syncPanelOpen,
9902
9961
  assistant: assistantOpen,
9903
9962
  },
9904
9963
  side: panelSide,
@@ -9909,6 +9968,7 @@ function App() {
9909
9968
  else if (id === 'inspector') setInspectorOpen(val);
9910
9969
  else if (id === 'comments') setCommentsPanelOpen(val);
9911
9970
  else if (id === 'changes') setChangesOpen(val);
9971
+ else if (id === 'sync') setSyncPanelOpen(val);
9912
9972
  else if (id === 'assistant') setAssistantOpen(val);
9913
9973
  }, []);
9914
9974
  const sideOf = useCallback(
@@ -10443,7 +10503,9 @@ function App() {
10443
10503
  // inputs stay exempt.
10444
10504
  if (tag === 'INPUT' && t?.type !== 'range') return;
10445
10505
  if (tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return;
10446
- const fps = s.comps[0]?.fps || 30;
10506
+ // The timebase of the comp the transport is on, not of whichever comp
10507
+ // announced first — Shift+arrow is "±1 second" on THIS artboard (#75).
10508
+ const fps = activeComp(s.comps, s.compId)?.fps || 30;
10447
10509
  const total = Math.max(1, s.total);
10448
10510
  const doSeek = (f) => {
10449
10511
  const nf = Math.max(0, Math.min(total - 1, Math.round(f)));
@@ -10625,8 +10687,13 @@ function App() {
10625
10687
  setTimelineTotal(0);
10626
10688
  return;
10627
10689
  }
10628
- const total = activeComps[0]?.durationInFrames || 0;
10629
10690
  const artboard = canvasActiveArtboard ?? selected?.artboardId ?? null;
10691
+ // Seed the parser's fallback total from the comp mounted in THIS artboard,
10692
+ // not from whichever comp announced first (issue #75).
10693
+ const seedComp =
10694
+ (artboard && activeComps.find((c) => c.artboardId === artboard || c.id === artboard)) ||
10695
+ activeComps[0];
10696
+ const total = seedComp?.durationInFrames || 0;
10630
10697
  const parsed = parseCompTimeline(timelineSource, total, artboard);
10631
10698
  setTimelineArtboardId(parsed.artboardId ?? artboard ?? null);
10632
10699
  // Overlay the enumerator's authoritative media (kind badge + replace target)
@@ -11035,13 +11102,30 @@ function App() {
11035
11102
 
11036
11103
  // ----- WebSocket -----
11037
11104
  useEffect(() => {
11105
+ // KEEPALIVE. The inspector feed only pushes on events (a comment, a
11106
+ // selection, sync:status), so an idle designer's socket exchanges nothing
11107
+ // for minutes — and Bun.serve closes a WebSocket after `idleTimeout` (120s
11108
+ // default) with no message in EITHER direction. The client then reconnected
11109
+ // in a 1 s loop, so the status bar read `reconnecting` every couple of
11110
+ // minutes on a perfectly healthy link ("porad reconnecting" while HUB SYNC
11111
+ // said synced — the two are different sockets). A cheap app-level ping every
11112
+ // 25 s keeps the socket active (Bun resets the idle timer on any frame,
11113
+ // inbound OR outbound); the server ignores an unknown message type.
11114
+ let pingTimer = null;
11038
11115
  function connect() {
11039
11116
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
11040
11117
  const ws = new WebSocket(proto + '//' + location.host + '/_ws');
11041
11118
  wsRef.current = ws;
11042
- ws.addEventListener('open', () => setWsConnected(true));
11119
+ ws.addEventListener('open', () => {
11120
+ setWsConnected(true);
11121
+ clearInterval(pingTimer);
11122
+ pingTimer = setInterval(() => {
11123
+ if (ws.readyState === WebSocket.OPEN) ws.send('{"type":"ping"}');
11124
+ }, 25000);
11125
+ });
11043
11126
  ws.addEventListener('close', () => {
11044
11127
  setWsConnected(false);
11128
+ clearInterval(pingTimer);
11045
11129
  setTimeout(connect, 1000);
11046
11130
  });
11047
11131
  ws.addEventListener('error', () => {});
@@ -11161,7 +11245,10 @@ function App() {
11161
11245
  });
11162
11246
  }
11163
11247
  connect();
11164
- return () => wsRef.current && wsRef.current.close();
11248
+ return () => {
11249
+ clearInterval(pingTimer);
11250
+ if (wsRef.current) wsRef.current.close();
11251
+ };
11165
11252
  // loadTree + loadServerConfig are stable useCallback([])s; listed so the
11166
11253
  // canvas-list-update / config-updated handlers always call the live refs.
11167
11254
  }, [loadTree, loadServerConfig]);
@@ -12509,8 +12596,10 @@ function App() {
12509
12596
  ? iframesRef.current.get(activePath)?.contentWindow
12510
12597
  : null;
12511
12598
  if (e.source !== activeWin) return;
12512
- // Cap the id like the sibling timeline-comps handler (defensive; inert).
12513
- setCanvasActiveArtboard(typeof m.id === 'string' ? m.id.slice(0, 120) : null);
12599
+ // Neutralize + cap exactly like the sibling timeline-comps handler —
12600
+ // this id is matched AGAINST that one, so both sides must go through
12601
+ // the same normalization or an odd-but-legal id would never match.
12602
+ setCanvasActiveArtboard(sanitizeArtboardText(m.id));
12514
12603
  } else if (m.dgn === 'timeline-comps' && Array.isArray(m.comps)) {
12515
12604
  // DDR-148 — a video-comp announces its comp meta (from video-comp.tsx).
12516
12605
  // Gate to the ACTIVE canvas window (phase-28 F-2 pattern): a background
@@ -12530,6 +12619,13 @@ function App() {
12530
12619
  durationInFrames: Math.max(1, Math.min(1_000_000, Math.round(Number(c.durationInFrames) || 1))),
12531
12620
  width: Math.max(1, Math.round(Number(c.width) || 0)),
12532
12621
  height: Math.max(1, Math.round(Number(c.height) || 0)),
12622
+ // The enclosing artboard (issue #75) — the key the transport target
12623
+ // is resolved on, and (the label) shell chrome the user reads. This
12624
+ // is untrusted canvas-origin text, so neutralize the bidi/zero-width
12625
+ // class HERE, at the boundary, and every downstream consumer
12626
+ // inherits a clean value (security-review 2026-08-12).
12627
+ artboardId: sanitizeArtboardText(c.artboardId),
12628
+ artboardLabel: sanitizeArtboardText(c.artboardLabel),
12533
12629
  }));
12534
12630
  setActiveComps(safe);
12535
12631
  // rca/issue-video-artboard-frame-reset-on-edit — the SAME comp
@@ -14240,6 +14336,9 @@ function App() {
14240
14336
  // CHANGE, not cannot SEE: a reviewer needs structure and measured values,
14241
14337
  // which is the whole reason C1 exists.
14242
14338
  if (viewerMode && id === 'assistant') return false;
14339
+ // feature-sync-progress-modal — the Sync panel only exists for a linked
14340
+ // project (solo has no hub, so the tab would open onto nothing).
14341
+ if (id === 'sync') return !!syncStatus;
14243
14342
  return id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
14244
14343
  };
14245
14344
  const idsForSide = (side) =>
@@ -14252,6 +14351,7 @@ function App() {
14252
14351
  inspector: inspectorOpen,
14253
14352
  comments: commentsPanelOpen,
14254
14353
  changes: changesOpen,
14354
+ sync: syncPanelOpen,
14255
14355
  assistant: assistantOpen,
14256
14356
  };
14257
14357
  // Per-shell overrides for the dock tab strip. In a cell the `changes` panel
@@ -14369,6 +14469,16 @@ function App() {
14369
14469
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
14370
14470
  />
14371
14471
  );
14472
+ if (id === 'sync')
14473
+ return (
14474
+ <SyncPanel
14475
+ status={syncStatus}
14476
+ project={cfg?.cloud?.projectName || project}
14477
+ groupPaths={(groups || []).map((g) => g.path).filter(Boolean)}
14478
+ resizing={resizingFor('sync')}
14479
+ onClose={() => setSyncPanelOpen(false)}
14480
+ />
14481
+ );
14372
14482
  if (id === 'inspector' || id === 'layers')
14373
14483
  return (
14374
14484
  <InspectorPanel
@@ -14703,6 +14813,7 @@ function App() {
14703
14813
  {timelineOpen && (
14704
14814
  <TimelinePanel
14705
14815
  comps={activeComps}
14816
+ compId={timelineCompId}
14706
14817
  sequences={timelineSequences}
14707
14818
  audio={timelineAudio}
14708
14819
  transitions={timelineTransitions}
@@ -14981,7 +15092,7 @@ function App() {
14981
15092
  // files always land in the audio band. Without a position, the
14982
15093
  // legacy append path.
14983
15094
  const artboardId = timelineArtboardId || undefined;
14984
- const fps = activeComps[0]?.fps || 30;
15095
+ const fps = timelineFps;
14985
15096
  const canvas = activePath;
14986
15097
  const list = (Array.isArray(files) ? files : [files]).filter(Boolean);
14987
15098
  (async () => {
@@ -15195,7 +15306,7 @@ function App() {
15195
15306
  // happens in the timeline inspector).
15196
15307
  Promise.resolve('Title').then((text) => {
15197
15308
  if (!text) return;
15198
- const fps = activeComps[0]?.fps || 30;
15309
+ const fps = timelineFps;
15199
15310
  fetch('/_api/insert-sequence', {
15200
15311
  method: 'POST',
15201
15312
  headers: { 'content-type': 'application/json' },
@@ -15229,7 +15340,7 @@ function App() {
15229
15340
  (async () => {
15230
15341
  const prompt = 'Describe this shot — double-click to edit';
15231
15342
  const kind = 'veo';
15232
- const fps = activeComps[0]?.fps || 30;
15343
+ const fps = timelineFps;
15233
15344
  fetch('/_api/insert-sequence', {
15234
15345
  method: 'POST',
15235
15346
  headers: { 'content-type': 'application/json' },
@@ -15336,7 +15447,7 @@ function App() {
15336
15447
  // overlay-lane <Img> at the playhead. WKWebView can't open an
15337
15448
  // HTML file input, so native goes through the Rust pick dialog.
15338
15449
  const uploadPicked = (blob, type) => {
15339
- const fps = activeComps[0]?.fps || 30;
15450
+ const fps = timelineFps;
15340
15451
  fetch('/_api/asset', {
15341
15452
  method: 'POST',
15342
15453
  headers: { 'Content-Type': type || 'application/octet-stream' },
@@ -15404,6 +15515,10 @@ function App() {
15404
15515
  onClearSelected={clearSelected}
15405
15516
  syncStatus={syncStatus}
15406
15517
  syncProject={cfg?.cloud?.projectName || project}
15518
+ syncOpen={syncPanelOpen}
15519
+ // Toggle through the dock helpers so the one-panel-per-side invariant
15520
+ // holds (opening Sync closes whatever else the right slot shows).
15521
+ onOpenSync={syncStatus ? () => toggleRightPanel('sync') : undefined}
15407
15522
  changesCount={unsavedCount}
15408
15523
  unpushed={gitStatus?.unpushed || 0}
15409
15524
  changesOpen={changesOpen}
@@ -0,0 +1,229 @@
1
+ // feature-sync-progress-modal — the per-file Sync panel (right dock).
2
+ //
3
+ // Pure surfacing of the `sync:status` payload: the DDR-102 per-document
4
+ // `items` list plus the DDR-217 asset lane's live progress. No new sync
5
+ // semantics and NO new sync words — the header sentence comes from
6
+ // `syncPresentation` (the one rule the status-bar chip and the cloud rail
7
+ // already share), and rows map DocSyncState onto that same vocabulary:
8
+ // pending → syncing, connected → synced, auth-rejected → refused.
9
+ //
10
+ // DDR-054 — slugs are local canvas identifiers and asset keys are local
11
+ // paths, but everything renders through safeName anyway: this payload is
12
+ // read back off disk (`_sync.json`), and a bounded text-only row is free.
13
+
14
+ import { useMemo } from 'react';
15
+
16
+ import { safeName, syncPresentation } from '../../sync/presentation.ts';
17
+
18
+ /** DocSyncState → the presentation vocabulary (never invent a new word). */
19
+ const STATE_WORD = {
20
+ pending: 'syncing',
21
+ connected: 'synced',
22
+ 'auth-rejected': 'refused',
23
+ };
24
+
25
+ /** AuthFailureClass → plain words for the refused-row detail. */
26
+ const REASON_WORD = {
27
+ 'rate-limit': 'rate limited',
28
+ 'not-authorized': 'not authorized',
29
+ 'invalid-token': 'sign-in expired',
30
+ generic: 'refused by the hub',
31
+ };
32
+
33
+ function Row({ item }) {
34
+ const name = safeName(item.slug, '(unnamed)');
35
+ const state = STATE_WORD[item.state] || 'syncing';
36
+ const reason = item.state === 'auth-rejected' ? REASON_WORD[item.reason] || REASON_WORD.generic : null;
37
+ return (
38
+ <li className={'sp-row is-' + item.state} data-testid={'sync-row-' + item.slug}>
39
+ <span className="sp-row-dot" aria-hidden="true" />
40
+ <span className="sp-row-name" title={name}>
41
+ {name}
42
+ </span>
43
+ <span className="sp-row-state">{reason ? `refused — ${reason}` : state}</span>
44
+ </li>
45
+ );
46
+ }
47
+
48
+ /** Which declared canvas group a slug belongs to (slugs collapse '/' → '-'). */
49
+ function groupOf(slug, groupPaths) {
50
+ for (const p of groupPaths) {
51
+ const g = p.replace(/\//g, '-');
52
+ if (slug === g || slug.startsWith(g + '-')) return p;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // FAIL CLOSED, like presentation.ts `readCounts`: this payload is JSON.parse
58
+ // of `_sync.json` with no schema, so a partial write / older producer must
59
+ // degrade to "nothing to show", never crash the render or print NaN.
60
+
61
+ const isCount = (n) => typeof n === 'number' && Number.isInteger(n) && n >= 0;
62
+
63
+ /** The per-doc rows, or [] when the shape can't be trusted. */
64
+ function readItems(raw) {
65
+ if (!Array.isArray(raw)) return [];
66
+ return raw.filter((i) => i && typeof i.slug === 'string' && typeof i.state === 'string');
67
+ }
68
+
69
+ /** The asset lane, or null when any count is unreadable. */
70
+ function readAssets(raw) {
71
+ if (!raw || typeof raw !== 'object') return null;
72
+ if (![raw.total, raw.done, raw.pushed, raw.skipped, raw.failedCount].every(isCount)) return null;
73
+ const failures = Array.isArray(raw.failures)
74
+ ? raw.failures.filter((f) => f && typeof f.key === 'string')
75
+ : [];
76
+ return { ...raw, failures };
77
+ }
78
+
79
+ export default function SyncPanel({
80
+ status, // the live `sync:status` payload (never null while mounted)
81
+ project, // display name for the header sentence (hub-supplied → safeName'd)
82
+ groupPaths = [], // declared canvas-group paths, for row grouping
83
+ resizing,
84
+ onClose,
85
+ }) {
86
+ const p = syncPresentation(status, { project });
87
+ const items = readItems(status?.items);
88
+ const truncated = isCount(status?.itemsTruncated) ? status.itemsTruncated : 0;
89
+ const assets = readAssets(status?.assets);
90
+ const assetFailures = assets?.failures || [];
91
+
92
+ const { attention, byGroup } = useMemo(() => {
93
+ const attention = items.filter((i) => i.state === 'auth-rejected');
94
+ const rest = items.filter((i) => i.state !== 'auth-rejected');
95
+ const byGroup = new Map();
96
+ for (const item of rest) {
97
+ const g = groupOf(item.slug, groupPaths) ?? 'canvases';
98
+ if (!byGroup.has(g)) byGroup.set(g, []);
99
+ byGroup.get(g).push(item);
100
+ }
101
+ return { attention, byGroup };
102
+ }, [items, groupPaths]);
103
+
104
+ // Same fail-closed rule for the header chip — readCounts-shaped, so the
105
+ // chip can never say "NaN synced" while the note below fails closed.
106
+ const rawDocs = status?.docs;
107
+ const docs =
108
+ rawDocs && [rawDocs.synced, rawDocs.pending, rawDocs.rejected].every(isCount)
109
+ ? rawDocs
110
+ : null;
111
+ const counts =
112
+ docs &&
113
+ [
114
+ `${docs.synced} synced`,
115
+ docs.pending > 0 ? `${docs.pending} syncing` : null,
116
+ docs.rejected > 0 ? `${docs.rejected} refused` : null,
117
+ ]
118
+ .filter(Boolean)
119
+ .join(' · ');
120
+
121
+ return (
122
+ <aside
123
+ className={'st-rpanel sp-panel' + (resizing ? ' is-resizing' : '')}
124
+ aria-label="Sync"
125
+ data-testid="sync-panel"
126
+ >
127
+ <div className="gp-head">
128
+ <div className="gp-panel-hd">
129
+ <span className="gp-panel-title">Sync</span>
130
+ {counts && <span className="gp-count">{counts}</span>}
131
+ <span className="gp-spacer" />
132
+ <button type="button" className="gp-x" aria-label="Close" onClick={onClose}>
133
+ ×
134
+ </button>
135
+ </div>
136
+ {/* The one-rule sentence, live — same aria pattern as the rail note:
137
+ a polite announcement when the phase changes, never a focus steal. */}
138
+ {p && (
139
+ <div className="sp-note" role="status" aria-live="polite">
140
+ <span className={'sp-note-dot is-' + p.phase} aria-hidden="true" />
141
+ <span>
142
+ {p.title}
143
+ {p.next ? ` ${p.next}` : ''}
144
+ </span>
145
+ </div>
146
+ )}
147
+ </div>
148
+
149
+ <div className="sp-body">
150
+ {items.length === 0 && !assets && (
151
+ <div className="gp-empty">
152
+ <p>No per-file detail yet — it arrives with the first sync report after this panel shipped. If this persists, restart Maude.</p>
153
+ </div>
154
+ )}
155
+
156
+ {attention.length > 0 && (
157
+ <section aria-label="Needs attention">
158
+ <div className="gp-sect-label sp-sect-attention">Needs attention</div>
159
+ <ul className="sp-list">
160
+ {attention.map((item) => (
161
+ <Row key={item.slug} item={item} />
162
+ ))}
163
+ </ul>
164
+ </section>
165
+ )}
166
+
167
+ {[...byGroup.entries()].map(([group, rows]) => (
168
+ <section key={group} aria-label={group}>
169
+ <div className="gp-sect-label">
170
+ {group} <span className="gp-group-count">{rows.length}</span>
171
+ </div>
172
+ <ul className="sp-list">
173
+ {rows.map((item) => (
174
+ <Row key={item.slug} item={item} />
175
+ ))}
176
+ </ul>
177
+ </section>
178
+ ))}
179
+
180
+ {truncated > 0 && (
181
+ <div className="sp-truncated">
182
+ +{truncated} more synced canvas{truncated === 1 ? '' : 'es'} not listed.
183
+ </div>
184
+ )}
185
+
186
+ {assets && (
187
+ <section aria-label="Assets" data-testid="sync-assets">
188
+ <div className="gp-sect-label">
189
+ assets <span className="gp-group-count">{assets.total}</span>
190
+ </div>
191
+ <div className="sp-assets-line">
192
+ {assets.finished
193
+ ? `${assets.pushed} pushed · ${assets.skipped} already there` +
194
+ (assets.failedCount > 0 ? ` · ${assets.failedCount} failed` : '')
195
+ : `Pushing assets — ${assets.done} of ${assets.total}…`}
196
+ </div>
197
+ {!assets.finished && assets.active && (
198
+ <div className="sp-assets-active" title={safeName(assets.active, '')}>
199
+ ↑ {safeName(assets.active, '')}
200
+ </div>
201
+ )}
202
+ {assets.failedCount > 0 && (
203
+ <ul className="sp-list">
204
+ {assetFailures.map((f) => (
205
+ <li className="sp-row is-auth-rejected" key={f.key}>
206
+ <span className="sp-row-dot" aria-hidden="true" />
207
+ <span className="sp-row-name" title={safeName(f.key, '(unnamed)')}>
208
+ {safeName(f.key, '(unnamed)')}
209
+ </span>
210
+ <span className="sp-row-state">{safeName(f.reason, 'failed')}</span>
211
+ </li>
212
+ ))}
213
+ {assets.failedCount > assetFailures.length && (
214
+ <li className="sp-truncated">
215
+ +{assets.failedCount - assetFailures.length} more failed. All retry on the next
216
+ launch.
217
+ </li>
218
+ )}
219
+ </ul>
220
+ )}
221
+ {assets.failedCount > 0 && assets.finished && (
222
+ <div className="sp-assets-retry">Failed assets retry on the next launch.</div>
223
+ )}
224
+ </section>
225
+ )}
226
+ </div>
227
+ </aside>
228
+ );
229
+ }