@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/analysis/cluster.cjs +114 -0
- package/dist/analysis/cluster.d.cts +68 -0
- package/dist/analysis/cluster.d.ts +68 -0
- package/dist/analysis/cluster.js +109 -0
- package/dist/analysis/distances.cjs +124 -0
- package/dist/analysis/distances.d.cts +58 -0
- package/dist/analysis/distances.d.ts +58 -0
- package/dist/analysis/distances.js +119 -0
- package/dist/analysis/geometry.cjs +60 -0
- package/dist/analysis/geometry.d.cts +34 -0
- package/dist/analysis/geometry.d.ts +34 -0
- package/dist/analysis/geometry.js +53 -0
- package/dist/analysis/layout-tree.cjs +245 -0
- package/dist/analysis/layout-tree.d.cts +45 -0
- package/dist/analysis/layout-tree.d.ts +45 -0
- package/dist/analysis/layout-tree.js +242 -0
- package/dist/analysis/region-query.cjs +71 -0
- package/dist/analysis/region-query.d.cts +25 -0
- package/dist/analysis/region-query.d.ts +25 -0
- package/dist/analysis/region-query.js +67 -0
- package/dist/analysis/types.cjs +7 -0
- package/dist/analysis/types.d.cts +143 -0
- package/dist/analysis/types.d.ts +143 -0
- package/dist/analysis/types.js +6 -0
- package/dist/bundle.cjs +182 -0
- package/dist/bundle.d.cts +91 -0
- package/dist/bundle.d.ts +91 -0
- package/dist/bundle.js +175 -0
- package/dist/cli.cjs +755 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +719 -0
- package/dist/diff/changes.cjs +656 -0
- package/dist/diff/changes.d.cts +2 -0
- package/dist/diff/changes.d.ts +2 -0
- package/dist/diff/changes.js +653 -0
- package/dist/diff/identity.cjs +167 -0
- package/dist/diff/identity.d.cts +62 -0
- package/dist/diff/identity.d.ts +62 -0
- package/dist/diff/identity.js +159 -0
- package/dist/diff/index.cjs +84 -0
- package/dist/diff/index.d.cts +76 -0
- package/dist/diff/index.d.ts +76 -0
- package/dist/diff/index.js +68 -0
- package/dist/diff/match.cjs +156 -0
- package/dist/diff/match.d.cts +17 -0
- package/dist/diff/match.d.ts +17 -0
- package/dist/diff/match.js +152 -0
- package/dist/diff/render.cjs +540 -0
- package/dist/diff/render.d.cts +2 -0
- package/dist/diff/render.d.ts +2 -0
- package/dist/diff/render.js +537 -0
- package/dist/diff/types.cjs +2 -0
- package/dist/diff/types.d.cts +211 -0
- package/dist/diff/types.d.ts +211 -0
- package/dist/diff/types.js +1 -0
- package/dist/digest/index.cjs +8 -0
- package/dist/digest/index.d.cts +4 -0
- package/dist/digest/index.d.ts +4 -0
- package/dist/digest/index.js +2 -0
- package/dist/digest/parse.cjs +163 -0
- package/dist/digest/parse.d.cts +80 -0
- package/dist/digest/parse.d.ts +80 -0
- package/dist/digest/parse.js +160 -0
- package/dist/digest/render.cjs +1575 -0
- package/dist/digest/render.d.cts +41 -0
- package/dist/digest/render.d.ts +41 -0
- package/dist/digest/render.js +1571 -0
- package/dist/digest/types.cjs +10 -0
- package/dist/digest/types.d.cts +86 -0
- package/dist/digest/types.d.ts +86 -0
- package/dist/digest/types.js +9 -0
- package/dist/index.cjs +61 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +19 -0
- package/dist/page.cjs +35 -0
- package/dist/page.d.cts +40 -0
- package/dist/page.d.ts +40 -0
- package/dist/page.js +31 -0
- package/dist/playwright.cjs +138 -0
- package/dist/playwright.d.cts +69 -0
- package/dist/playwright.d.ts +69 -0
- package/dist/playwright.js +131 -0
- package/dist/query.cjs +1152 -0
- package/dist/query.d.cts +47 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +1146 -0
- package/dist/snapshot/capture.cjs +80 -0
- package/dist/snapshot/capture.d.cts +34 -0
- package/dist/snapshot/capture.d.ts +34 -0
- package/dist/snapshot/capture.js +77 -0
- package/dist/snapshot/cdp.cjs +450 -0
- package/dist/snapshot/cdp.d.cts +36 -0
- package/dist/snapshot/cdp.d.ts +36 -0
- package/dist/snapshot/cdp.js +447 -0
- package/dist/snapshot/clip.cjs +100 -0
- package/dist/snapshot/clip.d.cts +26 -0
- package/dist/snapshot/clip.d.ts +26 -0
- package/dist/snapshot/clip.js +96 -0
- package/dist/snapshot/collect.cjs +358 -0
- package/dist/snapshot/collect.d.cts +18 -0
- package/dist/snapshot/collect.d.ts +18 -0
- package/dist/snapshot/collect.js +355 -0
- package/dist/snapshot/color.cjs +1039 -0
- package/dist/snapshot/color.d.cts +316 -0
- package/dist/snapshot/color.d.ts +316 -0
- package/dist/snapshot/color.js +1026 -0
- package/dist/snapshot/facts.cjs +167 -0
- package/dist/snapshot/facts.d.cts +48 -0
- package/dist/snapshot/facts.d.ts +48 -0
- package/dist/snapshot/facts.js +161 -0
- package/dist/snapshot/limits.cjs +69 -0
- package/dist/snapshot/limits.d.cts +59 -0
- package/dist/snapshot/limits.d.ts +59 -0
- package/dist/snapshot/limits.js +65 -0
- package/dist/snapshot/not-measured.cjs +44 -0
- package/dist/snapshot/not-measured.d.cts +14 -0
- package/dist/snapshot/not-measured.d.ts +14 -0
- package/dist/snapshot/not-measured.js +41 -0
- package/dist/snapshot/types.cjs +2 -0
- package/dist/snapshot/types.d.cts +238 -0
- package/dist/snapshot/types.d.ts +238 -0
- package/dist/snapshot/types.js +1 -0
- package/dist/spec/index.cjs +9 -0
- package/dist/spec/index.d.cts +15 -0
- package/dist/spec/index.d.ts +15 -0
- package/dist/spec/index.js +6 -0
- package/package.json +141 -0
- 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
|
+
}
|