@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.
- package/apps/studio/bin/_import-figma.mjs +314 -30
- package/apps/studio/client/panels/SyncPanel.jsx +93 -2
- package/apps/studio/client/styles/3-shell-maude.css +10 -0
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +547 -547
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/figma/fig-decode.test.ts +100 -14
- package/apps/studio/figma/fig-decode.ts +247 -25
- package/apps/studio/figma/fig-differential.test.ts +182 -0
- package/apps/studio/figma/fig-translator.test.ts +192 -0
- package/apps/studio/figma/fig-vector.test.ts +113 -0
- package/apps/studio/figma/fig-vector.ts +145 -0
- package/apps/studio/figma/sanitize.ts +7 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/http.ts +47 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +101 -7
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/index.ts +29 -5
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +157 -8
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-panel-surface.test.ts +34 -1
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/whats-new.json +16 -0
- 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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 (
|
|
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 =
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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(
|
|
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
|
|
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). */
|
package/apps/studio/context.ts
CHANGED
|
@@ -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
|
|