@invarn/cibuild 2.3.0 → 2.3.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.
@@ -8,7 +8,7 @@
8
8
  * overflowing voucher tag; the deriver must surface the bottom clip as a
9
9
  * fact so it is never again skipped by eye.
10
10
  */
11
- import { deriveStructuralFacts, derivePresenceFacts, deriveCopyFacts, deriveSpanFacts, deriveColorFacts, ciede2000, hexToLab, diffKeyGeometry, buildStructuralBlock, buildReviewScaffold, appearanceReferenceFromPreviousRender, buildAppearanceReferenceFromParams, getStructuralFactsStageInternals, getGeometryStageInternals, } from './structural-facts.js';
11
+ import { deriveStructuralFacts, derivePresenceFacts, deriveCopyFacts, deriveSpanFacts, deriveLineFacts, deriveColorFacts, deriveTypefaceFacts, ciede2000, hexToLab, diffKeyGeometry, buildStructuralBlock, buildReviewScaffold, appearanceReferenceFromPreviousRender, buildAppearanceReferenceFromParams, getStructuralFactsStageInternals, getGeometryStageInternals, } from './structural-facts.js';
12
12
  // 200x400 canvas in px for all fixtures unless noted.
13
13
  const CANVAS = { left: 0, top: 0, right: 200, bottom: 400 };
14
14
  describe('deriveStructuralFacts — containment', () => {
@@ -688,6 +688,112 @@ describe('deriveSpanFacts — declared highlight extents vs rendered background
688
688
  ]);
689
689
  });
690
690
  });
691
+ describe('deriveLineFacts — declared line split vs rendered laid-out lines', () => {
692
+ test('rendered lines matching the declared split produce no fact', () => {
693
+ expect(deriveLineFacts({
694
+ promoCopy: [
695
+ { start: 0, end: 28, text: 'Get 5 EUR off your first order' },
696
+ { start: 29, end: 50, text: 'placed by tomorrow.' },
697
+ ],
698
+ }, { promoCopy: ['Get 5 EUR off your first order', 'placed by tomorrow.'] })).toEqual([]);
699
+ });
700
+ test('a break on the wrong word yields line_break_mismatch with both splits', () => {
701
+ expect(deriveLineFacts({
702
+ promoCopy: [
703
+ { start: 0, end: 31, text: 'Get 5 EUR off your first order placed' },
704
+ { start: 32, end: 45, text: 'by tomorrow.' },
705
+ ],
706
+ }, { promoCopy: ['Get 5 EUR off your first order', 'placed by tomorrow.'] })).toEqual([
707
+ {
708
+ kind: 'line_break_mismatch',
709
+ elementId: 'promoCopy',
710
+ rendered: 'Get 5 EUR off your first order placed\nby tomorrow.',
711
+ reference: 'Get 5 EUR off your first order\nplaced by tomorrow.',
712
+ },
713
+ ]);
714
+ });
715
+ test('a dropped break (single rendered line vs a declared two-line split) is a fact', () => {
716
+ expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 50, text: 'Get 5 EUR off your first order placed by tomorrow.' }] }, { promoCopy: ['Get 5 EUR off your first order', 'placed by tomorrow.'] })).toEqual([
717
+ {
718
+ kind: 'line_break_mismatch',
719
+ elementId: 'promoCopy',
720
+ rendered: 'Get 5 EUR off your first order placed by tomorrow.',
721
+ reference: 'Get 5 EUR off your first order\nplaced by tomorrow.',
722
+ },
723
+ ]);
724
+ });
725
+ test('a node that emitted no lines produces no fact (old packages degrade gracefully)', () => {
726
+ expect(deriveLineFacts({}, { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] })).toEqual([]);
727
+ });
728
+ test('no declared entry / an empty declared split produces no fact (absence of signal is not a divergence)', () => {
729
+ expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, {})).toEqual([]);
730
+ expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, { promoCopy: [] })).toEqual([]);
731
+ expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, { promoCopy: ['', ''] })).toEqual([]);
732
+ });
733
+ test('result is order-independent and deterministic (sorted by element id)', () => {
734
+ const rendered = {
735
+ b: [{ start: 0, end: 1, text: 'x' }],
736
+ a: [{ start: 0, end: 1, text: 'y' }],
737
+ };
738
+ const one = deriveLineFacts(rendered, { a: ['right'], b: ['x'] });
739
+ const two = deriveLineFacts(rendered, { b: ['x'], a: ['right'] });
740
+ expect(one).toEqual(two);
741
+ expect(one).toEqual([
742
+ { kind: 'line_break_mismatch', elementId: 'a', rendered: 'y', reference: 'right' },
743
+ ]);
744
+ });
745
+ });
746
+ describe('deriveTypefaceFacts — resolved vs design typeface per element', () => {
747
+ test('a family mismatch yields one typeface fact carrying both fonts', () => {
748
+ const facts = deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
749
+ expect(facts).toEqual([
750
+ {
751
+ kind: 'typeface',
752
+ elementId: 'code',
753
+ channel: 'text',
754
+ rendered: { family: 'Roboto', weight: 400 },
755
+ reference: { family: 'Poppins', weight: 600 },
756
+ },
757
+ ]);
758
+ });
759
+ test('a weight mismatch on the same family yields a typeface fact', () => {
760
+ const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
761
+ expect(facts).toHaveLength(1);
762
+ expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code', channel: 'text' });
763
+ });
764
+ test('an exact family+weight match produces no fact', () => {
765
+ expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
766
+ });
767
+ test('a normalization-equivalent pair produces no fact (suffix-encoded weight vs numeric, case, separators)', () => {
768
+ // design names "Poppins-SemiBold"; render resolved "Poppins" weight 600 — same font.
769
+ expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } })).toEqual([]);
770
+ // resource-name encoding "fira_sans_medium" vs design "Fira Sans" weight 500.
771
+ expect(deriveTypefaceFacts({ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } })).toEqual([]);
772
+ // pure case difference on the family.
773
+ expect(deriveTypefaceFacts({ code: { family: 'POPPINS', weight: 600 } }, { code: { family: 'poppins', weight: 600 } })).toEqual([]);
774
+ });
775
+ test('a suffix-encoded family that disagrees on weight still flags', () => {
776
+ // design "Poppins-SemiBold" (→600) vs resolved "Poppins" Regular (400) — a real
777
+ // weight fallback the suffix normalization must NOT mask.
778
+ const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } });
779
+ expect(facts).toHaveLength(1);
780
+ expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code' });
781
+ });
782
+ test('a missing rendered or reference font produces no fact (graceful, no false positive)', () => {
783
+ // reference declares a font the render never resolved → no signal → no fact
784
+ expect(deriveTypefaceFacts({ code: undefined }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
785
+ expect(deriveTypefaceFacts({ code: { family: '' } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
786
+ // render resolved a font the reference never declared → no reference signal → no fact
787
+ expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, {})).toEqual([]);
788
+ expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: '' } })).toEqual([]);
789
+ });
790
+ test('result is order-independent and deterministic (sorted by element id)', () => {
791
+ const a = deriveTypefaceFacts({ b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } }, { a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } });
792
+ const b = deriveTypefaceFacts({ a: { family: 'Arial', weight: 400 }, b: { family: 'Roboto', weight: 400 } }, { b: { family: 'Roboto', weight: 400 }, a: { family: 'Poppins', weight: 600 } });
793
+ expect(a).toEqual(b);
794
+ expect(a.map((f) => f.elementId)).toEqual(['a']);
795
+ });
796
+ });
691
797
  describe('buildStructuralBlock — appearance container (Signal C, additive)', () => {
692
798
  test('no appearanceReference → no appearance container; facts + geometry unchanged', () => {
693
799
  const withC = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
@@ -905,6 +1011,117 @@ describe('buildStructuralBlock — appearance container (Signal C, additive)', (
905
1011
  const block = buildStructuralBlock(COLOR_DUMP, null, 2);
906
1012
  expect(block.appearance).toBeUndefined();
907
1013
  });
1014
+ // ---- Signal C typeface facts (resolved vs design font, issue 03) ----
1015
+ // A dump whose text node carries the resolved-typeface enrichment (issue 03):
1016
+ // the font that ACTUALLY painted, post-fallback — here a Roboto fallback where
1017
+ // the design wants Poppins SemiBold. The dump also declares the render SDK the
1018
+ // proof ran at (TextRunShaper, the capture seam, is API 31+).
1019
+ const TYPEFACE_DUMP = {
1020
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1021
+ renderSdk: 34,
1022
+ nodes: [
1023
+ {
1024
+ id: 'code',
1025
+ bounds: { left: 0, top: 0, right: 100, bottom: 20 },
1026
+ parentId: null,
1027
+ clipsChildren: false,
1028
+ text: 'INV-50',
1029
+ resolvedTypeface: { family: 'Roboto', weight: 400 },
1030
+ },
1031
+ ],
1032
+ };
1033
+ test('with a reference typeface → a typeface fact fires on divergence and the resolved font is persisted (issue 03)', () => {
1034
+ const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
1035
+ baseline: 'figma',
1036
+ elementIds: ['code'],
1037
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1038
+ });
1039
+ expect(block.appearance?.facts).toEqual([
1040
+ {
1041
+ kind: 'typeface',
1042
+ elementId: 'code',
1043
+ channel: 'text',
1044
+ rendered: { family: 'Roboto', weight: 400 },
1045
+ reference: { family: 'Poppins', weight: 600 },
1046
+ },
1047
+ ]);
1048
+ expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
1049
+ });
1050
+ test('a matching resolved typeface → no fact; the snapshot still persists the resolved font (issue 03)', () => {
1051
+ const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
1052
+ baseline: 'figma',
1053
+ elementIds: ['code'],
1054
+ typeface: { code: { family: 'Roboto', weight: 400 } },
1055
+ });
1056
+ expect(block.appearance?.facts).toEqual([]);
1057
+ expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
1058
+ });
1059
+ test('a node without the enrichment yields no typeface signal and no snapshot entry (old packages degrade gracefully)', () => {
1060
+ const block = buildStructuralBlock(COPY_DUMP, null, 2, undefined, {
1061
+ baseline: 'figma',
1062
+ elementIds: ['title'],
1063
+ typeface: { title: { family: 'Poppins', weight: 600 } },
1064
+ });
1065
+ expect(block.appearance?.facts).toEqual([]);
1066
+ expect(block.appearance?.rendered?.typeface).toBeUndefined();
1067
+ });
1068
+ test('a render SDK below 31 omits typeface facts AND carries the advisory — never a silent pass (issue 03)', () => {
1069
+ const oldSdkDump = {
1070
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1071
+ renderSdk: 28,
1072
+ nodes: [
1073
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
1074
+ ],
1075
+ };
1076
+ const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
1077
+ baseline: 'figma',
1078
+ elementIds: ['code'],
1079
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1080
+ });
1081
+ expect(block.appearance?.facts).toEqual([]);
1082
+ expect(block.appearance?.advisories).toEqual([
1083
+ 'typeface check requires render SDK >= 31; this proof rendered at SDK 28 — typeface facts were not derived (not checked, not passing)',
1084
+ ]);
1085
+ });
1086
+ test('a render SDK below 31 without a typeface reference carries no advisory (nothing was checkable)', () => {
1087
+ const oldSdkDump = {
1088
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1089
+ renderSdk: 28,
1090
+ nodes: [
1091
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
1092
+ ],
1093
+ };
1094
+ const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
1095
+ baseline: 'figma',
1096
+ elementIds: ['code'],
1097
+ });
1098
+ expect(block.appearance?.advisories).toBeUndefined();
1099
+ });
1100
+ test('a render SDK >= 31 or an undeclared one carries no advisory; facts derive normally', () => {
1101
+ // Declared modern SDK → facts derive, no advisory.
1102
+ const modern = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
1103
+ baseline: 'figma',
1104
+ elementIds: ['code'],
1105
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1106
+ });
1107
+ expect(modern.appearance?.advisories).toBeUndefined();
1108
+ expect(modern.appearance?.facts).toHaveLength(1);
1109
+ // Undeclared SDK (an old package's dump): no advisory, derivation still runs
1110
+ // (it just finds no rendered typefaces → no facts, the graceful path).
1111
+ const undeclared = {
1112
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1113
+ nodes: [
1114
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
1115
+ ],
1116
+ };
1117
+ const block = buildStructuralBlock(undeclared, null, 2, undefined, {
1118
+ baseline: 'figma',
1119
+ elementIds: ['code'],
1120
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1121
+ });
1122
+ expect(block.appearance?.advisories).toBeUndefined();
1123
+ expect(block.appearance?.facts).toEqual([]);
1124
+ });
908
1125
  });
909
1126
  // ---- Signal C: previous-render → AppearanceReference mapper (issue 01) ----
910
1127
  //
@@ -953,6 +1170,47 @@ describe('appearanceReferenceFromPreviousRender — previous render snapshot →
953
1170
  text: { copy: 'Get 5 € off your first order placed by 1 lug 2026.' },
954
1171
  });
955
1172
  });
1173
+ test('a previous render with per-element resolved typefaces → the mapper carries typeface into the reference (typeface issue 05)', () => {
1174
+ // The previous render's RESOLVED font becomes the drift reference: a
1175
+ // previously-correct element that silently falls back on a later build then
1176
+ // diverges from its own prior snapshot and is flagged.
1177
+ const previous = {
1178
+ elementIds: ['banner', 'code'],
1179
+ typeface: { code: { family: 'poppins_semibold', weight: 600 } },
1180
+ };
1181
+ expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
1182
+ baseline: 'previous_render',
1183
+ elementIds: ['banner', 'code'],
1184
+ typeface: { code: { family: 'poppins_semibold', weight: 600 } },
1185
+ });
1186
+ // An empty typeface map is no signal on that axis — not carried.
1187
+ expect(appearanceReferenceFromPreviousRender({ elementIds: ['banner'], typeface: {} })).toEqual({ baseline: 'previous_render', elementIds: ['banner'] });
1188
+ });
1189
+ test('fed into buildStructuralBlock, a typeface that drifts from the previous render yields a previous_render typeface fact (typeface issue 05)', () => {
1190
+ const dump = {
1191
+ canvas: { left: 0, top: 0, right: 343, bottom: 72 },
1192
+ renderSdk: 34,
1193
+ nodes: [
1194
+ { id: 'code', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
1195
+ ],
1196
+ };
1197
+ const previous = {
1198
+ elementIds: ['code'],
1199
+ typeface: { code: { family: 'poppins_semibold' } },
1200
+ };
1201
+ const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
1202
+ expect(block.appearance?.baseline).toBe('previous_render');
1203
+ const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
1204
+ expect(typefaceFacts).toEqual([
1205
+ {
1206
+ kind: 'typeface',
1207
+ elementId: 'code',
1208
+ channel: 'text',
1209
+ rendered: { family: 'Roboto-Regular', weight: 400 },
1210
+ reference: { family: 'poppins_semibold' },
1211
+ },
1212
+ ]);
1213
+ });
956
1214
  test('a previous render with both color and text → the reference carries both maps (issue 03)', () => {
957
1215
  const previous = {
958
1216
  elementIds: ['banner', 'copy'],
@@ -1190,9 +1448,65 @@ describe('buildAppearanceReferenceFromParams — union of geometry/text/color id
1190
1448
  spans: { discountText: { text: '5 EUR off', background: '#ffe45e' } },
1191
1449
  });
1192
1450
  });
1451
+ test('lines only (no geometry/text/color/spans) → a reference carrying lines, ids from the line keys (issue 18)', () => {
1452
+ const ref = buildAppearanceReferenceFromParams({
1453
+ refLines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
1454
+ baseline: 'figma',
1455
+ });
1456
+ expect(ref).toEqual({
1457
+ baseline: 'figma',
1458
+ elementIds: ['promoCopy'],
1459
+ lines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
1460
+ });
1461
+ });
1462
+ test('typeface only (no geometry/text/color) → a reference carrying typeface, ids from the typeface keys (typeface issue 04)', () => {
1463
+ const ref = buildAppearanceReferenceFromParams({
1464
+ refTypeface: { code: { family: 'Poppins', weight: 600 } },
1465
+ baseline: 'figma',
1466
+ });
1467
+ expect(ref).toEqual({
1468
+ baseline: 'figma',
1469
+ elementIds: ['code'],
1470
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1471
+ });
1472
+ });
1193
1473
  test('none present → null (no appearance container)', () => {
1194
1474
  expect(buildAppearanceReferenceFromParams({ baseline: 'figma' })).toBeNull();
1195
- expect(buildAppearanceReferenceFromParams({ baseline: 'figma', refGeom: {}, refColor: {}, refText: {}, refSpans: {} })).toBeNull();
1475
+ expect(buildAppearanceReferenceFromParams({ baseline: 'figma', refGeom: {}, refColor: {}, refText: {}, refSpans: {}, refLines: {}, refTypeface: {} })).toBeNull();
1476
+ });
1477
+ test('fed into buildStructuralBlock, a typeface reference flags a diverging resolved font and stays silent on a matching or unresolvable one (typeface issue 04)', () => {
1478
+ // The design (Figma) declares Poppins 600 on `code` and Fira Sans 500 on
1479
+ // `label`. The render resolved a Roboto fallback on `code` (→ one fact) and
1480
+ // the correct font on `label` (→ none); `legacyText` has a declared font but
1481
+ // no resolved enrichment (old package) → no signal, no fact.
1482
+ const dump = {
1483
+ canvas: { left: 0, top: 0, right: 686, bottom: 282 },
1484
+ renderSdk: 34,
1485
+ nodes: [
1486
+ { id: 'code', bounds: { left: 0, top: 0, right: 300, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
1487
+ { id: 'label', bounds: { left: 0, top: 50, right: 300, bottom: 90 }, parentId: null, clipsChildren: false, text: 'Your code', resolvedTypeface: { family: 'fira_sans_medium' } },
1488
+ { id: 'legacyText', bounds: { left: 0, top: 100, right: 300, bottom: 140 }, parentId: null, clipsChildren: false, text: 'Old' },
1489
+ ],
1490
+ };
1491
+ const ref = buildAppearanceReferenceFromParams({
1492
+ refTypeface: {
1493
+ code: { family: 'Poppins', weight: 600 },
1494
+ label: { family: 'Fira Sans', weight: 500 },
1495
+ legacyText: { family: 'Poppins', weight: 400 },
1496
+ },
1497
+ baseline: 'figma',
1498
+ });
1499
+ const block = buildStructuralBlock(dump, null, 2, undefined, ref);
1500
+ const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
1501
+ expect(typefaceFacts).toEqual([
1502
+ {
1503
+ kind: 'typeface',
1504
+ elementId: 'code',
1505
+ channel: 'text',
1506
+ rendered: { family: 'Roboto-Regular', weight: 400 },
1507
+ reference: { family: 'Poppins', weight: 600 },
1508
+ },
1509
+ ]);
1196
1510
  });
1197
1511
  test('fed into buildStructuralBlock, a spans-only reference emits exactly one span_mismatch for the wrong extent (issue 16)', () => {
1198
1512
  const dump = {
@@ -1244,6 +1558,63 @@ describe('buildAppearanceReferenceFromParams — union of geometry/text/color id
1244
1558
  const block = buildStructuralBlock(dump, null, 2, undefined, ref);
1245
1559
  expect(block.appearance?.facts).toEqual([]);
1246
1560
  });
1561
+ test('fed into buildStructuralBlock, a lines-only reference emits exactly one line_break_mismatch for a dropped break (issue 18)', () => {
1562
+ const dump = {
1563
+ canvas: { left: 0, top: 0, right: 686, bottom: 282 },
1564
+ nodes: [
1565
+ {
1566
+ id: 'promoCopy',
1567
+ bounds: { left: 0, top: 0, right: 600, bottom: 40 },
1568
+ parentId: null,
1569
+ clipsChildren: false,
1570
+ text: 'Get 5 EUR off placed by tomorrow.',
1571
+ textLines: [{ start: 0, end: 33, text: 'Get 5 EUR off placed by tomorrow.' }],
1572
+ },
1573
+ // Emitted no lines (old package) — the declared split stays silent.
1574
+ { id: 'legacyText', bounds: { left: 0, top: 50, right: 600, bottom: 90 }, parentId: null, clipsChildren: false, text: 'Free delivery' },
1575
+ ],
1576
+ };
1577
+ const ref = buildAppearanceReferenceFromParams({
1578
+ refLines: {
1579
+ promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'],
1580
+ legacyText: ['Free', 'delivery'],
1581
+ },
1582
+ baseline: 'figma',
1583
+ });
1584
+ const block = buildStructuralBlock(dump, null, 2, undefined, ref);
1585
+ expect(block.appearance?.facts).toEqual([
1586
+ {
1587
+ kind: 'line_break_mismatch',
1588
+ elementId: 'promoCopy',
1589
+ rendered: 'Get 5 EUR off placed by tomorrow.',
1590
+ reference: 'Get 5 EUR off\nplaced by tomorrow.',
1591
+ },
1592
+ ]);
1593
+ });
1594
+ test('a matching line split yields a container with zero facts', () => {
1595
+ const dump = {
1596
+ canvas: { left: 0, top: 0, right: 686, bottom: 282 },
1597
+ nodes: [
1598
+ {
1599
+ id: 'promoCopy',
1600
+ bounds: { left: 0, top: 0, right: 600, bottom: 40 },
1601
+ parentId: null,
1602
+ clipsChildren: false,
1603
+ text: 'Get 5 EUR off\nplaced by tomorrow.',
1604
+ textLines: [
1605
+ { start: 0, end: 13, text: 'Get 5 EUR off' },
1606
+ { start: 14, end: 33, text: 'placed by tomorrow.' },
1607
+ ],
1608
+ },
1609
+ ],
1610
+ };
1611
+ const ref = buildAppearanceReferenceFromParams({
1612
+ refLines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
1613
+ baseline: 'figma',
1614
+ });
1615
+ const block = buildStructuralBlock(dump, null, 2, undefined, ref);
1616
+ expect(block.appearance?.facts).toEqual([]);
1617
+ });
1247
1618
  test('fed into buildStructuralBlock, a color-only reference still emits the color fact', () => {
1248
1619
  const dump = {
1249
1620
  canvas: { left: 0, top: 0, right: 686, bottom: 282 },
@@ -1469,6 +1840,112 @@ describe('embedded geometry stage parity', () => {
1469
1840
  };
1470
1841
  expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
1471
1842
  });
1843
+ test('embedded buildAppearanceReferenceFromParams matches the module for a typeface declaration (typeface issue 04)', () => {
1844
+ const cases = [
1845
+ { refTypeface: { code: { family: 'Poppins', weight: 600 } }, baseline: 'figma' },
1846
+ {
1847
+ refGeom: { code: { w: 100, h: 20 } },
1848
+ refTypeface: { code: { family: 'Poppins', weight: 600 }, label: { family: 'Fira Sans' } },
1849
+ baseline: 'figma',
1850
+ },
1851
+ { refTypeface: {}, baseline: 'figma' },
1852
+ ];
1853
+ for (const params of cases) {
1854
+ expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
1855
+ }
1856
+ });
1857
+ test('embedded deriveLineFacts matches the module (Signal C, issue 18)', () => {
1858
+ const cases = [
1859
+ [
1860
+ { promoCopy: [{ start: 0, end: 13, text: 'Get 5 EUR off' }, { start: 14, end: 33, text: 'placed by tomorrow.' }] },
1861
+ { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
1862
+ ],
1863
+ [
1864
+ { promoCopy: [{ start: 0, end: 33, text: 'Get 5 EUR off placed by tomorrow.' }] },
1865
+ { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
1866
+ ],
1867
+ [{}, { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] }],
1868
+ [{ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, { promoCopy: [] }],
1869
+ [
1870
+ { b: [{ start: 0, end: 1, text: 'x' }], a: [{ start: 0, end: 1, text: 'y' }] },
1871
+ { a: ['right'], b: ['x'] },
1872
+ ],
1873
+ ];
1874
+ for (const [rendered, reference] of cases) {
1875
+ expect(embedded.deriveLineFacts(rendered, reference)).toEqual(deriveLineFacts(rendered, reference));
1876
+ }
1877
+ });
1878
+ test('embedded buildStructuralBlock matches the module with an appearanceReference carrying lines (issue 18)', () => {
1879
+ const dumpWithLines = {
1880
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1881
+ nodes: [
1882
+ {
1883
+ id: 'promoCopy',
1884
+ bounds: { left: 0, top: 0, right: 100, bottom: 20 },
1885
+ parentId: null,
1886
+ clipsChildren: false,
1887
+ text: 'Get 5 EUR off placed by tomorrow.',
1888
+ textLines: [{ start: 0, end: 33, text: 'Get 5 EUR off placed by tomorrow.' }],
1889
+ },
1890
+ ],
1891
+ };
1892
+ const appearanceReference = {
1893
+ baseline: 'figma',
1894
+ elementIds: ['promoCopy'],
1895
+ lines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
1896
+ };
1897
+ expect(embedded.buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference));
1898
+ });
1899
+ test('embedded deriveTypefaceFacts matches the module (Signal C, issue 03)', () => {
1900
+ const cases = [
1901
+ [{ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } }],
1902
+ [{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } }],
1903
+ [{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } }],
1904
+ [{ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } }],
1905
+ [{ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } }],
1906
+ [{}, { code: { family: 'Poppins', weight: 600 } }],
1907
+ [{ code: { family: 'Roboto', weight: 400 } }, {}],
1908
+ [
1909
+ { b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } },
1910
+ { a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } },
1911
+ ],
1912
+ ];
1913
+ for (const [rendered, reference] of cases) {
1914
+ expect(embedded.deriveTypefaceFacts(rendered, reference)).toEqual(deriveTypefaceFacts(rendered, reference));
1915
+ }
1916
+ });
1917
+ test('embedded buildStructuralBlock matches the module with a typeface reference + the render-SDK gate (issue 03)', () => {
1918
+ const dumps = [
1919
+ {
1920
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1921
+ renderSdk: 34,
1922
+ nodes: [
1923
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto', weight: 400 } },
1924
+ ],
1925
+ },
1926
+ {
1927
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1928
+ renderSdk: 28,
1929
+ nodes: [
1930
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
1931
+ ],
1932
+ },
1933
+ {
1934
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1935
+ nodes: [
1936
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
1937
+ ],
1938
+ },
1939
+ ];
1940
+ const appearanceReference = {
1941
+ baseline: 'figma',
1942
+ elementIds: ['code'],
1943
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1944
+ };
1945
+ for (const dump of dumps) {
1946
+ expect(embedded.buildStructuralBlock(dump, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dump, null, 2, undefined, appearanceReference));
1947
+ }
1948
+ });
1472
1949
  test('embedded deriveCopyFacts matches the module (Signal C)', () => {
1473
1950
  const cases = [
1474
1951
  [{ title: 'a' }, { title: 'a' }],
@@ -1 +1 @@
1
- {"version":3,"file":"ui-fidelity-render-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAMpE,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D,iDAAiD;AACjD,eAAO,MAAM,eAAe,6BAA8B,CAAC;AAC3D,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,aAAwB,CAAC;AAE9D,mDAAmD;AACnD,eAAO,MAAM,iBAAiB,6BAA8B,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAE,eAA0B,CAAC;AAElE,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AAExD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,8EAA8E;AAC9E,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAy8BD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAqBrF;AAED;;;GAGG;AACH,qBAAa,mCAAoC,SAAQ,gBAAgB;IACvE,yBAAyB,CACvB,OAAO,EAAE,6BAA6B,EACtC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAepB,OAAO,CACX,MAAM,EAAE,6BAA6B,EACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CA0EpB"}
1
+ {"version":3,"file":"ui-fidelity-render-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-render-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAMpE,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D,iDAAiD;AACjD,eAAO,MAAM,eAAe,6BAA8B,CAAC;AAC3D,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE7D;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,aAAwB,CAAC;AAE9D,mDAAmD;AACnD,eAAO,MAAM,iBAAiB,6BAA8B,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAE,eAA0B,CAAC;AAElE,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,mBAAmB,CAAC;AAEzD,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AAExD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,8EAA8E;AAC9E,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC;AAq+BD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAqBrF;AAED;;;GAGG;AACH,qBAAa,mCAAoC,SAAQ,gBAAgB;IACvE,yBAAyB,CACvB,OAAO,EAAE,6BAA6B,EACtC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,qBAAqB,EAAE;IAepB,OAAO,CACX,MAAM,EAAE,6BAA6B,EACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,QAAQ,GAChB,OAAO,CAAC,OAAO,CAAC;CA0EpB"}
@@ -123,6 +123,15 @@ var referenceColor = null;
123
123
  // extent per element id per screen, as { text, background }), set in main() and
124
124
  // read by the structural assembler. Null when none declared → no span facts.
125
125
  var referenceSpans = null;
126
+ // params.reference_lines (Signal C line facts, issue 18: the declared line
127
+ // split per element id per screen, as an array of line texts), set in main()
128
+ // and read by the structural assembler. Null when none declared → no line facts.
129
+ var referenceLines = null;
130
+ // params.reference_typeface (Signal C typeface facts, typeface issue 04: the
131
+ // DESIGN font per element id per screen, as { family, weight }, built by the
132
+ // caller from Figma node metadata), set in main() and read by the structural
133
+ // assembler. Null when none declared → no typeface facts.
134
+ var referenceTypeface = null;
126
135
  // params.reference_baseline (Signal C: 'figma' (default) or 'previous_render').
127
136
  // Marks whether the appearance reference is the design (divergences) or the
128
137
  // previous catalog render (regressions/drift, the no-Figma path).
@@ -669,7 +678,7 @@ function renderScreens(renderable, projectRoot) {
669
678
  // geometry deltas vs reference_geometry) onto the screen entry, so the
670
679
  // judge reads the signals from protocol-result.json without fetching
671
680
  // artifacts. CONFIG.scale is the render density (dp = px / scale).
672
- assembleStructural(entry, layoutSource, referenceGeometry, CONFIG.scale, referenceText, referenceColor, referenceBaseline, referenceFrame, proofKind, referenceSpans);
681
+ assembleStructural(entry, layoutSource, referenceGeometry, CONFIG.scale, referenceText, referenceColor, referenceBaseline, referenceFrame, proofKind, referenceSpans, referenceLines, referenceTypeface);
673
682
  }
674
683
  });
675
684
  } finally {
@@ -760,6 +769,25 @@ function main() {
760
769
  !Array.isArray(params.reference_spans)
761
770
  ? params.reference_spans
762
771
  : null;
772
+ // Signal-C line reference (issue 18): { "<Screen>": { "<elementId>":
773
+ // ["line 1", "line 2"] } } — the exact line texts the design breaks that
774
+ // text element into. Absent → no line facts.
775
+ referenceLines =
776
+ params.reference_lines &&
777
+ typeof params.reference_lines === 'object' &&
778
+ !Array.isArray(params.reference_lines)
779
+ ? params.reference_lines
780
+ : null;
781
+ // Signal-C typeface reference (typeface issue 04): { "<Screen>":
782
+ // { "<elementId>": { family, weight } } } — the DESIGN font per element, from
783
+ // Figma node metadata. Diffed against the RESOLVED (painted) font the dump
784
+ // captured. Absent → no typeface facts.
785
+ referenceTypeface =
786
+ params.reference_typeface &&
787
+ typeof params.reference_typeface === 'object' &&
788
+ !Array.isArray(params.reference_typeface)
789
+ ? params.reference_typeface
790
+ : null;
763
791
  // Signal-C baseline marker: 'previous_render' when the appearance reference was
764
792
  // sourced from the previous catalog render (no-Figma drift path), else 'figma'.
765
793
  referenceBaseline =
@@ -50,7 +50,9 @@ const FAKE_GRADLEW_LINES = [
50
50
  // explicit golden-style dotted name via FAKE_ROBORAZZI_LAYOUT_NAME to prove
51
51
  // the consumer finds it by dot-segment match, decoupled from the PNG stem.
52
52
  ' lname="${FAKE_ROBORAZZI_LAYOUT_NAME:-$s.layout.json}"',
53
- ` printf '%s' '{"canvas":{"left":0,"top":0,"right":200,"bottom":400},"nodes":[{"id":"card","bounds":{"left":20,"top":20,"right":180,"bottom":200},"parentId":null,"clipsChildren":true},{"id":"tag","bounds":{"left":60,"top":150,"right":140,"bottom":224},"parentId":"card","clipsChildren":false}]}' > "$out/$lname"`,
53
+ // The tag node carries the resolved-typeface enrichment (a Roboto fallback)
54
+ // so a run with reference_typeface can prove the params → fact plumbing.
55
+ ` printf '%s' '{"renderSdk":34,"canvas":{"left":0,"top":0,"right":200,"bottom":400},"nodes":[{"id":"card","bounds":{"left":20,"top":20,"right":180,"bottom":200},"parentId":null,"clipsChildren":true},{"id":"tag","bounds":{"left":60,"top":150,"right":140,"bottom":224},"parentId":"card","clipsChildren":false,"text":"TAG","resolvedTypeface":{"family":"Roboto-Regular","weight":400}}]}' > "$out/$lname"`,
54
56
  ' fi',
55
57
  'done',
56
58
  'exit 0',
@@ -440,6 +442,52 @@ describe('android render runtime — render + trim (happy path)', () => {
440
442
  expect(tag?.sizeFlag).toBe(false);
441
443
  expect(tag?.deltas).toEqual([]);
442
444
  });
445
+ test('derives a flagged typeface fact when reference_typeface is given and the resolved font diverges (typeface issue 04)', async () => {
446
+ // The fake dump resolved Roboto-Regular 400 on `tag`; the design declares
447
+ // Poppins 600 → exactly one typeface fact on the run result. A matching
448
+ // declaration on a second run produces none.
449
+ const project = makeProject({
450
+ screens: { XProof: 'x.png' },
451
+ reference_typeface: { XProof: { tag: { family: 'Poppins', weight: 600 } } },
452
+ });
453
+ writeReference(project, 'x.png');
454
+ stageArchive(project, gradleProjectEntries());
455
+ const run = runScript(project, await buildScript(), {
456
+ FAKE_ROBORAZZI_SCREENS: 'XProof',
457
+ FAKE_ROBORAZZI_LAYOUT: '1',
458
+ });
459
+ expect(run.status).toBe(0);
460
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
461
+ const typefaceFacts = (screen?.structural?.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
462
+ expect(typefaceFacts).toEqual([
463
+ {
464
+ kind: 'typeface',
465
+ elementId: 'tag',
466
+ channel: 'text',
467
+ rendered: { family: 'Roboto-Regular', weight: 400 },
468
+ reference: { family: 'Poppins', weight: 600 },
469
+ },
470
+ ]);
471
+ // The resolved font is persisted as the next drift baseline.
472
+ expect(screen?.structural?.appearance?.rendered?.typeface).toEqual({
473
+ tag: { family: 'Roboto-Regular', weight: 400 },
474
+ });
475
+ // Matching declaration → no typeface fact (and no advisory).
476
+ const clean = makeProject({
477
+ screens: { XProof: 'x.png' },
478
+ reference_typeface: { XProof: { tag: { family: 'Roboto-Regular', weight: 400 } } },
479
+ });
480
+ writeReference(clean, 'x.png');
481
+ stageArchive(clean, gradleProjectEntries());
482
+ const cleanRun = runScript(clean, await buildScript(), {
483
+ FAKE_ROBORAZZI_SCREENS: 'XProof',
484
+ FAKE_ROBORAZZI_LAYOUT: '1',
485
+ });
486
+ expect(cleanRun.status).toBe(0);
487
+ const cleanScreen = readResult(clean).screens.find((s) => s.screen === 'XProof');
488
+ expect((cleanScreen?.structural?.appearance?.facts ?? []).filter((f) => f.kind === 'typeface')).toEqual([]);
489
+ expect(cleanScreen?.structural?.appearance?.advisories).toBeUndefined();
490
+ });
443
491
  test('finds the layout dump by dot-segment match even when its name differs from the PNG stem', async () => {
444
492
  const project = makeProject({ screens: { XProof: 'x.png' } });
445
493
  writeReference(project, 'x.png');