nativemason 0.0.0-stage → 0.3.0

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 (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +4 -2
  3. package/bin/nativemason.js +13 -0
  4. package/dist/application/index.d.ts +34 -0
  5. package/dist/application/index.js +593 -0
  6. package/dist/browser/accessibility.d.ts +4 -0
  7. package/dist/browser/accessibility.js +93 -0
  8. package/dist/browser/index.d.ts +16 -0
  9. package/dist/browser/index.js +495 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +23 -0
  12. package/dist/core/accessibility.d.ts +5 -0
  13. package/dist/core/accessibility.js +55 -0
  14. package/dist/core/design-intent.d.ts +3 -0
  15. package/dist/core/design-intent.js +108 -0
  16. package/dist/core/index.d.ts +15 -0
  17. package/dist/core/index.js +324 -0
  18. package/dist/editor/index.d.ts +4 -0
  19. package/dist/editor/index.js +4 -0
  20. package/dist/editor/planner.d.ts +4 -0
  21. package/dist/editor/planner.js +374 -0
  22. package/dist/editor/source-format.d.ts +6 -0
  23. package/dist/editor/source-format.js +32 -0
  24. package/dist/editor/source-port.d.ts +3 -0
  25. package/dist/editor/source-port.js +79 -0
  26. package/dist/editor/transaction-planner.d.ts +3 -0
  27. package/dist/editor/transaction-planner.js +231 -0
  28. package/dist/editor/transaction-port.d.ts +3 -0
  29. package/dist/editor/transaction-port.js +257 -0
  30. package/dist/evidence/report.d.ts +4 -0
  31. package/dist/evidence/report.js +180 -0
  32. package/dist/init/index.d.ts +26 -0
  33. package/dist/init/index.js +81 -0
  34. package/dist/instrumentation/index.d.ts +25 -0
  35. package/dist/instrumentation/index.js +235 -0
  36. package/dist/mcp/index.d.ts +3 -0
  37. package/dist/mcp/index.js +42 -0
  38. package/dist/native/index.d.ts +4 -0
  39. package/dist/native/index.js +221 -0
  40. package/dist/overlay/index.d.ts +3 -0
  41. package/dist/overlay/index.js +35 -0
  42. package/dist/scenario/index.d.ts +5 -0
  43. package/dist/scenario/index.js +318 -0
  44. package/package.json +46 -3
@@ -0,0 +1,55 @@
1
+ /** WCAG relative luminance for previously validated opaque sRGB channels. */
2
+ export function solidContrastRatio(foreground, background) {
3
+ const luminance = (rgb) => rgb.map(value => { const x = value / 255; return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }).reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0);
4
+ const first = luminance(foreground), second = luminance(background);
5
+ return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
6
+ }
7
+ /** Bounded evidence checks; never infers native assistive-technology behavior. */
8
+ export function inspectAccessibility(snapshot) {
9
+ const findings = [];
10
+ const add = (code, status, ids, message) => findings.push({ code, status, elementIds: ids, message, platform: snapshot.platform });
11
+ if (snapshot.status !== 'stable')
12
+ return [{ code: 'RTL_EVIDENCE', status: 'unavailable', elementIds: [], message: 'Accessibility checks require a settled capture', platform: snapshot.platform }];
13
+ const byId = new Map(snapshot.nodes.map(node => [node.id, node]));
14
+ const inactive = (node) => { for (let parent = node.parentId; parent; parent = byId.get(parent)?.parentId ?? null)
15
+ if (byId.get(parent)?.accessibility?.disabled)
16
+ return true; return node.accessibility?.disabled === true; };
17
+ for (const node of snapshot.nodes) {
18
+ const evidence = node.accessibility;
19
+ if (snapshot.platform !== 'web' && evidence?.directionSource === 'native-global')
20
+ add('RTL_EVIDENCE', 'unavailable', [node.id], 'I18nManager provides only a global direction; this node’s resolved Yoga direction was not measured');
21
+ if (evidence?.hidden || evidence?.accessible === false) {
22
+ if (snapshot.platform === 'web' && typeof evidence.browser?.focusOrder === 'number')
23
+ add('FOCUS_ORDER', 'advisory', [node.id], 'Estimated DOM sequential focus position: ' + evidence.browser.focusOrder + '. This control is hidden from the accessibility tree, and the estimate is not verified assistive-technology order.');
24
+ continue;
25
+ }
26
+ const interactive = node.kind === 'Pressable' || node.kind === 'TextInput' || ['button', 'link', 'checkbox', 'switch', 'radio'].includes(node.role ?? '') || evidence?.focusable === true;
27
+ if (interactive) {
28
+ if (snapshot.platform === 'web' && evidence?.browser?.name !== null && evidence?.browser?.name !== undefined)
29
+ add('ACCESSIBLE_NAME', evidence.browser.name.trim() ? 'pass' : 'fail', [node.id], evidence.browser.name.trim() ? 'Chromium computed a nonempty accessible name' : 'Chromium computed an empty accessible name');
30
+ else if (evidence?.authoredLabel !== undefined)
31
+ add('ACCESSIBLE_NAME', evidence.authoredLabel.trim() ? 'advisory' : 'fail', [node.id], evidence.authoredLabel.trim() ? 'An explicit label is authored; native spoken output has not been measured' : 'The explicit accessible label is empty');
32
+ else
33
+ add('ACCESSIBLE_NAME', 'unavailable', [node.id], 'A derived accessible name was not measured; a missing explicit label alone does not prove an unlabeled control');
34
+ const order = evidence?.browser?.focusOrder;
35
+ add('FOCUS_ORDER', order === null || order === undefined ? 'unavailable' : 'advisory', [node.id], typeof order === 'number' ? 'Estimated DOM sequential focus position: ' + order + '. This is not verified assistive-technology order.' : 'Sequential focus order is unavailable for this control');
36
+ }
37
+ if (node.kind === 'Text' || node.kind === 'TextInput') {
38
+ const contrast = evidence?.browser?.contrast;
39
+ if (inactive(node))
40
+ add('TEXT_CONTRAST', 'unavailable', [node.id], 'Text belongs to an inactive control and is exempt from ordinary WCAG text contrast evaluation');
41
+ else if (contrast?.status === 'measured' && contrast.ratio !== undefined && contrast.requiredRatio !== undefined)
42
+ add('TEXT_CONTRAST', contrast.ratio >= contrast.requiredRatio ? 'pass' : 'fail', [node.id], 'Computed opaque-style contrast ' + contrast.ratio.toFixed(2) + '; required ' + contrast.requiredRatio);
43
+ else
44
+ add('TEXT_CONTRAST', 'unavailable', [node.id], contrast?.reason ?? 'Native foreground/background pixel contrast has not been measured');
45
+ }
46
+ if (evidence?.direction === 'rtl') {
47
+ const physical = ['marginLeft', 'marginRight', 'paddingLeft', 'paddingRight', 'left', 'right'].filter(property => node.styles[property] !== undefined);
48
+ if (physical.length)
49
+ add('RTL_PHYSICAL_SPACING', 'advisory', [node.id], (evidence.directionSource === 'native-global' ? 'I18nManager reports global RTL; this node’s direction is unmeasured. ' : 'RTL layout uses ') + 'physical properties ' + physical.join(', ') + '. Confirm that physical, rather than logical, edges are intended.');
50
+ }
51
+ }
52
+ if (!snapshot.nodes.some(node => node.accessibility?.direction && node.accessibility.direction !== 'unknown'))
53
+ add('RTL_EVIDENCE', 'unavailable', [], 'Layout direction was not measured');
54
+ return findings;
55
+ }
@@ -0,0 +1,3 @@
1
+ import { type DesignIntent, type DesignIntentResult } from '@nativemason/contracts';
2
+ /** Compiles explicit design data. No image interpretation or app-state acceptance occurs here. */
3
+ export declare function importDesignIntent(raw: DesignIntent): DesignIntentResult;
@@ -0,0 +1,108 @@
1
+ import { BridgeFault, DesignIntentSchema, DesignIntentResultSchema, FigmaNodeSchema } from '@nativemason/contracts';
2
+ const invalid = (message) => { throw new BridgeFault('INVALID_INPUT', message); };
3
+ const referenced = (rule) => 'elementId' in rule ? [rule.elementId] : 'elementIds' in rule ? [...rule.elementIds] : [rule.first, rule.second];
4
+ /** Compiles explicit design data. No image interpretation or app-state acceptance occurs here. */
5
+ export function importDesignIntent(raw) {
6
+ const parsed = DesignIntentSchema.safeParse(raw);
7
+ if (!parsed.success)
8
+ invalid('Invalid design intent: ' + parsed.error.message);
9
+ const input = parsed.data, constraints = [], notes = [], required = new Set();
10
+ const { profile, tolerance } = input;
11
+ if (profile.root.width <= 0 || profile.root.height <= 0)
12
+ invalid('Design import requires a nonempty target app root');
13
+ const rectangle = (id, target) => {
14
+ if (required.has(id))
15
+ invalid('Duplicate mapped element: ' + id);
16
+ required.add(id);
17
+ constraints.push({ id: 'design:rect:' + encodeURIComponent(id), type: 'rect', elementId: id, target, tolerance });
18
+ };
19
+ if (input.kind === 'reference-annotations') {
20
+ const crop = input.appRootPixels, scale = input.pixelsPerLogicalUnit;
21
+ if (crop.x < 0 || crop.y < 0 || crop.x + crop.width > input.image.width || crop.y + crop.height > input.image.height)
22
+ invalid('App-root crop must fit the reference image');
23
+ if (Math.abs(crop.width - profile.root.width * scale) > 1 || Math.abs(crop.height - profile.root.height * scale) > 1)
24
+ invalid('Reference app-root crop and pixel scale disagree with the target profile');
25
+ for (const annotation of input.annotations) {
26
+ const box = annotation.rectPixels;
27
+ if (box.x < 0 || box.y < 0 || box.x + box.width > input.image.width || box.y + box.height > input.image.height)
28
+ invalid('Annotated rectangle must fit the reference image');
29
+ rectangle(annotation.elementId, { x: (box.x - crop.x) / scale, y: (box.y - crop.y) / scale, width: box.width / scale, height: box.height / scale });
30
+ }
31
+ constraints.push(...input.assertions);
32
+ for (const rule of input.assertions)
33
+ for (const id of referenced(rule))
34
+ required.add(id);
35
+ notes.push('Reference rectangles are agent-supplied annotations; the image hash records provenance, not semantic verification.');
36
+ }
37
+ else {
38
+ const frame = input.frame.absoluteBoundingBox, scale = input.logicalUnitsPerDesignUnit;
39
+ if (!frame || frame.width <= 0 || frame.height <= 0)
40
+ invalid('The selected Figma frame needs a nonempty absoluteBoundingBox');
41
+ const origin = frame;
42
+ if (Math.abs(origin.width * scale - profile.root.width) > tolerance || Math.abs(origin.height * scale - profile.root.height) > tolerance)
43
+ invalid('Figma frame scale must match the target app-root dimensions');
44
+ const mapped = new Map(Object.entries(input.elementMap)), values = [...mapped.values()];
45
+ if (!values.length || new Set(values).size !== values.length)
46
+ invalid('Figma mappings must use unique layout element IDs');
47
+ const nodes = new Map(), stack = [{ raw: input.frame, depth: 0 }];
48
+ while (stack.length) {
49
+ const item = stack.pop();
50
+ if (item.depth > 64 || nodes.size >= 10000)
51
+ invalid('Figma import exceeds the supported traversal limit');
52
+ const result = FigmaNodeSchema.safeParse(item.raw);
53
+ if (!result.success)
54
+ invalid('Malformed Figma node: ' + result.error.message);
55
+ const node = result.data;
56
+ if (nodes.has(node.id))
57
+ invalid('Duplicate Figma node ID: ' + node.id);
58
+ nodes.set(node.id, node);
59
+ for (const child of [...node.children ?? []].reverse())
60
+ stack.push({ raw: child, depth: item.depth + 1 });
61
+ }
62
+ for (const [figmaId, elementId] of mapped) {
63
+ const box = nodes.get(figmaId)?.absoluteBoundingBox;
64
+ if (!box)
65
+ invalid('Mapped Figma node is missing or has no bounds: ' + figmaId);
66
+ rectangle(elementId, { x: (box.x - origin.x) * scale, y: (box.y - origin.y) * scale, width: box.width * scale, height: box.height * scale });
67
+ }
68
+ for (const node of nodes.values()) {
69
+ if (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL')
70
+ continue;
71
+ if (node.layoutWrap === 'WRAP') {
72
+ notes.push('Wrapped Figma auto-layout requires reviewed row constraints: ' + node.id);
73
+ continue;
74
+ }
75
+ const axis = node.layoutMode === 'HORIZONTAL' ? 'x' : 'y', cross = axis === 'x' ? 'y' : 'x';
76
+ const children = (node.children ?? []).map(child => FigmaNodeSchema.parse(child));
77
+ const gap = node.itemSpacing;
78
+ const fixedGap = gap !== undefined && gap >= 0 && node.primaryAxisAlignItems !== 'SPACE_BETWEEN';
79
+ if (gap !== undefined && !fixedGap)
80
+ notes.push('Skipped negative/distributed Figma spacing: ' + node.id);
81
+ let run = [];
82
+ const align = () => {
83
+ const edge = node.counterAxisAlignItems === 'MIN' ? 'start' : node.counterAxisAlignItems === 'CENTER' ? 'center' : node.counterAxisAlignItems === 'MAX' ? 'end' : undefined;
84
+ if (run.length > 1 && edge)
85
+ constraints.push({ id: 'design:align:' + encodeURIComponent(node.id) + ':' + encodeURIComponent(run[0]), type: 'align', axis: cross, edge, elementIds: [...run], tolerance });
86
+ run = [];
87
+ };
88
+ for (const child of children) {
89
+ const id = mapped.get(child.id);
90
+ if (!id || child.layoutPositioning === 'ABSOLUTE') {
91
+ align();
92
+ continue;
93
+ }
94
+ const previous = run.at(-1);
95
+ if (previous && fixedGap)
96
+ constraints.push({ id: 'design:gap:' + encodeURIComponent(node.id) + ':' + encodeURIComponent(previous) + ':' + encodeURIComponent(id), type: 'gap', axis, first: previous, second: id, value: gap * scale, tolerance });
97
+ run.push(id);
98
+ }
99
+ align();
100
+ if (node.counterAxisAlignItems === 'BASELINE')
101
+ notes.push('Figma baseline alignment is not a supported geometric constraint: ' + node.id);
102
+ }
103
+ notes.push('Figma coordinates and auto-layout are design intent. Review the generated constraints against the app scenario.');
104
+ }
105
+ if (new Set(constraints.map(rule => rule.id)).size !== constraints.length)
106
+ invalid('Design import contains duplicate constraint IDs');
107
+ return DesignIntentResultSchema.parse({ source: input.kind, reviewRequired: true, notes, spec: { schemaVersion: 1, scenarioId: input.scenarioId, profile, requiredElementIds: [...required], constraints } });
108
+ }
@@ -0,0 +1,15 @@
1
+ import { type SuggestionCandidate, type InferenceOptions, type RuleFinding, type ConstraintResult, type DeviceProfile, type NormalizedPatch, type OverlayScene, type Rect, type Result, type SpatialDelta, type SpatialSnapshot, type WireframeSpec } from '@nativemason/contracts';
2
+ /** Convert a window-space rectangle to root-space without applying insets twice. */
3
+ export declare function normalizeRect(rect: Rect, root: Rect): Rect;
4
+ export declare function safeAreaRect(profile: DeviceProfile): Rect;
5
+ export declare function normalizePatch(patch: unknown): Result<NormalizedPatch>;
6
+ export declare function profilesMatch(a: DeviceProfile, b: DeviceProfile): boolean;
7
+ export declare function evaluateConstraints(snapshot: SpatialSnapshot, spec: WireframeSpec): readonly ConstraintResult[];
8
+ export declare function diffSnapshots(before: SpatialSnapshot, after: SpatialSnapshot): SpatialDelta;
9
+ export declare function buildOverlayScene(snapshot: SpatialSnapshot, spec: WireframeSpec): OverlayScene;
10
+ /** Bounded deterministic proposals; trial evidence, never predicted acceptance. */
11
+ export declare function generateFixCandidates(snapshot: SpatialSnapshot, spec: WireframeSpec, limit?: number): readonly SuggestionCandidate[];
12
+ /** Produces an editable proposal, never an automatically accepted design specification. */
13
+ export declare function inferConstraints(snapshot: SpatialSnapshot, options?: InferenceOptions): WireframeSpec;
14
+ /** Advisory overlap and measured-box rules do not establish hit targets or design intent. */
15
+ export declare function evaluateDefaultRules(snapshot: SpatialSnapshot): readonly RuleFinding[];
@@ -0,0 +1,324 @@
1
+ import { RnStylePatchSchema } from '@nativemason/contracts';
2
+ /** Convert a window-space rectangle to root-space without applying insets twice. */
3
+ export function normalizeRect(rect, root) {
4
+ return { ...rect, x: rect.x - root.x, y: rect.y - root.y };
5
+ }
6
+ export function safeAreaRect(profile) {
7
+ const { window: win, root, insets } = profile;
8
+ const left = Math.max(root.x, insets.left);
9
+ const top = Math.max(root.y, insets.top);
10
+ const right = Math.min(root.x + root.width, win.width - insets.right);
11
+ const bottom = Math.min(root.y + root.height, win.height - insets.bottom);
12
+ return { x: left - root.x, y: top - root.y, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
13
+ }
14
+ export function normalizePatch(patch) {
15
+ const parsed = RnStylePatchSchema.safeParse(patch);
16
+ return parsed.success ? { ok: true, value: Object.freeze(parsed.data) } : {
17
+ ok: false, error: { code: 'INVALID_INPUT', message: parsed.error.message },
18
+ };
19
+ }
20
+ function idsFor(rule) {
21
+ switch (rule.type) {
22
+ case 'textFits':
23
+ case 'rect':
24
+ case 'minTouchTarget':
25
+ case 'insideSafeArea': return [rule.elementId];
26
+ case 'align': return rule.elementIds;
27
+ default: return [rule.first, rule.second];
28
+ }
29
+ }
30
+ function axisValue(rect, axis, edge) {
31
+ const size = axis === 'x' ? rect.width : rect.height;
32
+ return rect[axis] + (edge === 'start' ? 0 : edge === 'center' ? size / 2 : size);
33
+ }
34
+ function deviation(rule, rects, profile) {
35
+ const [a, b] = rects;
36
+ switch (rule.type) {
37
+ case 'textFits': return 0;
38
+ case 'rect': return Math.max(...['x', 'y', 'width', 'height'].map(k => Math.abs(a[k] - rule.target[k])));
39
+ case 'minTouchTarget': return Math.max(0, rule.width - a.width, rule.height - a.height);
40
+ case 'align': {
41
+ const values = rects.map(r => axisValue(r, rule.axis, rule.edge));
42
+ return Math.max(...values) - Math.min(...values);
43
+ }
44
+ case 'gap': return Math.abs(axisValue(b, rule.axis, 'start') - axisValue(a, rule.axis, 'end') - rule.value);
45
+ case 'insideSafeArea': {
46
+ const safe = safeAreaRect(profile);
47
+ return Math.max(0, safe.x - a.x, safe.y - a.y, a.x + a.width - safe.x - safe.width, a.y + a.height - safe.y - safe.height);
48
+ }
49
+ case 'disjoint': {
50
+ if (a.width === 0 || a.height === 0 || b.width === 0 || b.height === 0)
51
+ return 0;
52
+ const overlapX = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
53
+ const overlapY = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
54
+ return Math.max(0, Math.min(overlapX, overlapY));
55
+ }
56
+ }
57
+ }
58
+ export function profilesMatch(a, b) {
59
+ return a.orientation === b.orientation && a.fontScale === b.fontScale && a.pixelRatio === b.pixelRatio &&
60
+ Math.abs(a.window.width - b.window.width) <= 0.01 && Math.abs(a.window.height - b.window.height) <= 0.01 &&
61
+ ['x', 'y', 'width', 'height'].every(k => Math.abs(a.root[k] - b.root[k]) <= 0.01) &&
62
+ ['top', 'right', 'bottom', 'left'].every(k => Math.abs(a.insets[k] - b.insets[k]) <= 0.01);
63
+ }
64
+ export function evaluateConstraints(snapshot, spec) {
65
+ const nodes = new Map(snapshot.nodes.map(n => [n.id, n]));
66
+ const duplicates = nodes.size !== snapshot.nodes.length;
67
+ const contextMatches = snapshot.scenarioId === spec.scenarioId && profilesMatch(snapshot.profile, spec.profile) && snapshot.status === 'stable' && !duplicates;
68
+ const results = spec.requiredElementIds.filter(id => !nodes.has(id)).map(id => ({
69
+ constraintId: `required:${id}`, status: 'unavailable', elementIds: [id], deviation: null, tolerance: 0, message: `Required element ${id} is missing`,
70
+ }));
71
+ if (!contextMatches)
72
+ results.push({ constraintId: 'capture-context', status: 'unavailable', elementIds: [], deviation: null, tolerance: 0, message: 'Scenario, profile, unique IDs and stable capture must match the specification' });
73
+ for (const rule of spec.constraints) {
74
+ const ids = idsFor(rule);
75
+ if (!contextMatches || ids.some(id => !nodes.has(id))) {
76
+ results.push({ constraintId: rule.id, status: 'unavailable', elementIds: ids, deviation: null, tolerance: rule.tolerance, message: 'Required element or matching stable capture unavailable' });
77
+ continue;
78
+ }
79
+ if (rule.type === 'textFits') {
80
+ const node = nodes.get(rule.elementId);
81
+ const evidence = node.textFit;
82
+ const tolerance = rule.pixelAware ? Math.max(rule.tolerance, 2 / snapshot.profile.pixelRatio) : rule.tolerance;
83
+ if (!evidence || evidence.status !== 'supported' || evidence.fullLineCount === undefined || evidence.fullHeight === undefined || evidence.availableHeight === undefined) {
84
+ results.push({ constraintId: rule.id, status: 'unavailable', elementIds: ids, deviation: null, tolerance, message: evidence?.reason ?? 'Equivalent unconstrained text measurement unavailable' });
85
+ }
86
+ else {
87
+ const overflow = Math.max(0, evidence.fullHeight - evidence.availableHeight);
88
+ const lineLimit = node.authoredTextProps?.numberOfLines;
89
+ const tooManyLines = lineLimit !== undefined && evidence.fullLineCount > lineLimit;
90
+ const passed = !tooManyLines && overflow <= tolerance;
91
+ results.push({ constraintId: rule.id, status: passed ? 'pass' : 'fail', elementIds: ids, deviation: overflow, tolerance, message: passed ? 'Unconstrained text fits the measured box and line limit' : tooManyLines ? `Full text requires ${evidence.fullLineCount} lines, exceeding the authored limit ${lineLimit}` : `Full text exceeds available height by ${overflow} logical units` });
92
+ }
93
+ continue;
94
+ }
95
+ const amount = deviation(rule, ids.map(id => nodes.get(id).rect), snapshot.profile);
96
+ const tolerance = rule.pixelAware ? Math.max(rule.tolerance, 2 / snapshot.profile.pixelRatio) : rule.tolerance;
97
+ results.push({ constraintId: rule.id, status: amount <= tolerance ? 'pass' : 'fail', elementIds: ids, deviation: amount, tolerance, message: amount <= tolerance ? 'Within tolerance' : `Deviation ${amount} exceeds tolerance ${tolerance}` });
98
+ }
99
+ return results;
100
+ }
101
+ export function diffSnapshots(before, after) {
102
+ const old = new Map(before.nodes.map(n => [n.id, n]));
103
+ const next = new Map(after.nodes.map(n => [n.id, n]));
104
+ return {
105
+ added: [...next.keys()].filter(id => !old.has(id)).sort(),
106
+ removed: [...old.keys()].filter(id => !next.has(id)).sort(),
107
+ changed: [...next.keys()].filter(id => old.has(id)).sort().flatMap(id => {
108
+ const a = old.get(id).rect, b = next.get(id).rect;
109
+ if (['x', 'y', 'width', 'height'].every(k => a[k] === b[k]))
110
+ return [];
111
+ return [{ id, before: { ...a }, after: { ...b }, delta: { x: b.x - a.x, y: b.y - a.y, width: b.width - a.width, height: b.height - a.height } }];
112
+ }),
113
+ };
114
+ }
115
+ export function buildOverlayScene(snapshot, spec) {
116
+ return {
117
+ width: snapshot.profile.window.width, height: snapshot.profile.window.height,
118
+ marks: [...snapshot.nodes].sort((a, b) => a.id.localeCompare(b.id)).map((n, i) => ({ label: i + 1, id: n.id, rect: { ...n.rect, x: n.rect.x + snapshot.profile.root.x, y: n.rect.y + snapshot.profile.root.y } })),
119
+ targets: spec.constraints.flatMap(c => c.type === 'rect' ? [{ id: c.elementId, rect: { ...c.target, x: c.target.x + snapshot.profile.root.x, y: c.target.y + snapshot.profile.root.y } }] : []),
120
+ };
121
+ }
122
+ /** Bounded deterministic proposals; trial evidence, never predicted acceptance. */
123
+ export function generateFixCandidates(snapshot, spec, limit = 12) {
124
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
125
+ throw new Error('Candidate limit must be an integer from 1 to 100');
126
+ const nodes = new Map(snapshot.nodes.map(node => [node.id, node]));
127
+ const failed = new Set(evaluateConstraints(snapshot, spec).filter(result => result.status === 'fail').map(result => result.constraintId));
128
+ const candidates = [];
129
+ const seen = new Map();
130
+ const add = (candidate) => {
131
+ if (!candidate.mutations.length || candidate.mutations.some(mutation => !nodes.get(mutation.elementId)?.editable || !RnStylePatchSchema.safeParse(mutation.patch).success))
132
+ return;
133
+ const key = JSON.stringify(candidate.mutations), existing = seen.get(key);
134
+ if (existing !== undefined) {
135
+ candidates[existing].constraintIds = [...new Set([...candidates[existing].constraintIds, ...candidate.constraintIds])];
136
+ return;
137
+ }
138
+ seen.set(key, candidates.length);
139
+ candidates.push(candidate);
140
+ };
141
+ const numeric = (value, fallback = 0) => typeof value === 'number' && Number.isFinite(value) ? value : fallback;
142
+ const margin = (node, axis) => numeric(node.styles[axis === 'x' ? 'marginLeft' : 'marginTop'], numeric(node.styles[axis === 'x' ? 'marginHorizontal' : 'marginVertical'], numeric(node.styles.margin)));
143
+ const offerWrapping = (parent, constraintId) => {
144
+ if (!parent?.editable || !String(parent.styles.flexDirection ?? 'column').startsWith('row') || parent.rect.width <= 0)
145
+ return;
146
+ const children = snapshot.nodes.filter(node => node.parentId === parent.id);
147
+ const padding = numeric(parent.styles.paddingLeft, numeric(parent.styles.paddingHorizontal, numeric(parent.styles.padding))) + numeric(parent.styles.paddingRight, numeric(parent.styles.paddingHorizontal, numeric(parent.styles.padding)));
148
+ const availableWidth = Math.max(0, parent.rect.width - padding);
149
+ const gap = numeric(parent.styles.columnGap, numeric(parent.styles.gap));
150
+ const totalWidth = children.reduce((total, child) => total + child.rect.width, 0) + Math.max(0, children.length - 1) * gap;
151
+ if (children.length > 1 && totalWidth > availableWidth)
152
+ add({ mutations: [{ elementId: parent.id, patch: { flexWrap: 'wrap' } }], reason: 'Trial sibling wrapping where measured row contents exceed the available parent width', constraintIds: [constraintId] });
153
+ };
154
+ for (const rule of spec.constraints) {
155
+ if (!failed.has(rule.id))
156
+ continue;
157
+ const selected = idsFor(rule).map(id => nodes.get(id));
158
+ if (selected.some(node => !node))
159
+ continue;
160
+ const members = selected;
161
+ const parentId = members[0].parentId;
162
+ const parent = parentId && members.every(node => node.parentId === parentId) ? nodes.get(parentId) : undefined;
163
+ const [first, second] = members;
164
+ if (rule.type === 'minTouchTarget' && first.editable) {
165
+ const patch = { minWidth: Math.max(numeric(first.styles.minWidth), rule.width), minHeight: Math.max(numeric(first.styles.minHeight), rule.height), ...(typeof first.styles.maxWidth === 'number' && first.styles.maxWidth < rule.width ? { maxWidth: rule.width } : {}), ...(typeof first.styles.maxHeight === 'number' && first.styles.maxHeight < rule.height ? { maxHeight: rule.height } : {}) };
166
+ add({ mutations: [{ elementId: first.id, patch }], reason: 'Increase measured layout-box minimum dimensions; effective touchability still needs separate evidence', constraintIds: [rule.id] });
167
+ }
168
+ else if (rule.type === 'insideSafeArea') {
169
+ const safe = safeAreaRect(snapshot.profile);
170
+ if (first.editable && safe.width > 0 && safe.height > 0) {
171
+ const width = Math.min(first.rect.width, safe.width), height = Math.min(first.rect.height, safe.height);
172
+ const targetX = Math.max(safe.x, Math.min(first.rect.x, safe.x + safe.width - width));
173
+ const targetY = Math.max(safe.y, Math.min(first.rect.y, safe.y + safe.height - height));
174
+ const dx = targetX - first.rect.x, dy = targetY - first.rect.y;
175
+ add({ mutations: [{ elementId: first.id, patch: { ...(first.rect.width > safe.width ? { width: safe.width } : {}), ...(first.rect.height > safe.height ? { height: safe.height } : {}), ...(dx !== 0 ? { marginLeft: margin(first, 'x') + dx } : {}), ...(dy !== 0 ? { marginTop: margin(first, 'y') + dy } : {}) } }], reason: 'Trial measured box bounds and spacing within the root-relative safe area', constraintIds: [rule.id] });
176
+ }
177
+ offerWrapping(parent || undefined, rule.id);
178
+ }
179
+ else if (rule.type === 'disjoint') {
180
+ if (parent?.editable)
181
+ for (const direction of ['row', 'column'])
182
+ add({ mutations: [{ elementId: parent.id, patch: { flexDirection: direction, gap: Math.max(0, numeric(parent.styles.gap)) } }], reason: 'Trial non-overlapping sibling flow for an explicit disjoint constraint', constraintIds: [rule.id] });
183
+ if (second.editable)
184
+ for (const axis of ['x', 'y']) {
185
+ const property = axis === 'x' ? 'marginLeft' : 'marginTop';
186
+ const shift = axisValue(first.rect, axis, 'end') - axisValue(second.rect, axis, 'start');
187
+ if (shift > 0)
188
+ add({ mutations: [{ elementId: second.id, patch: { [property]: margin(second, axis) + shift } }], reason: 'Move the second measured box beyond the first on one axis; trial checks regressions', constraintIds: [rule.id] });
189
+ }
190
+ offerWrapping(parent || undefined, rule.id);
191
+ }
192
+ else if (rule.type === 'textFits') {
193
+ // A failing textFits result already requires supported, equivalent full-text evidence.
194
+ // We never infer an authored numberOfLines prop or fabricate font metrics.
195
+ const evidence = first.textFit;
196
+ if (first.editable) {
197
+ if (evidence.fullHeight > evidence.availableHeight)
198
+ add({ mutations: [{ elementId: first.id, patch: { height: evidence.fullHeight, minHeight: evidence.fullHeight } }], reason: 'Trial enough box height for the measured unconstrained text; authored line limits remain unchanged', constraintIds: [rule.id] });
199
+ if (parent && parent.rect.width > first.rect.width)
200
+ add({ mutations: [{ elementId: first.id, patch: { width: parent.rect.width, flexShrink: 0 } }], reason: 'Trial additional measured parent width to reduce text wrapping without shrinking the font', constraintIds: [rule.id] });
201
+ if (numeric(first.styles.flexShrink) > 0)
202
+ add({ mutations: [{ elementId: first.id, patch: { flexShrink: 0 } }], reason: 'Trial preserving text width instead of allowing sibling flex shrink', constraintIds: [rule.id] });
203
+ }
204
+ offerWrapping(parent || undefined, rule.id);
205
+ }
206
+ else if (rule.type === 'rect' && first.editable) {
207
+ add({ mutations: [{ elementId: first.id, patch: { width: rule.target.width, height: rule.target.height, ...(first.rect.x !== rule.target.x ? { marginLeft: margin(first, 'x') + rule.target.x - first.rect.x } : {}), ...(first.rect.y !== rule.target.y ? { marginTop: margin(first, 'y') + rule.target.y - first.rect.y } : {}) } }], reason: 'Trial explicit target dimensions and measured spacing correction', constraintIds: [rule.id] });
208
+ offerWrapping(parent || undefined, rule.id);
209
+ }
210
+ else if (rule.type === 'gap') {
211
+ if (parent?.editable) {
212
+ const property = rule.axis === 'x' ? 'columnGap' : 'rowGap';
213
+ add({ mutations: [{ elementId: parent.id, patch: { [property]: rule.value } }], reason: 'Set the shared parent spacing on the requested axis', constraintIds: [rule.id] });
214
+ add({ mutations: [{ elementId: parent.id, patch: { flexDirection: rule.axis === 'x' ? 'row' : 'column', [property]: rule.value } }], reason: 'Arrange siblings on the requested axis with explicit spacing', constraintIds: [rule.id] });
215
+ }
216
+ if (second.editable) {
217
+ const property = rule.axis === 'x' ? 'marginLeft' : 'marginTop';
218
+ const correction = axisValue(first.rect, rule.axis, 'end') + rule.value - axisValue(second.rect, rule.axis, 'start');
219
+ add({ mutations: [{ elementId: second.id, patch: { [property]: margin(second, rule.axis) + correction } }], reason: 'Adjust the second element spacing by the measured gap error', constraintIds: [rule.id] });
220
+ }
221
+ offerWrapping(parent || undefined, rule.id);
222
+ }
223
+ else if (rule.type === 'align') {
224
+ const alignment = rule.edge === 'start' ? 'flex-start' : rule.edge === 'end' ? 'flex-end' : 'center';
225
+ const crossAxis = parent && (String(parent.styles.flexDirection ?? 'column').startsWith('row') ? 'y' : 'x');
226
+ if (parent?.editable && crossAxis === rule.axis)
227
+ add({ mutations: [{ elementId: parent.id, patch: { alignItems: alignment } }], reason: 'Align siblings along their parent cross axis', constraintIds: [rule.id] });
228
+ if (parent?.editable && crossAxis !== rule.axis)
229
+ add({ mutations: [{ elementId: parent.id, patch: { flexDirection: rule.axis === 'y' ? 'row' : 'column', alignItems: alignment } }], reason: 'Arrange siblings so the requested alignment is on the parent cross axis', constraintIds: [rule.id] });
230
+ const mutations = members.filter(node => node.editable).map(node => ({ elementId: node.id, patch: { alignSelf: alignment } }));
231
+ if (mutations.length && crossAxis === rule.axis)
232
+ add({ mutations, reason: 'Align the selected siblings without changing the parent default', constraintIds: [rule.id] });
233
+ offerWrapping(parent || undefined, rule.id);
234
+ }
235
+ if (candidates.length >= limit)
236
+ break;
237
+ }
238
+ return candidates.slice(0, limit);
239
+ }
240
+ /** Produces an editable proposal, never an automatically accepted design specification. */
241
+ export function inferConstraints(snapshot, options = {}) {
242
+ const tolerance = options.tolerance ?? 2;
243
+ if (!Number.isFinite(tolerance) || tolerance < 0)
244
+ throw new Error('Inference tolerance must be finite and nonnegative');
245
+ if (snapshot.status !== 'stable' || new Set(snapshot.nodes.map(node => node.id)).size !== snapshot.nodes.length)
246
+ throw new Error('Inference requires a stable snapshot with unique element IDs');
247
+ const constraints = [];
248
+ const ordered = [...snapshot.nodes].sort((a, b) => a.id.localeCompare(b.id));
249
+ const safe = safeAreaRect(snapshot.profile);
250
+ for (const node of ordered) {
251
+ const id = encodeURIComponent(node.id);
252
+ if (options.includeRectangles !== false)
253
+ constraints.push({ id: `inferred:rect:${id}`, type: 'rect', elementId: node.id, target: { ...node.rect }, tolerance });
254
+ if (node.rect.x >= safe.x && node.rect.y >= safe.y && node.rect.x + node.rect.width <= safe.x + safe.width && node.rect.y + node.rect.height <= safe.y + safe.height)
255
+ constraints.push({ id: `inferred:safe:${id}`, type: 'insideSafeArea', elementId: node.id, tolerance });
256
+ }
257
+ const groups = new Map();
258
+ for (const node of ordered) {
259
+ const group = groups.get(node.parentId) ?? [];
260
+ group.push(node);
261
+ groups.set(node.parentId, group);
262
+ }
263
+ for (const group of groups.values())
264
+ for (const axis of ['x', 'y']) {
265
+ for (const edge of ['start', 'center', 'end']) {
266
+ const sorted = [...group].sort((a, b) => axisValue(a.rect, axis, edge) - axisValue(b.rect, axis, edge) || a.id.localeCompare(b.id));
267
+ let cluster = [];
268
+ const flush = () => { if (cluster.length > 1)
269
+ constraints.push({ id: `inferred:align:${axis}:${edge}:${cluster.map(n => encodeURIComponent(n.id)).join(':')}`, type: 'align', axis, edge, elementIds: cluster.map(n => n.id), tolerance }); };
270
+ for (const node of sorted) {
271
+ if (cluster.length && axisValue(node.rect, axis, edge) - axisValue(cluster[0].rect, axis, edge) > tolerance) {
272
+ flush();
273
+ cluster = [];
274
+ }
275
+ cluster.push(node);
276
+ }
277
+ flush();
278
+ }
279
+ const sorted = [...group].sort((a, b) => a.rect[axis] - b.rect[axis] || a.id.localeCompare(b.id));
280
+ for (let i = 1; i < sorted.length; i++) {
281
+ const a = sorted[i - 1], b = sorted[i], cross = axis === 'x' ? 'y' : 'x', size = cross === 'x' ? 'width' : 'height';
282
+ const gap = axisValue(b.rect, axis, 'start') - axisValue(a.rect, axis, 'end');
283
+ const crossOverlap = Math.min(a.rect[cross] + a.rect[size], b.rect[cross] + b.rect[size]) - Math.max(a.rect[cross], b.rect[cross]);
284
+ if (gap >= 0 && crossOverlap > 0)
285
+ constraints.push({ id: `inferred:gap:${axis}:${encodeURIComponent(a.id)}:${encodeURIComponent(b.id)}`, type: 'gap', axis, first: a.id, second: b.id, value: gap, tolerance });
286
+ }
287
+ }
288
+ return { schemaVersion: 1, scenarioId: snapshot.scenarioId, profile: structuredClone(snapshot.profile), requiredElementIds: ordered.map(node => node.id), constraints };
289
+ }
290
+ /** Advisory overlap and measured-box rules do not establish hit targets or design intent. */
291
+ export function evaluateDefaultRules(snapshot) {
292
+ const findings = [];
293
+ if (snapshot.status !== 'stable')
294
+ return [{ code: 'UNSTABLE_LAYOUT', severity: 'unavailable', elementIds: [], message: 'Default rules require a settled capture' }];
295
+ const minimum = snapshot.platform === 'ios' ? 44 : 48;
296
+ const safe = safeAreaRect(snapshot.profile);
297
+ const ordered = [...snapshot.nodes].sort((a, b) => a.id.localeCompare(b.id));
298
+ for (const node of ordered) {
299
+ if ((node.kind === 'Pressable' || node.role === 'button') && (node.rect.width < minimum || node.rect.height < minimum))
300
+ findings.push({ code: 'MINIMUM_LAYOUT_BOX', severity: 'confirmed', elementIds: [node.id], message: `Measured box is ${node.rect.width} × ${node.rect.height}; recommended minimum is ${minimum} × ${minimum}. This does not measure effective touchability.` });
301
+ if (node.parentId !== null && (node.rect.x < safe.x - 2 || node.rect.y < safe.y - 2 || node.rect.x + node.rect.width > safe.x + safe.width + 2 || node.rect.y + node.rect.height > safe.y + safe.height + 2))
302
+ findings.push({ code: 'SAFE_AREA', severity: 'advisory', elementIds: [node.id], message: 'Measured element extends outside the safe area; confirm whether edge-to-edge rendering is intentional' });
303
+ }
304
+ for (let i = 0; i < ordered.length; i++)
305
+ for (let j = i + 1; j < ordered.length; j++) {
306
+ const a = ordered[i], b = ordered[j];
307
+ if (a.parentId === null || a.parentId !== b.parentId)
308
+ continue;
309
+ const x = Math.min(a.rect.x + a.rect.width, b.rect.x + b.rect.width) - Math.max(a.rect.x, b.rect.x);
310
+ const y = Math.min(a.rect.y + a.rect.height, b.rect.y + b.rect.height) - Math.max(a.rect.y, b.rect.y);
311
+ if (x > 2 && y > 2)
312
+ findings.push({ code: 'SIBLING_OVERLAP', severity: 'advisory', elementIds: [a.id, b.id], message: 'Sibling rectangles overlap; explicit disjoint constraints are required to classify intentional layering as a violation' });
313
+ }
314
+ if (snapshot.platform === 'web') {
315
+ if (!snapshot.webDiagnostics)
316
+ findings.push({ code: 'CLIPPING_EVIDENCE', severity: 'unavailable', elementIds: [], message: 'Browser clipping diagnostics are unavailable' });
317
+ else
318
+ for (const warning of snapshot.webDiagnostics.warnings)
319
+ findings.push({ code: warning.code, severity: warning.severity === 'confirmed' ? 'confirmed' : 'unavailable', elementIds: [warning.elementId], message: warning.message });
320
+ }
321
+ else
322
+ findings.push({ code: 'CLIPPING_EVIDENCE', severity: 'unavailable', elementIds: [], message: 'Native clipping is not established by the registered layout boxes' });
323
+ return findings;
324
+ }
@@ -0,0 +1,4 @@
1
+ export { planSourcePatch, hashSource } from './planner.js';
2
+ export { planTransaction } from './transaction-planner.js';
3
+ export { createSourcePort } from './source-port.js';
4
+ export { createTransactionSourcePort } from './transaction-port.js';
@@ -0,0 +1,4 @@
1
+ export { planSourcePatch, hashSource } from './planner.js';
2
+ export { planTransaction } from './transaction-planner.js';
3
+ export { createSourcePort } from './source-port.js';
4
+ export { createTransactionSourcePort } from './transaction-port.js';
@@ -0,0 +1,4 @@
1
+ import { type EditPlan, type Result, type RnStylePatch, type SourceRef, type EditScope } from '@nativemason/contracts';
2
+ export declare const hashSource: (text: string) => string;
3
+ /** Pure planning: parses in memory and returns text; never writes the source tree. */
4
+ export declare function planSourcePatch(sourceText: string, sourceRef: SourceRef, patch: RnStylePatch, scope?: EditScope): Result<EditPlan>;