@michaelmagan/dnumb 0.1.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 (133) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/analysis/cluster.cjs +114 -0
  5. package/dist/analysis/cluster.d.cts +68 -0
  6. package/dist/analysis/cluster.d.ts +68 -0
  7. package/dist/analysis/cluster.js +109 -0
  8. package/dist/analysis/distances.cjs +124 -0
  9. package/dist/analysis/distances.d.cts +58 -0
  10. package/dist/analysis/distances.d.ts +58 -0
  11. package/dist/analysis/distances.js +119 -0
  12. package/dist/analysis/geometry.cjs +60 -0
  13. package/dist/analysis/geometry.d.cts +34 -0
  14. package/dist/analysis/geometry.d.ts +34 -0
  15. package/dist/analysis/geometry.js +53 -0
  16. package/dist/analysis/layout-tree.cjs +245 -0
  17. package/dist/analysis/layout-tree.d.cts +45 -0
  18. package/dist/analysis/layout-tree.d.ts +45 -0
  19. package/dist/analysis/layout-tree.js +242 -0
  20. package/dist/analysis/region-query.cjs +71 -0
  21. package/dist/analysis/region-query.d.cts +25 -0
  22. package/dist/analysis/region-query.d.ts +25 -0
  23. package/dist/analysis/region-query.js +67 -0
  24. package/dist/analysis/types.cjs +7 -0
  25. package/dist/analysis/types.d.cts +143 -0
  26. package/dist/analysis/types.d.ts +143 -0
  27. package/dist/analysis/types.js +6 -0
  28. package/dist/bundle.cjs +182 -0
  29. package/dist/bundle.d.cts +91 -0
  30. package/dist/bundle.d.ts +91 -0
  31. package/dist/bundle.js +175 -0
  32. package/dist/cli.cjs +755 -0
  33. package/dist/cli.d.cts +2 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +719 -0
  36. package/dist/diff/changes.cjs +656 -0
  37. package/dist/diff/changes.d.cts +2 -0
  38. package/dist/diff/changes.d.ts +2 -0
  39. package/dist/diff/changes.js +653 -0
  40. package/dist/diff/identity.cjs +167 -0
  41. package/dist/diff/identity.d.cts +62 -0
  42. package/dist/diff/identity.d.ts +62 -0
  43. package/dist/diff/identity.js +159 -0
  44. package/dist/diff/index.cjs +84 -0
  45. package/dist/diff/index.d.cts +76 -0
  46. package/dist/diff/index.d.ts +76 -0
  47. package/dist/diff/index.js +68 -0
  48. package/dist/diff/match.cjs +156 -0
  49. package/dist/diff/match.d.cts +17 -0
  50. package/dist/diff/match.d.ts +17 -0
  51. package/dist/diff/match.js +152 -0
  52. package/dist/diff/render.cjs +540 -0
  53. package/dist/diff/render.d.cts +2 -0
  54. package/dist/diff/render.d.ts +2 -0
  55. package/dist/diff/render.js +537 -0
  56. package/dist/diff/types.cjs +2 -0
  57. package/dist/diff/types.d.cts +211 -0
  58. package/dist/diff/types.d.ts +211 -0
  59. package/dist/diff/types.js +1 -0
  60. package/dist/digest/index.cjs +8 -0
  61. package/dist/digest/index.d.cts +4 -0
  62. package/dist/digest/index.d.ts +4 -0
  63. package/dist/digest/index.js +2 -0
  64. package/dist/digest/parse.cjs +163 -0
  65. package/dist/digest/parse.d.cts +80 -0
  66. package/dist/digest/parse.d.ts +80 -0
  67. package/dist/digest/parse.js +160 -0
  68. package/dist/digest/render.cjs +1575 -0
  69. package/dist/digest/render.d.cts +41 -0
  70. package/dist/digest/render.d.ts +41 -0
  71. package/dist/digest/render.js +1571 -0
  72. package/dist/digest/types.cjs +10 -0
  73. package/dist/digest/types.d.cts +86 -0
  74. package/dist/digest/types.d.ts +86 -0
  75. package/dist/digest/types.js +9 -0
  76. package/dist/index.cjs +61 -0
  77. package/dist/index.d.cts +27 -0
  78. package/dist/index.d.ts +27 -0
  79. package/dist/index.js +19 -0
  80. package/dist/page.cjs +35 -0
  81. package/dist/page.d.cts +40 -0
  82. package/dist/page.d.ts +40 -0
  83. package/dist/page.js +31 -0
  84. package/dist/playwright.cjs +138 -0
  85. package/dist/playwright.d.cts +69 -0
  86. package/dist/playwright.d.ts +69 -0
  87. package/dist/playwright.js +131 -0
  88. package/dist/query.cjs +1152 -0
  89. package/dist/query.d.cts +47 -0
  90. package/dist/query.d.ts +47 -0
  91. package/dist/query.js +1146 -0
  92. package/dist/snapshot/capture.cjs +80 -0
  93. package/dist/snapshot/capture.d.cts +34 -0
  94. package/dist/snapshot/capture.d.ts +34 -0
  95. package/dist/snapshot/capture.js +77 -0
  96. package/dist/snapshot/cdp.cjs +450 -0
  97. package/dist/snapshot/cdp.d.cts +36 -0
  98. package/dist/snapshot/cdp.d.ts +36 -0
  99. package/dist/snapshot/cdp.js +447 -0
  100. package/dist/snapshot/clip.cjs +100 -0
  101. package/dist/snapshot/clip.d.cts +26 -0
  102. package/dist/snapshot/clip.d.ts +26 -0
  103. package/dist/snapshot/clip.js +96 -0
  104. package/dist/snapshot/collect.cjs +358 -0
  105. package/dist/snapshot/collect.d.cts +18 -0
  106. package/dist/snapshot/collect.d.ts +18 -0
  107. package/dist/snapshot/collect.js +355 -0
  108. package/dist/snapshot/color.cjs +1039 -0
  109. package/dist/snapshot/color.d.cts +316 -0
  110. package/dist/snapshot/color.d.ts +316 -0
  111. package/dist/snapshot/color.js +1026 -0
  112. package/dist/snapshot/facts.cjs +167 -0
  113. package/dist/snapshot/facts.d.cts +48 -0
  114. package/dist/snapshot/facts.d.ts +48 -0
  115. package/dist/snapshot/facts.js +161 -0
  116. package/dist/snapshot/limits.cjs +69 -0
  117. package/dist/snapshot/limits.d.cts +59 -0
  118. package/dist/snapshot/limits.d.ts +59 -0
  119. package/dist/snapshot/limits.js +65 -0
  120. package/dist/snapshot/not-measured.cjs +44 -0
  121. package/dist/snapshot/not-measured.d.cts +14 -0
  122. package/dist/snapshot/not-measured.d.ts +14 -0
  123. package/dist/snapshot/not-measured.js +41 -0
  124. package/dist/snapshot/types.cjs +2 -0
  125. package/dist/snapshot/types.d.cts +238 -0
  126. package/dist/snapshot/types.d.ts +238 -0
  127. package/dist/snapshot/types.js +1 -0
  128. package/dist/spec/index.cjs +9 -0
  129. package/dist/spec/index.d.cts +15 -0
  130. package/dist/spec/index.d.ts +15 -0
  131. package/dist/spec/index.js +6 -0
  132. package/package.json +141 -0
  133. package/skills/dnumb/SKILL.md +136 -0
package/dist/query.cjs ADDED
@@ -0,0 +1,1152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QueryError = exports.Q_VERBS = void 0;
4
+ exports.look = look;
5
+ exports.query = query;
6
+ /**
7
+ * The offline verbs.
8
+ *
9
+ * Pure functions over a `Bundle` — no filesystem, no browser, no clock. That is
10
+ * what makes `dnumb --from capture.json q describe e91` answerable six months
11
+ * later on a machine with neither the app nor a browser, which is the entire
12
+ * claim the capture bundle makes.
13
+ *
14
+ * Nothing here adjudicates. Every verb reports the measurement and the adjacent
15
+ * facts, in the same register as `snapshot/facts.ts`: no severity, no
16
+ * thresholds, no "problem". A rule engine standing between the measurement and
17
+ * the model is what killed the predecessor to this tool.
18
+ *
19
+ * Each verb returns both renderings at once. They are built from one pass over
20
+ * one context, so `--json` can never report something the text does not — the
21
+ * alternative is a CLI that re-derives meaning from a structure and the two
22
+ * outputs drifting apart on the verb nobody reads twice.
23
+ */
24
+ const distances_js_1 = require("./analysis/distances.cjs");
25
+ const layout_tree_js_1 = require("./analysis/layout-tree.cjs");
26
+ const region_query_js_1 = require("./analysis/region-query.cjs");
27
+ const index_js_1 = require("./digest/index.cjs");
28
+ const color_js_1 = require("./snapshot/color.cjs");
29
+ const facts_js_1 = require("./snapshot/facts.cjs");
30
+ const not_measured_js_1 = require("./snapshot/not-measured.cjs");
31
+ exports.Q_VERBS = [
32
+ 'describe',
33
+ 'find',
34
+ 'at',
35
+ 'within',
36
+ 'distance',
37
+ 'nearest',
38
+ 'facts',
39
+ 'color',
40
+ ];
41
+ /**
42
+ * A verb could not answer the question as asked. The message is the whole
43
+ * product: it names what was wrong and the command that fixes it, because an
44
+ * agent that gets `no element "e99"` and no route forward will guess.
45
+ */
46
+ class QueryError extends Error {
47
+ name = 'QueryError';
48
+ }
49
+ exports.QueryError = QueryError;
50
+ const DEFAULT_LIMIT = 50;
51
+ const DEFAULT_K = 4;
52
+ const TEXT_TRUNCATE = 30;
53
+ const round = (n) => Math.round(n);
54
+ /** Integers stay integers; a distance keeps one decimal and no more. */
55
+ const num = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(1));
56
+ const sides = (b) => `${num(b.top)} ${num(b.right)} ${num(b.bottom)} ${num(b.left)}`;
57
+ function context(bundle) {
58
+ const byId = new Map(bundle.elements.map((el) => [el.id, el]));
59
+ const facts = new Map();
60
+ for (const el of bundle.elements) {
61
+ const found = (0, facts_js_1.factsFor)(el, byId);
62
+ if (found.length > 0)
63
+ facts.set(el.id, found);
64
+ }
65
+ // Same `keepIds` the digest passes, for the same reason: the `0x1239` column
66
+ // the facts model exists to surface has no visible box either, and a point
67
+ // query that silently cannot hit it is a point query that lies.
68
+ const { tree, dropped } = (0, layout_tree_js_1.buildLayoutTree)(bundle.elements, { keepIds: new Set(facts.keys()) });
69
+ return {
70
+ bundle,
71
+ byId,
72
+ facts,
73
+ tree,
74
+ notInTree: dropped.invisible + dropped.svgInternal + dropped.wrappers,
75
+ };
76
+ }
77
+ /**
78
+ * One record as a digest line.
79
+ *
80
+ * `digest/render.ts` builds its lines from `LayoutNode`s inside a tree walk and
81
+ * does not export the piece that formats one, so this is the flat, record-shaped
82
+ * twin rather than a second grammar: same order, same tokens, no flow or gap
83
+ * (both are properties of a node's position among its siblings, which a match
84
+ * list has thrown away).
85
+ */
86
+ function digestLine(el, facts, suffix = '') {
87
+ let head = `${el.id} ${el.tag}`;
88
+ if (el.testId)
89
+ head += `#${el.testId}`;
90
+ else if (el.role)
91
+ head += `.${el.role}`;
92
+ const label = el.name || el.text;
93
+ if (label) {
94
+ head += ` "${label.length > TEXT_TRUNCATE ? `${label.slice(0, TEXT_TRUNCATE)}…` : label}"`;
95
+ }
96
+ const geometry = `@${round(el.rect.x)},${round(el.rect.y)} ${round(el.rect.width)}x${round(el.rect.height)}`;
97
+ return [head, geometry, suffix, ...facts.map((f) => `[${f}]`)].filter(Boolean).join(' ');
98
+ }
99
+ const factTexts = (ctx, id) => (ctx.facts.get(id) ?? []).map((f) => f.text);
100
+ function nodeLine(ctx, node, suffix = '') {
101
+ const el = ctx.byId.get(node.elementId);
102
+ if (el)
103
+ return digestLine(el, factTexts(ctx, node.elementId), suffix);
104
+ const geometry = `@${round(node.rect.x)},${round(node.rect.y)} ${round(node.rect.width)}x${round(node.rect.height)}`;
105
+ return [node.elementId, node.tag, geometry, suffix].filter(Boolean).join(' ');
106
+ }
107
+ function nodeMatch(ctx, node) {
108
+ const el = ctx.byId.get(node.elementId);
109
+ return {
110
+ id: node.elementId,
111
+ tag: node.tag,
112
+ kind: node.kind,
113
+ rect: node.rect,
114
+ ...(el?.role ? { role: el.role } : {}),
115
+ ...(el?.name ? { name: el.name } : {}),
116
+ ...(el?.text ? { text: el.text } : {}),
117
+ facts: factTexts(ctx, node.elementId),
118
+ };
119
+ }
120
+ function findNode(nodes, match) {
121
+ for (const node of nodes) {
122
+ if (match(node))
123
+ return node;
124
+ const inChild = findNode(node.children, match);
125
+ if (inChild)
126
+ return inChild;
127
+ }
128
+ return null;
129
+ }
130
+ /** The trailing digits of `e13`, or `NaN` for an id with no ordinal in it. */
131
+ const ordinal = (id) => Number.parseInt(id.replace(/^\D+/, ''), 10);
132
+ /** Ids a typo of `id` most plausibly meant: prefix relatives first, then ordinal neighbours. */
133
+ function nearbyIds(ctx, id) {
134
+ const ids = ctx.bundle.elements.map((el) => el.id);
135
+ const related = ids.filter((x) => x.startsWith(id) || id.startsWith(x));
136
+ if (related.length > 0)
137
+ return related.slice(0, 5);
138
+ const target = ordinal(id);
139
+ if (!Number.isFinite(target))
140
+ return ids.slice(0, 5);
141
+ // `|| Number.POSITIVE_INFINITY` used to be here to push an id with no
142
+ // ordinal (NaN) to the back — but `0 || Number.POSITIVE_INFINITY` is also
143
+ // `Number.POSITIVE_INFINITY`, since 0 is falsy. That sorted the one id at
144
+ // distance zero — the exact ordinal match, e.g. `e20` when the typo was
145
+ // `x20` — to the very back, where `.slice(0, 5)` always dropped it. Only
146
+ // NaN (no ordinal at all) should fall back to infinity; a real distance of
147
+ // zero is the closest possible match, not the furthest.
148
+ const away = (x) => {
149
+ const diff = Math.abs(ordinal(x) - target);
150
+ return Number.isNaN(diff) ? Number.POSITIVE_INFINITY : diff;
151
+ };
152
+ return [...ids]
153
+ .sort((a, b) => away(a) - away(b))
154
+ .slice(0, 5)
155
+ .sort();
156
+ }
157
+ function requireElement(ctx, id) {
158
+ const el = ctx.byId.get(id);
159
+ if (el)
160
+ return el;
161
+ const near = nearbyIds(ctx, id);
162
+ const notMeasured = coverageCaveat(ctx);
163
+ throw new QueryError(`no element "${id}" in bundle "${ctx.bundle.name}" (${ctx.bundle.elements.length} records).` +
164
+ (near.length > 0 ? ` Nearby ids: ${near.join(', ')}.` : '') +
165
+ // An id that looks missing may not be missing from the page -- it may be
166
+ // inside a shadow root or an iframe this capture never entered, which
167
+ // looks identical to "does not exist" unless said out loud here.
168
+ (notMeasured ? ` This capture also has ${notMeasured} it could not see into.` : '') +
169
+ // Same reasoning as `notMeasured` above: a missing id may just be past
170
+ // where a truncated capture stopped, which looks identical to "never
171
+ // existed" unless this says so. Without this, "no element e5001 in
172
+ // bundle (5000 records)" reads as "the page has 5000 elements", when
173
+ // 5000 is only where this capture gave up.
174
+ (ctx.bundle.truncated
175
+ ? ` This capture is also truncated — it stopped at the element cap, so the page has more ` +
176
+ `than ${ctx.bundle.elements.length} elements and "${id}" may exist past where it stopped.`
177
+ : '') +
178
+ ' Ids are stable within one capture only — `q find <text>` searches by text, tag or role.');
179
+ }
180
+ /**
181
+ * Every list verb below counts against `ctx.bundle.elements.length` — "279 of
182
+ * 5000 records match". On a truncated bundle that reads as "5000 is the whole
183
+ * page", when 5000 is only where this capture gave up (`look`'s own
184
+ * `# TRUNCATED:` header line, `digest/render.ts`). Same disclosure
185
+ * `notMeasuredNote` makes for shadow DOM and iframes, for the same reason: a
186
+ * bound this file did not choose must never read as a claim of completeness.
187
+ */
188
+ const truncatedNote = (ctx) => ctx.bundle.truncated
189
+ ? [
190
+ `# TRUNCATED: this capture stopped at the element cap; the page has more than ` +
191
+ `${ctx.bundle.elements.length} elements, so the counts above cover only what was captured`,
192
+ ]
193
+ : [];
194
+ /** A positional that has to be a coordinate, with the verb's own usage in the complaint. */
195
+ function coordinate(raw, verb, usage) {
196
+ const value = raw === undefined ? Number.NaN : Number.parseFloat(raw);
197
+ if (!Number.isFinite(value)) {
198
+ throw new QueryError(`${verb} needs numbers: ${usage}. Got "${raw ?? ''}".`);
199
+ }
200
+ return value;
201
+ }
202
+ /** Applied to both renderings, never to just one — a cap the JSON does not report is a lie. */
203
+ function cap(items, limit) {
204
+ if (limit <= 0 || items.length <= limit)
205
+ return { shown: items, hidden: 0 };
206
+ return { shown: items.slice(0, limit), hidden: items.length - limit };
207
+ }
208
+ const HIDDEN_NOTE = (hidden) => hidden > 0 ? [`…+${hidden} not listed (--limit 0 for all)`] : [];
209
+ /* -------------------------------------------------------------------------- */
210
+ /** `look` — the digest, which is the format the whole package exists to print. */
211
+ function look(bundle, options = {}) {
212
+ const ctx = context(bundle);
213
+ if (options.focus !== undefined) {
214
+ // `renderDigest` answers an unknown focus with an empty body, which reads
215
+ // exactly like a page that has nothing in it.
216
+ const key = options.focus;
217
+ if (!findNode(ctx.tree, (n) => n.elementId === key || n.tag === key)) {
218
+ throw new QueryError(`--focus "${key}" matches no layout node in bundle "${bundle.name}".` +
219
+ ` Nearby ids: ${nearbyIds(ctx, key).join(', ')}. --focus takes an element id or a tag name.`);
220
+ }
221
+ }
222
+ const text = (0, index_js_1.renderDigest)({
223
+ elements: bundle.elements,
224
+ truncated: bundle.truncated,
225
+ viewport: bundle.viewport,
226
+ notMeasured: bundle.notMeasured,
227
+ }, {
228
+ url: bundle.url,
229
+ viewport: bundle.viewport,
230
+ state: bundle.name,
231
+ capturedAt: bundle.capturedAt,
232
+ strategy: bundle.strategy,
233
+ ...(bundle.cdpFellBack ? { cdpFellBack: true } : {}),
234
+ }, {
235
+ ...(options.focus !== undefined ? { focus: options.focus } : {}),
236
+ ...(options.edges ? { edges: true } : {}),
237
+ ...(options.budgetChars !== undefined ? { budgetChars: options.budgetChars } : {}),
238
+ ...(options.budgetLines !== undefined ? { budgetLines: options.budgetLines } : {}),
239
+ }).trimEnd();
240
+ return {
241
+ text,
242
+ json: {
243
+ verb: 'look',
244
+ bundle: {
245
+ name: bundle.name,
246
+ url: bundle.url,
247
+ capturedAt: bundle.capturedAt,
248
+ viewport: bundle.viewport,
249
+ strategy: bundle.strategy,
250
+ ...(bundle.cdpFellBack ? { cdpFellBack: true } : {}),
251
+ truncated: bundle.truncated,
252
+ records: bundle.elements.length,
253
+ notMeasured: bundle.notMeasured,
254
+ },
255
+ digest: text,
256
+ },
257
+ };
258
+ }
259
+ /**
260
+ * `describe` used to print `color <fg> on <bg>` unconditionally, so a
261
+ * transparent element printed `color rgb(102,102,102) on rgba(0, 0, 0, 0)`.
262
+ * The word "on" asserts a relationship nothing here measured: text with a
263
+ * transparent background is drawn on whatever is actually behind it — an
264
+ * ancestor's background, an image, a gradient, or an unrelated element.
265
+ *
266
+ * That relationship is now a real compositing walk
267
+ * (`snapshot/color.ts`, per `docs/notes/03-color.md` §3-4), and this is the
268
+ * §4 grammar line in place of the old sentence — but only where the
269
+ * measurement gate applies (visible, with its own text; §4's "no text, no
270
+ * ink-on-backdrop question to answer").
271
+ *
272
+ * `colorFactFor` has exactly two early-outs, both the same "visible, with
273
+ * its own text" gate `q color`/`docs/digest.md` §4 states, never a third
274
+ * reason: `!el.visible` or `!el.text` (see `snapshot/color.ts#colorFactFor`).
275
+ * So when it returns `null`, this element was never eligible for a color
276
+ * fact at all — the backdrop compositing walk was never run, not run and
277
+ * inconclusive.
278
+ *
279
+ * P1-2: this used to print "(transparent — what this text is actually drawn
280
+ * on was not measured)" for the transparent-background case, which is the
281
+ * same wording the genuine `gradient`/`image`/`under`/`behind` refusals use
282
+ * for a compositing walk that *was* attempted and came back unmeasured —
283
+ * implying an attempt that failed, on an element the gate never let one
284
+ * start for. It also said "this text" unconditionally, which is false on
285
+ * exactly the element this branch exists to describe: one with no own text
286
+ * at all. `q color <id>` on the same element already states the true reason
287
+ * (`no color fact: this element has no own direct text (the gate is
288
+ * "visible, with its own text")`); this says the same thing, in the same
289
+ * words, rather than a second, contradictory account of why nothing is
290
+ * here.
291
+ */
292
+ function paintValue(el, byId, elements, canvas) {
293
+ const fact = (0, color_js_1.colorFactFor)(el, byId, elements, canvas);
294
+ if (fact)
295
+ return (0, color_js_1.renderColorFact)(fact);
296
+ const reason = !el.visible ? 'is not visible' : 'has no own direct text';
297
+ const base = `color ${el.color}; background-color ${el.backgroundColor} ` +
298
+ `(no color fact: this element ${reason} — the gate is "visible, with its own text")`;
299
+ // The color-fact grammar folds size/weight/opacity into its own line; the
300
+ // fallback (gate not met) still owes a reader those, so it states them
301
+ // itself.
302
+ return `${base} · ${el.fontSize}/${el.fontWeight}${el.opacity < 1 ? ` · opacity ${num(el.opacity)}` : ''}`;
303
+ }
304
+ /** `q describe <id>` — everything the capture knows about one element. */
305
+ function describe(ctx, args) {
306
+ const id = args[0];
307
+ if (!id)
308
+ throw new QueryError('describe needs an element id: `q describe e12`.');
309
+ const el = requireElement(ctx, id);
310
+ const facts = ctx.facts.get(id) ?? [];
311
+ const rows = [
312
+ [
313
+ 'box',
314
+ `@${num(el.rect.x)},${num(el.rect.y)} ${num(el.rect.width)}x${num(el.rect.height)} · content ${num(el.clientWidth)}x${num(el.clientHeight)} · scroll ${num(el.scrollWidth)}x${num(el.scrollHeight)}`,
315
+ ],
316
+ [
317
+ 'spacing',
318
+ `margin ${sides(el.margin)} · padding ${sides(el.padding)} · border ${sides(el.border)} · gap ${num(el.gap.row)}/${num(el.gap.column)}`,
319
+ ],
320
+ [
321
+ 'layout',
322
+ `display:${el.display} position:${el.position} flex-direction:${el.flexDirection} grid-template-columns:${el.gridTemplateColumns}`,
323
+ ],
324
+ [
325
+ 'overflow',
326
+ `x:${el.overflowX} y:${el.overflowY} · text-overflow:${el.textOverflow} white-space:${el.whiteSpace} overflow-wrap:${el.overflowWrap}`,
327
+ ],
328
+ [
329
+ 'paint',
330
+ `${paintValue(el, ctx.byId, ctx.bundle.elements, ctx.bundle.documentBackground)} · text-align:${el.textAlign} visibility:${el.visibility} visible=${el.visible}`,
331
+ ],
332
+ [
333
+ 'a11y',
334
+ `role=${el.role ?? '—'} name=${el.name ? `"${el.name}"` : '—'} heading-level=${el.headingLevel ?? '—'} aria-expanded=${el.ariaExpanded ?? '—'}`,
335
+ ],
336
+ ['ids', `testid=${el.testId ?? '—'} dom-id=${el.domId ?? '—'} class=${el.className || '—'}`],
337
+ ['text', el.text ? `"${el.text}"` : '—'],
338
+ [
339
+ 'tree',
340
+ `parent ${el.parentId ?? '—'} · ${el.childIds.length} ${el.childIds.length === 1 ? 'child' : 'children'}${el.childIds.length > 0 ? ` ${el.childIds.join(', ')}` : ''}`,
341
+ ],
342
+ ];
343
+ if (el.clip) {
344
+ const c = el.clip;
345
+ rows.push([
346
+ 'clip',
347
+ `by ${c.by} ${c.byTag} [overflow:${c.overflow}, ${c.scrollable ? 'scrollable' : 'not scrollable'}]` +
348
+ ` · hidden top ${num(c.hidden.top)} right ${num(c.hidden.right)} bottom ${num(c.hidden.bottom)} left ${num(c.hidden.left)}` +
349
+ ` · ${num(c.visible.width)}x${num(c.visible.height)} visible`,
350
+ ]);
351
+ }
352
+ if (ctx.bundle.truncated) {
353
+ // This one element is real either way -- `requireElement` found it -- but
354
+ // its *tree* (parent/children ids just above) can itself be capped, so
355
+ // the same disclosure `truncatedNote` makes for the list verbs belongs
356
+ // here too, not just on the "no such element" error path.
357
+ rows.push([
358
+ 'truncated',
359
+ `this capture stopped at the element cap (${ctx.bundle.elements.length} records) — more elements may exist past where it stopped`,
360
+ ]);
361
+ }
362
+ // The head line is byte-identical to the one `look` prints for this element,
363
+ // facts included, so an id read off a digest lands somewhere recognisable.
364
+ // There is no separate `facts` row: restating them below would be the digest
365
+ // saying one thing twice, which is what the step-2 grammar redesign removed.
366
+ const text = [
367
+ digestLine(el, facts.map((f) => f.text)),
368
+ ...rows.map(([key, value]) => ` ${key.padEnd(9)}${value}`),
369
+ ].join('\n');
370
+ return {
371
+ text,
372
+ json: {
373
+ verb: 'describe',
374
+ element: el,
375
+ facts: facts.map((f) => ({ kind: f.kind, text: f.text })),
376
+ parent: el.parentId,
377
+ children: el.childIds,
378
+ clip: el.clip ?? null,
379
+ inLayoutTree: findNode(ctx.tree, (n) => n.elementId === id) !== null,
380
+ truncated: ctx.bundle.truncated,
381
+ notMeasured: ctx.bundle.notMeasured,
382
+ },
383
+ };
384
+ }
385
+ /**
386
+ * The compound token a digest line actually prints: `tag.role` or
387
+ * `tag#testid`, no space between the tag and its sigil (`digestLine` above
388
+ * and `render.ts`'s `label()` are the two producers). Mirrors
389
+ * `digest/parse.ts`'s own annotation grammar — `[.#][^\s"]+` after a bare tag
390
+ * — so the query side can never accept a shape the parser would reject.
391
+ *
392
+ * Before this, an agent that read `li.listitem` off a digest line and pasted
393
+ * it into `q find` got zero matches, because `find` only ever matched `tag`,
394
+ * `role` or `testId` each whole and alone: the exact string the tool itself
395
+ * printed was not valid input to the tool.
396
+ *
397
+ * The tag half allows a hyphen — `[a-zA-Z][a-zA-Z0-9-]*`, the same shape
398
+ * `digest/parse.ts`'s own `idTagMatch` uses — because every custom element
399
+ * tag name contains one (the platform requires it: `sl-menu`, `md-select`).
400
+ * Before this allowed it, `sl-menu.menu` off a digest line for a web
401
+ * component matched nothing either, for the identical reason `li.listitem`
402
+ * used to: the regex accepted the shape a plain HTML tag takes and rejected
403
+ * the shape a custom element's tag actually takes.
404
+ */
405
+ const ANNOTATED_QUERY = /^([a-zA-Z][a-zA-Z0-9-]*)?([.#])([^\s"]+)$/;
406
+ function parseAnnotatedQuery(needle) {
407
+ const match = ANNOTATED_QUERY.exec(needle);
408
+ if (!match)
409
+ return null;
410
+ const [, tag, sigil, value] = match;
411
+ if (!value)
412
+ return null;
413
+ return {
414
+ tag: tag ? tag.toLowerCase() : null,
415
+ field: sigil === '#' ? 'testId' : 'role',
416
+ value: value.toLowerCase(),
417
+ };
418
+ }
419
+ /**
420
+ * The one honest sentence a zero-match compound query gets. Not a suggestion
421
+ * engine — it names what was tried (the query parses as the digest's own
422
+ * `tag.role` / `tag#testid` form, and that exact combination was searched)
423
+ * and the two things trying each half alone would tell the caller.
424
+ *
425
+ * `#` here means test id, never a dom id — CSS's own `#id` convention says
426
+ * otherwise, and `diff`'s renderer produced `#domId` compounds before B9,
427
+ * so a needle typed or copied with that expectation (`table#hnmain` meaning
428
+ * "dom id hnmain") parses fine but searches the wrong field entirely. The
429
+ * plain "no element has tag X with test id Y" that used to be the whole
430
+ * message is true about the one field this compound form actually checks,
431
+ * but says nothing about the field it did not -- dom id -- so a reader who
432
+ * meant dom id reads a bare negative and has no way to tell "does not
433
+ * exist" from "exists, under a field this form never searches" (B9-routed).
434
+ * When the bare value *is* some element's dom id, this names it -- a
435
+ * positive fact about the field the compound form skipped, not a second,
436
+ * broader negative it never earned.
437
+ */
438
+ function annotatedZeroHint(ctx, query) {
439
+ const field = query.field === 'testId' ? 'test id' : 'role';
440
+ const halves = query.tag ? [query.tag, query.value] : [query.value];
441
+ const scope = query.tag
442
+ ? `tag "${query.tag}" with ${field} "${query.value}"`
443
+ : `${field} "${query.value}"`;
444
+ const domMatch = query.field === 'testId'
445
+ ? ctx.bundle.elements.find((candidate) => candidate.domId?.toLowerCase() === query.value &&
446
+ (query.tag === null || candidate.tag === query.tag))
447
+ : undefined;
448
+ const domNote = domMatch
449
+ ? ` "${query.value}" is a dom id on ${domMatch.id} -- \`#\` in this compound form means test ` +
450
+ `id, not dom id; \`q find ${query.value}\` matches dom ids as a bare field.`
451
+ : '';
452
+ return (` "${needleFor(query)}" also parses as the digest's tag+${field} form; no element has ${scope}.` +
453
+ `${domNote} Try ${halves.map((h) => `\`q find ${h}\``).join(' or ')} to search one half alone.`);
454
+ }
455
+ const needleFor = (query) => `${query.tag ?? ''}${query.field === 'testId' ? '#' : '.'}${query.value}`;
456
+ /**
457
+ * A digest line truncates text and the accessible name to 30 characters and
458
+ * appends this glyph when it does — usually the record behind the line never
459
+ * contains it, so a needle copied verbatim off a digest line, ellipsis
460
+ * included, would otherwise fail `.includes()` against every element's
461
+ * stored (untruncated) text: the rendered form and the queryable form would
462
+ * disagree on one character.
463
+ *
464
+ * "Usually", not "never": a record's own real, complete text can end in this
465
+ * glyph too — a page's own "See all…" link is nine characters of real
466
+ * content, not a truncation artifact. Stripping it unconditionally used to
467
+ * turn that needle into "See all", which then also matched "See allowance
468
+ * details" right along with it — a false positive `q find` is supposed to be
469
+ * unable to produce. `matchNeedle` below tries the needle exactly as given
470
+ * first, and only strips this glyph as a fallback when the literal needle
471
+ * finds nothing.
472
+ */
473
+ const ELLIPSIS = '…';
474
+ /**
475
+ * `e13`, `e0` — the id shape every digest line starts with. `collect.ts`'s
476
+ * `nextId` is the only producer and it is always the letter `e`, so this is
477
+ * deliberately narrower than "letters then digits": a real tag like `h1` or
478
+ * `h2` fits that looser shape too, and would get this hint instead of an
479
+ * honest "no such tag in this bundle".
480
+ */
481
+ const ID_SHAPE = /^e\d+$/;
482
+ /**
483
+ * `find` searches text, not ids — `q describe <id>` does, and its own error
484
+ * already points here for the reverse mix-up. An id-shaped needle that
485
+ * exists in this bundle gets routed straight to `describe`. One that does
486
+ * not exist must not: before this checked `ctx.byId`, `q find e99999` on a
487
+ * bundle with no `e99999` still said "try `q describe e99999`", which itself
488
+ * exits 1 — a hint that sends the caller into a second dead end instead of
489
+ * naming what was wrong. That case gets the same nearby-id hint `describe`
490
+ * gives instead.
491
+ */
492
+ function idShapedHint(ctx, needle) {
493
+ if (ctx.byId.has(needle)) {
494
+ return (` "${needle}" looks like an element id, not a search term. Ids are not searched by ` +
495
+ `\`find\` — try \`q describe ${needle}\`.`);
496
+ }
497
+ const near = nearbyIds(ctx, needle);
498
+ return (` "${needle}" looks like an element id, but no element has that id in bundle "${ctx.bundle.name}" ` +
499
+ `(${ctx.bundle.elements.length} records).` +
500
+ (near.length > 0 ? ` Nearby ids: ${near.join(', ')}.` : ''));
501
+ }
502
+ /** One matching pass against a single needle value: exact fields matched whole, text and the
503
+ * accessible name matched as substrings, plus the compound `tag.role`/`tag#testid` form. */
504
+ function matchNeedle(ctx, raw) {
505
+ const n = raw.toLowerCase();
506
+ const annotated = parseAnnotatedQuery(raw);
507
+ const hits = ctx.bundle.elements.filter((el) => {
508
+ if (el.tag === n ||
509
+ el.role?.toLowerCase() === n ||
510
+ el.testId?.toLowerCase() === n ||
511
+ el.domId?.toLowerCase() === n ||
512
+ el.text.toLowerCase().includes(n) ||
513
+ (el.name?.toLowerCase().includes(n) ?? false)) {
514
+ return true;
515
+ }
516
+ if (!annotated)
517
+ return false;
518
+ if (annotated.tag && el.tag !== annotated.tag)
519
+ return false;
520
+ const field = annotated.field === 'testId' ? el.testId : el.role;
521
+ return field?.toLowerCase() === annotated.value;
522
+ });
523
+ return { hits, annotated };
524
+ }
525
+ /**
526
+ * `q find <text|tag|role|tag.role|tag#testid>` — one needle against several
527
+ * fields.
528
+ *
529
+ * Tag, role, test id and DOM id are names and are matched whole; text and the
530
+ * accessible name are prose and are matched as substrings. `class` is matched
531
+ * against nothing at all, per the step-1 finding that class tokens are churn.
532
+ * A needle shaped like a digest annotation (`li.listitem`, `button#save-btn`,
533
+ * or the bare `.role`/`#testid` half) additionally matches conjunctively on
534
+ * tag and role/test id together — see `parseAnnotatedQuery` above.
535
+ */
536
+ function find(ctx, args, limit) {
537
+ const needle = args[0];
538
+ if (!needle)
539
+ throw new QueryError('find needs something to look for: `q find "Save"`.');
540
+ // The needle exactly as given, first — see the `ELLIPSIS` doc comment for
541
+ // why this cannot unconditionally strip a trailing ellipsis before it
542
+ // compares anything. Only when that finds nothing, and the needle ends in
543
+ // the digest's truncation glyph, retry with the glyph stripped: the case
544
+ // this fallback exists for is a needle copied off a digest line, where the
545
+ // glyph was appended by `digestLine`'s own 30-character truncation and the
546
+ // record behind the line never contained it.
547
+ let { hits, annotated } = matchNeedle(ctx, needle);
548
+ if (hits.length === 0 && needle.endsWith(ELLIPSIS)) {
549
+ const trimmed = needle.slice(0, -ELLIPSIS.length);
550
+ if (trimmed) {
551
+ const fallback = matchNeedle(ctx, trimmed);
552
+ if (fallback.hits.length > 0) {
553
+ hits = fallback.hits;
554
+ annotated = fallback.annotated;
555
+ }
556
+ }
557
+ }
558
+ const { shown, hidden } = cap(hits, limit);
559
+ const zeroHint = hits.length === 0
560
+ ? annotated
561
+ ? annotatedZeroHint(ctx, annotated)
562
+ : ID_SHAPE.test(needle)
563
+ ? idShapedHint(ctx, needle)
564
+ : ''
565
+ : '';
566
+ const header = `# find "${needle}" — ${hits.length} of ${ctx.bundle.elements.length} records match on tag, role, test id, dom id, text or accessible name` +
567
+ zeroHint;
568
+ return {
569
+ text: [
570
+ header,
571
+ ...truncatedNote(ctx),
572
+ ...notMeasuredNote(ctx),
573
+ ...shown.map((el) => digestLine(el, factTexts(ctx, el.id))),
574
+ ...HIDDEN_NOTE(hidden),
575
+ ].join('\n'),
576
+ json: {
577
+ verb: 'find',
578
+ query: needle,
579
+ total: hits.length,
580
+ hidden,
581
+ truncated: ctx.bundle.truncated,
582
+ notMeasured: ctx.bundle.notMeasured,
583
+ matches: shown.map((el) => ({
584
+ id: el.id,
585
+ tag: el.tag,
586
+ role: el.role,
587
+ name: el.name,
588
+ text: el.text,
589
+ rect: el.rect,
590
+ facts: factTexts(ctx, el.id),
591
+ })),
592
+ },
593
+ };
594
+ }
595
+ /** Records that never reached the layout tree, stated rather than left implicit. */
596
+ const treeNote = (ctx) => ctx.notInTree > 0
597
+ ? [
598
+ `# ${ctx.notInTree} of ${ctx.bundle.elements.length} records are not layout nodes (invisible, zero-size, SVG internals, or folded into a wrapper) and cannot be hit`,
599
+ ]
600
+ : [];
601
+ /**
602
+ * `formatNotMeasured` (`snapshot/not-measured.ts`) returns `null` for a walk
603
+ * capture with zero *open* shadow roots -- correct for the digest identity
604
+ * line, which discloses the walk/closed-roots blind spot on its own strategy
605
+ * segment regardless of count (`digest/render.ts`'s `strategyLabelFor`,
606
+ * `# dnumb digest v2 ... — walk (closed shadow roots undetectable)`). None of
607
+ * the `q` verbs render that identity line, so `formatNotMeasured` alone is
608
+ * their *only* vehicle for this disclosure -- and it stayed silent on
609
+ * exactly the bundles where a walk capture's blindness to shadow content is
610
+ * least visible: a page that happened to have no *open* shadow root looks
611
+ * identical to a page with none at all, even though a walk capture cannot
612
+ * see into a shadow root's content either way, open or closed (B12). This
613
+ * wraps the formatter so that blind spot is never gated on a nonzero count
614
+ * for the one capture path where the count itself can never say "no shadow
615
+ * content exists".
616
+ */
617
+ function coverageCaveat(ctx) {
618
+ const measured = (0, not_measured_js_1.formatNotMeasured)(ctx.bundle.notMeasured);
619
+ if (measured)
620
+ return measured;
621
+ return ctx.bundle.notMeasured.shadowRoots.closed === 'unknown'
622
+ ? 'not measured: shadow DOM (walk capture — closed shadow roots are undetectable, and no ' +
623
+ 'shadow root content is visible to this strategy)'
624
+ : null;
625
+ }
626
+ /**
627
+ * Shadow DOM and iframe content this capture never saw, stated for the same
628
+ * reason as `treeNote`: a query verb that comes back empty or short and says
629
+ * nothing about this reads as "not on the page", and it may only be "not in
630
+ * this bundle".
631
+ */
632
+ const notMeasuredNote = (ctx) => {
633
+ const line = coverageCaveat(ctx);
634
+ return line ? [`# ${line} — a match inside one of these would not appear here`] : [];
635
+ };
636
+ /**
637
+ * `q at <x> <y>` — the vision→geometry bridge: a model reads a coordinate off a
638
+ * screenshot and asks what is there.
639
+ *
640
+ * Innermost first. Paint order would be the better answer for stacked content
641
+ * and the CDP capture does request `paintOrders`, but spec 1 records do not
642
+ * carry it, so ordering is by ascending area and the header says so rather than
643
+ * implying a z-order the data cannot support.
644
+ */
645
+ function at(ctx, args, limit) {
646
+ const x = coordinate(args[0], 'at', '`q at 120 240`');
647
+ const y = coordinate(args[1], 'at', '`q at 120 240`');
648
+ const hits = (0, region_query_js_1.nodesAtPoint)(ctx.tree, x, y);
649
+ const { shown, hidden } = cap(hits, limit);
650
+ const header = `# at ${num(x)},${num(y)} — ${hits.length} ${hits.length === 1 ? 'node contains' : 'nodes contain'} this point, innermost first`;
651
+ return {
652
+ text: [
653
+ header,
654
+ ...truncatedNote(ctx),
655
+ ...treeNote(ctx),
656
+ ...notMeasuredNote(ctx),
657
+ ...shown.map((node) => nodeLine(ctx, node)),
658
+ ...HIDDEN_NOTE(hidden),
659
+ ].join('\n'),
660
+ json: {
661
+ verb: 'at',
662
+ point: { x, y },
663
+ total: hits.length,
664
+ hidden,
665
+ truncated: ctx.bundle.truncated,
666
+ notInTree: ctx.notInTree,
667
+ notMeasured: ctx.bundle.notMeasured,
668
+ matches: shown.map((node) => nodeMatch(ctx, node)),
669
+ },
670
+ };
671
+ }
672
+ /** `q within <x> <y> <w> <h>` — partial overlap counts, most-overlapping first. */
673
+ function within(ctx, args, limit) {
674
+ const usage = '`q within 0 0 400 300`';
675
+ const x = coordinate(args[0], 'within', usage);
676
+ const y = coordinate(args[1], 'within', usage);
677
+ const width = coordinate(args[2], 'within', usage);
678
+ const height = coordinate(args[3], 'within', usage);
679
+ if (width <= 0 || height <= 0) {
680
+ throw new QueryError(`within needs a positive width and height; got ${num(width)}x${num(height)}.`);
681
+ }
682
+ const hits = (0, region_query_js_1.nodesInRegion)(ctx.tree, { x, y, width, height });
683
+ const { shown, hidden } = cap(hits, limit);
684
+ const header = `# within ${num(x)},${num(y)} ${num(width)}x${num(height)} — ${hits.length} ${hits.length === 1 ? 'node overlaps' : 'nodes overlap'} this region, largest overlap first`;
685
+ return {
686
+ text: [
687
+ header,
688
+ ...truncatedNote(ctx),
689
+ ...treeNote(ctx),
690
+ ...notMeasuredNote(ctx),
691
+ ...shown.map((m) => nodeLine(ctx, m.node, `∩${num(round(m.intersectionArea))}px²`)),
692
+ ...HIDDEN_NOTE(hidden),
693
+ ].join('\n'),
694
+ json: {
695
+ verb: 'within',
696
+ region: { x, y, width, height },
697
+ total: hits.length,
698
+ hidden,
699
+ truncated: ctx.bundle.truncated,
700
+ notInTree: ctx.notInTree,
701
+ notMeasured: ctx.bundle.notMeasured,
702
+ matches: shown.map((m) => ({
703
+ ...nodeMatch(ctx, m.node),
704
+ intersectionArea: m.intersectionArea,
705
+ })),
706
+ },
707
+ };
708
+ }
709
+ /** A signed per-axis gap, with the sign spelled out — `-18` alone reads as a typo. */
710
+ const axisGap = (value) => value >= 0 ? `+${num(value)} gap` : `${num(value)} overlap`;
711
+ /**
712
+ * `nodeMatch` shaped, for an `ElementRecord` rather than a `LayoutNode` --
713
+ * `distance` and `nearest` both take element ids, but only `nearest` walks
714
+ * the tree already (`findNode`). `distance` is purely geometric (it reads
715
+ * `a.rect`/`b.rect` directly, per its own doc comment) and answerable for an
716
+ * id that never became its own layout node, so it cannot require one the way
717
+ * `nodeMatch` does. When the element *is* a tree node, this reuses
718
+ * `nodeMatch` verbatim -- same fields, same order -- so `--json` never
719
+ * disagrees with the tree-node shape `at`/`within`/`nearest` already print;
720
+ * when it is not, it falls back to the same fields `nodeMatch` would carry
721
+ * anyway (role/name/text/facts come from `ctx.byId`, not from tree
722
+ * membership), just without a `kind` no layout node exists to supply (B10).
723
+ */
724
+ function elementMatch(ctx, el) {
725
+ const node = findNode(ctx.tree, (n) => n.elementId === el.id);
726
+ if (node)
727
+ return nodeMatch(ctx, node);
728
+ return {
729
+ id: el.id,
730
+ tag: el.tag,
731
+ rect: el.rect,
732
+ ...(el.role ? { role: el.role } : {}),
733
+ ...(el.name ? { name: el.name } : {}),
734
+ ...(el.text ? { text: el.text } : {}),
735
+ facts: factTexts(ctx, el.id),
736
+ };
737
+ }
738
+ /** `q distance <idA> <idB>` — edge-to-edge, per axis, and centre to centre. */
739
+ function distanceBetween(ctx, args) {
740
+ const [idA, idB] = args;
741
+ if (!idA || !idB)
742
+ throw new QueryError('distance needs two element ids: `q distance e12 e18`.');
743
+ const a = requireElement(ctx, idA);
744
+ const b = requireElement(ctx, idB);
745
+ const d = (0, distances_js_1.distance)(a.rect, b.rect);
746
+ return {
747
+ text: [
748
+ digestLine(a, factTexts(ctx, a.id)),
749
+ digestLine(b, factTexts(ctx, b.id)),
750
+ `edge ${num(d.edge)} · center ${num(d.center)} · dx ${axisGap(d.dx)} · dy ${axisGap(d.dy)}`,
751
+ ...notMeasuredNote(ctx),
752
+ ].join('\n'),
753
+ json: {
754
+ verb: 'distance',
755
+ // Used to be `{ id, tag, rect }` alone -- role, name, text and facts
756
+ // all printed on the text lines above but silently dropped from
757
+ // `--json`, the exact defect this file's own header says `--json` can
758
+ // never commit (B10): the same fix `nearest` already got, carried
759
+ // across to the one other verb `nodeMatch`-shaped data never reached.
760
+ a: elementMatch(ctx, a),
761
+ b: elementMatch(ctx, b),
762
+ edge: d.edge,
763
+ center: d.center,
764
+ dx: d.dx,
765
+ dy: d.dy,
766
+ truncated: ctx.bundle.truncated,
767
+ notMeasured: ctx.bundle.notMeasured,
768
+ },
769
+ };
770
+ }
771
+ /** `q nearest <id>` — the k closest layout nodes by edge distance. */
772
+ function nearestTo(ctx, args, k) {
773
+ const id = args[0];
774
+ if (!id)
775
+ throw new QueryError('nearest needs an element id: `q nearest e12`.');
776
+ requireElement(ctx, id);
777
+ if (!findNode(ctx.tree, (n) => n.elementId === id)) {
778
+ // Distance is computed between tree nodes, and the tree drops invisible
779
+ // boxes and folds wrapper chains into their child. Naming the surviving
780
+ // ancestor turns a dead end into the next command.
781
+ let ancestor = ctx.byId.get(id)?.parentId ?? null;
782
+ while (ancestor && !findNode(ctx.tree, (n) => n.elementId === ancestor)) {
783
+ ancestor = ctx.byId.get(ancestor)?.parentId ?? null;
784
+ }
785
+ throw new QueryError(`"${id}" is in bundle "${ctx.bundle.name}" but is not its own layout node — it is invisible, zero-size, or folded into a wrapper.` +
786
+ (ancestor
787
+ ? ` Its nearest surviving ancestor is ${ancestor}: try \`q nearest ${ancestor}\`.`
788
+ : ' Run `look` to see which ids are layout nodes.') +
789
+ ` \`q describe ${id}\` still reports its geometry.`);
790
+ }
791
+ const results = (0, distances_js_1.nearest)(ctx.tree, id, k);
792
+ const header = `# nearest ${id} — ${results.length} closest by edge distance, ancestors and descendants excluded (--k <n> for more)`;
793
+ return {
794
+ text: [
795
+ header,
796
+ ...truncatedNote(ctx),
797
+ ...notMeasuredNote(ctx),
798
+ ...results.map((r) => {
799
+ const node = findNode(ctx.tree, (n) => n.elementId === r.id);
800
+ const suffix = `edge ${num(r.edge)} · dx ${axisGap(r.dx)} · dy ${axisGap(r.dy)}`;
801
+ return node ? nodeLine(ctx, node, suffix) : `${r.id} ${suffix}`;
802
+ }),
803
+ ].join('\n'),
804
+ json: {
805
+ verb: 'nearest',
806
+ id,
807
+ k,
808
+ truncated: ctx.bundle.truncated,
809
+ notMeasured: ctx.bundle.notMeasured,
810
+ // `nearest()` (`analysis/distances.ts`) returns only `{id, edge, dx,
811
+ // dy}` — the geometry `nodeMatch` carries (rect, tag, role, name,
812
+ // facts) is exactly what `at`/`within` put in *their* `--json`, and
813
+ // dropping it here for `nearest` alone falsified this file's own
814
+ // header claim that `--json` never reports less than the text: the
815
+ // text rendering above resolves each neighbour through `nodeLine`,
816
+ // which prints the digest line, facts included.
817
+ neighbours: results.map((r) => {
818
+ const node = findNode(ctx.tree, (n) => n.elementId === r.id);
819
+ return {
820
+ ...(node ? nodeMatch(ctx, node) : { id: r.id }),
821
+ edge: r.edge,
822
+ dx: r.dx,
823
+ dy: r.dy,
824
+ };
825
+ }),
826
+ },
827
+ };
828
+ }
829
+ /** `q facts` — every element carrying a fact, one per line, in document order. */
830
+ function facts(ctx, limit) {
831
+ const carrying = ctx.bundle.elements.filter((el) => ctx.facts.has(el.id));
832
+ const { shown, hidden } = cap(carrying, limit);
833
+ const header = `# facts — ${carrying.length} of ${ctx.bundle.elements.length} records ${carrying.length === 1 ? 'carries' : 'carry'} facts`;
834
+ return {
835
+ text: [
836
+ header,
837
+ ...truncatedNote(ctx),
838
+ ...notMeasuredNote(ctx),
839
+ ...shown.map((el) => digestLine(el, factTexts(ctx, el.id))),
840
+ ...HIDDEN_NOTE(hidden),
841
+ ].join('\n'),
842
+ json: {
843
+ verb: 'facts',
844
+ total: carrying.length,
845
+ hidden,
846
+ truncated: ctx.bundle.truncated,
847
+ notMeasured: ctx.bundle.notMeasured,
848
+ elements: shown.map((el) => ({
849
+ id: el.id,
850
+ tag: el.tag,
851
+ rect: el.rect,
852
+ facts: (ctx.facts.get(el.id) ?? []).map((f) => ({ kind: f.kind, text: f.text })),
853
+ })),
854
+ },
855
+ };
856
+ }
857
+ /** Integer px when exact, one decimal otherwise — the same rounding facts.ts
858
+ * and clip.ts use for a measurement, applied to font size and stroke width. */
859
+ const colorPx = (n) => Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, '');
860
+ function sizeRangeLabel(pxs) {
861
+ const min = Math.min(...pxs);
862
+ const max = Math.max(...pxs);
863
+ return min === max ? `${colorPx(min)}px` : `${colorPx(min)}-${colorPx(max)}px`;
864
+ }
865
+ function weightRangeLabel(weights) {
866
+ const uniq = [...new Set(weights)].sort((a, b) => a - b);
867
+ return uniq.length === 1 ? String(uniq[0]) : `${uniq[0]}-${uniq[uniq.length - 1]}`;
868
+ }
869
+ /** Rows sort by ratio ascending, unmeasured last -- presentation, never
870
+ * adjudication (`docs/notes/03-color.md` §6: "the sort is disclosed" and
871
+ * nothing is dropped). Ties (every unmeasured row shares "no ratio") break
872
+ * by element count, then alphabetically, so the order is stable and does not
873
+ * depend on capture-time happenstance. */
874
+ function sortColorPairs(pairs) {
875
+ return [...pairs].sort((a, b) => {
876
+ if (a.ratio !== null && b.ratio !== null && a.ratio !== b.ratio)
877
+ return a.ratio - b.ratio;
878
+ if ((a.ratio === null) !== (b.ratio === null))
879
+ return a.ratio === null ? 1 : -1;
880
+ if (b.elementIds.length !== a.elementIds.length)
881
+ return b.elementIds.length - a.elementIds.length;
882
+ return `${a.fg} ${a.bgLabel}`.localeCompare(`${b.fg} ${b.bgLabel}`);
883
+ });
884
+ }
885
+ const COLOR_EXAMPLE_CAP = 6;
886
+ /**
887
+ * A pair's effective-opacity values, deduped and sorted -- the raw material
888
+ * for both `pairRow`'s token and `pairJson`'s field. `groupColorFacts`
889
+ * (`snapshot/color.ts`) folds a pair down to `fg`/`bgLabel`/`ratio`/count/
890
+ * size/weight and drops `effectiveOpacity` entirely, so a pair carries no
891
+ * memory of it on its own; this recovers it from the ungrouped facts the
892
+ * same way `pairRow`'s `examples` recovers element ids, keyed by the same
893
+ * `elementIds` the pair already carries. `null` on a `ColorFact` means "at
894
+ * full opacity" (`ColorFact.effectiveOpacity`'s own doc comment), so those
895
+ * are filtered out rather than treated as a measurement of zero.
896
+ */
897
+ function pairOpacities(elementIds, factsById) {
898
+ const values = elementIds
899
+ .map((id) => factsById.get(id)?.effectiveOpacity)
900
+ .filter((o) => o !== null && o !== undefined);
901
+ return [...new Set(values)].sort((a, b) => a - b);
902
+ }
903
+ /**
904
+ * A closed `sl-dialog` with `opacity:0` composites to `ratio 1.00` on every
905
+ * descendant -- correct arithmetic, but a reader who does not also see
906
+ * `opacity ×0` reads that row as the page's worst *visible* contrast
907
+ * problem, when the counter-fact that explains the number is exactly what
908
+ * made it uninformative. `q describe` and `q color <id>` both keep this
909
+ * token (`renderColorFact`); `q color`'s grouped rows dropped it the moment
910
+ * `groupColorFacts` folded per-element facts into a pair (A6-routed). Same
911
+ * range convention as `sizeRangeLabel`/`weightRangeLabel` beside it: a
912
+ * single value prints bare, a spread prints as `min-max`.
913
+ */
914
+ const opacityToken = (opacities) => opacities.length === 0
915
+ ? ''
916
+ : ` opacity ×${opacities.length === 1 ? num(opacities[0]) : `${num(opacities[0])}-${num(opacities[opacities.length - 1])}`}`;
917
+ function pairRow(pair, opacities) {
918
+ const label = `${pair.fg} on ${pair.bgLabel}`;
919
+ const ratio = pair.ratio !== null ? pair.ratio.toFixed(2) : 'unmeasured';
920
+ const count = `${pair.elementIds.length} ${pair.elementIds.length === 1 ? 'el' : 'els'}`;
921
+ const sizes = `${sizeRangeLabel(pair.fontSizesPx)}/${weightRangeLabel(pair.weights)}`;
922
+ const examples = pair.elementIds.length > COLOR_EXAMPLE_CAP
923
+ ? `${pair.elementIds.slice(0, COLOR_EXAMPLE_CAP).join(', ')}, …`
924
+ : pair.elementIds.join(', ');
925
+ return `${label} ratio ${ratio} ${count} ${sizes}${opacityToken(opacities)} ${examples}`;
926
+ }
927
+ function pairJson(pair, opacities) {
928
+ return {
929
+ fg: pair.fg,
930
+ bg: pair.bgLabel,
931
+ ratio: pair.ratio,
932
+ ratioReason: pair.ratioReason,
933
+ fontSizePx: [Math.min(...pair.fontSizesPx), Math.max(...pair.fontSizesPx)],
934
+ fontWeight: [...new Set(pair.weights)].sort((a, b) => a - b),
935
+ // `null` when every element in this pair is at full opacity -- same
936
+ // "nothing to disclose" convention `effectiveOpacity` itself uses.
937
+ opacity: opacities.length > 0 ? opacities : null,
938
+ elements: pair.elementIds,
939
+ };
940
+ }
941
+ /** `q color` — one row per (ink, backdrop) pair, ratio ascending, unmeasured
942
+ * last (`docs/notes/03-color.md` §6). Thousands of elements compress to
943
+ * dozens of rows on a real page: the token discipline this verb exists for. */
944
+ function colorPairs(ctx, limit) {
945
+ const colorFacts = (0, color_js_1.colorFactsForAll)(ctx.bundle.elements, ctx.bundle.documentBackground);
946
+ const factsById = new Map(colorFacts.map((f) => [f.elementId, f]));
947
+ const pairs = sortColorPairs((0, color_js_1.groupColorFacts)(colorFacts));
948
+ const unmeasured = colorFacts.filter((f) => f.ratio === null).length;
949
+ const { shown, hidden } = cap(pairs, limit);
950
+ const opacitiesFor = (pair) => pairOpacities(pair.elementIds, factsById);
951
+ const header = `# color — ${pairs.length} fg/bg ${pairs.length === 1 ? 'pair' : 'pairs'} across ` +
952
+ `${colorFacts.length} text ${colorFacts.length === 1 ? 'element' : 'elements'}` +
953
+ (unmeasured > 0 ? `; ${unmeasured} unmeasured` : '');
954
+ return {
955
+ text: [
956
+ header,
957
+ ...truncatedNote(ctx),
958
+ ...notMeasuredNote(ctx),
959
+ ...shown.map((pair) => pairRow(pair, opacitiesFor(pair))),
960
+ ...HIDDEN_NOTE(hidden),
961
+ ].join('\n'),
962
+ json: {
963
+ verb: 'color',
964
+ pairs: pairs.length,
965
+ textElements: colorFacts.length,
966
+ unmeasured,
967
+ hidden,
968
+ truncated: ctx.bundle.truncated,
969
+ rows: shown.map((pair) => pairJson(pair, opacitiesFor(pair))),
970
+ },
971
+ };
972
+ }
973
+ /** `q color --all` — one line per gated element, same convention as `q facts`. */
974
+ function colorAll(ctx, limit) {
975
+ const colorFacts = (0, color_js_1.colorFactsForAll)(ctx.bundle.elements, ctx.bundle.documentBackground);
976
+ const { shown, hidden } = cap(colorFacts, limit);
977
+ const header = `# color --all — ${colorFacts.length} text elements`;
978
+ const lineFor = (fact) => {
979
+ const el = ctx.byId.get(fact.elementId);
980
+ const head = el ? digestLine(el, []) : fact.elementId;
981
+ return `${head} ${(0, color_js_1.renderColorFact)(fact)}`;
982
+ };
983
+ return {
984
+ text: [
985
+ header,
986
+ ...truncatedNote(ctx),
987
+ ...notMeasuredNote(ctx),
988
+ ...shown.map(lineFor),
989
+ ...HIDDEN_NOTE(hidden),
990
+ ].join('\n'),
991
+ json: {
992
+ verb: 'color',
993
+ all: true,
994
+ total: colorFacts.length,
995
+ hidden,
996
+ truncated: ctx.bundle.truncated,
997
+ elements: shown.map((f) => ({ id: f.elementId, ...f })),
998
+ },
999
+ };
1000
+ }
1001
+ /**
1002
+ * `textShadow`, `textStrokeWidth` and `textStrokeColor` were added to
1003
+ * `ElementRecord` for color capture and are required, not optional --
1004
+ * `snapshot/types.ts` declares all three as plain `string`, every real
1005
+ * capture path populates them, and `parseBundle` backfills them for a
1006
+ * bundle written before the fields existed. The only way one is `undefined`
1007
+ * at runtime is a hand-built record that skips a field its own type
1008
+ * declares required -- TypeScript cannot catch that at the JS boundary a
1009
+ * hand-assembled object or an unvalidated `JSON.parse` crosses. Interpolating
1010
+ * that straight into a sentence prints the literal string `undefined` into
1011
+ * something that otherwise reads as a measurement, which is exactly the
1012
+ * fabrication this package exists to refuse. This mirrors
1013
+ * `snapshot/color.ts`'s own `requireColorField` (not exported, so not
1014
+ * reused directly -- this is the same diagnosis in the same voice, not a
1015
+ * second decision) rather than let a caller substitute a raw `undefined`.
1016
+ */
1017
+ function requireStringField(elementId, field, value) {
1018
+ if (typeof value !== 'string') {
1019
+ throw new Error(`dnumb: element "${elementId}" has no "${field}" (added for color capture -- every real ` +
1020
+ 'capture sets it, and parseBundle backfills it for a bundle written before this field ' +
1021
+ 'existed). A hand-built ElementRecord must include it -- see the type exported from ' +
1022
+ '"@michaelmagan/dnumb/spec", or produce the record with capture()/parseBundle instead of by hand.');
1023
+ }
1024
+ return value;
1025
+ }
1026
+ /**
1027
+ * `q color <id>` — the single-element long form: the full composite chain
1028
+ * (each layer's own color, alpha and owning ancestor), the overlap finding,
1029
+ * raw vs. effective ink, and shadow/stroke values verbatim
1030
+ * (`docs/notes/03-color.md` §6).
1031
+ */
1032
+ function colorDescribe(ctx, args) {
1033
+ const id = args[0];
1034
+ if (!id)
1035
+ throw new QueryError('color needs an element id: `q color e12`.');
1036
+ const el = requireElement(ctx, id);
1037
+ const fact = (0, color_js_1.colorFactFor)(el, ctx.byId, ctx.bundle.elements, ctx.bundle.documentBackground);
1038
+ if (!fact) {
1039
+ // Not a usage error -- the element is real, it simply does not qualify
1040
+ // for a color fact (not visible, or no own text: §4's measurement gate).
1041
+ // Refusing honestly, with the reason, is the whole discipline this
1042
+ // module exists to hold; a thrown error here would hide that reason
1043
+ // behind an exit code instead of printing it.
1044
+ const reason = !el.visible ? 'is not visible' : 'has no own direct text';
1045
+ const text = `${digestLine(el, factTexts(ctx, id))}\nno color fact: this element ${reason} (the gate is "visible, with its own text").`;
1046
+ return {
1047
+ text,
1048
+ json: { verb: 'color', id, applies: false, reason },
1049
+ };
1050
+ }
1051
+ const resolved = (0, color_js_1.resolveBackdrop)(el, ctx.byId, ctx.bundle.documentBackground);
1052
+ const overlay = (0, color_js_1.detectOverlay)(el, ctx.byId, ctx.bundle.elements);
1053
+ const chain = resolved.chain
1054
+ .map((layer) => `${layer.id} ${layer.color} α${layer.alpha.toFixed(2)}`)
1055
+ .join(' → ');
1056
+ const overlaySummary = overlay
1057
+ ? `${overlay.reason} ${overlay.via} -- the ancestor walk below is what it would have found had the overlay not intervened`
1058
+ : 'none found';
1059
+ // `hitStackIds`/`paintOrder` are genuinely nullable (`string[] | null`,
1060
+ // `number | null` in `snapshot/types.ts`) -- an old capture path really
1061
+ // has no such signal, and `null` says that honestly. `!== null` used to
1062
+ // guard this, which also passes for `undefined`: a record whose key is
1063
+ // simply absent (violating the type the same way the string color fields
1064
+ // above can) sailed through and then threw on `.length` a line later.
1065
+ // `Array.isArray` is the same distinction `snapshot/color.ts#detectOverlay`
1066
+ // already draws between "measured as nothing" (a real, empty array or a
1067
+ // real `null`) and "never measured" (the key is not there at all).
1068
+ // `typeof ... === 'number'`, not `!== null`, for the identical reason on
1069
+ // `paintOrder`'s side of the same branch -- `undefined !== null` is also
1070
+ // `true`, which would print `paintOrder (cdp): undefined` instead of
1071
+ // falling through to the honest "no overlap signal" case.
1072
+ const hitSignal = Array.isArray(el.hitStackIds)
1073
+ ? `hit-stack (walk): ${el.hitStackIds.length > 0 ? el.hitStackIds.join(', ') : '(empty)'}`
1074
+ : typeof el.paintOrder === 'number'
1075
+ ? `paintOrder (cdp): ${el.paintOrder}`
1076
+ : 'no overlap signal captured for this element';
1077
+ const rows = [
1078
+ ['grammar', (0, color_js_1.renderColorFact)(fact)],
1079
+ ['chain', chain || '(none)'],
1080
+ ['overlay', overlaySummary],
1081
+ ['overlap-in', hitSignal],
1082
+ [
1083
+ 'ink',
1084
+ `raw color:${el.color} opacity:${num(el.opacity)} · effective ${fact.fg}` +
1085
+ (fact.effectiveOpacity !== null
1086
+ ? ` (opacity ×${num(fact.effectiveOpacity)} combined through ancestors)`
1087
+ : ''),
1088
+ ],
1089
+ [
1090
+ 'shadow',
1091
+ requireStringField(id, 'textShadow', el.textShadow) === 'none'
1092
+ ? 'none'
1093
+ : `raw text-shadow: ${el.textShadow}`,
1094
+ ],
1095
+ [
1096
+ 'stroke',
1097
+ requireStringField(id, 'textStrokeWidth', el.textStrokeWidth) === '0px'
1098
+ ? 'none'
1099
+ : `raw -webkit-text-stroke-width:${el.textStrokeWidth} -webkit-text-stroke-color:` +
1100
+ `${requireStringField(id, 'textStrokeColor', el.textStrokeColor)}`,
1101
+ ],
1102
+ ];
1103
+ const text = [
1104
+ digestLine(el, factTexts(ctx, id)),
1105
+ ...rows.map(([key, value]) => ` ${key.padEnd(11)}${value}`),
1106
+ ].join('\n');
1107
+ return {
1108
+ text,
1109
+ json: {
1110
+ verb: 'color',
1111
+ id,
1112
+ applies: true,
1113
+ fact,
1114
+ chain: resolved.chain,
1115
+ overlay,
1116
+ },
1117
+ };
1118
+ }
1119
+ /** Dispatches one `q` verb. Throws `QueryError` for anything the caller can fix. */
1120
+ function query(bundle, verb, args = [], options = {}) {
1121
+ const known = exports.Q_VERBS.join(' | ');
1122
+ if (!verb)
1123
+ throw new QueryError(`q needs a verb: ${known}.`);
1124
+ if (!exports.Q_VERBS.includes(verb)) {
1125
+ throw new QueryError(`unknown verb "q ${verb}". Known: ${known}.`);
1126
+ }
1127
+ const ctx = context(bundle);
1128
+ const limit = options.limit ?? DEFAULT_LIMIT;
1129
+ switch (verb) {
1130
+ case 'describe':
1131
+ return describe(ctx, args);
1132
+ case 'find':
1133
+ return find(ctx, args, limit);
1134
+ case 'at':
1135
+ return at(ctx, args, limit);
1136
+ case 'within':
1137
+ return within(ctx, args, limit);
1138
+ case 'distance':
1139
+ return distanceBetween(ctx, args);
1140
+ case 'nearest':
1141
+ return nearestTo(ctx, args, options.k ?? DEFAULT_K);
1142
+ case 'facts':
1143
+ return facts(ctx, limit);
1144
+ default:
1145
+ // `color`. `--all` and a specific element id are mutually exclusive
1146
+ // views (the per-element list vs. the single-element long form); an id
1147
+ // wins when both are given, since it is the more specific request.
1148
+ if (args[0] !== undefined)
1149
+ return colorDescribe(ctx, args);
1150
+ return options.all ? colorAll(ctx, limit) : colorPairs(ctx, limit);
1151
+ }
1152
+ }