@1agh/maude 0.59.0 → 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 (30) hide show
  1. package/apps/studio/bin/_import-figma.mjs +314 -30
  2. package/apps/studio/client/panels/SyncPanel.jsx +93 -2
  3. package/apps/studio/client/styles/3-shell-maude.css +10 -0
  4. package/apps/studio/context.ts +4 -0
  5. package/apps/studio/dist/client.bundle.js +547 -547
  6. package/apps/studio/dist/styles.css +1 -1
  7. package/apps/studio/figma/fig-decode.test.ts +100 -14
  8. package/apps/studio/figma/fig-decode.ts +247 -25
  9. package/apps/studio/figma/fig-differential.test.ts +182 -0
  10. package/apps/studio/figma/fig-translator.test.ts +192 -0
  11. package/apps/studio/figma/fig-vector.test.ts +113 -0
  12. package/apps/studio/figma/fig-vector.ts +145 -0
  13. package/apps/studio/figma/sanitize.ts +7 -0
  14. package/apps/studio/figma/to-artboard.ts +41 -1
  15. package/apps/studio/http.ts +47 -0
  16. package/apps/studio/sync/asset-push-worker.ts +84 -0
  17. package/apps/studio/sync/asset-push.ts +101 -7
  18. package/apps/studio/sync/asset-sweep.ts +262 -0
  19. package/apps/studio/sync/index.ts +29 -5
  20. package/apps/studio/sync/presentation.ts +21 -0
  21. package/apps/studio/sync/supervisor.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
  23. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  24. package/apps/studio/test/sync-asset-push.test.ts +157 -8
  25. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  26. package/apps/studio/test/sync-panel-surface.test.ts +34 -1
  27. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  28. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  29. package/apps/studio/whats-new.json +16 -0
  30. package/package.json +8 -8
@@ -27,6 +27,7 @@
27
27
  // Exit: 0 ok · 2 usage · 3 validation/mapping reject · 4 fetch/parse error ·
28
28
  // 5 not configured (no token) · 6 write/containment error · 1 other.
29
29
 
30
+ import { createHash } from 'node:crypto';
30
31
  import {
31
32
  existsSync,
32
33
  mkdirSync,
@@ -59,6 +60,9 @@ import {
59
60
  } from '../figma/client.ts';
60
61
  import { CodegenError, CodegenSession } from '../figma/codegen-client.ts';
61
62
  import { commentsToStrokes, indexNodes } from '../figma/comments-to-strokes.ts';
63
+ import { decodeFigArchive, FigDecodeError } from '../figma/fig-decode.ts';
64
+ import { artToSvg } from '../figma/fig-vector.ts';
65
+ import { readFigZip } from '../figma/fig-zip.ts';
62
66
  import { attrValue, ImportReport } from '../figma/sanitize.ts';
63
67
  import { JsxTooLargeError, toArtboard, toCanvas } from '../figma/to-artboard.ts';
64
68
  import { toRenderCanvas } from '../figma/to-render.ts';
@@ -212,6 +216,158 @@ export function formatSummary(report, extra = {}) {
212
216
  * plus `sanitizeAnnotationSvg`, so this verb can never persist a shape the
213
217
  * canvas would reject (D6's annotation row).
214
218
  */
219
+ /**
220
+ * Read and decode a local `.fig` / `.jam` (DDR-221). Offline end to end: no
221
+ * network, no token, no SSRF surface.
222
+ *
223
+ * PROVENANCE. A local archive does not carry the REST file key — `originFileKey`
224
+ * is an opaque internal `lk-` link key, and `meta.json`'s `file_name` is the
225
+ * Figma document TITLE, which DDR-216 D7 forbids recording. So the key is
226
+ * CONTENT-ADDRESSED from the payload: stable across re-imports of the same
227
+ * export, reveals nothing, and satisfies the same charset rule the URL parser
228
+ * enforces. Pass `--file-key` when you know the real one and want the canvas to
229
+ * point back at the Figma document.
230
+ */
231
+ /**
232
+ * Resolve pending assets from the archive itself — the offline half of DDR-221
233
+ * D6. An IMAGE fill is present as `images/<imageRef>` and needs no network; a
234
+ * VECTOR cluster is a server-side render that a local export simply does not
235
+ * contain, so it is reported as unavailable rather than attempted.
236
+ *
237
+ * Bytes go through the SAME content-addressed promote as every other ingested
238
+ * asset, so the on-disk name is a hash we computed and the archive's own entry
239
+ * name never reaches a path (D6's lookup-key rule).
240
+ */
241
+ /**
242
+ * Compose one vector cluster into a standalone SVG from the archive's OWN
243
+ * geometry — no Figma render, no network.
244
+ *
245
+ * The cluster node and every descendant that carries a path contribute one
246
+ * `<path>`, translated into the cluster's coordinate space via the absolute
247
+ * boxes the decoder already composed. Returns null when nothing in the subtree
248
+ * has geometry, so the caller can report the absence honestly.
249
+ */
250
+ function buildClusterSvg(local, nodeId) {
251
+ let cluster = null;
252
+ walkNodes(local.document.root, (n) => {
253
+ if (n.id === nodeId) cluster = n;
254
+ });
255
+ if (!cluster?.absoluteBoundingBox) return null;
256
+
257
+ const origin = cluster.absoluteBoundingBox;
258
+ const paths = [];
259
+ const collect = (node) => {
260
+ const art = local.vectors.get(node.id);
261
+ const box = node.absoluteBoundingBox;
262
+ if (art && box) {
263
+ paths.push({ ...art, x: box.x - origin.x, y: box.y - origin.y });
264
+ }
265
+ for (const kid of node.children ?? []) collect(kid);
266
+ };
267
+ collect(cluster);
268
+ if (paths.length === 0) return null;
269
+
270
+ return artToSvg({ width: origin.width, height: origin.height, paths });
271
+ }
272
+
273
+ async function resolveArchiveAssets(
274
+ local,
275
+ pendingExports,
276
+ report,
277
+ { root, designRootRel, stagingDir }
278
+ ) {
279
+ const deps = makeAssetDeps({ root, designRootRel, stagingDir });
280
+ const rewrites = new Map();
281
+ const resolved = [];
282
+ let totalBytes = 0;
283
+
284
+ for (const p of pendingExports) {
285
+ if (!p.imageRef) {
286
+ // NOT unavailable after all: a `.fig` carries the path geometry itself
287
+ // (fillGeometry -> commandsBlob -> blobs[]), so the icon is rebuilt here
288
+ // rather than requested from Figma. Corrects the claim DDR-221 A9/A10
289
+ // shipped. It still goes through the DDR-167 SVG lane on promote — this
290
+ // is a third party's file, and we authored the string from their bytes.
291
+ const svg = buildClusterSvg(local, p.nodeId);
292
+ if (!svg) {
293
+ report.add(
294
+ p.nodeId,
295
+ 'VECTOR',
296
+ 'asset-unavailable-offline',
297
+ 'no path geometry in the archive for this node'
298
+ );
299
+ continue;
300
+ }
301
+ const stagedSvgPath = deps.stagingPath(p.nodeId, 'svg');
302
+ writeFileSync(stagedSvgPath, svg, 'utf8');
303
+ try {
304
+ const { ref } = await deps.promote(stagedSvgPath, 'svg');
305
+ rewrites.set(p.placeholder, ref);
306
+ resolved.push({ nodeId: p.nodeId, ref });
307
+ totalBytes += Buffer.byteLength(svg);
308
+ } catch (err) {
309
+ report.add(p.nodeId, 'VECTOR', 'asset-skipped', `promote failed: ${err.code ?? 'error'}`);
310
+ }
311
+ continue;
312
+ }
313
+ // Charset-checked before it is used as a lookup key, so a crafted ref can
314
+ // never be read as a path even though we only ever compare it to entries.
315
+ if (!/^[0-9a-f]{8,128}$/.test(p.imageRef)) {
316
+ report.add(p.nodeId, 'IMAGE', 'asset-skipped', 'malformed image reference');
317
+ continue;
318
+ }
319
+ const bytes = local.zip.get(`images/${p.imageRef}`);
320
+ if (!bytes) {
321
+ report.add(p.nodeId, 'IMAGE', 'asset-skipped', 'not present in the archive');
322
+ continue;
323
+ }
324
+ const staged = deps.stagingPath(p.nodeId, 'png');
325
+ writeFileSync(staged, bytes);
326
+ try {
327
+ const { ref } = await deps.promote(staged, 'png');
328
+ rewrites.set(p.placeholder, ref);
329
+ resolved.push({ nodeId: p.nodeId, ref });
330
+ totalBytes += bytes.length;
331
+ } catch (err) {
332
+ report.add(p.nodeId, 'IMAGE', 'asset-skipped', `promote failed: ${err.code ?? 'error'}`);
333
+ }
334
+ }
335
+ return { resolved, rewrites, totalBytes };
336
+ }
337
+
338
+ export function decodeLocalFig(path, fileKeyOverride = null) {
339
+ let bytes;
340
+ try {
341
+ bytes = readFileSync(path);
342
+ } catch (err) {
343
+ throw new ImportFigmaError(4, `cannot read ${path}: ${err.code ?? err.message}`);
344
+ }
345
+ if (fileKeyOverride !== null && !/^[A-Za-z0-9]{10,64}$/.test(fileKeyOverride)) {
346
+ throw new ImportFigmaError(2, 'invalid --file-key (want [A-Za-z0-9]{10,64})');
347
+ }
348
+ const fileKey =
349
+ fileKeyOverride ?? `fig${createHash('sha256').update(bytes).digest('hex').slice(0, 29)}`;
350
+ try {
351
+ const { document, report, vectors } = decodeFigArchive(new Uint8Array(bytes), { fileKey });
352
+ // The archive stays open: image fills resolve out of `images/<imageRef>`
353
+ // rather than over `/v1/images` (DDR-221 D6 — no expiry, no rate limit, no
354
+ // SSRF surface, because there is no request).
355
+ return {
356
+ document,
357
+ report,
358
+ // Path geometry by node id — what lets a vector cluster be rebuilt here
359
+ // instead of requested from Figma.
360
+ vectors,
361
+ fileKey,
362
+ surface: document.surface,
363
+ zip: readFigZip(new Uint8Array(bytes)),
364
+ };
365
+ } catch (err) {
366
+ if (err instanceof FigDecodeError) throw new ImportFigmaError(4, err.message);
367
+ throw err;
368
+ }
369
+ }
370
+
215
371
  export async function importBoard({
216
372
  url,
217
373
  root,
@@ -219,13 +375,18 @@ export async function importBoard({
219
375
  slug,
220
376
  dryRun = false,
221
377
  confirmLarge = false,
378
+ local = null,
222
379
  }) {
223
- const target = parseFigmaTarget(url, 'board');
224
- const doc = await fetchDocument({
225
- fileKey: target.fileKey,
226
- surface: 'board',
227
- ...(target.nodeId ? { nodeId: target.nodeId } : {}),
228
- });
380
+ // `local` is a already-decoded `.fig` (the offline door, DDR-221). Same
381
+ // normalized tree, so everything below is shared verbatim with the REST path.
382
+ const target = local ? { fileKey: local.fileKey, nodeId: null } : parseFigmaTarget(url, 'board');
383
+ const doc =
384
+ local?.document ??
385
+ (await fetchDocument({
386
+ fileKey: target.fileKey,
387
+ surface: 'board',
388
+ ...(target.nodeId ? { nodeId: target.nodeId } : {}),
389
+ }));
229
390
  const { strokes, report, pendingImages, origin } = toStrokes(doc, { confirmLarge });
230
391
 
231
392
  const outSlug = slug ?? `figjam-${target.fileKey.slice(0, 8).toLowerCase()}`;
@@ -958,10 +1119,19 @@ function selectFrames(doc, nodeId) {
958
1119
  // An explicit node-id means "this subtree" — the root IS the selection.
959
1120
  if (nodeId && doc.root.id === nodeId) return [doc.root];
960
1121
  if (wanted.has(doc.root.type)) return [doc.root];
1122
+ // A DOCUMENT root means the caller handed us a whole file rather than a page
1123
+ // or a frame — always the case for the local `.fig` door, which has no
1124
+ // node-id to scope with. Descend to the first CANVAS. Previously this fell
1125
+ // through to "no FRAME or COMPONENT found", so this turns a hard error into
1126
+ // the obvious behaviour; the node/depth caps still apply either way.
1127
+ const root =
1128
+ doc.root.type === 'DOCUMENT'
1129
+ ? ((doc.root.children ?? []).find((n) => n.type === 'CANVAS') ?? doc.root)
1130
+ : doc.root;
961
1131
  // Otherwise take the page's top-level frames. Deliberately NOT a deep walk:
962
1132
  // whole-file import is not a viable default (DDR-216 D5) and a nested frame
963
1133
  // is part of its parent's composition, not a canvas of its own.
964
- return (doc.root.children ?? []).filter((n) => wanted.has(n.type) && n.visible);
1134
+ return (root.children ?? []).filter((n) => wanted.has(n.type) && n.visible);
965
1135
  }
966
1136
 
967
1137
  /**
@@ -978,13 +1148,16 @@ export async function importFrames({
978
1148
  slug,
979
1149
  dryRun = false,
980
1150
  kind = 'digital',
1151
+ local = null,
981
1152
  }) {
982
- const target = parseFigmaTarget(url, 'design');
983
- const doc = await fetchDocument({
984
- fileKey: target.fileKey,
985
- surface: 'design',
986
- ...(target.nodeId ? { nodeId: target.nodeId } : {}),
987
- });
1153
+ const target = local ? { fileKey: local.fileKey, nodeId: null } : parseFigmaTarget(url, 'design');
1154
+ const doc =
1155
+ local?.document ??
1156
+ (await fetchDocument({
1157
+ fileKey: target.fileKey,
1158
+ surface: 'design',
1159
+ ...(target.nodeId ? { nodeId: target.nodeId } : {}),
1160
+ }));
988
1161
 
989
1162
  const frames = selectFrames(doc, target.nodeId);
990
1163
  if (frames.length === 0) {
@@ -1021,21 +1194,33 @@ export async function importFrames({
1021
1194
  // placeholders the emitter left behind. A placeholder that never resolves
1022
1195
  // is deliberately LEFT IN PLACE — a visibly broken image beats a silently
1023
1196
  // missing element, and the summary already names the node.
1024
- const assets = await resolveAssets(
1025
- target.fileKey,
1026
- result.pendingExports.map((p) => ({
1027
- nodeId: p.nodeId,
1028
- format: p.format,
1029
- placeholder: p.placeholder,
1030
- })),
1031
- makeAssetDeps({ root, designRootRel, stagingDir: staging }),
1032
- result.report,
1033
- // ONE budget for the WHOLE import (review F4). The caps are meaningless
1034
- // as per-call locals: `importFrames` loops over frames, so 60 frames ×
1035
- // 200 assets × 2 MB reconstructs the multi-GB Syncthing shape D5 says
1036
- // it closed — and each asset costs a browser launch for the SVG canary.
1037
- budget
1038
- );
1197
+ // OFFLINE DOOR: `/v1/images` renders are produced by Figma's servers and
1198
+ // simply do not exist in a local export, so there is nothing to resolve
1199
+ // and no network call to make. The placeholders stay in place — a
1200
+ // visibly broken image beats a silently missing element — and every one
1201
+ // is named in the summary rather than quietly dropped (DDR-221 D6: the
1202
+ // archive covers image FILLS, never server-side vector renders).
1203
+ const assets = local
1204
+ ? await resolveArchiveAssets(local, result.pendingExports, result.report, {
1205
+ root,
1206
+ designRootRel,
1207
+ stagingDir: staging,
1208
+ })
1209
+ : await resolveAssets(
1210
+ target.fileKey,
1211
+ result.pendingExports.map((p) => ({
1212
+ nodeId: p.nodeId,
1213
+ format: p.format,
1214
+ placeholder: p.placeholder,
1215
+ })),
1216
+ makeAssetDeps({ root, designRootRel, stagingDir: staging }),
1217
+ result.report,
1218
+ // ONE budget for the WHOLE import (review F4). The caps are meaningless
1219
+ // as per-call locals: `importFrames` loops over frames, so 60 frames ×
1220
+ // 200 assets × 2 MB reconstructs the multi-GB Syncthing shape D5 says
1221
+ // it closed — and each asset costs a browser launch for the SVG canary.
1222
+ budget
1223
+ );
1039
1224
  const tsx = applyRewrites(result.tsx, assets.rewrites);
1040
1225
  resolvedAssets += assets.resolved.length;
1041
1226
 
@@ -1459,6 +1644,8 @@ function parseArgv(argv) {
1459
1644
  canvas: null,
1460
1645
  artboard: null,
1461
1646
  confirmDocument: false,
1647
+ figPath: null,
1648
+ fileKey: null,
1462
1649
  };
1463
1650
  for (let i = 0; i < argv.length; i += 1) {
1464
1651
  const a = argv[i];
@@ -1468,10 +1655,27 @@ function parseArgv(argv) {
1468
1655
  case '--pages':
1469
1656
  case '--tokens':
1470
1657
  if (out.mode)
1471
- throw new ImportFigmaError(2, 'pick exactly one of --board/--frames/--tokens/--explode');
1658
+ throw new ImportFigmaError(
1659
+ 2,
1660
+ 'pick exactly one of --board/--frames/--tokens/--fig/--explode'
1661
+ );
1472
1662
  out.mode = a.slice(2);
1473
1663
  out.url = argv[++i];
1474
1664
  break;
1665
+ // `--fig` takes a local PATH, not a URL. The route (board vs frames) is
1666
+ // decided by the archive's own 8-byte prelude, not by the caller.
1667
+ case '--fig':
1668
+ if (out.mode)
1669
+ throw new ImportFigmaError(
1670
+ 2,
1671
+ 'pick exactly one of --board/--frames/--tokens/--fig/--explode'
1672
+ );
1673
+ out.mode = 'fig';
1674
+ out.figPath = argv[++i];
1675
+ break;
1676
+ case '--file-key':
1677
+ out.fileKey = argv[++i];
1678
+ break;
1475
1679
  // `--explode` takes an ARTBOARD ID, not a URL: it is not an import route,
1476
1680
  // it is a follow-up operation on an artboard a deterministic import
1477
1681
  // already placed (DDR-219 D1).
@@ -1531,6 +1735,8 @@ Usage:
1531
1735
  maude design import-figma --pages <figma-url> --root <repo> [--folder <name>] [--editable]
1532
1736
  maude design import-figma --frames <figma-url> --root <repo> [--slug <name>]
1533
1737
  maude design import-figma --tokens <figma-url> --root <repo>
1738
+ maude design import-figma --fig <path.fig> --root <repo> [--slug <name>]
1739
+ [--file-key <key>] [--dry-run] [--json]
1534
1740
  maude design import-figma --explode <artboard-id> --canvas ui/<folder>/<Page>.tsx
1535
1741
  --root <repo> [--confirm-document] [--dry-run] [--json]
1536
1742
 
@@ -1557,6 +1763,14 @@ the frame's name is cross-checked before anything is written). One codegen call
1557
1763
  per invocation, always. Unavailable is a normal outcome, reported as
1558
1764
  \`codegen-unavailable\` — it never silently falls back to another route.
1559
1765
 
1766
+ \`--fig\` reads a \`.fig\` / \`.jam\` you exported from Figma, entirely OFFLINE — no
1767
+ network, no token, no Figma seat. The archive's own 8-byte prelude decides the
1768
+ route (a \`.jam\` is a board, a \`.fig\` a design file), and an unrecognised prelude
1769
+ or container version REFUSES rather than decoding approximately. Images travel
1770
+ inside the archive, so nothing expires and nothing is rate-limited. A local file
1771
+ carries no REST file key, so provenance is content-addressed unless you pass
1772
+ \`--file-key\`; the summary says which you got.
1773
+
1560
1774
  The file's REVIEW COMMENTS come across as sticky annotations pinned where they
1561
1775
  sit — open threads on yellow paper, resolved ones on grey.
1562
1776
 
@@ -1578,7 +1792,11 @@ async function main() {
1578
1792
  process.stdout.write(`${HELP}\n`);
1579
1793
  process.exit(opts.help ? 0 : 2);
1580
1794
  }
1581
- if (opts.mode !== 'explode' && !opts.url) {
1795
+ if (opts.mode === 'fig' && !opts.figPath) {
1796
+ process.stderr.write('import-figma: --fig needs a path to a .fig or .jam file\n');
1797
+ process.exit(2);
1798
+ }
1799
+ if (opts.mode !== 'explode' && opts.mode !== 'fig' && !opts.url) {
1582
1800
  process.stderr.write('import-figma: a Figma URL is required\n');
1583
1801
  process.exit(2);
1584
1802
  }
@@ -1622,6 +1840,72 @@ async function main() {
1622
1840
  }
1623
1841
  return;
1624
1842
  }
1843
+ if (opts.mode === 'fig') {
1844
+ const local = decodeLocalFig(opts.figPath, opts.fileKey);
1845
+ // The 8-byte prelude decides the route, not the caller: a `.jam` is a
1846
+ // board and a `.fig` is a design file, and the archive is authoritative
1847
+ // about which it is.
1848
+ const isBoard = local.surface === 'board';
1849
+ const r = isBoard
1850
+ ? await importBoard({
1851
+ root,
1852
+ designRootRel: opts.designRoot,
1853
+ slug: opts.slug,
1854
+ dryRun: opts.dryRun,
1855
+ confirmLarge: opts.confirmLarge,
1856
+ local,
1857
+ })
1858
+ : await importFrames({
1859
+ root,
1860
+ designRootRel: opts.designRoot,
1861
+ slug: opts.slug,
1862
+ dryRun: opts.dryRun,
1863
+ local,
1864
+ });
1865
+ const merged = new ImportReport();
1866
+ if (isBoard) merged.entries.push(...r.report.entries);
1867
+ else for (const rep of r.reports) merged.entries.push(...rep.entries);
1868
+ const provenance = {
1869
+ containerVersion: local.report.containerVersion,
1870
+ schemaSha256: local.report.schemaSha256,
1871
+ exportedAt: local.report.exportedAt ?? null,
1872
+ fileKey: local.fileKey,
1873
+ derivedFileKey: opts.fileKey === null,
1874
+ };
1875
+ if (opts.json) {
1876
+ process.stdout.write(
1877
+ `${JSON.stringify({
1878
+ route: 'fig-local',
1879
+ surface: local.surface,
1880
+ ...provenance,
1881
+ unmappedTypes: local.report.unmappedTypes,
1882
+ lossyFields: local.report.lossyFields,
1883
+ internalNodesSkipped: local.report.internalNodesSkipped,
1884
+ ...(isBoard ? { slug: r.slug, strokes: r.strokeCount } : { written: r.written }),
1885
+ dispositions: merged.entries,
1886
+ })}\n`
1887
+ );
1888
+ } else {
1889
+ const lossy = local.report.lossyFields
1890
+ .map((f) => ` lossy ${f.field} x${f.count} — ${f.why}`)
1891
+ .join('\n');
1892
+ const unmapped = local.report.unmappedTypes
1893
+ .map((u) => ` unmapped type ${u.type} x${u.count}`)
1894
+ .join('\n');
1895
+ const head = isBoard
1896
+ ? `import-figma: board -> ${r.slug} (${r.strokeCount} strokes)${opts.dryRun ? ' (dry run)' : ''}`
1897
+ : `import-figma: ${r.written.length} frame(s)${opts.dryRun ? ' (dry run)' : ''}`;
1898
+ process.stdout.write(
1899
+ `${head}\n` +
1900
+ ` offline: container v${provenance.containerVersion} · schema ${provenance.schemaSha256.slice(0, 8)}` +
1901
+ `${provenance.exportedAt ? ` · exported ${provenance.exportedAt}` : ''}\n` +
1902
+ ` file key: ${provenance.fileKey}${provenance.derivedFileKey ? ' (content-derived — pass --file-key for the real one)' : ''}\n` +
1903
+ `${[unmapped, lossy].filter(Boolean).join('\n')}${unmapped || lossy ? '\n' : ''}` +
1904
+ `${formatSummary(merged)}\n`
1905
+ );
1906
+ }
1907
+ return;
1908
+ }
1625
1909
  if (opts.mode === 'pages') {
1626
1910
  const r = await importPages({
1627
1911
  url: opts.url,
@@ -11,9 +11,22 @@
11
11
  // paths, but everything renders through safeName anyway: this payload is
12
12
  // read back off disk (`_sync.json`), and a bounded text-only row is free.
13
13
 
14
- import { useMemo } from 'react';
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
15
 
16
- import { safeName, syncPresentation } from '../../sync/presentation.ts';
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;
17
30
 
18
31
  /** DocSyncState → the presentation vocabulary (never invent a new word). */
19
32
  const STATE_WORD = {
@@ -84,6 +97,52 @@ export default function SyncPanel({
84
97
  onClose,
85
98
  }) {
86
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
+
87
146
  const items = readItems(status?.items);
88
147
  const truncated = isCount(status?.itemsTruncated) ? status.itemsTruncated : 0;
89
148
  const assets = readAssets(status?.assets);
@@ -129,10 +188,28 @@ export default function SyncPanel({
129
188
  <span className="gp-panel-title">Sync</span>
130
189
  {counts && <span className="gp-count">{counts}</span>}
131
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>
132
204
  <button type="button" className="gp-x" aria-label="Close" onClick={onClose}>
133
205
  ×
134
206
  </button>
135
207
  </div>
208
+ {note && (
209
+ <div className="sp-resync-note" role="status" aria-live="polite">
210
+ {safeDetail(note, '')}
211
+ </div>
212
+ )}
136
213
  {/* The one-rule sentence, live — same aria pattern as the rail note:
137
214
  a polite announcement when the phase changes, never a focus steal. */}
138
215
  {p && (
@@ -193,6 +270,20 @@ export default function SyncPanel({
193
270
  ? `${assets.pushed} pushed · ${assets.skipped} already there` +
194
271
  (assets.failedCount > 0 ? ` · ${assets.failedCount} failed` : '')
195
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
+ )}
196
287
  </div>
197
288
  {!assets.finished && assets.active && (
198
289
  <div className="sp-assets-active" title={safeName(assets.active, '')}>
@@ -2692,6 +2692,16 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2692
2692
  .sp-assets-line { padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); }
2693
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
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; }
2695
2705
  /* The popup — opens UPWARD from the dock (gi-menu anatomy). SOLID opaque menu
2696
2706
  * surface + border here (NOT the canvas `.panel`, which isn't in the client bundle
2697
2707
  * — phase-28 lesson; the tree must never bleed through). */
@@ -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