@invarn/cibuild 2.0.9 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/cli.cjs +86 -8
  2. package/dist/src/lib.d.ts +2 -0
  3. package/dist/src/lib.d.ts.map +1 -1
  4. package/dist/src/lib.js +2 -0
  5. package/dist/src/yaml/fidelity-manifest.d.ts +126 -0
  6. package/dist/src/yaml/fidelity-manifest.d.ts.map +1 -0
  7. package/dist/src/yaml/fidelity-manifest.js +134 -0
  8. package/dist/src/yaml/fidelity-manifest.test.d.ts +12 -0
  9. package/dist/src/yaml/fidelity-manifest.test.d.ts.map +1 -0
  10. package/dist/src/yaml/fidelity-manifest.test.js +251 -0
  11. package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts +53 -0
  12. package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts.map +1 -0
  13. package/dist/src/yaml/steps/fidelity-aspect-guard.js +72 -0
  14. package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts +11 -0
  15. package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts.map +1 -0
  16. package/dist/src/yaml/steps/fidelity-aspect-guard.test.js +50 -0
  17. package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts +45 -0
  18. package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts.map +1 -0
  19. package/dist/src/yaml/steps/fidelity-reference-provenance.js +69 -0
  20. package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts +14 -0
  21. package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts.map +1 -0
  22. package/dist/src/yaml/steps/fidelity-reference-provenance.test.js +54 -0
  23. package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -1
  24. package/dist/src/yaml/steps/render-post-processor.js +19 -6
  25. package/dist/src/yaml/steps/render-post-processor.test.js +46 -2
  26. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -1
  27. package/dist/src/yaml/steps/ui-fidelity-preview-android.js +42 -16
  28. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +53 -1
  29. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts +26 -0
  30. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +1 -1
  31. package/dist/src/yaml/steps/ui-fidelity-preview.js +21 -9
  32. package/dist/src/yaml/steps/ui-fidelity-preview.test.js +31 -1
  33. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +9 -0
  34. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
  35. package/dist/src/yaml/steps/ui-fidelity-render-android.js +88 -1
  36. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +98 -0
  37. package/package.json +1 -1
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Fidelity aspect-ratio guard.
3
+ *
4
+ * One named composable per state is the authoring norm; the failure this
5
+ * catches is the opposite — several states stacked into one composable, which
6
+ * renders far taller (or wider) than the single-state reference it is paired
7
+ * against. That shows up as a sharp divergence between the rendered and
8
+ * reference aspect ratios, well before a human reads the diff.
9
+ *
10
+ * The guard is a pure comparison over the dimensions the render already
11
+ * reports (`rendered_px`, `reference_px`). It emits a WARNING when the
12
+ * divergence exceeds a documented fold factor — it never fails the render, and
13
+ * it stays silent when there is no reference to compare against.
14
+ */
15
+ /**
16
+ * Fold factor beyond which a rendered/reference aspect-ratio divergence is
17
+ * worth a warning. A faithful single-state wrapper sits near 1.0; trim and
18
+ * reference alignment nudge it a few percent; a re-stacked 2-up wrapper roughly
19
+ * doubles one dimension and lands near 2.0. 1.5 catches the stack while
20
+ * tolerating the wobble.
21
+ */
22
+ export const ASPECT_RATIO_WARN_FACTOR = 1.5;
23
+ function isPositiveSize(size) {
24
+ return (Array.isArray(size) &&
25
+ size.length === 2 &&
26
+ Number.isFinite(size[0]) &&
27
+ Number.isFinite(size[1]) &&
28
+ size[0] > 0 &&
29
+ size[1] > 0);
30
+ }
31
+ /**
32
+ * Compares a rendered size against its reference size. Returns a
33
+ * non-warning, non-comparable result when either size is missing or
34
+ * non-positive — the guard only speaks when it has two real frames to compare.
35
+ */
36
+ export function evaluateAspectRatio(renderedPx, referencePx, factor = ASPECT_RATIO_WARN_FACTOR) {
37
+ if (!isPositiveSize(renderedPx) || !isPositiveSize(referencePx)) {
38
+ return {
39
+ comparable: false,
40
+ renderedAspect: null,
41
+ referenceAspect: null,
42
+ divergence: null,
43
+ warn: false,
44
+ };
45
+ }
46
+ const renderedAspect = renderedPx[0] / renderedPx[1];
47
+ const referenceAspect = referencePx[0] / referencePx[1];
48
+ const divergence = Math.max(renderedAspect / referenceAspect, referenceAspect / renderedAspect);
49
+ return {
50
+ comparable: true,
51
+ renderedAspect,
52
+ referenceAspect,
53
+ divergence,
54
+ warn: divergence > factor,
55
+ };
56
+ }
57
+ /**
58
+ * Human/agent-readable warning line for a divergent screen, or null when the
59
+ * screen does not warrant one. The caller decides where to surface it (preview
60
+ * result, preflight output) — it is advisory, never a failure.
61
+ */
62
+ export function formatAspectRatioWarning(screen, check) {
63
+ if (!check.warn || check.divergence === null)
64
+ return null;
65
+ const r = check.renderedAspect?.toFixed(2);
66
+ const ref = check.referenceAspect?.toFixed(2);
67
+ return (`Screen "${screen}" renders at aspect ratio ${r}:1 but its reference is ` +
68
+ `${ref}:1 (${check.divergence.toFixed(1)}× divergence). This often means ` +
69
+ `multiple states were stacked into one composable — author one named ` +
70
+ `composable per state instead.`);
71
+ }
72
+ //# sourceMappingURL=fidelity-aspect-guard.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Unit tests for the fidelity aspect-ratio guard.
3
+ *
4
+ * A wrapper that stacks several states into one composable renders far taller
5
+ * (or wider) than its single-state reference, so its aspect ratio diverges
6
+ * sharply from the reference's. The guard is the early warning for that
7
+ * re-stacking: it compares the rendered vs reference aspect ratios and flags a
8
+ * divergence beyond a documented fold factor — a WARNING, never a failure.
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=fidelity-aspect-guard.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fidelity-aspect-guard.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/fidelity-aspect-guard.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Unit tests for the fidelity aspect-ratio guard.
3
+ *
4
+ * A wrapper that stacks several states into one composable renders far taller
5
+ * (or wider) than its single-state reference, so its aspect ratio diverges
6
+ * sharply from the reference's. The guard is the early warning for that
7
+ * re-stacking: it compares the rendered vs reference aspect ratios and flags a
8
+ * divergence beyond a documented fold factor — a WARNING, never a failure.
9
+ */
10
+ import { describe, test, expect } from '@jest/globals';
11
+ import { evaluateAspectRatio, ASPECT_RATIO_WARN_FACTOR, } from './fidelity-aspect-guard.js';
12
+ describe('evaluateAspectRatio', () => {
13
+ test('does not warn when rendered and reference aspect ratios match', () => {
14
+ // A faithful single-state banner: same proportions as its reference.
15
+ const check = evaluateAspectRatio([720, 1280], [360, 640]);
16
+ expect(check.comparable).toBe(true);
17
+ expect(check.warn).toBe(false);
18
+ expect(check.divergence).toBeCloseTo(1, 5);
19
+ });
20
+ test('warns when a stacked render is far off the reference aspect ratio', () => {
21
+ // The canonical miss: two banner states stacked in one Column render at
22
+ // ~2.2:1, judged against a single-state ~4.76:1 reference.
23
+ const rendered = [880, 400]; // 2.2:1
24
+ const reference = [952, 200]; // 4.76:1
25
+ const check = evaluateAspectRatio(rendered, reference);
26
+ expect(check.comparable).toBe(true);
27
+ expect(check.warn).toBe(true);
28
+ expect(check.divergence).toBeCloseTo(4.76 / 2.2, 1);
29
+ });
30
+ test('tolerates a small alignment/trim wobble within the fold factor', () => {
31
+ // Trim + reference alignment can nudge dimensions a few percent; that must
32
+ // not trip the warning.
33
+ const check = evaluateAspectRatio([726, 1280], [360, 640]);
34
+ expect(check.warn).toBe(false);
35
+ });
36
+ test('is not comparable when there is no reference', () => {
37
+ const check = evaluateAspectRatio([720, 1280], null);
38
+ expect(check.comparable).toBe(false);
39
+ expect(check.warn).toBe(false);
40
+ });
41
+ test('is not comparable when a dimension is zero or negative', () => {
42
+ expect(evaluateAspectRatio([720, 0], [360, 640]).comparable).toBe(false);
43
+ expect(evaluateAspectRatio([720, 1280], [0, 640]).comparable).toBe(false);
44
+ });
45
+ test('exposes a sane default fold factor', () => {
46
+ expect(ASPECT_RATIO_WARN_FACTOR).toBeGreaterThan(1);
47
+ expect(ASPECT_RATIO_WARN_FACTOR).toBeLessThan(2);
48
+ });
49
+ });
50
+ //# sourceMappingURL=fidelity-aspect-guard.test.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Reference-provenance gate.
3
+ *
4
+ * A UI-fidelity reference is a PNG the gate cannot read, so reference validity
5
+ * is asserted at export time as provenance metadata (`figmaNode`, `locale`,
6
+ * `resolved`, `exportedAt`) and the gate checks that METADATA — never the
7
+ * pixels, never via OCR. A reference that is absent, unprovenanced,
8
+ * placeholder-bearing (`resolved == false`), or in a locale other than the
9
+ * render's is UNVERIFIABLE: a hard, mechanical verdict that fails loud instead
10
+ * of producing a confident-but-false PASS, and that kills the ad-hoc-Figma-fetch
11
+ * root cause (an ad-hoc fetch has no provenance, so it is Unverifiable).
12
+ *
13
+ * Content differences (copy wording, geometry) are deliberately NOT evaluated
14
+ * here — those are soft, judged findings that legitimately drift between
15
+ * reference-export and gate-run.
16
+ *
17
+ * This module is the authoritative spec for the check. The Android render
18
+ * runtime inlines an equivalent JS implementation (it ships as a self-contained
19
+ * script string and cannot import this module); the render-step integration
20
+ * tests exercise that inlined copy end-to-end against the same fixtures, so the
21
+ * two cannot silently drift.
22
+ */
23
+ /** Export-time provenance as it travels in the run-input sidecar. */
24
+ export interface ReferenceProvenanceMeta {
25
+ figmaNode?: string;
26
+ locale?: string;
27
+ resolved?: boolean;
28
+ exportedAt?: string;
29
+ }
30
+ /** A reason a reference cannot be honestly judged. */
31
+ export interface ProvenanceFault {
32
+ /** Stable machine code (NO_PROVENANCE / UNRESOLVED_REFERENCE / …). */
33
+ code: string;
34
+ /** Human/agent-readable reason, naming the screen. */
35
+ message: string;
36
+ }
37
+ /** The per-state status string written to protocol-result.json. */
38
+ export declare const UNVERIFIABLE_STATUS = "unverifiable";
39
+ /**
40
+ * Returns the reason a reference is UNVERIFIABLE, or null when it is verifiable.
41
+ * Checks metadata then existence; order is irrelevant for single-fault inputs
42
+ * but deterministic when several faults coincide.
43
+ */
44
+ export declare function provenanceFault(screen: string, provenance: ReferenceProvenanceMeta | null | undefined, renderLocale: string, referenceExists: boolean): ProvenanceFault | null;
45
+ //# sourceMappingURL=fidelity-reference-provenance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fidelity-reference-provenance.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/fidelity-reference-provenance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,qEAAqE;AACrE,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,sDAAsD;AACtD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,mEAAmE;AACnE,eAAO,MAAM,mBAAmB,iBAAiB,CAAC;AAgBlD;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,uBAAuB,GAAG,IAAI,GAAG,SAAS,EACtD,YAAY,EAAE,MAAM,EACpB,eAAe,EAAE,OAAO,GACvB,eAAe,GAAG,IAAI,CAgCxB"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Reference-provenance gate.
3
+ *
4
+ * A UI-fidelity reference is a PNG the gate cannot read, so reference validity
5
+ * is asserted at export time as provenance metadata (`figmaNode`, `locale`,
6
+ * `resolved`, `exportedAt`) and the gate checks that METADATA — never the
7
+ * pixels, never via OCR. A reference that is absent, unprovenanced,
8
+ * placeholder-bearing (`resolved == false`), or in a locale other than the
9
+ * render's is UNVERIFIABLE: a hard, mechanical verdict that fails loud instead
10
+ * of producing a confident-but-false PASS, and that kills the ad-hoc-Figma-fetch
11
+ * root cause (an ad-hoc fetch has no provenance, so it is Unverifiable).
12
+ *
13
+ * Content differences (copy wording, geometry) are deliberately NOT evaluated
14
+ * here — those are soft, judged findings that legitimately drift between
15
+ * reference-export and gate-run.
16
+ *
17
+ * This module is the authoritative spec for the check. The Android render
18
+ * runtime inlines an equivalent JS implementation (it ships as a self-contained
19
+ * script string and cannot import this module); the render-step integration
20
+ * tests exercise that inlined copy end-to-end against the same fixtures, so the
21
+ * two cannot silently drift.
22
+ */
23
+ /** The per-state status string written to protocol-result.json. */
24
+ export const UNVERIFIABLE_STATUS = 'unverifiable';
25
+ function hasProvenance(p) {
26
+ // Provenance is only "present" when the fields the gate reasons over are
27
+ // there. A partial object (e.g. a hand-dropped `{ figmaNode }`) is treated as
28
+ // no provenance — verifiable is earned only through the full export path.
29
+ return (p != null &&
30
+ typeof p === 'object' &&
31
+ typeof p.locale === 'string' &&
32
+ typeof p.resolved === 'boolean');
33
+ }
34
+ /**
35
+ * Returns the reason a reference is UNVERIFIABLE, or null when it is verifiable.
36
+ * Checks metadata then existence; order is irrelevant for single-fault inputs
37
+ * but deterministic when several faults coincide.
38
+ */
39
+ export function provenanceFault(screen, provenance, renderLocale, referenceExists) {
40
+ if (!hasProvenance(provenance)) {
41
+ return {
42
+ code: 'NO_PROVENANCE',
43
+ message: `reference for ${screen} has no export provenance; a reference is ` +
44
+ `verifiable only when exported through the deterministic path`,
45
+ };
46
+ }
47
+ if (provenance.resolved === false) {
48
+ return {
49
+ code: 'UNRESOLVED_REFERENCE',
50
+ message: `reference for ${screen} still has unresolved copy placeholders ` +
51
+ `(resolved=false) — export it with the copy filled in`,
52
+ };
53
+ }
54
+ if (provenance.locale !== renderLocale) {
55
+ return {
56
+ code: 'LOCALE_MISMATCH',
57
+ message: `reference for ${screen} is locale "${provenance.locale}" but the ` +
58
+ `render locale is "${renderLocale}"`,
59
+ };
60
+ }
61
+ if (!referenceExists) {
62
+ return {
63
+ code: 'REFERENCE_ABSENT',
64
+ message: `reference image for ${screen} is absent`,
65
+ };
66
+ }
67
+ return null;
68
+ }
69
+ //# sourceMappingURL=fidelity-reference-provenance.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Unit tests for the reference-provenance gate.
3
+ *
4
+ * A UI-fidelity reference is a PNG; the gate cannot read its text. So validity
5
+ * is asserted at export time as provenance metadata, and the gate checks the
6
+ * METADATA, never the pixels (no OCR). A reference that is absent, unprovenanced,
7
+ * placeholder-bearing (`resolved == false`), or in the wrong locale is
8
+ * UNVERIFIABLE — a loud, mechanical verdict, not a confident-but-false PASS.
9
+ *
10
+ * Content differences (copy wording, geometry) are NOT checked here — those are
11
+ * soft, judged findings that legitimately drift between export and run.
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=fidelity-reference-provenance.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fidelity-reference-provenance.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/fidelity-reference-provenance.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Unit tests for the reference-provenance gate.
3
+ *
4
+ * A UI-fidelity reference is a PNG; the gate cannot read its text. So validity
5
+ * is asserted at export time as provenance metadata, and the gate checks the
6
+ * METADATA, never the pixels (no OCR). A reference that is absent, unprovenanced,
7
+ * placeholder-bearing (`resolved == false`), or in the wrong locale is
8
+ * UNVERIFIABLE — a loud, mechanical verdict, not a confident-but-false PASS.
9
+ *
10
+ * Content differences (copy wording, geometry) are NOT checked here — those are
11
+ * soft, judged findings that legitimately drift between export and run.
12
+ */
13
+ import { describe, test, expect } from '@jest/globals';
14
+ import { provenanceFault, UNVERIFIABLE_STATUS, } from './fidelity-reference-provenance.js';
15
+ const GOOD = {
16
+ figmaNode: '12:345',
17
+ locale: 'en',
18
+ resolved: true,
19
+ exportedAt: '2026-06-18T10:00:00Z',
20
+ };
21
+ describe('provenanceFault', () => {
22
+ test('a present, provenanced, resolved, locale-matching reference is verifiable', () => {
23
+ expect(provenanceFault('Guest', GOOD, 'en', true)).toBeNull();
24
+ });
25
+ test('an absent reference is UNVERIFIABLE (REFERENCE_ABSENT)', () => {
26
+ const fault = provenanceFault('Guest', GOOD, 'en', false);
27
+ expect(fault?.code).toBe('REFERENCE_ABSENT');
28
+ });
29
+ test('a reference with no provenance is UNVERIFIABLE (NO_PROVENANCE)', () => {
30
+ expect(provenanceFault('Guest', null, 'en', true)?.code).toBe('NO_PROVENANCE');
31
+ expect(provenanceFault('Guest', undefined, 'en', true)?.code).toBe('NO_PROVENANCE');
32
+ });
33
+ test('incomplete provenance (missing locale/resolved) is UNVERIFIABLE (NO_PROVENANCE)', () => {
34
+ expect(provenanceFault('Guest', { figmaNode: 'x', exportedAt: 'y' }, 'en', true)?.code).toBe('NO_PROVENANCE');
35
+ });
36
+ test('a placeholder reference (resolved=false) is UNVERIFIABLE (UNRESOLVED_REFERENCE)', () => {
37
+ const fault = provenanceFault('Guest', { ...GOOD, resolved: false }, 'en', true);
38
+ expect(fault?.code).toBe('UNRESOLVED_REFERENCE');
39
+ });
40
+ test('a wrong-locale reference is UNVERIFIABLE (LOCALE_MISMATCH)', () => {
41
+ // Italian reference vs an English render.
42
+ const fault = provenanceFault('Guest', { ...GOOD, locale: 'it' }, 'en', true);
43
+ expect(fault?.code).toBe('LOCALE_MISMATCH');
44
+ });
45
+ test('every fault carries the screen name and a human reason', () => {
46
+ const fault = provenanceFault('LoggedIn', { ...GOOD, resolved: false }, 'en', true);
47
+ expect(fault?.message).toContain('LoggedIn');
48
+ expect(fault?.message.length).toBeGreaterThan(10);
49
+ });
50
+ test('exposes the unverifiable status string the result document uses', () => {
51
+ expect(UNVERIFIABLE_STATUS).toBe('unverifiable');
52
+ });
53
+ });
54
+ //# sourceMappingURL=fidelity-reference-provenance.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"render-post-processor.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/render-post-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH;;;;;;;;;GASG;AACH,eAAO,MAAM,wBAAwB,EAAE,MAiJpB,CAAC;AAEpB,eAAO,MAAM,wBAAwB,EAAE,MAsFtC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,YAAY,CAAC;IACxB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,sBAAsB,CAMlE"}
1
+ {"version":3,"file":"render-post-processor.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/render-post-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH;;;;;;;;;GASG;AACH,eAAO,MAAM,wBAAwB,EAAE,MA8JpB,CAAC;AAEpB,eAAO,MAAM,wBAAwB,EAAE,MAsFtC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE5C,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,EAAE,YAAY,CAAC;IACxB,MAAM,EAAE,YAAY,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,sBAAsB,CAMlE"}
@@ -61,14 +61,25 @@ export const TRIM_HELPER_SWIFT_SOURCE = [
61
61
  ' return CGImageDestinationFinalize(dest)',
62
62
  '}',
63
63
  '',
64
- '// Redraw an image at an exact pixel size (used to match the reference).',
65
- 'func resize(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
64
+ '// Draw `image` centered on a transparent reference-sized canvas, scaled to fit',
65
+ '// while PRESERVING its aspect ratio. Unlike `resize` (which stretches to exact',
66
+ '// dimensions and distorts proportions when the aspect ratios differ), this',
67
+ '// keeps the rendered shape honest: an aspect mismatch shows as transparent',
68
+ '// letterbox/pillarbox padding rather than a squished image, while the canvas',
69
+ '// stays exactly reference-sized so the pair still overlays 1:1.',
70
+ 'func fitPad(_ image: CGImage, toWidth w: Int, height h: Int) -> CGImage? {',
66
71
  ' if w <= 0 || h <= 0 { return nil }',
67
72
  ' let bytesPerRow = w * 4',
68
73
  ' let space = CGColorSpaceCreateDeviceRGB()',
69
74
  ' guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }',
75
+ ' ctx.clear(CGRect(x: 0, y: 0, width: w, height: h))',
70
76
  ' ctx.interpolationQuality = .high',
71
- ' ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))',
77
+ ' let scale = min(Double(w) / Double(image.width), Double(h) / Double(image.height))',
78
+ ' let drawW = Double(image.width) * scale',
79
+ ' let drawH = Double(image.height) * scale',
80
+ ' let originX = (Double(w) - drawW) / 2.0',
81
+ ' let originY = (Double(h) - drawH) / 2.0',
82
+ ' ctx.draw(image, in: CGRect(x: originX, y: originY, width: drawW, height: drawH))',
72
83
  ' return ctx.makeImage()',
73
84
  '}',
74
85
  '',
@@ -169,8 +180,10 @@ export const TRIM_HELPER_SWIFT_SOURCE = [
169
180
  ' exit(68)',
170
181
  '}',
171
182
  '',
172
- '// Optional aligned output: the trimmed image resized to the reference pixel',
173
- '// size, a 1:1 overlay pair. Best-effort; dimensions are reported either way.',
183
+ '// Optional aligned output: the trimmed image fit onto a reference-sized',
184
+ '// canvas, preserving aspect ratio (transparent letterbox/pillarbox padding',
185
+ '// when aspect ratios differ). A reference-sized, undistorted overlay pair.',
186
+ '// Best-effort; dimensions are reported either way.',
174
187
  'var referenceWidth = 0',
175
188
  'var referenceHeight = 0',
176
189
  'if arguments.count >= 5 {',
@@ -179,7 +192,7 @@ export const TRIM_HELPER_SWIFT_SOURCE = [
179
192
  ' if let reference = loadCGImage(referencePath) {',
180
193
  ' referenceWidth = reference.width',
181
194
  ' referenceHeight = reference.height',
182
- ' if let aligned = resize(cropped, toWidth: referenceWidth, height: referenceHeight) {',
195
+ ' if let aligned = fitPad(cropped, toWidth: referenceWidth, height: referenceHeight) {',
183
196
  ' _ = writePNG(aligned, to: alignedPath)',
184
197
  ' }',
185
198
  ' }',
@@ -116,13 +116,39 @@ describe('trim helper pixel logic (real toolchain)', () => {
116
116
  const BLUE = { r: 0, g: 0, b: 1, a: 1 };
117
117
  const WHITE = { r: 1, g: 1, b: 1, a: 1 };
118
118
  const PINK = { r: 1, g: 240 / 255, b: 238 / 255, a: 1 };
119
+ // Prints "<r> <g> <b> <a>" (0..255) for one pixel, so a test can tell padding
120
+ // (transparent) apart from content (opaque) in an aligned image.
121
+ const PROBE_SWIFT = [
122
+ 'import Foundation',
123
+ 'import ImageIO',
124
+ 'import CoreGraphics',
125
+ 'let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: CommandLine.arguments[1]) as CFURL, nil)!',
126
+ 'let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!',
127
+ 'let x = Int(CommandLine.arguments[2])!, y = Int(CommandLine.arguments[3])!',
128
+ 'let w = img.width, h = img.height',
129
+ 'var data = [UInt8](repeating: 0, count: w * h * 4)',
130
+ 'let cs = CGColorSpaceCreateDeviceRGB()',
131
+ 'let c = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!',
132
+ 'c.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))',
133
+ 'let i = y * w * 4 + x * 4',
134
+ 'print("\\(data[i]) \\(data[i+1]) \\(data[i+2]) \\(data[i+3])")',
135
+ ].join('\n') + '\n';
119
136
  function scene() {
120
137
  const dir = mkdtempSync(join(tmpdir(), 'ui-fidelity-postproc-'));
121
138
  dirs.push(dir);
122
139
  writeFileSync(join(dir, 'Make.swift'), MAKE_FIXTURE_SWIFT);
123
140
  writeFileSync(join(dir, 'Trim.swift'), TRIM_HELPER_SWIFT_SOURCE);
141
+ writeFileSync(join(dir, 'Probe.swift'), PROBE_SWIFT);
124
142
  return dir;
125
143
  }
144
+ // Alpha (0..255) of one pixel in a PNG.
145
+ function alphaAt(dir, filePath, x, y) {
146
+ const probe = spawnSync('swift', [join(dir, 'Probe.swift'), filePath, String(x), String(y)], {
147
+ encoding: 'utf-8',
148
+ });
149
+ expect(probe.status).toBe(0);
150
+ return Number(probe.stdout.trim().split(/\s+/)[3]);
151
+ }
126
152
  function craft(dir, name, w, h, rects) {
127
153
  const out = join(dir, name);
128
154
  const make = spawnSync('swift', [join(dir, 'Make.swift'), out, JSON.stringify({ w, h, rects })], { encoding: 'utf-8' });
@@ -178,8 +204,8 @@ describe('trim helper pixel logic (real toolchain)', () => {
178
204
  const out = join(dir, 'out.png');
179
205
  const aligned = join(dir, 'aligned.png');
180
206
  const result = trim(dir, input, out, aligned, reference);
181
- // The trimmed render is the content; the aligned copy is resized to the
182
- // reference pixel size for a 1:1 overlay.
207
+ // The trimmed render is the content; the aligned copy is fit onto a
208
+ // reference-sized canvas (preserving aspect) for a 1:1 overlay.
183
209
  expect(pngSize(out)).toEqual({ width: 60, height: 40 });
184
210
  expect(pngSize(aligned)).toEqual({ width: 100, height: 50 });
185
211
  // The TRIMDIMS line reports rendered (trimmed) / reference / pre-trim
@@ -190,5 +216,23 @@ describe('trim helper pixel logic (real toolchain)', () => {
190
216
  source: [200, 200],
191
217
  });
192
218
  }, 600_000);
219
+ realSwiftTest('fits a wide render into a square reference without distortion (transparent letterbox)', () => {
220
+ const dir = scene();
221
+ // Content fills its canvas: a 2:1 render (80x40), all opaque red.
222
+ const input = craft(dir, 'in.png', 80, 40, [{ x: 0, y: 0, w: 80, h: 40, ...RED }]);
223
+ // Square 1:1 reference (50x50) — a deliberately different aspect ratio.
224
+ const reference = craft(dir, 'ref.png', 50, 50, [{ x: 0, y: 0, w: 50, h: 50, ...BLUE }]);
225
+ const out = join(dir, 'out.png');
226
+ const aligned = join(dir, 'aligned.png');
227
+ trim(dir, input, out, aligned, reference);
228
+ // Aligned stays exactly reference-sized (overlay pair holds).
229
+ expect(pngSize(aligned)).toEqual({ width: 50, height: 50 });
230
+ // Aspect preserved: the 2:1 content fits by width (50x25) and is centered,
231
+ // so both letterbox bands are transparent while the center is opaque
232
+ // content. A stretch-to-fit would make every one of these opaque.
233
+ expect(alphaAt(dir, aligned, 25, 3)).toBe(0); // top band
234
+ expect(alphaAt(dir, aligned, 25, 46)).toBe(0); // bottom band
235
+ expect(alphaAt(dir, aligned, 25, 25)).toBe(255); // content (center)
236
+ }, 600_000);
193
237
  });
194
238
  //# sourceMappingURL=render-post-processor.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAsBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAExB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAkBD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AA4CrE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CAsKzB"}
1
+ {"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAuBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAGxB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAQD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AAqErE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CAuKzB"}
@@ -20,10 +20,11 @@
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
23
- import { tmpdir } from 'node:os';
23
+ import { homedir, tmpdir } from 'node:os';
24
24
  import { basename, join, resolve } from 'node:path';
25
25
  import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
26
26
  import { DEFAULT_GRADLE_TASK, PACKAGE_ARCHIVE_BASENAME, generateAndroidRenderScript, isValidGradleTask, } from './ui-fidelity-render-android.js';
27
+ import { buildPreviewScreenResult } from './ui-fidelity-preview.js';
27
28
  /**
28
29
  * Build error returned when the local Android toolchain is absent, so the caller
29
30
  * can fall back to a remote run instead of a confusing build failure. Generic
@@ -51,19 +52,44 @@ function commandExists(command, pathPrepend) {
51
52
  : spawnSync(`command -v ${command}`, { encoding: 'utf-8', env, shell: true });
52
53
  return probe.status === 0 && (probe.stdout || '').trim() !== '';
53
54
  }
55
+ /**
56
+ * Resolve the Android SDK directory: prefer an explicit `ANDROID_HOME` /
57
+ * `ANDROID_SDK_ROOT` env var (when it points at a real directory), otherwise
58
+ * fall back to the platform's default install location. This mirrors the
59
+ * auto-detect the Android build step already does, so a dev machine with a
60
+ * standard Android Studio install previews locally without the caller (or the
61
+ * MCP server's launch environment) having to export `ANDROID_HOME`. Returns the
62
+ * resolved path, or null when no SDK can be found.
63
+ */
64
+ function resolveAndroidSdk() {
65
+ const fromEnv = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '';
66
+ if (fromEnv && existsSync(fromEnv))
67
+ return fromEnv;
68
+ const home = process.env.HOME || homedir();
69
+ const candidates = [
70
+ join(home, 'Library', 'Android', 'sdk'), // macOS
71
+ join(home, 'Android', 'Sdk'), // Linux
72
+ '/usr/local/android-sdk',
73
+ ];
74
+ for (const candidate of candidates) {
75
+ if (existsSync(candidate))
76
+ return candidate;
77
+ }
78
+ return null;
79
+ }
54
80
  /**
55
81
  * Report which env-level Android prerequisites are missing: the JVM (`java`) and
56
- * the SDK (`ANDROID_HOME`/`ANDROID_SDK_ROOT`). Gradle itself ships inside the
82
+ * the SDK (a resolved [resolveAndroidSdk] path). Gradle itself ships inside the
57
83
  * render project (gradlew), so it is not an env-level prerequisite. Returns an
58
84
  * empty array when the toolchain is present.
59
85
  */
60
- function missingToolchain(pathPrepend) {
86
+ function missingToolchain(sdk, pathPrepend) {
61
87
  const missing = [];
62
88
  if (!commandExists('java', pathPrepend))
63
89
  missing.push('a JDK (java not on PATH)');
64
- const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '';
65
- if (!sdk || !existsSync(sdk))
66
- missing.push('the Android SDK (ANDROID_HOME not set)');
90
+ if (!sdk) {
91
+ missing.push('the Android SDK (ANDROID_HOME not set and no SDK at the default location)');
92
+ }
67
93
  return missing;
68
94
  }
69
95
  /** Resolve an artifact-relative path to absolute, or null. */
@@ -122,7 +148,8 @@ export function renderUiFidelityPreviewAndroid(options) {
122
148
  // A render is a heavy Gradle/Robolectric build; if the dev machine has no
123
149
  // local Android toolchain at all, fall back to a remote run with a clear
124
150
  // message instead of letting the build fail confusingly.
125
- const missing = missingToolchain(options.toolchainPath);
151
+ const androidSdk = resolveAndroidSdk();
152
+ const missing = missingToolchain(androidSdk, options.toolchainPath);
126
153
  if (missing.length > 0) {
127
154
  return {
128
155
  packageSource: 'inputs',
@@ -187,6 +214,13 @@ export function renderUiFidelityPreviewAndroid(options) {
187
214
  if (options.toolchainPath) {
188
215
  env.PATH = options.toolchainPath + ':' + (process.env.PATH ?? '');
189
216
  }
217
+ // Export the resolved SDK so gradlew sees it even when ANDROID_HOME was
218
+ // auto-detected (env unset) rather than already present in process.env.
219
+ if (androidSdk) {
220
+ env.ANDROID_HOME = androidSdk;
221
+ if (!process.env.ANDROID_SDK_ROOT)
222
+ env.ANDROID_SDK_ROOT = androidSdk;
223
+ }
190
224
  const run = spawnSync('node', ['-e', script], {
191
225
  cwd: workDir,
192
226
  env,
@@ -200,15 +234,7 @@ export function renderUiFidelityPreviewAndroid(options) {
200
234
  (detail ? `:\n${detail}` : ''));
201
235
  }
202
236
  const raw = JSON.parse(readFileSync(resultPath, 'utf-8'));
203
- const screens = (raw.screens || []).map((s) => ({
204
- screen: s.screen,
205
- status: s.status,
206
- error: s.error ?? null,
207
- referenceImagePath: absOrNull(artifactsDir, s.reference_image_path),
208
- renderedImagePath: absOrNull(artifactsDir, s.rendered_image_path),
209
- alignedImagePath: absOrNull(artifactsDir, s.aligned_image_path),
210
- dimensions: s.dimensions ?? null,
211
- }));
237
+ const screens = (raw.screens || []).map((s) => buildPreviewScreenResult(s, artifactsDir));
212
238
  const buildError = raw.error ?? null;
213
239
  const ok = buildError === null && screens.length > 0 && screens.every((s) => s.status === 'rendered');
214
240
  return { packageSource: 'inputs', buildError, screens, artifactsDir, ok };