Releases: vibesyemmy/obsrv
Release list
v0.62.1
What's Changed
- board: claim chore-live-walk-short-step-probe by @vibesyemmy in #384
- Hand a1 to Kenya, since Rook is away by @vibesyemmy in #385
- board: Kenya claims bug-dev-app-exited, reassigned from Rook by @vibesyemmy in #386
- Measure the text-scale confirm: the fix holds, a narrow undisclosed case survives by @vibesyemmy in #387
- board: walk-visible probe results (recovered from prior session) by @vibesyemmy in #390
- c5's last producer: pin the uncovered sentence whole, not by its opening by @vibesyemmy in #389
- Close the sentence #377 left open: the instrument is where the bug is now by @vibesyemmy in #391
- c5: the unfired column is closed, and the card is not by @vibesyemmy in #392
- A script that answers "may I run this spec on someone's desk" by @vibesyemmy in #393
- Record live-drive:1069's first sighting, with what it is and is not evidence for by @vibesyemmy in #394
- board: bug-dev-app-exited, a second sighting, this one attributable by @vibesyemmy in #395
- a1: bring chore/signing current on main, add the CI identity check by @vibesyemmy in #397
- A capture that settles under a rescued epoch says so by @vibesyemmy in #388
- chore-raster-resizing-sentence-unasserted: found already covered, tightened the one gap by @vibesyemmy in #396
- board(c5): one bar for "observed", and the remainder is three by @vibesyemmy in #401
- chore-capture-adds-page-globals: scope SCROLL_HOST_SCRIPT, matching the other three call sites by @vibesyemmy in #403
- chore-uninstall-remove-end-to-end: a real filesystem, and a real gap found building it by @vibesyemmy in #404
- chore-shadow-collection-edges: one target per host, and a slot's fallback text measured by @vibesyemmy in #405
- board: bug-dev-app-exited, resolved -- Opeyemi closed the window himself by @vibesyemmy in #406
- test(e2e): observe walkCoverage.ts:200 on a page whose scroller is closed-shadow by @vibesyemmy in #402
- feat(ci): plugin-tag job refuses to go green before npm has that version by @vibesyemmy in #399
- One bar for "observed", not two written weeks apart by @vibesyemmy in #400
- Move the live-raster card to Done: all three acceptance lines met by @vibesyemmy in #398
- fix: check the content before claiming a file in a shared directory by @vibesyemmy in #408
- 0.62.1 by @vibesyemmy in #409
- fix: unsigned dist build no longer attempts ad-hoc code signing by @vibesyemmy in #410
Full Changelog: v0.62.0...v0.62.1
Obsrv 0.62.0 — every measurement reaches into shadow roots, and a raster only vouches for what it painted
Highlights
- Shadow DOM, everywhere.
obsrv audit,obsrv lint,obsrv inspect(at a point), and the live walk all now measure inside open shadow roots instead of stopping at the host element. (#293, #306, #257) obsrv uninstall --removeactually removes the app files it finds, on top of the existing dry-run listing. (#298, #335)- First launch says when the render isn't at true physical size — a diagonal hint appears on the first window until scale is confirmed. (#325)
- Tabs can be dragged for real, with main reporting where one landed. (#244, #245)
Fixes
- A live raster no longer vouches for a layout the pane has already left — a capture mid-resize is now named as motion, not reported settled. (#314, #330, #361)
- Never-painted pixels in a raster are disclosed as transparent, not reported as "still painting." (#302)
- The raster's layout epoch now bumps when a text-scale change actually lands, not when it's requested — closes a race where a capture mid-text-scale-change could go unflagged. (#382, #363)
- The toolbar's address field no longer loses what you were typing to a navigation answer that arrives mid-keystroke. (#239)
obsrv inspect's footer reports the colour the screen actually shows, with contrast read off the same value. (#225)- A visible child under a hidden parent is now drawn — inspect previously said it wasn't. (#218)
obsrv --helpanswers from the CLI's own parser, before any Electron lookup. (#216)- A redirect's second address could commit twice under
sync; repeats now collapse before comparing. (#213) - A load-timeout disclosure now only names a throttle when one was actually in force. (#221)
- A measurement taken right after a history move now says which page it measured and who moved the tab. (#222)
Also in this release
Most of this cut's PR volume (roughly 150 of ~165 merges) was board process and a long flake/desk-safety investigation, not product code: bug-e2e-takes-the-desk closed for good after six measured runs found zero unexplained activations, local e2e runs now leave CLI specs out by default (so a local run can't take your desk by accident), and a ci-flake-check.js tool that reads a run's real e2e summary instead of trusting the checkmark.
Full Changelog: v0.61.0...v0.62.0
Obsrv 0.61.0 — the MCP tools drive the app with only the npm package, and both surfaces answer the same question
Restart your MCP session after upgrading
Not optional for this release. MCP output schemas are
additionalProperties: false, and 0.61.0 adds fields and an enum value, so a session that listed the tools before the upgrade can reject a reply that is entirely correct. It looks like a validation error naming a field, on a call that worked yesterday. A new conversation, or reconnecting the MCP server, fixes it.Update the app too. Live measurements are made by the app, and many fixes below are in the app. A 0.61.0 MCP server still drives a 0.60.0 app, but that app's fixes aren't there: live
lintandinspectstill judge contrast withoutopacity, a refused throttle is still reported as applied, andcolorPaintedis absent.
The MCP tools drive the app live on a machine with only the npm package
With no Obsrv.app installed, the MCP tools launch the package's own copy of the app. That copy named itself "Electron", not "Obsrv", so it wrote its discovery file where the tools never look. The first call waited 12 s, then fell back to headless and said "the next call will find it". The next call didn't, and blamed "an older Obsrv version". Anyone with only the npm package has never had live drive.
The app now names itself Obsrv before it reads any path. On a fresh macOS machine with nothing installed but the npm package, the first obsrv_snap comes back mode: live, launched: true in 14.5 s, Electron's download included. The next calls took 0.3 s and 1.1 s. The package's copy now keeps its data where Obsrv.app does, and the two share a single-instance lock.
If you used the npm package's MCP tools before 0.61.0 with no app installed, that app's settings, history, tabs, log and Chromium profile are under ~/Library/Application Support/Electron and ~/Library/Logs/Electron, directories other unnamed Electron apps share. They don't carry over. The README says how to tell which files are Obsrv's, and when the whole directory is.
Both surfaces now answer the same question under the same name
A sweep compared every field the live and headless surfaces return. Where one name meant two things, the fix is the break. Each is in docs/breaking-changes.md, with what breaks and what to do.
- Live
obsrv_snapanswerspresetandprofile.presetIdandprofileIdare gone. - Live
settledmeans the page went paint-quiet, as it does headless. It used to mean the app had confirmed the navigation, so a still-animating page came backsettled: true.unsettledReasoncomes through, and it can now be"resizing". - The rule on audit, lint and diff:
warningsis about the page,notesis about the call. The live walk's sentences moved fromnotestowarnings. The other tools:obsrv_reporthasnotesabout the call andwarningson each screen;obsrv_inspecthas onlynotes;obsrv_snapandobsrv_drivehave onlywarnings.
- After a refused throttle,
throttlenames the conditions the page loaded under:"none"on a fresh render, on both surfaces. In 0.60.0 the CLI echoed the flag you passed, and the app reported the refused throttle as applied. A report whose screens disagree still states the throttle asked for, and says how many screens it held on. warnings[]entries no longer begin withwarning:. The stderr line keeps the label.rotatearrives, andorientationis deprecated.orientationis relative to how a preset is stored, and'landscape'means rotated. Every monitor and laptop preset is stored landscape, soorientation: 'landscape'gave, and still gives, a portrait screen.rotate: true(--rotate) turns the screen a quarter turn, whatever the preset.obsrv_snapandobsrv_driveanswerrotated.- A headless
snapof a monitor asked forlandscapewarns that it came out portrait. - A
rotateandorientationthat disagree are refused wherever rotation applies. Live audit, lint and inspect say they ignored both. orientationkeeps its meaning.
- The CLI's snap JSON gains
url.
Replies a validating client was rejecting
Two replies carried keys their schemas didn't list, so a client that checks output against the published schema refused them:
- every
obsrv_drivereply since 0.17.0 (visionType,visionSeverity); - every live
obsrv_snapsince 0.26.0 and 0.28.0 (onionSkin,loading).
Both are declared now. obsrv_drive also gains deviceScaleFactor, and obsrv_inspect's readout gains colorPainted: the colour the screen actually shows once opacity is applied. Both are absent from an app older than the field.
Measurements say when they can't be trusted
- A page that is still moving says so.
audit,lintandreportmeasure again 250 ms later. When boxes moved, the warning says how many elements moved and how far, and that a repeat run won't agree on their coordinates. - The
lintandinspectcommands and tools include CSSopacityin contrast, multiplied down the ancestors. White text underopacity: .62on near-black was judged 19.56:1 and is now 7.72:1. Expectlintto report contrast failures it used to pass. That is the fix. inspect:- An element under
display: noneorvisibility: hidden, on it or an ancestor, says it isn't drawn and that its contrast verdict describes nothing a reader sees. - An invalid selector says it's invalid, instead of answering like no match.
- A point outside the screen says so.
- An element under
- A walk that stopped short says why, from a measurement: the page didn't grow (so something held it) or it grew as it was walked.
- A refused throttle is in the reply (
warnings, ornotesfor inspect), not only on stderr. groupsOnly: truesays the list was left out, so an emptyfindingscan't read as a clean page.- An onion skin the screen can't have is refused with the reason, and a resize that drops the skin's reference turns the skin off, instead of showing a value over nothing.
--full-page --single-surfacewaits up to 2 s for the page to take the taller surface before judging whether it lays out against the viewport height, and says so if it never did.
The app
- Drag tabs to rearrange them. The new order comes back on relaunch.
- The Chromium disk cache is capped at 256 MiB. A working profile had reached 1.3 GB, 915 MB of it cache. Five cold and warm runs of the same page moved no result field.
- An address typed in the toolbar answers within 30 s, instead of staying pending on a page that never finishes loading.
- An agent that switches tabs and changes the screen in one call changes the tab it switched to, not the one it left.
- Live audits, lints and inspects no longer blame the page for Obsrv's own navigation. "The page navigated after it loaded" had been describing Obsrv's own window restore, and its own mirroring of a redirect between the two panes. A redirect of a load Obsrv issued now counts as that load arriving, so each pane loads the page once.
- On the call that launches the app,
statusdescribes one screen: the restored tab's preset and its size, not that preset beside the default 1920x1080. - Every line of
obsrv.lognames what wrote it:appfor the packaged app;dev:for an unpackaged copy, including the npm package's;lane:for the dev lane;testfor a test.
The CLI and the MCP server
obsrv --version(-v) works on the machine that is broken: no build, no Electron download. In 0.60.0 it downloaded Electron and then answered "unknown command".- The Dock icon a CLI run flashes up is gone sooner on macOS.
- The MCP server clears its own temp directories older than a day, when it starts. That includes the captures, diffs and reports its tools wrote there, so copy anything you want to keep.
The skill
The obsrv-screens skill:
- names the version it describes;
- tells the agent to restart its session after an upgrade;
- states which list a sentence lands in;
- shows
obsrv --version.
The plugin carries it. If you installed it with obsrv install-skill, run obsrv install-skill --force from the 0.61.0 CLI. Without --force it won't replace an older copy.
Documentation
- What Obsrv cannot do: the boundary in one place.
- Thresholds: where the audit and lint thresholds come from, what each was calibrated against, and what would move it. It covers size, thin text, contrast, edges, shadow share and page motion; the image rules aren't there yet.
- Breaking changes and compatibility: the register, and the rule it is kept by.
- Agent control: every control command, and what an agent can do to your machine.
- Privacy and files in the README: the network requests Obsrv makes, the files it writes, and what its log records.
Known, not fixed in this release
- After back, forward, or a link clicked in the native pane, a live audit or lint no longer says the page navigated after it loaded. After a reload, a live audit no longer does. 0.60.0 said so in those cases.
urlstill names the page that was measured. It's a side effect of stopping Obsrv's own mirrored navigation from triggering the warning. - The app's Inspect footer judges contrast on the stated colour and ignores
opacity. Theinspectcommand and tool account for it. - Live and headless walks can cover a page that grows as it is walked differently.
- After a refused throttle, a load timeout says "under --throttle none".
- **
obsrv --helpdo...
Obsrv 0.60.0 — the inspector's second click is no longer swallowed
The inspector's second click is no longer swallowed
Open the target's inspector from the View menu, then click the menu item again straight away to close it: nothing happened. You had to click a third time.
Present since 0.24.1, and in every version since — this is not a regression from 0.59.0.
The toggle refuses a second click while an open is in flight, which is deliberate: a close issued in the beat after the inspector's frontend loads trips a check in Chromium's teardown and takes the app down with it — 3 of 3 attempts, against 0 of 3 for the same close issued from a timer. Refusing meant dropping, and the window it drops into is invisible from outside — the inspector reports itself open before the event saying so has fired, so a close aimed at what looks like an open inspector goes nowhere at all.
A click that arrives in that window is now held and applied once the inspector is really open. One click is held, not a queue, and what is held is the intent to close rather than "toggle again" — so an inspector you shut by hand while it was still opening cannot have your close turned into a re-open. The held click is released from a timer rather than from inside the event handler, which is what keeps the original crash fixed.
One consequence worth knowing: two rapid clicks now leave the inspector closed where they used to leave it open. That is what two clicks on a toggle mean; the old result only looked right because the discarded click was invisible.
Found by CI, in a way worth recording: 0.59.0's own release build went red on this, the test timing out on a loaded runner where the invisible window is wide enough to swallow a click.
Install
This release is the app. The fix is in the application menu, which only the desktop app builds — the CLI and the MCP tools drive Obsrv through its control socket and have no View menu to click. The npm and plugin channels carry the version rather than the fix, and still want updating so the three stay in step (the plugin's MCP server is pinned to getobsrv@0.60.0).
- App: the DMGs below. This is where the fix is.
- CLI and MCP:
npm i -g getobsrv@0.60.0, or let the plugin fetch it — the version, to stay in step. - Plugin:
claude plugin marketplace update obsrv && claude plugin update obsrv@obsrv, then restart the session.
The DMGs are unsigned. macOS will call the app damaged on first launch; clear the quarantine flag with xattr -dr com.apple.quarantine /Applications/Obsrv.app, or install from npm with npm i -g getobsrv.
Obsrv 0.59.0 — one meaning for `url`, and answers that name the page they measured
url now means one thing, and for live callers that is a change
If you read the url field of obsrv_audit, obsrv_lint or obsrv_inspect in live mode, it answers differently than it did in 0.58.0. It used to report the page the app ended up showing; it now reports the address the call asked for, which is what the headless mode has always answered.
obsrv_audit { url: ".../private" } // the route redirects to /login
0.58.0 live → url: ".../login" headless → url: ".../private"
0.59.0 live → url: ".../private" headless → url: ".../private"
The old behaviour was documented — "the argument (headless) or what the app reports showing (live)" — so this is a deliberate divergence being closed rather than a bug being repaired. It mattered because url is the only machine-readable address in the result: a script, a CI gate or a dashboard reading fields alone could not recover what had been asked for without parsing English out of a warning.
Where the landing address lives now. The note still names both — "the load of …/private ended at …/login" — and obsrv_drive's status reports the current page as a field, "The URL the target pane reports showing". That is a fresh read, so on a page still moving it can disagree with the audit that preceded it; the note remains the record of where those figures came from. obsrv_snap is the exception and keeps meaning the page captured, since a capture is of the landing by definition.
A page that hides itself now says so, on every surface
Eight notes were sweeping past facts they already held. The sweep that found them was not another live run: every defect the week produced belonged to one family — a sentence guessing at something the measurement was holding, or a silence that fits two opposite facts equally well — so the family was enumerated by driving eleven page shapes through each surface and printing what each actually said.
- A locked page with no dialog semantics said nothing at all. Strip
role="dialog"from a fixture, change nothing else, and a page whose panel scrolled five screenfuls while the page never moved produced an empty answer. The note is keyed on the measurement now — an element scroller on a locked document — and the dialog wording is layered on when the role is there. - The live path guessed on an error page whose HTTP status it was already holding, and never said which page it had measured. A live audit of an empty 404 offered "a page rendered by script that had not run yet, a bot wall, or an empty document" with the answer sitting in a field nobody read.
hiddenmeant two different things across the surface boundary —overflowHidden()headless,scroller === 'root' && overflowHidden()live — so the live reply was structurally unable to express the case above. The measurement and the role are now separate fields.- A components page said the same thing twice and the two disagreed about whether its light DOM had text.
- The share counts what a measurement would keep, on both sides. The old denominator was a raw
querySelectorAll, so "26 of this page's 29 text elements" sat beside a sentence saying the page had no visible text. - Plus: a sentence that described the reader's layout ("the figures above") and is false in JSON; a wall's second sentence that restated the first three ways; and a stale evidence claim in two places, citing a live site for a case only a fixture had ever produced.
The live path counts commits instead of comparing addresses
A page that moves while it is being measured is measured somewhere other than where you pointed it. Headless has watched for that since 0.58.0; live compared the address at the start against the address at the end, and could not see any movement that returned to its own starting value — most plainly a page that reloads to the same address, where the strings match on both sides of a document that has been replaced. The tab now counts committed navigations, and a count cannot return to its old value.
The walk describes one page
walked: { screenfuls, atEnd, ms } could describe two documents at once. A page that replaced itself mid-walk kept counting: driving the live app at a 7,377 px page — six scrolls from top to end — reported 10 screenfuls, with atEnd: true vouching for the end of a document that no longer existed.
Headless failed in the other direction. The replacement starts at the top, so its first scroll landed exactly where the old document had already reached; the walk read that as a page that would not move, stopped after one screenful, and blamed "a locked scroll, or a page that scrolls by other means" — a guess printed one line under the note that had just said the page navigated. The figures themselves were unharmed on a fixture whose content is all present at load: the audit measures the DOM, not what the walk saw, and the same 18 text elements came back before and after. What a one-screenful walk does cost is real but narrower than it looks — anything that loads as you scroll never loaded, and the coverage the answer claimed was of a page it had barely moved.
The walk now restarts its count, its remembered offset and its reported clock on a commit, so every field of walked describes the document that was measured. The budget keeps a separate clock from the start of the walk — 15 s on both surfaces — so a page that replaces itself on every scroll still stops rather than walking forever.
Install
- App: the DMGs below.
- CLI and MCP:
npm i -g getobsrv@0.59.0, or let the plugin fetch it. - Plugin:
claude plugin marketplace update obsrv && claude plugin update obsrv@obsrv, then restart the session.
The DMGs are unsigned. macOS will call the app damaged on first launch; clear the quarantine flag with xattr -dr com.apple.quarantine /Applications/Obsrv.app, or install from npm with npm i -g getobsrv.
0.58.0 — what it could not measure, and what was holding the page
Three silences, found by pointing Obsrv at pages it had never been pointed at and by reading what it said out loud. In each one the tool had already measured the answer and said something else, or nothing (docs/research/2026-09-12-live-run-0.58.0-candidate.md).
A page that hides most of itself now says how much
A page with twelve controls in the light DOM and forty inside web components answered 12 targets, 12 findings, 0 warnings — a complete-looking measurement of a quarter of a page. The counts existed: shadowContent() runs on every measurement. They were read in four places and every one was inside a guard for the page being completely empty, so the rare case was covered and the common one was not, in the app as much as in the CLI.
4 shadow roots hold 40 of this page's 52 interactive elements, which the measurement does not enter: the figures above are of the light DOM alone
The denominator is measured, not borrowed — the light DOM counted with the same selector and the same text rule. The audit's own target count would have been the wrong number to divide by, because it exempts inline text links (gov.uk: 102 of 125), so 40 of 12 was a sentence that choice could produce.
It speaks when a real share of the page is hidden or when a real number is. A share answers "how much of this page did I miss"; a count answers "how much work is unchecked". They agree on a fifty-element page and part company on a large one, where 60 hidden controls is 10.7% and was silent. The count is the audit's alone: text elements per interactive element measured 1.15, 1.19, 1.47 and 5.75 across four live sites, so a count of text is a different amount of page on each of them, and lint keeps the share, which scales by construction.
This also makes good a promise 0.57.0 made and did not keep. Every run was supposed to report how often components hide a page, as the evidence for deciding whether to enter shadow roots at all. Until now it reported only on pages that measured as nothing.
The walk names the wall instead of listing three things it might be
A page whose scroll is locked used to be told: content "in an iframe, in a shadow root, or in a container that scrolls by transform". On ft.com it was handed all three while the measurement held a 100% iframe and a shadow-host count of zero. The sentence was a bare constant and could not consult anything.
an
<iframe>covers 100% of the viewport and the page beneath it did not move — a consent wall, a paywall or an onboarding layer holds it
Where nothing measured explains it, one guess remains — a transform-scrolled container cannot be counted from outside — and it is now one guess about a page two facts have been ruled out for. A caller that measured neither still gets the old list, rather than a confident claim about a look nobody took.
The sentence beside it stops hedging what that one just named: it no longer offers "a modal or a locked scroll held the page, or it grew after the walk" for a page that never moved.
The answer says which page it measured when the load moved
Ask for an authenticated route, have the dev server bounce you to a login, and the audit measured the login page while reporting the address you typed — two targets where you expected fifty, and nothing said. A redirect during the load that ends in a 200 fell between the two fixes 0.57.0 shipped: no post-load navigation to report, and a perfectly good final status.
the load of
…/privateended at…/login: a login wall, a route that has moved, or a redirect the server chose — the figures are of the page it landed on, not of the one asked for
The cause list narrows on a fact the note already holds: a landing page that answered an error was not a login wall, so that cause goes. On a 404 the sentences stop repeating each other — the contrast is carried once, and the empty-page guesses go where the status has disproved them.
This is the headless path — the CLI and the MCP tools. Driving the visible app takes another route through the main process, which still says nothing for either case.
Also
1 shadow root hold 12 interactive elements— the noun was singularised and the verb was not. Shipped in 0.57.0 and never printed until a page had exactly one open host; the test that should have caught it asserted the noun alone.
Install
- App: the DMGs below.
- CLI and MCP:
npm i -g getobsrv@0.58.0, or let the plugin fetch it. - Plugin:
claude plugin marketplace update obsrv && claude plugin update obsrv@obsrv, then restart the session.
0.57.0 — measured in the light DOM, and the page it was measured on
Two fixes, both found by pointing Obsrv at the thing its users actually point it at: work in progress on a dev server, and a page built from components. Twelve earlier rounds of live testing had only ever driven finished public websites (docs/research/2026-09-12-live-run-0.56.0.md).
A page built from web components is named as one, not called empty
chromestatus.com/features answered 0 targets and 0 text, and said the page was "a page rendered by script that had not run yet, a bot wall, or an empty document". All three were false. The page holds 159 open shadow roots with 139 interactive elements in them, and querySelectorAll does not cross a shadow boundary — so the light DOM the measurement reads is genuinely empty while the page plainly is not. The answer even contradicted itself: it had walked three screenfuls of a 3,682 px page, which an empty document cannot do.
The measurement now counts what is behind the boundary and says it:
nothing to measure in the light DOM: … but 159 shadow roots hold 139 interactive elements and 145 text elements the measurement does not enter — the page measured is built from web components, not empty, and no wait brings its content into the light DOM
The advice to wait longer goes with the guesses, because no wait can help. Closed shadow roots are not counted: they cannot be reached from script, and a number that pretended otherwise would be a guess of exactly the kind this removes.
This is deliberately naming the limit rather than traversing it. Every run from here reports how often components hide a page and how much they hide, which is the evidence for deciding whether to enter shadow roots at all. The hard part of traversal is not reaching the elements — it is that a finding carries a locator that must still resolve from the document, and ids inside a shadow root do not.
The answer names the page it measured, not only the page it was asked for
Two ways the figures could be about a page you did not ask for, both ordinary in development and both silent until now.
A page that navigates while the measurement waits — hot reload after a save, an auth redirect, a router replacing the URL — was measured where it ended up and reported under the address you typed. It now says which page the figures came from.
A page the server answered with an error status was measured as though it were the page. A mistyped route, a stale dev server or the wrong port all land there. It now says: the server answered 404 Not Found … the figures are of the error page it sent, not of the page asked for — check the route, the port, and that the server has it.
The url field still means the address that was asked for; changing it would break every caller that reads one. The arrival and the status are said in notes instead.
Where a page has both — an error page built from components — the status comes first, because when nothing else names the page, the status is what tells you that everything after it describes the wrong one.
Install
- App: the DMGs below.
- CLI and MCP:
npm i -g getobsrv@0.57.0, or let the plugin fetch it. - Plugin:
claude plugin marketplace update obsrv && claude plugin update obsrv@obsrv, then restart the session.
0.56.0 — the walk says which thing happened
One fix, found by driving 0.55.0 against seven live sites (docs/research/2026-09-12-live-run-0.55.0.md), and one change for contributors.
The walk says which thing happened, not both
A page the walk could not cover gets a sentence saying so. Until now that sentence offered two causes at once:
the walk saw the end after 9 screenfuls (10800 CSS px), but the page measures 21440 CSS px (20 screenfuls): a modal or a locked scroll held the page, or it grew after the walk
On theguardian.com, spiegel.de and nytimes.com, the first half of that is impossible: the walk had just scrolled the root of those pages to its end, nine or ten screenfuls deep. A page held by a modal cannot do that, and the walk knows it — it knows what it moved, how far, and that it reached the bottom. So the reader was being sent to look for a modal that could not be there, while the true cause sat in the clause at the end.
The walks already watch, on every step, whether the document hides its own overflow — the shape a scroll lock makes, including one that appears mid-walk. That answer now reaches the sentence, which says one thing:
…: the page grew as it was walked — a feed that extends as you scroll — so the end the walk saw was the end at the time
The old wording stays for the case it was written for: a document that was locked when the walk stopped, or a walk that never moved at all. Nothing changes for a caller that cannot tell — an older app gets the cautious sentence, as before.
For contributors: a version tag stops re-running the suite
A v* tag points at a commit that was pushed to main and tested there minutes earlier. The tag build ran the whole suite over it again — about fourteen minutes of macOS CI for an answer already held — and because the DMG publish waited on that redundant run, a flake in it skipped the release: twice in a row (0.54.0, 0.55.0), each time leaving npm live with a release page carrying no downloads until someone noticed.
The suite now sits out version tags, and a tested-on-main job states the invariant the release actually depends on: the tagged commit is on main, and main's own CI went green for that exact SHA. A tag cut from a side branch, or pushed before main finished, fails with which of the two was missing rather than publishing DMGs nobody tested.
Install
- App: the DMGs below.
- CLI and MCP:
npm i -g getobsrv@0.56.0, or let the plugin fetch it. - Plugin:
claude plugin marketplace update obsrv && claude plugin update obsrv@obsrv, then restart the session.
0.55.0 — a measurement that loses something says so
Two fixes to the same habit: a measurement that lost something and did not say so. Both were found by driving 0.54.0 against live sites (docs/research/2026-09-12-live-run-0.54.0-candidate.md), and both are pinned on fixtures rather than on anyone's home page.
The walk says when it scrolled a dialog rather than the page
A consent wall, a paywall or an onboarding modal fixes the body in place. That collapses the document's scroll height and leaves the dialog's own panel as the only scroller on the page — so the walk finds that panel and scrolls it, then reports walked: { screenfuls: 5, atEnd: true }, which reads as a page walked to its end when the page never moved at all. Anything your page loads as it scrolls was never brought in, and was measured as absent.
Both walks now say so:
the walk scrolled a dialog, not the page: this page hides the document's overflow while a dialog is open, so the 5 screenfuls above are that dialog's and the page never moved — content the page loads as it scrolls, and anything below the first screen, was not brought into view before measuring
Three conditions together, so nothing else starts warning: the walk scrolled a container rather than the page, that container sits inside a <dialog>, [role="dialog"], [role="alertdialog"] or [aria-modal="true"], and the document hides its own overflow. An app shell whose inner container is the content stays quiet, and so does a dialog over a page that can still scroll. Named dialog semantics only: a page locked by an anonymous div says nothing rather than the wrong thing.
This is not the walk-coverage note again. That one compares what the walk covered against the page it measured, and catches a short dialog; a dialog that scrolls far inflates the apparent coverage until the arithmetic looks satisfied, which is exactly when this one speaks.
An entry the checks refuse costs itself, not the page
0.54.0 fixed the bound that made reuters.com lose a page's 365 text elements and 49 images over one srcset descriptor. It did not fix the shape of that failure. Every per-entry check in the audit's and the lint's parsers now drops the entry that failed and counts it; only what cannot be salvaged — no viewport, a list past the bound — still refuses the report whole, because there is no partial answer to give there.
Dropping quietly has its own risk: a producer and a validator disagreeing about what can arrive would read as a thin page rather than as a bug. So the count is the first warning, with the kind named — "412 of 415 text elements the page sent were dropped" is a bug report where a bare count is noise. Nothing changed in any tool's output shape.
A page whose only element was dropped also used to come back as "nothing to measure: the page had no visible text", which is false — the page had text and the measurement refused it. A report with dropped entries is no longer read as an empty document.
Install
- App: the DMGs below.
- CLI and MCP:
npm i -g getobsrv@0.55.0, or let the plugin fetch it. - Plugin:
claude plugin marketplace update obsrv && claude plugin update obsrv@obsrv, then restart the session.
0.54.0 — a srcset is read as written, and a refused report says so
Two fixes from driving 0.53.0 against arxiv.org, reuters.com, wise.com, w3.org and duckduckgo.com (docs/research/2026-09-11-live-run-0.53.0.md), and the load-error state rebuilt. The result was then driven against bbc.com, stripe.com, github.com, developer.mozilla.org, airbnb.com and vercel.com before this cut (docs/research/2026-09-12-live-run-0.54.0-candidate.md).
A srcset is read as written, comma or no comma after it
reuters.com writes url 60w,url 240w — no space after the comma, which the HTML grammar allows and CDN markup commonly emits. Splitting on whitespace alone glued the next URL onto the descriptor, so a candidate came back 254 characters long, and the checks that keep a page's report honest refused it: 365 text elements and 49 images discarded because one image's srcset was written the legal way.
obsrv lint https://www.reuters.com --preset 1080p-24 exited 1. It now answers with 30 oversized images in three srcset groups. The split separates a descriptor from a URL glued to it, which is the only position a comma can end a candidate, so the commas inside a data URL are still left alone.
A malformed detail costs one image its detail, not the page its lint
A srcset descriptor is short by nature — "60w", "2x" — so one past 16 characters is now dropped on its own, as is an over-long chosen descriptor and any candidate past the twelve the page sends. Everything that is the measurement — a box, a colour, a font size, a natural size — is still refused whole, because half a page's figures are worse than none.
And when a report is refused, the reason survives. "the page did not answer the lint (it may have navigated away, or thrown while being measured)" was a guess, and on reuters.com both halves of it were false: the page had answered in full and promptly. The measurement now remembers that an ask answered and its report did not pass checking, and the CLI and the running app say so.
A failed load looks like the rest of the app
A tab whose address would not load is the same kind of moment as a tab with no address at all: nothing to show, and one way out. It was drawn as a bordered panel in the middle of the window, which read as a dialog over work that was not there. It now takes the empty state's column exactly — an illustration, the address and the reason, and the control — standing in the surround.
Install
- App: the DMGs below.
- CLI and MCP:
npm i -g getobsrv@0.54.0, or let the plugin fetch it. - Plugin:
claude plugin marketplace update obsrv && claude plugin update obsrv@obsrv, then restart the session.