@zenuml/core 3.46.0 → 3.46.1

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 (55) hide show
  1. package/.claude/skills/dia-scoring/SKILL.md +139 -0
  2. package/.claude/skills/dia-scoring/agents/openai.yaml +7 -0
  3. package/.claude/skills/dia-scoring/references/selectors-and-keys.md +253 -0
  4. package/CLAUDE.md +1 -1
  5. package/bun.lock +25 -11
  6. package/cy/canonical-history.html +908 -0
  7. package/cy/compare-case.html +357 -0
  8. package/cy/compare-cases.js +824 -0
  9. package/cy/compare.html +35 -0
  10. package/cy/diff-algorithm.js +199 -0
  11. package/cy/element-report.html +705 -0
  12. package/cy/icons-test.html +29 -0
  13. package/cy/legacy-vs-html.html +291 -0
  14. package/cy/native-diff-ext/background.js +60 -0
  15. package/cy/native-diff-ext/bridge.js +26 -0
  16. package/cy/native-diff-ext/content.js +194 -0
  17. package/cy/parity-test.html +122 -0
  18. package/cy/return-in-nested-if.html +29 -0
  19. package/cy/svg-preview.html +56 -0
  20. package/cy/svg-test.html +21 -0
  21. package/cy/theme-default-test.html +28 -0
  22. package/dist/stats.html +1 -1
  23. package/dist/zenuml.esm.mjs +16352 -15223
  24. package/dist/zenuml.js +701 -575
  25. package/docs/superpowers/plans/2026-03-23-svg-parity-features.md +283 -0
  26. package/index.html +568 -73
  27. package/package.json +15 -4
  28. package/scripts/analyze-compare-case/collect-data.mjs +991 -0
  29. package/scripts/analyze-compare-case/config.mjs +102 -0
  30. package/scripts/analyze-compare-case/geometry.mjs +101 -0
  31. package/scripts/analyze-compare-case/native-diff.mjs +224 -0
  32. package/scripts/analyze-compare-case/output.mjs +74 -0
  33. package/scripts/analyze-compare-case/panel-diff.mjs +114 -0
  34. package/scripts/analyze-compare-case/report.mjs +157 -0
  35. package/scripts/analyze-compare-case/residual-scopes.mjs +325 -0
  36. package/scripts/analyze-compare-case/scoring.mjs +816 -0
  37. package/scripts/analyze-compare-case.mjs +149 -0
  38. package/scripts/snapshot-dual.js +34 -34
  39. package/skills/dia-scoring/SKILL.md +129 -0
  40. package/skills/dia-scoring/agents/openai.yaml +7 -0
  41. package/skills/dia-scoring/references/selectors-and-keys.md +253 -0
  42. package/test-setup.ts +8 -0
  43. package/types/index.d.ts +56 -0
  44. package/vite.config.ts +4 -0
  45. package/dist/10029-icon-service-Function-Apps-ObflOLuF.js +0 -5
  46. package/dist/Res_AWS-Identity-Access-Management_IAM-Access-Analyzer_48-BPq60XMY.js +0 -11
  47. package/dist/Res_AWS-Lambda_Lambda-Function_48-Co38UB_2.js +0 -12
  48. package/dist/Res_Amazon-EC2_Instance_48-CRaqbNUl.js +0 -12
  49. package/dist/Res_Amazon-Simple-Notification-Service_Topic_48-q13mxUeM.js +0 -11
  50. package/dist/Res_Amazon-Simple-Queue-Service_Queue_48-D2-8gbFw.js +0 -11
  51. package/dist/Robustness_Diagram_Boundary-nYnmTPs8.js +0 -10
  52. package/dist/Robustness_Diagram_Control-DLNLoMxd.js +0 -11
  53. package/dist/Robustness_Diagram_Entity-Be3kcbIE.js +0 -11
  54. package/dist/actor-BMj_HFpo.js +0 -11
  55. package/dist/database-BKHQQWQK.js +0 -8
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
7
+ <title>Icons Test</title>
8
+ <style>
9
+ body {
10
+ margin: 0;
11
+ }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <div id="diagram" class="diagram">
16
+ <pre class="zenuml" style="margin: 0">
17
+ @Actor User
18
+ @Database DB &lt;&lt;database&gt;&gt;
19
+ @Queue MQ &lt;&lt;sqs&gt;&gt;
20
+
21
+ User.login() {
22
+ DB.verify()
23
+ MQ.enqueue()
24
+ }
25
+ </pre>
26
+ </div>
27
+ <script type="module" src="/src/main.ts"></script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,291 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Legacy vs HTML Vertical Mode</title>
6
+ <style>
7
+ body { margin: 0; font-family: system-ui, sans-serif; }
8
+ .page-header { padding: 8px 16px; background: #1e293b; color: white; display: flex; gap: 12px; align-items: center; }
9
+ .page-header h2 { margin: 0; font-size: 14px; font-weight: 600; }
10
+ .nav { display: flex; gap: 8px; margin-left: auto; align-items: center; }
11
+ .nav a { color: #93c5fd; text-decoration: none; font-size: 12px; padding: 4px 10px; background: #334155; border-radius: 4px; }
12
+ .nav a:hover { text-decoration: underline; }
13
+ .nav a.hidden { visibility: hidden; }
14
+ .container { display: flex; gap: 0; }
15
+ .panel { flex: 1; overflow: auto; border-right: 1px solid #e2e8f0; }
16
+ .panel:last-child { border-right: none; }
17
+ .panel-header { padding: 6px 12px; background: #334155; color: white; font-size: 13px; font-weight: 600; }
18
+ .panel-content { padding: 0; background: white; }
19
+ .panel-info { padding: 8px 12px; background: #f1f5f9; font-size: 12px; font-family: monospace; color: #475569; border-top: 1px solid #e2e8f0; }
20
+ .delta { padding: 8px 16px; font-size: 13px; font-family: monospace; display: none; }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <div class="page-header">
25
+ <h2 id="case-name">Vertical Mode Comparison</h2>
26
+ <div class="nav">
27
+ <a id="prev-link" href="#">&larr; Prev</a>
28
+ <a id="next-link" href="#">Next &rarr;</a>
29
+ </div>
30
+ </div>
31
+ <div id="delta-bar" class="delta"></div>
32
+ <div class="container">
33
+ <div class="panel">
34
+ <div class="panel-header">HTML Mode (VerticalCoordinates)</div>
35
+ <div class="panel-content" id="html-output"></div>
36
+ <div class="panel-info" id="html-info">Loading...</div>
37
+ </div>
38
+ <div class="panel">
39
+ <div class="panel-header">Legacy Mode (DOM Measurement)</div>
40
+ <div class="panel-content" id="legacy-output"></div>
41
+ <div class="panel-info" id="legacy-info">Loading...</div>
42
+ </div>
43
+ </div>
44
+
45
+ <script type="module">
46
+ import ZenUml from "/src/core.tsx";
47
+
48
+ const CASES = {
49
+ "return-in-nested-if": `A.m {
50
+ if (condition) {
51
+ return ret
52
+ if(x) {
53
+ new B
54
+ }
55
+ }
56
+ }`,
57
+ "vertical-1": `// red
58
+ // green
59
+ a = A.m111
60
+ new E`,
61
+ "vertical-2": `// [red]
62
+ new B`,
63
+ "vertical-3": `if(x) {
64
+ // comment
65
+ new A
66
+ } else {
67
+ new B
68
+ }
69
+
70
+ new C
71
+ try {
72
+ new D
73
+ } catch {
74
+ par {
75
+ new E
76
+ new F
77
+ }
78
+ }`,
79
+ "vertical-4": `if(x) {
80
+ // comment
81
+ new A
82
+ } else {
83
+ new B
84
+ }
85
+
86
+ new C
87
+ try {
88
+ new D
89
+ } catch {
90
+ par {
91
+ new E
92
+ new F
93
+ if(x) {
94
+ new X
95
+ } else {
96
+ new Y
97
+ }
98
+ }
99
+ }`,
100
+ "vertical-5": `par {
101
+ new F
102
+ if(x) {
103
+ new X
104
+ } else {
105
+ try {
106
+ new Y
107
+ } catch {
108
+ par {
109
+ new G
110
+ if(x) {
111
+ new H
112
+ } else {
113
+ new I
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }`,
119
+ "vertical-6": `new a
120
+ if(x) {
121
+ new b
122
+ } else {
123
+ new c
124
+ new e
125
+ }
126
+ new D`,
127
+ "vertical-7": `A.method
128
+ section(){
129
+ new B
130
+ }`,
131
+ "vertical-8": `new Creation() {
132
+ return from_creation
133
+ }
134
+ return "from if to original source"
135
+ try {
136
+ new AHasAVeryLongNameLongNameLongNameLongName() {
137
+ new CreatWithinCreat()
138
+ }
139
+ }`,
140
+ "vertical-9": `A0->A0: self
141
+ new A`,
142
+ "vertical-10": `new E
143
+ E.messageA()
144
+ new A {
145
+ if (x) {
146
+ new D
147
+ }
148
+ new B {
149
+ new C
150
+ }
151
+ }`,
152
+ "vertical-11": `A.call {
153
+ // pre creation
154
+ A->B: prep
155
+ a = new A()
156
+ a->B: post
157
+ }`,
158
+ };
159
+
160
+ const caseNames = Object.keys(CASES);
161
+ const params = new URLSearchParams(window.location.search);
162
+ const caseName = params.get("case") || caseNames[0];
163
+ const code = CASES[caseName];
164
+
165
+ // Update title
166
+ document.title = `Legacy vs HTML: ${caseName}`;
167
+ document.getElementById("case-name").textContent = caseName;
168
+
169
+ // Prev/Next navigation
170
+ const idx = caseNames.indexOf(caseName);
171
+ const prevLink = document.getElementById("prev-link");
172
+ const nextLink = document.getElementById("next-link");
173
+ if (idx > 0) {
174
+ prevLink.href = `?case=${encodeURIComponent(caseNames[idx - 1])}`;
175
+ } else {
176
+ prevLink.classList.add("hidden");
177
+ }
178
+ if (idx < caseNames.length - 1) {
179
+ nextLink.href = `?case=${encodeURIComponent(caseNames[idx + 1])}`;
180
+ } else {
181
+ nextLink.classList.add("hidden");
182
+ }
183
+
184
+ if (code === undefined) {
185
+ const errP = document.createElement("p");
186
+ errP.style.color = "red";
187
+ errP.textContent = "Unknown case: " + caseName;
188
+ document.getElementById("html-output").appendChild(errP);
189
+ } else {
190
+ function createPanel(containerId, mode) {
191
+ const container = document.getElementById(containerId);
192
+ const pre = document.createElement("pre");
193
+ pre.className = "zenuml";
194
+ pre.style.margin = "0";
195
+ pre.textContent = code;
196
+ container.appendChild(pre);
197
+
198
+ const zenUml = new ZenUml(pre);
199
+ zenUml.setVerticalMode(mode);
200
+ zenUml.render(code, { theme: "theme-default" });
201
+ return zenUml;
202
+ }
203
+
204
+ function measureCreationParticipants(containerId) {
205
+ const container = document.getElementById(containerId);
206
+ const diagram = container.querySelector(".sequence-diagram");
207
+ if (!diagram) return {};
208
+
209
+ // Hide footer
210
+ const footer = container.querySelector(".footer");
211
+ if (footer) footer.style.display = "none";
212
+
213
+ const dRect = diagram.getBoundingClientRect();
214
+ const results = {};
215
+ container.querySelectorAll("[data-participant-id]").forEach(el => {
216
+ const name = el.getAttribute("data-participant-id");
217
+ // Check if this is a creation participant (lifeline paddingTop > 20)
218
+ const lifeline = container.querySelector(`[id="${name}"]`);
219
+ const pt = lifeline ? parseFloat(lifeline.style.paddingTop) : 0;
220
+ if (pt > 20) {
221
+ const rect = el.getBoundingClientRect();
222
+ results[name] = Math.round(rect.top - dRect.top);
223
+ }
224
+ });
225
+ return results;
226
+ }
227
+
228
+ // Render both panels
229
+ createPanel("html-output", "html");
230
+ createPanel("legacy-output", "legacy");
231
+
232
+ // Poll until both panels have .sequence-diagram, then measure
233
+ const poll = setInterval(() => {
234
+ const htmlDiag = document.querySelector('#html-output .sequence-diagram');
235
+ const legacyDiag = document.querySelector('#legacy-output .sequence-diagram');
236
+ if (!htmlDiag || !legacyDiag) return;
237
+
238
+ clearInterval(poll);
239
+
240
+ setTimeout(() => {
241
+ const htmlPositions = measureCreationParticipants("html-output");
242
+ const legacyPositions = measureCreationParticipants("legacy-output");
243
+
244
+ const allNames = new Set([...Object.keys(htmlPositions), ...Object.keys(legacyPositions)]);
245
+
246
+ if (allNames.size === 0) {
247
+ document.getElementById("html-info").textContent = "No creation participants";
248
+ document.getElementById("legacy-info").textContent = "No creation participants";
249
+ const bar = document.getElementById("delta-bar");
250
+ bar.style.display = "block";
251
+ bar.textContent = "No creation participants in this case";
252
+ bar.style.background = "#e2e8f0";
253
+ bar.style.color = "#475569";
254
+ return;
255
+ }
256
+
257
+ const htmlLines = [];
258
+ const legacyLines = [];
259
+ const deltaLines = [];
260
+ let allMatch = true;
261
+
262
+ for (const name of allNames) {
263
+ const h = htmlPositions[name];
264
+ const l = legacyPositions[name];
265
+ htmlLines.push(`${name}: ${h != null ? h + 'px' : 'n/a'}`);
266
+ legacyLines.push(`${name}: ${l != null ? l + 'px' : 'n/a'}`);
267
+ if (h != null && l != null) {
268
+ const delta = l - h;
269
+ if (delta !== 0) allMatch = false;
270
+ deltaLines.push(`${name}: ${delta === 0 ? 'identical' : delta + 'px'} (html=${h}, legacy=${l})`);
271
+ }
272
+ }
273
+
274
+ document.getElementById("html-info").textContent = htmlLines.join(' | ');
275
+ document.getElementById("legacy-info").textContent = legacyLines.join(' | ');
276
+
277
+ const bar = document.getElementById("delta-bar");
278
+ bar.style.display = "block";
279
+ bar.textContent = allMatch
280
+ ? `All creation participants identical`
281
+ : deltaLines.join(' | ');
282
+ bar.style.background = allMatch ? "#d1fae5" : "#fef3c7";
283
+ bar.style.color = allMatch ? "#065f46" : "#92400e";
284
+
285
+ console.info("[Parity] %s: %s", caseName, allMatch ? "all identical" : deltaLines.join(", "));
286
+ }, 500);
287
+ }, 100);
288
+ }
289
+ </script>
290
+ </body>
291
+ </html>
@@ -0,0 +1,60 @@
1
+ // Background service worker: uses chrome.debugger + CDP for element screenshots
2
+
3
+ chrome.action.onClicked.addListener(async (tab) => {
4
+ if (!tab.url || !tab.url.includes("compare-case.html")) return;
5
+ try {
6
+ const response = await chrome.tabs.sendMessage(tab.id, { action: "native-diff" });
7
+ console.log("[native-diff-ext bg]", response?.error || `Done: ${response?.pixelPct}%`);
8
+ } catch (err) {
9
+ console.error("[native-diff-ext bg] Failed:", err);
10
+ }
11
+ });
12
+
13
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
14
+ if (msg.action === "cdp-screenshot") {
15
+ cdpScreenshot(sender.tab.id, msg.selector)
16
+ .then((dataUrl) => sendResponse({ dataUrl }))
17
+ .catch((err) => sendResponse({ error: err.message }));
18
+ return true;
19
+ }
20
+ });
21
+
22
+ // Take a single element screenshot
23
+ async function cdpScreenshot(tabId, selector) {
24
+ const target = { tabId };
25
+
26
+ try {
27
+ await chrome.debugger.attach(target, "1.3");
28
+ const { root } = await cdp(target, "DOM.getDocument", {});
29
+ return await screenshotNode(target, root.nodeId, selector);
30
+ } finally {
31
+ try { await chrome.debugger.detach(target); } catch (_) {}
32
+ }
33
+ }
34
+
35
+ async function screenshotNode(target, rootNodeId, selector) {
36
+ const { nodeId } = await cdp(target, "DOM.querySelector", {
37
+ nodeId: rootNodeId,
38
+ selector,
39
+ });
40
+ if (!nodeId) throw new Error(`Element not found: ${selector}`);
41
+
42
+ const { model } = await cdp(target, "DOM.getBoxModel", { nodeId });
43
+ const border = model.border;
44
+ const x = border[0];
45
+ const y = border[1];
46
+ const width = Math.ceil(border[2] - border[0]);
47
+ const height = Math.ceil(border[5] - border[1]);
48
+
49
+ const { data } = await cdp(target, "Page.captureScreenshot", {
50
+ format: "png",
51
+ clip: { x, y, width, height, scale: 1 },
52
+ captureBeyondViewport: true,
53
+ });
54
+
55
+ return "data:image/png;base64," + data;
56
+ }
57
+
58
+ function cdp(target, method, params) {
59
+ return chrome.debugger.sendCommand(target, method, params);
60
+ }
@@ -0,0 +1,26 @@
1
+ // Bridge script: runs in ISOLATED world
2
+ // Relays messages between content.js (MAIN world) and background.js (service worker)
3
+
4
+ // Content.js → bridge → background.js: screenshot requests
5
+ window.addEventListener("message", (event) => {
6
+ if (event.data && event.data.type === "native-diff-screenshot") {
7
+ console.log("[native-diff-ext bridge] Screenshot request:", event.data.selector);
8
+ chrome.runtime.sendMessage(
9
+ { action: "cdp-screenshot", selector: event.data.selector },
10
+ (response) => {
11
+ window.postMessage({
12
+ type: "native-diff-screenshot-response",
13
+ dataUrl: response?.dataUrl,
14
+ error: response?.error,
15
+ }, "*");
16
+ }
17
+ );
18
+ }
19
+ });
20
+
21
+ // Icon click → content.js: trigger diff
22
+ chrome.runtime.onMessage.addListener((msg) => {
23
+ if (msg.action === "native-diff") {
24
+ window.postMessage({ type: "native-diff-trigger" }, "*");
25
+ }
26
+ });
@@ -0,0 +1,194 @@
1
+ // Content script: runs in MAIN world (has access to page's window functions)
2
+ // Uses CDP element screenshots via bridge.js → background.js
3
+ // Hides neighboring panels before each screenshot to prevent overlap.
4
+ //
5
+ // Diff algorithm lives in cy/diff-algorithm.js, loaded by compare-case.html
6
+ // and exposed as window.diffFromImages. This script only handles screenshots
7
+ // and orchestration.
8
+
9
+ // Auto-run on page load
10
+ window.addEventListener("load", () => {
11
+ console.log("[native-diff-ext] Page loaded, waiting 1s for renderers...");
12
+ setTimeout(() => {
13
+ console.log("[native-diff-ext] Starting native diff...");
14
+ runNativeDiff();
15
+ }, 1000);
16
+ });
17
+
18
+ // Icon click trigger from bridge.js
19
+ window.addEventListener("message", (event) => {
20
+ if (event.data && event.data.type === "native-diff-trigger") {
21
+ console.log("[native-diff-ext] Triggered by icon click");
22
+ runNativeDiff();
23
+ }
24
+ });
25
+
26
+ // Request a single CDP element screenshot via bridge
27
+ function screenshotOne(selector) {
28
+ return new Promise((resolve) => {
29
+ function handler(event) {
30
+ if (event.data && event.data.type === "native-diff-screenshot-response") {
31
+ window.removeEventListener("message", handler);
32
+ resolve(event.data);
33
+ }
34
+ }
35
+ window.addEventListener("message", handler);
36
+ window.postMessage({ type: "native-diff-screenshot", selector }, "*");
37
+ });
38
+ }
39
+
40
+ async function runNativeDiff() {
41
+ if (typeof window.prepareHtmlForCapture !== "function") {
42
+ console.error("[native-diff-ext] prepareHtmlForCapture not found");
43
+ return;
44
+ }
45
+ if (typeof window.diffFromImages !== "function") {
46
+ console.error("[native-diff-ext] diffFromImages not found — compare-case.html must load diff-algorithm.js");
47
+ return;
48
+ }
49
+
50
+ // 1. Prepare: hide HTML chrome (same as skill's page.evaluate step)
51
+ console.log("[native-diff-ext] Preparing HTML for capture...");
52
+ window.prepareHtmlForCapture();
53
+
54
+ // 2. Determine selectors
55
+ const htmlSelector = document.querySelector("#html-output .frame")
56
+ ? "#html-output .frame"
57
+ : "#html-output .sequence-diagram";
58
+ const svgSelector = "#svg-output > svg";
59
+
60
+ // 3. Screenshot HTML (CDP clip isolates the element — no need to hide siblings)
61
+ console.log("[native-diff-ext] Taking HTML screenshot...");
62
+ const htmlCapture = await screenshotOne(htmlSelector);
63
+
64
+ if (htmlCapture.error) {
65
+ console.error("[native-diff-ext] HTML screenshot failed:", htmlCapture.error);
66
+ window.restoreHtmlAfterCapture();
67
+ return;
68
+ }
69
+
70
+ // 4. Screenshot SVG
71
+ console.log("[native-diff-ext] Taking SVG screenshot...");
72
+ const svgCapture = await screenshotOne(svgSelector);
73
+
74
+ // 5. Restore HTML chrome
75
+ window.restoreHtmlAfterCapture();
76
+
77
+ if (svgCapture.error) {
78
+ console.error("[native-diff-ext] SVG screenshot failed:", svgCapture.error);
79
+ return;
80
+ }
81
+
82
+ console.log("[native-diff-ext] Screenshots captured. Running diff...");
83
+
84
+ // 6. Run diff via shared algorithm (exposed by compare-case.html)
85
+ const result = await window.diffFromImages(htmlCapture.dataUrl, svgCapture.dataUrl);
86
+ console.log("[native-diff-ext] Done!", result.pixelPct + "% pixel match", result.posPct + "% position-only match");
87
+
88
+ // Post result back for icon-click flow
89
+ window.postMessage({ type: "native-diff-result", result }, "*");
90
+
91
+ // Batch mode: if __cr_cases is set in localStorage, save result and auto-advance
92
+ try {
93
+ const batchCases = localStorage.getItem("__cr_cases");
94
+ if (batchCases) {
95
+ const cases = JSON.parse(batchCases);
96
+ const results = JSON.parse(localStorage.getItem("__cr_results") || "{}");
97
+ const currentCase = new URLSearchParams(window.location.search).get("case");
98
+ if (currentCase && !results[currentCase]) {
99
+ // Get the DSL for this case from the page (exposed by compare-case.html)
100
+ const dsl = window.__currentDSL || "";
101
+
102
+ results[currentCase] = { score: result.pixelPct, posScore: result.posPct, dsl };
103
+ localStorage.setItem("__cr_results", JSON.stringify(results));
104
+ const doneCount = Object.keys(results).length;
105
+ console.log(`[native-diff-ext] Batch: ${currentCase}=${result.pixelPct}% px / ${result.posPct}% pos (${doneCount}/${cases.length})`);
106
+ if (doneCount < cases.length) {
107
+ const idx = cases.indexOf(currentCase);
108
+ if (idx >= 0 && idx + 1 < cases.length) {
109
+ setTimeout(() => {
110
+ window.location.href = `/cy/compare-case.html?case=${cases[idx + 1]}`;
111
+ }, 200);
112
+ }
113
+ } else {
114
+ const elapsed = ((Date.now() - parseInt(localStorage.getItem("__cr_start") || "0")) / 1000).toFixed(1);
115
+ console.log(`[native-diff-ext] Batch DONE in ${elapsed}s`);
116
+ // Save to IndexedDB for history tracking
117
+ saveBatchToHistory(results, elapsed);
118
+ // Clear batch vars so extension doesn't re-trigger on subsequent page loads
119
+ localStorage.removeItem("__cr_cases");
120
+ localStorage.removeItem("__cr_results");
121
+ localStorage.removeItem("__cr_start");
122
+ }
123
+ }
124
+ }
125
+ } catch (e) { /* batch mode is optional, don't break normal flow */ }
126
+ }
127
+
128
+ // ---- IndexedDB history storage ----
129
+ // Database: "canonical-history", store: "runs"
130
+ // Each run: { timestamp, elapsed, cases: { name: { score, dsl } }, average }
131
+
132
+ function openHistoryDB() {
133
+ return new Promise((resolve, reject) => {
134
+ const req = indexedDB.open("canonical-history", 1);
135
+ req.onupgradeneeded = () => {
136
+ const db = req.result;
137
+ if (!db.objectStoreNames.contains("runs")) {
138
+ db.createObjectStore("runs", { keyPath: "timestamp" });
139
+ }
140
+ };
141
+ req.onsuccess = () => resolve(req.result);
142
+ req.onerror = () => reject(req.error);
143
+ });
144
+ }
145
+
146
+ async function saveBatchToHistory(results, elapsed) {
147
+ try {
148
+ const db = await openHistoryDB();
149
+ const scores = Object.values(results).map(r => typeof r === "object" ? r.score : r);
150
+ const posScores = Object.values(results).map(r => {
151
+ if (typeof r === "object" && typeof r.posScore === "number") return r.posScore;
152
+ return typeof r === "object" ? r.score : r;
153
+ });
154
+ const average = scores.length > 0
155
+ ? parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1))
156
+ : 0;
157
+ const averagePos = posScores.length > 0
158
+ ? parseFloat((posScores.reduce((a, b) => a + b, 0) / posScores.length).toFixed(1))
159
+ : average;
160
+
161
+ const record = {
162
+ timestamp: new Date().toISOString(),
163
+ elapsed,
164
+ cases: results,
165
+ average,
166
+ averagePos,
167
+ caseCount: Object.keys(results).length,
168
+ };
169
+
170
+ const tx = db.transaction("runs", "readwrite");
171
+ tx.objectStore("runs").add(record);
172
+ await new Promise((resolve, reject) => {
173
+ tx.oncomplete = resolve;
174
+ tx.onerror = () => reject(tx.error);
175
+ });
176
+
177
+ console.log(`[native-diff-ext] History saved: avg=${average}% px / ${averagePos}% pos, ${Object.keys(results).length} cases`);
178
+ db.close();
179
+ } catch (e) {
180
+ console.error("[native-diff-ext] Failed to save history:", e);
181
+ }
182
+ }
183
+
184
+ // Expose history reader for dashboard pages
185
+ window.__getCanonicalHistory = async function() {
186
+ const db = await openHistoryDB();
187
+ const tx = db.transaction("runs", "readonly");
188
+ const store = tx.objectStore("runs");
189
+ return new Promise((resolve, reject) => {
190
+ const req = store.getAll();
191
+ req.onsuccess = () => { db.close(); resolve(req.result); };
192
+ req.onerror = () => { db.close(); reject(req.error); };
193
+ });
194
+ };