@1agh/maude 0.58.1 → 0.58.3

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 (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -30,11 +30,13 @@ import { createConnectionMonitor } from '../sync/connection-state.ts';
30
30
  import {
31
31
  type AwarenessRegistry,
32
32
  buildNoSyncablePayload,
33
+ classifyAuthFailure,
33
34
  createSyncRuntime,
34
35
  discoverCanvases,
35
36
  type SyncProvider,
36
37
  scanCanvases,
37
38
  toWsUrl,
39
+ validExpiry,
38
40
  } from '../sync/index.ts';
39
41
  import { createSyncStatusStore } from '../sync/status.ts';
40
42
 
@@ -1029,6 +1031,49 @@ describe('createDefaultProviderFactory — shared socket multiplexing', () => {
1029
1031
 
1030
1032
  // DDR-102 — auth-failure intelligence (classification, aggregation,
1031
1033
  // destroy-on-permanent + re-probe) and the honest settled boot summary.
1034
+ describe('validExpiry — a hub-reported deadline sane enough to schedule against', () => {
1035
+ const NOW = 1_000_000_000_000;
1036
+ test('accepts a plausible future integer', () => {
1037
+ expect(validExpiry(NOW + 12 * 60 * 60 * 1000, NOW)).toBe(NOW + 12 * 60 * 60 * 1000);
1038
+ });
1039
+ test('rejects a past or present stamp (falls to the invalid-token path instead)', () => {
1040
+ expect(validExpiry(NOW - 1, NOW)).toBeNull();
1041
+ expect(validExpiry(NOW, NOW)).toBeNull();
1042
+ });
1043
+ test('rejects an absurdly far-future stamp (the setTimeout-overflow trap)', () => {
1044
+ expect(validExpiry(NOW + 60 * 24 * 60 * 60 * 1000, NOW)).toBeNull(); // 60 days
1045
+ });
1046
+ test('rejects non-integers and non-numbers', () => {
1047
+ expect(validExpiry(NOW + 1000.5, NOW)).toBeNull();
1048
+ expect(validExpiry('soon', NOW)).toBeNull();
1049
+ expect(validExpiry(Number.NaN, NOW)).toBeNull();
1050
+ expect(validExpiry(Number.POSITIVE_INFINITY, NOW)).toBeNull();
1051
+ expect(validExpiry(undefined, NOW)).toBeNull();
1052
+ });
1053
+ });
1054
+
1055
+ describe('classifyAuthFailure — permanent classes outrank the transient ones', () => {
1056
+ test('the invalid-token bucket refusal classifies as invalid-token, not rate-limit', () => {
1057
+ // The hub's invalid-token bucket says BOTH things. Filing it under
1058
+ // rate-limit (transient) made the runtime retry into the very bucket
1059
+ // refusing it — the alligators incident's 1840-vs-138 log ratio.
1060
+ expect(classifyAuthFailure('invalid token — rate limited, retry in up to 60s')).toBe(
1061
+ 'invalid-token'
1062
+ );
1063
+ });
1064
+
1065
+ test('plain reasons still land in their own classes', () => {
1066
+ expect(classifyAuthFailure('invalid token')).toBe('invalid-token');
1067
+ expect(classifyAuthFailure('token not authorized for this documentName')).toBe(
1068
+ 'not-authorized'
1069
+ );
1070
+ expect(classifyAuthFailure('rate limit exceeded for this token — retry in up to 60s')).toBe(
1071
+ 'rate-limit'
1072
+ );
1073
+ expect(classifyAuthFailure('permission-denied')).toBe('generic');
1074
+ });
1075
+ });
1076
+
1032
1077
  describe('DDR-102 — auth-failure intelligence + boot summary', () => {
1033
1078
  function fakeTimerQueue() {
1034
1079
  let nextId = 1;
@@ -1059,15 +1104,17 @@ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
1059
1104
  function authStubFactory() {
1060
1105
  const made: Array<{
1061
1106
  documentName: string;
1107
+ token: string | undefined;
1062
1108
  destroyed: boolean;
1063
1109
  emitAuthFailure: (reason: string) => void;
1064
1110
  document: Y.Doc;
1065
1111
  }> = [];
1066
- const factory = (args: { documentName: string; document?: Y.Doc }) => {
1112
+ const factory = (args: { documentName: string; token?: string; document?: Y.Doc }) => {
1067
1113
  const document = args.document ?? new Y.Doc();
1068
1114
  const cbs = new Set<(info: { reason: string }) => void>();
1069
1115
  const entry = {
1070
1116
  documentName: args.documentName,
1117
+ token: args.token,
1071
1118
  destroyed: false,
1072
1119
  emitAuthFailure: (reason: string) => {
1073
1120
  for (const cb of cbs) cb({ reason });
@@ -1171,6 +1218,392 @@ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
1171
1218
  }
1172
1219
  });
1173
1220
 
1221
+ test('invalid-token triggers ONE silent renewal and an immediate re-probe with the fresh token', async () => {
1222
+ const url = 'https://hub.example.com';
1223
+ writeHubsConfig(url, 'mau_stale');
1224
+ const ctx = makeCtx({ url, linkedAt: 1 });
1225
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1226
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
1227
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'c.html'), '<c/>');
1228
+
1229
+ const timers = fakeTimerQueue();
1230
+ const { factory, made } = authStubFactory();
1231
+ let renewCalls = 0;
1232
+ const origWarn = console.warn;
1233
+ const origLog = console.log;
1234
+ console.warn = () => {};
1235
+ console.log = () => {};
1236
+ try {
1237
+ const runtime = createSyncRuntime(ctx, {
1238
+ providerFactory: factory,
1239
+ auth: {
1240
+ warnDebounceMs: 2_000,
1241
+ reprobeMs: 300_000,
1242
+ settleTimeoutMs: 15_000,
1243
+ setTimer: timers.setTimer,
1244
+ clearTimer: timers.clearTimer,
1245
+ renewCredential: async () => {
1246
+ renewCalls++;
1247
+ return { token: 'mau_fresh', expiresAt: null };
1248
+ },
1249
+ },
1250
+ });
1251
+ await runtime?.start();
1252
+ expect(made).toHaveLength(3);
1253
+ expect(made.every((m) => m.token === 'mau_stale')).toBe(true);
1254
+
1255
+ // The whole burst rejects with the expired credential…
1256
+ for (const m of made.slice(0, 3)) m.emitAuthFailure('invalid token');
1257
+ await new Promise((res) => setTimeout(res, 10));
1258
+
1259
+ // …ONE renewal (single-flight), and the re-probe happened NOW — no
1260
+ // 5-minute wait — with the fresh token on every reconnected provider.
1261
+ expect(renewCalls).toBe(1);
1262
+ const reprobed = made.slice(3);
1263
+ expect(reprobed.map((m) => m.documentName).sort()).toEqual(['ui-a', 'ui-b', 'ui-c']);
1264
+ expect(reprobed.every((m) => m.token === 'mau_fresh')).toBe(true);
1265
+
1266
+ await runtime?.stop();
1267
+ } finally {
1268
+ console.warn = origWarn;
1269
+ console.log = origLog;
1270
+ }
1271
+ });
1272
+
1273
+ test('F1: a hub refusing on VOLUME cannot drive a renewal storm — the cap stops it', async () => {
1274
+ // The regression this whole review turned on. A cell refusing its own
1275
+ // clients with "invalid token — rate limited" classifies permanent →
1276
+ // triggers renewal → renewal succeeds (token was fine) → reprobe → every
1277
+ // reconnected doc is refused again → loop. Reproduced at 2342/s before the
1278
+ // cap. Here every reconnected provider auto-re-rejects, so without the cap
1279
+ // this test would spin forever; with it, renewals are bounded.
1280
+ const url = 'https://hub.example.com';
1281
+ writeHubsConfig(url, 'mau_stale');
1282
+ const ctx = makeCtx({ url, linkedAt: 1 });
1283
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1284
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
1285
+
1286
+ let renewCalls = 0;
1287
+ let providersMade = 0;
1288
+ const origWarn = console.warn;
1289
+ const origLog = console.log;
1290
+ console.warn = () => {};
1291
+ console.log = () => {};
1292
+ // Every provider auto-rejects with the volume-refusal string the moment a
1293
+ // failure handler is attached — modelling a full hub bucket.
1294
+ const factory = (args: { documentName: string; token?: string; document?: Y.Doc }) => {
1295
+ const document = args.document ?? new Y.Doc();
1296
+ providersMade++;
1297
+ return {
1298
+ document,
1299
+ onAuthFailed(cb: (info: { reason: string }) => void) {
1300
+ queueMicrotask(() => cb({ reason: 'invalid token — rate limited, retry in up to 60s' }));
1301
+ return () => {};
1302
+ },
1303
+ onceSynced: () => new Promise<void>(() => {}),
1304
+ destroy() {},
1305
+ };
1306
+ };
1307
+ try {
1308
+ const runtime = createSyncRuntime(ctx, {
1309
+ providerFactory: factory,
1310
+ auth: {
1311
+ warnDebounceMs: 2_000,
1312
+ reprobeMs: 300_000,
1313
+ settleTimeoutMs: 15_000,
1314
+ setTimer: () => 0 as unknown as ReturnType<typeof setTimeout>,
1315
+ clearTimer: () => {},
1316
+ renewMinIntervalMs: 0, // isolate the CAP from the floor
1317
+ renewCredential: async () => {
1318
+ renewCalls++;
1319
+ return { token: `mau_fresh_${renewCalls}`, expiresAt: null };
1320
+ },
1321
+ },
1322
+ });
1323
+ await runtime?.start();
1324
+ // Let the storm run itself out — if the cap failed, this never settles
1325
+ // and the test times out (which is itself the failure signal).
1326
+ for (let i = 0; i < 20; i++) await new Promise((res) => setTimeout(res, 5));
1327
+
1328
+ // Bounded by the cap (default 3), NOT thousands.
1329
+ expect(renewCalls).toBeLessThanOrEqual(3);
1330
+ expect(renewCalls).toBeGreaterThan(0);
1331
+ // Providers created is likewise bounded — no runaway reconnect fan-out.
1332
+ expect(providersMade).toBeLessThan(20);
1333
+ await runtime?.stop();
1334
+ } finally {
1335
+ console.warn = origWarn;
1336
+ console.log = origLog;
1337
+ }
1338
+ });
1339
+
1340
+ test('F1: the frequency floor collapses a rapid second burst to no extra renewal', async () => {
1341
+ const url = 'https://hub.example.com';
1342
+ writeHubsConfig(url, 'mau_stale');
1343
+ const ctx = makeCtx({ url, linkedAt: 1 });
1344
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1345
+
1346
+ let renewCalls = 0;
1347
+ let clock = 1_000_000;
1348
+ const origWarn = console.warn;
1349
+ const origLog = console.log;
1350
+ console.warn = () => {};
1351
+ console.log = () => {};
1352
+ const { factory, made } = authStubFactory();
1353
+ try {
1354
+ const runtime = createSyncRuntime(ctx, {
1355
+ providerFactory: factory,
1356
+ auth: {
1357
+ warnDebounceMs: 2_000,
1358
+ reprobeMs: 300_000,
1359
+ settleTimeoutMs: 15_000,
1360
+ setTimer: () => 0 as unknown as ReturnType<typeof setTimeout>,
1361
+ clearTimer: () => {},
1362
+ now: () => clock,
1363
+ renewMinIntervalMs: 60_000,
1364
+ renewCredential: async () => {
1365
+ renewCalls++;
1366
+ return { token: 'mau_fresh', expiresAt: null };
1367
+ },
1368
+ },
1369
+ });
1370
+ await runtime?.start();
1371
+ made[0]?.emitAuthFailure('invalid token');
1372
+ await new Promise((res) => setTimeout(res, 10));
1373
+ expect(renewCalls).toBe(1);
1374
+ // A second rejection 10 s later (inside the 60 s floor) → no new renewal.
1375
+ clock += 10_000;
1376
+ made[0]?.emitAuthFailure('invalid token');
1377
+ await new Promise((res) => setTimeout(res, 10));
1378
+ expect(renewCalls).toBe(1);
1379
+ // Past the floor → renewal is allowed again.
1380
+ clock += 60_000;
1381
+ made[0]?.emitAuthFailure('invalid token');
1382
+ await new Promise((res) => setTimeout(res, 10));
1383
+ expect(renewCalls).toBe(2);
1384
+ await runtime?.stop();
1385
+ } finally {
1386
+ console.warn = origWarn;
1387
+ console.log = origLog;
1388
+ }
1389
+ });
1390
+
1391
+ test('a failed renewal changes nothing — rejected docs wait for the slow re-probe as before', async () => {
1392
+ const url = 'https://hub.example.com';
1393
+ writeHubsConfig(url, 'mau_stale');
1394
+ const ctx = makeCtx({ url, linkedAt: 1 });
1395
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1396
+
1397
+ const timers = fakeTimerQueue();
1398
+ const { factory, made } = authStubFactory();
1399
+ const origWarn = console.warn;
1400
+ const origLog = console.log;
1401
+ console.warn = () => {};
1402
+ console.log = () => {};
1403
+ try {
1404
+ const runtime = createSyncRuntime(ctx, {
1405
+ providerFactory: factory,
1406
+ auth: {
1407
+ warnDebounceMs: 2_000,
1408
+ reprobeMs: 300_000,
1409
+ settleTimeoutMs: 15_000,
1410
+ setTimer: timers.setTimer,
1411
+ clearTimer: timers.clearTimer,
1412
+ renewCredential: async () => null, // signed out / revoked / self-hosted
1413
+ },
1414
+ });
1415
+ await runtime?.start();
1416
+ made[0]?.emitAuthFailure('invalid token');
1417
+ await new Promise((res) => setTimeout(res, 10));
1418
+
1419
+ // No immediate re-probe (the renewal failed) — the doc stays rejected…
1420
+ expect(made).toHaveLength(1);
1421
+ expect(runtime?.status()?.docs?.rejected).toBe(1);
1422
+ // …until the slow re-probe fires, with the ORIGINAL token (unchanged).
1423
+ timers.fire(300_000);
1424
+ await new Promise((res) => setTimeout(res, 10));
1425
+ expect(made).toHaveLength(2);
1426
+ expect(made[1]?.token).toBe('mau_stale');
1427
+
1428
+ await runtime?.stop();
1429
+ } finally {
1430
+ console.warn = origWarn;
1431
+ console.log = origLog;
1432
+ }
1433
+ });
1434
+
1435
+ test('a stored expiry arms a pre-expiry renewal at ~80% of the remaining life', async () => {
1436
+ const url = 'https://hub.example.com';
1437
+ const cfgPath = process.env.HUBS_CONFIG_PATH;
1438
+ if (!cfgPath) throw new Error('HUBS_CONFIG_PATH not set by beforeEach');
1439
+ writeFileSync(
1440
+ cfgPath,
1441
+ JSON.stringify({
1442
+ hubs: { [url]: { token: 'mau_stale', linkedAt: 1, expiresAt: Date.now() + 100_000 } },
1443
+ })
1444
+ );
1445
+ chmodSync(cfgPath, 0o600);
1446
+ const ctx = makeCtx({ url, linkedAt: 1 });
1447
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1448
+
1449
+ const timers = fakeTimerQueue();
1450
+ const { factory } = authStubFactory();
1451
+ let renewCalls = 0;
1452
+ const origWarn = console.warn;
1453
+ const origLog = console.log;
1454
+ console.warn = () => {};
1455
+ console.log = () => {};
1456
+ try {
1457
+ const runtime = createSyncRuntime(ctx, {
1458
+ providerFactory: factory,
1459
+ auth: {
1460
+ warnDebounceMs: 2_000,
1461
+ reprobeMs: 300_000,
1462
+ settleTimeoutMs: 15_000,
1463
+ setTimer: timers.setTimer,
1464
+ clearTimer: timers.clearTimer,
1465
+ renewCredential: async () => {
1466
+ renewCalls++;
1467
+ return { token: 'mau_fresh', expiresAt: null };
1468
+ },
1469
+ },
1470
+ });
1471
+ await runtime?.start();
1472
+ // 100 s of life left → the renewal timer sits at 80 s, so a 30 s sweep
1473
+ // must not reach it (only the settle timers live down there)…
1474
+ timers.fire(30_000);
1475
+ await new Promise((res) => setTimeout(res, 5));
1476
+ expect(renewCalls).toBe(0);
1477
+ // …and the 80 s sweep fires it, once, without any rejection happening.
1478
+ timers.fire(80_000);
1479
+ await new Promise((res) => setTimeout(res, 5));
1480
+ expect(renewCalls).toBe(1);
1481
+
1482
+ await runtime?.stop();
1483
+ } finally {
1484
+ console.warn = origWarn;
1485
+ console.log = origLog;
1486
+ }
1487
+ });
1488
+
1489
+ test('a successful re-probe clears the refusal, and the reconcile is a separate word', async () => {
1490
+ // A REGRESSION GUARD, not a falsifier — this already held. It is here
1491
+ // because the clear moved: it used to sit at the BOTTOM of the
1492
+ // post-handshake path, below a reconcile that can throw into a rejection
1493
+ // `settleWait` swallows, so a document that reconnected fine but failed to
1494
+ // settle on disk would have kept telling the user to go fix a credential
1495
+ // that was never the problem. The clear now runs on the handshake, and the
1496
+ // reconcile reports separately (`pending` vs `connected`) and loudly.
1497
+ const url = 'https://hub.example.com';
1498
+ writeHubsConfig(url, 'mau_test');
1499
+ const ctx = makeCtx({ url, linkedAt: 1 });
1500
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1501
+
1502
+ const timers = fakeTimerQueue();
1503
+ const origWarn = console.warn;
1504
+ const origLog = console.log;
1505
+ console.warn = () => {};
1506
+ console.log = () => {};
1507
+
1508
+ let round = 0;
1509
+ const factory = (args: { documentName: string; document?: Y.Doc }) => {
1510
+ const document = args.document ?? new Y.Doc();
1511
+ const first = round++ === 0;
1512
+ return {
1513
+ document,
1514
+ onAuthFailed(cb: (info: { reason: string }) => void) {
1515
+ if (first) queueMicrotask(() => cb({ reason: 'token not authorized' }));
1516
+ return () => {};
1517
+ },
1518
+ onceSynced: () => (first ? new Promise<void>(() => {}) : Promise.resolve()),
1519
+ destroy() {},
1520
+ };
1521
+ };
1522
+
1523
+ try {
1524
+ const runtime = createSyncRuntime(ctx, {
1525
+ providerFactory: factory,
1526
+ auth: {
1527
+ warnDebounceMs: 2_000,
1528
+ reprobeMs: 300_000,
1529
+ settleTimeoutMs: 15_000,
1530
+ setTimer: timers.setTimer,
1531
+ clearTimer: timers.clearTimer,
1532
+ },
1533
+ });
1534
+ await runtime?.start();
1535
+ await new Promise((res) => setTimeout(res, 10));
1536
+ expect(runtime?.status().docs?.rejected).toBe(1);
1537
+
1538
+ timers.fire(300_000);
1539
+ await new Promise((res) => setTimeout(res, 40));
1540
+
1541
+ expect(runtime?.status().docs?.rejected).toBe(0);
1542
+ expect(runtime?.status().rejectedSlugs ?? []).toEqual([]);
1543
+
1544
+ await runtime?.stop();
1545
+ } finally {
1546
+ console.warn = origWarn;
1547
+ console.log = origLog;
1548
+ }
1549
+ });
1550
+
1551
+ test('a new process never serves the previous one’s verdict, not even mid-boot', async () => {
1552
+ // `_sync.json` is a file and `/_sync-status` returns whatever is in it. The
1553
+ // first honest snapshot of a run is not written until every provider has
1554
+ // been built — after a scan AND a listing fetch that waits up to 6 seconds.
1555
+ // For that whole window the CLI and the browser banner were reading the
1556
+ // LAST run's counters as current. So the window is what this asserts: the
1557
+ // fetch is held open, and the file must ALREADY be clean.
1558
+ const url = 'https://hub.example.com';
1559
+ writeHubsConfig(url, 'mau_test');
1560
+ const ctx = makeCtx({ url, linkedAt: 1 });
1561
+ const statusFile = join(ctx.paths.designRoot, '_sync.json');
1562
+ writeFileSync(
1563
+ statusFile,
1564
+ JSON.stringify({
1565
+ url,
1566
+ canvases: 76,
1567
+ state: 'online',
1568
+ docs: { synced: 0, pending: 3, rejected: 73 },
1569
+ rejectedSlugs: ['ui-a', 'ui-b'],
1570
+ })
1571
+ );
1572
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1573
+
1574
+ const realFetch = globalThis.fetch;
1575
+ let releaseListing: () => void = () => {};
1576
+ const listingReached = new Promise<void>((res) => {
1577
+ globalThis.fetch = (async () => {
1578
+ res();
1579
+ await new Promise<void>((r) => {
1580
+ releaseListing = r;
1581
+ });
1582
+ return new Response(JSON.stringify({ documents: [] }), { status: 200 });
1583
+ }) as typeof fetch;
1584
+ });
1585
+
1586
+ try {
1587
+ const { factory } = inMemoryProviderFactory();
1588
+ const runtime = createSyncRuntime(ctx, { providerFactory: factory });
1589
+ const booting = runtime?.start();
1590
+ await listingReached;
1591
+
1592
+ // Mid-boot, with the listing still in flight — exactly where the stale
1593
+ // file used to be authoritative.
1594
+ const midBoot = JSON.parse(readFileSync(statusFile, 'utf8'));
1595
+ expect(midBoot.docs.rejected).toBe(0);
1596
+ expect(midBoot.rejectedSlugs ?? []).toEqual([]);
1597
+ expect(midBoot.state).toBe('connecting');
1598
+
1599
+ releaseListing();
1600
+ await booting;
1601
+ await runtime?.stop();
1602
+ } finally {
1603
+ globalThis.fetch = realFetch;
1604
+ }
1605
+ });
1606
+
1174
1607
  test('boot summary prints AFTER settle with honest counts (not the premature N/N)', async () => {
1175
1608
  const url = 'https://hub.example.com';
1176
1609
  writeHubsConfig(url, 'mau_test');
@@ -151,7 +151,7 @@ describe('video-comp seek bridge', () => {
151
151
  expect(a.seeks).toEqual([42, 42]);
152
152
  });
153
153
 
154
- test('a Player ref that is not yet ready (null) does not throw', async () => {
154
+ test('a not-yet-ready Player is resilient in UI mode but still RECORDED', async () => {
155
155
  const w = window as unknown as SeekWindow;
156
156
  installMaudeSeekBridge();
157
157
  if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
@@ -163,6 +163,28 @@ describe('video-comp seek bridge', () => {
163
163
  height: 360,
164
164
  ref: { current: null },
165
165
  });
166
+ // A live scrub of a still-mounting comp must not blow up the UI...
167
+ const before = w.__maude_seek_failures__ ?? 0;
166
168
  await expect(w.__maude_seek__?.(10)).resolves.toBeUndefined();
169
+ // ...but it is no longer silent: the capture shim reads this counter and
170
+ // refuses to encode, because a seek that never landed means a stale frame.
171
+ expect(w.__maude_seek_failures__ ?? 0).toBeGreaterThan(before);
172
+ });
173
+
174
+ test('capture mode (strict) throws instead of pretending the seek landed', async () => {
175
+ const w = window as unknown as SeekWindow;
176
+ installMaudeSeekBridge();
177
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
178
+ w.__maudeVideoComps.set('pending-strict', {
179
+ id: 'pending-strict',
180
+ fps: 30,
181
+ durationInFrames: 90,
182
+ width: 640,
183
+ height: 360,
184
+ ref: { current: null },
185
+ });
186
+ await expect(w.__maude_seek__?.(10, { strict: true })).rejects.toThrow(
187
+ /refusing to report a seek that did not happen/
188
+ );
167
189
  });
168
190
  });
@@ -233,6 +233,7 @@ describe('the vocabulary itself', () => {
233
233
  '/_api/debug-bundle',
234
234
  '/_api/design',
235
235
  '/_api/export',
236
+ '/_api/figma',
236
237
  '/_api/generate',
237
238
  '/_api/github',
238
239
  '/_api/hub',
@@ -81,7 +81,20 @@ interface MaudeSeekWindow {
81
81
  __maudeVideoComps?: Map<string, CompEntry>;
82
82
  /** Seek every registered comp to `frame` (paused). Ordinary artboards fall
83
83
  * back to a time-based WAAPI seek. Resolves once the frame has painted. */
84
- __maude_seek__?: (frame: number, opts?: { fps?: number }) => Promise<void>;
84
+ __maude_seek__?: (
85
+ frame: number,
86
+ opts?: {
87
+ fps?: number;
88
+ /** Capture mode: throw when a seek never lands, instead of only counting it. */
89
+ strict?: boolean;
90
+ }
91
+ ) => Promise<void>;
92
+ /**
93
+ * Count of seeks that never landed. The capture shim reads this and refuses
94
+ * to ship the export rather than encoding a stale frame — a silent wrong
95
+ * frame is worse than a loud failure.
96
+ */
97
+ __maude_seek_failures__?: number;
85
98
  /** Serializable comp list for the capture shim / Timeline panel. */
86
99
  __maude_comps__?: () => CompSnapshot[];
87
100
  /**
@@ -140,6 +153,36 @@ function afterPaint(): Promise<void> {
140
153
  });
141
154
  }
142
155
 
156
+ /** How many paint-spaced attempts a cold Player gets before a seek is a failure. */
157
+ const SEEK_ATTEMPTS = 3;
158
+
159
+ /**
160
+ * Seek one comp, giving a not-yet-mounted Player a bounded number of chances.
161
+ *
162
+ * Returns whether the seek actually landed. The retry replaces the old silent
163
+ * swallow: a Player that is genuinely mid-mount settles within a paint or two,
164
+ * and one that never settles is a real fault the caller must not paper over.
165
+ */
166
+ async function seekEntryWithRetry(
167
+ ref: { current: { pause?: () => void; seekTo: (f: number) => void } | null },
168
+ frame: number
169
+ ): Promise<boolean> {
170
+ for (let attempt = 0; attempt < SEEK_ATTEMPTS; attempt += 1) {
171
+ try {
172
+ const player = ref.current;
173
+ if (player) {
174
+ player.pause?.();
175
+ player.seekTo(frame);
176
+ return true;
177
+ }
178
+ } catch {
179
+ /* fall through to the retry — the next paint may settle it */
180
+ }
181
+ if (attempt < SEEK_ATTEMPTS - 1) await afterPaint();
182
+ }
183
+ return false;
184
+ }
185
+
143
186
  /**
144
187
  * Install the singleton seek bridge on `window`. Idempotent — the first
145
188
  * VideoComp to mount wins; later mounts reuse the same functions. The bridge
@@ -156,11 +199,32 @@ export function installMaudeSeekBridge(): void {
156
199
  if (entries.length > 0) {
157
200
  for (const e of entries) {
158
201
  const f = clamp(Math.round(frame), 0, Math.max(0, e.durationInFrames - 1));
159
- try {
160
- e.ref.current?.pause();
161
- e.ref.current?.seekTo(f);
162
- } catch {
163
- /* Player not yet ready — a later seek settles it */
202
+ // This used to be a bare try/catch with an empty body and the comment
203
+ // "Player not yet ready — a later seek settles it". That made a seek
204
+ // that COMPLETELY FAILED resolve as success: `afterPaint()` proves a
205
+ // paint happened, not that the right content was in it. Serially it was
206
+ // benign — frame N+1 repaired frame N's miss, and only frame 0 was ever
207
+ // exposed — which is exactly why it survived. It is not benign the
208
+ // moment anything captures ranges in parallel: every range boundary is a
209
+ // first seek on a cold page, so "a later seek settles it" would be
210
+ // invoked once per worker instead of once per export, and a stale frame
211
+ // is a valid-looking file with wrong pixels, not a crash.
212
+ //
213
+ // So the hope becomes an explicit bounded retry, and exhausting it is a
214
+ // real failure the capture shim can see and refuse to ship.
215
+ if (!(await seekEntryWithRetry(e.ref, f))) {
216
+ // ALWAYS recorded, so the capture shim can refuse the export even on
217
+ // the non-strict path. Only THROWN under `strict`, which the shim
218
+ // passes: a live UI scrub of a still-mounting comp should stay
219
+ // resilient, but a capture must never proceed on a seek that did not
220
+ // happen. The old code did neither — it just resolved.
221
+ w.__maude_seek_failures__ = (w.__maude_seek_failures__ ?? 0) + 1;
222
+ if (opts?.strict) {
223
+ throw new Error(
224
+ `__maude_seek__: comp did not accept frame ${f} after ${SEEK_ATTEMPTS} attempts ` +
225
+ '(Player never became ready) — refusing to report a seek that did not happen'
226
+ );
227
+ }
164
228
  }
165
229
  }
166
230
  await afterPaint();
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "cloud-sync-matches-your-desktop",
6
+ "version": "0.58.3",
7
+ "date": "2026-08-11",
8
+ "kind": "fix",
9
+ "title": "The cloud copy matches your desktop",
10
+ "summary": "A project you connect to the cloud now looks exactly like it does on your machine: no duplicated files, every canvas opens instead of breaking, and your images show up instead of grey boxes. The cloud picker says \"Connected\" for the project this folder is linked to, with a Disconnect right there — and once you're connected, the cloud saves every change automatically, so there's one place your work is saved, not two.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "cloud-link-self-renews",
15
+ "version": "0.58.3",
16
+ "date": "2026-08-11",
17
+ "kind": "fix",
18
+ "title": "Cloud sync keeps itself signed in",
19
+ "summary": "A workspace you connect to the cloud stays synced — the session refreshes itself in the background instead of quietly expiring after a few hours and stranding your canvases on \"connecting…\". If a link ever can't sync for a few minutes it now says so and points you to reconnect, instead of spinning forever.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "figma-import",
24
+ "version": "0.58.3",
25
+ "date": "2026-08-11",
26
+ "kind": "feature",
27
+ "title": "Bring your Figma work in",
28
+ "summary": "Paste a Figma link and get the real thing, not a picture of it: design frames become editable canvases, a FigJam board becomes your whiteboard with its sticky notes and arrows still attached, and your colour and text styles become design tokens. Add a Figma access token once in Settings to get started.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "sync-carries-the-path",
33
+ "version": "0.58.2",
34
+ "date": "2026-08-08",
35
+ "kind": "fix",
36
+ "title": "Canvases arrive in the folder you made",
37
+ "summary": "A canvas you create in a nested folder now shows up on your other machine — and in the cloud — in that same folder, instead of quietly landing somewhere the file tree never listed. Connecting a project to an empty folder brings the whole thing down with its structure intact.",
38
+ "surface": "design-ui"
39
+ },
4
40
  {
5
41
  "id": "release-version-visible",
6
42
  "version": "0.58.0",
@@ -66,6 +66,10 @@ export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: stri
66
66
  prefix: '/_api/generate',
67
67
  why: 'media generation runs tenant-authored prompts against a provider key held here',
68
68
  },
69
+ {
70
+ prefix: '/_api/figma',
71
+ why: 'the Figma lane STORES and SPENDS a user personal access token, and imports write the design root — a cell must hold no provider credential (DDR-216 D2/D3, same class as /_api/generate)',
72
+ },
69
73
  {
70
74
  prefix: '/_api/shell-shot',
71
75
  why: 'the shell screenshot spawns a headless browser against the studio — the same evaluation `/_api/export` is forbidden for',