browser-verification-measure
Pattern of rule browser-verification. Load when a conclusion about layout needs a number behind it — ready-made measurements, a computed value broken down over all nodes, a narrow screen through an if
SKILL.md.claude/skills/browser-verification-measure/SKILL.md
GitHub<!-- rt-kit v0.35.0 · patterns/browser-verification-measure.md · a17eb7cd05da · правится надстройкой, не здесь -->
Pitfalls of the
Measurement instead of a look
Pattern of the rule browser-verification. What must be true — the law
docs/constitution/verifiability.md.
When to use
- Layout was edited, and the result needs confirming.
- The screen looks wrong, and there is nothing to search for: a search over the code answers "clean".
- A narrow screen is under check.
A conclusion is backed by a number
getComputedStyle, getBoundingClientRect, contrast, matching centres, landing in the viewport.
"Looks fine" is never a check result.
A measurement answers only the question asked. A match on the listed properties says nothing about a rule that is not in the measured list. The rows of the profile popup matched the reference by padding, font size and rounding, while the reference paints no hover background there at all — a full-width highlight held on for two rounds with correct numbers. If the reference sources are available, the divergence is found by reading, and the measurement stays a check of the result.
The look of an element that today is visible on no screen is not confirmed by a measurement, and a rule about it stays a hypothesis.
The waiting state is measured on a delayed request
A button in waiting, a list skeleton and a loading bar live exactly as long as the request runs: on a stand that is a fraction of a second, and no measurement call can catch them. The request itself is delayed, not the measurement.
The technique has three parts, and the third is mandatory: the network call is substituted, the answer is held for a few seconds, and it ends with a refusal. A real answer would leave a real record on the stand — that is how an extra row got into the stand's database.
globalThis.__realFetch ??= globalThis.fetch;
globalThis.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input.url;
if (!url.includes('<the piece of the address being held>')) {
return globalThis.__realFetch(input, init);
}
return new Promise((resolve) => {
setTimeout(() => resolve(new Response('{}', { status: 500 })), 5000);
});
};
'substituted';
The substitution is removed by assigning the saved one back: the page lives until reload, and a forgotten substitution leads the next measurement astray.
The sign that this is exactly the case: the button on the screenshot in its ordinary look, while the request has already gone out in the list of network calls. Assembling the technique anew each time costs more than reading it here: over one epic it was assembled three times.
A value that is not in the code
The typeface, line-height, colour and appearance of form elements are set by the browser, and
the tree has none of these values. A search over the code answers "clean" for such a defect, the
linter and the build are silent. It is found by breaking the computed value down over all nodes of
the page, not by measuring a couple of elements:
[...document.querySelectorAll('*')].reduce((acc, el) => {
const key = getComputedStyle(el).fontFamily;
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});
That is how 91 controls out of 106 set in the wrong typeface were found on the admin dashboard, and 13 out of 87 on the site's main page. The count fits any inherited property: what is looked at is not one value but the number of nodes with an unexpected one.
Measurement across all thresholds at once
A measurement at one width per call gives numbers from different minutes: between calls the page is redrawn, and they cannot be compared. So the widths are run as a batch in one pass — each lives in its own frame, and the measurement is taken from all of them at once:
await (async (address, widths, row) => {
const frames = widths.map((width) => {
const frame = document.createElement('iframe');
frame.style.cssText = `width:${width}px;height:900px;border:0;position:fixed;left:-9999px`;
frame.src = address;
document.body.appendChild(frame);
return { width, frame };
});
await Promise.all(frames.map(({ frame }) => new Promise((done) => (frame.onload = done))));
return frames.map(({ width, frame }) => {
const view = frame.contentWindow;
const doc = frame.contentDocument;
const cells = [...doc.querySelectorAll(row)];
return {
asked: width,
got: view.innerWidth,
heights: [...new Set(cells.map((el) => Math.round(el.getBoundingClientRect().height)))],
scrolls: doc.documentElement.scrollWidth > doc.documentElement.clientWidth,
escaped: cells.filter((el) => el.getBoundingClientRect().left > view.innerWidth).length,
};
});
})('/screen', [360, 599, 600, 904, 905, 1239, 1240, 1440], '.field');
The answer is read by three signs, and the first is not about looks:
- the heights of neighbours in one row are equal — a set of values in
heightslonger than one means the fields came out different sizes; - there is no horizontal scrolling —
scrollsis true where the content is wider than the frame; - no left edge goes past the frame width —
escapedabove zero. On a screenshot this is not visible at all: an element beyond the right edge looks absent.
The frame width is smaller than the ordered one by a scrollbar, so the comparison is against got,
not against the number from the list. A threshold named by the ordered width turns out on the other
side of itself in the frame.
Narrow screen
resize_window does not work when Chrome is in full-screen mode: the tool reports success,
innerWidth does not change, media queries stay desktop. Narrow widths are checked in a nested
iframe of the needed width — inside it matchMedia counts from the frame; at width: 375px and
border: 2px the inner innerWidth equals 371.
Changing the number of elements in a container is a layout edit: it is checked at 375, not only by response codes.
An input event is not replaced by a faked value
Scrolling set by assignment gives no event. The scroll value changes, but the subscriber does not fire on the document, on the window or on the root node. The sign the application computes by the event stays as it was, and the edit made reads as not made — two sessions in a row took apart a working listener.
Hence two checks, and neither replaces the other: the mechanics are checked with a real wheel, and the layout under the sign — with a sign set by hand. The class is put on the host, and what depends on it is measured; the threshold takes no part in that.
The sign that this is exactly the case: the listener is set, the scroll value changed, the event counter is zero. There is no need to take the listener itself apart after that — it works.
Pitfalls of the computer tool
- Click coordinates are the coordinates of the screenshot, not CSS pixels: at a viewport of 2560
the screenshot arrives 1568 wide, and a click at the "seen" coordinate goes astray, giving a false
signal. Recompute by the actual scale or aim through
find. - The
zoomarea must lie entirely inside the viewport. - An
awaitbetween clicks is mandatory: a synchronous loop of "clicked — read the DOM" reads the state before the redraw and returns stale values. select_browsergoes stale after 300 seconds. On a long check this fires in the middle of the work — it is not a stand failure; repeat the call and go on. The pinned profile is named by the tree itself — by an environment variable or by its own file next to the layout settings. It is asked from the helper, not remembered: the identifier is local to the machine and does not travel into the package at all.
A long run is started in the page, not in the call
A tool call is cut off by its own limit at about forty-five seconds, while the walk it started goes on — nobody will ask it for the result any more. One width from the set of screens takes about a minute and a half, so not one fits into a call.
The order is this: the call puts the walk into a page variable and ends at once, and the following calls ask that same variable whether it is done.
globalThis.__probe = { done: false, rows: [] };
(async () => {
for (const w of [375, 480, 481, 768, 769, 1080, 1081, 1380]) {
globalThis.__probe.rows.push(await measureWidth(w));
}
globalThis.__probe.done = true;
})();
'started';
The tool's answer is truncated at about fifteen hundred characters with no mark: the end of the summary looks not cut off but absent — output gathered in one pass and printed at once reads as shorter than it is. So what was gathered stays in the variable whole, and is printed in slices by index.
Router behaviour is reproduced by clicks
Substituting the address, history.pushState with popstate and entering by a direct link raise
the application anew, and it has no accumulated state — no open outlet, no guard of the previous
panel. A clean pass over the addresses reads as "the defect is not confirmed": the event feed panel
got stuck on the very first click from the menu and passed the check by addresses three times.
Common misses
- A comment in a config is a hypothesis like any other. A claim about eviction of a cache record
held on through three rounds of adversarial code review and was refuted by one
curl. - The conclusion "no defect, it is the cache" closes the investigation, so it is accepted only after a check on a clean build.
- A defect in a client chunk from a compilation before the edit looks like a defect in the code: the sign of a dev build is bundle names without a hash.