@juxtapose/core 0.4.0 → 0.6.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 (153) hide show
  1. package/dist/agent/handoff.d.ts +8 -1
  2. package/dist/agent/handoff.js +75 -7
  3. package/dist/agent/handoff.js.map +1 -1
  4. package/dist/analysis/affected.d.ts +37 -0
  5. package/dist/analysis/affected.js +97 -22
  6. package/dist/analysis/affected.js.map +1 -1
  7. package/dist/analysis/cause.d.ts +32 -0
  8. package/dist/analysis/cause.js +187 -0
  9. package/dist/analysis/cause.js.map +1 -0
  10. package/dist/baseline/approve.d.ts +24 -0
  11. package/dist/baseline/approve.js +54 -0
  12. package/dist/baseline/approve.js.map +1 -0
  13. package/dist/baseline/git-store.d.ts +4 -0
  14. package/dist/baseline/git-store.js +22 -0
  15. package/dist/baseline/git-store.js.map +1 -1
  16. package/dist/browser/acquire.d.ts +15 -0
  17. package/dist/browser/acquire.js +26 -0
  18. package/dist/browser/acquire.js.map +1 -0
  19. package/dist/browser/browser.d.ts +53 -0
  20. package/dist/browser/browser.js +122 -0
  21. package/dist/browser/browser.js.map +1 -0
  22. package/dist/browser/cdp.d.ts +81 -0
  23. package/dist/browser/cdp.js +230 -0
  24. package/dist/browser/cdp.js.map +1 -0
  25. package/dist/browser/daemon/client.d.ts +145 -0
  26. package/dist/browser/daemon/client.js +764 -0
  27. package/dist/browser/daemon/client.js.map +1 -0
  28. package/dist/browser/daemon/state.d.ts +99 -0
  29. package/dist/browser/daemon/state.js +297 -0
  30. package/dist/browser/daemon/state.js.map +1 -0
  31. package/dist/browser/daemon/supervisor-main.d.ts +1 -0
  32. package/dist/browser/daemon/supervisor-main.js +54 -0
  33. package/dist/browser/daemon/supervisor-main.js.map +1 -0
  34. package/dist/browser/daemon/supervisor.d.ts +37 -0
  35. package/dist/browser/daemon/supervisor.js +777 -0
  36. package/dist/browser/daemon/supervisor.js.map +1 -0
  37. package/dist/{capture/launch.d.ts → browser/flags.d.ts} +19 -1
  38. package/dist/browser/flags.js +146 -0
  39. package/dist/browser/flags.js.map +1 -0
  40. package/dist/browser/index.d.ts +14 -0
  41. package/dist/browser/index.js +14 -0
  42. package/dist/browser/index.js.map +1 -0
  43. package/dist/browser/install.d.ts +33 -0
  44. package/dist/browser/install.js +167 -0
  45. package/dist/browser/install.js.map +1 -0
  46. package/dist/browser/launch.d.ts +37 -0
  47. package/dist/browser/launch.js +254 -0
  48. package/dist/browser/launch.js.map +1 -0
  49. package/dist/browser/page.d.ts +125 -0
  50. package/dist/browser/page.js +564 -0
  51. package/dist/browser/page.js.map +1 -0
  52. package/dist/browser/pins.d.ts +12 -0
  53. package/dist/browser/pins.js +57 -0
  54. package/dist/browser/pins.js.map +1 -0
  55. package/dist/browser/private-dir.d.ts +7 -0
  56. package/dist/browser/private-dir.js +24 -0
  57. package/dist/browser/private-dir.js.map +1 -0
  58. package/dist/browser/resolve.d.ts +31 -0
  59. package/dist/browser/resolve.js +126 -0
  60. package/dist/browser/resolve.js.map +1 -0
  61. package/dist/browser/selectors.d.ts +10 -0
  62. package/dist/browser/selectors.js +60 -0
  63. package/dist/browser/selectors.js.map +1 -0
  64. package/dist/browser/transport.d.ts +51 -0
  65. package/dist/browser/transport.js +124 -0
  66. package/dist/browser/transport.js.map +1 -0
  67. package/dist/browser/types.d.ts +4 -0
  68. package/dist/browser/types.js +2 -0
  69. package/dist/browser/types.js.map +1 -0
  70. package/dist/browser/zip.d.ts +27 -0
  71. package/dist/browser/zip.js +241 -0
  72. package/dist/browser/zip.js.map +1 -0
  73. package/dist/capture/capture.d.ts +7 -1
  74. package/dist/capture/capture.js +25 -8
  75. package/dist/capture/capture.js.map +1 -1
  76. package/dist/capture/env.d.ts +1 -1
  77. package/dist/capture/pool.d.ts +1 -1
  78. package/dist/capture/pool.js +41 -8
  79. package/dist/capture/pool.js.map +1 -1
  80. package/dist/capture/settle.d.ts +1 -1
  81. package/dist/capture/settle.js +2 -0
  82. package/dist/capture/settle.js.map +1 -1
  83. package/dist/capture/snap.d.ts +43 -0
  84. package/dist/capture/snap.js +103 -0
  85. package/dist/capture/snap.js.map +1 -0
  86. package/dist/capture/structure.d.ts +77 -0
  87. package/dist/capture/structure.js +498 -0
  88. package/dist/capture/structure.js.map +1 -0
  89. package/dist/diff/compare.d.ts +19 -0
  90. package/dist/diff/compare.js +19 -0
  91. package/dist/diff/compare.js.map +1 -0
  92. package/dist/diff/crops.d.ts +26 -0
  93. package/dist/diff/crops.js +92 -0
  94. package/dist/diff/crops.js.map +1 -0
  95. package/dist/diff/structural/classify.d.ts +29 -0
  96. package/dist/diff/structural/classify.js +113 -0
  97. package/dist/diff/structural/classify.js.map +1 -0
  98. package/dist/diff/structural/coverage.d.ts +11 -0
  99. package/dist/diff/structural/coverage.js +37 -0
  100. package/dist/diff/structural/coverage.js.map +1 -0
  101. package/dist/diff/structural/deltas.d.ts +29 -0
  102. package/dist/diff/structural/deltas.js +218 -0
  103. package/dist/diff/structural/deltas.js.map +1 -0
  104. package/dist/diff/structural/disambiguate.d.ts +3 -0
  105. package/dist/diff/structural/disambiguate.js +89 -0
  106. package/dist/diff/structural/disambiguate.js.map +1 -0
  107. package/dist/diff/structural/label.d.ts +15 -0
  108. package/dist/diff/structural/label.js +64 -0
  109. package/dist/diff/structural/label.js.map +1 -0
  110. package/dist/diff/structural/match.d.ts +13 -0
  111. package/dist/diff/structural/match.js +135 -0
  112. package/dist/diff/structural/match.js.map +1 -0
  113. package/dist/diff/structural/shift.d.ts +2 -0
  114. package/dist/diff/structural/shift.js +144 -0
  115. package/dist/diff/structural/shift.js.map +1 -0
  116. package/dist/diff/structural/summary.d.ts +31 -0
  117. package/dist/diff/structural/summary.js +420 -0
  118. package/dist/diff/structural/summary.js.map +1 -0
  119. package/dist/diff/structural.d.ts +40 -0
  120. package/dist/diff/structural.js +219 -0
  121. package/dist/diff/structural.js.map +1 -0
  122. package/dist/index.d.ts +28 -4
  123. package/dist/index.js +32 -3
  124. package/dist/index.js.map +1 -1
  125. package/dist/platform.d.ts +1 -1
  126. package/dist/platforms/urls.d.ts +3 -0
  127. package/dist/platforms/urls.js +89 -0
  128. package/dist/platforms/urls.js.map +1 -0
  129. package/dist/report/comment.js +9 -3
  130. package/dist/report/comment.js.map +1 -1
  131. package/dist/report/format.d.ts +18 -0
  132. package/dist/report/format.js +20 -0
  133. package/dist/report/format.js.map +1 -1
  134. package/dist/report/html.js +2 -2
  135. package/dist/report/html.js.map +1 -1
  136. package/dist/run/groups.d.ts +2 -0
  137. package/dist/run/groups.js +45 -0
  138. package/dist/run/groups.js.map +1 -0
  139. package/dist/run/run.d.ts +6 -2
  140. package/dist/run/run.js +74 -6
  141. package/dist/run/run.js.map +1 -1
  142. package/dist/shell-quote.d.ts +2 -0
  143. package/dist/shell-quote.js +10 -0
  144. package/dist/shell-quote.js.map +1 -0
  145. package/dist/types.d.ts +201 -5
  146. package/dist/types.js +0 -2
  147. package/dist/types.js.map +1 -1
  148. package/dist/url-platform.d.ts +2 -2
  149. package/dist/url-platform.js +2 -2
  150. package/dist/url-platform.js.map +1 -1
  151. package/package.json +6 -10
  152. package/dist/capture/launch.js +0 -56
  153. package/dist/capture/launch.js.map +0 -1
@@ -0,0 +1,103 @@
1
+ // Capture one URL through the full settle pipeline, outside any run.
2
+ //
3
+ // The public entry point for `jux snap` and for anything else that needs a
4
+ // single deterministic capture. It exists so captureStory and the settle
5
+ // pipeline can stay unexported (see index.ts): callers get the result of the
6
+ // load-bearing ordering without being able to reorder it.
7
+ import { createUrlPlatform } from '../platforms/urls.js';
8
+ import { captureStory } from './capture.js';
9
+ import { captureEnv } from './env.js';
10
+ const DEFAULT_SNAP_VIEWPORT = { width: 1280, height: 800 };
11
+ /**
12
+ * Capture several URLs with one context and one page, in order.
13
+ * A page that fails records its error and the rest still capture: the agent
14
+ * asked for all of them and wants to know which ones it got.
15
+ *
16
+ * One page, navigated, is the cold path captureStory already takes: a
17
+ * navigation discards injected styles and captureStory re-applies them.
18
+ * Origin state is cleared between pages by resetBetweenPages.
19
+ */
20
+ export async function snapPages(opts) {
21
+ const viewport = opts.viewport ?? DEFAULT_SNAP_VIEWPORT;
22
+ const context = await opts.browser.newContext({ viewport, blockServiceWorkers: true });
23
+ try {
24
+ const page = await context.newPage();
25
+ let status = null;
26
+ const off = page.onMainResponse((s) => (status = s));
27
+ const pages = [];
28
+ for (const [k, url] of opts.urls.entries()) {
29
+ status = null;
30
+ const id = `snap-${k}`;
31
+ const platform = createUrlPlatform([
32
+ { id, url, ...(opts.params ? { params: opts.params } : {}) },
33
+ ]);
34
+ try {
35
+ const [story] = await platform.enumerate({ projectRoot: process.cwd() });
36
+ const snapshot = await captureStory(page, story, { platform, baseUrl: url }, viewport);
37
+ if (!snapshot)
38
+ throw new Error('page declared skip');
39
+ pages.push({ url, snapshot, status });
40
+ }
41
+ catch (err) {
42
+ pages.push({
43
+ url,
44
+ status,
45
+ error: err instanceof Error ? err.message : String(err),
46
+ });
47
+ }
48
+ await resetBetweenPages(context, page, url);
49
+ }
50
+ off();
51
+ return { env: captureEnv(opts.browser), pages };
52
+ }
53
+ finally {
54
+ await context.close().catch(() => { });
55
+ }
56
+ }
57
+ /**
58
+ * A navigation keeps everything origin-scoped: cookies, localStorage,
59
+ * IndexedDB, Cache Storage and service workers. One context is shared by every
60
+ * page (a context per page would cost the launch savings back), so the
61
+ * previous page's origin is wiped through the page's own CDP session
62
+ * (Storage.clearDataForOrigin, as before) instead; otherwise a page's pixels
63
+ * could depend on which page was captured before it. Service workers
64
+ * are blocked at context creation, since clearing cannot stop one that has
65
+ * already intercepted the next navigation.
66
+ *
67
+ * The origin comes from the requested URL, not only the page: after a failed
68
+ * load the page is an error page with an opaque origin. The page's final URL
69
+ * is cleared too, for redirects. Covers IndexedDB, Cache Storage, service
70
+ * workers and local storage. Best effort: nothing here
71
+ * may fail a capture that already succeeded.
72
+ */
73
+ async function resetBetweenPages(context, page, requestedUrl) {
74
+ await context.clearCookies().catch(() => { });
75
+ // A redirect lands on another origin (a login host, a canonical port) that
76
+ // holds state of its own.
77
+ const origins = new Set();
78
+ for (const u of [requestedUrl, page.url()]) {
79
+ try {
80
+ const o = new URL(u);
81
+ if (o.protocol === 'http:' || o.protocol === 'https:')
82
+ origins.add(o.origin);
83
+ }
84
+ catch {
85
+ // about:blank, chrome-error:// — no origin to clear.
86
+ }
87
+ }
88
+ for (const origin of origins)
89
+ await page.clearOrigin(origin).catch(() => { });
90
+ }
91
+ export async function snapPage(opts) {
92
+ const { env, pages } = await snapPages({
93
+ urls: [opts.url],
94
+ browser: opts.browser,
95
+ ...(opts.viewport ? { viewport: opts.viewport } : {}),
96
+ ...(opts.params ? { params: opts.params } : {}),
97
+ });
98
+ const [r] = pages;
99
+ if (!r.snapshot)
100
+ throw new Error(r.error ?? 'jux: could not capture');
101
+ return { snapshot: r.snapshot, env, status: r.status };
102
+ }
103
+ //# sourceMappingURL=snap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snap.js","sourceRoot":"","sources":["../../src/capture/snap.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,77 @@
1
+ import type { Page } from '../browser/page.js';
2
+ import type { StructureSnapshot, Viewport } from '../types.js';
3
+ /** Styles recorded per element. `display`/`visibility` are deliberately absent:
4
+ * display:none elements have no layout entry at all, and visibility is read
5
+ * only to filter (see `visibilityIdx` in normalizeStructure). */
6
+ export declare const STRUCTURE_PROPS: readonly string[];
7
+ /** Inherited properties are stored only where they differ from the parent. */
8
+ export declare const INHERITED_PROPS: ReadonlySet<string>;
9
+ /** Computed-style initial values for non-inherited properties. A value equal
10
+ * to its initial value is not stored. Border colors are special-cased: they
11
+ * default to currentColor, so they are stored only when that side's width is
12
+ * non-zero. */
13
+ export declare const INITIAL_VALUES: Readonly<Record<string, string>>;
14
+ export declare const MASK_ATTR = "data-jux-masked";
15
+ export declare const MAX_ELEMENTS = 5000;
16
+ /** The subset of a CDP DOMSnapshot.captureSnapshot response we read. */
17
+ export interface CdpSnapshot {
18
+ documents: Array<{
19
+ contentHeight?: number;
20
+ nodes: {
21
+ parentIndex: number[];
22
+ nodeType: number[];
23
+ nodeName: number[];
24
+ nodeValue: number[];
25
+ attributes: number[][];
26
+ /** Rare-string data: parallel arrays of node index → string index.
27
+ * Verified on Chromium 151: pseudo-elements appear as element nodes
28
+ * named `::before`/`::after` with pseudoType set; an <input>'s typed
29
+ * value is in inputValue, not in an attribute. */
30
+ pseudoType?: {
31
+ index: number[];
32
+ value: number[];
33
+ };
34
+ inputValue?: {
35
+ index: number[];
36
+ value: number[];
37
+ };
38
+ };
39
+ layout: {
40
+ nodeIndex: number[];
41
+ styles: number[][];
42
+ bounds: number[][];
43
+ paintOrders?: number[];
44
+ };
45
+ }>;
46
+ strings: string[];
47
+ }
48
+ export declare const round: (n: number) => number;
49
+ /**
50
+ * Turn a raw CDP snapshot into a StructureSnapshot.
51
+ *
52
+ * Only the top document is read: iframe content is a separate document and is
53
+ * represented by the <iframe> element's own rect. Open shadow content is part
54
+ * of the host document's node list and is included.
55
+ */
56
+ export declare function normalizeStructure(raw: CdpSnapshot, viewport: Viewport): StructureSnapshot;
57
+ /** The full value of `prop` for element `index`, reconstructing what the
58
+ * compact storage omitted. */
59
+ export declare function effectiveStyle(s: StructureSnapshot, index: number, prop: string): string;
60
+ export declare function encodeStructure(s: StructureSnapshot): Buffer;
61
+ export declare function decodeStructure(b: Buffer): StructureSnapshot;
62
+ /**
63
+ * Snapshot the page's structure. Never throws: a missing structure costs the
64
+ * agent some explanation, a thrown error would cost the story its capture.
65
+ *
66
+ * Mask selectors are resolved with the same CSS-over-open-shadow-roots query
67
+ * the screenshot masks with; Playwright-only syntax is rejected in
68
+ * captureStory before anything runs. Matches get MASK_ATTR so the normalizer
69
+ * collapses their subtree. A plain document.querySelectorAll would miss
70
+ * matches inside shadow roots and leak masked content into the sidecar.
71
+ *
72
+ * Fail closed: if marking fails, or any match lost its mark by the time the
73
+ * snapshot returns (the page mutated in between), return undefined. Setting an
74
+ * attribute does not affect paint unless a stylesheet targets it, which
75
+ * nothing ships with.
76
+ */
77
+ export declare function captureStructure(page: Page, viewport: Viewport, mask: readonly string[]): Promise<StructureSnapshot | undefined>;
@@ -0,0 +1,498 @@
1
+ // Structure snapshot: the visible DOM and its computed styles, captured in the
2
+ // same frame as the screenshot.
3
+ //
4
+ // Why: pixels say WHERE something changed; this says WHAT — which element,
5
+ // which property, old → new — so an agent can decide without reading an image
6
+ // (spec: docs/specs/2026-10-12-sp0-structural-diff-agent-surface-design.md).
7
+ // It explains a failure; it never decides one. The pixel hash still does.
8
+ //
9
+ // Everything here is a pure function of the CDP response so it can be tested
10
+ // against real Chromium output committed as a fixture.
11
+ import { createHash } from 'node:crypto';
12
+ import { gunzipSync, gzipSync } from 'node:zlib';
13
+ import { queryAllExpression } from '../browser/selectors.js';
14
+ /** Styles recorded per element. `display`/`visibility` are deliberately absent:
15
+ * display:none elements have no layout entry at all, and visibility is read
16
+ * only to filter (see `visibilityIdx` in normalizeStructure). */
17
+ export const STRUCTURE_PROPS = [
18
+ 'color',
19
+ 'background-color',
20
+ 'background-image',
21
+ 'font-family',
22
+ 'font-size',
23
+ 'font-weight',
24
+ 'font-style',
25
+ 'line-height',
26
+ 'letter-spacing',
27
+ 'text-align',
28
+ 'text-decoration-line',
29
+ 'text-transform',
30
+ 'padding-top',
31
+ 'padding-right',
32
+ 'padding-bottom',
33
+ 'padding-left',
34
+ 'margin-top',
35
+ 'margin-right',
36
+ 'margin-bottom',
37
+ 'margin-left',
38
+ 'border-top-width',
39
+ 'border-right-width',
40
+ 'border-bottom-width',
41
+ 'border-left-width',
42
+ 'border-top-color',
43
+ 'border-right-color',
44
+ 'border-bottom-color',
45
+ 'border-left-color',
46
+ 'border-top-left-radius',
47
+ 'border-top-right-radius',
48
+ 'border-bottom-right-radius',
49
+ 'border-bottom-left-radius',
50
+ 'opacity',
51
+ 'transform',
52
+ 'box-shadow',
53
+ 'gap',
54
+ 'z-index',
55
+ // SVG paint is computed style, not an attribute: without these an icon
56
+ // recolour is unexplained pixels.
57
+ 'fill',
58
+ 'stroke',
59
+ 'stroke-width',
60
+ // A pseudo-element's visible text lives here, not in a text node: without it
61
+ // a CSS icon swap (`content: "★"` → `"☆"`) is unexplained pixels.
62
+ 'content',
63
+ ];
64
+ /** Inherited properties are stored only where they differ from the parent. */
65
+ export const INHERITED_PROPS = new Set([
66
+ 'color',
67
+ 'font-family',
68
+ 'font-size',
69
+ 'font-weight',
70
+ 'font-style',
71
+ 'line-height',
72
+ 'letter-spacing',
73
+ 'text-align',
74
+ 'text-transform',
75
+ 'fill',
76
+ 'stroke',
77
+ 'stroke-width',
78
+ ]);
79
+ /** Computed-style initial values for non-inherited properties. A value equal
80
+ * to its initial value is not stored. Border colors are special-cased: they
81
+ * default to currentColor, so they are stored only when that side's width is
82
+ * non-zero. */
83
+ export const INITIAL_VALUES = {
84
+ 'background-color': 'rgba(0, 0, 0, 0)',
85
+ 'background-image': 'none',
86
+ 'text-decoration-line': 'none',
87
+ 'padding-top': '0px',
88
+ 'padding-right': '0px',
89
+ 'padding-bottom': '0px',
90
+ 'padding-left': '0px',
91
+ 'margin-top': '0px',
92
+ 'margin-right': '0px',
93
+ 'margin-bottom': '0px',
94
+ 'margin-left': '0px',
95
+ 'border-top-width': '0px',
96
+ 'border-right-width': '0px',
97
+ 'border-bottom-width': '0px',
98
+ 'border-left-width': '0px',
99
+ 'border-top-left-radius': '0px',
100
+ 'border-top-right-radius': '0px',
101
+ 'border-bottom-right-radius': '0px',
102
+ 'border-bottom-left-radius': '0px',
103
+ opacity: '1',
104
+ transform: 'none',
105
+ 'box-shadow': 'none',
106
+ gap: 'normal',
107
+ 'z-index': 'auto',
108
+ content: 'normal',
109
+ };
110
+ /** Root defaults for inherited props when no kept ancestor exists. */
111
+ const ROOT_INHERITED = {
112
+ color: 'rgb(0, 0, 0)',
113
+ 'font-style': 'normal',
114
+ 'font-weight': '400',
115
+ 'letter-spacing': 'normal',
116
+ 'text-align': 'start',
117
+ 'text-transform': 'none',
118
+ // SVG paint defaults (CSS initial values, as Chromium computes them)
119
+ fill: 'rgb(0, 0, 0)',
120
+ stroke: 'none',
121
+ 'stroke-width': '1px',
122
+ };
123
+ export const MASK_ATTR = 'data-jux-masked';
124
+ export const MAX_ELEMENTS = 5000;
125
+ const ELEMENT_NODE = 1;
126
+ const TEXT_NODE = 3;
127
+ const IMPLICIT_ROLES = {
128
+ button: 'button',
129
+ h1: 'heading',
130
+ h2: 'heading',
131
+ h3: 'heading',
132
+ h4: 'heading',
133
+ h5: 'heading',
134
+ h6: 'heading',
135
+ img: 'img',
136
+ select: 'combobox',
137
+ textarea: 'textbox',
138
+ nav: 'navigation',
139
+ main: 'main',
140
+ header: 'banner',
141
+ footer: 'contentinfo',
142
+ ul: 'list',
143
+ ol: 'list',
144
+ li: 'listitem',
145
+ table: 'table',
146
+ dialog: 'dialog',
147
+ iframe: 'document',
148
+ };
149
+ export const round = (n) => Math.round(n * 100) / 100;
150
+ /**
151
+ * Turn a raw CDP snapshot into a StructureSnapshot.
152
+ *
153
+ * Only the top document is read: iframe content is a separate document and is
154
+ * represented by the <iframe> element's own rect. Open shadow content is part
155
+ * of the host document's node list and is included.
156
+ */
157
+ export function normalizeStructure(raw, viewport) {
158
+ const doc = raw.documents[0];
159
+ const str = (i) => i === undefined || i < 0 ? undefined : raw.strings[i];
160
+ const { nodes, layout } = doc;
161
+ const styleIdx = STRUCTURE_PROPS.map((_, i) => i);
162
+ // `visibility` is requested after STRUCTURE_PROPS and used only to filter.
163
+ const visibilityIdx = STRUCTURE_PROPS.length;
164
+ // node index -> layout row. A node can have several rows (a pseudo-element
165
+ // gets one for its box and one per text fragment); the FIRST is the box.
166
+ const layoutOf = new Map();
167
+ layout.nodeIndex.forEach((n, row) => {
168
+ if (!layoutOf.has(n))
169
+ layoutOf.set(n, row);
170
+ });
171
+ const contentIdx = STRUCTURE_PROPS.indexOf('content');
172
+ const rare = (r) => {
173
+ const m = new Map();
174
+ r?.index.forEach((n, k) => m.set(n, str(r.value[k]) ?? ''));
175
+ return m;
176
+ };
177
+ const inputValue = rare(nodes.inputValue);
178
+ // children lists and own text, in document order
179
+ const children = new Map();
180
+ const ownText = new Map();
181
+ for (let i = 0; i < nodes.nodeType.length; i++) {
182
+ const p = nodes.parentIndex[i];
183
+ if (p < 0)
184
+ continue;
185
+ if (nodes.nodeType[i] === TEXT_NODE) {
186
+ const t = str(nodes.nodeValue[i]) ?? '';
187
+ ownText.set(p, (ownText.get(p) ?? '') + t);
188
+ }
189
+ else if (nodes.nodeType[i] === ELEMENT_NODE) {
190
+ const list = children.get(p) ?? [];
191
+ list.push(i);
192
+ children.set(p, list);
193
+ }
194
+ }
195
+ const attrs = (i) => {
196
+ const out = {};
197
+ const a = nodes.attributes[i] ?? [];
198
+ for (let k = 0; k + 1 < a.length; k += 2)
199
+ out[str(a[k])] = str(a[k + 1]) ?? '';
200
+ return out;
201
+ };
202
+ const elements = [];
203
+ let total = 0;
204
+ // effective inherited values per kept element, for compact storage
205
+ const effective = [];
206
+ const visit = (i, parentPath, keptParent) => {
207
+ const tag = (str(nodes.nodeName[i]) ?? '').toLowerCase();
208
+ if (tag === 'head' || tag === 'script' || tag === 'style')
209
+ return;
210
+ const a = attrs(i);
211
+ const siblings = children.get(nodes.parentIndex[i]) ?? [];
212
+ const sameTag = siblings.filter((s) => (str(nodes.nodeName[s]) ?? '').toLowerCase() === tag);
213
+ const step = a.id !== undefined
214
+ ? `${tag}#${a.id}`
215
+ : a['data-testid'] !== undefined
216
+ ? `${tag}[data-testid="${a['data-testid']}"]`
217
+ : `${tag}:nth-of-type(${sameTag.indexOf(i) + 1})`;
218
+ const path = parentPath ? `${parentPath}>${step}` : step;
219
+ const row = layoutOf.get(i);
220
+ // Mask first, before any check that can descend: a visibility:hidden mask
221
+ // target with a visibility:visible child used to take the hidden branch
222
+ // below and record the child's text, while the screenshot mask covered it.
223
+ if (a[MASK_ATTR] !== undefined) {
224
+ if (row === undefined)
225
+ return; // no box: nothing to stand in for
226
+ total++;
227
+ if (elements.length < MAX_ELEMENTS) {
228
+ const [x, y, w, h] = layout.bounds[row];
229
+ elements.push({
230
+ path,
231
+ parent: keptParent,
232
+ tag: 'masked',
233
+ rect: { x: round(x), y: round(y), w: round(w), h: round(h) },
234
+ paintOrder: layout.paintOrders?.[row] ?? 0,
235
+ styles: {},
236
+ });
237
+ effective.push(keptParent === null ? { ...ROOT_INHERITED } : effective[keptParent]);
238
+ }
239
+ return; // never descend into a masked subtree
240
+ }
241
+ if (row === undefined)
242
+ return; // display:none (or no box): drop the subtree
243
+ const values = layout.styles[row].map((s) => urlsToFileNames(str(s) ?? ''));
244
+ if (values[visibilityIdx] === 'hidden' || values[visibilityIdx] === 'collapse') {
245
+ // hidden boxes can still contain visible descendants (visibility: visible)
246
+ for (const c of children.get(i) ?? [])
247
+ visit(c, path, keptParent);
248
+ return;
249
+ }
250
+ const [x, y, w, h] = layout.bounds[row];
251
+ const rect = { x: round(x), y: round(y), w: round(w), h: round(h) };
252
+ const offscreen = rect.x + rect.w <= 0 ||
253
+ rect.y + rect.h <= 0 ||
254
+ rect.x >= viewport.width ||
255
+ rect.y >= viewport.height;
256
+ const empty = rect.w <= 0 || rect.h <= 0;
257
+ const keep = !offscreen && !empty && tag !== 'html' && tag !== 'body';
258
+ let me = keptParent;
259
+ if (keep) {
260
+ total++;
261
+ if (elements.length < MAX_ELEMENTS) {
262
+ const inherited = keptParent === null ? ROOT_INHERITED : effective[keptParent];
263
+ const styles = {};
264
+ const eff = { ...inherited };
265
+ for (const k of styleIdx) {
266
+ const prop = STRUCTURE_PROPS[k];
267
+ const v = values[k];
268
+ if (INHERITED_PROPS.has(prop)) {
269
+ eff[prop] = v;
270
+ if (inherited[prop] !== v)
271
+ styles[prop] = v;
272
+ }
273
+ else if (prop.startsWith('border-') && prop.endsWith('-color')) {
274
+ const side = prop.split('-')[1];
275
+ const width = values[STRUCTURE_PROPS.indexOf(`border-${side}-width`)];
276
+ if (width !== '0px')
277
+ styles[prop] = v;
278
+ }
279
+ else if (INITIAL_VALUES[prop] !== v) {
280
+ styles[prop] = v;
281
+ }
282
+ }
283
+ // `content` is meaningful only on a pseudo-element, where it IS the
284
+ // text; on a normal element it is always `normal`, so drop it there.
285
+ const isPseudo = tag.startsWith('::');
286
+ if (!isPseudo)
287
+ delete styles.content;
288
+ const pseudoText = isPseudo ? parseContent(values[contentIdx]) : '';
289
+ // Sidecars are committed to git: never record a secret field's value.
290
+ const secret = tag === 'input' && isSecretInput(a);
291
+ const typed = secret ? undefined : inputValue.get(i);
292
+ const text = collapse(pseudoText || typed || ownText.get(i) || '');
293
+ const role = a.role ?? implicitRole(tag, a);
294
+ const name = (a['aria-label'] ?? a.alt ?? a.title ?? text) || undefined;
295
+ const el = {
296
+ path,
297
+ parent: keptParent,
298
+ tag,
299
+ rect,
300
+ paintOrder: layout.paintOrders?.[row] ?? 0,
301
+ styles,
302
+ };
303
+ if (a.id !== undefined)
304
+ el.id = a.id;
305
+ if (a['data-testid'] !== undefined)
306
+ el.testId = a['data-testid'];
307
+ const classes = (a.class ?? '').split(/\s+/).filter(Boolean);
308
+ if (classes.length)
309
+ el.classes = classes;
310
+ if (role)
311
+ el.role = role;
312
+ if (name)
313
+ el.name = name.slice(0, 80);
314
+ if (text)
315
+ el.text = text.slice(0, 200);
316
+ if (a.src !== undefined)
317
+ el.src = fileName(a.src);
318
+ elements.push(el);
319
+ effective.push(eff);
320
+ me = elements.length - 1;
321
+ }
322
+ }
323
+ for (const c of children.get(i) ?? [])
324
+ visit(c, path, me);
325
+ };
326
+ // node 0 is #document; start from its element children (<html>)
327
+ for (const c of children.get(0) ?? [])
328
+ visit(c, '', null);
329
+ const snapshot = {
330
+ schema: 'juxtapose.structure/v1',
331
+ viewport,
332
+ structureHash: hashElements(elements),
333
+ contentHeight: round(doc.contentHeight ?? viewport.height),
334
+ elements,
335
+ };
336
+ if (total > elements.length)
337
+ snapshot.truncated = { kept: elements.length, total };
338
+ return snapshot;
339
+ }
340
+ /** Autofill tokens that name a secret: a typed OTP, password or card number
341
+ * in a `type=text` field is as sensitive as one in `type=password`. */
342
+ const SECRET_AUTOCOMPLETE = new Set([
343
+ 'one-time-code',
344
+ 'current-password',
345
+ 'new-password',
346
+ ]);
347
+ function isSecretInput(a) {
348
+ if (['password', 'hidden'].includes((a.type ?? '').toLowerCase()))
349
+ return true;
350
+ return (a.autocomplete ?? '')
351
+ .toLowerCase()
352
+ .split(/\s+/)
353
+ .some((t) => SECRET_AUTOCOMPLETE.has(t) || t.startsWith('cc-'));
354
+ }
355
+ function implicitRole(tag, a) {
356
+ if (tag === 'a')
357
+ return a.href !== undefined ? 'link' : undefined;
358
+ if (tag === 'input') {
359
+ const t = (a.type ?? 'text').toLowerCase();
360
+ return t === 'checkbox' || t === 'radio'
361
+ ? t
362
+ : t === 'button' || t === 'submit'
363
+ ? 'button'
364
+ : 'textbox';
365
+ }
366
+ return IMPLICIT_ROLES[tag];
367
+ }
368
+ /** The filename of a URL: no origin, path, query or hash. The origin changes
369
+ * every run (`jux run` serves on port 0) and between machines, so recording
370
+ * it would make the same page hash differently each time. */
371
+ function fileName(u) {
372
+ // A data: URI's "filename" is a slice of its payload: opaque, and as long as
373
+ // the payload happens to be between slashes.
374
+ if (/^data:/i.test(u.trim()))
375
+ return 'data:';
376
+ return u.split(/[?#]/)[0].split('/').pop() ?? '';
377
+ }
378
+ /** Computed values serialize url() tokens absolute: probe2 recorded
379
+ * `url("http://127.0.0.1:41001/bg.png")` vs `...:41002/bg.png` for one page
380
+ * on two ports, and two different structureHashes. Keep only the filename. */
381
+ function urlsToFileNames(v) {
382
+ if (!v.includes('url('))
383
+ return v;
384
+ return v.replace(/url\((?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([^)]*))\)/g, (_m, dq, sq, bare) => `url(${JSON.stringify(fileName((dq ?? sq ?? bare ?? '').trim()))})`);
385
+ }
386
+ const collapse = (s) => s.replace(/\s+/g, ' ').trim();
387
+ /** Computed `content` is a CSS string like `"★"` (quoted), `none`, `normal`,
388
+ * or something like `counter(x)`. Return the visible literal text when there
389
+ * is one; otherwise the raw value, so a change is still detected. */
390
+ function parseContent(v) {
391
+ if (!v || v === 'none' || v === 'normal')
392
+ return '';
393
+ const m = /^"(.*)"$/.exec(v);
394
+ return m ? m[1] : v;
395
+ }
396
+ /** The full value of `prop` for element `index`, reconstructing what the
397
+ * compact storage omitted. */
398
+ export function effectiveStyle(s, index, prop) {
399
+ const el = s.elements[index];
400
+ if (el.styles[prop] !== undefined)
401
+ return el.styles[prop];
402
+ if (INHERITED_PROPS.has(prop)) {
403
+ return el.parent === null
404
+ ? (ROOT_INHERITED[prop] ?? '')
405
+ : effectiveStyle(s, el.parent, prop);
406
+ }
407
+ if (prop.startsWith('border-') && prop.endsWith('-color')) {
408
+ return effectiveStyle(s, index, 'color'); // currentColor
409
+ }
410
+ return INITIAL_VALUES[prop] ?? '';
411
+ }
412
+ /** Canonical JSON: object keys sorted at every level, so hashing is stable. */
413
+ function canonical(v) {
414
+ if (Array.isArray(v))
415
+ return `[${v.map(canonical).join(',')}]`;
416
+ if (v && typeof v === 'object') {
417
+ const o = v;
418
+ return `{${Object.keys(o)
419
+ .sort()
420
+ .filter((k) => o[k] !== undefined)
421
+ .map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`)
422
+ .join(',')}}`;
423
+ }
424
+ return JSON.stringify(v);
425
+ }
426
+ function hashElements(elements) {
427
+ return createHash('sha256').update(canonical(elements)).digest('hex');
428
+ }
429
+ export function encodeStructure(s) {
430
+ return gzipSync(Buffer.from(JSON.stringify(s)));
431
+ }
432
+ export function decodeStructure(b) {
433
+ return JSON.parse(gunzipSync(b).toString('utf8'));
434
+ }
435
+ /**
436
+ * Snapshot the page's structure. Never throws: a missing structure costs the
437
+ * agent some explanation, a thrown error would cost the story its capture.
438
+ *
439
+ * Mask selectors are resolved with the same CSS-over-open-shadow-roots query
440
+ * the screenshot masks with; Playwright-only syntax is rejected in
441
+ * captureStory before anything runs. Matches get MASK_ATTR so the normalizer
442
+ * collapses their subtree. A plain document.querySelectorAll would miss
443
+ * matches inside shadow roots and leak masked content into the sidecar.
444
+ *
445
+ * Fail closed: if marking fails, or any match lost its mark by the time the
446
+ * snapshot returns (the page mutated in between), return undefined. Setting an
447
+ * attribute does not affect paint unless a stylesheet targets it, which
448
+ * nothing ships with.
449
+ */
450
+ export async function captureStructure(page, viewport, mask) {
451
+ const mark = (sel) => page.evaluate(queryAllExpression(sel, "for (const el of els) el.setAttribute(a0, '1'); return els.length;", MASK_ATTR));
452
+ const verify = (sel) => page.evaluate(queryAllExpression(sel, 'return { total: els.length, marked: els.filter((e) => e.hasAttribute(a0)).length };', MASK_ATTR));
453
+ const unmark = () => page
454
+ .evaluate(queryAllExpression(`[${MASK_ATTR}]`, 'for (const el of els) el.removeAttribute(a0);', MASK_ATTR))
455
+ .catch(() => { });
456
+ // How many elements each selector marked. Verification re-resolves the
457
+ // selector, so an element that lost both its mark and its match would drop
458
+ // out of the re-resolved list and `every` would pass on the survivors
459
+ // (probe c: SECRET-C recorded). Comparing counts closes that.
460
+ const markedCounts = [];
461
+ try {
462
+ for (const sel of mask) {
463
+ const n = await mark(sel);
464
+ markedCounts.push(typeof n === 'number' ? n : 0);
465
+ }
466
+ }
467
+ catch {
468
+ if (mask.length)
469
+ await unmark();
470
+ return undefined;
471
+ }
472
+ try {
473
+ const raw = (await page.send('DOMSnapshot.captureSnapshot', {
474
+ computedStyles: [...STRUCTURE_PROPS, 'visibility'],
475
+ includeDOMRects: true,
476
+ includePaintOrder: true,
477
+ }));
478
+ for (const [k, sel] of mask.entries()) {
479
+ const v = (await verify(sel));
480
+ // Every current match still marked, and none of the marked ones gone.
481
+ const intact = typeof v === 'object' &&
482
+ v !== null &&
483
+ v.marked === v.total &&
484
+ v.marked >= markedCounts[k];
485
+ if (!intact)
486
+ return undefined;
487
+ }
488
+ return normalizeStructure(raw, viewport);
489
+ }
490
+ catch {
491
+ return undefined;
492
+ }
493
+ finally {
494
+ if (mask.length)
495
+ await unmark();
496
+ }
497
+ }
498
+ //# sourceMappingURL=structure.js.map