nativemason 0.0.0-stage → 0.2.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 +591 -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 +370 -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 +228 -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 +218 -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 +306 -0
  44. package/package.json +46 -3
@@ -0,0 +1,93 @@
1
+ import { BridgeFault, BrowserAccessibilitySamplesSchema } from '@nativemason/contracts';
2
+ import { solidContrastRatio } from '../core/accessibility.js';
3
+ /** Read-only, bounded browser evidence; native semantics are never inferred here. */
4
+ export async function captureAccessibilityEvidence(page, cdp, snapshot) {
5
+ const samples = BrowserAccessibilitySamplesSchema.parse(await page.evaluate(() => {
6
+ const runtime = window.__RN_LAYOUT_BRIDGE__;
7
+ const focusable = Array.from(document.querySelectorAll('button,a[href],input,select,textarea,[tabindex],[contenteditable=true]')).filter(node => node.tabIndex >= 0 && !node.matches(':disabled') && !node.closest('[inert],[hidden]') && node.getClientRects().length > 0 && getComputedStyle(node).visibility === 'visible');
8
+ focusable.sort((a, b) => (a.tabIndex > 0 ? a.tabIndex : Infinity) - (b.tabIndex > 0 ? b.tabIndex : Infinity));
9
+ const rgba = (value) => {
10
+ const match = /^rgba?\(([^)]+)\)$/.exec(value);
11
+ if (!match || match[1].includes('%'))
12
+ return null;
13
+ const values = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
14
+ if (values.length !== 3 && values.length !== 4 || values.some(value => !Number.isFinite(value)))
15
+ return null;
16
+ return [...values.slice(0, 3), values[3] ?? 1];
17
+ };
18
+ return Object.fromEntries((runtime?.getDiagnosticTargets() ?? []).map(({ id, element }) => {
19
+ if (!(element instanceof HTMLElement))
20
+ return [id, { direction: 'unknown', focusOrder: null, foreground: null, background: null, requiredRatio: 4.5, contrastReason: 'Registered host is not an HTML element' }];
21
+ const style = getComputedStyle(element), textFill = style.getPropertyValue('-webkit-text-fill-color'), color = textFill ? rgba(textFill) : rgba(style.color);
22
+ let background = null, reason = element.closest(':disabled,[aria-disabled="true"]') ? 'Text belongs to an inactive control; contrast exemption cannot be evaluated as ordinary text' : null;
23
+ for (let current = element; current; current = current.parentElement) {
24
+ const computed = getComputedStyle(current);
25
+ if (Number(computed.opacity) !== 1 || computed.filter !== 'none' || computed.mixBlendMode !== 'normal' || (computed.backdropFilter && computed.backdropFilter !== 'none')) {
26
+ reason ??= 'Opacity, blending, or filters prevent a proven solid-color contrast';
27
+ break;
28
+ }
29
+ if (!background) {
30
+ if (computed.backgroundImage !== 'none') {
31
+ reason ??= 'An image or gradient background requires pixel evidence';
32
+ break;
33
+ }
34
+ const value = rgba(computed.backgroundColor);
35
+ if (!value) {
36
+ reason ??= 'Unsupported background color space';
37
+ break;
38
+ }
39
+ if (value[3] === 1)
40
+ background = value.slice(0, 3);
41
+ else if (value[3] !== 0) {
42
+ reason ??= 'Translucent backgrounds require compositing evidence';
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ if (textFill && !color)
48
+ reason ??= 'Unsupported text fill color space';
49
+ if (!color || color[3] !== 1)
50
+ reason ??= 'An opaque sRGB foreground was not established';
51
+ if (!background)
52
+ reason ??= 'No explicit opaque ancestor background was established';
53
+ if (parseFloat(style.getPropertyValue('-webkit-text-stroke-width')) > 0)
54
+ reason ??= 'Text stroke changes glyph contrast and requires pixel evidence';
55
+ if (style.textShadow !== 'none')
56
+ reason ??= 'Text shadow changes require pixel evidence';
57
+ if (element instanceof HTMLInputElement && element.value === '' && element.placeholder)
58
+ reason ??= 'Placeholder foreground evidence is unavailable';
59
+ const index = focusable.indexOf(element), large = parseFloat(style.fontSize) >= 24 || (parseFloat(style.fontSize) >= 18.6666667 && Number(style.fontWeight) >= 700);
60
+ return [id, { direction: style.direction === 'rtl' ? 'rtl' : 'ltr', focusOrder: index < 0 ? null : index, foreground: reason ? null : color.slice(0, 3), background: reason ? null : background, requiredRatio: large ? 3 : 4.5, contrastReason: reason }];
61
+ }));
62
+ }));
63
+ const names = new Map();
64
+ await cdp.send('Accessibility.enable');
65
+ const root = await cdp.send('Runtime.evaluate', { expression: 'Object.fromEntries(window.__RN_LAYOUT_BRIDGE__.getDiagnosticTargets().map(target=>[target.id,target.element]))', objectGroup: 'nativemason-accessibility' });
66
+ if (!root.result.objectId)
67
+ throw new BridgeFault('STALE_SNAPSHOT', 'Accessibility targets disappeared');
68
+ try {
69
+ const properties = await cdp.send('Runtime.getProperties', { objectId: root.result.objectId, ownProperties: true });
70
+ for (const property of properties.result) {
71
+ names.set(property.name, null);
72
+ if (!property.value?.objectId)
73
+ continue;
74
+ try {
75
+ const described = await cdp.send('DOM.describeNode', { objectId: property.value.objectId });
76
+ const tree = await cdp.send('Accessibility.getPartialAXTree', { backendNodeId: described.node.backendNodeId, fetchRelatives: false });
77
+ const node = tree.nodes.find(node => node.backendDOMNodeId === described.node.backendNodeId && !node.ignored);
78
+ names.set(property.name, typeof node?.name?.value === 'string' ? node.name.value : null);
79
+ }
80
+ catch { /* Missing AX evidence is explicit; never replace it with an inferred label. */ }
81
+ }
82
+ }
83
+ finally {
84
+ await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'nativemason-accessibility' }).catch(() => undefined);
85
+ }
86
+ return { ...snapshot, nodes: snapshot.nodes.map(node => {
87
+ const sample = samples[node.id];
88
+ if (!sample)
89
+ return node;
90
+ const contrast = sample.foreground && sample.background ? { status: 'measured', ratio: solidContrastRatio(sample.foreground, sample.background), requiredRatio: sample.requiredRatio } : { status: 'unavailable', reason: sample.contrastReason ?? 'Solid-style contrast is unavailable' };
91
+ return { ...node, accessibility: { ...node.accessibility, direction: sample.direction, directionSource: 'browser-computed', browser: { name: names.get(node.id) ?? null, focusOrder: sample.focusOrder, focusOrderSource: 'dom-sequential-estimate', contrast } } };
92
+ }) };
93
+ }
@@ -0,0 +1,16 @@
1
+ import { type BrowserPort, type DeviceProfile } from '@nativemason/contracts';
2
+ export interface BrowserBridgeOptions {
3
+ webUrl: string;
4
+ sessionId: string;
5
+ scenarioId: string;
6
+ profile: DeviceProfile;
7
+ timeoutMs?: number;
8
+ }
9
+ export interface BrowserBridge extends BrowserPort {
10
+ timings(): {
11
+ captureMs: number[];
12
+ trialMs: number[];
13
+ };
14
+ }
15
+ /** Owns one Chromium context. Runtime changes travel through React Native props, never CSS. */
16
+ export declare function createBrowserBridge(options: BrowserBridgeOptions): Promise<BrowserBridge>;
@@ -0,0 +1,495 @@
1
+ import { captureAccessibilityEvidence } from './accessibility.js';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { chromium } from 'playwright';
4
+ import { BridgeFault, DeviceProfileSchema, ErrorCodeSchema, SpatialSnapshotSchema, LayoutMutationSchema, ScenarioScriptSchema, } from '@nativemason/contracts';
5
+ function applyProfileOverrides(base, overrides) {
6
+ if (!overrides)
7
+ return structuredClone(base);
8
+ if (overrides.fontScale !== undefined)
9
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Chromium font-scale simulation is not supported until rendered text changes can be verified');
10
+ if (!overrides.orientation || overrides.orientation === base.orientation)
11
+ return structuredClone(base);
12
+ const { width, height } = base.window;
13
+ if (Math.abs(width - height) < 0.01)
14
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Cannot infer the alternate viewport dimensions from a square profile');
15
+ const insets = overrides.orientation === 'landscape'
16
+ ? { top: base.insets.left, right: base.insets.top, bottom: base.insets.right, left: base.insets.bottom }
17
+ : { top: base.insets.right, right: base.insets.bottom, bottom: base.insets.left, left: base.insets.top };
18
+ const profile = { ...base, orientation: overrides.orientation, window: { width: height, height: width }, root: { x: 0, y: 0, width: height, height: width }, insets };
19
+ if (insets.left + insets.right >= profile.window.width || insets.top + insets.bottom >= profile.window.height) {
20
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Rotated safe-area insets do not fit the requested orientation');
21
+ }
22
+ return profile;
23
+ }
24
+ // CDP models SafeAreaInsets as integer CSS pixels; captured geometry remains fractional.
25
+ function emulatedInsets(profile) { return Object.fromEntries(Object.entries(profile.insets).map(([edge, value]) => [edge, Math.round(value)])); }
26
+ function fault(error, fallback) {
27
+ if (error instanceof BridgeFault)
28
+ return error;
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ const code = /timeout|timed out/i.test(message) ? 'TIMEOUT' : 'INTERNAL_ERROR';
31
+ return new BridgeFault(code, `${fallback}: ${message}`);
32
+ }
33
+ function validateSnapshot(value, options) {
34
+ const parsed = SpatialSnapshotSchema.safeParse(value);
35
+ if (!parsed.success)
36
+ throw new BridgeFault('INVALID_INPUT', 'Browser returned an invalid snapshot', parsed.error.flatten());
37
+ const snapshot = parsed.data;
38
+ if (snapshot.platform !== 'web' || snapshot.sessionId !== options.sessionId || snapshot.scenarioId !== options.scenarioId) {
39
+ throw new BridgeFault('STALE_SNAPSHOT', 'Browser snapshot belongs to another platform, session, or scenario');
40
+ }
41
+ if (JSON.stringify(snapshot.profile) !== JSON.stringify(options.profile)) {
42
+ // Parse both objects first to make field insertion order irrelevant.
43
+ if (JSON.stringify(DeviceProfileSchema.parse(snapshot.profile)) !== JSON.stringify(DeviceProfileSchema.parse(options.profile))) {
44
+ throw new BridgeFault('STALE_SNAPSHOT', 'Browser profile differs from the configured session profile');
45
+ }
46
+ }
47
+ const ids = snapshot.nodes.map(node => node.id);
48
+ if (new Set(ids).size !== ids.length)
49
+ throw new BridgeFault('DUPLICATE_ELEMENT', 'Browser snapshot contains duplicate element IDs');
50
+ const known = new Set(ids);
51
+ for (const node of snapshot.nodes) {
52
+ if (node.parentId !== null && !known.has(node.parentId)) {
53
+ throw new BridgeFault('MISSING_ELEMENT', `Missing instrumented parent ${node.parentId} for ${node.id}`);
54
+ }
55
+ if (node.source && snapshot.revisions[node.source.path] !== node.source.hash) {
56
+ throw new BridgeFault('STALE_SOURCE', `Source revision mismatch for ${node.id}`);
57
+ }
58
+ }
59
+ return snapshot;
60
+ }
61
+ function identity(snapshot) {
62
+ return JSON.stringify({
63
+ generation: snapshot.generation,
64
+ profile: snapshot.profile,
65
+ revisions: Object.entries(snapshot.revisions).sort(([a], [b]) => a.localeCompare(b)),
66
+ nodes: snapshot.nodes.map(node => ({ id: node.id, parentId: node.parentId, source: node.source, pressableState: node.pressableState })).sort((a, b) => a.id.localeCompare(b.id)),
67
+ });
68
+ }
69
+ function geometry(snapshot) {
70
+ return JSON.stringify(snapshot.nodes.map(node => ({ id: node.id, rect: node.rect })).sort((a, b) => a.id.localeCompare(b.id)));
71
+ }
72
+ async function waitForAssets(page, timeoutMs) {
73
+ await page.waitForFunction(() => {
74
+ const fontsReady = !document.fonts || document.fonts.status === 'loaded';
75
+ return fontsReady && Array.from(document.images).every(image => image.complete);
76
+ }, undefined, { timeout: timeoutMs });
77
+ const broken = await page.evaluate(() => Array.from(document.images).filter(image => image.currentSrc && image.naturalWidth === 0).map(image => image.currentSrc));
78
+ if (broken.length > 0)
79
+ throw new BridgeFault('INVALID_INPUT', 'One or more screen images failed to load', { images: broken });
80
+ }
81
+ async function collectDiagnostics(cdp) {
82
+ const root = await cdp.send('Runtime.evaluate', {
83
+ expression: 'Object.fromEntries(window.__RN_LAYOUT_BRIDGE__.getDiagnosticTargets().map(target => [target.id, target.element]))',
84
+ returnByValue: false, objectGroup: 'nativemason-diagnostics',
85
+ });
86
+ const objectId = root.result.objectId;
87
+ if (!objectId)
88
+ throw new BridgeFault('STALE_SNAPSHOT', 'Runtime did not expose registered DOM targets');
89
+ try {
90
+ const properties = await cdp.send('Runtime.getProperties', { objectId, ownProperties: true });
91
+ const nodes = {};
92
+ const warnings = [];
93
+ let domSnapshot;
94
+ try {
95
+ domSnapshot = await cdp.send('DOMSnapshot.captureSnapshot', { computedStyles: [], includePaintOrder: true, includeDOMRects: true });
96
+ }
97
+ catch { /* Paint order remains nullable. */ }
98
+ for (const property of properties.result) {
99
+ const elementObjectId = property.value?.objectId;
100
+ if (!property.name)
101
+ continue;
102
+ if (!elementObjectId) {
103
+ nodes[property.name] = { clipping: 'unknown', centerHitTest: 'unknown', scrollOverflow: { horizontal: false, vertical: false }, paintOrder: null, fonts: [], lineBoxCount: null };
104
+ warnings.push({ elementId: property.name, code: 'EVIDENCE_UNAVAILABLE', severity: 'uncertain', message: 'The registered host did not resolve to a DOM element' });
105
+ continue;
106
+ }
107
+ const requested = await cdp.send('DOM.requestNode', { objectId: elementObjectId });
108
+ if (!requested.nodeId) {
109
+ nodes[property.name] = { clipping: 'unknown', centerHitTest: 'unknown', scrollOverflow: { horizontal: false, vertical: false }, paintOrder: null, fonts: [], lineBoxCount: null };
110
+ warnings.push({ elementId: property.name, code: 'EVIDENCE_UNAVAILABLE', severity: 'uncertain', message: 'The registered host has no CDP DOM node' });
111
+ continue;
112
+ }
113
+ const described = await cdp.send('DOM.describeNode', { nodeId: requested.nodeId, depth: 0 });
114
+ const backendNodeId = described.node.backendNodeId;
115
+ const diagnostic = await cdp.send('Runtime.callFunctionOn', {
116
+ objectId: elementObjectId,
117
+ functionDeclaration: `function(){
118
+ const r=this.getBoundingClientRect(); const cx=r.left+r.width/2, cy=r.top+r.height/2;
119
+ let clipped=false, known=true, scrollX=false, scrollY=false;
120
+ for(let p=this;p&&p.nodeType===1;p=p.parentElement){
121
+ const s=getComputedStyle(p), pr=p.getBoundingClientRect();
122
+ if(s.overflowX==='hidden'||s.overflowX==='clip'||s.overflowX==='auto'||s.overflowX==='scroll'){
123
+ scrollX=scrollX||p.scrollWidth>p.clientWidth; if(p!==this&&(r.left<pr.left||r.right>pr.right))clipped=true;
124
+ }
125
+ if(s.overflowY==='hidden'||s.overflowY==='clip'||s.overflowY==='auto'||s.overflowY==='scroll'){
126
+ scrollY=scrollY||p.scrollHeight>p.clientHeight; if(p!==this&&(r.top<pr.top||r.bottom>pr.bottom))clipped=true;
127
+ }
128
+ }
129
+ const style=getComputedStyle(this); const requested=style.fontFamily.split(',').map(v=>v.trim().replace(/^['"]|['"]$/g,''));
130
+ let lines=null; if(this.childNodes.length){const range=document.createRange();range.selectNodeContents(this);const rects=[...range.getClientRects()];lines=new Set(rects.map(x=>Math.round(x.top*100)/100)).size;}
131
+ const failedFonts=[...document.fonts].filter(f=>f.status==='error').map(f=>f.family.replace(/^['"]|['"]$/g,''));
132
+ return {clipped,known,scrollX,scrollY,cx,cy,requested,lines,failedFonts};
133
+ }`, returnByValue: true,
134
+ });
135
+ const value = diagnostic.result?.value;
136
+ if (!value)
137
+ continue;
138
+ const clipping = value.known ? (value.clipped ? 'confirmed' : 'clear') : 'unknown';
139
+ let centerHitTest = 'unknown';
140
+ try {
141
+ const hit = await cdp.send('DOM.getNodeForLocation', { x: Math.round(value.cx), y: Math.round(value.cy), includeUserAgentShadowDOM: true, ignorePointerEventsNone: false });
142
+ const hitObject = await cdp.send('DOM.resolveNode', { backendNodeId: hit.backendNodeId, objectGroup: 'nativemason-diagnostics' });
143
+ if (hitObject.object.objectId) {
144
+ const contains = await cdp.send('Runtime.callFunctionOn', {
145
+ objectId: elementObjectId,
146
+ functionDeclaration: `function(hit){const owns=(ancestor,node)=>{for(let current=node;current;current=current.parentNode||(current.getRootNode&&current.getRootNode().host)||null)if(current===ancestor)return true;return false;};return owns(this,hit)?'clear':owns(hit,this)?'unknown':'confirmed';}`,
147
+ arguments: [{ objectId: hitObject.object.objectId }], returnByValue: true,
148
+ });
149
+ centerHitTest = contains.result.value;
150
+ }
151
+ }
152
+ catch {
153
+ centerHitTest = 'unknown';
154
+ }
155
+ let paintOrder = null;
156
+ if (domSnapshot) {
157
+ for (const doc of domSnapshot.documents) {
158
+ const index = doc.nodes.backendNodeId?.indexOf(backendNodeId) ?? -1;
159
+ const layoutIndex = doc.layout.nodeIndex.indexOf(index);
160
+ if (layoutIndex >= 0)
161
+ paintOrder = doc.layout.paintOrders?.[layoutIndex] ?? null;
162
+ }
163
+ }
164
+ let fonts = [];
165
+ try {
166
+ const platformFonts = await cdp.send('CSS.getPlatformFontsForNode', { nodeId: requested.nodeId });
167
+ fonts = platformFonts.fonts.map((font) => ({ family: font.familyName, glyphCount: font.glyphCount, isCustom: font.isCustomFont ?? null }));
168
+ }
169
+ catch { /* Font evidence can be unavailable for non-text hosts. */ }
170
+ const lineBoxCount = value.lines ?? null;
171
+ nodes[property.name] = { clipping, centerHitTest, scrollOverflow: { horizontal: value.scrollX, vertical: value.scrollY }, paintOrder, fonts, lineBoxCount };
172
+ if (clipping === 'confirmed')
173
+ warnings.push({ elementId: property.name, code: 'CLIPPED', severity: 'confirmed', message: 'The registered element is clipped by an ancestor' });
174
+ if (centerHitTest === 'confirmed')
175
+ warnings.push({ elementId: property.name, code: 'OCCLUDED', severity: 'confirmed', message: 'A different DOM node receives the center-point hit test' });
176
+ const genericFamilies = new Set(['arial', 'sans-serif', 'serif', 'monospace', 'system-ui', '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'roboto', 'helvetica', 'noto sans']);
177
+ const customRequested = value.requested.some((family) => !genericFamilies.has(family.toLowerCase()));
178
+ if (customRequested && !fonts.some(font => value.requested.some((family) => font.family.toLowerCase() === family.toLowerCase()))) {
179
+ const failed = value.requested.some((family) => value.failedFonts.some((name) => name.toLowerCase() === family.toLowerCase()));
180
+ warnings.push({ elementId: property.name, code: 'FONT_LOAD_FAILED', severity: failed ? 'confirmed' : 'uncertain', message: failed ? 'The requested custom FontFace has an error status' : 'Chromium rendered glyphs with a family other than the first requested custom family' });
181
+ }
182
+ if (clipping === 'unknown' || centerHitTest === 'unknown')
183
+ warnings.push({ elementId: property.name, code: 'EVIDENCE_UNAVAILABLE', severity: 'uncertain', message: 'One or more browser diagnostics could not be established' });
184
+ }
185
+ return { nodes, warnings };
186
+ }
187
+ finally {
188
+ await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'nativemason-diagnostics' }).catch(() => undefined);
189
+ }
190
+ }
191
+ /** Owns one Chromium context. Runtime changes travel through React Native props, never CSS. */
192
+ export async function createBrowserBridge(options) {
193
+ const profile = DeviceProfileSchema.parse(options.profile);
194
+ if (profile.fontScale !== 1)
195
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Chromium font-scale simulation has no rendered-text proof');
196
+ const timeoutMs = options.timeoutMs ?? 10_000;
197
+ let browser;
198
+ try {
199
+ browser = await chromium.launch({ headless: true });
200
+ const context = await browser.newContext({
201
+ viewport: { width: Math.round(profile.window.width), height: Math.round(profile.window.height) },
202
+ deviceScaleFactor: profile.pixelRatio,
203
+ reducedMotion: 'reduce',
204
+ });
205
+ const page = await context.newPage();
206
+ const cdp = await context.newCDPSession(page);
207
+ await cdp.send('DOM.enable');
208
+ await cdp.send('DOMSnapshot.enable');
209
+ await cdp.send('CSS.enable');
210
+ // Emulate the environment, so nested safe-area providers observe the same profile.
211
+ await cdp.send('Emulation.setSafeAreaInsetsOverride', { insets: emulatedInsets(profile) });
212
+ page.setDefaultTimeout(timeoutMs);
213
+ await page.goto(options.webUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
214
+ await cdp.send('DOM.getDocument', { depth: -1, pierce: true });
215
+ await page.waitForFunction(() => !!window.__RN_LAYOUT_BRIDGE__, undefined, { timeout: timeoutMs });
216
+ await page.evaluate(input => window.__RN_LAYOUT_BRIDGE__.configure(input), {
217
+ sessionId: options.sessionId,
218
+ scenarioId: options.scenarioId,
219
+ profile,
220
+ });
221
+ await waitForAssets(page, timeoutMs);
222
+ const captureMs = [];
223
+ const trialMs = [];
224
+ let closed = false;
225
+ let baseline;
226
+ let activeTrial = false;
227
+ let activeProfile = profile;
228
+ let navigation = 0;
229
+ let baselineNavigation = 0;
230
+ page.on('framenavigated', frame => { if (frame === page.mainFrame())
231
+ navigation++; });
232
+ async function callRuntime(operation, id, patch, mutations) {
233
+ let timer;
234
+ try {
235
+ const result = await Promise.race([
236
+ page.evaluate(async (input) => {
237
+ try {
238
+ const runtime = window.__RN_LAYOUT_BRIDGE__;
239
+ if (!runtime)
240
+ return { ok: false, code: 'STALE_SNAPSHOT', message: 'Screen runtime is no longer mounted' };
241
+ const value = input.operation === 'capture' ? await runtime.capture()
242
+ : input.operation === 'resetTrial' ? await runtime.resetTrial()
243
+ : input.operation === 'applyTrialBatch' ? runtime.applyTrialBatch ? await runtime.applyTrialBatch(input.mutations) : input.mutations.length === 1 ? await runtime.applyTrial(input.mutations[0].elementId, input.mutations[0].patch) : (() => { throw new Error('Runtime does not support batch mutations; update its development bundle'); })()
244
+ : await runtime.applyTrial(input.id, input.patch);
245
+ return { ok: true, value };
246
+ }
247
+ catch (error) {
248
+ const failure = error;
249
+ return { ok: false, code: failure?.code, message: String(failure?.message ?? error) };
250
+ }
251
+ }, { operation, id, patch, mutations }),
252
+ new Promise((_resolve, reject) => {
253
+ timer = setTimeout(() => reject(new BridgeFault('TIMEOUT', `Browser runtime ${operation} timed out`)), timeoutMs);
254
+ }),
255
+ ]);
256
+ if (!result.ok) {
257
+ const code = ErrorCodeSchema.safeParse(result.code);
258
+ throw new BridgeFault(code.success ? code.data : 'INTERNAL_ERROR', result.message);
259
+ }
260
+ return result.value;
261
+ }
262
+ finally {
263
+ if (timer)
264
+ clearTimeout(timer);
265
+ }
266
+ }
267
+ function ensureOpen() {
268
+ if (closed || !browser.isConnected())
269
+ throw new BridgeFault('SESSION_NOT_FOUND', 'Browser session is closed');
270
+ }
271
+ async function readStable() {
272
+ ensureOpen();
273
+ const started = performance.now();
274
+ await waitForAssets(page, timeoutMs);
275
+ let previous;
276
+ while (performance.now() - started < timeoutMs) {
277
+ const raw = await callRuntime('capture');
278
+ const parsed = SpatialSnapshotSchema.parse(raw);
279
+ const snapshot = validateSnapshot({ ...parsed, webDiagnostics: await collectDiagnostics(cdp) }, { ...options, profile: activeProfile });
280
+ if (snapshot.status === 'stable' && previous?.status === 'stable' && identity(previous) === identity(snapshot) && geometry(previous) === geometry(snapshot))
281
+ return snapshot;
282
+ previous = snapshot;
283
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
284
+ }
285
+ throw new BridgeFault('UNSTABLE_LAYOUT', 'Screen did not produce two matching stable layout captures');
286
+ }
287
+ async function setProfile(overrides) {
288
+ const nextProfile = applyProfileOverrides(profile, overrides);
289
+ const profileChanged = JSON.stringify(nextProfile) !== JSON.stringify(activeProfile);
290
+ if (!profileChanged)
291
+ return;
292
+ {
293
+ activeTrial = false;
294
+ baseline = undefined;
295
+ await callRuntime('resetTrial');
296
+ await cdp.send('Emulation.setSafeAreaInsetsOverride', { insets: emulatedInsets(nextProfile) });
297
+ await page.setViewportSize({ width: Math.round(nextProfile.window.width), height: Math.round(nextProfile.window.height) });
298
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width: Math.round(nextProfile.window.width), height: Math.round(nextProfile.window.height), deviceScaleFactor: nextProfile.pixelRatio, mobile: true, screenOrientation: { type: nextProfile.orientation === 'landscape' ? 'landscapePrimary' : 'portraitPrimary', angle: nextProfile.orientation === 'landscape' ? 90 : 0 } });
299
+ }
300
+ activeProfile = nextProfile;
301
+ await page.evaluate(input => window.__RN_LAYOUT_BRIDGE__.configure(input), { sessionId: options.sessionId, scenarioId: options.scenarioId, profile: activeProfile });
302
+ }
303
+ async function trialBatch(mutations, overrides) {
304
+ const started = performance.now();
305
+ ensureOpen();
306
+ if (!mutations.length)
307
+ throw new BridgeFault('INVALID_INPUT', 'A trial needs at least one mutation');
308
+ const parsed = mutations.map(mutation => { const result = LayoutMutationSchema.safeParse(mutation); if (!result.success)
309
+ throw new BridgeFault('INVALID_INPUT', result.error.message); return result.data; });
310
+ if (new Set(parsed.map(m => m.elementId)).size !== parsed.length)
311
+ throw new BridgeFault('INVALID_INPUT', 'A batch must contain each element only once');
312
+ if (!baseline)
313
+ throw new BridgeFault('STALE_SNAPSHOT', 'Capture a baseline before trying a layout mutation');
314
+ try {
315
+ await setProfile(overrides);
316
+ if (!baseline)
317
+ throw new BridgeFault('STALE_SNAPSHOT', 'Profile changed; capture a fresh profile-bound baseline');
318
+ const before = await readStable();
319
+ if (navigation !== baselineNavigation || identity(before) !== identity(baseline)) {
320
+ throw new BridgeFault('STALE_SNAPSHOT', 'Source, profile, navigation, or mounted elements changed since the baseline');
321
+ }
322
+ for (const mutation of parsed)
323
+ if (!before.nodes.some(node => node.id === mutation.elementId))
324
+ throw new BridgeFault('MISSING_ELEMENT', `Unknown element ${mutation.elementId}`);
325
+ await callRuntime('resetTrial');
326
+ activeTrial = false;
327
+ const reset = await readStable();
328
+ if (identity(reset) !== identity(baseline) || geometry(reset) !== geometry(baseline)) {
329
+ throw new BridgeFault('STALE_SNAPSHOT', 'Reset layout no longer matches the captured baseline');
330
+ }
331
+ await callRuntime('applyTrialBatch', undefined, undefined, parsed);
332
+ activeTrial = true;
333
+ const snapshot = await readStable();
334
+ if (identity(snapshot) !== identity(baseline))
335
+ throw new BridgeFault('STALE_SNAPSHOT', 'The mounted screen changed while applying the trial');
336
+ return snapshot;
337
+ }
338
+ catch (error) {
339
+ // A failed candidate must never leave a partially applied trial behind.
340
+ await callRuntime('resetTrial').catch(() => undefined);
341
+ activeTrial = false;
342
+ throw fault(error, 'Could not trial browser layout');
343
+ }
344
+ finally {
345
+ trialMs.push(performance.now() - started);
346
+ }
347
+ }
348
+ return {
349
+ async capture(overrides) {
350
+ const started = performance.now();
351
+ try {
352
+ await setProfile(overrides);
353
+ const snapshot = await readStable();
354
+ if (!activeTrial) {
355
+ baseline = snapshot;
356
+ baselineNavigation = navigation;
357
+ }
358
+ return snapshot;
359
+ }
360
+ catch (error) {
361
+ throw fault(error, 'Could not capture browser layout');
362
+ }
363
+ finally {
364
+ captureMs.push(performance.now() - started);
365
+ }
366
+ },
367
+ trial(elementId, patch, overrides) { return trialBatch([{ elementId, patch }], overrides); },
368
+ trialBatch,
369
+ async resetTrial() {
370
+ ensureOpen();
371
+ try {
372
+ await callRuntime('resetTrial');
373
+ activeTrial = false;
374
+ }
375
+ catch (error) {
376
+ throw fault(error, 'Could not reset browser trial');
377
+ }
378
+ },
379
+ async runScenario(script) {
380
+ ensureOpen();
381
+ const parsed = ScenarioScriptSchema.parse(script);
382
+ if (parsed.scenarioId !== options.scenarioId)
383
+ throw new BridgeFault('INVALID_INPUT', 'Scenario script must match the session scenario');
384
+ await callRuntime('resetTrial');
385
+ activeTrial = false;
386
+ baseline = undefined;
387
+ for (const action of [...parsed.reset, ...parsed.actions]) {
388
+ if (action.type === 'navigate') {
389
+ const url = new URL(action.url, options.webUrl);
390
+ if (url.origin !== new URL(options.webUrl).origin)
391
+ throw new BridgeFault('INVALID_INPUT', 'Scenario navigation must remain on the application origin');
392
+ await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
393
+ await page.waitForFunction(() => !!window.__RN_LAYOUT_BRIDGE__, undefined, { timeout: timeoutMs });
394
+ await page.evaluate(input => window.__RN_LAYOUT_BRIDGE__.configure(input), { sessionId: options.sessionId, scenarioId: options.scenarioId, profile: activeProfile });
395
+ continue;
396
+ }
397
+ if (action.type === 'hook') {
398
+ await page.evaluate(async (input) => {
399
+ const runtime = window.__RN_LAYOUT_BRIDGE__;
400
+ if (runtime?.runHook) {
401
+ await runtime.runHook(input.name, input.value);
402
+ return;
403
+ }
404
+ const hooks = window.__NATIVEMASON_SCENARIO_HOOKS__;
405
+ if (!hooks?.[input.name])
406
+ throw new Error(`Unknown scenario hook ${input.name}`);
407
+ await hooks[input.name](input.value);
408
+ }, action);
409
+ continue;
410
+ }
411
+ if (action.type === 'keyboard') {
412
+ if (action.visible)
413
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Chromium cannot establish native software keyboard visibility');
414
+ await page.keyboard.press('Escape');
415
+ await page.evaluate(() => { const focused = document.activeElement; if (focused instanceof HTMLElement)
416
+ focused.blur(); });
417
+ continue;
418
+ }
419
+ if (action.type === 'wait') {
420
+ await page.waitForFunction(id => {
421
+ const target = window.__RN_LAYOUT_BRIDGE__?.getDiagnosticTargets().find(item => item.id === id)?.element;
422
+ if (!(target instanceof Element))
423
+ return false;
424
+ const box = target.getBoundingClientRect();
425
+ return box.width > 0 && box.height > 0 && getComputedStyle(target).visibility !== 'hidden';
426
+ }, action.elementId, { timeout: action.timeoutMs });
427
+ continue;
428
+ }
429
+ const handle = await page.evaluateHandle(id => { const target = window.__RN_LAYOUT_BRIDGE__?.getDiagnosticTargets().find(item => item.id === id)?.element; return target instanceof Element ? target : null; }, action.elementId);
430
+ try {
431
+ const element = handle.asElement();
432
+ if (!element)
433
+ throw new BridgeFault('MISSING_ELEMENT', `Scenario target ${action.elementId} is unavailable`);
434
+ if (action.type === 'press') {
435
+ if (action.phase === 'down') {
436
+ await element.scrollIntoViewIfNeeded();
437
+ const box = await element.boundingBox();
438
+ if (!box)
439
+ throw new BridgeFault('MISSING_ELEMENT', 'Held pointer target has no box');
440
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
441
+ await page.mouse.down();
442
+ }
443
+ else
444
+ await page.mouse.up();
445
+ }
446
+ else if (action.type === 'tap')
447
+ await element.click();
448
+ else if (action.type === 'input') {
449
+ if (action.mode === 'append') {
450
+ await element.focus();
451
+ await element.press('ControlOrMeta+End');
452
+ await page.keyboard.insertText(action.text);
453
+ }
454
+ else
455
+ await element.fill(action.text);
456
+ }
457
+ else if (action.type === 'swipe')
458
+ await element.evaluate((node, direction) => { if (!(node instanceof Element))
459
+ throw new Error('Swipe target is not an element'); node.scrollBy(direction === 'LEFT' ? node.clientWidth : direction === 'RIGHT' ? -node.clientWidth : 0, direction === 'UP' ? node.clientHeight : direction === 'DOWN' ? -node.clientHeight : 0); }, action.direction);
460
+ else if (action.type === 'scroll')
461
+ await element.evaluate((node, delta) => { if (!(node instanceof Element))
462
+ throw new Error('Scroll target is not an element'); node.scrollBy(delta.x, delta.y); }, action);
463
+ }
464
+ finally {
465
+ await handle.dispose();
466
+ }
467
+ }
468
+ await readStable();
469
+ },
470
+ async captureAccessibility() {
471
+ const snapshot = await readStable(), evidence = await captureAccessibilityEvidence(page, cdp, snapshot), after = await readStable();
472
+ if (identity(snapshot) !== identity(after) || geometry(snapshot) !== geometry(after))
473
+ throw new BridgeFault('STALE_SNAPSHOT', 'Layout changed during accessibility capture');
474
+ return SpatialSnapshotSchema.parse({ ...evidence, snapshotId: after.snapshotId });
475
+ },
476
+ async screenshot() {
477
+ ensureOpen();
478
+ await readStable();
479
+ return page.screenshot({ type: 'png', fullPage: false, animations: 'disabled', scale: 'device' });
480
+ },
481
+ async close() {
482
+ if (closed)
483
+ return;
484
+ closed = true;
485
+ await cdp.detach().catch(() => undefined);
486
+ await browser.close();
487
+ },
488
+ timings() { return { captureMs: [...captureMs], trialMs: [...trialMs] }; },
489
+ };
490
+ }
491
+ catch (error) {
492
+ await browser?.close().catch(() => undefined);
493
+ throw fault(error, 'Could not open Chromium session');
494
+ }
495
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { BridgeApplication } from './application/index.js';
4
+ import { createMcpServer } from './mcp/index.js';
5
+ if (process.argv[2] === 'init') {
6
+ const { initialize } = await import('./init/index.js');
7
+ const root = process.argv.find((arg, index) => index > 2 && !arg.startsWith('--')) ?? process.cwd();
8
+ console.log(JSON.stringify(await initialize(root, process.argv.includes('--dry-run'), { textProbes: process.argv.includes('--text-probes') }), null, 2));
9
+ process.exit(0);
10
+ }
11
+ if (process.argv.includes('--help')) {
12
+ console.log('nativemason [init [project-root] [--dry-run]]\nWithout arguments, starts the MCP stdio server.');
13
+ process.exit(0);
14
+ }
15
+ const app = new BridgeApplication();
16
+ const server = createMcpServer(app);
17
+ let closing = false;
18
+ async function close() { if (closing)
19
+ return; closing = true; await app.close(); await server.close(); }
20
+ process.once('SIGINT', () => { void close().finally(() => process.exit(0)); });
21
+ process.once('SIGTERM', () => { void close().finally(() => process.exit(0)); });
22
+ process.stdin.once('end', () => { void close(); });
23
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,5 @@
1
+ import type { AccessibilityFinding, SpatialSnapshot } from '@nativemason/contracts';
2
+ /** WCAG relative luminance for previously validated opaque sRGB channels. */
3
+ export declare function solidContrastRatio(foreground: readonly number[], background: readonly number[]): number;
4
+ /** Bounded evidence checks; never infers native assistive-technology behavior. */
5
+ export declare function inspectAccessibility(snapshot: SpatialSnapshot): AccessibilityFinding[];