@zenuml/core 3.47.1 → 3.47.2

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 (30) hide show
  1. package/.agents/skills/babysit-pr/SKILL.md +223 -0
  2. package/.agents/skills/babysit-pr/agents/openai.yaml +7 -0
  3. package/.agents/skills/dia-scoring/SKILL.md +139 -0
  4. package/.agents/skills/dia-scoring/agents/openai.yaml +7 -0
  5. package/.agents/skills/dia-scoring/references/selectors-and-keys.md +253 -0
  6. package/.agents/skills/land-pr/SKILL.md +120 -0
  7. package/.agents/skills/propagate-core-release/SKILL.md +205 -0
  8. package/.agents/skills/propagate-core-release/agents/openai.yaml +7 -0
  9. package/.agents/skills/propagate-core-release/references/downstreams.md +42 -0
  10. package/.agents/skills/ship-branch/SKILL.md +105 -0
  11. package/.agents/skills/submit-branch/SKILL.md +76 -0
  12. package/.agents/skills/validate-branch/SKILL.md +72 -0
  13. package/.claude/skills/emoji-eval/SKILL.md +187 -0
  14. package/.claude/skills/propagate-core-release/SKILL.md +81 -76
  15. package/.claude/skills/propagate-core-release/agents/openai.yaml +2 -2
  16. package/AGENTS.md +1 -1
  17. package/dist/stats.html +1 -1
  18. package/dist/zenuml.esm.mjs +16092 -15337
  19. package/dist/zenuml.js +540 -535
  20. package/docs/superpowers/plans/2026-03-30-emoji-support.md +1220 -0
  21. package/docs/superpowers/plans/2026-03-30-self-correcting-scoring.md +206 -0
  22. package/e2e/data/compare-cases.js +233 -0
  23. package/e2e/tools/compare-case.html +16 -2
  24. package/package.json +3 -3
  25. package/playwright.config.ts +1 -1
  26. package/scripts/analyze-compare-case/collect-data.mjs +139 -16
  27. package/scripts/analyze-compare-case/config.mjs +1 -1
  28. package/scripts/analyze-compare-case/report.mjs +3 -0
  29. package/scripts/analyze-compare-case/residual-scopes.mjs +23 -1
  30. package/scripts/analyze-compare-case/scoring.mjs +1 -0
@@ -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
- const iconHost = firstChild && (
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
- ? firstChild
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
- const iconPaintRoot = iconHost?.querySelector("svg") ?? iconHost;
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(iconEl, rootRect),
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
- const groups = [];
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 name = textContentNormalized(nameEl);
590
- const box = boxOrNull(relRect(groupEl.getBoundingClientRect(), rootRect));
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
- const box = boxOrNull(relRect(groupEl.getBoundingClientRect(), rootRect));
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
  }
@@ -15,7 +15,7 @@
15
15
  */
16
16
  export const DEFAULTS = {
17
17
  caseName: "async-2a",
18
- baseUrl: "http://localhost:8080",
18
+ baseUrl: "http://localhost:4000",
19
19
  lumaThreshold: 240,
20
20
  channelTolerance: 12,
21
21
  positionTolerance: 0,
@@ -63,6 +63,9 @@ function formatArrowSummary(arrow) {
63
63
 
64
64
  function formatParticipantIconSummary(icon) {
65
65
  const notes = [];
66
+ if (icon.emoji) {
67
+ notes.push(`emoji=${icon.emoji}`);
68
+ }
66
69
  if (icon.label_text) {
67
70
  notes.push(`label=${icon.label_text}`);
68
71
  }
@@ -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
- ].sort((a, b) => b.size - a.size);
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,