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