@1agh/maude 0.60.6 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -6
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +919 -214
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +90 -16
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +99 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -98,6 +98,123 @@ function readFiles(raw) {
98
98
  return raw;
99
99
  }
100
100
 
101
+ /** Consent-class notices (feature-before-first-external-users Task 1), or []
102
+ * when the shape can't be trusted. Absent is the norm — the payload carries
103
+ * `notices` only when a boot raised one (shared-doc ON, TSX bodies). */
104
+ function readNotices(raw) {
105
+ if (!Array.isArray(raw)) return [];
106
+ return raw.filter((n) => n && typeof n.id === 'string' && typeof n.text === 'string');
107
+ }
108
+
109
+ /** DeliveryState → plain words for the doručenka rows (file-ledger.ts owns the
110
+ * union; unknown states render verbatim so a NEWER producer stays readable). */
111
+ const DELIVERY_WORD = {
112
+ conflict: 'conflict — older copy in Trash below',
113
+ stuck: 'stuck',
114
+ 'referenced-but-unoffered': 'referenced, never received',
115
+ 'local-only': 'only on this machine',
116
+ pushing: 'uploading…',
117
+ 'on-hub': 'on the workspace',
118
+ durable: 'backed up',
119
+ 'at-peer': 'reached a teammate',
120
+ 'ui-healed': 'healed',
121
+ everywhere: 'everywhere',
122
+ };
123
+
124
+ /** States where a person has to look — always-visible rows; the rest folds. */
125
+ const DELIVERY_ATTENTION = new Set(['conflict', 'stuck', 'referenced-but-unoffered', 'local-only']);
126
+
127
+ /** The per-file doručenka map split into attention/fine [rel, state] lists,
128
+ * fail-closed like every other reader here. */
129
+ function readDelivery(raw) {
130
+ const attention = [];
131
+ const fine = [];
132
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
133
+ for (const [rel, state] of Object.entries(raw)) {
134
+ if (typeof rel !== 'string' || typeof state !== 'string') continue;
135
+ (DELIVERY_ATTENTION.has(state) ? attention : fine).push([rel, state]);
136
+ }
137
+ attention.sort((a, b) => a[0].localeCompare(b[0]));
138
+ fine.sort((a, b) => a[0].localeCompare(b[0]));
139
+ }
140
+ return { attention, fine };
141
+ }
142
+
143
+ /** TrashEntry.reason → plain words. */
144
+ const TRASH_REASON_WORD = {
145
+ conflict: 'conflict loser',
146
+ removed: 'removed by sync',
147
+ moved: 'moved elsewhere',
148
+ deleted: 'deleted',
149
+ migration: 'migration backup',
150
+ unknown: 'parked',
151
+ };
152
+
153
+ function fmtBytes(n) {
154
+ if (!Number.isFinite(n) || n < 0) return '0 B';
155
+ if (n < 1024) return `${n} B`;
156
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} kB`;
157
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
158
+ }
159
+
160
+ function fmtAge(at) {
161
+ if (!Number.isFinite(at) || at <= 0) return 'unknown age';
162
+ const days = Math.floor((Date.now() - at) / (24 * 60 * 60 * 1000));
163
+ if (days <= 0) return 'today';
164
+ if (days === 1) return 'yesterday';
165
+ if (days < 30) return `${days} d ago`;
166
+ const months = Math.floor(days / 30);
167
+ return `${months} mo ago`;
168
+ }
169
+
170
+ /** Display host of a hub url ('workspace' when unparseable). */
171
+ function hostOf(url) {
172
+ try {
173
+ return new URL(url).host;
174
+ } catch {
175
+ return 'workspace';
176
+ }
177
+ }
178
+
179
+ /** safeDetail's sanitation with a paragraph-sized cap — a consent notice is a
180
+ * full explanation by design and `MAX_DETAIL_LEN`'s 160 chars would cut it
181
+ * mid-sentence. Still bounded: the payload is read back off `_sync.json`. */
182
+ function safeNoticeText(raw) {
183
+ const s = String(raw ?? '')
184
+ .replace(/[\p{Cc}\p{Cf}]/gu, '')
185
+ .replace(/\s+/g, ' ')
186
+ .trim();
187
+ return s.length > 600 ? `${s.slice(0, 600)}…` : s;
188
+ }
189
+
190
+ /**
191
+ * Machine-local dismiss ack, keyed per (hub url) → list of dismissed notice
192
+ * ids — the `mdcc-whatsnew-seen` convention. Per-hub on purpose: dismissing
193
+ * "shared-doc is ON for hub A" must not silence the same notice when the
194
+ * project is re-linked to hub B; what leaves this machine changed again.
195
+ */
196
+ const NOTICE_ACK_KEY = 'maude-sync-notice-ack';
197
+ function readNoticeAcks(url) {
198
+ try {
199
+ const all = JSON.parse(localStorage.getItem(NOTICE_ACK_KEY) || '{}');
200
+ const ids = all[url];
201
+ return Array.isArray(ids) ? ids.filter((i) => typeof i === 'string') : [];
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+ function writeNoticeAck(url, id) {
207
+ try {
208
+ const all = JSON.parse(localStorage.getItem(NOTICE_ACK_KEY) || '{}');
209
+ const ids = Array.isArray(all[url]) ? all[url] : [];
210
+ if (!ids.includes(id)) ids.push(id);
211
+ all[url] = ids;
212
+ localStorage.setItem(NOTICE_ACK_KEY, JSON.stringify(all));
213
+ } catch {
214
+ /* private mode etc. — the notice simply reappears next boot */
215
+ }
216
+ }
217
+
101
218
  export default function SyncPanel({
102
219
  status, // the live `sync:status` payload (never null while mounted)
103
220
  project, // display name for the header sentence (hub-supplied → safeName'd)
@@ -161,6 +278,171 @@ export default function SyncPanel({
161
278
  const assetFailures = assets?.failures || [];
162
279
  const files = readFiles(status?.files);
163
280
 
281
+ const delivery = useMemo(() => readDelivery(status?.files?.delivery), [status?.files?.delivery]);
282
+
283
+ // Sync settings (Task 2) — fetched once on mount; `settings` stays null when
284
+ // no hub is linked, which is also the render gate for the whole section.
285
+ const [settings, setSettings] = useState(null);
286
+ const [settingsBusy, setSettingsBusy] = useState(false);
287
+ const [settingsNote, setSettingsNote] = useState('');
288
+ useEffect(() => {
289
+ let gone = false;
290
+ fetch('/_api/sync/settings')
291
+ .then((r) => (r.ok ? r.json() : null))
292
+ .then((j) => {
293
+ if (!gone && j && typeof j === 'object') setSettings(j.settings ?? null);
294
+ })
295
+ .catch(() => {
296
+ /* no server / old server — the section simply doesn't render */
297
+ });
298
+ return () => {
299
+ gone = true;
300
+ };
301
+ }, []);
302
+ const changeSetting = useCallback(async (patch) => {
303
+ setSettingsBusy(true);
304
+ setSettingsNote('');
305
+ let json = null;
306
+ try {
307
+ const res = await fetch('/_api/sync/settings', {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json' },
310
+ body: JSON.stringify(patch),
311
+ });
312
+ json = await res.json().catch(() => null);
313
+ } catch {
314
+ /* said below */
315
+ }
316
+ setSettingsBusy(false);
317
+ if (!json || json.ok !== true) {
318
+ setSettingsNote(safeDetail(json?.detail, 'The setting could not be saved.'));
319
+ return;
320
+ }
321
+ setSettings(json.settings);
322
+ // Saved is a fact; whether it took effect NOW depends on the supervisor —
323
+ // say which of the two happened rather than letting "saved" imply "live".
324
+ setSettingsNote(json.applied ? 'Saved — sync is restarting with the new setting.' : 'Saved — applies the next time sync restarts.');
325
+ }, []);
326
+
327
+ // Ownership (Task 2) — repo-owned vs hub-owned, previously CLI-only against
328
+ // DDR-177's own posture. Mutations go through an explicit two-step confirm:
329
+ // the confirm row IS the asking B11 found missing in non-TTY settleOwnership.
330
+ const [ownership, setOwnership] = useState(null);
331
+ const [ownershipConfirm, setOwnershipConfirm] = useState(null); // 'adopt' | 'detach' | null
332
+ const [ownershipBusy, setOwnershipBusy] = useState(false);
333
+ const [ownershipNote, setOwnershipNote] = useState('');
334
+ useEffect(() => {
335
+ let gone = false;
336
+ fetch('/_api/sync/ownership')
337
+ .then((r) => (r.ok ? r.json() : null))
338
+ .then((j) => {
339
+ if (!gone && j && typeof j.mode === 'string') setOwnership(j);
340
+ })
341
+ .catch(() => {
342
+ /* old server — the section doesn't render */
343
+ });
344
+ return () => {
345
+ gone = true;
346
+ };
347
+ }, []);
348
+ const changeOwnership = useCallback(async (action) => {
349
+ setOwnershipBusy(true);
350
+ setOwnershipNote('');
351
+ let json = null;
352
+ try {
353
+ const res = await fetch('/_api/sync/ownership', {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ action }),
357
+ });
358
+ json = await res.json().catch(() => null);
359
+ } catch {
360
+ /* said below */
361
+ }
362
+ setOwnershipBusy(false);
363
+ setOwnershipConfirm(null);
364
+ if (!json || json.ok !== true) {
365
+ setOwnershipNote(safeDetail(json?.detail, 'The ownership change did not complete.'));
366
+ return;
367
+ }
368
+ setOwnershipNote(
369
+ action === 'adopt'
370
+ ? `Done — .design/ is workspace-owned now. ${json.untracked ?? 0} file(s) untracked (still on disk, staged as deletions — commit when ready).`
371
+ : 'Done — unlinked and repo-owned again. Commit .design/ when you are ready.'
372
+ );
373
+ // Re-read rather than guess — the server is the source for mode.
374
+ try {
375
+ const r = await fetch('/_api/sync/ownership');
376
+ const j = r.ok ? await r.json() : null;
377
+ if (j && typeof j.mode === 'string') setOwnership(j);
378
+ } catch {
379
+ /* keep the stale mode; the note already said what happened */
380
+ }
381
+ }, []);
382
+
383
+ // Trash (Task 3, F-6) — quarantine-not-delete is only safe if a person can
384
+ // FIND the quarantine. Until this section, the product's copy pointed at a
385
+ // hidden gitignored folder.
386
+ const [trash, setTrash] = useState(null); // { entries, total, bytes } | null
387
+ const [trashBusy, setTrashBusy] = useState(false);
388
+ const [trashNote, setTrashNote] = useState('');
389
+ const [pruneConfirm, setPruneConfirm] = useState(false);
390
+ const refreshTrash = useCallback(async () => {
391
+ try {
392
+ const r = await fetch('/_api/sync/trash');
393
+ const j = r.ok ? await r.json() : null;
394
+ if (j && Array.isArray(j.entries)) setTrash(j);
395
+ } catch {
396
+ /* old server — the section doesn't render */
397
+ }
398
+ }, []);
399
+ useEffect(() => {
400
+ refreshTrash();
401
+ }, [refreshTrash]);
402
+ const trashAction = useCallback(
403
+ async (body, doneNote) => {
404
+ setTrashBusy(true);
405
+ setTrashNote('');
406
+ let json = null;
407
+ try {
408
+ const res = await fetch('/_api/sync/trash', {
409
+ method: 'POST',
410
+ headers: { 'Content-Type': 'application/json' },
411
+ body: JSON.stringify(body),
412
+ });
413
+ json = await res.json().catch(() => null);
414
+ } catch {
415
+ /* said below */
416
+ }
417
+ setTrashBusy(false);
418
+ setPruneConfirm(false);
419
+ if (!json || json.ok !== true) {
420
+ setTrashNote(safeDetail(json?.detail, 'That did not complete.'));
421
+ } else {
422
+ setTrashNote(doneNote(json));
423
+ }
424
+ refreshTrash();
425
+ },
426
+ [refreshTrash]
427
+ );
428
+
429
+ // Consent notices minus this machine's dismissals. `ackTick` only forces the
430
+ // re-read after a dismiss — the acks themselves live in localStorage.
431
+ const hubUrl = typeof status?.url === 'string' ? status.url : '';
432
+ const [ackTick, setAckTick] = useState(0);
433
+ const notices = useMemo(() => {
434
+ const acked = readNoticeAcks(hubUrl);
435
+ return readNotices(status?.notices).filter((n) => !acked.includes(n.id));
436
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- ackTick invalidates the localStorage read
437
+ }, [status?.notices, hubUrl, ackTick]);
438
+ const dismissNotice = useCallback(
439
+ (id) => {
440
+ writeNoticeAck(hubUrl, id);
441
+ setAckTick((t) => t + 1);
442
+ },
443
+ [hubUrl]
444
+ );
445
+
164
446
  const { attention, byGroup } = useMemo(() => {
165
447
  const attention = items.filter((i) => i.state === 'auth-rejected');
166
448
  const rest = items.filter((i) => i.state !== 'auth-rejected');
@@ -249,6 +531,29 @@ export default function SyncPanel({
249
531
  </div>
250
532
 
251
533
  <div className="sp-body">
534
+ {/* Consent-class notices come FIRST: they say what leaves this machine
535
+ (shared-doc buffer, TSX bodies) and used to live only in a
536
+ console.warn a desktop user never sees. Dismiss is per (notice,
537
+ hub) on this machine — a new hub resurfaces them by design. */}
538
+ {notices.length > 0 && (
539
+ <section aria-label="Notices" data-testid="sync-notices">
540
+ {notices.map((n) => (
541
+ <div key={n.id} className="sp-notice" data-testid={`sync-notice-${n.id}`}>
542
+ <p className="sp-notice-text">{safeNoticeText(n.text)}</p>
543
+ <button
544
+ type="button"
545
+ className="sp-notice-dismiss"
546
+ data-testid={`sync-notice-dismiss-${n.id}`}
547
+ onClick={() => dismissNotice(n.id)}
548
+ title="Got it — don't show this again for this hub on this machine"
549
+ >
550
+ Got it
551
+ </button>
552
+ </div>
553
+ ))}
554
+ </section>
555
+ )}
556
+
252
557
  {items.length === 0 && !assets && (
253
558
  <div className="gp-empty">
254
559
  <p>No per-file detail yet — it arrives with the first sync report after this panel shipped. If this persists, restart Maude.</p>
@@ -351,7 +656,296 @@ export default function SyncPanel({
351
656
  {files.conflicts > 0 && (
352
657
  <div className="sp-assets-retry" data-testid="sync-files-conflicts">
353
658
  {files.conflicts} conflict{files.conflicts === 1 ? '' : 's'} — the older copies are
354
- kept in _trash/.
659
+ kept in Trash, below.
660
+ </div>
661
+ )}
662
+ {/*
663
+ A HELD BREAKER, said out loud. These used to exist only as a
664
+ console.warn, which on a machine whose user never opens a
665
+ terminal is the same as not existing — while the release leaned
666
+ on them as its reason for shipping deletion without a soak.
667
+ */}
668
+ {Array.isArray(files.held) &&
669
+ files.held.map((h) => (
670
+ <div key={h.kind} className="sp-assets-retry" data-testid={`sync-held-${h.kind}`}>
671
+ <strong>sync paused — nothing was removed.</strong> {h.detail}
672
+ {h.paths?.length > 0 && (
673
+ <details className="sp-held-paths">
674
+ <summary>
675
+ {h.count} file{h.count === 1 ? '' : 's'}
676
+ </summary>
677
+ <ul>
678
+ {h.paths.slice(0, 50).map((rel) => (
679
+ <li key={rel}>{rel}</li>
680
+ ))}
681
+ {h.paths.length > 50 && <li>…and {h.paths.length - 50} more</li>}
682
+ </ul>
683
+ </details>
684
+ )}
685
+ </div>
686
+ ))}
687
+ {/* THE DORUČENKA, per file (DDR-226 §7). The counts above say how
688
+ many are fine; they cannot point at the one that is not — which
689
+ is the question three days of dogfood kept asking. Rows needing
690
+ a person come first and are always visible; the delivered rest
691
+ stays behind a fold so a healthy project reads as one line. */}
692
+ {delivery.attention.length > 0 && (
693
+ <ul className="sp-list" data-testid="sync-delivery-attention">
694
+ {delivery.attention.slice(0, 50).map(([rel, state]) => (
695
+ <li className="sp-row is-auth-rejected" key={rel}>
696
+ <span className="sp-row-dot" aria-hidden="true" />
697
+ <span className="sp-row-name" title={safeName(rel, '(unnamed)')}>
698
+ {safeName(rel, '(unnamed)')}
699
+ </span>
700
+ <span className="sp-row-state">{DELIVERY_WORD[state] || state}</span>
701
+ </li>
702
+ ))}
703
+ {delivery.attention.length > 50 && (
704
+ <li className="sp-truncated">…and {delivery.attention.length - 50} more</li>
705
+ )}
706
+ </ul>
707
+ )}
708
+ {delivery.fine.length > 0 && (
709
+ <details className="sp-held-paths" data-testid="sync-delivery-fine">
710
+ <summary>
711
+ {delivery.fine.length} file{delivery.fine.length === 1 ? '' : 's'} delivered
712
+ </summary>
713
+ <ul>
714
+ {delivery.fine.slice(0, 200).map(([rel, state]) => (
715
+ <li key={rel}>
716
+ {safeName(rel, '(unnamed)')} — {DELIVERY_WORD[state] || state}
717
+ </li>
718
+ ))}
719
+ {delivery.fine.length > 200 && <li>…and {delivery.fine.length - 200} more</li>}
720
+ </ul>
721
+ </details>
722
+ )}
723
+ </section>
724
+ )}
725
+
726
+ {/* Trash (Task 3, F-6) — everything sync parked instead of deleting,
727
+ with a way back. Rendered only when something is actually parked. */}
728
+ {trash && trash.total > 0 && (
729
+ <section aria-label="Trash" data-testid="sync-trash">
730
+ <div className="gp-sect-label">
731
+ trash <span className="gp-group-count">{trash.total}</span>
732
+ </div>
733
+ <div className="sp-assets-line">
734
+ {trash.total} file{trash.total === 1 ? '' : 's'} kept instead of deleted (
735
+ {fmtBytes(trash.bytes)}) — replaced copies, conflict losers and remote deletions
736
+ land here, in .design/_trash/.
737
+ </div>
738
+ <ul className="sp-list" data-testid="sync-trash-list">
739
+ {trash.entries.slice(0, 50).map((e) => (
740
+ <li className="sp-row" key={e.trashRel}>
741
+ <span className="sp-row-name" title={safeName(e.trashRel, '(unnamed)')}>
742
+ {safeName(e.sourceRel ?? e.trashRel.replace(/^_trash\//, ''), '(unnamed)')}
743
+ </span>
744
+ <span className="sp-row-state">
745
+ {TRASH_REASON_WORD[e.reason] || e.reason} · {fmtAge(e.at)}
746
+ </span>
747
+ {e.sourceRel && (
748
+ <button
749
+ type="button"
750
+ className="sp-assets-cancel"
751
+ data-testid={`sync-trash-restore-${e.sourceRel}`}
752
+ disabled={trashBusy}
753
+ onClick={() =>
754
+ trashAction({ action: 'restore', trashRel: e.trashRel }, (j) =>
755
+ j.restoredTo ? `Restored to ${j.restoredTo}.` : 'Restored.'
756
+ )
757
+ }
758
+ >
759
+ Restore
760
+ </button>
761
+ )}
762
+ </li>
763
+ ))}
764
+ {trash.total > 50 && (
765
+ <li className="sp-truncated">…and {trash.total - 50} more in .design/_trash/</li>
766
+ )}
767
+ </ul>
768
+ {!pruneConfirm ? (
769
+ <div className="sp-assets-line">
770
+ <button
771
+ type="button"
772
+ className="sp-assets-cancel"
773
+ data-testid="sync-trash-prune"
774
+ disabled={trashBusy}
775
+ onClick={() => setPruneConfirm(true)}
776
+ >
777
+ Remove copies older than 30 days…
778
+ </button>
779
+ </div>
780
+ ) : (
781
+ <div className="sp-assets-retry" data-testid="sync-trash-prune-confirm">
782
+ Permanently delete every parked copy older than 30 days? This cannot be undone.{' '}
783
+ <button
784
+ type="button"
785
+ className="sp-assets-cancel"
786
+ data-testid="sync-trash-prune-yes"
787
+ disabled={trashBusy}
788
+ onClick={() =>
789
+ trashAction(
790
+ { action: 'prune', olderThanDays: 30 },
791
+ (j) => `Removed ${j.pruned} file${j.pruned === 1 ? '' : 's'} (${fmtBytes(j.bytes)}); ${j.kept} kept.`
792
+ )
793
+ }
794
+ >
795
+ {trashBusy ? 'Removing…' : 'Delete them'}
796
+ </button>{' '}
797
+ <button
798
+ type="button"
799
+ className="sp-assets-cancel"
800
+ disabled={trashBusy}
801
+ onClick={() => setPruneConfirm(false)}
802
+ >
803
+ Cancel
804
+ </button>
805
+ </div>
806
+ )}
807
+ {trashNote && (
808
+ <div className="sp-resync-note" role="status" aria-live="polite">
809
+ {trashNote}
810
+ </div>
811
+ )}
812
+ </section>
813
+ )}
814
+
815
+ {/* Settings (feature-before-first-external-users Task 2) — the three
816
+ toggles every breaker remediation used to hand the user as "edit
817
+ linkedHub.* JSON". Rendered only when a hub is linked (the route
818
+ answers settings: null otherwise — dead controls teach nothing). */}
819
+ {settings && (
820
+ <section aria-label="Sync settings" data-testid="sync-settings">
821
+ <div className="gp-sect-label">settings</div>
822
+ <label className="sp-setting" data-testid="sync-setting-syncFiles">
823
+ <input
824
+ type="checkbox"
825
+ checked={settings.syncFiles}
826
+ disabled={settingsBusy}
827
+ onChange={(e) => changeSetting({ syncFiles: e.target.checked })}
828
+ />
829
+ <span>
830
+ Sync project files
831
+ <small>The whole design folder mirrors both ways, not just canvases.</small>
832
+ </span>
833
+ </label>
834
+ <label className="sp-setting" data-testid="sync-setting-propagateDeletes">
835
+ <input
836
+ type="checkbox"
837
+ checked={settings.propagateDeletes}
838
+ disabled={settingsBusy}
839
+ onChange={(e) => changeSetting({ propagateDeletes: e.target.checked })}
840
+ />
841
+ <span>
842
+ Propagate deletions
843
+ <small>
844
+ Removing a file here removes it everywhere; replaced copies are kept in Trash.
845
+ </small>
846
+ </span>
847
+ </label>
848
+ <label className="sp-setting sp-setting-select" data-testid="sync-setting-firstAnchor">
849
+ <span>
850
+ First-link conflicts
851
+ <small>When both sides have content the first time a project links.</small>
852
+ </span>
853
+ <select
854
+ value={settings.resolveFirstAnchor}
855
+ disabled={settingsBusy}
856
+ onChange={(e) => changeSetting({ resolveFirstAnchor: e.target.value })}
857
+ >
858
+ <option value="ask">Keep asking</option>
859
+ <option value="keep-local">Keep this machine's</option>
860
+ <option value="keep-cloud">Keep the workspace's</option>
861
+ </select>
862
+ </label>
863
+ {settingsNote && (
864
+ <div className="sp-resync-note" role="status" aria-live="polite">
865
+ {settingsNote}
866
+ </div>
867
+ )}
868
+ </section>
869
+ )}
870
+
871
+ {/* Ownership (Task 2) — who owns .design/: this repo's git, or the
872
+ linked workspace. CLI parity for `maude design adopt` / `detach`,
873
+ behind an explicit confirm. Hidden on a cell (a member's browser
874
+ must not re-own the operator's checkout) and without git. */}
875
+ {ownership && !cloud && ownership.git && (
876
+ <section aria-label="Ownership" data-testid="sync-ownership">
877
+ <div className="gp-sect-label">ownership</div>
878
+ <div className="sp-assets-line" data-testid="sync-ownership-mode">
879
+ {ownership.mode === 'hub-owned' &&
880
+ 'Workspace-owned — .design/ is gitignored and mirrored by the workspace.'}
881
+ {ownership.mode === 'repo-owned' &&
882
+ 'Repo-owned — .design/ is committed with this repository.'}
883
+ {ownership.mode === 'hybrid' &&
884
+ `Two owners — git commits .design/ (${ownership.trackedCount} file${ownership.trackedCount === 1 ? '' : 's'}) AND a workspace mirrors it. A git pull and a sync pass can each undo the other.`}
885
+ </div>
886
+ {ownershipConfirm === null && ownership.linked && ownership.mode !== 'hub-owned' && (
887
+ <div className="sp-assets-line">
888
+ <button
889
+ type="button"
890
+ className="sp-assets-cancel"
891
+ data-testid="sync-ownership-adopt"
892
+ disabled={ownershipBusy}
893
+ onClick={() => setOwnershipConfirm('adopt')}
894
+ >
895
+ Hand .design/ to the workspace…
896
+ </button>
897
+ </div>
898
+ )}
899
+ {ownershipConfirm === null && ownership.mode === 'hub-owned' && (
900
+ <div className="sp-assets-line">
901
+ <button
902
+ type="button"
903
+ className="sp-assets-cancel"
904
+ data-testid="sync-ownership-detach"
905
+ disabled={ownershipBusy}
906
+ onClick={() => setOwnershipConfirm('detach')}
907
+ >
908
+ Take .design/ back into this repo…
909
+ </button>
910
+ </div>
911
+ )}
912
+ {ownershipConfirm && (
913
+ <div className="sp-assets-retry" data-testid="sync-ownership-confirm">
914
+ {ownershipConfirm === 'adopt' ? (
915
+ <>
916
+ Stop committing .design/ and let the workspace mirror it. Nothing is deleted —
917
+ every file stays on disk; git stops tracking them (staged as deletions, commit
918
+ when ready).
919
+ </>
920
+ ) : (
921
+ <>
922
+ Disconnect from the workspace ({hostOf(ownership.hubUrl)}) and take .design/
923
+ back into git. Every file is already on disk in full; commit the folder when
924
+ you are ready.
925
+ </>
926
+ )}{' '}
927
+ <button
928
+ type="button"
929
+ className="sp-assets-cancel"
930
+ data-testid="sync-ownership-confirm-yes"
931
+ disabled={ownershipBusy}
932
+ onClick={() => changeOwnership(ownershipConfirm)}
933
+ >
934
+ {ownershipBusy ? 'Working…' : 'Do it'}
935
+ </button>{' '}
936
+ <button
937
+ type="button"
938
+ className="sp-assets-cancel"
939
+ disabled={ownershipBusy}
940
+ onClick={() => setOwnershipConfirm(null)}
941
+ >
942
+ Cancel
943
+ </button>
944
+ </div>
945
+ )}
946
+ {ownershipNote && (
947
+ <div className="sp-resync-note" role="status" aria-live="polite">
948
+ {ownershipNote}
355
949
  </div>
356
950
  )}
357
951
  </section>
@@ -614,6 +614,10 @@
614
614
  .st-fmt { display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-start; padding: var(--space-3) var(--space-4); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); cursor: pointer; color: var(--fg-1); appearance: none; text-align: left; }
615
615
  .st-fmt:hover { border-color: var(--border-strong); }
616
616
  .st-fmt.is-on { border-color: var(--accent); background: var(--accent-tint); color: var(--fg-0); box-shadow: 0 0 0 2px var(--accent-tint); }
617
+ .st-fmt:disabled { opacity: 0.45; cursor: not-allowed; }
618
+ .st-fmt:disabled:hover { border-color: var(--border-default); }
619
+ /* feature-cloud-export-render-workers — the "why is this disabled" line under the format grid. */
620
+ .st-dialog-note { margin-top: var(--space-3); font-size: var(--type-xs); line-height: 1.5; color: var(--fg-2); background: var(--bg-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); }
617
621
  .st-fmt-name { font-family: var(--font-mono); font-size: var(--type-sm); font-weight: 600; }
618
622
  .st-fmt-sub { font-size: var(--type-xs); color: var(--fg-2); }
619
623
  .st-dialog-row { display: flex; align-items: center; gap: var(--space-3); }
@@ -2692,6 +2696,12 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2692
2696
  .sp-assets-line { padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); }
2693
2697
  .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
2698
  .sp-assets-retry { padding: var(--space-1) var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
2699
+ /* A held breaker is not an error and not routine — it is waiting for a person. */
2700
+ .sp-assets-retry strong { color: var(--fg-0); }
2701
+ .sp-held-paths { margin-top: var(--space-1); }
2702
+ .sp-held-paths summary { cursor: pointer; color: var(--fg-1); }
2703
+ .sp-held-paths ul { margin: var(--space-1) 0 0; padding-left: var(--space-4); max-height: 12rem; overflow-y: auto; }
2704
+ .sp-held-paths li { font-family: var(--font-mono, monospace); word-break: break-all; }
2695
2705
  /* feature-sync-resync-and-out-of-process-sweep — Resync (header) re-runs the
2696
2706
  * whole sync; Cancel (assets line) stops only the upload sweep. */
2697
2707
  .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); }
@@ -2699,6 +2709,34 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2699
2709
  .sp-resync:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2700
2710
  .sp-resync:disabled { color: var(--fg-3); cursor: default; }
2701
2711
  .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); }
2712
+ /* Consent-class notices (feature-before-first-external-users Task 1) — a warm
2713
+ card at the top of the panel body: says what leaves this machine, dismissed
2714
+ per (notice, hub) on this machine. */
2715
+ .sp-notice { margin: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-3); border: 1px solid var(--status-warn); border-radius: var(--radius-md); background: color-mix(in oklab, var(--status-warn) 8%, transparent); }
2716
+ .sp-notice-text { margin: 0 0 var(--space-1); font-size: var(--type-xs); color: var(--fg-1); line-height: var(--lh-base); }
2717
+ .sp-notice-dismiss { appearance: none; 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; }
2718
+ .sp-notice-dismiss:hover { color: var(--fg-0); }
2719
+ .sp-notice-dismiss:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2720
+
2721
+ /* First-upgrade sync consent (feature-before-first-external-users Task 2) —
2722
+ rides the shared .st-scrim/.st-dialog shell. */
2723
+ .st-consent .st-dialog-bd { font-size: var(--type-sm); color: var(--fg-1); line-height: var(--lh-base); }
2724
+ .st-consent .st-dialog-bd p { margin: 0 0 var(--space-2); }
2725
+ .st-consent-list { margin: 0 0 var(--space-2); padding-left: var(--space-4); display: flex; flex-direction: column; gap: var(--space-1); }
2726
+ .st-consent-fine { font-size: var(--type-xs); color: var(--fg-2); }
2727
+ .st-consent-note { font-size: var(--type-xs); color: var(--status-error); }
2728
+ .st-consent .st-dialog-ft { display: flex; justify-content: flex-end; gap: var(--space-2); }
2729
+
2730
+ /* Sync settings (feature-before-first-external-users Task 2) — the three
2731
+ toggles that used to mean "edit linkedHub.* JSON by hand". */
2732
+ .sp-setting { display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); cursor: pointer; }
2733
+ .sp-setting input[type='checkbox'] { margin-top: 2px; accent-color: var(--accent); flex: none; }
2734
+ .sp-setting > span { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
2735
+ .sp-setting small { font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
2736
+ .sp-setting-select { align-items: center; justify-content: space-between; }
2737
+ .sp-setting-select select { font-family: var(--font-body); font-size: var(--type-xs); color: var(--fg-1); background: var(--bg-2); border: 1px solid var(--stroke-1); border-radius: var(--radius-sm); padding: 2px var(--space-1); flex: none; }
2738
+ .sp-setting-select select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2739
+
2702
2740
  .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
2741
  .sp-assets-cancel:hover { color: var(--status-error); }
2704
2742
  .sp-assets-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }