@invarn/cibuild 2.3.1 → 2.3.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.
@@ -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, deriveLineFacts, 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', () => {
@@ -223,6 +223,18 @@ describe('embedded render-side stage parity', () => {
223
223
  { id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, unclippedBounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
224
224
  ],
225
225
  },
226
+ // A duplicated testTag: the collision guard must withhold the child's
227
+ // (mispairable) overflow and emit the duplicate_tag fact identically.
228
+ duplicateTag: {
229
+ canvas: CANVAS,
230
+ nodes: [
231
+ { id: 'list', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
232
+ { id: 'copy_button', bounds: { left: 20, top: 20, right: 180, bottom: 60 }, parentId: 'list', clipsChildren: false },
233
+ { id: 'copy_button', bounds: { left: 20, top: 80, right: 180, bottom: 120 }, parentId: 'list', clipsChildren: false },
234
+ { id: 'copy_button', bounds: { left: 20, top: 140, right: 180, bottom: 180 }, parentId: 'list', clipsChildren: false },
235
+ { id: 'copy_label', bounds: { left: 30, top: 30, right: 120, bottom: 50 }, parentId: 'copy_button', clipsChildren: false },
236
+ ],
237
+ },
226
238
  };
227
239
  for (const [name, dump] of Object.entries(fixtures)) {
228
240
  test(`embedded port matches the module for the ${name} fixture`, () => {
@@ -256,6 +268,45 @@ describe('deriveStructuralFacts — motivating regression (voucher tag bottom-cl
256
268
  });
257
269
  });
258
270
  });
271
+ describe('deriveStructuralFacts — duplicate testTag collision guard', () => {
272
+ // Repeated rows tagging their pill with one shared testTag: parent lookups
273
+ // resolve through a last-wins id map, so overflow/clip derived through the
274
+ // duplicated tag compares arbitrary instances (a child of the first row
275
+ // measured against the last row's box reads as a huge spurious overflow).
276
+ // The deriver must refuse to guess — name the collision as a fact and
277
+ // withhold the pairing-derived facts for that tag.
278
+ test('three nodes sharing one testTag yield a duplicate_tag fact and no overflow/clip facts through that tag', () => {
279
+ const dump = {
280
+ canvas: CANVAS,
281
+ nodes: [
282
+ { id: 'list', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
283
+ { id: 'copy_button', bounds: { left: 20, top: 20, right: 180, bottom: 60 }, parentId: 'list', clipsChildren: false },
284
+ { id: 'copy_button', bounds: { left: 20, top: 80, right: 180, bottom: 120 }, parentId: 'list', clipsChildren: false },
285
+ { id: 'copy_button', bounds: { left: 20, top: 140, right: 180, bottom: 180 }, parentId: 'list', clipsChildren: false },
286
+ // Sits inside the FIRST pill; paired against the last-wins instance
287
+ // (top 140) it would read as a spurious 110px top overflow.
288
+ { id: 'copy_label', bounds: { left: 30, top: 30, right: 120, bottom: 50 }, parentId: 'copy_button', clipsChildren: false },
289
+ ],
290
+ };
291
+ expect(deriveStructuralFacts(dump)).toEqual([
292
+ { kind: 'duplicate_tag', tag: 'copy_button', instanceCount: 3 },
293
+ ]);
294
+ });
295
+ test('a duplicated node overflowing its unique parent is withheld too — attribution is ambiguous', () => {
296
+ const dump = {
297
+ canvas: CANVAS,
298
+ nodes: [
299
+ { id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
300
+ // The first instance sticks out 15px past the row; the second is contained.
301
+ { id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
302
+ { id: 'chip', bounds: { left: 30, top: 30, right: 60, bottom: 60 }, parentId: 'row', clipsChildren: false },
303
+ ],
304
+ };
305
+ expect(deriveStructuralFacts(dump)).toEqual([
306
+ { kind: 'duplicate_tag', tag: 'chip', instanceCount: 2 },
307
+ ]);
308
+ });
309
+ });
259
310
  // ---- Signal B: key-geometry diff + inline structural-block assembly ----
260
311
  describe('diffKeyGeometry — dimensional gaps', () => {
261
312
  test('reports a size delta on the right element beyond tolerance', () => {
@@ -743,6 +794,57 @@ describe('deriveLineFacts — declared line split vs rendered laid-out lines', (
743
794
  ]);
744
795
  });
745
796
  });
797
+ describe('deriveTypefaceFacts — resolved vs design typeface per element', () => {
798
+ test('a family mismatch yields one typeface fact carrying both fonts', () => {
799
+ const facts = deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
800
+ expect(facts).toEqual([
801
+ {
802
+ kind: 'typeface',
803
+ elementId: 'code',
804
+ channel: 'text',
805
+ rendered: { family: 'Roboto', weight: 400 },
806
+ reference: { family: 'Poppins', weight: 600 },
807
+ },
808
+ ]);
809
+ });
810
+ test('a weight mismatch on the same family yields a typeface fact', () => {
811
+ const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
812
+ expect(facts).toHaveLength(1);
813
+ expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code', channel: 'text' });
814
+ });
815
+ test('an exact family+weight match produces no fact', () => {
816
+ expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
817
+ });
818
+ test('a normalization-equivalent pair produces no fact (suffix-encoded weight vs numeric, case, separators)', () => {
819
+ // design names "Poppins-SemiBold"; render resolved "Poppins" weight 600 — same font.
820
+ expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } })).toEqual([]);
821
+ // resource-name encoding "fira_sans_medium" vs design "Fira Sans" weight 500.
822
+ expect(deriveTypefaceFacts({ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } })).toEqual([]);
823
+ // pure case difference on the family.
824
+ expect(deriveTypefaceFacts({ code: { family: 'POPPINS', weight: 600 } }, { code: { family: 'poppins', weight: 600 } })).toEqual([]);
825
+ });
826
+ test('a suffix-encoded family that disagrees on weight still flags', () => {
827
+ // design "Poppins-SemiBold" (→600) vs resolved "Poppins" Regular (400) — a real
828
+ // weight fallback the suffix normalization must NOT mask.
829
+ const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } });
830
+ expect(facts).toHaveLength(1);
831
+ expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code' });
832
+ });
833
+ test('a missing rendered or reference font produces no fact (graceful, no false positive)', () => {
834
+ // reference declares a font the render never resolved → no signal → no fact
835
+ expect(deriveTypefaceFacts({ code: undefined }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
836
+ expect(deriveTypefaceFacts({ code: { family: '' } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
837
+ // render resolved a font the reference never declared → no reference signal → no fact
838
+ expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, {})).toEqual([]);
839
+ expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: '' } })).toEqual([]);
840
+ });
841
+ test('result is order-independent and deterministic (sorted by element id)', () => {
842
+ const a = deriveTypefaceFacts({ b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } }, { a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } });
843
+ const b = deriveTypefaceFacts({ a: { family: 'Arial', weight: 400 }, b: { family: 'Roboto', weight: 400 } }, { b: { family: 'Roboto', weight: 400 }, a: { family: 'Poppins', weight: 600 } });
844
+ expect(a).toEqual(b);
845
+ expect(a.map((f) => f.elementId)).toEqual(['a']);
846
+ });
847
+ });
746
848
  describe('buildStructuralBlock — appearance container (Signal C, additive)', () => {
747
849
  test('no appearanceReference → no appearance container; facts + geometry unchanged', () => {
748
850
  const withC = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
@@ -960,6 +1062,117 @@ describe('buildStructuralBlock — appearance container (Signal C, additive)', (
960
1062
  const block = buildStructuralBlock(COLOR_DUMP, null, 2);
961
1063
  expect(block.appearance).toBeUndefined();
962
1064
  });
1065
+ // ---- Signal C typeface facts (resolved vs design font, issue 03) ----
1066
+ // A dump whose text node carries the resolved-typeface enrichment (issue 03):
1067
+ // the font that ACTUALLY painted, post-fallback — here a Roboto fallback where
1068
+ // the design wants Poppins SemiBold. The dump also declares the render SDK the
1069
+ // proof ran at (TextRunShaper, the capture seam, is API 31+).
1070
+ const TYPEFACE_DUMP = {
1071
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1072
+ renderSdk: 34,
1073
+ nodes: [
1074
+ {
1075
+ id: 'code',
1076
+ bounds: { left: 0, top: 0, right: 100, bottom: 20 },
1077
+ parentId: null,
1078
+ clipsChildren: false,
1079
+ text: 'INV-50',
1080
+ resolvedTypeface: { family: 'Roboto', weight: 400 },
1081
+ },
1082
+ ],
1083
+ };
1084
+ test('with a reference typeface → a typeface fact fires on divergence and the resolved font is persisted (issue 03)', () => {
1085
+ const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
1086
+ baseline: 'figma',
1087
+ elementIds: ['code'],
1088
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1089
+ });
1090
+ expect(block.appearance?.facts).toEqual([
1091
+ {
1092
+ kind: 'typeface',
1093
+ elementId: 'code',
1094
+ channel: 'text',
1095
+ rendered: { family: 'Roboto', weight: 400 },
1096
+ reference: { family: 'Poppins', weight: 600 },
1097
+ },
1098
+ ]);
1099
+ expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
1100
+ });
1101
+ test('a matching resolved typeface → no fact; the snapshot still persists the resolved font (issue 03)', () => {
1102
+ const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
1103
+ baseline: 'figma',
1104
+ elementIds: ['code'],
1105
+ typeface: { code: { family: 'Roboto', weight: 400 } },
1106
+ });
1107
+ expect(block.appearance?.facts).toEqual([]);
1108
+ expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
1109
+ });
1110
+ test('a node without the enrichment yields no typeface signal and no snapshot entry (old packages degrade gracefully)', () => {
1111
+ const block = buildStructuralBlock(COPY_DUMP, null, 2, undefined, {
1112
+ baseline: 'figma',
1113
+ elementIds: ['title'],
1114
+ typeface: { title: { family: 'Poppins', weight: 600 } },
1115
+ });
1116
+ expect(block.appearance?.facts).toEqual([]);
1117
+ expect(block.appearance?.rendered?.typeface).toBeUndefined();
1118
+ });
1119
+ test('a render SDK below 31 omits typeface facts AND carries the advisory — never a silent pass (issue 03)', () => {
1120
+ const oldSdkDump = {
1121
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1122
+ renderSdk: 28,
1123
+ nodes: [
1124
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
1125
+ ],
1126
+ };
1127
+ const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
1128
+ baseline: 'figma',
1129
+ elementIds: ['code'],
1130
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1131
+ });
1132
+ expect(block.appearance?.facts).toEqual([]);
1133
+ expect(block.appearance?.advisories).toEqual([
1134
+ 'typeface check requires render SDK >= 31; this proof rendered at SDK 28 — typeface facts were not derived (not checked, not passing)',
1135
+ ]);
1136
+ });
1137
+ test('a render SDK below 31 without a typeface reference carries no advisory (nothing was checkable)', () => {
1138
+ const oldSdkDump = {
1139
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1140
+ renderSdk: 28,
1141
+ nodes: [
1142
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
1143
+ ],
1144
+ };
1145
+ const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
1146
+ baseline: 'figma',
1147
+ elementIds: ['code'],
1148
+ });
1149
+ expect(block.appearance?.advisories).toBeUndefined();
1150
+ });
1151
+ test('a render SDK >= 31 or an undeclared one carries no advisory; facts derive normally', () => {
1152
+ // Declared modern SDK → facts derive, no advisory.
1153
+ const modern = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
1154
+ baseline: 'figma',
1155
+ elementIds: ['code'],
1156
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1157
+ });
1158
+ expect(modern.appearance?.advisories).toBeUndefined();
1159
+ expect(modern.appearance?.facts).toHaveLength(1);
1160
+ // Undeclared SDK (an old package's dump): no advisory, derivation still runs
1161
+ // (it just finds no rendered typefaces → no facts, the graceful path).
1162
+ const undeclared = {
1163
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
1164
+ nodes: [
1165
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
1166
+ ],
1167
+ };
1168
+ const block = buildStructuralBlock(undeclared, null, 2, undefined, {
1169
+ baseline: 'figma',
1170
+ elementIds: ['code'],
1171
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1172
+ });
1173
+ expect(block.appearance?.advisories).toBeUndefined();
1174
+ expect(block.appearance?.facts).toEqual([]);
1175
+ });
963
1176
  });
964
1177
  // ---- Signal C: previous-render → AppearanceReference mapper (issue 01) ----
965
1178
  //
@@ -1008,6 +1221,47 @@ describe('appearanceReferenceFromPreviousRender — previous render snapshot →
1008
1221
  text: { copy: 'Get 5 € off your first order placed by 1 lug 2026.' },
1009
1222
  });
1010
1223
  });
1224
+ test('a previous render with per-element resolved typefaces → the mapper carries typeface into the reference (typeface issue 05)', () => {
1225
+ // The previous render's RESOLVED font becomes the drift reference: a
1226
+ // previously-correct element that silently falls back on a later build then
1227
+ // diverges from its own prior snapshot and is flagged.
1228
+ const previous = {
1229
+ elementIds: ['banner', 'code'],
1230
+ typeface: { code: { family: 'poppins_semibold', weight: 600 } },
1231
+ };
1232
+ expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
1233
+ baseline: 'previous_render',
1234
+ elementIds: ['banner', 'code'],
1235
+ typeface: { code: { family: 'poppins_semibold', weight: 600 } },
1236
+ });
1237
+ // An empty typeface map is no signal on that axis — not carried.
1238
+ expect(appearanceReferenceFromPreviousRender({ elementIds: ['banner'], typeface: {} })).toEqual({ baseline: 'previous_render', elementIds: ['banner'] });
1239
+ });
1240
+ test('fed into buildStructuralBlock, a typeface that drifts from the previous render yields a previous_render typeface fact (typeface issue 05)', () => {
1241
+ const dump = {
1242
+ canvas: { left: 0, top: 0, right: 343, bottom: 72 },
1243
+ renderSdk: 34,
1244
+ nodes: [
1245
+ { id: 'code', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
1246
+ ],
1247
+ };
1248
+ const previous = {
1249
+ elementIds: ['code'],
1250
+ typeface: { code: { family: 'poppins_semibold' } },
1251
+ };
1252
+ const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
1253
+ expect(block.appearance?.baseline).toBe('previous_render');
1254
+ const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
1255
+ expect(typefaceFacts).toEqual([
1256
+ {
1257
+ kind: 'typeface',
1258
+ elementId: 'code',
1259
+ channel: 'text',
1260
+ rendered: { family: 'Roboto-Regular', weight: 400 },
1261
+ reference: { family: 'poppins_semibold' },
1262
+ },
1263
+ ]);
1264
+ });
1011
1265
  test('a previous render with both color and text → the reference carries both maps (issue 03)', () => {
1012
1266
  const previous = {
1013
1267
  elementIds: ['banner', 'copy'],
@@ -1256,9 +1510,54 @@ describe('buildAppearanceReferenceFromParams — union of geometry/text/color id
1256
1510
  lines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
1257
1511
  });
1258
1512
  });
1513
+ test('typeface only (no geometry/text/color) → a reference carrying typeface, ids from the typeface keys (typeface issue 04)', () => {
1514
+ const ref = buildAppearanceReferenceFromParams({
1515
+ refTypeface: { code: { family: 'Poppins', weight: 600 } },
1516
+ baseline: 'figma',
1517
+ });
1518
+ expect(ref).toEqual({
1519
+ baseline: 'figma',
1520
+ elementIds: ['code'],
1521
+ typeface: { code: { family: 'Poppins', weight: 600 } },
1522
+ });
1523
+ });
1259
1524
  test('none present → null (no appearance container)', () => {
1260
1525
  expect(buildAppearanceReferenceFromParams({ baseline: 'figma' })).toBeNull();
1261
- expect(buildAppearanceReferenceFromParams({ baseline: 'figma', refGeom: {}, refColor: {}, refText: {}, refSpans: {}, refLines: {} })).toBeNull();
1526
+ expect(buildAppearanceReferenceFromParams({ baseline: 'figma', refGeom: {}, refColor: {}, refText: {}, refSpans: {}, refLines: {}, refTypeface: {} })).toBeNull();
1527
+ });
1528
+ test('fed into buildStructuralBlock, a typeface reference flags a diverging resolved font and stays silent on a matching or unresolvable one (typeface issue 04)', () => {
1529
+ // The design (Figma) declares Poppins 600 on `code` and Fira Sans 500 on
1530
+ // `label`. The render resolved a Roboto fallback on `code` (→ one fact) and
1531
+ // the correct font on `label` (→ none); `legacyText` has a declared font but
1532
+ // no resolved enrichment (old package) → no signal, no fact.
1533
+ const dump = {
1534
+ canvas: { left: 0, top: 0, right: 686, bottom: 282 },
1535
+ renderSdk: 34,
1536
+ nodes: [
1537
+ { id: 'code', bounds: { left: 0, top: 0, right: 300, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
1538
+ { id: 'label', bounds: { left: 0, top: 50, right: 300, bottom: 90 }, parentId: null, clipsChildren: false, text: 'Your code', resolvedTypeface: { family: 'fira_sans_medium' } },
1539
+ { id: 'legacyText', bounds: { left: 0, top: 100, right: 300, bottom: 140 }, parentId: null, clipsChildren: false, text: 'Old' },
1540
+ ],
1541
+ };
1542
+ const ref = buildAppearanceReferenceFromParams({
1543
+ refTypeface: {
1544
+ code: { family: 'Poppins', weight: 600 },
1545
+ label: { family: 'Fira Sans', weight: 500 },
1546
+ legacyText: { family: 'Poppins', weight: 400 },
1547
+ },
1548
+ baseline: 'figma',
1549
+ });
1550
+ const block = buildStructuralBlock(dump, null, 2, undefined, ref);
1551
+ const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
1552
+ expect(typefaceFacts).toEqual([
1553
+ {
1554
+ kind: 'typeface',
1555
+ elementId: 'code',
1556
+ channel: 'text',
1557
+ rendered: { family: 'Roboto-Regular', weight: 400 },
1558
+ reference: { family: 'Poppins', weight: 600 },
1559
+ },
1560
+ ]);
1262
1561
  });
1263
1562
  test('fed into buildStructuralBlock, a spans-only reference emits exactly one span_mismatch for the wrong extent (issue 16)', () => {
1264
1563
  const dump = {
@@ -1524,6 +1823,235 @@ describe('buildStructuralBlock — placement facts (composition runs)', () => {
1524
1823
  }
1525
1824
  });
1526
1825
  });
1826
+ describe('buildStructuralBlock — duplicate testTag withhold (geometry / overhang / placement)', () => {
1827
+ // A duplicated tag's entry in the generated-geometry map is last-wins, so its
1828
+ // size diff, any overhang computed against it as a frame, and its placement
1829
+ // drift would all compare an arbitrary instance. Each is withheld; the
1830
+ // deriver's duplicate_tag fact carries the collision.
1831
+ test('a duplicated tag gets no geometry element — no size/clip facts for it', () => {
1832
+ const dump = {
1833
+ canvas: { left: 0, top: 0, right: 360, bottom: 200 },
1834
+ nodes: [
1835
+ { id: 'card', bounds: { left: 0, top: 0, right: 360, bottom: 200 }, parentId: null, clipsChildren: false },
1836
+ // 64×33 rendered vs 60×36 declared — would sizeFlag if compared.
1837
+ { id: 'pill', bounds: { left: 10, top: 10, right: 74, bottom: 43 }, parentId: 'card', clipsChildren: false },
1838
+ { id: 'pill', bounds: { left: 10, top: 60, right: 74, bottom: 93 }, parentId: 'card', clipsChildren: false },
1839
+ ],
1840
+ };
1841
+ const block = buildStructuralBlock(dump, { card: { w: 360, h: 200 }, pill: { w: 60, h: 36 } }, 1);
1842
+ expect(block.facts).toContainEqual({ kind: 'duplicate_tag', tag: 'pill', instanceCount: 2 });
1843
+ expect(block.geometry.elements.map((e) => e.id)).toEqual(['card']);
1844
+ expect(block.summary.clean).toBe(false);
1845
+ });
1846
+ test('no overhang is computed against a duplicated frame — the pairing would be arbitrary', () => {
1847
+ const dump = {
1848
+ canvas: { left: 0, top: 0, right: 360, bottom: 400 },
1849
+ nodes: [
1850
+ { id: 'banner', bounds: { left: 0, top: 0, right: 343, bottom: 72 }, parentId: null, clipsChildren: false },
1851
+ { id: 'banner', bounds: { left: 0, top: 100, right: 343, bottom: 172 }, parentId: null, clipsChildren: false },
1852
+ // Child of the duplicated banner: vs the last-wins instance it would
1853
+ // read a spurious 100dp top clip.
1854
+ { id: 'coin', bounds: { left: 300, top: 40, right: 340, bottom: 90 }, parentId: 'banner', clipsChildren: false },
1855
+ ],
1856
+ };
1857
+ const block = buildStructuralBlock(dump, { banner: { x: 0, y: 0, w: 343, h: 72 }, coin: { x: 300, y: 40, w: 40, h: 50 } }, 1);
1858
+ const coin = block.geometry.elements.find((e) => e.id === 'coin');
1859
+ expect(coin).toBeDefined();
1860
+ expect(coin.overhang).toBeUndefined();
1861
+ });
1862
+ test('placement facts skip a duplicated member tag (composition runs)', () => {
1863
+ const dump = {
1864
+ canvas: { left: 0, top: 0, right: 360, bottom: 640 },
1865
+ nodes: [
1866
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
1867
+ { id: 'member', bounds: { left: 0, top: 0, right: 360, bottom: 100 }, parentId: 'root', clipsChildren: false },
1868
+ { id: 'member', bounds: { left: 0, top: 200, right: 360, bottom: 300 }, parentId: 'root', clipsChildren: false },
1869
+ // Genuinely misplaced: rendered y 500 vs design y 540.
1870
+ { id: 'footer', bounds: { left: 0, top: 500, right: 360, bottom: 640 }, parentId: 'root', clipsChildren: false },
1871
+ ],
1872
+ };
1873
+ const block = buildStructuralBlock(dump, { member: { x: 0, y: 0, w: 360, h: 100 }, footer: { x: 0, y: 540, w: 360, h: 140 } }, 1, undefined, undefined, { w: 360, h: 640 });
1874
+ expect(block.placement.withheld).toBe(false);
1875
+ expect(block.placement.facts.map((f) => f.elementId)).toEqual(['footer']);
1876
+ });
1877
+ test('the review scaffold rows the collision under the tag with the re-tag remediation', () => {
1878
+ const scaffold = buildReviewScaffold({
1879
+ facts: [{ kind: 'duplicate_tag', tag: 'copy_button', instanceCount: 3 }],
1880
+ });
1881
+ expect(scaffold.mustExplain).toEqual(['copy_button']);
1882
+ expect(scaffold.rows).toHaveLength(1);
1883
+ expect(scaffold.rows[0].element).toBe('copy_button');
1884
+ expect(scaffold.rows[0].fact).toContain('3 nodes');
1885
+ expect(scaffold.rows[0].fact).toContain('unique testTag');
1886
+ });
1887
+ });
1888
+ describe('buildStructuralBlock — text-aware size tolerance (line-box residue)', () => {
1889
+ // JVM (Robolectric) text metrics differ a few dp from Figma's nominal line
1890
+ // boxes, so a text-driven element legitimately renders ±~2dp per laid-out
1891
+ // line. The significance flag (sizeFlag) gets that band — min(8, 2 + 2·L)dp
1892
+ // over the element's subtree line count — while the MEASUREMENT (deltas)
1893
+ // stays exact and non-text elements keep the strict 1dp band (ADR-0004:
1894
+ // strict, not loosen). Fixtures pin the July log cases.
1895
+ const CANVAS_640 = { left: 0, top: 0, right: 360, bottom: 640 };
1896
+ test('the 1-line pill (64×33 rendered vs 60×36 design) records the deltas but does not size-flag', () => {
1897
+ const dump = {
1898
+ canvas: CANVAS_640,
1899
+ nodes: [
1900
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
1901
+ { id: 'pill', bounds: { left: 16, top: 16, right: 80, bottom: 49 }, parentId: 'root', clipsChildren: false },
1902
+ {
1903
+ id: 'pill_label',
1904
+ bounds: { left: 24, top: 24, right: 72, bottom: 41 },
1905
+ parentId: 'pill',
1906
+ clipsChildren: false,
1907
+ text: 'Copy code',
1908
+ textLines: [{ start: 0, end: 9, text: 'Copy code' }],
1909
+ },
1910
+ ],
1911
+ };
1912
+ const block = buildStructuralBlock(dump, { pill: { w: 60, h: 36 } }, 1);
1913
+ const pill = block.geometry.elements.find((e) => e.id === 'pill');
1914
+ // Measurement integrity: the deltas are still recorded, exactly.
1915
+ expect(pill.deltas).toEqual([
1916
+ { dimension: 'w', delta: 4 },
1917
+ { dimension: 'h', delta: -3 },
1918
+ ]);
1919
+ // Significance: within the 1-line text band (±4dp) — not a size divergence.
1920
+ expect(pill.sizeFlag).toBe(false);
1921
+ expect(block.summary.clean).toBe(true);
1922
+ });
1923
+ test('the 3-line card (65dp rendered vs 72 design) is within the line-scaled band', () => {
1924
+ const dump = {
1925
+ canvas: CANVAS_640,
1926
+ nodes: [
1927
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
1928
+ { id: 'card', bounds: { left: 16, top: 16, right: 359, bottom: 81 }, parentId: 'root', clipsChildren: false },
1929
+ {
1930
+ id: 'title',
1931
+ bounds: { left: 24, top: 24, right: 351, bottom: 45 },
1932
+ parentId: 'card',
1933
+ clipsChildren: false,
1934
+ text: 'Fresh picks',
1935
+ textLines: [{ start: 0, end: 11, text: 'Fresh picks' }],
1936
+ },
1937
+ {
1938
+ id: 'subtitle',
1939
+ bounds: { left: 24, top: 49, right: 351, bottom: 73 },
1940
+ parentId: 'card',
1941
+ clipsChildren: false,
1942
+ text: 'Two lines of helper copy wrap here',
1943
+ textLines: [
1944
+ { start: 0, end: 17, text: 'Two lines of help' },
1945
+ { start: 18, end: 34, text: 'er copy wrap here' },
1946
+ ],
1947
+ },
1948
+ ],
1949
+ };
1950
+ const block = buildStructuralBlock(dump, { card: { w: 343, h: 72 } }, 1);
1951
+ const card = block.geometry.elements.find((e) => e.id === 'card');
1952
+ expect(card.deltas).toEqual([{ dimension: 'h', delta: -7 }]);
1953
+ // 3 laid-out lines in the subtree → band min(8, 2 + 2·3) = 8dp ≥ 7.
1954
+ expect(card.sizeFlag).toBe(false);
1955
+ });
1956
+ test('a −2dp helper text node stays unflagged (its own line makes it text-driven)', () => {
1957
+ const dump = {
1958
+ canvas: CANVAS_640,
1959
+ nodes: [
1960
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
1961
+ {
1962
+ id: 'helper',
1963
+ bounds: { left: 16, top: 100, right: 136, bottom: 118 },
1964
+ parentId: 'root',
1965
+ clipsChildren: false,
1966
+ text: 'Terms apply',
1967
+ textLines: [{ start: 0, end: 11, text: 'Terms apply' }],
1968
+ },
1969
+ ],
1970
+ };
1971
+ const block = buildStructuralBlock(dump, { helper: { w: 120, h: 20 } }, 1);
1972
+ const helper = block.geometry.elements.find((e) => e.id === 'helper');
1973
+ expect(helper.deltas).toEqual([{ dimension: 'h', delta: -2 }]);
1974
+ expect(helper.sizeFlag).toBe(false);
1975
+ });
1976
+ test('the 328 vs 343dp button class still fails — 15dp is no line-box residue', () => {
1977
+ const dump = {
1978
+ canvas: CANVAS_640,
1979
+ nodes: [
1980
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
1981
+ { id: 'button', bounds: { left: 16, top: 500, right: 344, bottom: 548 }, parentId: 'root', clipsChildren: false },
1982
+ {
1983
+ id: 'button_label',
1984
+ bounds: { left: 100, top: 514, right: 260, bottom: 534 },
1985
+ parentId: 'button',
1986
+ clipsChildren: false,
1987
+ text: 'Continue',
1988
+ textLines: [{ start: 0, end: 8, text: 'Continue' }],
1989
+ },
1990
+ ],
1991
+ };
1992
+ const block = buildStructuralBlock(dump, { button: { w: 343, h: 48 } }, 1);
1993
+ const button = block.geometry.elements.find((e) => e.id === 'button');
1994
+ expect(button.sizeFlag).toBe(true);
1995
+ expect(block.summary.clean).toBe(false);
1996
+ });
1997
+ test('the text band is capped at 8dp — a 10dp delta on a 5-line block still fails', () => {
1998
+ const dump = {
1999
+ canvas: CANVAS_640,
2000
+ nodes: [
2001
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
2002
+ {
2003
+ id: 'paragraph',
2004
+ bounds: { left: 16, top: 16, right: 344, bottom: 126 },
2005
+ parentId: 'root',
2006
+ clipsChildren: false,
2007
+ text: 'five lines of copy wrap',
2008
+ textLines: [
2009
+ { start: 0, end: 4, text: 'five' },
2010
+ { start: 5, end: 10, text: 'lines' },
2011
+ { start: 11, end: 13, text: 'of' },
2012
+ { start: 14, end: 18, text: 'copy' },
2013
+ { start: 19, end: 23, text: 'wrap' },
2014
+ ],
2015
+ },
2016
+ ],
2017
+ };
2018
+ const block = buildStructuralBlock(dump, { paragraph: { w: 328, h: 120 } }, 1);
2019
+ const paragraph = block.geometry.elements.find((e) => e.id === 'paragraph');
2020
+ // min(8, 2 + 2·5) = 8dp < 10dp — the cap keeps large stacks honest.
2021
+ expect(paragraph.sizeFlag).toBe(true);
2022
+ });
2023
+ test('a non-text element keeps the strict band — a 3dp delta still flags', () => {
2024
+ const dump = {
2025
+ canvas: CANVAS_640,
2026
+ nodes: [
2027
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
2028
+ { id: 'thumbnail', bounds: { left: 16, top: 16, right: 119, bottom: 116 }, parentId: 'root', clipsChildren: false },
2029
+ ],
2030
+ };
2031
+ const block = buildStructuralBlock(dump, { thumbnail: { w: 100, h: 100 } }, 1);
2032
+ const thumbnail = block.geometry.elements.find((e) => e.id === 'thumbnail');
2033
+ expect(thumbnail.deltas).toEqual([{ dimension: 'w', delta: 3 }]);
2034
+ expect(thumbnail.sizeFlag).toBe(true);
2035
+ });
2036
+ test('an old package (text but no textLines) still counts one line per text node', () => {
2037
+ const dump = {
2038
+ canvas: CANVAS_640,
2039
+ nodes: [
2040
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
2041
+ { id: 'pill', bounds: { left: 16, top: 16, right: 80, bottom: 49 }, parentId: 'root', clipsChildren: false },
2042
+ {
2043
+ id: 'pill_label',
2044
+ bounds: { left: 24, top: 24, right: 72, bottom: 41 },
2045
+ parentId: 'pill',
2046
+ clipsChildren: false,
2047
+ text: 'Copy code',
2048
+ },
2049
+ ],
2050
+ };
2051
+ const block = buildStructuralBlock(dump, { pill: { w: 60, h: 36 } }, 1);
2052
+ expect(block.geometry.elements.find((e) => e.id === 'pill').sizeFlag).toBe(false);
2053
+ });
2054
+ });
1527
2055
  describe('embedded geometry stage parity', () => {
1528
2056
  // The render ships a JS-source port of the differ + assembler. It must produce
1529
2057
  // byte-identical blocks to the canonical typed module, or run-side structural
@@ -1592,6 +2120,20 @@ describe('embedded geometry stage parity', () => {
1592
2120
  };
1593
2121
  expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
1594
2122
  });
2123
+ test('embedded buildAppearanceReferenceFromParams matches the module for a typeface declaration (typeface issue 04)', () => {
2124
+ const cases = [
2125
+ { refTypeface: { code: { family: 'Poppins', weight: 600 } }, baseline: 'figma' },
2126
+ {
2127
+ refGeom: { code: { w: 100, h: 20 } },
2128
+ refTypeface: { code: { family: 'Poppins', weight: 600 }, label: { family: 'Fira Sans' } },
2129
+ baseline: 'figma',
2130
+ },
2131
+ { refTypeface: {}, baseline: 'figma' },
2132
+ ];
2133
+ for (const params of cases) {
2134
+ expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
2135
+ }
2136
+ });
1595
2137
  test('embedded deriveLineFacts matches the module (Signal C, issue 18)', () => {
1596
2138
  const cases = [
1597
2139
  [
@@ -1634,6 +2176,56 @@ describe('embedded geometry stage parity', () => {
1634
2176
  };
1635
2177
  expect(embedded.buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference));
1636
2178
  });
2179
+ test('embedded deriveTypefaceFacts matches the module (Signal C, issue 03)', () => {
2180
+ const cases = [
2181
+ [{ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } }],
2182
+ [{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } }],
2183
+ [{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } }],
2184
+ [{ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } }],
2185
+ [{ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } }],
2186
+ [{}, { code: { family: 'Poppins', weight: 600 } }],
2187
+ [{ code: { family: 'Roboto', weight: 400 } }, {}],
2188
+ [
2189
+ { b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } },
2190
+ { a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } },
2191
+ ],
2192
+ ];
2193
+ for (const [rendered, reference] of cases) {
2194
+ expect(embedded.deriveTypefaceFacts(rendered, reference)).toEqual(deriveTypefaceFacts(rendered, reference));
2195
+ }
2196
+ });
2197
+ test('embedded buildStructuralBlock matches the module with a typeface reference + the render-SDK gate (issue 03)', () => {
2198
+ const dumps = [
2199
+ {
2200
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
2201
+ renderSdk: 34,
2202
+ nodes: [
2203
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto', weight: 400 } },
2204
+ ],
2205
+ },
2206
+ {
2207
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
2208
+ renderSdk: 28,
2209
+ nodes: [
2210
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
2211
+ ],
2212
+ },
2213
+ {
2214
+ canvas: { left: 0, top: 0, right: 200, bottom: 400 },
2215
+ nodes: [
2216
+ { id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
2217
+ ],
2218
+ },
2219
+ ];
2220
+ const appearanceReference = {
2221
+ baseline: 'figma',
2222
+ elementIds: ['code'],
2223
+ typeface: { code: { family: 'Poppins', weight: 600 } },
2224
+ };
2225
+ for (const dump of dumps) {
2226
+ expect(embedded.buildStructuralBlock(dump, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dump, null, 2, undefined, appearanceReference));
2227
+ }
2228
+ });
1637
2229
  test('embedded deriveCopyFacts matches the module (Signal C)', () => {
1638
2230
  const cases = [
1639
2231
  [{ title: 'a' }, { title: 'a' }],
@@ -1732,6 +2324,40 @@ describe('embedded geometry stage parity', () => {
1732
2324
  };
1733
2325
  expect(embedded.buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference));
1734
2326
  });
2327
+ // Duplicated banner tag + a child declared in the reference: exercises the
2328
+ // collision guard end-to-end (duplicate_tag fact, geometry skip, overhang
2329
+ // withheld against the duplicated frame, scaffold row).
2330
+ const DUPLICATE_TAG_DUMP = {
2331
+ canvas: { left: 0, top: 0, right: 720, bottom: 800 },
2332
+ nodes: [
2333
+ { id: 'banner', bounds: { left: 0, top: 0, right: 686, bottom: 144 }, parentId: null, clipsChildren: false },
2334
+ { id: 'banner', bounds: { left: 0, top: 200, right: 686, bottom: 344 }, parentId: null, clipsChildren: false },
2335
+ { id: 'coin', bounds: { left: 600, top: 80, right: 680, bottom: 180 }, parentId: 'banner', clipsChildren: false },
2336
+ ],
2337
+ };
2338
+ const DUPLICATE_TAG_REF = {
2339
+ banner: { x: 0, y: 0, w: 343, h: 72 },
2340
+ coin: { x: 300, y: 40, w: 40, h: 50 },
2341
+ };
2342
+ // A text-driven pill inside the band (density 2: px are 2× the dp numbers)
2343
+ // plus a non-text sibling outside it — exercises the text-aware size band.
2344
+ const TEXT_BAND_DUMP = {
2345
+ canvas: { left: 0, top: 0, right: 720, bottom: 400 },
2346
+ nodes: [
2347
+ { id: 'root', bounds: { left: 0, top: 0, right: 720, bottom: 400 }, parentId: null, clipsChildren: false },
2348
+ { id: 'pill', bounds: { left: 32, top: 32, right: 160, bottom: 98 }, parentId: 'root', clipsChildren: false },
2349
+ {
2350
+ id: 'pill_label',
2351
+ bounds: { left: 48, top: 48, right: 144, bottom: 82 },
2352
+ parentId: 'pill',
2353
+ clipsChildren: false,
2354
+ text: 'Copy code',
2355
+ textLines: [{ start: 0, end: 9, text: 'Copy code' }],
2356
+ },
2357
+ { id: 'thumbnail', bounds: { left: 200, top: 32, right: 406, bottom: 232 }, parentId: 'root', clipsChildren: false },
2358
+ ],
2359
+ };
2360
+ const TEXT_BAND_REF = { pill: { w: 60, h: 36 }, thumbnail: { w: 100, h: 100 } };
1735
2361
  const parityCases = [
1736
2362
  { name: 'factsOnly', dump: HUB_PROMO_DUMP, ref: null },
1737
2363
  { name: 'withGeometry', dump: HUB_PROMO_DUMP, ref: HUB_PROMO_REFERENCE },
@@ -1739,6 +2365,8 @@ describe('embedded geometry stage parity', () => {
1739
2365
  { name: 'overhangGuest', dump: OVERHANG_GUEST_DUMP, ref: OVERHANG_GUEST_REF },
1740
2366
  { name: 'overhangPosition', dump: OVERHANG_POSITION_DUMP, ref: OVERHANG_POSITION_REF },
1741
2367
  { name: 'overhangMissing', dump: OVERHANG_MISSING_DUMP, ref: OVERHANG_MISSING_REF },
2368
+ { name: 'duplicateTag', dump: DUPLICATE_TAG_DUMP, ref: DUPLICATE_TAG_REF },
2369
+ { name: 'textSizeBand', dump: TEXT_BAND_DUMP, ref: TEXT_BAND_REF },
1742
2370
  ];
1743
2371
  for (const { name, dump, ref } of parityCases) {
1744
2372
  test(`embedded buildStructuralBlock matches the module (${name})`, () => {
@@ -1749,5 +2377,19 @@ describe('embedded geometry stage parity', () => {
1749
2377
  expect(embedded.buildReviewScaffold(block)).toEqual(buildReviewScaffold(block));
1750
2378
  });
1751
2379
  }
2380
+ test('embedded buildStructuralBlock matches the module for a duplicated member tag under a declared frame', () => {
2381
+ const dump = {
2382
+ canvas: { left: 0, top: 0, right: 360, bottom: 640 },
2383
+ nodes: [
2384
+ { id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
2385
+ { id: 'member', bounds: { left: 0, top: 0, right: 360, bottom: 100 }, parentId: 'root', clipsChildren: false },
2386
+ { id: 'member', bounds: { left: 0, top: 200, right: 360, bottom: 300 }, parentId: 'root', clipsChildren: false },
2387
+ { id: 'footer', bounds: { left: 0, top: 500, right: 360, bottom: 640 }, parentId: 'root', clipsChildren: false },
2388
+ ],
2389
+ };
2390
+ const ref = { member: { x: 0, y: 0, w: 360, h: 100 }, footer: { x: 0, y: 540, w: 360, h: 140 } };
2391
+ const frame = { w: 360, h: 640 };
2392
+ expect(embedded.buildStructuralBlock(dump, ref, 1, undefined, undefined, frame)).toEqual(buildStructuralBlock(dump, ref, 1, undefined, undefined, frame));
2393
+ });
1752
2394
  });
1753
2395
  //# sourceMappingURL=structural-facts.test.js.map