@1agh/maude 1.3.0 → 1.4.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 (36) hide show
  1. package/apps/studio/annotation-edit-base.ts +36 -0
  2. package/apps/studio/annotations-layer.tsx +13 -2
  3. package/apps/studio/api.ts +51 -2
  4. package/apps/studio/canvas-build.ts +49 -8
  5. package/apps/studio/canvas-edit.ts +77 -0
  6. package/apps/studio/canvas-lib.tsx +25 -10
  7. package/apps/studio/canvas-list-watch.ts +33 -2
  8. package/apps/studio/client/app.jsx +99 -4
  9. package/apps/studio/client/canvas-url.js +39 -1
  10. package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
  11. package/apps/studio/client/photo-knobs.jsx +3 -3
  12. package/apps/studio/client/styles/3-shell-maude.css +1 -0
  13. package/apps/studio/collab/awareness-bridge.ts +65 -14
  14. package/apps/studio/comments-overlay.tsx +86 -2
  15. package/apps/studio/context.ts +93 -1
  16. package/apps/studio/dist/client.bundle.js +619 -619
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/accepted-guard.ts +36 -0
  20. package/apps/studio/hmr-broadcast.ts +34 -1
  21. package/apps/studio/http.ts +15 -1
  22. package/apps/studio/managed-projects.ts +22 -0
  23. package/apps/studio/server.ts +3 -0
  24. package/apps/studio/sync/accepted-link.ts +3 -0
  25. package/apps/studio/sync/file-plane.ts +15 -0
  26. package/apps/studio/sync/index.ts +138 -12
  27. package/apps/studio/sync/presentation.ts +30 -0
  28. package/apps/studio/sync/status.ts +5 -1
  29. package/apps/studio/sync/transaction-client.ts +29 -0
  30. package/apps/studio/use-tool-mode.tsx +44 -0
  31. package/apps/studio/whats-new.json +72 -0
  32. package/apps/studio/ws.ts +9 -2
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +48 -7
  35. package/plugins/flow/.claude-plugin/config.schema.json +15 -2
  36. package/plugins/flow/README.md +1 -1
@@ -32,7 +32,7 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
32
32
  // same "pull only pure logic into the client bundle" shape as the imports
33
33
  // above (a type-only SyncStatusSnapshot import that Bun erases).
34
34
  import { syncPresentation } from '../sync/presentation.ts';
35
- import { canvasUrl } from './canvas-url.js';
35
+ import { canvasTokenRefreshDelay, canvasUrl, setLiveCanvasToken } from './canvas-url.js';
36
36
  import { applyEditRequest } from './apply-edit-request.ts';
37
37
  import { createIndexLoader } from './index-loader.ts';
38
38
  import {
@@ -3245,6 +3245,7 @@ function Sidebar({
3245
3245
  <button
3246
3246
  type="button"
3247
3247
  className={'st-tree-sec-hd' + (isRootOver ? ' is-drop-target' : '')}
3248
+ data-testid={`tree-section-${g.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
3248
3249
  onClick={() => onToggleSection(g.label, defaultOpen)}
3249
3250
  aria-expanded={sectionOpen}
3250
3251
  aria-dropeffect={canDropOnRoot ? 'move' : undefined}
@@ -4684,6 +4685,28 @@ function Viewport({
4684
4685
  useEffect(() => {
4685
4686
  if (previewPath) previewRef.current?.focus();
4686
4687
  }, [previewPath]);
4688
+ // An open canvas keeps the URL it was opened with. The capability in it is
4689
+ // re-minted on a timer (canvas-url.js); letting that change the `src` of a
4690
+ // live iframe would reload every open canvas every few minutes. The fresh
4691
+ // capability reaches an open canvas by message instead, and a frame that is
4692
+ // (re)created — a new tab, a Retry, a config change — is built with it.
4693
+ // Only while the frame is open: a closed tab reopened later is a new frame
4694
+ // and must not inherit a capability that may have expired meanwhile.
4695
+ const srcCache = useRef(new Map());
4696
+ const openPaths = new Set(tabs.map((t) => t.path));
4697
+ for (const [key, entry] of srcCache.current) {
4698
+ if (!openPaths.has(entry.path)) srcCache.current.delete(key);
4699
+ }
4700
+ const stableSrc = (path) => {
4701
+ const bare = canvasUrl(path, { ...cfg, canvasToken: undefined });
4702
+ const key = `${path}#${canvasReloadNonce}|${bare}`;
4703
+ let entry = srcCache.current.get(key);
4704
+ if (!entry) {
4705
+ entry = { path, src: canvasUrl(path, cfg) };
4706
+ srcCache.current.set(key, entry);
4707
+ }
4708
+ return entry.src;
4709
+ };
4687
4710
  // One observable word for "what is the canvas pane actually doing" — the
4688
4711
  // top-frame signal the #115 E2E scenario waits on, and the only place these
4689
4712
  // three states are named together. `ready` is `dgn:'loaded'`-backed, so it
@@ -4776,7 +4799,7 @@ function Viewport({
4776
4799
  // path exactly as before.
4777
4800
  key={`${t.path}#${canvasReloadNonce}`}
4778
4801
  ref={(el) => registerIframe(t.path, el)}
4779
- src={canvasUrl(t.path, cfg)}
4802
+ src={stableSrc(t.path)}
4780
4803
  className={t.path === activePath ? 'active' : ''}
4781
4804
  data-path={t.path}
4782
4805
  data-testid={t.path === activePath ? 'canvas-frame' : undefined}
@@ -5353,6 +5376,7 @@ function CommentsPanel({
5353
5376
  type="button"
5354
5377
  className={'st-cm-filter' + (filter === 'all' ? ' is-active' : '')}
5355
5378
  role="tab"
5379
+ data-testid="comment-filter-all"
5356
5380
  aria-selected={filter === 'all'}
5357
5381
  onClick={() => setFilter('all')}
5358
5382
  >
@@ -5362,6 +5386,7 @@ function CommentsPanel({
5362
5386
  type="button"
5363
5387
  className={'st-cm-filter' + (filter === 'open' ? ' is-active' : '')}
5364
5388
  role="tab"
5389
+ data-testid="comment-filter-open"
5365
5390
  aria-selected={filter === 'open'}
5366
5391
  onClick={() => setFilter('open')}
5367
5392
  >
@@ -5371,6 +5396,7 @@ function CommentsPanel({
5371
5396
  type="button"
5372
5397
  className={'st-cm-filter' + (filter === 'resolved' ? ' is-active' : '')}
5373
5398
  role="tab"
5399
+ data-testid="comment-filter-resolved"
5374
5400
  aria-selected={filter === 'resolved'}
5375
5401
  onClick={() => setFilter('resolved')}
5376
5402
  >
@@ -5402,6 +5428,7 @@ function CommentsPanel({
5402
5428
  {g.comments.map((c) => (
5403
5429
  <div
5404
5430
  key={c.id}
5431
+ data-testid={`comment-item-${c.id}`}
5405
5432
  className={
5406
5433
  'st-comment' +
5407
5434
  (c.status === 'resolved' ? ' is-resolved' : '') +
@@ -7054,6 +7081,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
7054
7081
  <button
7055
7082
  type="button"
7056
7083
  className="st-cp-sechd"
7084
+ data-testid={`inspector-section-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
7057
7085
  aria-expanded={!!open[name]}
7058
7086
  onClick={() => setOpen((o) => ({ ...o, [name]: !o[name] }))}
7059
7087
  >
@@ -9578,8 +9606,10 @@ function InspectorPanel({
9578
9606
  };
9579
9607
  // An annotation-image has no element tabs; force Photo. Otherwise honor the
9580
9608
  // requested tab, but drop off a stale 'photo' tab when the new selection isn't
9581
- // photo-eligible (so switching from a photo to a normal element lands sanely).
9582
- const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'inspect' : tab;
9609
+ // photo-eligible — onto CSS, the editing tab a fresh selection opens on (Stage
9610
+ // C). Landing on read-only Inspect meant that after touching a sticker, the
9611
+ // next element you clicked showed facts instead of the controls to change it.
9612
+ const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'css' : tab;
9583
9613
  const tabBtn = (id, label, icon) => (
9584
9614
  <button
9585
9615
  type="button"
@@ -10806,6 +10836,71 @@ function App() {
10806
10836
  const wsRef = useRef(null);
10807
10837
  const iframesRef = useRef(new Map());
10808
10838
 
10839
+ // THE CANVAS CAPABILITY OUTLIVES NO TAB (cloud only). It expires 15 minutes
10840
+ // after the proxy minted it (render-token.mjs) and cannot be revoked, so it
10841
+ // stays short — which left every cloud tab open longer than that with
10842
+ // canvases that stopped updating: a teammate's edit re-imports the canvas
10843
+ // module, and the canvas origin answered 401. Re-mint it well inside the
10844
+ // lifetime (every `/_config` answer carries a fresh one), hand it to each
10845
+ // open canvas, and let new frames be built with it. On waking a tab that
10846
+ // slept past the cadence, do it at once. Desktops have no capability and
10847
+ // never start the timer.
10848
+ useEffect(() => {
10849
+ if (!cfg?.canvasToken) return undefined;
10850
+ let stopped = false;
10851
+ let timer = null;
10852
+ let dueAt = 0;
10853
+ const schedule = (token) => {
10854
+ clearTimeout(timer);
10855
+ const delay = canvasTokenRefreshDelay(token);
10856
+ dueAt = Date.now() + delay;
10857
+ timer = setTimeout(refresh, delay);
10858
+ };
10859
+ async function refresh() {
10860
+ let token = null;
10861
+ try {
10862
+ const res = await fetch('/_config', { cache: 'no-store', credentials: 'same-origin' });
10863
+ if (res.ok) token = (await res.json())?.canvasToken;
10864
+ } catch {
10865
+ /* retried below; the current capability may still be valid */
10866
+ }
10867
+ if (stopped) return;
10868
+ if (typeof token !== 'string' || !token) {
10869
+ clearTimeout(timer);
10870
+ dueAt = Date.now() + 30_000;
10871
+ timer = setTimeout(refresh, 30_000);
10872
+ return;
10873
+ }
10874
+ setLiveCanvasToken(token);
10875
+ // To the canvas origin only, never '*': a frame the canvas content
10876
+ // navigated elsewhere must not be handed the capability.
10877
+ let target = null;
10878
+ try {
10879
+ target = new URL(cfg.canvasOrigin, location.href).origin;
10880
+ } catch {}
10881
+ if (target) {
10882
+ for (const el of iframesRef.current.values()) {
10883
+ try {
10884
+ el.contentWindow?.postMessage({ dgn: 'canvas-cap', t: token }, target);
10885
+ } catch {}
10886
+ }
10887
+ }
10888
+ schedule(token);
10889
+ }
10890
+ schedule(cfg.canvasToken);
10891
+ // A background tab's timers are throttled; one woken past its due time
10892
+ // re-mints at once rather than on the throttled tick.
10893
+ const onVisible = () => {
10894
+ if (document.visibilityState === 'visible' && Date.now() >= dueAt) refresh();
10895
+ };
10896
+ document.addEventListener('visibilitychange', onVisible);
10897
+ return () => {
10898
+ stopped = true;
10899
+ clearTimeout(timer);
10900
+ document.removeEventListener('visibilitychange', onVisible);
10901
+ };
10902
+ }, [cfg?.canvasToken, cfg?.canvasOrigin]);
10903
+
10809
10904
  // Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
10810
10905
  // The canvas-shell listens for these `dgn:*` messages and dispatches into the
10811
10906
  // matching local provider (annotations visibility / both selection stores /
@@ -9,6 +9,43 @@
9
9
  // default DS's tokens — whose ladder is scoped to a different `.<rootClass>`
10
10
  // subtree — so every `var(--*)` went undefined and the canvas rendered white.
11
11
 
12
+ // THE CANVAS CAPABILITY EXPIRES (render-token.mjs: 15 minutes) and cannot be
13
+ // revoked, so it is short on purpose. A tab open longer than that must carry a
14
+ // fresh one or its canvases stop loading and stop updating — every module
15
+ // re-import after a teammate's edit answers 401. The shell re-mints it on a
16
+ // timer (`refreshCanvasToken` in app.jsx) and records it here; a URL built
17
+ // after that carries the fresh capability. `null` until the first refresh:
18
+ // the one in `/_config` is then current.
19
+ let liveCanvasToken = null;
20
+ export function setLiveCanvasToken(token) {
21
+ liveCanvasToken = typeof token === 'string' && token ? token : null;
22
+ }
23
+ export function currentCanvasToken(cfg) {
24
+ return cfg?.canvasToken ? (liveCanvasToken ?? cfg.canvasToken) : null;
25
+ }
26
+
27
+ /**
28
+ * When to re-mint a capability: at 60% of what is left of it, read off the
29
+ * token's own expiry claim (`e`, ms — render-token.mjs), so the cadence
30
+ * follows the hub's lifetime instead of a copy of it. Never sooner than 30 s
31
+ * (a skewed clock cannot turn this into a busy loop) and never later than
32
+ * 10 minutes; an unreadable token gets the ceiling.
33
+ */
34
+ export function canvasTokenRefreshDelay(token, now = Date.now()) {
35
+ const MIN = 30_000;
36
+ const MAX = 10 * 60_000;
37
+ try {
38
+ const body = String(token).slice(0, String(token).lastIndexOf('.'));
39
+ const b64 = body.replace(/-/g, '+').replace(/_/g, '/');
40
+ const claims = JSON.parse(atob(b64 + '==='.slice((b64.length + 3) % 4)));
41
+ const left = Number(claims?.e) - now;
42
+ if (!Number.isFinite(left)) return MAX;
43
+ return Math.min(MAX, Math.max(MIN, Math.floor(left * 0.6)));
44
+ } catch {
45
+ return MAX;
46
+ }
47
+ }
48
+
12
49
  export function urlOf(p) {
13
50
  return '/' + p.split('/').map(encodeURIComponent).join('/');
14
51
  }
@@ -47,7 +84,8 @@ export function canvasUrl(p, cfg, opts) {
47
84
  // origin: a cookie scoped widely enough to cover it would be readable by the
48
85
  // untrusted canvas content itself, which is the one thing the DDR-054 split
49
86
  // exists to prevent — so the capability lives in the URL instead.
50
- if (cfg?.canvasToken) params.set('t', cfg.canvasToken);
87
+ const token = currentCanvasToken(cfg);
88
+ if (token) params.set('t', token);
51
89
  const ds0 = cfg?.designSystems?.[0];
52
90
  // Specimen detection: anything under `system/<ds>/preview/` belongs to that
53
91
  // specific DS, so it must render with *that* DS's tokens — not always the
@@ -137,6 +137,24 @@ function fmtTime(frame, fps) {
137
137
  return `${whole}.${String(cs).padStart(2, '0')}s`;
138
138
  }
139
139
 
140
+ /**
141
+ * Drag state its own release handler reads synchronously. The releases used
142
+ * to commit from INSIDE a state updater (`setX((d) => { commit(d); … })`), and
143
+ * React is free to run an updater more than once — StrictMode does it on
144
+ * purpose, and a render replayed against a concurrent update may too — so one
145
+ * drag could write its clip twice. Updaters passed to `set` run once, here,
146
+ * against the live value; the release commits outside any updater.
147
+ */
148
+ function useLiveState(initial) {
149
+ const [state, setState] = useState(initial);
150
+ const live = useRef(initial);
151
+ const set = useCallback((next) => {
152
+ live.current = typeof next === 'function' ? next(live.current) : next;
153
+ setState(live.current);
154
+ }, []);
155
+ return [state, set, live];
156
+ }
157
+
140
158
  // The frame axis starts AFTER the fixed label gutter.
141
159
  const LABEL_GUTTER = 96;
142
160
  // Padding the .tl-scroll container puts around the tracks (see 3-shell-maude.css).
@@ -353,10 +371,10 @@ export default function TimelinePanel({
353
371
 
354
372
  // -------------------------------------------------------------------------
355
373
  // Drag machines (retime / move) — px-per-frame based.
356
- const [retimeDrag, setRetimeDrag] = useState(null);
357
- const [moveDrag, setMoveDrag] = useState(null);
374
+ const [retimeDrag, setRetimeDrag, retimeLive] = useLiveState(null);
375
+ const [moveDrag, setMoveDrag, moveLive] = useLiveState(null);
358
376
  // Task 6 — magnetic storyline reorder drag: { index, slot, targetSlot, dx, pxf }.
359
- const [reorderDrag, setReorderDrag] = useState(null);
377
+ const [reorderDrag, setReorderDrag, reorderLive] = useLiveState(null);
360
378
  // Debate verdict (feedback): a momentary accent rule at the frame a drag just
361
379
  // snapped to — magnetism is otherwise invisible. { frame } | null.
362
380
  const [snapFlash, setSnapFlash] = useState(null);
@@ -374,7 +392,7 @@ export default function TimelinePanel({
374
392
  const [commentMode, setCommentMode] = useState(false);
375
393
  // Dogfood (2026-07-30) — vertical layer reorder: dragging an overlay row's
376
394
  // lane chip up/down re-stacks the standalone clips (z-order = doc order).
377
- const [layerDrag, setLayerDrag] = useState(null);
395
+ const [layerDrag, setLayerDrag, layerLive] = useLiveState(null);
378
396
  // Right-click clip context menu: { index, x, y }.
379
397
  const [ctxMenu, setCtxMenu] = useState(null);
380
398
  // Task 23 — the open comment-thread popover: { id, x, y } | null.
@@ -384,7 +402,7 @@ export default function TimelinePanel({
384
402
  // a seam chip click.
385
403
  const [inspector, setInspector] = useState(null);
386
404
  // Task 11 — left-edge in-point trim drag: { index, ref, startX, pxf, delta }.
387
- const [trimInDrag, setTrimInDrag] = useState(null);
405
+ const [trimInDrag, setTrimInDrag, trimInLive] = useLiveState(null);
388
406
  // True after a body-drag actually moved — suppresses the click-to-select.
389
407
  const movedRef = useRef(false);
390
408
  // Drag the top edge to resize the panel — pointer-capture + window listeners
@@ -508,10 +526,9 @@ export default function TimelinePanel({
508
526
  setRetimeDrag((d) => (d ? { ...d, curDur, atClamp } : d));
509
527
  };
510
528
  const up = () => {
511
- setRetimeDrag((d) => {
512
- if (d && d.curDur !== d.startDur) onRetime?.(d.ref ?? d.index, { durationInFrames: d.curDur });
513
- return null;
514
- });
529
+ const d = retimeLive.current;
530
+ setRetimeDrag(null);
531
+ if (d && d.curDur !== d.startDur) onRetime?.(d.ref ?? d.index, { durationInFrames: d.curDur });
515
532
  };
516
533
  window.addEventListener('pointermove', move);
517
534
  window.addEventListener('pointerup', up, { once: true });
@@ -537,25 +554,23 @@ export default function TimelinePanel({
537
554
  setMoveDrag((d) => (d ? { ...d, curFrom, overStory } : d));
538
555
  };
539
556
  const up = (e) => {
540
- setMoveDrag((d) => {
541
- if (d) {
542
- // Dropping the clip ON the storyline lane moves it between layers
543
- // (the layers model); otherwise commit the horizontal from-move.
544
- const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
545
- if (
546
- story &&
547
- onClipVerb &&
548
- e.clientY >= story.top &&
549
- e.clientY <= story.bottom &&
550
- movedRef.current
551
- ) {
552
- onClipVerb(d.ref ?? { index: d.index }, 'to-storyline', {});
553
- } else if (d.curFrom !== d.startFrom) {
554
- onRetime?.(d.ref ?? d.index, { from: d.curFrom });
555
- }
556
- }
557
- return null;
558
- });
557
+ const d = moveLive.current;
558
+ setMoveDrag(null);
559
+ if (!d) return;
560
+ // Dropping the clip ON the storyline lane moves it between layers
561
+ // (the layers model); otherwise commit the horizontal from-move.
562
+ const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
563
+ if (
564
+ story &&
565
+ onClipVerb &&
566
+ e.clientY >= story.top &&
567
+ e.clientY <= story.bottom &&
568
+ movedRef.current
569
+ ) {
570
+ onClipVerb(d.ref ?? { index: d.index }, 'to-storyline', {});
571
+ } else if (d.curFrom !== d.startFrom) {
572
+ onRetime?.(d.ref ?? d.index, { from: d.curFrom });
573
+ }
559
574
  };
560
575
  window.addEventListener('pointermove', move);
561
576
  window.addEventListener('pointerup', up, { once: true });
@@ -576,10 +591,9 @@ export default function TimelinePanel({
576
591
  setTrimInDrag((d) => (d ? { ...d, delta } : d));
577
592
  };
578
593
  const up = () => {
579
- setTrimInDrag((d) => {
580
- if (d && d.delta) onClipVerb?.(d.ref, 'trim-in', { deltaFrames: d.delta });
581
- return null;
582
- });
594
+ const d = trimInLive.current;
595
+ setTrimInDrag(null);
596
+ if (d && d.delta) onClipVerb?.(d.ref, 'trim-in', { deltaFrames: d.delta });
583
597
  };
584
598
  window.addEventListener('pointermove', move);
585
599
  window.addEventListener('pointerup', up, { once: true });
@@ -646,33 +660,33 @@ export default function TimelinePanel({
646
660
  setReorderDrag((d) => (d ? { ...d, dx, targetSlot: target } : d));
647
661
  };
648
662
  const up = (e) => {
649
- setReorderDrag((d) => {
650
- if (d && movedRef.current && onClipVerb) {
651
- const story = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
652
- if (story && e.clientY < story.top - 6) {
653
- const beat = sequences[d.index];
654
- if (beat) {
655
- onClipVerb(refFor(beat, d.index), 'to-overlay', {});
656
- return null;
657
- }
663
+ const d = reorderLive.current;
664
+ setReorderDrag(null);
665
+ if (!d) return;
666
+ if (movedRef.current && onClipVerb) {
667
+ const track = document.querySelector('.tl-storyline-track')?.getBoundingClientRect();
668
+ if (track && e.clientY < track.top - 6) {
669
+ const beat = sequences[d.index];
670
+ if (beat) {
671
+ onClipVerb(refFor(beat, d.index), 'to-overlay', {});
672
+ return;
658
673
  }
659
674
  }
660
- if (d && d.targetSlot !== d.slot) {
661
- const moved = story[d.slot];
662
- // targetSlot counts positions among the OTHER beats — map to a ref
663
- // beat + before/after in the original order.
664
- const others = story.filter((_, k) => k !== d.slot);
665
- if (moved && others.length) {
666
- if (d.targetSlot <= 0) {
667
- onReorderMove?.(refFor(moved.s, moved.i), refFor(others[0].s, others[0].i), 'before');
668
- } else {
669
- const ref = others[Math.min(d.targetSlot, others.length) - 1];
670
- onReorderMove?.(refFor(moved.s, moved.i), refFor(ref.s, ref.i), 'after');
671
- }
675
+ }
676
+ if (d.targetSlot !== d.slot) {
677
+ const moved = story[d.slot];
678
+ // targetSlot counts positions among the OTHER beats — map to a ref
679
+ // beat + before/after in the original order.
680
+ const others = story.filter((_, k) => k !== d.slot);
681
+ if (moved && others.length) {
682
+ if (d.targetSlot <= 0) {
683
+ onReorderMove?.(refFor(moved.s, moved.i), refFor(others[0].s, others[0].i), 'before');
684
+ } else {
685
+ const ref = others[Math.min(d.targetSlot, others.length) - 1];
686
+ onReorderMove?.(refFor(moved.s, moved.i), refFor(ref.s, ref.i), 'after');
672
687
  }
673
688
  }
674
- return null;
675
- });
689
+ }
676
690
  };
677
691
  window.addEventListener('pointermove', move);
678
692
  window.addEventListener('pointerup', up, { once: true });
@@ -701,13 +715,12 @@ export default function TimelinePanel({
701
715
  setLayerDrag((d) => (d ? { ...d, curTarget: target } : d));
702
716
  };
703
717
  const up = () => {
704
- setLayerDrag((d) => {
705
- if (d && d.curTarget !== d.docIdx) {
706
- const seq = sequences[d.index];
707
- if (seq) onClipVerb?.(refFor(seq, d.index), 'layer-order', { toIndex: d.curTarget });
708
- }
709
- return null;
710
- });
718
+ const d = layerLive.current;
719
+ setLayerDrag(null);
720
+ if (d && d.curTarget !== d.docIdx) {
721
+ const seq = sequences[d.index];
722
+ if (seq) onClipVerb?.(refFor(seq, d.index), 'layer-order', { toIndex: d.curTarget });
723
+ }
711
724
  };
712
725
  window.addEventListener('pointermove', move);
713
726
  window.addEventListener('pointerup', up, { once: true });
@@ -317,7 +317,7 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
317
317
 
318
318
  <Section
319
319
  title="Duotone"
320
- right={<Toggle checked={duo.enabled} onChange={(v) => setSection('duotone', { enabled: v }, true)} label="on" />}
320
+ right={<Toggle checked={duo.enabled} onChange={(v) => setSection('duotone', { enabled: v }, true)} label="on" ariaLabel="Duotone on" />}
321
321
  onReset={() => clearSection('duotone')}
322
322
  >
323
323
  {duo.enabled && (
@@ -337,7 +337,7 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
337
337
 
338
338
  <Section
339
339
  title="Grain"
340
- right={<Toggle checked={grain.enabled} onChange={(v) => setSection('grain', { enabled: v }, true)} label="on" />}
340
+ right={<Toggle checked={grain.enabled} onChange={(v) => setSection('grain', { enabled: v }, true)} label="on" ariaLabel="Grain on" />}
341
341
  onReset={() => clearSection('grain')}
342
342
  >
343
343
  {grain.enabled && (
@@ -358,7 +358,7 @@ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBa
358
358
 
359
359
  <Section
360
360
  title="Pattern"
361
- right={<Toggle checked={pat.enabled} onChange={(v) => setSection('pattern', { enabled: v }, true)} label="on" />}
361
+ right={<Toggle checked={pat.enabled} onChange={(v) => setSection('pattern', { enabled: v }, true)} label="on" ariaLabel="Pattern on" />}
362
362
  onReset={() => clearSection('pattern')}
363
363
  >
364
364
  {pat.enabled && (
@@ -2734,6 +2734,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2734
2734
  .sp-note-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
2735
2735
  .sp-note-dot.is-synced { background: var(--presence-online); }
2736
2736
  .sp-note-dot.is-refused, .sp-note-dot.is-stalled, .sp-note-dot.is-offline { background: var(--status-error); }
2737
+ .sp-note > .btn { flex: none; white-space: nowrap; }
2737
2738
  .sp-sect-attention { color: var(--status-error); }
2738
2739
  .sp-list { list-style: none; margin: 0; padding: 0; }
2739
2740
  .sp-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); min-width: 0; }
@@ -27,8 +27,45 @@
27
27
  // originating clientID — the random 32-bit ids make local↔remote collisions
28
28
  // negligible.
29
29
 
30
+ import * as encoding from 'lib0/encoding';
30
31
  import type { Awareness } from 'y-protocols/awareness';
31
- import { applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
32
+ import {
33
+ applyAwarenessUpdate,
34
+ encodeAwarenessUpdate,
35
+ removeAwarenessStates,
36
+ } from 'y-protocols/awareness';
37
+
38
+ /**
39
+ * A departed relayed client, as the hub can carry it.
40
+ *
41
+ * A peer leaving is a REMOVAL — a null awareness state. The hub (Hocuspocus 4)
42
+ * runs every incoming awareness frame through a scratch Awareness for its
43
+ * `beforeHandleAwareness` hook and re-encodes only the states that scratch
44
+ * still HOLDS, so a null state is dropped on the way in: a tab closing behind
45
+ * this studio never left anyone else's presence until the 30 s awareness
46
+ * timeout (plan T31/L19). The one thing it forwards is a state, so a departure
47
+ * crosses as this marker with a newer clock; every bridge turns it back into a
48
+ * real removal on its room. It carries no `name`, so a client that predates
49
+ * the marker drops it at the trust boundary (`sanitizeForeignState`) instead
50
+ * of drawing a nameless avatar.
51
+ */
52
+ export const DEPARTED = Object.freeze({ __left: true });
53
+
54
+ function isDeparted(state: unknown): boolean {
55
+ return !!state && typeof state === 'object' && (state as { __left?: unknown }).__left === true;
56
+ }
57
+
58
+ /** Encode a departure for each of `clients`, one clock past what `from` knows. */
59
+ function encodeDepartures(from: Awareness, clients: readonly number[]): Uint8Array {
60
+ const enc = encoding.createEncoder();
61
+ encoding.writeVarUint(enc, clients.length);
62
+ for (const clientID of clients) {
63
+ encoding.writeVarUint(enc, clientID);
64
+ encoding.writeVarUint(enc, (from.meta.get(clientID)?.clock ?? 0) + 1);
65
+ encoding.writeVarString(enc, JSON.stringify(DEPARTED));
66
+ }
67
+ return encoding.toUint8Array(enc);
68
+ }
32
69
 
33
70
  interface AwarenessChange {
34
71
  added: number[];
@@ -49,22 +86,36 @@ export function bridgeAwareness(a: Awareness, b: Awareness): () => void {
49
86
  // so it relays; a change this bridge applied carries BRIDGE, so it stops.
50
87
  const BRIDGE = { awarenessBridge: true };
51
88
 
52
- function makeRelay(from: Awareness, to: Awareness) {
53
- return ({ added, updated, removed }: AwarenessChange, origin: unknown) => {
54
- if (origin === BRIDGE) return;
55
- const changed = added.concat(updated, removed);
56
- if (changed.length === 0) return;
57
- applyAwarenessUpdate(to, encodeAwarenessUpdate(from, changed), BRIDGE);
58
- };
59
- }
60
-
61
- const aToB = makeRelay(a, b);
62
- const bToA = makeRelay(b, a);
89
+ // Room → hub. A client that left the room leaves as a DEPARTED marker (see
90
+ // above) — never as the null state the hub would drop. Everything else
91
+ // relays as it is.
92
+ const aToB = ({ added, updated, removed }: AwarenessChange, origin: unknown) => {
93
+ if (origin === BRIDGE) return;
94
+ const present = added.concat(updated);
95
+ if (present.length > 0) applyAwarenessUpdate(b, encodeAwarenessUpdate(a, present), BRIDGE);
96
+ const gone = removed.filter((id) => id !== b.clientID && b.getStates().has(id));
97
+ if (gone.length > 0) applyAwarenessUpdate(b, encodeDepartures(b, gone), BRIDGE);
98
+ };
99
+ // Hub → room. A DEPARTED marker is a removal here; a real removal (the hub's
100
+ // own timeout, a peer's socket closing) relays as ever.
101
+ const bToA = ({ added, updated, removed }: AwarenessChange, origin: unknown) => {
102
+ if (origin === BRIDGE) return;
103
+ const states = b.getStates();
104
+ const departed = added.concat(updated).filter((id) => isDeparted(states.get(id)));
105
+ const live = added.concat(updated).filter((id) => !isDeparted(states.get(id)));
106
+ const changed = live.concat(removed);
107
+ if (changed.length > 0) applyAwarenessUpdate(a, encodeAwarenessUpdate(b, changed), BRIDGE);
108
+ const leaving = departed.filter((id) => a.getStates().has(id));
109
+ if (leaving.length > 0) removeAwarenessStates(a, leaving, BRIDGE);
110
+ };
63
111
 
64
- // Initial state exchange — push each side's current states to the other.
112
+ // Initial state exchange — push each side's current states to the other
113
+ // (departed markers are not a presence).
65
114
  const aClients = Array.from(a.getStates().keys());
66
115
  if (aClients.length > 0) applyAwarenessUpdate(b, encodeAwarenessUpdate(a, aClients), BRIDGE);
67
- const bClients = Array.from(b.getStates().keys());
116
+ const bClients = Array.from(b.getStates().entries())
117
+ .filter(([, state]) => !isDeparted(state))
118
+ .map(([id]) => id);
68
119
  if (bClients.length > 0) applyAwarenessUpdate(a, encodeAwarenessUpdate(b, bClients), BRIDGE);
69
120
 
70
121
  a.on('update', aToB);