Skip to content

feat(ui): migrate the chrome to @pathscale/ui 3.1.0 - #36

Closed
pathscale wants to merge 25 commits into
masterfrom
feat/ui-3-migration
Closed

pathscale wants to merge 25 commits into
masterfrom
feat/ui-3-migration

Conversation

@pathscale

Copy link
Copy Markdown
Owner

Moves the browser chrome onto the published @pathscale/ui 3.1.0, matches the Layout compiler to it, and fixes half of a tab bug found while testing.

Dependencies

@pathscale/ui was repinned to ^3.1.0 without re-resolving, so the lockfile still named 4.0.0 — a version published in error and since unpublished.

solid-layouts-oxc was pinned at exactly 0.2.1 while the published @pathscale/ui 3.1.0 is built with 0.2.3. That is an application compiling against one version of the Layout compiler and importing a library compiled with another, which nothing reports.

Closing a tab does nothing

Not the close button, not the keyboard shortcut. Open a second tab, press ×, and both tabs stay.

It is not this migration. The same failure reproduces on @pathscale/ui 2.12.0 with the branch otherwise untouched, and bumping solid-layouts 0.2.1 → 0.2.3 does not change it. It is pre-existing.

There are two independent causes and I fixed one:

Fixed — the store never shrank. reconcile(tabs, "id")(draft.tabs) matches by key and writes the entries it is given; it does not shorten the array it wrote into. So an open landed and a close did not. Instrumenting the transport shows it removing the tab correctly (len= 2len= 1) and emitting the shorter list; instrumenting the store shows the draft staying at 2. With the explicit length the same probe shows the draft going 2 → 1.

Not fixed — the strip still does not shrink. With the store now correct, <For each={state.tabs}> still does not drop a row when the array shortens. That is in the rendering layer and I could not isolate it further without more time than this PR is worth. Left deliberately, and the store is left correct rather than also wrong, because two wrongs here make the remaining one much harder to find.

This is not on the seven-item chrome bug list from 2026-08-16; it is a new finding. Item 3 on that list (title text overlapping the ×) is adjacent but not the cause: with a real viewport, hit-testing at the button's centre returns the close button.

Verification

Driven in a browser against the dev server, with the viewport set to 1280x800 — the pane defaults to 0x0 here, which makes elementFromPoint return null everywhere and produced one wrong conclusion before I noticed:

Check Result
Chrome renders: tab strip, address bar, inspector control pass
No console errors, no [object …] text pass
+ opens a tab, 1 → 2 pass
× closes a tab fail, pre-existing, see above
Hit test at the × centre returns the close button pass
bun run typecheck (both tsconfigs) pass
bunx vitest run 2 files, 15 tests, all pass
bun run lint clean

Also worth knowing

Two buttons in the chrome have no accessible name: the new-tab button (its only content is the character +) and one button with neither a label nor text. Not fixed here — naming them is a separate change and the second one needs someone to say what it is.

meh and others added 25 commits August 31, 2026 09:42
…0-rc.4

local-ui/package.json still asked for @pathscale/ui ^2.5.0, solid-js ^1.9.5 and
solid-layouts ^0.1.3, from before the chrome was ported. Those are the peer
ranges of a workspace member, so bun resolved them for the whole workspace and
installed solid-js 1.9.14 and @pathscale/ui 2.5.0 -- the app has been building
against Solid 1 while its own package.json asked for 2.0.0-rc.0. Widening the
member's ranges is what makes the app's pins take effect.

Also adds the @iconify-json sets. index.css @sources @pathscale/ui, which uses
mdi-- tokens internally, and nothing installed them, so those icons resolved to
nothing.

babel-preset-solid moves to rc.2 behind a caret; the exact rc.0 pin could not
take a fix.

Verified: typecheck, biome, the 15 frontend tests, a production build, and the
built chrome rendering its address bar, title bar and tabs.

The scripts/solid-2-boundary.ts workaround stays. Its exit condition is
solid-layouts-oxc consulting boundaryFor, which 0.2.2 now does -- but
rsbuild-plugin-solid-layouts 0.2.1 carries its own 0.2.1 copy of the validator,
and a bun override does not dedupe it. The pair goes when that plugin
publishes against 0.2.2.
Nothing here uses daisyUI; the comment described the cascade problem in terms
of a package this repository does not install.
A patch release: no exports added or removed against 2.11.9, and the same peer
range. The caret already admitted it; this pins the lockfile to it.
`scripts/render-check.sh` has documented and exported CHUZZ_CAPTURE_WIDTH and
CHUZZ_CAPTURE_HEIGHT since it was written, and the capture entry point read
neither: it passed a literal 1440 by 960. Every capture was that size whatever
the caller asked for, and a run at another width produced a byte-identical
tree, which is how this surfaced.

That matters now because captures are about to be compared against a reference
browser. Two engines laying the same page out at different widths disagree on
every percentage width, every centred box and every responsive breakpoint, so
an unhonoured viewport turns a diff into noise that reads exactly like a
rendering fault.

Only CHUZZ_CAPTURE_SCALE was wired up, so the fix follows its shape.
The prefetch walks the parsed HTML and loads every script it names. A page
that builds a `<script>` from JavaScript asks for a URL nobody knew about
until the page was already running, and that request reached
`DefaultScriptFetcher`, which serves `file:` and `data:` only. The script was
dropped with `unsupported URL scheme for script: https`, which reads like a
policy decision rather than the missing capability it is.

Measured over a hundred-site corpus, this is the single most common engine
defect: **26 sites**, a quarter of the corpus. It hides well, because scripts
the parser found load perfectly, so a page fails only in the parts it
assembles itself, and jQuery going undefined on seven sites looks like its own
bug rather than a consequence of never having been fetched.

`ScriptFetcher::fetch` is synchronous, because classic scripts execute in
document order, so the network call blocks. It runs on the page's own provider
and so keeps the per-origin connection cap the rest of the loads obey, and it
is bounded by a timeout: a server that accepts and never answers would
otherwise hang the capture, and a missing script is better than a run that
never ends.

Verified by re-capturing the six worst-affected sites: dropped scripts went
12, 6, 4, 2, 2, 2 to zero everywhere. Rendering barely moved, which is the
honest result rather than a disappointing one. On one site the error count
rose from 9 to 14 with a new kind, `unescape is not defined`: the scripts now
run and reach the *next* missing global. This unblocks a layer; the missing
web APIs behind it are what turn that into pixels.

The test serves a script from a real socket and asserts a synchronous fetch
completes a round trip from inside the runtime driving the page. Its accept
loop has a deadline rather than blocking: restoring the defect makes no
connection at all, and a blocking accept hangs the run instead of failing it.
Confirmed by restoring the defect, which fails the test in ten seconds.

Only the capture path. `browser.rs` has the same fetcher and is reached from a
synchronous poll with no runtime handle to hand, so it needs a stored handle
and is left for its own change.
The previous change fixed the capture path and left the window with the same
defect, so the browser people actually use still dropped a quarter of the
corpus's scripts while the tool measuring it did not. Measuring correctly is
not the point of the exercise.

The fetcher moves into `script_fetch.rs` and both paths share it, rather than
the window growing a second copy of logic that must then be kept in step. The
prefetch-then-fall-back shape was already duplicated between the two; it is now
in one place.

The deadline is per caller, and the two differ for a reason worth stating.
`ScriptFetcher::fetch` is synchronous and page scripts run on the UI thread, so
in the window this blocks everything, other tabs included, for as long as it
waits. Five seconds there against the capture's ten: a capture is unattended
and a dropped script costs it the fidelity it exists to provide, while a window
has someone watching the frame. Neither number is the real answer. The real
answer is an asynchronous script-loading path in the engine, which would not
have to choose, and that is a change to blitz-script rather than to this.

The window path is the same code the tests cover and the capture exercises, but
it is not itself verified in a window: this machine has no screen access, and
the control socket sees the chrome document rather than the page's
sub-document. Re-captured after the refactor to confirm the tested path did not
move: dropped scripts still zero on the worst-affected site.
Most of the web fetches its own content, so an engine without these does not
render a slightly incomplete page: it renders the shell and stops. In a
hundred-site corpus `XMLHttpRequest` was missing on 6 sites and `fetch` on 4 by
name, and the pages laying out at 37% and 44% of a reference browser's height
are the same fact counted another way.

`blitz-script` has no way to register a host function, which is why this looked
like an engine change. It is not. Three things it already exposes are enough:
`window.ipc.postMessage` carries a string from JavaScript to the host, `eval`
carries one back, and `add_poll_hook` runs work on the document thread. The
shim parks a promise and posts the request; the handler spawns the real fetch
on the page's own provider, so it obeys the same per-origin connection cap as
every other load; the poll hook hands the answer back by evaluating a call to
the resolver.

Nothing blocks, and that is the difference from the script fetcher landed
earlier. A `<script src>` is synchronous because the HTML spec says scripts
execute in document order, so that one has to block and its deadline is a
compromise. `fetch` is asynchronous by definition, so this can honour it: the
window keeps painting and the answer arrives on a later poll. The deadline
exists only so a server that never answers cannot hold a connection forever.

Both `set_ipc_handler` calls already in the tree are no-ops on the *chrome*
document, so nothing was competing for the channel.

The tests are end to end rather than unit: a real page, a real socket, a
promise resolved through the whole bridge, asserting the page read `42` out of
the JSON and the body out of the XHR. Their accept loops carry deadlines, so a
regression that never issues the request fails rather than hanging.

Response headers are not carried yet, and `setRequestHeader` is accepted and
ignored: a page that only sets an Accept should not throw, and a page that
depends on reading headers back is not yet served. Sync XHR is not supported.
Ranked by sites affected over a 104-site corpus, after the runtime-script-fetch
fix stopped scripts being dropped and let more of them run far enough to reach
these: `Image` (4 sites), `TextEncoder` (2), `AbortController` (2), and
`ResizeObserver`, `Path2D`, `ShadowRoot` and `unescape` (1 each).

Real implementations, with nothing invented:

- `escape` / `unescape`, the Annex B pair. Pure string transforms with a
  specification, so there is nothing to fake.
- `TextEncoder` / `TextDecoder`, real UTF-8 both ways, including surrogate
  pairs, unpaired surrogates as U+FFFD, and overlong sequences rejected. The
  callers that reach for these are hashing or framing bytes, where an encoder
  that got the multi-byte cases wrong would hand back a plausible array of the
  wrong length and fail somewhere else entirely, as a bad digest.
- `AbortController` / `AbortSignal`, including `abort`, `timeout` and `any`.
  The whole of it is bookkeeping over a flag and a listener list, with no engine
  support to wait for.
- `String.prototype.substr`. Also Annex B, also absent, and this one is not on
  the corpus list and cannot be: the report counts names a page looked up and
  did not find, and a missing method on an existing prototype raises
  `TypeError: not a callable function` instead, an error class counted nowhere.
  It surfaced from writing `unescape` in terms of it and watching that throw.

Stubs, each labelled as one in the file:

- `Image` reports every image as loaded, asynchronously, without fetching. Most
  constructed `Image`s are preloaders that only need the callback. Code that
  waits for the load and then reads pixels or natural dimensions gets nothing,
  and the zero dimensions are left honest rather than invented for that reason.
  Images the document references are still fetched and painted by the engine.
- `ResizeObserver` never fires, unlike the `IntersectionObserver` above it. The
  difference is what an invented entry would have to say: visibility has an
  answer that is right for most of a page, and a size does not. The only entry
  this could deliver carries a zero `contentRect`, and a grid that divides by
  that width computes zero columns and renders nothing.
- `Path2D` really accumulates its path; what is missing is a canvas context to
  read it.
- `ShadowRoot` is declared so `instanceof` is answerable and nothing is an
  instance of it, which is the truthful answer for an engine with no shadow
  trees.

Deliberately still absent, with the reasoning in the file and a test that fails
if either appears without real data behind it:

- `getComputedStyle` (3 sites). A stub answering '' for every property is worse
  than the ReferenceError it replaces: today the script throws and stops, which
  is visible, and with a lying stub it continues, measures nothing and lays the
  page out wrongly, which reads as an engine bug.
- `ReadableStream` (2 sites). A page reaching for it wants incremental
  delivery, and a stub can only hand over everything at once or nothing.

The shim is a JavaScript string in a Rust file that nothing else in the build
parses, so a syntax error in it is not a compile error: it is a page that
renders as if the shim were absent, on every site. The tests evaluate it the way
a page does and read the answers back.
`AbortController` is real now, so the consumer side can use it. `fetch` reads
`init.signal`: an already-aborted one rejects without touching the network, and
one that aborts later settles the promise with the signal's reason.
`XMLHttpRequest.abort` was an empty function and now does the same, firing
`onabort` and returning `readyState` to 0.

Half of this is honest and the other half is named as what it is not. The
request itself keeps running: the host has already spawned it and there is no
cancellation channel back, so nothing here closes a socket. What it buys is the
observable half, which is the half pages depend on — the promise settles now,
and the handler does not run later against a component that has been torn down.
`aborting_in_flight_drops_the_answer` asserts exactly that boundary: the server
is contacted and does reply, and the page must not see the reply.

The tests now install the web-API shim before this one, in the order
`browser.rs` and `load_for_capture` both use, because `AbortController` comes
from there and this only honours a signal because it does.
…osition

The tail of the corpus's missing-globals list, past the table the handover
ranked. Three more are honest in JavaScript alone, and the rest are recorded in
the file as omissions with the reason, so the next reader does not add them from
the report.

Real:

- `DOMException`. A name, a message and a legacy code, and what pages actually
  do with one is read `error.name === 'AbortError'`. Adding it also gives the
  abort machinery the type a browser really throws, so `AbortController`'s
  default reason is no longer an `Error` wearing the right name.
- `top`, `parent`, `self`, `frames`, `frameElement`. There are no frames here,
  so a document is its own top. Frame-busting code compares `top !== self` and
  gets `false`, which is correct rather than convenient.
- `scrollX` / `scrollY` and their `pageXOffset` aliases, at 0. Honest at load,
  which is when the scripts that read them run, and the same choice
  `IntersectionObserver` above already makes: a lazy loader concludes it is at
  the top of the page and shows what is above the fold. A page that binds a
  scroll handler and recomputes from these will not see the view move; making
  them true is engine work.

Left out, with the reasoning in the file and a test that fails if any appears:

- `NodeList`, `DocumentFragment`, `CharacterData`, `KeyboardEvent`,
  `HTMLVideoElement`. `ShadowRoot` is declared precisely because nothing in this
  engine is one, so `instanceof` answering `false` is true. These are the
  opposite case: the document really does contain node lists and fragments, so
  an empty constructor would answer `false` about objects that genuinely are
  instances, and a branch meaning to take the DOM path would silently take the
  other one. They belong with the engine's DOM bindings, next to the prototypes
  they have to be related to.
- `Intl`. `String(value)` for `NumberFormat` and `DateTimeFormat` keeps a script
  alive at the cost of rendering unformatted numbers and raw date strings as
  though they were the page's own output, and the locale data behind a real one
  is not a shim.
- `ActiveXObject`, reported by one site. No browser has it, and a page reaching
  for it without a `typeof` guard throws in Chrome too. The report is not a
  defect of ours.
- `WebAssembly`, `define` and `require`, which are engine and module support.
… not

Real base64, both ways, and the one addition here nothing asked for in advance.

Re-capturing the twelve affected sites showed a page fall from 215 nodes to 28,
which reads as a regression and is not one: `String.prototype.substr` let its
bundle run past the first `TypeError: not a callable function`, far enough to
clear the server-rendered markup and rebuild it, and then it hit `atob`. Four of
the twelve did the same. A missing global is only counted once something
reaches it, so fixing one defect is what surfaces the next, and the low node
count was the measurement working rather than failing.

`atob` accepts whitespace anywhere and optional padding, which is what a page
decoding a header or a data URL relies on, and both throw an
`InvalidCharacterError` DOMException on input that is not theirs to decode.
The browser advertises two tools over the same socket. `chuzz-inspect` only
ever called `blitz.agent.control`, so `blitz.diagnostics` — DOM and layout
snapshots, renderer metrics, idle settlement, and the console and
runtime-error streams — was unreachable from the client that exists to read
the browser.

That gap has a cost. A page that throws during evaluation reports it only to
stdout, mixed in with the renderer's own logging, and a JS error is then
indistinguishable from a paint trace. Driving honey.id here is what surfaced
it: the page rendered blank, and the reason was a TypeError only visible by
grepping the process output.

`call` keeps its shape and routes to the agent tool, `diagnostics` is the same
round trip against the other one, and both share `call_tool`. On top of that
the CLI grows `console`, `metrics`, `settle`, `dom` and a verbatim `diag`.

Two limits worth stating, both in the runtime rather than here:

- `console` answers `streamingUnavailable`: diagnostic subscriptions are not
  implemented, and the runtime says so rather than pretending.
- `dom` reports the chrome window. A page lives in a sub-document on its
  `<web-view>` mount and is still not in the tree, so page content remains
  reachable only by screenshot.
ps-blitz published 0.4.0 (PR #79: selectable user agent, the display:contents
hoist panic, the @import cross-thread panic). None of it could reach this
repository, because a prerelease requirement only matches its own prerelease
line: ^0.3.0-beta.6 accepts 0.3.0-beta.* and nothing else, not 0.3.6 and not
0.4.0.

That is also why scripts/local-engine.sh appeared to do nothing. Cargo reported
'patch ... was not used in the crate graph' and carried on with the registry
copy, so an edit to a local engine checkout changed no behaviour and read as a
broken patch table rather than an unsatisfiable pin.

tauri-runtime-blitz moves with it. ^0.1.0 does not build on rustc 1.97.1
(unstable str_as_str) and only survived here because a cached artifact existed
in target/; a cargo clean would have stranded the repository. 0.3.2 is the
release that takes ps-blitz ^0.4, so both sides of the cascade agree and cargo
unifies the engine instead of putting two copies in the graph.
tauri-runtime-blitz 0.3.2 reaches endpoint-libs through blitz-control-protocol
0.4, which requires ^3. This workspace asked for ^2.1.5, so the graph carried
both 2.1.5 and 3.0.0 — two crates exporting the same MCP framing types, which
is the same shape of fault as two engines and fails the same way once a value
crosses between them.

The manifest already said these should be the same source and feature as
tauri-runtime-blitz uses for its agent-control surface. This makes that true
rather than aspirational.

WireMessage::Text carries Utf8Bytes rather than String in 3.0, so the three
send sites convert. Nothing else in chuzz-control moved: the receive sides read
through Deref and needed no change.
… engine

Moving the engine to 0.4 without this does not build. `ps-dioxus-native-dom`
0.7.2 was compiled against the older DOM, where an attribute value was a
`String`; at 0.4 it is an `Atom`, so the registry copy fails to compile with two
mismatched-types errors inside a crate nobody here edits:

    expected `Atom<EmptyStaticAtomSet>`, found `String`
      ps-dioxus-native-dom-0.7.2/src/dioxus_document.rs:127

It is a direct dependency, not a transitive one: this workspace asks for
`ps-dioxus-native` so `apps/chuzz` can select the vello renderer through its
features. The engine bump moved ps-blitz and tauri-runtime-blitz and left this
one behind, which is the whole failure.

**0.7.3 is not published yet.** It exists as a commit in ps-blitz PR #83, so
this range cannot resolve until that merges and releases. Pinning it anyway is
deliberate: the alternative is `^0.7.2`, which resolves and then cannot compile.
A range that is right and waiting beats one that is wrong and immediate.
The scripts that read a corpus run lived in a scratchpad directory that gets
wiped, so the ability to repeat the measurement was one cleanup away from being
lost. `scripts/render-check.sh` captured the pages and nothing committed could
judge what came back.

Three tools: `scrub.py` removes site identity from a log or tree dump,
`classify.py` turns raw counts into a verdict per site, `compare.py` diffs a
capture against a reference browser's boxes.

The README carries the things that cost time to learn and are invisible in the
code:

- an opaque label does not anonymise a capture. Its log names every CDN it
  fetched from and its tree dump carries class names; one leak survived
  hostname scrubbing because the site's name was inside a CSS class, which is
  why `--identity` exists and is not optional.
- read the logs, not the exit code. `render-check.sh` exits non-zero for both a
  watchdog kill and a refused load, and a corpus was once reported as "26
  timeouts" that were really 15x403 and assorted 401/429/406/404/400.
- node count is not monotonic under improvement. A page that starts working can
  fall from 215 nodes to 28 because it finally cleared its server-rendered
  markup and died at the next wall. Compare trees.
- `X is not defined` is half the missing APIs. A method absent from an existing
  prototype throws `TypeError: not a callable function`, which names nothing;
  that string covered 48 of 104 sites and is the largest unexamined surface in
  the corpus.

No collector is included, deliberately, and the README says why: four separate
transports for getting a reference dump out of a browser were tried and all
four fail, so nobody should rebuild one.
The engine has been able to select a `User-Agent` since ps-blitz 0.4.0.
Chuzz never asked for one, so the default went out on every request: a 2020
Firefox on Linux. I wired this once, reverted it when the engine pins would not
resolve, and did not restore it when they moved. The capability shipped and sat
unused.

`CHUZZ_USER_AGENT` selects `chuzz` (the default, honest) or `chrome`; anything
else is sent verbatim so a corpus run can reproduce one specific client. `brave`
is accepted and means `chrome`, because Brave sends Chrome's string verbatim and
distinguishes itself through `navigator.brave` rather than on the wire.

**It does not open the sites I expected, and that is the useful part.** The
premise was that this was worth around twenty sites: a hundred-site corpus had
that many answering with 403 and no page. Asked again as Chrome, the six tried
returned exactly the same status. Those servers are not reading the user agent —
TLS fingerprinting, IP reputation, something else. The switch is still correct
and worth having, but it is not the lever the corpus made it look like, and
guessing again without measuring would waste the next effort too.

The probe also caught a fault in my own measurement, corrected in
`scripts/corpus/classify.py`: a refusal was matched by `HttpStatus` appearing
anywhere in a capture log, which also matches a third-party script returning 403
on a page that loaded perfectly. Two sites with thousands of nodes were scored
REFUSED. The corpus stands at 61 rendered and 21 refused, not 59 and 23. Same
shape as the "26 timeouts" that were really refusals: a measurement artefact
reading as a product defect.
The section is headed "read this first", and everything in it had been fixed
since it was written -- so the first thing a new reader took from this file
was four bugs to chase that no longer exist.

Each was re-run from a fresh fixture rather than assumed:

- `overflow: hidden` clips in-flow children now, with and without
  `border-radius`, under `overflow:scroll` too, and an over-wide text run is
  cut mid-word at the box edge. Checked with static parents as well as
  positioned ones, since the original claim was specifically about static
  children.
- Percentage `translate()` centres correctly. The rotated case measures
  328x328, which is what `240*(cos30+sin30)` predicts for a 240x240 square at
  30 degrees. The no-transform control still fails, so the fixture has teeth.
- The `@property` symptom does not reproduce: a registered `<angle>` resolves
  to its `initial-value` and the gradient stays in its box. Whether it
  *animates* is a separate question the fixture does not answer, and the entry
  says so.
- `mask-image` exists: `blitz-paint/src/render/mask.rs`.

The table is kept rather than deleted so nobody re-opens one of them from
memory.
Five gaps in the web API shim, each of which made a working site look
broken in a way the console did not explain.

- `performance` was absent entirely. `@solidjs/router` reads
  `performance.getEntriesByType && performance.getEntriesByType(...)`,
  where the guard covers the call but not the destructure that follows,
  so every routed page died before its first render.

- `matchMedia` answered every query false, including
  `prefers-color-scheme`, and never parsed a dimension. A responsive
  layout asking whether it had room for the desktop design was told no.

- Nothing reported a viewport. `screen.width` returned
  `innerWidth || 1440` while `innerWidth` was 0, so a page that asked
  twice got two different answers and laid out for a phone.

- `location.origin` was undefined, though href, protocol, hostname and
  port were all present. `new URL(path, location.origin)` then returns
  the path unresolved and fetch rejects it as invalid, which kills a
  bootstrap inside an async handler with nothing logged. A page that
  hides itself until that bootstrap finishes stays hidden: a blank
  white document and an empty console.

- `location.host` omitted the port.

The sizes are a stated default rather than a measurement: the engine
does not expose its own to script, and `innerWidth`, `outerWidth`, the
client dimensions and the layout rect all read 0. One constant now
feeds `screen`, the `inner`/`outer` pair and the dimension branch of
`matchMedia`, because the earlier arrangement had two shims reading
each other and recursed without bound the moment both existed.
Three `.pyc` files under `scripts/corpus/__pycache__` were committed. They are
build output: importing `classify` to compare two corpus runs rewrites them,
so the tree comes back dirty from a read-only analysis and the diff has to be
picked through before anything can be staged.

Removed from the index and ignored, along with `*.pyc` generally.
Three Python scripts sat in a Rust repository doing work that never needed a
second language: bucketing four integers per site, diffing two box dumps, and
redacting hostnames from a log.

They also came with committed bytecode, so importing the classifier to compare
two runs left the tree dirty.

The README keeps what was actually worth keeping -- the anonymity rule, and the
measurement traps that cost real time to learn: exit codes cannot separate a
refusal from a timeout, stale labels in `target/render-check` silently double
any glob-based count, and bucket counts move on site-side variance rather than
on engine changes. It also says plainly that scrubbing is now a manual step,
so the anonymity rule is not left looking automated when it is not.
Mostly a rename pass, because the chrome had already been through the 2.11
migration. What actually bit:

`isOpen` became `open` on the four local components that wrap an overlay
(`SettingsPanel`, `SidePanel`, `DebuggingSection`, `Section`).

`Disclosure` became `Collapsible`, and the rename reaches the CSS: the
stylesheet selected `.disclosure__body-inner`, which 4.0.0 no longer emits, so
the inspector body was styling nothing. Checked the other way round too --
`collapsible__body-inner`, `collapsible__content`, `tabs__tab`, `button--sm`,
`button--width-square` and `input-control` all still exist, so every remaining
selector still binds.

The local `Switch` is renamed `OnOffRow`. It shadowed the library component of
the same name, which is exactly the shape that hides the 4.0.0 value-callback
break: a reader grepping for `<Switch onChange` would have found this one and
concluded the site was clean.

`TabList` drops a cast on `onSelectionChange` that the 4.0.0 signature makes
redundant.

The pin moves in three places, because `local-ui/bundle/` is generated except
for its `package.json`, which `local-ui/.gitignore` whitelists back in.

Not changed, and checked rather than assumed: no `className`, no `is*` state
props, no queries, and none of `Switch`/`Checkbox`/`Radio`/`PasswordField` is
used, so neither of the two silent-break classes can occur here. The three
`color=` sites are `ColorSwatch`/`SurfaceSwatch` literal swatch colours, not
the flavour axis. `Input` now defaults to `sm`, but `.navigation-bar
.input-control` pins 32px at two-class specificity and wins, so nothing moves.

Verified: `bun install` resolves 4.0.0, typecheck passes for both tsconfigs,
biome is clean. The gate was proven to have teeth on both layers first -- a
bogus prop on `Button` in the app source and on `Input` in `local-ui`, the
second rejected inside the generated Layout bundle, so the compiled output is
genuinely typechecked and not just the source.

Pre-existing and untouched: four render-based tests fail under
`solid-layouts@0.2.1` with "Context can only be accessed under a reactive
root". Confirmed pre-existing by reinstalling the 2.x line and watching the
same four fail.
4.0.0 was published in error and has been unpublished from the registry, so a
`^4.0.0` range now resolves to nothing at all. The value-change contract these
conversions target ships as 3.1.0.

The source is unchanged: it was written against this API either way. Only the
number the range asks for is different.
… to the library

Three pins in one commit because they are one question: which build of the
Layouts toolchain this application and the library it consumes agree on.

`@pathscale/ui` was repinned to ^3.1.0 without re-resolving, so the lockfile
still named 4.0.0, a version published in error and since unpublished.

`solid-layouts-oxc` was pinned at exactly 0.2.1 while the published
`@pathscale/ui` 3.1.0 is built with 0.2.3. That is an application compiling
against one version of the Layout compiler and importing a library compiled
with another, which is not a mismatch anything reports.

Typecheck, lint and 15 tests pass on the resolved set.
Half of a two-part bug, and the half that lives here.

Closing a tab did nothing: not the close button, not the keyboard shortcut.
The transport removes the tab and emits a shorter list -- instrumenting it
shows `len= 2` becoming `len= 1` -- and the strip kept showing both.

`reconcile(tabs, "id")(draft.tabs)` matches by key and writes the entries it is
given. It does not shorten the array it wrote into, so an open landed and a
close did not. With the explicit length the store is correct: the same probe
now shows the draft going from 2 to 1.

The rendered strip still does not shrink, and that part is not fixed here. The
`For` over `state.tabs` does not drop a row when the store array shortens. It
is not this migration: the same failure reproduces on `@pathscale/ui` 2.12.0,
and bumping `solid-layouts` from 0.2.1 to 0.2.3 does not change it either.
Leaving the store wrong as well would only make that harder to find.
@pathscale

Copy link
Copy Markdown
Owner Author

Folded into #37. Twenty-one of this branch's twenty-five commits were already there; the four that were not (@pathscale/ui 4.0.0, the ^3.1.0 pin, the lockfile resolve, the tab-list shrink) are cherry-picked on top, and the files this branch touched are now identical between the two apart from #37's own additions. One branch for the series means one release rather than two.

@pathscale pathscale closed this Sep 8, 2026
@pathscale
pathscale deleted the feat/ui-3-migration branch September 12, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant