@zenuml/core 3.47.0 → 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 +16210 -15460
  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 +17 -3
  24. package/package.json +3 -3
  25. package/playwright.config.ts +1 -1
  26. package/scripts/analyze-compare-case/collect-data.mjs +159 -16
  27. package/scripts/analyze-compare-case/config.mjs +1 -1
  28. package/scripts/analyze-compare-case/report.mjs +5 -0
  29. package/scripts/analyze-compare-case/residual-scopes.mjs +23 -1
  30. package/scripts/analyze-compare-case/scoring.mjs +13 -0
@@ -273,6 +273,24 @@ export async function collectLabelData(page) {
273
273
  return Array.from(byName.values());
274
274
  }
275
275
 
276
+ function collectHtmlTitle(root, rootRect) {
277
+ const titleEl = root.querySelector(".title");
278
+ if (!titleEl) return null;
279
+ const text = (titleEl.textContent ?? "").trim();
280
+ if (!text) return null;
281
+ const measured = measureTextEntry(titleEl, rootRect);
282
+ return { side: "html", kind: "title", text, box: measured.box, font: measured.font, letters: measured.letters };
283
+ }
284
+
285
+ function collectSvgTitle(root, rootRect) {
286
+ const titleEl = root.querySelector("text.frame-title");
287
+ if (!titleEl) return null;
288
+ const text = (titleEl.textContent ?? "").trim();
289
+ if (!text) return null;
290
+ const measured = measureTextEntry(titleEl, rootRect);
291
+ return { side: "svg", kind: "title", text, box: measured.box, font: measured.font, letters: measured.letters };
292
+ }
293
+
276
294
  function collectHtmlLabels(root, rootRect) {
277
295
  const labels = [];
278
296
  const selectorPairs = [
@@ -396,6 +414,21 @@ export async function collectLabelData(page) {
396
414
  }
397
415
 
398
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
+ }
399
432
  for (const groupEl of fragmentEl.querySelectorAll(":scope > g")) {
400
433
  const conditionTextEls = Array.from(groupEl.querySelectorAll(":scope > text.fragment-condition"));
401
434
  if (conditionTextEls.length > 0) {
@@ -443,18 +476,23 @@ export async function collectLabelData(page) {
443
476
 
444
477
  const rowEl = participantEl.querySelector(":scope > .flex.items-center.justify-center, :scope > div:last-child");
445
478
  const firstChild = rowEl?.firstElementChild ?? null;
446
- 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 && (
447
484
  firstChild.matches("[aria-description]") ||
448
485
  firstChild.querySelector("svg") ||
449
486
  /\bh-6\b/.test(firstChild.className || "")
450
- )
451
- ? firstChild
452
- : null;
487
+ ) ? firstChild : null;
488
+ const iconHost = typeIconDiv || emojiSpan || null;
453
489
  const labelEl = Array.from(participantEl.querySelectorAll(".name")).at(-1) ?? null;
454
490
  const measuredLabel = labelEl ? measureTextEntry(labelEl, rootRect) : null;
455
491
  const stereotypeEl = participantEl.querySelector("label.interface");
456
492
  const measuredStereotype = stereotypeEl ? measureTextEntry(stereotypeEl, rootRect) : null;
457
- 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);
458
496
  const participantStyle = getComputedStyle(participantEl);
459
497
 
460
498
  participants.push({
@@ -470,6 +508,7 @@ export async function collectLabelData(page) {
470
508
  stereotypeFont: measuredStereotype?.font ?? null,
471
509
  stereotypeLetters: measuredStereotype?.letters ?? [],
472
510
  iconBox: paintedBox(iconPaintRoot, rootRect),
511
+ emojiText: emojiText || null,
473
512
  anchorKind: measuredLabel?.box ? "label" : "participant-box",
474
513
  anchorBox: measuredLabel?.box ?? participantBox,
475
514
  backgroundColor: normalizeColorValue(participantStyle.backgroundColor),
@@ -500,7 +539,20 @@ export async function collectLabelData(page) {
500
539
  .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)[0]
501
540
  || null;
502
541
  const measuredStereotype = stereotypeEl ? measureTextEntry(stereotypeEl, rootRect) : null;
503
- 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;
504
556
  const participantBoxStyle = participantBoxEl ? getComputedStyle(participantBoxEl) : null;
505
557
 
506
558
  participants.push({
@@ -515,7 +567,8 @@ export async function collectLabelData(page) {
515
567
  stereotypeBox: measuredStereotype?.box ?? null,
516
568
  stereotypeFont: measuredStereotype?.font ?? null,
517
569
  stereotypeLetters: measuredStereotype?.letters ?? [],
518
- iconBox: paintedBox(iconEl, rootRect),
570
+ iconBox: paintedBox(iconTarget, rootRect),
571
+ emojiText: svgEmojiText || null,
519
572
  anchorKind: measuredLabel?.box ? "label" : "participant-box",
520
573
  anchorBox: measuredLabel?.box ?? participantBox,
521
574
  backgroundColor: normalizeColorValue(participantBoxStyle?.fill || participantBoxEl?.getAttribute("fill")),
@@ -565,20 +618,36 @@ export async function collectLabelData(page) {
565
618
  }
566
619
 
567
620
  function collectHtmlGroups(root, rootRect) {
568
- 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 = [];
569
627
  for (const groupEl of root.querySelectorAll(".lifeline-group-container")) {
570
628
  const nameEl = groupEl.querySelector(".text-skin-lifeline-group-name");
571
- const name = textContentNormalized(nameEl);
572
- 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;
573
643
  if (!box) continue;
574
- const measuredName = nameEl ? measureTextEntry(nameEl, rootRect) : null;
575
644
  groups.push({
576
645
  side: "html",
577
- name,
646
+ name: nameEntries[i].name,
578
647
  box,
579
- nameBox: measuredName?.box ?? null,
580
- nameFont: measuredName?.font ?? null,
581
- nameLetters: measuredName?.letters ?? [],
648
+ nameBox: nameEntries[i].measuredName?.box ?? null,
649
+ nameFont: nameEntries[i].measuredName?.font ?? null,
650
+ nameLetters: nameEntries[i].measuredName?.letters ?? [],
582
651
  });
583
652
  }
584
653
  return groups;
@@ -589,7 +658,10 @@ export async function collectLabelData(page) {
589
658
  for (const groupEl of root.querySelectorAll("g.participant-group")) {
590
659
  const nameEl = groupEl.querySelector(":scope > text");
591
660
  const name = textContentNormalized(nameEl);
592
- 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));
593
665
  if (!box) continue;
594
666
  const measuredName = nameEl ? measureTextEntry(nameEl, rootRect) : null;
595
667
  groups.push({
@@ -948,6 +1020,73 @@ export async function collectLabelData(page) {
948
1020
  return dividers;
949
1021
  }
950
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
+
951
1090
  const prepared = typeof window.prepareHtmlForCapture === "function"
952
1091
  ? window.prepareHtmlForCapture()
953
1092
  : null;
@@ -968,6 +1107,8 @@ export async function collectLabelData(page) {
968
1107
  htmlRootBox: { x: 0, y: 0, w: htmlRootRect.width, h: htmlRootRect.height },
969
1108
  svgRootBox: { x: 0, y: 0, w: svgRootRect.width, h: svgRootRect.height },
970
1109
  svgFrameBorderBox: boxOrNull(strokedElementOuterRect(svgFrameBorderEl, svgRootRect)),
1110
+ htmlTitle: collectHtmlTitle(htmlRoot, htmlRootRect),
1111
+ svgTitle: collectSvgTitle(svgRoot, svgRootRect),
971
1112
  htmlLabels: collectHtmlLabels(htmlRoot, htmlRootRect),
972
1113
  svgLabels: collectSvgLabels(svgRoot, svgRootRect),
973
1114
  htmlNumbers: collectHtmlNumbers(htmlRoot, htmlRootRect),
@@ -986,6 +1127,8 @@ export async function collectLabelData(page) {
986
1127
  svgFragmentDividers: collectSvgFragmentDividers(svgRoot, svgRootRect),
987
1128
  htmlDividers: collectHtmlDividers(htmlRoot, htmlRootRect),
988
1129
  svgDividers: collectSvgDividers(svgRoot, svgRootRect),
1130
+ htmlCriticalFragmentHeaders: collectHtmlCriticalFragmentHeaders(htmlRoot, htmlRootRect),
1131
+ svgCriticalFragmentHeaders: collectSvgCriticalFragmentHeaders(svgRoot, svgRootRect),
989
1132
  };
990
1133
  });
991
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
  }
@@ -123,6 +126,7 @@ export function buildReport(caseName, extracted, diffImage) {
123
126
 
124
127
  return {
125
128
  case: caseName,
129
+ title: sections.title,
126
130
  labels: sections.labels,
127
131
  numbers: sections.numbers,
128
132
  arrows: sections.arrows,
@@ -137,6 +141,7 @@ export function buildReport(caseName, extracted, diffImage) {
137
141
  fragment_dividers: sections.fragmentDividers,
138
142
  dividers: sections.dividers,
139
143
  residual_scopes: residualScopes.scopes,
144
+ title_summary: sections.title ? formatSectionSummary("title", sections.title) : null,
140
145
  summary: sections.labels.map((label) => formatSectionSummary("label", label)),
141
146
  number_summary: sections.numbers.map((number) => formatSectionSummary("number", number)),
142
147
  arrow_summary: sections.arrows.map((arrow) => `arrow:${arrow.key.text} -> ${formatArrowSummary(arrow)}`),
@@ -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,
@@ -770,8 +771,19 @@ function buildFragmentDividerSection(htmlDividers, svgDividers) {
770
771
  return results;
771
772
  }
772
773
 
774
+ function buildTitleSection(htmlTitle, svgTitle, diffImage) {
775
+ if (!htmlTitle && !svgTitle) return null;
776
+ // Wrap as single-element arrays and reuse buildSection
777
+ const htmlItems = htmlTitle ? [htmlTitle] : [];
778
+ const svgItems = svgTitle ? [svgTitle] : [];
779
+ const results = buildSection(htmlItems, svgItems, diffImage);
780
+ return results.length > 0 ? results[0] : null;
781
+ }
782
+
773
783
  export function buildScoredSections(extracted, diffImage) {
774
784
  const {
785
+ htmlTitle,
786
+ svgTitle,
775
787
  htmlLabels,
776
788
  svgLabels,
777
789
  htmlNumbers,
@@ -799,6 +811,7 @@ export function buildScoredSections(extracted, diffImage) {
799
811
  const svgParticipantStereotypes = buildParticipantStereotypeItems(svgParticipants);
800
812
 
801
813
  return {
814
+ title: buildTitleSection(htmlTitle || null, svgTitle || null, diffImage),
802
815
  labels: buildSection(htmlLabels, svgLabels, diffImage),
803
816
  numbers: buildSection(htmlNumbers, svgNumbers, diffImage),
804
817
  arrows: buildArrowSection(htmlArrows, svgArrows, diffImage),