@zenuml/core 3.47.1 → 3.47.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.
- package/.agents/skills/babysit-pr/SKILL.md +223 -0
- package/.agents/skills/babysit-pr/agents/openai.yaml +7 -0
- package/.agents/skills/dia-scoring/SKILL.md +139 -0
- package/.agents/skills/dia-scoring/agents/openai.yaml +7 -0
- package/.agents/skills/dia-scoring/references/selectors-and-keys.md +253 -0
- package/.agents/skills/land-pr/SKILL.md +120 -0
- package/.agents/skills/propagate-core-release/SKILL.md +205 -0
- package/.agents/skills/propagate-core-release/agents/openai.yaml +7 -0
- package/.agents/skills/propagate-core-release/references/downstreams.md +42 -0
- package/.agents/skills/ship-branch/SKILL.md +105 -0
- package/.agents/skills/submit-branch/SKILL.md +76 -0
- package/.agents/skills/validate-branch/SKILL.md +72 -0
- package/.claude/skills/emoji-eval/SKILL.md +187 -0
- package/.claude/skills/propagate-core-release/SKILL.md +81 -76
- package/.claude/skills/propagate-core-release/agents/openai.yaml +2 -2
- package/.claude/skills/zenuml-ux-research/SKILL.md +183 -0
- package/.claude/skills/zenuml-ux-research/references/assertion-catalog.md +261 -0
- package/.claude/skills/zenuml-ux-research/references/best-practices-overview.md +56 -0
- package/.claude/skills/zenuml-ux-research/references/report-template.md +89 -0
- package/.claude/skills/zenuml-ux-research/references/scenarios/edit-message-label.md +37 -0
- package/.claude/skills/zenuml-ux-research/references/scenarios/insert-message.md +36 -0
- package/.claude/skills/zenuml-ux-research/references/scenarios/insert-participant.md +31 -0
- package/.claude/skills/zenuml-ux-research/references/scenarios/rename-participant.md +33 -0
- package/.claude/skills/zenuml-ux-research/references/scenarios/undo-insert.md +35 -0
- package/AGENTS.md +1 -1
- package/dist/stats.html +1 -1
- package/dist/zenuml.esm.mjs +22732 -20169
- package/dist/zenuml.js +590 -543
- package/docs/superpowers/plans/2026-03-30-emoji-support.md +1220 -0
- package/docs/superpowers/plans/2026-03-30-self-correcting-scoring.md +206 -0
- package/docs/superpowers/plans/2026-04-15-keyboard-editing-on-diagram.md +1992 -0
- package/docs/superpowers/plans/2026-04-15-zenuml-ux-research-skill.md +1452 -0
- package/docs/ux-research/.gitkeep +0 -0
- package/docs/ux-research/2026-04-15-rename-participant.md +156 -0
- package/docs/ux-research/2026-04-18-insert-participant.md +151 -0
- package/e2e/data/compare-cases.js +233 -0
- package/e2e/fixtures/create-message.html +26 -0
- package/e2e/fixtures/editable-label.html +1 -0
- package/e2e/fixtures/empty-diagram.html +23 -0
- package/e2e/fixtures/insert-participant.html +23 -0
- package/e2e/fixtures/reorder-cross-fragment.html +31 -0
- package/e2e/fixtures/reorder-fragment.html +29 -0
- package/e2e/fixtures/reorder-message.html +27 -0
- package/e2e/fixtures/type-switch.html +29 -0
- package/e2e/tools/compare-case.html +16 -2
- package/index.html +44 -0
- package/package.json +3 -3
- package/playwright.config.ts +1 -1
- package/scripts/analyze-compare-case/collect-data.mjs +139 -16
- package/scripts/analyze-compare-case/config.mjs +1 -1
- package/scripts/analyze-compare-case/report.mjs +3 -0
- package/scripts/analyze-compare-case/residual-scopes.mjs +23 -1
- package/scripts/analyze-compare-case/scoring.mjs +1 -0
|
@@ -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>reorder fragment</title>
|
|
8
|
+
<style>
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
}
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
|
|
15
|
+
<body>
|
|
16
|
+
<div id="diagram" class="diagram">
|
|
17
|
+
<pre class="zenuml" style="margin: 0">
|
|
18
|
+
A
|
|
19
|
+
B
|
|
20
|
+
C
|
|
21
|
+
if(ready) {
|
|
22
|
+
A->B: alpha
|
|
23
|
+
A->C: beta
|
|
24
|
+
}
|
|
25
|
+
</pre>
|
|
26
|
+
</div>
|
|
27
|
+
<script type="module" src="/src/main-e2e.ts"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
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>reorder message</title>
|
|
8
|
+
<style>
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
}
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
|
|
15
|
+
<body>
|
|
16
|
+
<div id="diagram" class="diagram">
|
|
17
|
+
<pre class="zenuml" style="margin: 0">
|
|
18
|
+
A
|
|
19
|
+
B
|
|
20
|
+
C
|
|
21
|
+
A->B: first
|
|
22
|
+
A->C: second
|
|
23
|
+
</pre>
|
|
24
|
+
</div>
|
|
25
|
+
<script type="module" src="/src/main-e2e.ts"></script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -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>vue-sequence type switch</title>
|
|
8
|
+
<style>
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
/* mostly for demo on mobile */
|
|
12
|
+
}
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
|
|
16
|
+
<body>
|
|
17
|
+
<div id="diagram" class="diagram">
|
|
18
|
+
<pre class="zenuml" style="margin: 0">
|
|
19
|
+
title Type Switch
|
|
20
|
+
A->B.login()
|
|
21
|
+
B->C: validate
|
|
22
|
+
B-->A: token
|
|
23
|
+
</pre
|
|
24
|
+
>
|
|
25
|
+
</div>
|
|
26
|
+
<!-- built files will be auto injected -->
|
|
27
|
+
<script type="module" src="/src/main-e2e.ts"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
.nav button { padding: 4px 12px; background: #7c3aed; border: none; border-radius: 4px; color: white; font-size: 12px; cursor: pointer; }
|
|
18
18
|
.nav button:hover { background: #6d28d9; }
|
|
19
19
|
.nav button:disabled { background: #64748b; cursor: wait; }
|
|
20
|
+
.nav-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: #334155; border: none; border-radius: 4px; color: white; font-size: 16px; cursor: pointer; text-decoration: none; line-height: 1; }
|
|
21
|
+
.nav-btn:hover { background: #475569; }
|
|
22
|
+
.nav-btn[style*="hidden"] { visibility: hidden; }
|
|
20
23
|
.nav .match-badge { font-size: 11px; color: #94a3b8; }
|
|
21
24
|
.container { display: flex; gap: 0; }
|
|
22
25
|
.container.stacked { flex-direction: column; }
|
|
@@ -35,8 +38,8 @@
|
|
|
35
38
|
<a href="/e2e/tools/compare.html">← All cases</a>
|
|
36
39
|
<h2 id="case-name"></h2>
|
|
37
40
|
<div class="nav">
|
|
38
|
-
<a id="prev-link" href="#"
|
|
39
|
-
<a id="next-link" href="#"
|
|
41
|
+
<a id="prev-link" class="nav-btn" href="#" title="Previous case">‹</a>
|
|
42
|
+
<a id="next-link" class="nav-btn" href="#" title="Next case">›</a>
|
|
40
43
|
<span class="match-badge" id="match-badge"></span>
|
|
41
44
|
</div>
|
|
42
45
|
</div>
|
|
@@ -87,6 +90,15 @@
|
|
|
87
90
|
nextLink.style.visibility = "hidden";
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
// Keyboard navigation
|
|
94
|
+
document.addEventListener("keydown", (e) => {
|
|
95
|
+
if (e.key === "ArrowLeft" && prevLink.style.visibility !== "hidden") {
|
|
96
|
+
window.location.href = prevLink.href;
|
|
97
|
+
} else if (e.key === "ArrowRight" && nextLink.style.visibility !== "hidden") {
|
|
98
|
+
window.location.href = nextLink.href;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
90
102
|
// Expose DSL for the extension's batch mode to read
|
|
91
103
|
window.__currentDSL = code || "";
|
|
92
104
|
|
|
@@ -296,7 +308,9 @@
|
|
|
296
308
|
if (htmlFrame) {
|
|
297
309
|
const frameRect2 = htmlFrame.getBoundingClientRect();
|
|
298
310
|
// Hide SVG icons in the header area (top 35px) — checkbox, etc.
|
|
311
|
+
// Skip group outline overlays (data-group-overlay) which are functional rendering elements.
|
|
299
312
|
htmlFrame.querySelectorAll("svg").forEach(svg => {
|
|
313
|
+
if (svg.hasAttribute("data-group-overlay")) return;
|
|
300
314
|
const r = svg.getBoundingClientRect();
|
|
301
315
|
if (r.width > 0 && (r.top - frameRect2.top) < 35) _hideEl(svg);
|
|
302
316
|
});
|
package/index.html
CHANGED
|
@@ -434,6 +434,11 @@
|
|
|
434
434
|
<button id="view-svg" class="tool-chip">SVG</button>
|
|
435
435
|
<button id="view-dom" class="tool-chip">DOM</button>
|
|
436
436
|
<button id="view-split" class="tool-chip">Split</button>
|
|
437
|
+
<span class="toolbar-label">Editing</span>
|
|
438
|
+
<button class="tool-chip" data-flag="enableParticipantInsertion" title="Toggle ParticipantInsertControls (+ between participants)">+ Participant</button>
|
|
439
|
+
<button class="tool-chip" data-flag="enableMessageInsertion" title="Toggle Occurrence drag-to-create + GapHandleZone message handles">+ Message</button>
|
|
440
|
+
<button class="tool-chip" data-flag="enableDividerInsertion" title="Toggle GapHandleZone divider button">+ Divider</button>
|
|
441
|
+
<button class="tool-chip" data-flag="enableParticipantStyleEditing" title="Toggle ParticipantStylePanel (color/type)">Style Participant</button>
|
|
437
442
|
<button id="width-provider-toggle" class="tool-chip"></button>
|
|
438
443
|
<span id="svg-stats" class="stats-text">SVG: waiting</span>
|
|
439
444
|
<a class="tool-link" href="/e2e/tools/compare.html" target="_blank" rel="noreferrer">
|
|
@@ -550,8 +555,37 @@
|
|
|
550
555
|
localStorage.setItem("zenuml-workbench-view", validMode);
|
|
551
556
|
}
|
|
552
557
|
|
|
558
|
+
const FLAG_KEYS = [
|
|
559
|
+
"enableParticipantInsertion",
|
|
560
|
+
"enableMessageInsertion",
|
|
561
|
+
"enableDividerInsertion",
|
|
562
|
+
"enableParticipantStyleEditing",
|
|
563
|
+
];
|
|
564
|
+
const FLAG_STORAGE_PREFIX = "zenuml-workbench-flag-";
|
|
565
|
+
|
|
566
|
+
function readFlag(key) {
|
|
567
|
+
const stored = localStorage.getItem(FLAG_STORAGE_PREFIX + key);
|
|
568
|
+
// Default ON in the workbench so dev experience matches main.tsx.
|
|
569
|
+
return stored === null ? true : stored === "true";
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function writeFlag(key, value) {
|
|
573
|
+
localStorage.setItem(FLAG_STORAGE_PREFIX + key, String(value));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function readFlags() {
|
|
577
|
+
return Object.fromEntries(FLAG_KEYS.map((k) => [k, readFlag(k)]));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function syncFlagButtons() {
|
|
581
|
+
document.querySelectorAll("[data-flag]").forEach((btn) => {
|
|
582
|
+
btn.classList.toggle("is-active", readFlag(btn.dataset.flag));
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
553
586
|
const updateHtmlPreview = debounce((content) => {
|
|
554
587
|
const config = createConfig({
|
|
588
|
+
...readFlags(),
|
|
555
589
|
onContentChange: (code) => editor.setValue(code),
|
|
556
590
|
});
|
|
557
591
|
|
|
@@ -562,6 +596,16 @@
|
|
|
562
596
|
});
|
|
563
597
|
}, 400);
|
|
564
598
|
|
|
599
|
+
document.querySelectorAll("[data-flag]").forEach((btn) => {
|
|
600
|
+
btn.addEventListener("click", () => {
|
|
601
|
+
const key = btn.dataset.flag;
|
|
602
|
+
writeFlag(key, !readFlag(key));
|
|
603
|
+
syncFlagButtons();
|
|
604
|
+
updateHtmlPreview(editor.getValue());
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
syncFlagButtons();
|
|
608
|
+
|
|
565
609
|
const updateSvgPreview = debounce((content) => {
|
|
566
610
|
try {
|
|
567
611
|
const t0 = performance.now();
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenuml/core",
|
|
3
|
-
"version": "3.47.
|
|
3
|
+
"version": "3.47.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/mermaid-js/zenuml-core"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"dev": "vite dev --port
|
|
11
|
-
"preview": "bun run --bun vite preview --port
|
|
10
|
+
"dev": "vite dev --port 4000 --host 0.0.0.0",
|
|
11
|
+
"preview": "bun run --bun vite preview --port 4000 --host",
|
|
12
12
|
"build:site": "bun run --bun vite build",
|
|
13
13
|
"build:gh-pages": "bun run --bun vite build --mode gh-pages",
|
|
14
14
|
"build": "bun run --bun vite build -c vite.config.lib.ts",
|
package/playwright.config.ts
CHANGED
|
@@ -414,6 +414,21 @@ export async function collectLabelData(page) {
|
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
for (const fragmentEl of root.querySelectorAll("g.fragment")) {
|
|
417
|
+
// Detect direct text.fragment-section-label children (e.g. [else] rendered without a <g> wrapper)
|
|
418
|
+
for (const directLabel of fragmentEl.querySelectorAll(":scope > text.fragment-section-label")) {
|
|
419
|
+
const text = (directLabel.textContent ?? "").trim();
|
|
420
|
+
if (!text) continue;
|
|
421
|
+
const measured = measureTextEntry(directLabel, rootRect);
|
|
422
|
+
labels.push({
|
|
423
|
+
side: "svg",
|
|
424
|
+
kind: text.startsWith("[") ? "fragment-condition" : "fragment-section",
|
|
425
|
+
text,
|
|
426
|
+
ownerText: textOrEmpty(fragmentEl, ":scope > text.fragment-label") || null,
|
|
427
|
+
box: measured.box,
|
|
428
|
+
font: measured.font,
|
|
429
|
+
letters: measured.letters,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
417
432
|
for (const groupEl of fragmentEl.querySelectorAll(":scope > g")) {
|
|
418
433
|
const conditionTextEls = Array.from(groupEl.querySelectorAll(":scope > text.fragment-condition"));
|
|
419
434
|
if (conditionTextEls.length > 0) {
|
|
@@ -461,18 +476,23 @@ export async function collectLabelData(page) {
|
|
|
461
476
|
|
|
462
477
|
const rowEl = participantEl.querySelector(":scope > .flex.items-center.justify-center, :scope > div:last-child");
|
|
463
478
|
const firstChild = rowEl?.firstElementChild ?? null;
|
|
464
|
-
|
|
479
|
+
// Find emoji span (span.mr-1.flex-shrink-0 containing emoji text)
|
|
480
|
+
const emojiSpan = participantEl.querySelector("span.mr-1.flex-shrink-0, span[data-testid='participant-emoji']");
|
|
481
|
+
const emojiText = emojiSpan ? emojiSpan.textContent.trim() : null;
|
|
482
|
+
// Find type icon div (div with aria-description or h-6.w-6 containing an SVG icon)
|
|
483
|
+
const typeIconDiv = firstChild && (
|
|
465
484
|
firstChild.matches("[aria-description]") ||
|
|
466
485
|
firstChild.querySelector("svg") ||
|
|
467
486
|
/\bh-6\b/.test(firstChild.className || "")
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
: null;
|
|
487
|
+
) ? firstChild : null;
|
|
488
|
+
const iconHost = typeIconDiv || emojiSpan || null;
|
|
471
489
|
const labelEl = Array.from(participantEl.querySelectorAll(".name")).at(-1) ?? null;
|
|
472
490
|
const measuredLabel = labelEl ? measureTextEntry(labelEl, rootRect) : null;
|
|
473
491
|
const stereotypeEl = participantEl.querySelector("label.interface");
|
|
474
492
|
const measuredStereotype = stereotypeEl ? measureTextEntry(stereotypeEl, rootRect) : null;
|
|
475
|
-
|
|
493
|
+
// For icon measurement: prefer type icon (so we compare type icon vs type icon across renderers).
|
|
494
|
+
// Fall back to emoji span for emoji-only participants.
|
|
495
|
+
const iconPaintRoot = typeIconDiv ? (typeIconDiv.querySelector("svg") ?? typeIconDiv) : (emojiSpan || null);
|
|
476
496
|
const participantStyle = getComputedStyle(participantEl);
|
|
477
497
|
|
|
478
498
|
participants.push({
|
|
@@ -488,6 +508,7 @@ export async function collectLabelData(page) {
|
|
|
488
508
|
stereotypeFont: measuredStereotype?.font ?? null,
|
|
489
509
|
stereotypeLetters: measuredStereotype?.letters ?? [],
|
|
490
510
|
iconBox: paintedBox(iconPaintRoot, rootRect),
|
|
511
|
+
emojiText: emojiText || null,
|
|
491
512
|
anchorKind: measuredLabel?.box ? "label" : "participant-box",
|
|
492
513
|
anchorBox: measuredLabel?.box ?? participantBox,
|
|
493
514
|
backgroundColor: normalizeColorValue(participantStyle.backgroundColor),
|
|
@@ -518,7 +539,20 @@ export async function collectLabelData(page) {
|
|
|
518
539
|
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)[0]
|
|
519
540
|
|| null;
|
|
520
541
|
const measuredStereotype = stereotypeEl ? measureTextEntry(stereotypeEl, rootRect) : null;
|
|
521
|
-
const iconEl = participantEl.querySelector(":scope > g[transform]");
|
|
542
|
+
const iconEl = participantEl.querySelector(":scope > g.participant-icon[transform]");
|
|
543
|
+
// Detect emoji: separate text.participant-emoji element (present for both emoji-only AND icon+emoji participants)
|
|
544
|
+
// or first tspan in participant-label containing emoji codepoints (legacy inline-tspan format)
|
|
545
|
+
const emojiPattern = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}]/u;
|
|
546
|
+
const emojiTextEl = participantEl.querySelector(":scope > text.participant-emoji") ?? null;
|
|
547
|
+
const emojiTspan = !emojiTextEl && labelEl
|
|
548
|
+
? Array.from(labelEl.querySelectorAll("tspan")).find((ts) => emojiPattern.test(ts.textContent))
|
|
549
|
+
: null;
|
|
550
|
+
const svgEmojiText = (emojiTextEl || emojiTspan)
|
|
551
|
+
? (emojiTextEl || emojiTspan).textContent.trim()
|
|
552
|
+
: null;
|
|
553
|
+
// For icon measurement: prefer type icon so we compare type icon vs type icon across renderers.
|
|
554
|
+
// Fall back to emoji element for emoji-only participants.
|
|
555
|
+
const iconTarget = iconEl || emojiTextEl || emojiTspan;
|
|
522
556
|
const participantBoxStyle = participantBoxEl ? getComputedStyle(participantBoxEl) : null;
|
|
523
557
|
|
|
524
558
|
participants.push({
|
|
@@ -533,7 +567,8 @@ export async function collectLabelData(page) {
|
|
|
533
567
|
stereotypeBox: measuredStereotype?.box ?? null,
|
|
534
568
|
stereotypeFont: measuredStereotype?.font ?? null,
|
|
535
569
|
stereotypeLetters: measuredStereotype?.letters ?? [],
|
|
536
|
-
iconBox: paintedBox(
|
|
570
|
+
iconBox: paintedBox(iconTarget, rootRect),
|
|
571
|
+
emojiText: svgEmojiText || null,
|
|
537
572
|
anchorKind: measuredLabel?.box ? "label" : "participant-box",
|
|
538
573
|
anchorBox: measuredLabel?.box ?? participantBox,
|
|
539
574
|
backgroundColor: normalizeColorValue(participantBoxStyle?.fill || participantBoxEl?.getAttribute("fill")),
|
|
@@ -583,20 +618,36 @@ export async function collectLabelData(page) {
|
|
|
583
618
|
}
|
|
584
619
|
|
|
585
620
|
function collectHtmlGroups(root, rootRect) {
|
|
586
|
-
|
|
621
|
+
// Group containers appear twice: once in participant layer (has name label, no overlay)
|
|
622
|
+
// and once in lifeline layer (has overlay rect, no name label).
|
|
623
|
+
// Collect name data from participant-layer containers and outline boxes from
|
|
624
|
+
// lifeline-layer containers, then merge by index order.
|
|
625
|
+
const nameEntries = [];
|
|
626
|
+
const boxEntries = [];
|
|
587
627
|
for (const groupEl of root.querySelectorAll(".lifeline-group-container")) {
|
|
588
628
|
const nameEl = groupEl.querySelector(".text-skin-lifeline-group-name");
|
|
589
|
-
const
|
|
590
|
-
|
|
629
|
+
const outlineRect = groupEl.querySelector("[data-group-overlay] rect");
|
|
630
|
+
if (nameEl) {
|
|
631
|
+
nameEntries.push({
|
|
632
|
+
name: textContentNormalized(nameEl),
|
|
633
|
+
measuredName: measureTextEntry(nameEl, rootRect),
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (outlineRect) {
|
|
637
|
+
boxEntries.push(boxOrNull(relRect(outlineRect.getBoundingClientRect(), rootRect)));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const groups = [];
|
|
641
|
+
for (let i = 0; i < nameEntries.length; i++) {
|
|
642
|
+
const box = boxEntries[i] || null;
|
|
591
643
|
if (!box) continue;
|
|
592
|
-
const measuredName = nameEl ? measureTextEntry(nameEl, rootRect) : null;
|
|
593
644
|
groups.push({
|
|
594
645
|
side: "html",
|
|
595
|
-
name,
|
|
646
|
+
name: nameEntries[i].name,
|
|
596
647
|
box,
|
|
597
|
-
nameBox: measuredName?.box ?? null,
|
|
598
|
-
nameFont: measuredName?.font ?? null,
|
|
599
|
-
nameLetters: measuredName?.letters ?? [],
|
|
648
|
+
nameBox: nameEntries[i].measuredName?.box ?? null,
|
|
649
|
+
nameFont: nameEntries[i].measuredName?.font ?? null,
|
|
650
|
+
nameLetters: nameEntries[i].measuredName?.letters ?? [],
|
|
600
651
|
});
|
|
601
652
|
}
|
|
602
653
|
return groups;
|
|
@@ -607,7 +658,10 @@ export async function collectLabelData(page) {
|
|
|
607
658
|
for (const groupEl of root.querySelectorAll("g.participant-group")) {
|
|
608
659
|
const nameEl = groupEl.querySelector(":scope > text");
|
|
609
660
|
const name = textContentNormalized(nameEl);
|
|
610
|
-
|
|
661
|
+
// Measure the outline <rect> directly — consistent with HTML side measurement.
|
|
662
|
+
const outlineRect = groupEl.querySelector("rect.group-outline");
|
|
663
|
+
const measureEl = outlineRect || groupEl;
|
|
664
|
+
const box = boxOrNull(relRect(measureEl.getBoundingClientRect(), rootRect));
|
|
611
665
|
if (!box) continue;
|
|
612
666
|
const measuredName = nameEl ? measureTextEntry(nameEl, rootRect) : null;
|
|
613
667
|
groups.push({
|
|
@@ -966,6 +1020,73 @@ export async function collectLabelData(page) {
|
|
|
966
1020
|
return dividers;
|
|
967
1021
|
}
|
|
968
1022
|
|
|
1023
|
+
/**
|
|
1024
|
+
* Critical fragment header color calibration.
|
|
1025
|
+
* HTML renders .fragment-critical .header::before with border-bottom: 2px solid
|
|
1026
|
+
* (the unique thick header separator not present on other fragment types).
|
|
1027
|
+
* SVG renders no extra border for critical kind.
|
|
1028
|
+
* This collects the header bounding box + color style from both sides for comparison.
|
|
1029
|
+
*/
|
|
1030
|
+
function collectHtmlCriticalFragmentHeaders(root, rootRect) {
|
|
1031
|
+
const results = [];
|
|
1032
|
+
for (const frag of root.querySelectorAll(".fragment.fragment-critical")) {
|
|
1033
|
+
const header = frag.querySelector(":scope > .header");
|
|
1034
|
+
if (!header) continue;
|
|
1035
|
+
const r = header.getBoundingClientRect();
|
|
1036
|
+
const box = {
|
|
1037
|
+
x: Math.round(r.left - rootRect.left),
|
|
1038
|
+
y: Math.round(r.top - rootRect.top),
|
|
1039
|
+
w: Math.round(r.width),
|
|
1040
|
+
h: Math.round(r.height),
|
|
1041
|
+
};
|
|
1042
|
+
// ::before pseudo-element carries the border-bottom: 2px solid style
|
|
1043
|
+
const pseudoStyle = window.getComputedStyle(header, "::before");
|
|
1044
|
+
const borderBottomWidth = parseFloat(pseudoStyle.borderBottomWidth || "0") || 0;
|
|
1045
|
+
const borderBottomColor = pseudoStyle.borderBottomColor || "";
|
|
1046
|
+
results.push({
|
|
1047
|
+
side: "html",
|
|
1048
|
+
idx: results.length,
|
|
1049
|
+
box,
|
|
1050
|
+
headerBottomY: box.y + box.h,
|
|
1051
|
+
borderBottomWidth,
|
|
1052
|
+
borderBottomColor,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
return results;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function collectSvgCriticalFragmentHeaders(root, rootRect) {
|
|
1059
|
+
const results = [];
|
|
1060
|
+
for (const frag of root.querySelectorAll("g.fragment.fragment-critical")) {
|
|
1061
|
+
const headerRect_el = frag.querySelector("rect.fragment-header");
|
|
1062
|
+
if (!headerRect_el) continue;
|
|
1063
|
+
const r = headerRect_el.getBoundingClientRect();
|
|
1064
|
+
const box = {
|
|
1065
|
+
x: Math.round(r.left - rootRect.left),
|
|
1066
|
+
y: Math.round(r.top - rootRect.top),
|
|
1067
|
+
w: Math.round(r.width),
|
|
1068
|
+
h: Math.round(r.height),
|
|
1069
|
+
};
|
|
1070
|
+
const headerBottomY = box.y + box.h;
|
|
1071
|
+
// Look for a line element at the header bottom edge (within 2px)
|
|
1072
|
+
const lines = Array.from(frag.querySelectorAll("line"));
|
|
1073
|
+
const borderLine = lines.find(l => {
|
|
1074
|
+
const lr = l.getBoundingClientRect();
|
|
1075
|
+
return Math.abs((lr.top - rootRect.top) - headerBottomY) < 3;
|
|
1076
|
+
});
|
|
1077
|
+
results.push({
|
|
1078
|
+
side: "svg",
|
|
1079
|
+
idx: results.length,
|
|
1080
|
+
box,
|
|
1081
|
+
headerBottomY,
|
|
1082
|
+
hasHeaderBottomLine: !!borderLine,
|
|
1083
|
+
headerBottomLineColor: borderLine ? (borderLine.getAttribute("stroke") || getComputedStyle(borderLine).stroke || "") : null,
|
|
1084
|
+
headerBottomLineWidth: borderLine ? (parseFloat(borderLine.getAttribute("stroke-width") || getComputedStyle(borderLine).strokeWidth || "0") || 0) : null,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
return results;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
969
1090
|
const prepared = typeof window.prepareHtmlForCapture === "function"
|
|
970
1091
|
? window.prepareHtmlForCapture()
|
|
971
1092
|
: null;
|
|
@@ -1006,6 +1127,8 @@ export async function collectLabelData(page) {
|
|
|
1006
1127
|
svgFragmentDividers: collectSvgFragmentDividers(svgRoot, svgRootRect),
|
|
1007
1128
|
htmlDividers: collectHtmlDividers(htmlRoot, htmlRootRect),
|
|
1008
1129
|
svgDividers: collectSvgDividers(svgRoot, svgRootRect),
|
|
1130
|
+
htmlCriticalFragmentHeaders: collectHtmlCriticalFragmentHeaders(htmlRoot, htmlRootRect),
|
|
1131
|
+
svgCriticalFragmentHeaders: collectSvgCriticalFragmentHeaders(svgRoot, svgRootRect),
|
|
1009
1132
|
};
|
|
1010
1133
|
});
|
|
1011
1134
|
}
|
|
@@ -235,6 +235,22 @@ function buildDiffClusters(diffImage, targetClass) {
|
|
|
235
235
|
return clusters.sort((a, b) => b.size - a.size);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
function normalizeClusterToFrameSpace(cluster, scaleX, scaleY) {
|
|
239
|
+
return {
|
|
240
|
+
...cluster,
|
|
241
|
+
bbox: {
|
|
242
|
+
x: cluster.bbox.x / scaleX,
|
|
243
|
+
y: cluster.bbox.y / scaleY,
|
|
244
|
+
w: cluster.bbox.w / scaleX,
|
|
245
|
+
h: cluster.bbox.h / scaleY,
|
|
246
|
+
},
|
|
247
|
+
centroid: {
|
|
248
|
+
x: cluster.centroid.x / scaleX,
|
|
249
|
+
y: cluster.centroid.y / scaleY,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
238
254
|
function pickScopeTarget(cluster, items) {
|
|
239
255
|
const centroid = cluster.centroid;
|
|
240
256
|
let best = null;
|
|
@@ -287,10 +303,16 @@ function formatResidualScopeSummary(scope) {
|
|
|
287
303
|
export function buildResidualScopes(extracted, diffImage) {
|
|
288
304
|
const htmlItems = buildScopeItems("html", extracted);
|
|
289
305
|
const svgItems = buildScopeItems("svg", extracted);
|
|
306
|
+
const frameWidth = extracted.htmlRootBox?.w || extracted.svgRootBox?.w || diffImage.width;
|
|
307
|
+
const frameHeight = extracted.htmlRootBox?.h || extracted.svgRootBox?.h || diffImage.height;
|
|
308
|
+
const scaleX = frameWidth > 0 ? diffImage.width / frameWidth : 1;
|
|
309
|
+
const scaleY = frameHeight > 0 ? diffImage.height / frameHeight : 1;
|
|
290
310
|
const clusters = [
|
|
291
311
|
...buildDiffClusters(diffImage, 2),
|
|
292
312
|
...buildDiffClusters(diffImage, 3),
|
|
293
|
-
]
|
|
313
|
+
]
|
|
314
|
+
.map((cluster) => normalizeClusterToFrameSpace(cluster, scaleX, scaleY))
|
|
315
|
+
.sort((a, b) => b.size - a.size);
|
|
294
316
|
|
|
295
317
|
const residualScopes = clusters.map((cluster, index) => ({
|
|
296
318
|
rank: index + 1,
|
|
@@ -376,6 +376,7 @@ function scoreParticipantIcon(htmlParticipant, svgParticipant, diffImage) {
|
|
|
376
376
|
const participant = {
|
|
377
377
|
name: base?.name ?? "",
|
|
378
378
|
label_text: htmlParticipant?.labelText || svgParticipant?.labelText || null,
|
|
379
|
+
emoji: htmlParticipant?.emojiText || svgParticipant?.emojiText || null,
|
|
379
380
|
presence: {
|
|
380
381
|
html: iconPresentHtml,
|
|
381
382
|
svg: iconPresentSvg,
|