@invarn/cibuild 2.1.2 → 2.1.4

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.
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Structural-facts deriver — Signal A of the UI-fidelity structural pass.
3
3
  *
4
- * Pure and platform-agnostic: it takes a per-node layout dump (bounds in px,
5
- * parent, clip flag) emitted alongside the render and derives intent-agnostic
6
- * geometric facts the fidelity judge should check instead of discovering them
7
- * by eye:
4
+ * Pure and platform-agnostic: it takes a per-node layout dump (clipped +
5
+ * unclipped bounds in px, parent) emitted alongside the render and derives
6
+ * intent-agnostic geometric facts the fidelity judge should check instead of
7
+ * discovering them by eye:
8
8
  *
9
9
  * - `overflow` — a node extends past its parent's box (parent does
10
10
  * NOT clip, so the excess is visibly sticking out).
@@ -26,11 +26,18 @@ export interface LayoutBounds {
26
26
  export interface LayoutNode {
27
27
  /** Stable id/label for the node within one dump. */
28
28
  id: string;
29
- /** Root-space bounds in px. */
29
+ /** Clipped root-space bounds in px (boundsInRoot — the visible region,
30
+ * clamped by any clipping ancestor). */
30
31
  bounds: LayoutBounds;
32
+ /** Unclipped root-space bounds in px (positionInRoot + size — the true layout
33
+ * extent). Absent in legacy dumps emitted before the structural pass tracked
34
+ * it; when present, overflow/clip is derived from this so it survives a
35
+ * clamping parent (which would otherwise hide the excess in `bounds`). */
36
+ unclippedBounds?: LayoutBounds;
31
37
  /** Parent node id, or null for a root node. */
32
38
  parentId: string | null;
33
- /** Whether this node clips its children to its bounds. */
39
+ /** Legacy clip hint (parent clips its children). The current emitter writes a
40
+ * constant false; used only for the legacy (no-`unclippedBounds`) fallback. */
34
41
  clipsChildren: boolean;
35
42
  }
36
43
  export interface LayoutDump {
@@ -74,6 +81,101 @@ export interface DeriveStructuralFactsOptions {
74
81
  overlapMinRatio?: number;
75
82
  }
76
83
  export declare function deriveStructuralFacts(dump: LayoutDump, options?: DeriveStructuralFactsOptions): StructuralFact[];
84
+ /** One element's geometry; x/y optional (position is advisory, see ADR-0004). */
85
+ export interface ElementGeometry {
86
+ width: number;
87
+ height: number;
88
+ x?: number;
89
+ y?: number;
90
+ }
91
+ export type GeometrySource = Record<string, ElementGeometry>;
92
+ export interface DimensionDelta {
93
+ dimension: 'width' | 'height' | 'x' | 'y';
94
+ generated: number;
95
+ reference: number;
96
+ delta: number;
97
+ }
98
+ export interface GeometryDelta {
99
+ elementId: string;
100
+ deltas: DimensionDelta[];
101
+ }
102
+ /**
103
+ * Per-element dimensional deltas between a GENERATED geometry (render layout) and
104
+ * a REFERENCE geometry (design numbers), both in the same unit. Only elements
105
+ * present on both sides, and only dimensions supplied on both, are compared.
106
+ * Port of `packages/cli/lib/fidelity-geometry-diff.mjs`.
107
+ */
108
+ export declare function diffKeyGeometry(generated: GeometrySource, reference: GeometrySource | null | undefined, options?: {
109
+ tolerancePx?: number;
110
+ }): GeometryDelta[];
111
+ /** `params.reference_geometry["<Screen>"]` — design px, treated 1:1 with dp. */
112
+ export type ReferenceGeometry = Record<string, {
113
+ w?: number;
114
+ h?: number;
115
+ x?: number;
116
+ y?: number;
117
+ }>;
118
+ export interface StructuralGeometryElement {
119
+ id: string;
120
+ rendered: {
121
+ w: number;
122
+ h: number;
123
+ };
124
+ design: {
125
+ w: number | undefined;
126
+ h: number | undefined;
127
+ };
128
+ deltas: Array<{
129
+ dimension: 'w' | 'h' | 'x' | 'y';
130
+ delta: number;
131
+ }>;
132
+ sizeFlag: boolean;
133
+ }
134
+ export interface StructuralGeometry {
135
+ unit: 'dp';
136
+ density: number;
137
+ elements: StructuralGeometryElement[];
138
+ }
139
+ export interface StructuralSummary {
140
+ clean: boolean;
141
+ factCount: number;
142
+ maxSizeDeltaDp: number | null;
143
+ }
144
+ export interface StructuralBlock {
145
+ summary: StructuralSummary;
146
+ facts: StructuralFact[];
147
+ geometry?: StructuralGeometry;
148
+ }
149
+ /**
150
+ * Assemble the inline `structural` block (ADR-0004): Signal-A facts always, plus
151
+ * the Signal-B geometry block when `referenceGeometry` is supplied. Size (w/h)
152
+ * deltas drive `sizeFlag`/`summary.clean`; x/y position deltas are reported but
153
+ * never flagged (canvas-width artifact). Mirrors Invarn's `buildStructuralBlock`.
154
+ */
155
+ export declare function buildStructuralBlock(dump: LayoutDump, referenceGeometry?: ReferenceGeometry | null, density?: number, options?: {
156
+ sizeTolerancePx?: number;
157
+ }): StructuralBlock;
158
+ export interface ReviewRow {
159
+ element: string;
160
+ rendered: string | null;
161
+ design: string | null;
162
+ fact: string;
163
+ verdict: null;
164
+ }
165
+ export interface ReviewScaffold {
166
+ verdict: null;
167
+ mustExplain: string[];
168
+ rows: ReviewRow[];
169
+ }
170
+ /**
171
+ * Build the run-only `review` scaffold from an assembled structural block:
172
+ * size-flagged geometry elements first (load-bearing), then every structural
173
+ * fact, each as a row with a null `verdict` the agent must replace.
174
+ */
175
+ export declare function buildReviewScaffold(structural: {
176
+ facts?: StructuralFact[];
177
+ geometry?: StructuralGeometry | null;
178
+ }): ReviewScaffold;
77
179
  /**
78
180
  * Render-side embedded form of the deriver.
79
181
  *
@@ -98,4 +200,33 @@ export declare const RENDER_SCRIPT_FACTS_STAGE: string;
98
200
  export declare function getStructuralFactsStageInternals(): {
99
201
  deriveStructuralFacts: (dump: LayoutDump, options?: DeriveStructuralFactsOptions) => StructuralFact[];
100
202
  };
203
+ /**
204
+ * Render-side embedded form of the Signal-B differ + structural-block assembler.
205
+ *
206
+ * Concatenated into the generated render script alongside `RENDER_SCRIPT_FACTS_STAGE`
207
+ * (function declarations hoist, so order does not matter — `buildStructuralBlock`
208
+ * calls the facts stage's `deriveStructuralFacts`). `assembleStructural` references
209
+ * runtime globals (`fs`, `path`, `log`) defined in the main script and is only
210
+ * invoked from there. Kept byte-equivalent to the typed `buildStructuralBlock` /
211
+ * `diffKeyGeometry` above by the parity test, so run-side and preview-side (Invarn)
212
+ * structural blocks cannot drift.
213
+ */
214
+ export declare const RENDER_SCRIPT_GEOMETRY_STAGE: string;
215
+ /**
216
+ * Evaluates the embedded facts + geometry stages together (mirroring how the
217
+ * render script concatenates them) and returns the pure `diffKeyGeometry` +
218
+ * `buildStructuralBlock`, so the parity test exercises exactly the shipped code.
219
+ */
220
+ export declare function getGeometryStageInternals(): {
221
+ diffKeyGeometry: (generated: GeometrySource, reference: GeometrySource | null | undefined, options?: {
222
+ tolerancePx?: number;
223
+ }) => GeometryDelta[];
224
+ buildStructuralBlock: (dump: LayoutDump, referenceGeometry?: ReferenceGeometry | null, density?: number, options?: {
225
+ sizeTolerancePx?: number;
226
+ }) => StructuralBlock;
227
+ buildReviewScaffold: (structural: {
228
+ facts?: StructuralFact[];
229
+ geometry?: StructuralGeometry | null;
230
+ }) => ReviewScaffold;
231
+ };
101
232
  //# sourceMappingURL=structural-facts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"structural-facts.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/structural-facts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,oDAAoD;IACpD,EAAE,EAAE,MAAM,CAAC;IACX,+BAA+B;IAC/B,MAAM,EAAE,YAAY,CAAC;IACrB,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,0DAA0D;IAC1D,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACzB,+CAA+C;IAC/C,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,UAAU,EAAE,CAAC;CACrB;AAED,MAAM,MAAM,IAAI,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEvD,MAAM,MAAM,cAAc,GACtB;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,UAAU,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;CAC3C,GACD;IACE,IAAI,EAAE,mBAAmB,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;CAC1C,GACD;IACE,IAAI,EAAE,YAAY,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,WAAW,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;CAC5C,GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7C,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEN,MAAM,WAAW,4BAA4B;IAC3C;yEACqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;mCAC+B;IAC/B,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAoCD,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,UAAU,EAChB,OAAO,GAAE,4BAAiC,GACzC,cAAc,EAAE,CA2ElB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,EAAE,MAuHvC,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,gCAAgC,IAAI;IAClD,qBAAqB,EAAE,CACrB,IAAI,EAAE,UAAU,EAChB,OAAO,CAAC,EAAE,4BAA4B,KACnC,cAAc,EAAE,CAAC;CACvB,CAWA"}
1
+ {"version":3,"file":"structural-facts.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/structural-facts.ts"],"names":[],"mappings":"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"}
@@ -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, getStructuralFactsStageInternals, } from './structural-facts.js';
11
+ import { deriveStructuralFacts, diffKeyGeometry, buildStructuralBlock, buildReviewScaffold, 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', () => {
@@ -63,6 +63,75 @@ describe('deriveStructuralFacts — overflow vs clip', () => {
63
63
  });
64
64
  });
65
65
  });
66
+ describe('deriveStructuralFacts — unclippedBounds path (real clamping renders)', () => {
67
+ // A real clipping parent CLAMPS the child's boundsInRoot to its own edge, so
68
+ // the clipped `bounds` show no excess — the overflow lives only in
69
+ // `unclippedBounds`. This is the case bounds-only v1 went blind on.
70
+ test('a clamped child is reported clipped-by-parent, recovered from unclippedBounds', () => {
71
+ const dump = {
72
+ canvas: { left: 0, top: 0, right: 360, bottom: 720 },
73
+ nodes: [
74
+ {
75
+ id: 'HubPromoBanner',
76
+ bounds: { left: 16, top: 100, right: 344, bottom: 262 },
77
+ unclippedBounds: { left: 16, top: 100, right: 344, bottom: 262 },
78
+ parentId: null,
79
+ clipsChildren: false,
80
+ },
81
+ {
82
+ // visible bounds CLAMPED to the card bottom (262); the true extent runs
83
+ // 18px past it → 18px cut away. The current emitter writes clipsChildren
84
+ // false, so only unclippedBounds can reveal this.
85
+ id: 'voucherTag',
86
+ bounds: { left: 250, top: 210, right: 330, bottom: 262 },
87
+ unclippedBounds: { left: 250, top: 210, right: 330, bottom: 280 },
88
+ parentId: 'HubPromoBanner',
89
+ clipsChildren: false,
90
+ },
91
+ ],
92
+ };
93
+ const facts = deriveStructuralFacts(dump);
94
+ expect(facts).toHaveLength(1);
95
+ expect(facts[0]).toMatchObject({
96
+ kind: 'clipped_by_parent',
97
+ nodeId: 'voucherTag',
98
+ clipperId: 'HubPromoBanner',
99
+ edges: ['bottom'],
100
+ clippedPx: { bottom: 18 },
101
+ });
102
+ });
103
+ test('a child sticking out of a non-clipping parent (bounds == unclippedBounds) is overflow', () => {
104
+ const dump = {
105
+ canvas: CANVAS,
106
+ nodes: [
107
+ {
108
+ id: 'row',
109
+ bounds: { left: 20, top: 20, right: 180, bottom: 100 },
110
+ unclippedBounds: { left: 20, top: 20, right: 180, bottom: 100 },
111
+ parentId: null,
112
+ clipsChildren: false,
113
+ },
114
+ {
115
+ // not clamped (bounds == unclipped) → the 15px excess is visible.
116
+ id: 'chip',
117
+ bounds: { left: 150, top: 30, right: 195, bottom: 80 },
118
+ unclippedBounds: { left: 150, top: 30, right: 195, bottom: 80 },
119
+ parentId: 'row',
120
+ clipsChildren: false,
121
+ },
122
+ ],
123
+ };
124
+ const facts = deriveStructuralFacts(dump);
125
+ expect(facts).toHaveLength(1);
126
+ expect(facts[0]).toMatchObject({
127
+ kind: 'overflow',
128
+ nodeId: 'chip',
129
+ parentId: 'row',
130
+ edges: ['right'],
131
+ overflowPx: { right: 15 },
132
+ });
133
+ });
134
+ });
66
135
  describe('deriveStructuralFacts — off-screen', () => {
67
136
  test('a node extending past the canvas is reported off-screen on those edges', () => {
68
137
  const dump = {
@@ -138,6 +207,22 @@ describe('embedded render-side stage parity', () => {
138
207
  { id: 'b', bounds: { left: 80, top: 60, right: 180, bottom: 160 }, parentId: 'root', clipsChildren: false },
139
208
  ],
140
209
  },
210
+ // New unclippedBounds path: a clamped child (clip) and a visible-excess child
211
+ // (overflow) — exercises the embedded port's new branch, not just the legacy one.
212
+ clipReal: {
213
+ canvas: CANVAS,
214
+ nodes: [
215
+ { id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, unclippedBounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: false },
216
+ { id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 200 }, unclippedBounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
217
+ ],
218
+ },
219
+ overflowReal: {
220
+ canvas: CANVAS,
221
+ nodes: [
222
+ { id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, unclippedBounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
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
+ ],
225
+ },
141
226
  };
142
227
  for (const [name, dump] of Object.entries(fixtures)) {
143
228
  test(`embedded port matches the module for the ${name} fixture`, () => {
@@ -171,4 +256,99 @@ describe('deriveStructuralFacts — motivating regression (voucher tag bottom-cl
171
256
  });
172
257
  });
173
258
  });
259
+ // ---- Signal B: key-geometry diff + inline structural-block assembly ----
260
+ describe('diffKeyGeometry — dimensional gaps', () => {
261
+ test('reports a size delta on the right element beyond tolerance', () => {
262
+ const generated = { card: { width: 328, height: 81 }, chip: { width: 64, height: 24 } };
263
+ const reference = { card: { width: 328, height: 72 }, chip: { width: 64, height: 24 } };
264
+ expect(diffKeyGeometry(generated, reference)).toEqual([
265
+ { elementId: 'card', deltas: [{ dimension: 'height', generated: 81, reference: 72, delta: 9 }] },
266
+ ]);
267
+ });
268
+ test('degrades cleanly to no deltas when there is no reference geometry', () => {
269
+ const generated = { card: { width: 100, height: 50 } };
270
+ expect(diffKeyGeometry(generated, null)).toEqual([]);
271
+ expect(diffKeyGeometry(generated, {})).toEqual([]);
272
+ });
273
+ });
274
+ // The golden hub promo banner (xhdpi, density 2): banner fills the 720px canvas,
275
+ // voucher 96x96dp vs Figma 81x81, banner 96dp tall vs Figma 72, copy<->voucher
276
+ // overlap. Identical fixture + expectations to Invarn's fidelity-structural test,
277
+ // so preview (Invarn) and run (cibuild) assemble byte-identical blocks.
278
+ const HUB_PROMO_DUMP = {
279
+ canvas: { left: 0, top: 0, right: 720, bottom: 192 },
280
+ nodes: [
281
+ { id: 'node-1', bounds: { left: 0, top: 0, right: 720, bottom: 192 }, unclippedBounds: { left: 0, top: 0, right: 720, bottom: 192 }, parentId: null, clipsChildren: false },
282
+ { id: 'banner', bounds: { left: 0, top: 0, right: 720, bottom: 192 }, unclippedBounds: { left: 0, top: 0, right: 720, bottom: 192 }, parentId: 'node-1', clipsChildren: false },
283
+ { id: 'voucher', bounds: { left: 496, top: 0, right: 688, bottom: 192 }, unclippedBounds: { left: 496, top: 0, right: 688, bottom: 192 }, parentId: 'banner', clipsChildren: false },
284
+ { id: 'copy', bounds: { left: 64, top: 24, right: 656, bottom: 117 }, unclippedBounds: { left: 64, top: 24, right: 656, bottom: 117 }, parentId: 'banner', clipsChildren: false },
285
+ ],
286
+ };
287
+ const HUB_PROMO_REFERENCE = { voucher: { w: 81, h: 81 }, banner: { w: 343, h: 72 } };
288
+ describe('buildStructuralBlock — facts only (no reference geometry)', () => {
289
+ test('surfaces facts + summary, no geometry block', () => {
290
+ const block = buildStructuralBlock(HUB_PROMO_DUMP, null, 2);
291
+ expect(block.facts).toEqual([
292
+ { kind: 'overlap', nodeIds: ['copy', 'voucher'], overlapPx: { width: 160, height: 93 }, ratio: 0.4036458333333333 },
293
+ ]);
294
+ expect(block.summary).toEqual({ clean: false, factCount: 1, maxSizeDeltaDp: null });
295
+ expect(block.geometry).toBeUndefined();
296
+ });
297
+ });
298
+ describe('buildStructuralBlock — with reference geometry (Signal B)', () => {
299
+ test('reports the golden size deltas and a not-clean summary', () => {
300
+ const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
301
+ expect(block.summary).toEqual({ clean: false, factCount: 1, maxSizeDeltaDp: 24 });
302
+ expect(block.geometry?.unit).toBe('dp');
303
+ expect(block.geometry?.density).toBe(2);
304
+ const voucher = block.geometry?.elements.find((e) => e.id === 'voucher');
305
+ expect(voucher).toEqual({
306
+ id: 'voucher',
307
+ rendered: { w: 96, h: 96 },
308
+ design: { w: 81, h: 81 },
309
+ deltas: [{ dimension: 'w', delta: 15 }, { dimension: 'h', delta: 15 }],
310
+ sizeFlag: true,
311
+ });
312
+ const banner = block.geometry?.elements.find((e) => e.id === 'banner');
313
+ expect(banner?.sizeFlag).toBe(true);
314
+ expect(banner?.deltas).toContainEqual({ dimension: 'h', delta: 24 });
315
+ });
316
+ });
317
+ describe('buildReviewScaffold — run-only verdict obligation', () => {
318
+ test('forces a verdict per flagged element + fact, with the golden mustExplain', () => {
319
+ const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
320
+ const review = buildReviewScaffold(block);
321
+ expect(review.verdict).toBeNull();
322
+ expect(review.mustExplain).toEqual(['voucher', 'banner', 'copy↔voucher']);
323
+ expect(review.rows.every((r) => r.verdict === null)).toBe(true);
324
+ expect(review.rows.find((r) => r.element === 'voucher')).toEqual({
325
+ element: 'voucher',
326
+ rendered: '96×96dp',
327
+ design: '81×81dp',
328
+ fact: 'size +15×+15',
329
+ verdict: null,
330
+ });
331
+ expect(review.rows.find((r) => r.element === 'copy↔voucher')?.fact).toBe('overlap 160×93px (ratio 0.40)');
332
+ });
333
+ });
334
+ describe('embedded geometry stage parity', () => {
335
+ // The render ships a JS-source port of the differ + assembler. It must produce
336
+ // byte-identical blocks to the canonical typed module, or run-side structural
337
+ // would diverge from preview-side (Invarn) and from the tests.
338
+ const embedded = getGeometryStageInternals();
339
+ test('embedded diffKeyGeometry matches the module', () => {
340
+ const generated = { card: { width: 328, height: 81 } };
341
+ const reference = { card: { width: 328, height: 72 } };
342
+ expect(embedded.diffKeyGeometry(generated, reference)).toEqual(diffKeyGeometry(generated, reference));
343
+ });
344
+ for (const [name, ref] of Object.entries({ factsOnly: null, withGeometry: HUB_PROMO_REFERENCE })) {
345
+ test(`embedded buildStructuralBlock matches the module (${name})`, () => {
346
+ expect(embedded.buildStructuralBlock(HUB_PROMO_DUMP, ref, 2)).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, ref, 2));
347
+ });
348
+ test(`embedded buildReviewScaffold matches the module (${name})`, () => {
349
+ const block = buildStructuralBlock(HUB_PROMO_DUMP, ref, 2);
350
+ expect(embedded.buildReviewScaffold(block)).toEqual(buildReviewScaffold(block));
351
+ });
352
+ }
353
+ });
174
354
  //# sourceMappingURL=structural-facts.test.js.map
@@ -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;AAKpE,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,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;CACpB;AA4uBD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAWrF;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;CAgBpB"}
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;AAKpE,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,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;CACpB;AA8vBD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAYrF;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;CAgBpB"}
@@ -50,7 +50,7 @@
50
50
  import { BaseStepExecutor } from './base.js';
51
51
  import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
52
52
  import { RENDER_SCRIPT_TRIM_STAGE } from './render-post-processor.js';
53
- import { RENDER_SCRIPT_FACTS_STAGE } from './structural-facts.js';
53
+ import { RENDER_SCRIPT_FACTS_STAGE, RENDER_SCRIPT_GEOMETRY_STAGE } from './structural-facts.js';
54
54
  /**
55
55
  * Default Roborazzi record task. Run from the project root so Gradle resolves
56
56
  * it in whichever subproject declares it.
@@ -91,6 +91,10 @@ var resultPath = path.join(artifactsDir, 'protocol-result.json');
91
91
 
92
92
  var currentEntries = [];
93
93
  var buildError = null;
94
+ // params.reference_geometry (design-px geometry per screen), set in main() and
95
+ // read by the structural assembler. Null when the run declared none → Signal B
96
+ // is skipped and the structural block carries facts only.
97
+ var referenceGeometry = null;
94
98
 
95
99
  function log(message) {
96
100
  console.log('[ui-fidelity-render-android] ' + message);
@@ -597,7 +601,13 @@ function renderScreens(renderable, projectRoot) {
597
601
  }
598
602
  }
599
603
  if (layoutSource !== null) {
604
+ // Signal A: write the facts + layout artifacts (deep-dive path).
600
605
  deriveAndWriteFacts(entry, layoutSource);
606
+ // ADR-0004: assemble the inline structural block (facts + Signal-B
607
+ // geometry deltas vs reference_geometry) onto the screen entry, so the
608
+ // judge reads the signals from protocol-result.json without fetching
609
+ // artifacts. CONFIG.scale is the render density (dp = px / scale).
610
+ assembleStructural(entry, layoutSource, referenceGeometry, CONFIG.scale);
601
611
  }
602
612
  });
603
613
  } finally {
@@ -655,6 +665,14 @@ function main() {
655
665
  params.provenance && typeof params.provenance === 'object' && !Array.isArray(params.provenance)
656
666
  ? params.provenance
657
667
  : {};
668
+ // Signal-B design numbers (advisory): { "<Screen>": { "<tagId>": {w,h,x,y} } }
669
+ // in design px. Absent → the structural block carries Signal-A facts only.
670
+ referenceGeometry =
671
+ params.reference_geometry &&
672
+ typeof params.reference_geometry === 'object' &&
673
+ !Array.isArray(params.reference_geometry)
674
+ ? params.reference_geometry
675
+ : null;
658
676
 
659
677
  var screens = Object.keys(params.screens);
660
678
  currentEntries = screens.map(function (screen) {
@@ -826,6 +844,7 @@ export function generateAndroidRenderScript(config) {
826
844
  'var PACKAGE_ARCHIVE_NAME = ' + JSON.stringify(PACKAGE_ARCHIVE_BASENAME) + ';',
827
845
  RENDER_SCRIPT_TRIM_STAGE,
828
846
  RENDER_SCRIPT_FACTS_STAGE,
847
+ RENDER_SCRIPT_GEOMETRY_STAGE,
829
848
  ANDROID_RENDER_SCRIPT_MAIN,
830
849
  ].join('\n');
831
850
  }
@@ -315,6 +315,57 @@ describe('android render runtime — render + trim (happy path)', () => {
315
315
  ],
316
316
  });
317
317
  });
318
+ test('assembles the inline structural block (facts) onto the screen entry', async () => {
319
+ const project = makeProject({ screens: { XProof: 'x.png' } });
320
+ writeReference(project, 'x.png');
321
+ stageArchive(project, gradleProjectEntries());
322
+ const run = runScript(project, await buildScript(), {
323
+ FAKE_ROBORAZZI_SCREENS: 'XProof',
324
+ FAKE_ROBORAZZI_LAYOUT: '1',
325
+ });
326
+ expect(run.status).toBe(0);
327
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
328
+ // Signal A surfaces inline (no reference_geometry → no geometry block).
329
+ expect(screen?.structural?.summary).toEqual({ clean: false, factCount: 1, maxSizeDeltaDp: null });
330
+ expect(screen?.structural?.facts).toHaveLength(1);
331
+ expect(screen?.structural?.geometry).toBeUndefined();
332
+ // The verdict obligation is baked onto the run result by the runner: the
333
+ // clip fact (node 'tag') becomes a must-explain row with a null verdict.
334
+ expect(screen?.structural?.review?.verdict).toBeNull();
335
+ expect(screen?.structural?.review?.mustExplain).toEqual(['tag']);
336
+ expect(screen?.structural?.review?.rows.every((r) => r.verdict === null)).toBe(true);
337
+ });
338
+ test('adds Signal-B geometry deltas to the structural block when reference_geometry is given', async () => {
339
+ // The fake layout dump: card bounds 160x180px, tag 80x74px (density 2 → dp
340
+ // card 80x90, tag 40x37). Declare design numbers so the card reads +10dp tall.
341
+ const project = makeProject({
342
+ screens: { XProof: 'x.png' },
343
+ reference_geometry: { XProof: { card: { w: 80, h: 80 }, tag: { w: 40, h: 37 } } },
344
+ });
345
+ writeReference(project, 'x.png');
346
+ stageArchive(project, gradleProjectEntries());
347
+ const run = runScript(project, await buildScript(), {
348
+ FAKE_ROBORAZZI_SCREENS: 'XProof',
349
+ FAKE_ROBORAZZI_LAYOUT: '1',
350
+ });
351
+ expect(run.status).toBe(0);
352
+ const screen = readResult(project).screens.find((s) => s.screen === 'XProof');
353
+ expect(screen?.structural?.summary).toEqual({ clean: false, factCount: 1, maxSizeDeltaDp: 10 });
354
+ const geometry = screen?.structural?.geometry;
355
+ expect(geometry?.unit).toBe('dp');
356
+ expect(geometry?.density).toBe(2);
357
+ const card = geometry?.elements.find((e) => e.id === 'card');
358
+ expect(card).toEqual({
359
+ id: 'card',
360
+ rendered: { w: 80, h: 90 },
361
+ design: { w: 80, h: 80 },
362
+ deltas: [{ dimension: 'h', delta: 10 }],
363
+ sizeFlag: true,
364
+ });
365
+ const tag = geometry?.elements.find((e) => e.id === 'tag');
366
+ expect(tag?.sizeFlag).toBe(false);
367
+ expect(tag?.deltas).toEqual([]);
368
+ });
318
369
  test('finds the layout dump by dot-segment match even when its name differs from the PNG stem', async () => {
319
370
  const project = makeProject({ screens: { XProof: 'x.png' } });
320
371
  writeReference(project, 'x.png');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",