@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -11,6 +11,7 @@ import {
11
11
  existsSync,
12
12
  mkdirSync,
13
13
  mkdtempSync,
14
+ readdirSync,
14
15
  readFileSync,
15
16
  rmSync,
16
17
  writeFileSync,
@@ -50,7 +51,6 @@ beforeEach(() => {
50
51
  afterEach(() => {
51
52
  // Node's process.env stringifies on assignment; assigning undefined yields
52
53
  // the literal string "undefined". delete is the correct restoration.
53
- // biome-ignore lint/performance/noDelete: process.env semantics.
54
54
  if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
55
55
  else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
56
56
  rmSync(dir, { recursive: true, force: true });
@@ -424,14 +424,15 @@ describe('createSyncRuntime', () => {
424
424
  await runtime?.stop();
425
425
  });
426
426
 
427
- test('Task 8: hub-wins reconcile over divergent local content records a conflict', async () => {
427
+ test('DDR-102: reconcile over divergent local content records a diverged conflict + snapshots both sides', async () => {
428
428
  const url = 'https://hub.example.com';
429
429
  writeHubsConfig(url, 'mau_test');
430
430
  const ctx = makeCtx({ url, linkedAt: 1 });
431
- // Local disk has divergent, non-empty content.
431
+ // Local disk has divergent, non-empty content (and no journal — divergence).
432
432
  writeFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), '<button>LOCAL EDIT</button>');
433
433
 
434
- // Provider whose doc already carries different hub state.
434
+ // Provider whose doc already carries different hub state. No syncMeta
435
+ // stamp (older peer) → newest-wins falls back to hub.
435
436
  const factory = () => {
436
437
  const document = new Y.Doc();
437
438
  document.getText('html').insert(0, '<button>HUB STATE</button>');
@@ -451,7 +452,25 @@ describe('createSyncRuntime', () => {
451
452
 
452
453
  const status = runtime?.status();
453
454
  expect(status?.conflicts.length).toBeGreaterThanOrEqual(1);
454
- expect(status?.conflicts[0].kind).toBe('cold-start-hub-wins');
455
+ const conflict = status?.conflicts[0];
456
+ expect(conflict?.kind).toBe('cold-start-diverged');
457
+ expect(conflict?.winner).toBe('hub');
458
+ // Both pre-resolution versions were snapshotted to _history/<slug>/.
459
+ expect(conflict?.snapshots?.local).toBeDefined();
460
+ expect(conflict?.snapshots?.hub).toBeDefined();
461
+ const historyDir = join(ctx.paths.historyDir, 'ui-screen');
462
+ const blobs = readdirSync(historyDir).filter((f) => f.endsWith('.html'));
463
+ expect(blobs.length).toBe(2);
464
+ // The loser (local) is recoverable byte-identical from its snapshot.
465
+ const metas = readdirSync(historyDir).filter((f) => f.endsWith('.json'));
466
+ const snapshotContents = blobs.map((f) => readFileSync(join(historyDir, f), 'utf8'));
467
+ expect(snapshotContents).toContain('<button>LOCAL EDIT</button>');
468
+ expect(snapshotContents).toContain('<button>HUB STATE</button>');
469
+ expect(metas.length).toBe(2);
470
+ // Hub won → disk now carries hub state.
471
+ expect(readFileSync(join(ctx.paths.designRoot, 'ui', 'screen.html'), 'utf8')).toBe(
472
+ '<button>HUB STATE</button>'
473
+ );
455
474
  await runtime?.stop();
456
475
  });
457
476
  });
@@ -885,3 +904,339 @@ describe('toWsUrl', () => {
885
904
  expect(toWsUrl('ws://localhost:1234')).toBe('ws://localhost:1234');
886
905
  });
887
906
  });
907
+
908
+ // DDR-102 — WS multiplexing: the default factory shares ONE
909
+ // HocuspocusProviderWebsocket per hub URL across all providers.
910
+ describe('createDefaultProviderFactory — shared socket multiplexing', () => {
911
+ class FakeEmitter {
912
+ handlers = new Map<string, Set<(arg?: unknown) => void>>();
913
+ on(evt: string, cb: (arg?: unknown) => void) {
914
+ if (!this.handlers.has(evt)) this.handlers.set(evt, new Set());
915
+ this.handlers.get(evt)?.add(cb);
916
+ }
917
+ off(evt: string, cb: (arg?: unknown) => void) {
918
+ this.handlers.get(evt)?.delete(cb);
919
+ }
920
+ emit(evt: string, arg?: unknown) {
921
+ for (const cb of this.handlers.get(evt) ?? []) cb(arg);
922
+ }
923
+ }
924
+
925
+ function makeFakeModule() {
926
+ const sockets: Array<{ url: string; destroyed: boolean }> = [];
927
+ const providers: Array<{
928
+ name: string;
929
+ attached: boolean;
930
+ destroyed: boolean;
931
+ socket: unknown;
932
+ }> = [];
933
+
934
+ class HocuspocusProviderWebsocket extends FakeEmitter {
935
+ url: string;
936
+ status = 'connecting';
937
+ destroyed = false;
938
+ constructor(cfg: { url: string }) {
939
+ super();
940
+ this.url = cfg.url;
941
+ sockets.push(this);
942
+ }
943
+ destroy() {
944
+ this.destroyed = true;
945
+ }
946
+ }
947
+
948
+ class HocuspocusProvider extends FakeEmitter {
949
+ configuration: { name: string; websocketProvider: unknown; document: unknown };
950
+ awareness = undefined;
951
+ synced = false;
952
+ attached = false;
953
+ destroyed = false;
954
+ name: string;
955
+ socket: unknown;
956
+ constructor(cfg: { name: string; websocketProvider: unknown; document: unknown }) {
957
+ super();
958
+ this.configuration = cfg;
959
+ this.name = cfg.name;
960
+ this.socket = cfg.websocketProvider;
961
+ providers.push(this);
962
+ }
963
+ attach() {
964
+ this.attached = true;
965
+ }
966
+ destroy() {
967
+ this.destroyed = true;
968
+ }
969
+ }
970
+
971
+ return { mod: { HocuspocusProviderWebsocket, HocuspocusProvider }, sockets, providers };
972
+ }
973
+
974
+ test('N providers for one hub URL share ONE socket, each explicitly attached', async () => {
975
+ const { mod, sockets, providers } = makeFakeModule();
976
+ const { createDefaultProviderFactory } = await import('../sync/index.ts');
977
+ const factory = createDefaultProviderFactory(async () => mod);
978
+
979
+ await factory({ url: 'https://hub.example.com', token: 't', documentName: 'ui-a' });
980
+ await factory({ url: 'https://hub.example.com', token: 't', documentName: 'ui-b' });
981
+ await factory({ url: 'https://hub.example.com', token: 't', documentName: 'ui-c' });
982
+
983
+ expect(sockets).toHaveLength(1);
984
+ expect(sockets[0].url).toBe('wss://hub.example.com');
985
+ expect(providers).toHaveLength(3);
986
+ for (const p of providers) {
987
+ expect(p.attached).toBe(true);
988
+ expect(p.socket).toBe(sockets[0]);
989
+ }
990
+ });
991
+
992
+ test('provider destroy() detaches only; dispose() destroys the shared socket', async () => {
993
+ const { mod, sockets, providers } = makeFakeModule();
994
+ const { createDefaultProviderFactory } = await import('../sync/index.ts');
995
+ const factory = createDefaultProviderFactory(async () => mod);
996
+
997
+ const p1 = await factory({ url: 'https://h.example.com', token: 't', documentName: 'ui-a' });
998
+ p1.destroy();
999
+ expect(providers[0].destroyed).toBe(true);
1000
+ expect(sockets[0].destroyed).toBe(false); // socket survives provider death
1001
+
1002
+ factory.dispose();
1003
+ expect(sockets[0].destroyed).toBe(true);
1004
+ });
1005
+
1006
+ test('onStatus seeds from the SOCKET status and forwards provider status events', async () => {
1007
+ const { mod, sockets, providers } = makeFakeModule();
1008
+ const { createDefaultProviderFactory } = await import('../sync/index.ts');
1009
+ const factory = createDefaultProviderFactory(async () => mod);
1010
+
1011
+ const wrapped = await factory({
1012
+ url: 'https://h.example.com',
1013
+ token: 't',
1014
+ documentName: 'ui-a',
1015
+ });
1016
+ const seen: string[] = [];
1017
+ const unsub = wrapped.onStatus?.((s) => seen.push(s));
1018
+ // Seeded from socket.status ('connecting') immediately.
1019
+ expect(seen).toEqual(['connecting']);
1020
+ // Provider-level forwarded events keep flowing.
1021
+ (providers[0] as unknown as FakeEmitter).emit('status', { status: 'connected' });
1022
+ expect(seen).toEqual(['connecting', 'connected']);
1023
+ unsub?.();
1024
+ (providers[0] as unknown as FakeEmitter).emit('status', { status: 'disconnected' });
1025
+ expect(seen).toEqual(['connecting', 'connected']); // unsubscribed
1026
+ expect(sockets).toHaveLength(1);
1027
+ });
1028
+ });
1029
+
1030
+ // DDR-102 — auth-failure intelligence (classification, aggregation,
1031
+ // destroy-on-permanent + re-probe) and the honest settled boot summary.
1032
+ describe('DDR-102 — auth-failure intelligence + boot summary', () => {
1033
+ function fakeTimerQueue() {
1034
+ let nextId = 1;
1035
+ const timers = new Map<number, { cb: () => void; ms: number }>();
1036
+ return {
1037
+ setTimer: (cb: () => void, ms: number) => {
1038
+ const id = nextId++;
1039
+ timers.set(id, { cb, ms });
1040
+ return id as unknown as ReturnType<typeof setTimeout>;
1041
+ },
1042
+ clearTimer: (h: ReturnType<typeof setTimeout>) => {
1043
+ timers.delete(h as unknown as number);
1044
+ },
1045
+ /** Fire every pending timer with ms <= maxMs (one sweep). */
1046
+ fire(maxMs: number) {
1047
+ for (const [id, t] of [...timers.entries()]) {
1048
+ if (t.ms <= maxMs) {
1049
+ timers.delete(id);
1050
+ t.cb();
1051
+ }
1052
+ }
1053
+ },
1054
+ pending: () => timers.size,
1055
+ };
1056
+ }
1057
+
1058
+ /** Stub factory whose providers expose onAuthFailed + track destroys. */
1059
+ function authStubFactory() {
1060
+ const made: Array<{
1061
+ documentName: string;
1062
+ destroyed: boolean;
1063
+ emitAuthFailure: (reason: string) => void;
1064
+ document: Y.Doc;
1065
+ }> = [];
1066
+ const factory = (args: { documentName: string; document?: Y.Doc }) => {
1067
+ const document = args.document ?? new Y.Doc();
1068
+ const cbs = new Set<(info: { reason: string }) => void>();
1069
+ const entry = {
1070
+ documentName: args.documentName,
1071
+ destroyed: false,
1072
+ emitAuthFailure: (reason: string) => {
1073
+ for (const cb of cbs) cb({ reason });
1074
+ },
1075
+ document,
1076
+ };
1077
+ made.push(entry);
1078
+ return {
1079
+ document,
1080
+ onAuthFailed(cb: (info: { reason: string }) => void) {
1081
+ cbs.add(cb);
1082
+ return () => cbs.delete(cb);
1083
+ },
1084
+ onceSynced() {
1085
+ return new Promise<void>(() => {}); // never settles (rejected docs)
1086
+ },
1087
+ destroy() {
1088
+ entry.destroyed = true;
1089
+ },
1090
+ };
1091
+ };
1092
+ return { factory, made };
1093
+ }
1094
+
1095
+ test('classification + ONE aggregated warn + destroy-on-permanent + re-probe', async () => {
1096
+ const url = 'https://hub.example.com';
1097
+ writeHubsConfig(url, 'mau_test');
1098
+ const ctx = makeCtx({ url, linkedAt: 1 });
1099
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'a.html'), '<a/>');
1100
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'b.html'), '<b/>');
1101
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'c.html'), '<c/>');
1102
+
1103
+ const timers = fakeTimerQueue();
1104
+ const { factory, made } = authStubFactory();
1105
+ const warns: string[] = [];
1106
+ const origWarn = console.warn;
1107
+ console.warn = (...args: unknown[]) => {
1108
+ warns.push(args.map(String).join(' '));
1109
+ };
1110
+
1111
+ try {
1112
+ const runtime = createSyncRuntime(ctx, {
1113
+ providerFactory: factory,
1114
+ auth: {
1115
+ warnDebounceMs: 2_000,
1116
+ reprobeMs: 300_000,
1117
+ settleTimeoutMs: 15_000,
1118
+ setTimer: timers.setTimer,
1119
+ clearTimer: timers.clearTimer,
1120
+ },
1121
+ });
1122
+ await runtime?.start();
1123
+ expect(made).toHaveLength(3);
1124
+ // readdir order is not deterministic — address providers by slug.
1125
+ const bySlug = (slug: string) => {
1126
+ const m = made.find((p) => p.documentName === slug);
1127
+ if (!m) throw new Error(`no provider for ${slug}`);
1128
+ return m;
1129
+ };
1130
+
1131
+ // Hub rejects: a + b permanently (scope), c transiently (rate limit).
1132
+ bySlug('ui-a').emitAuthFailure('token not authorized for this documentName');
1133
+ bySlug('ui-b').emitAuthFailure('token not authorized for this documentName');
1134
+ bySlug('ui-c').emitAuthFailure('rate limit exceeded for this token');
1135
+
1136
+ // Per-slug statuses are honest immediately.
1137
+ const status = runtime?.status();
1138
+ expect(status?.docs?.rejected).toBe(3);
1139
+ expect(status?.rejectedSlugs?.sort()).toEqual(['ui-a', 'ui-b', 'ui-c']);
1140
+
1141
+ // Permanent classes destroyed (retry storm stopped); transient keeps its
1142
+ // provider (built-in backoff).
1143
+ expect(bySlug('ui-a').destroyed).toBe(true);
1144
+ expect(bySlug('ui-b').destroyed).toBe(true);
1145
+ expect(bySlug('ui-c').destroyed).toBe(false);
1146
+
1147
+ // No warn yet (debounced); ONE aggregated warn after the window.
1148
+ const authWarnsBefore = warns.filter((w) => w.includes('auth rejections')).length;
1149
+ expect(authWarnsBefore).toBe(0);
1150
+ timers.fire(2_000);
1151
+ const authWarns = warns.filter((w) => w.includes('auth rejections'));
1152
+ expect(authWarns).toHaveLength(1);
1153
+ // Reason-specific hints, both classes in the one warn.
1154
+ expect(authWarns[0]).toContain('not-authorized');
1155
+ expect(authWarns[0]).toContain('rate-limit');
1156
+ expect(authWarns[0]).toContain('mint a hub-wide token');
1157
+ expect(authWarns[0]).toContain('HUB_CONN_RATE_LIMIT');
1158
+
1159
+ // Re-probe (5 min): the two permanently-rejected docs reconnect with the
1160
+ // SAME doc instance (agent wiring survives the provider swap).
1161
+ const docA = bySlug('ui-a').document;
1162
+ timers.fire(300_000);
1163
+ await new Promise((res) => setTimeout(res, 10));
1164
+ const reprobed = made.slice(3);
1165
+ expect(reprobed.map((m) => m.documentName).sort()).toEqual(['ui-a', 'ui-b']);
1166
+ expect(reprobed.find((m) => m.documentName === 'ui-a')?.document).toBe(docA);
1167
+
1168
+ await runtime?.stop();
1169
+ } finally {
1170
+ console.warn = origWarn;
1171
+ }
1172
+ });
1173
+
1174
+ test('boot summary prints AFTER settle with honest counts (not the premature N/N)', async () => {
1175
+ const url = 'https://hub.example.com';
1176
+ writeHubsConfig(url, 'mau_test');
1177
+ const ctx = makeCtx({ url, linkedAt: 1 });
1178
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'good.html'), '<g/>');
1179
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'bad.html'), '<b/>');
1180
+
1181
+ const timers = fakeTimerQueue();
1182
+ const logs: string[] = [];
1183
+ const origLog = console.log;
1184
+ const origWarn = console.warn;
1185
+ console.log = (...args: unknown[]) => {
1186
+ logs.push(args.map(String).join(' '));
1187
+ };
1188
+ console.warn = () => {};
1189
+
1190
+ // 'good' syncs immediately; 'bad' is auth-rejected and never settles.
1191
+ const { factory, made } = authStubFactory();
1192
+ const mixedFactory = (args: { documentName: string; document?: Y.Doc }) => {
1193
+ if (args.documentName === 'ui-good') {
1194
+ const document = args.document ?? new Y.Doc();
1195
+ return {
1196
+ document,
1197
+ async onceSynced() {},
1198
+ destroy() {},
1199
+ };
1200
+ }
1201
+ return factory(args);
1202
+ };
1203
+
1204
+ try {
1205
+ const runtime = createSyncRuntime(ctx, {
1206
+ providerFactory: mixedFactory,
1207
+ auth: {
1208
+ settleTimeoutMs: 15_000,
1209
+ warnDebounceMs: 2_000,
1210
+ setTimer: timers.setTimer,
1211
+ clearTimer: timers.clearTimer,
1212
+ },
1213
+ });
1214
+ await runtime?.start();
1215
+ made.find((m) => m.documentName === 'ui-bad')?.emitAuthFailure('invalid token');
1216
+ // Boot prints the linking line immediately, NOT a premature N/N.
1217
+ expect(logs.some((l) => l.includes('linking to'))).toBe(true);
1218
+ expect(logs.some((l) => l.includes('synced'))).toBe(false);
1219
+
1220
+ // Let the good handshake's microtask chain run, then hit the ceiling.
1221
+ await new Promise((res) => setTimeout(res, 10));
1222
+ timers.fire(15_000);
1223
+ await new Promise((res) => setTimeout(res, 10));
1224
+
1225
+ const summary = logs.find((l) => l.includes('synced'));
1226
+ expect(summary).toBeDefined();
1227
+ expect(summary).toContain('1/2 synced');
1228
+ expect(summary).toContain('1 auth-rejected');
1229
+ expect(summary).toContain('ui-bad');
1230
+ expect(summary).toContain('invalid-token');
1231
+
1232
+ // lastSyncAt reflects the real reconcile activity of the good canvas.
1233
+ expect(runtime?.status()?.lastSyncAt).not.toBeNull();
1234
+ expect(runtime?.status()?.docs?.synced).toBe(1);
1235
+
1236
+ await runtime?.stop();
1237
+ } finally {
1238
+ console.log = origLog;
1239
+ console.warn = origWarn;
1240
+ }
1241
+ });
1242
+ });
@@ -78,3 +78,69 @@ describe('sync status store', () => {
78
78
  expect(() => store.update(snap({ state: 'offline' }))).not.toThrow();
79
79
  });
80
80
  });
81
+
82
+ // DDR-102 — the payload carries the per-doc rollup + rich conflicts additively.
83
+ describe('sync status store — DDR-102 additive fields', () => {
84
+ test('docs + rejectedSlugs flow through update() into the payload', () => {
85
+ const writes: SyncStatusPayload[] = [];
86
+ const store = createSyncStatusStore({
87
+ url: 'https://h.example.com',
88
+ canvases: 3,
89
+ write: (p) => writes.push(p),
90
+ });
91
+ store.update({
92
+ state: 'online',
93
+ queuedOps: 0,
94
+ lastSyncAt: 123,
95
+ offlineSince: null,
96
+ flash: null,
97
+ updatedAt: 456,
98
+ docs: { synced: 1, pending: 0, rejected: 2 },
99
+ rejectedSlugs: ['ui-x', 'ui-y'],
100
+ });
101
+ const p = store.get();
102
+ expect(p.docs).toEqual({ synced: 1, pending: 0, rejected: 2 });
103
+ expect(p.rejectedSlugs).toEqual(['ui-x', 'ui-y']);
104
+ expect(p.lastSyncAt).toBe(123);
105
+ });
106
+
107
+ test('addConflict carries winner + snapshots (cold-start-diverged)', () => {
108
+ const store = createSyncStatusStore({
109
+ url: 'https://h.example.com',
110
+ canvases: 1,
111
+ write: () => {},
112
+ now: () => 999,
113
+ });
114
+ store.addConflict({
115
+ slug: 'ui-maskot',
116
+ kind: 'cold-start-diverged',
117
+ winner: 'local',
118
+ snapshots: { local: '2026-06-11T10:00:00.000Z', hub: '2026-06-11T10:00:00.001Z' },
119
+ });
120
+ const c = store.get().conflicts[0];
121
+ expect(c.kind).toBe('cold-start-diverged');
122
+ expect(c.winner).toBe('local');
123
+ expect(c.snapshots?.local).toBe('2026-06-11T10:00:00.000Z');
124
+ expect(c.at).toBe(999);
125
+ });
126
+
127
+ test('old-shape payload readers: a snapshot WITHOUT docs/rejectedSlugs still works', () => {
128
+ const store = createSyncStatusStore({
129
+ url: 'https://h.example.com',
130
+ canvases: 1,
131
+ write: () => {},
132
+ });
133
+ store.update({
134
+ state: 'online',
135
+ queuedOps: 0,
136
+ lastSyncAt: null,
137
+ offlineSince: null,
138
+ flash: null,
139
+ updatedAt: 1,
140
+ });
141
+ const p = store.get();
142
+ expect(p.docs).toBeUndefined();
143
+ expect(p.rejectedSlugs).toBeUndefined();
144
+ expect(p.state).toBe('online');
145
+ });
146
+ });
@@ -43,6 +43,24 @@ describe('TourOverlay', () => {
43
43
  expect(html).not.toContain('mdcc-tour__back'); // i === 0
44
44
  });
45
45
 
46
+ test('a step with a render graphic widens the card and draws the graphic (Phase 29 / E4)', () => {
47
+ function Graphic() {
48
+ return <div className="cm-info">TWO-LAYER-DIAGRAM</div>;
49
+ }
50
+ const html = renderToStaticMarkup(
51
+ <TourOverlay
52
+ open
53
+ steps={[{ render: Graphic, title: 'Working together', body: 'Two layers.' }]}
54
+ onClose={() => {}}
55
+ onComplete={() => {}}
56
+ />
57
+ );
58
+ expect(html).toContain('mdcc-tour__card--graphic');
59
+ expect(html).toContain('mdcc-tour__graphic');
60
+ expect(html).toContain('TWO-LAYER-DIAGRAM');
61
+ expect(html).toContain('Working together');
62
+ });
63
+
46
64
  test('closed or empty renders nothing', () => {
47
65
  expect(
48
66
  renderToStaticMarkup(
@@ -241,3 +241,162 @@ describe('resizeStroke / pen — point scaling', () => {
241
241
  expect(out.points[2]).toEqual([150, 150]);
242
242
  });
243
243
  });
244
+
245
+ describe('resizeStroke / rotation — Wave G corner rotate zones', () => {
246
+ // rect center = (50, 25). Angles below are measured around it.
247
+ test('relative rotation: grab angle is the zero reference (no jump)', () => {
248
+ // Grab at angle 0° (due east of center), drag to 45° → rotation 45.
249
+ const rotRef = { angle0: 0, rot0: 0 };
250
+ const out = resizeStroke(
251
+ rect,
252
+ 'rot-se',
253
+ 50 + 70,
254
+ 25 + 70,
255
+ { shift: false, alt: false },
256
+ rotRef
257
+ );
258
+ expect(out).toEqual({ rotation: 45 });
259
+ });
260
+
261
+ test('starting rotation accumulates with the drag delta', () => {
262
+ const rotated: RectStroke = { ...rect, rotation: 30 };
263
+ const rotRef = { angle0: 0, rot0: 30 };
264
+ const out = resizeStroke(
265
+ rotated,
266
+ 'rot-ne',
267
+ 50 + 70,
268
+ 25 + 70,
269
+ { shift: false, alt: false },
270
+ rotRef
271
+ );
272
+ expect(out).toEqual({ rotation: 75 });
273
+ });
274
+
275
+ test('magnetic cardinals: within 2° of 0 lock on and serialize as undefined', () => {
276
+ const rotated: RectStroke = { ...rect, rotation: 44 };
277
+ // Drag back by 42.5° → raw −1.5° + rot0 44 = ... use angle0 50, current 7.5 → deg = 44 − 42.5 = 1.5 → locks to 0.
278
+ const cur = (Math.PI / 180) * 7.5;
279
+ const out = resizeStroke(
280
+ rotated,
281
+ 'rot-nw',
282
+ 50 + 70 * Math.cos(cur),
283
+ 25 + 70 * Math.sin(cur),
284
+ { shift: false, alt: false },
285
+ { angle0: 50, rot0: 44 }
286
+ );
287
+ expect(out).toEqual({ rotation: undefined });
288
+ });
289
+
290
+ test('Shift snaps to 15° steps', () => {
291
+ // delta = 40° → snaps to 45.
292
+ const cur = (Math.PI / 180) * 40;
293
+ const out = resizeStroke(
294
+ rect,
295
+ 'rot-sw',
296
+ 50 + 70 * Math.cos(cur),
297
+ 25 + 70 * Math.sin(cur),
298
+ { shift: true, alt: false },
299
+ { angle0: 0, rot0: 0 }
300
+ );
301
+ expect(out).toEqual({ rotation: 45 });
302
+ });
303
+
304
+ test('non-rotatable strokes refuse the rotate corners', () => {
305
+ const arrow: ArrowStroke = {
306
+ id: 'a1',
307
+ tool: 'arrow',
308
+ color: '#000',
309
+ width: 2,
310
+ x1: 0,
311
+ y1: 0,
312
+ x2: 100,
313
+ y2: 0,
314
+ };
315
+ expect(
316
+ resizeStroke(arrow, 'rot-se', 50, 50, { shift: false, alt: false }, { angle0: 0, rot0: 0 })
317
+ ).toBeNull();
318
+ });
319
+
320
+ test('polygon rotates (diamond/triangle are rotatable hosts)', () => {
321
+ const poly: PolygonStroke = {
322
+ id: 'p1',
323
+ tool: 'polygon',
324
+ shape: 'diamond',
325
+ color: '#000',
326
+ width: 2,
327
+ x: 0,
328
+ y: 0,
329
+ w: 100,
330
+ h: 50,
331
+ };
332
+ const out = resizeStroke(
333
+ poly,
334
+ 'rot-se',
335
+ 50 + 70,
336
+ 25 + 70,
337
+ { shift: false, alt: false },
338
+ { angle0: 0, rot0: 0 }
339
+ );
340
+ expect(out).toEqual({ rotation: 45 });
341
+ });
342
+ });
343
+
344
+ describe('resizeStroke / rotated strokes — Wave H anchor compensation', () => {
345
+ // rect 100×50 at origin, rotated 90° around its center (50, 25).
346
+ const rotated: RectStroke = { ...rect, rotation: 90 };
347
+ const rotP = (px: number, py: number, cx: number, cy: number, deg: number): [number, number] => {
348
+ const r = (deg * Math.PI) / 180;
349
+ const dx = px - cx;
350
+ const dy = py - cy;
351
+ return [cx + dx * Math.cos(r) - dy * Math.sin(r), cy + dx * Math.sin(r) + dy * Math.cos(r)];
352
+ };
353
+
354
+ test('se-corner drag keeps the nw corner fixed in WORLD space', () => {
355
+ // Cursor in the LOCAL frame (applyResize inverse-rotates before calling).
356
+ const out = resizeStroke(rotated, 'se', 140, 90) as RectStroke;
357
+ expect(out.w).toBeCloseTo(140, 6);
358
+ expect(out.h).toBeCloseTo(90, 6);
359
+ const before = rotP(0, 0, 50, 25, 90);
360
+ const after = rotP(out.x, out.y, out.x + out.w / 2, out.y + out.h / 2, 90);
361
+ expect(after[0]).toBeCloseTo(before[0], 6);
362
+ expect(after[1]).toBeCloseTo(before[1], 6);
363
+ });
364
+
365
+ test('e-edge drag keeps the west edge midpoint fixed in WORLD space', () => {
366
+ const out = resizeStroke(rotated, 'e', 130, 25) as RectStroke;
367
+ expect(out.w).toBeCloseTo(130, 6);
368
+ const before = rotP(0, 25, 50, 25, 90);
369
+ const after = rotP(out.x, out.y + out.h / 2, out.x + out.w / 2, out.y + out.h / 2, 90);
370
+ expect(after[0]).toBeCloseTo(before[0], 6);
371
+ expect(after[1]).toBeCloseTo(before[1], 6);
372
+ });
373
+
374
+ test('Alt resize keeps the center (no compensation path)', () => {
375
+ const out = resizeStroke(rotated, 'se', 130, 80, { shift: false, alt: true }) as RectStroke;
376
+ expect(out.x + out.w / 2).toBeCloseTo(50, 6);
377
+ expect(out.y + out.h / 2).toBeCloseTo(25, 6);
378
+ });
379
+
380
+ test('unrotated strokes are byte-stable (no shift applied)', () => {
381
+ expect(resizeStroke(rect, 'se', 120, 80)).toEqual({ x: 0, y: 0, w: 120, h: 80 });
382
+ });
383
+
384
+ test('rotated ellipse: se drag keeps the nw bbox corner fixed in WORLD space', () => {
385
+ const ell: EllipseStroke = {
386
+ id: 'e1',
387
+ tool: 'ellipse',
388
+ color: '#000',
389
+ width: 2,
390
+ cx: 50,
391
+ cy: 25,
392
+ rx: 50,
393
+ ry: 25,
394
+ rotation: 45,
395
+ };
396
+ const out = resizeStroke(ell, 'se', 140, 90) as EllipseStroke;
397
+ const before = rotP(0, 0, 50, 25, 45);
398
+ const after = rotP(out.cx - out.rx, out.cy - out.ry, out.cx, out.cy, 45);
399
+ expect(after[0]).toBeCloseTo(before[0], 6);
400
+ expect(after[1]).toBeCloseTo(before[1], 6);
401
+ });
402
+ });
@@ -15,7 +15,6 @@ import {
15
15
  DEFAULT_SNAP_TOLERANCE,
16
16
  DRAG_THRESHOLD_PX,
17
17
  type DragEvent,
18
- type DragState,
19
18
  dragReducer,
20
19
  selectionsToArtboardIds,
21
20
  } from '../use-artboard-drag.tsx';
Binary file