@1agh/maude 0.58.3 → 0.60.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 (81) 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 +1180 -242
  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 +320 -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 +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -0,0 +1,320 @@
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+
16
+ import { safeDetail, safeName, syncPresentation } from '../../sync/presentation.ts';
17
+
18
+ /**
19
+ * How long Resync stays disabled after a cycle finishes.
20
+ *
21
+ * A resync is `syncControl.restart()`: it tears every provider down and
22
+ * re-authenticates EVERY document — 76 WS auths on the project this was built
23
+ * for, since auth fires once per document. The valid-token bucket is 600/min
24
+ * per label (DDR-102), so roughly eight presses inside a minute would pin the
25
+ * very bucket the incident behind this feature was about. Ten seconds caps an
26
+ * impatient person at six presses a minute and keeps them well under it. The
27
+ * hub's own 429 remains the real backstop — this is politeness, not security.
28
+ */
29
+ const RESYNC_COOLDOWN_MS = 10_000;
30
+
31
+ /** DocSyncState → the presentation vocabulary (never invent a new word). */
32
+ const STATE_WORD = {
33
+ pending: 'syncing',
34
+ connected: 'synced',
35
+ 'auth-rejected': 'refused',
36
+ };
37
+
38
+ /** AuthFailureClass → plain words for the refused-row detail. */
39
+ const REASON_WORD = {
40
+ 'rate-limit': 'rate limited',
41
+ 'not-authorized': 'not authorized',
42
+ 'invalid-token': 'sign-in expired',
43
+ generic: 'refused by the hub',
44
+ };
45
+
46
+ function Row({ item }) {
47
+ const name = safeName(item.slug, '(unnamed)');
48
+ const state = STATE_WORD[item.state] || 'syncing';
49
+ const reason = item.state === 'auth-rejected' ? REASON_WORD[item.reason] || REASON_WORD.generic : null;
50
+ return (
51
+ <li className={'sp-row is-' + item.state} data-testid={'sync-row-' + item.slug}>
52
+ <span className="sp-row-dot" aria-hidden="true" />
53
+ <span className="sp-row-name" title={name}>
54
+ {name}
55
+ </span>
56
+ <span className="sp-row-state">{reason ? `refused — ${reason}` : state}</span>
57
+ </li>
58
+ );
59
+ }
60
+
61
+ /** Which declared canvas group a slug belongs to (slugs collapse '/' → '-'). */
62
+ function groupOf(slug, groupPaths) {
63
+ for (const p of groupPaths) {
64
+ const g = p.replace(/\//g, '-');
65
+ if (slug === g || slug.startsWith(g + '-')) return p;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // FAIL CLOSED, like presentation.ts `readCounts`: this payload is JSON.parse
71
+ // of `_sync.json` with no schema, so a partial write / older producer must
72
+ // degrade to "nothing to show", never crash the render or print NaN.
73
+
74
+ const isCount = (n) => typeof n === 'number' && Number.isInteger(n) && n >= 0;
75
+
76
+ /** The per-doc rows, or [] when the shape can't be trusted. */
77
+ function readItems(raw) {
78
+ if (!Array.isArray(raw)) return [];
79
+ return raw.filter((i) => i && typeof i.slug === 'string' && typeof i.state === 'string');
80
+ }
81
+
82
+ /** The asset lane, or null when any count is unreadable. */
83
+ function readAssets(raw) {
84
+ if (!raw || typeof raw !== 'object') return null;
85
+ if (![raw.total, raw.done, raw.pushed, raw.skipped, raw.failedCount].every(isCount)) return null;
86
+ const failures = Array.isArray(raw.failures)
87
+ ? raw.failures.filter((f) => f && typeof f.key === 'string')
88
+ : [];
89
+ return { ...raw, failures };
90
+ }
91
+
92
+ export default function SyncPanel({
93
+ status, // the live `sync:status` payload (never null while mounted)
94
+ project, // display name for the header sentence (hub-supplied → safeName'd)
95
+ groupPaths = [], // declared canvas-group paths, for row grouping
96
+ resizing,
97
+ onClose,
98
+ }) {
99
+ const p = syncPresentation(status, { project });
100
+ const [resyncing, setResyncing] = useState(false);
101
+ const [cooling, setCooling] = useState(false);
102
+ const [note, setNote] = useState('');
103
+ const timers = useRef([]);
104
+
105
+ // Timers outlive an unmount otherwise — closing the panel mid-cooldown would
106
+ // set state on a gone component.
107
+ useEffect(
108
+ () => () => {
109
+ for (const t of timers.current) clearTimeout(t);
110
+ timers.current = [];
111
+ },
112
+ []
113
+ );
114
+
115
+ const resync = useCallback(async () => {
116
+ if (resyncing || cooling) return;
117
+ setResyncing(true);
118
+ setNote('');
119
+ let res = null;
120
+ let json = {};
121
+ try {
122
+ res = await fetch('/_api/sync/resync', { method: 'POST' });
123
+ json = await res.json().catch(() => ({}));
124
+ } catch {
125
+ /* the server went away — say so below rather than throwing into render */
126
+ }
127
+ setResyncing(false);
128
+ if (!res) setNote('Maude could not reach the sync service.');
129
+ else if (res.status === 409) setNote('Already restarting — give it a moment.');
130
+ else if (!res.ok || !json.ok) setNote(json.detail || 'Resync could not start.');
131
+ // A restart that declined is not an error, but it IS the only thing worth
132
+ // saying — the panel's own header keeps reporting the live state.
133
+ else if (json.sync && !json.sync.syncing) setNote(json.sync.detail || '');
134
+ setCooling(true);
135
+ timers.current.push(setTimeout(() => setCooling(false), RESYNC_COOLDOWN_MS));
136
+ }, [resyncing, cooling]);
137
+
138
+ const cancelAssets = useCallback(async () => {
139
+ try {
140
+ await fetch('/_api/sync/cancel-assets', { method: 'POST' });
141
+ } catch {
142
+ /* the sweep ends with the server either way */
143
+ }
144
+ }, []);
145
+
146
+ const items = readItems(status?.items);
147
+ const truncated = isCount(status?.itemsTruncated) ? status.itemsTruncated : 0;
148
+ const assets = readAssets(status?.assets);
149
+ const assetFailures = assets?.failures || [];
150
+
151
+ const { attention, byGroup } = useMemo(() => {
152
+ const attention = items.filter((i) => i.state === 'auth-rejected');
153
+ const rest = items.filter((i) => i.state !== 'auth-rejected');
154
+ const byGroup = new Map();
155
+ for (const item of rest) {
156
+ const g = groupOf(item.slug, groupPaths) ?? 'canvases';
157
+ if (!byGroup.has(g)) byGroup.set(g, []);
158
+ byGroup.get(g).push(item);
159
+ }
160
+ return { attention, byGroup };
161
+ }, [items, groupPaths]);
162
+
163
+ // Same fail-closed rule for the header chip — readCounts-shaped, so the
164
+ // chip can never say "NaN synced" while the note below fails closed.
165
+ const rawDocs = status?.docs;
166
+ const docs =
167
+ rawDocs && [rawDocs.synced, rawDocs.pending, rawDocs.rejected].every(isCount)
168
+ ? rawDocs
169
+ : null;
170
+ const counts =
171
+ docs &&
172
+ [
173
+ `${docs.synced} synced`,
174
+ docs.pending > 0 ? `${docs.pending} syncing` : null,
175
+ docs.rejected > 0 ? `${docs.rejected} refused` : null,
176
+ ]
177
+ .filter(Boolean)
178
+ .join(' · ');
179
+
180
+ return (
181
+ <aside
182
+ className={'st-rpanel sp-panel' + (resizing ? ' is-resizing' : '')}
183
+ aria-label="Sync"
184
+ data-testid="sync-panel"
185
+ >
186
+ <div className="gp-head">
187
+ <div className="gp-panel-hd">
188
+ <span className="gp-panel-title">Sync</span>
189
+ {counts && <span className="gp-count">{counts}</span>}
190
+ <span className="gp-spacer" />
191
+ {/* Resync re-runs the WHOLE sync — every canvas and every asset — so
192
+ it lives in the header, not inside the assets section. It is
193
+ `syncControl.restart()`, the same cycle Connect performs. */}
194
+ <button
195
+ type="button"
196
+ className="sp-resync"
197
+ data-testid="sync-resync"
198
+ onClick={resync}
199
+ disabled={resyncing || cooling}
200
+ title="Re-check every canvas and asset against the workspace"
201
+ >
202
+ {resyncing ? 'Resyncing…' : 'Resync'}
203
+ </button>
204
+ <button type="button" className="gp-x" aria-label="Close" onClick={onClose}>
205
+ ×
206
+ </button>
207
+ </div>
208
+ {note && (
209
+ <div className="sp-resync-note" role="status" aria-live="polite">
210
+ {safeDetail(note, '')}
211
+ </div>
212
+ )}
213
+ {/* The one-rule sentence, live — same aria pattern as the rail note:
214
+ a polite announcement when the phase changes, never a focus steal. */}
215
+ {p && (
216
+ <div className="sp-note" role="status" aria-live="polite">
217
+ <span className={'sp-note-dot is-' + p.phase} aria-hidden="true" />
218
+ <span>
219
+ {p.title}
220
+ {p.next ? ` ${p.next}` : ''}
221
+ </span>
222
+ </div>
223
+ )}
224
+ </div>
225
+
226
+ <div className="sp-body">
227
+ {items.length === 0 && !assets && (
228
+ <div className="gp-empty">
229
+ <p>No per-file detail yet — it arrives with the first sync report after this panel shipped. If this persists, restart Maude.</p>
230
+ </div>
231
+ )}
232
+
233
+ {attention.length > 0 && (
234
+ <section aria-label="Needs attention">
235
+ <div className="gp-sect-label sp-sect-attention">Needs attention</div>
236
+ <ul className="sp-list">
237
+ {attention.map((item) => (
238
+ <Row key={item.slug} item={item} />
239
+ ))}
240
+ </ul>
241
+ </section>
242
+ )}
243
+
244
+ {[...byGroup.entries()].map(([group, rows]) => (
245
+ <section key={group} aria-label={group}>
246
+ <div className="gp-sect-label">
247
+ {group} <span className="gp-group-count">{rows.length}</span>
248
+ </div>
249
+ <ul className="sp-list">
250
+ {rows.map((item) => (
251
+ <Row key={item.slug} item={item} />
252
+ ))}
253
+ </ul>
254
+ </section>
255
+ ))}
256
+
257
+ {truncated > 0 && (
258
+ <div className="sp-truncated">
259
+ +{truncated} more synced canvas{truncated === 1 ? '' : 'es'} not listed.
260
+ </div>
261
+ )}
262
+
263
+ {assets && (
264
+ <section aria-label="Assets" data-testid="sync-assets">
265
+ <div className="gp-sect-label">
266
+ assets <span className="gp-group-count">{assets.total}</span>
267
+ </div>
268
+ <div className="sp-assets-line">
269
+ {assets.finished
270
+ ? `${assets.pushed} pushed · ${assets.skipped} already there` +
271
+ (assets.failedCount > 0 ? ` · ${assets.failedCount} failed` : '')
272
+ : `Pushing assets — ${assets.done} of ${assets.total}…`}
273
+ {/* Cancel is scoped to the SWEEP — interrupting an upload is a
274
+ real gesture; interrupting a reconnect mid-handshake is not.
275
+ Safe to press: uploads are idempotent and the hub writes
276
+ temp-then-rename, so nothing half-written can survive. */}
277
+ {!assets.finished && (
278
+ <button
279
+ type="button"
280
+ className="sp-assets-cancel"
281
+ data-testid="sync-assets-cancel"
282
+ onClick={cancelAssets}
283
+ >
284
+ Cancel
285
+ </button>
286
+ )}
287
+ </div>
288
+ {!assets.finished && assets.active && (
289
+ <div className="sp-assets-active" title={safeName(assets.active, '')}>
290
+ ↑ {safeName(assets.active, '')}
291
+ </div>
292
+ )}
293
+ {assets.failedCount > 0 && (
294
+ <ul className="sp-list">
295
+ {assetFailures.map((f) => (
296
+ <li className="sp-row is-auth-rejected" key={f.key}>
297
+ <span className="sp-row-dot" aria-hidden="true" />
298
+ <span className="sp-row-name" title={safeName(f.key, '(unnamed)')}>
299
+ {safeName(f.key, '(unnamed)')}
300
+ </span>
301
+ <span className="sp-row-state">{safeName(f.reason, 'failed')}</span>
302
+ </li>
303
+ ))}
304
+ {assets.failedCount > assetFailures.length && (
305
+ <li className="sp-truncated">
306
+ +{assets.failedCount - assetFailures.length} more failed. All retry on the next
307
+ launch.
308
+ </li>
309
+ )}
310
+ </ul>
311
+ )}
312
+ {assets.failedCount > 0 && assets.finished && (
313
+ <div className="sp-assets-retry">Failed assets retry on the next launch.</div>
314
+ )}
315
+ </section>
316
+ )}
317
+ </div>
318
+ </aside>
319
+ );
320
+ }
@@ -23,6 +23,7 @@ import { ImagePlus, MessageSquarePlus, Plus, Scissors, Sparkles, Type } from 'lu
23
23
 
24
24
  import ClipInspector from './ClipInspector.jsx';
25
25
  import { computeSnapTargets, snapFrame } from './timeline-snap.js';
26
+ import { activeComp } from './timeline-comp-target.js';
26
27
  import { getFilmstrip, getPeaks, peaksToPath, stripBucket } from './timeline-media-cache.js';
27
28
  import {
28
29
  clampPxPerFrame,
@@ -143,6 +144,10 @@ const SCROLL_PAD_X = 24;
143
144
 
144
145
  export default function TimelinePanel({
145
146
  comps = [],
147
+ // issue #75 — the comp the shell's transport targets. The panel reads its
148
+ // meta (fps / duration) off the SAME comp, so the readout can never describe
149
+ // a different artboard than the one Play actually moves.
150
+ compId = null,
146
151
  sequences = [],
147
152
  audio = [],
148
153
  transitions = [],
@@ -204,7 +209,14 @@ export default function TimelinePanel({
204
209
  // storyline layout moves layer detail into the clip inspector).
205
210
  const [collapsed, setCollapsed] = useState(() => new Set());
206
211
  const [dropActive, setDropActive] = useState(false);
207
- const comp = comps[0] ?? null;
212
+ const comp = activeComp(comps, compId);
213
+ // issue #75 — which artboard the rows + transport belong to. `null` means the
214
+ // shell genuinely could NOT tell (a comp mounted outside any artboard, a
215
+ // resolver fall-through). That case must render as an explicit unknown, not
216
+ // as no chip at all: "no chip" already means "single comp, nothing to
217
+ // disambiguate", and reusing it for "I don't know" would make the ambiguous
218
+ // case look like the safe one (security-review 2026-08-12).
219
+ const scopedArtboard = comp?.artboardLabel || comp?.artboardId || null;
208
220
  const totalFrames = Math.max(1, total || comp?.durationInFrames || 1);
209
221
  const fps = comp?.fps ?? 30;
210
222
  const clamped = clamp(Math.round(frame), 0, totalFrames - 1);
@@ -1423,6 +1435,22 @@ export default function TimelinePanel({
1423
1435
  onChange={(e) => onVolume?.(Number(e.target.value) / 100)}
1424
1436
  />
1425
1437
  ) : null}
1438
+ {/* issue #75 — on a multi-comp canvas the rows + transport belong to
1439
+ ONE artboard, and nothing said which. Show it (label, id as the
1440
+ fallback) so the panel and the canvas agree out loud. */}
1441
+ {comps.length > 1 ? (
1442
+ <span
1443
+ className={`tl-artboard${scopedArtboard ? '' : ' is-unknown'}`}
1444
+ data-testid="timeline-artboard"
1445
+ title={
1446
+ scopedArtboard
1447
+ ? `Timeline is scoped to artboard "${scopedArtboard}" — pan to another artboard to switch`
1448
+ : "This canvas has several video comps and the Timeline couldn't tell which artboard this one sits in — the transport may be moving a comp you're not looking at"
1449
+ }
1450
+ >
1451
+ {scopedArtboard ?? 'artboard ?'}
1452
+ </span>
1453
+ ) : null}
1426
1454
  <span className="tl-readout" data-testid="timeline-readout">
1427
1455
  <b>{clamped}</b>
1428
1456
  <span className="tl-sep">/</span>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @file timeline-comp-target.js — which announced comp the Timeline drives.
3
+ * @scope apps/studio/client/panels/
4
+ * @purpose On a multi-comp canvas the Timeline shows ONE comp's rows (the
5
+ * parser scopes to the viewport-active artboard) but the transport
6
+ * (play / pause / scrub / mute / loop) is a targeted postMessage —
7
+ * it needs the Player's own comp id.
8
+ *
9
+ * This used to be resolved by matching the parsed row total against
10
+ * each comp's `durationInFrames`. Two comps of the SAME length (the
11
+ * common case — one composition duplicated across artboards) made
12
+ * `find` return the FIRST one, so the panel drew artboard #2's rows
13
+ * while Play moved artboard #1 (issue #75). Duration is not an
14
+ * identity, so the primary key is now the artboard the comp is
15
+ * mounted in (`artboardId`, resolved by DOM containment in
16
+ * video-comp.tsx). Duration stays as a fallback, and is only trusted
17
+ * when it identifies exactly ONE comp.
18
+ */
19
+
20
+ // Bidi overrides + isolates, zero-width / format characters, and C0/C1
21
+ // controls (newlines and tabs included). An artboard label is author-written
22
+ // text from an untrusted origin (DDR-054) that the shell RENDERS as chrome, so
23
+ // a trailing RLO or a label built entirely of zero-width joiners could make the
24
+ // Timeline's chip read as an artboard other than the one the transport is
25
+ // scoped to — defeating the exact signal the chip exists to give. Same class
26
+ // `sanitizeDisplayText` (file-preview.jsx) and `figma/sanitize.ts` already
27
+ // neutralize; widened here because a label, unlike a filename, has no shape
28
+ // constraints at all. Written as escapes on purpose — a literal invisible
29
+ // character in this class would be unreviewable.
30
+ const DISPLAY_UNSAFE =
31
+ /[\u202a-\u202e\u2066-\u2069\u200b-\u200f\u2060\u00ad\ufeff\u0000-\u001f\u007f-\u009f]/g;
32
+
33
+ /**
34
+ * Neutralize + cap one canvas-origin display string. Returns null for a value
35
+ * that is absent, or that is empty ONCE the unsafe characters are gone — an
36
+ * all-invisible label must read as "no label", not as a blank chip that looks
37
+ * like the panel failed to render.
38
+ *
39
+ * @param {unknown} s
40
+ * @param {number} [max]
41
+ * @returns {string|null}
42
+ */
43
+ export function sanitizeArtboardText(s, max = 120) {
44
+ if (typeof s !== 'string') return null;
45
+ const clean = s.replace(DISPLAY_UNSAFE, '').trim().slice(0, max).trim();
46
+ return clean || null;
47
+ }
48
+
49
+ /**
50
+ * @param {Array<{id: string, artboardId?: string|null, durationInFrames?: number}>} comps
51
+ * @param {{ artboardId?: string|string[]|null, total?: number }} [opts] `artboardId`
52
+ * may be a single id or a confidence-ordered list of candidates.
53
+ * @returns {string|null} the comp id the transport must target
54
+ */
55
+ export function resolveCompTarget(comps, opts = {}) {
56
+ if (!Array.isArray(comps) || comps.length === 0) return null;
57
+ const { artboardId = null, total = 0 } = opts;
58
+ // Candidates in confidence order. The caller passes the artboard the ROW
59
+ // PARSER scoped to (derived lexically from the .tsx) followed by the viewport
60
+ // signal canvas-lib reports on pan (derived structurally). They usually agree;
61
+ // when the lexical one names an artboard no comp is actually mounted in — a
62
+ // `<DCArtboard id=…>` inside a comment or a string literal is enough — the
63
+ // pan signal is the better answer, and trying both is what keeps a source
64
+ // file's stale opinion from silently out-ranking the live DOM.
65
+ const candidates = (Array.isArray(artboardId) ? artboardId : [artboardId]).filter(Boolean);
66
+ // Pass 1 — the artboard each comp ANNOUNCED (DOM containment). Runs across
67
+ // every candidate before pass 2 starts, deliberately.
68
+ for (const cand of candidates) {
69
+ const hit = comps.find((c) => c.artboardId === cand);
70
+ if (hit) return hit.id;
71
+ }
72
+ // Pass 2 — an author-set comp id that equals an artboard id (the documented
73
+ // "match the enclosing DCArtboard id" convention). Strictly WEAKER than pass
74
+ // 1: comp ids and artboard ids are separate author-chosen namespaces, so a
75
+ // `<VideoComp id="outro">` mounted inside `intro` must never be able to claim
76
+ // outro's transport while a comp genuinely mounted in outro exists.
77
+ for (const cand of candidates) {
78
+ const hit = comps.find((c) => c.id === cand);
79
+ if (hit) return hit.id;
80
+ }
81
+ if (total > 0) {
82
+ const byTotal = comps.filter((c) => c.durationInFrames === total);
83
+ // Exactly one — unambiguous. Several — duration tells us nothing, so fall
84
+ // through rather than silently picking the first (that WAS the bug).
85
+ if (byTotal.length === 1) return byTotal[0].id;
86
+ }
87
+ return comps[0].id;
88
+ }
89
+
90
+ /**
91
+ * The comp record the panel should read meta (fps, duration) off — same target
92
+ * as the transport, so the readout can never describe a different comp than the
93
+ * one Play moves.
94
+ *
95
+ * @param {Array<{id: string}>} comps
96
+ * @param {string|null} compId
97
+ */
98
+ export function activeComp(comps, compId) {
99
+ if (!Array.isArray(comps) || comps.length === 0) return null;
100
+ return comps.find((c) => c.id === compId) ?? comps[0];
101
+ }
@@ -209,6 +209,10 @@
209
209
  .st-live-dot.is-connected { background: var(--presence-online); }
210
210
  .st-sb-sync-dot { width: 7px; height: 7px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
211
211
  .st-sb-sync-dot.is-online { background: var(--presence-online); }
212
+ /* feature-sync-progress-modal — the chip doubles as the Sync-panel toggle. */
213
+ .st-sb-sync--btn { appearance: none; border: 0; padding: 0; border-radius: 0; background: transparent; font: inherit; cursor: pointer; }
214
+ .st-sb-sync--btn:hover .val, .st-sb-sync--btn.is-open .val { color: var(--accent); }
215
+ .st-sb-sync--btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
212
216
 
213
217
  /* Phase 28 — clickable "changes" slot (opens the Changes panel). Button reset; the
214
218
  .st-sb-slot + .st-sb-slot rule still supplies the divider + left padding. */
@@ -1984,6 +1988,10 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1984
1988
  .tl-btn.is-on { background: var(--accent-tint); border-color: var(--accent-muted); color: var(--accent); }
1985
1989
  .tl-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
1986
1990
  .tl-btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--accent-fg); }
1991
+ /* issue #75 — which artboard's comp this timeline is scoped to (multi-comp only). */
1992
+ .tl-artboard { margin-left: var(--space-2); padding: 2px 7px; border-radius: 999px; border: 1px solid var(--border-default); background: var(--bg-2); color: var(--fg-1); font-size: var(--type-xs); line-height: 1.4; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1993
+ /* The shell could not resolve the artboard — read as a warning, not as a name. */
1994
+ .tl-artboard.is-unknown { color: var(--fg-3); border-style: dashed; font-style: italic; }
1987
1995
  .tl-readout { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); display: inline-flex; align-items: baseline; gap: 4px; margin-left: var(--space-2); }
1988
1996
  .tl-readout b { color: var(--fg-0); font-weight: 600; }
1989
1997
  .tl-readout .tl-sep { color: var(--fg-3); }
@@ -2662,6 +2670,38 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2662
2670
  .rb-trigger-ver--ro .rb-trigger-ver-name { color: var(--fg-1); font-family: var(--font-mono); font-size: var(--type-xs); }
2663
2671
  /* GitPanel read-only footer hint (web studio — actions live in the terminal). */
2664
2672
  .gp-ro-hint { padding: var(--space-3) var(--space-4); color: var(--fg-2); }
2673
+
2674
+ /* feature-sync-progress-modal — the Sync panel (per-file sync rows). Reuses
2675
+ * the gp- head chrome; sp- covers only the sync-specific rows/sections. */
2676
+ .sp-panel { display: flex; flex-direction: column; }
2677
+ .sp-body { flex: 1; overflow-y: auto; padding-bottom: var(--space-4); }
2678
+ .sp-note { display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-2) var(--space-1) var(--space-2); font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
2679
+ .sp-note-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
2680
+ .sp-note-dot.is-synced { background: var(--presence-online); }
2681
+ .sp-note-dot.is-refused, .sp-note-dot.is-stalled, .sp-note-dot.is-offline { background: var(--status-error); }
2682
+ .sp-sect-attention { color: var(--status-error); }
2683
+ .sp-list { list-style: none; margin: 0; padding: 0; }
2684
+ .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; }
2685
+ .sp-row-dot { width: 6px; height: 6px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
2686
+ .sp-row.is-connected .sp-row-dot { background: var(--presence-online); }
2687
+ .sp-row.is-auth-rejected .sp-row-dot { background: var(--status-error); }
2688
+ .sp-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: var(--type-xs); }
2689
+ .sp-row-state { flex: none; font-size: var(--type-xs); color: var(--fg-2); }
2690
+ .sp-row.is-auth-rejected .sp-row-state { color: var(--status-error); }
2691
+ .sp-truncated { padding: var(--space-1) var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
2692
+ .sp-assets-line { padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); }
2693
+ .sp-assets-active { padding: 0 var(--space-4); font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2694
+ .sp-assets-retry { padding: var(--space-1) var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
2695
+ /* feature-sync-resync-and-out-of-process-sweep — Resync (header) re-runs the
2696
+ * whole sync; Cancel (assets line) stops only the upload sweep. */
2697
+ .sp-resync { appearance: none; flex: none; font-family: var(--font-body); font-size: var(--type-xs); color: var(--fg-1); background: var(--bg-2); border: 1px solid var(--border-default); border-radius: var(--radius-sm); padding: 2px var(--space-2); cursor: pointer; transition: color var(--dur-soft) var(--ease-out), border-color var(--dur-soft) var(--ease-out); }
2698
+ .sp-resync:hover:not(:disabled) { color: var(--fg-0); border-color: var(--border-strong); }
2699
+ .sp-resync:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2700
+ .sp-resync:disabled { color: var(--fg-3); cursor: default; }
2701
+ .sp-resync-note { padding: 0 var(--space-4) var(--space-2); font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
2702
+ .sp-assets-cancel { appearance: none; margin-left: var(--space-2); font-family: var(--font-body); font-size: var(--type-xs); color: var(--fg-2); background: transparent; border: 0; padding: 0; cursor: pointer; text-decoration: underline; }
2703
+ .sp-assets-cancel:hover { color: var(--status-error); }
2704
+ .sp-assets-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2665
2705
  /* The popup — opens UPWARD from the dock (gi-menu anatomy). SOLID opaque menu
2666
2706
  * surface + border here (NOT the canvas `.panel`, which isn't in the client bundle
2667
2707
  * — phase-28 lesson; the tree must never bleed through). */
@@ -1402,16 +1402,16 @@
1402
1402
  /* A degraded export is `done` but not clean — it must never read as success.
1403
1403
  Overrides --done, so declaration order matters. */
1404
1404
  .st-export-pill--degraded {
1405
- background: color-mix(in oklab, var(--u-status-warning, var(--u-accent)) 18%, transparent);
1406
- color: var(--u-status-warning, var(--u-accent));
1405
+ background: color-mix(in oklab, var(--u-status-warn, var(--u-accent)) 18%, transparent);
1406
+ color: var(--u-status-warn, var(--u-accent));
1407
1407
  }
1408
1408
  .st-export-degraded {
1409
1409
  font-size: 12px;
1410
1410
  line-height: 1.45;
1411
- color: var(--u-status-warning, var(--u-accent));
1411
+ color: var(--u-status-warn, var(--u-accent));
1412
1412
  padding: 6px 8px;
1413
1413
  border-radius: var(--radius-sm, 6px);
1414
- background: color-mix(in oklab, var(--u-status-warning, var(--u-accent)) 10%, transparent);
1414
+ background: color-mix(in oklab, var(--u-status-warn, var(--u-accent)) 10%, transparent);
1415
1415
  }
1416
1416
  .st-export-degraded__fix {
1417
1417
  margin-top: 4px;
@@ -198,6 +198,10 @@ export interface Context {
198
198
  reason?: string;
199
199
  detail?: string;
200
200
  }>;
201
+ /** A cycle is in flight — Resync refuses early rather than queueing. */
202
+ busy?(): boolean;
203
+ /** The live runtime, for the sweep-scoped cancel. Null in solo mode. */
204
+ current?(): { cancelAssetSweep(): boolean } | null;
201
205
  };
202
206
  }
203
207