Skip to content

Promote: the chain page gate runs on its own, and the adaptive shell - #25

Merged
bitcoinuniverseadmin merged 15 commits into
mainfrom
develop
Aug 30, 2026
Merged

bitcoinuniverseadmin merged 15 commits into
mainfrom
develop

Conversation

@bitcoinuniverseadmin

@bitcoinuniverseadmin bitcoinuniverseadmin commented Aug 30, 2026

Copy link
Copy Markdown

Promotes two merges to main. The title named only the first because the
second landed on develop while this was open.

1. The chain page gate runs on its own (#24)

The gate that #23 put in
front of the public origin was never run by the workflow it was added to. Its
first scheduled run stopped one step earlier and skipped everything after it:

4.Every advertised feature has routes, data, and a matching release=failure
5.Install the visual QA harness=skipped
6.The gate can still tell a bad page from a good one=skipped
7.The public chain pages say what the chain documents say=skipped

Step 4 failed honestly: index-doge-tap publishes no checkpoint while
ord-dogecoin rebuilds and its tip does not yet agree with Dogecoin Core, true
since 2026-08-29T00:01Z. What it took with it is the gate whose entire reason
for existing is to notice an obsolete dashboard on the origin. The two steps
run independently now; the job still fails if either fails.

2. The shell fits a phone (#21)

Merged to develop at 01:31Z with its own green run, including the mobile and
adaptive layout job and a WebKit and Firefox pass. It carries the adaptive
header and search, the touch target tokens, mobile-check.mjs and
mobile-perf.mjs, and a universe-viewport service.

It touches no file under frontend/src/app/universe/multichain-explorer/:

$ git diff --stat origin/main origin/develop -- frontend/src/app/universe/multichain-explorer/
(no output)

so the two chain pages this release cycle was opened for are unchanged in
markup, and the shared shell around them is what moves. The chain page smoke is
run against the origin again after cutover for exactly that reason.

Release

Same procedure as #23:
artifact built from this merge commit on the runner fleet, release tool
refreshed from the artifact, installed beside the running release, preflight,
cutover with a continuous probe, then the public origin verified with
synthetic-check.mjs, release-manifest.mjs verify and
visual-qa/chain-page-smoke.mjs. mempool-1b12b26f3 stays for rollback.

🤖 Generated with Claude Code

The layout has had phone widths in its test matrix for some time and has
looked right in all of them. Almost nothing that makes this product hard
to use on an actual phone shows up in a screenshot, so almost none of it
had been found.

What was wrong, in the order it costs a visitor something:

The search field computed at 13px below 576px, and the five preference
selects in the footer at 14px. iOS Safari zooms the layout viewport in
when a focused field is under 16px and does not zoom back out, so every
search on an iPhone ended with the visitor panning a page twice as wide
as their screen to read the answer. Both are 16px now. The placeholder
truncates a little sooner as a result, which is the right way round: it
is a hint, and the result list does the explaining.

The viewport meta did not ask for viewport-fit=cover and nothing in the
repository mentioned a safe area, so on every notched phone the header
sat inside a letterbox and the bottom bar sat under the home indicator.
The four insets are tokens now, used through max() so a phone without a
cutout keeps its ordinary padding, and the header, the bar and the
content column all read the same ones.

Content ended underneath the bottom bar. The reservation was 68px,
written on main, and the footer is a sibling of main: every settings
control and the whole last row of links sat behind the bar on every
phone. It is on their shared parent now and derived from what the bar
occupies, including the home indicator.

Nothing reserved room for the sticky layers when the browser scrolls.
Tabbing to a control near the bottom of a page put it exactly behind
the bar, which is a WCAG 2.2 focus-obscured failure. Each of the three
header shapes now states its own scroll-padding: writing one number for
the tallest of them caused the fault it was meant to prevent, because
the reserve is taken out of the room the browser has to place the
element in.

The bottom bar scrolled sideways with its scrollbar hidden and no other
sign, so destinations past the edge were present and unreachable, and
arriving on one from a link left the bar scrolled away from it. It has
scroll shadows now, painted with the two-layer background technique so
the fade appears only on the side that has more and only when there is
more, and the current destination is scrolled into view after every
navigation.

Controls were sized for a cursor. The chain and network pickers at 38px,
the pagination arrows at 35, the documentation tabs at 42, the copy
control beside every identifier at 21 by 17, and rows of identifier
links nineteen pixels apart. The floors are the platform ones, 44 for
anything in the shell or repeated, and WCAG's 24 with its spacing
exception everywhere else, which is what lets a dense table stay dense.

The block timeline scrolled sideways as an unnamed div. It is a region
with a name now: it is the one place here that should scroll sideways,
and saying so is what separates it from a row that merely got cut off.

The transaction tracker drew 37px blocks at 320px because its height was
a fifth of its width. It has a floor.

Menus had no height cap, so a chain list on a short screen ran off the
bottom with no way to reach the rest of it. The search results were
pinned 42px below a field that is now 48 tall.

100vh is still 100vh where the intent is the large viewport, and says so.

A new gate measures all of it. It is built the opposite way round from
the visual matrix, few page loads and many assertions each, so it costs
about a tenth of what the matrix does and can afford to grow.
The visual matrix job is at 60 minutes of its 100 minute cap and the deep
pass is 78 percent of it. Adding seven window sizes, device emulation, a
simulated cutout and a rotation to that job is how a gate stops
finishing, which is the fault the broad and deep split exists to
prevent. So the mobile gate is a job of its own on a second runner. The
workflow's wall clock does not move: it takes about nine minutes, six of
them its own build, on hardware that would otherwise be idle for the
hour the matrix runs.

It builds its own copy rather than taking an artifact from the job
beside it. That costs one production build and buys two things worth
more: neither gate can make the other fail or wait, and this one can be
read and run on its own.

The design system now carries the adaptive model rather than leaving it
to be rediscovered from the stylesheets: the three header shapes and the
one shell breakpoint, where each safe-area inset is spent and why they
are not on the body, which viewport unit means what and why the page
column is deliberately lvh, the two touch-target floors and which
controls each applies to, the 16px field rule and what it is for, the
two conditions under which a region may scroll sideways, and the window
set the gate walks with the reason each size is in it.

Also the real-device list, because emulation is the gate and devices are
the proof, and the two are not the same claim.
The bottom bar's edge fade was written inline in the component as
rgba(36, 26, 43, 0.14), which the colour gate rightly refused: interface
colour comes from a semantic token here, and a literal in a component is
a value nobody can find again.

It is --u-nav-scroll-shadow now, stated in both themes. The comment that
explains the number lives with it: 14 percent rather than the general
elevation token's 28, because a destination scrolled past the end of the
bar is read through this, and at 28 the active label composites to
rgb(194, 191, 196) and drops to 3.29:1. Dark states its own value rather
than inheriting, since darkening a dark bar moves its light label the
safe way and the two should not drift silently.

346 palette pairings still pass, and the three-theme contrast run at
375px reports zero failures.
Almost all of the adaptive behaviour here is stylesheet, and the mobile
gate measures it in a real browser, which is the right place for it. Three
pieces are TypeScript, and those are cheap to test directly and expensive
to find in a browser.

UniverseViewportService, eight tests. The one that matters is that it
publishes nothing at all where visualViewport is absent, rather than
publishing a zero or the window height. Every rule that reads its
properties does so through a var() fallback to a dvh value, and a
fallback only applies when the property was never set: a well-meaning
default would silently replace the CSS answer with a worse one. Also that
it counts offsetTop, so a page scrolled inside its visual viewport is not
reported as a keyboard that is not there, and that it writes only on
change, since a keyboard animation fires resize every frame.

The bottom bar's reveal, five tests. Not the scrolling, which belongs to
the browser, but the conditions: never on the server, never on the wide
layout where scrolling the bar would scroll the page, never before the
view exists, and never for a route that highlights nothing, because
scrolling the bar to its start would be a claim about where the visitor
is.

The search clear control, four tests. That its presence is read from the
control rather than mirrored into a field, so the reset after a
successful search takes the button with it, and that clearing hands focus
back, since clearing is the first half of typing something else.

vitest.config.ts now includes src/app/components. The only specs under it
are these two; the inherited components there ship none.

333 tests, up from 316.
revealActiveDestination called scrollIntoView on the current destination.
That does what was wanted and more: it adjusts every scrollable ancestor
it can reach, including the document, so tidying the bottom bar after a
navigation could have moved the reading position of the page underneath
it. Nothing observed it doing so, and nothing here would have noticed if
it had.

Writing one offset to the bar's own scrollLeft cannot do anything except
what it says, and it is testable, which scrollIntoView was not: the two
directions and the leave-it-alone case now assert the number rather than
the fact that a browser method was called. Seven tests where there were
five.

A 24px margin so the revealed destination does not sit flush against the
edge looking like the last one there is, when it is only the last one
visible.
Two things this found about itself.

The bottom-of-page check scrolled to the end and measured immediately.
Scrolling to the end is exactly what makes a page load whatever it was
deferring, and an image without dimensions finishes arriving at about the
same moment, so the measurement was taken while the bottom was still
moving and the answer depended on how fast the machine was. It produced
a finding on a Linux runner that could not be reproduced on a faster
workstation at any viewport height, which is the signature of a race
rather than a defect. It now scrolls, waits for the height to stop
changing, scrolls again, and only then measures.

The same finding could not say whether the element was in normal flow or
pinned over the bar. Those have different causes and different fixes, and
the arithmetic rules the first one out: with 103px reserved on the column
and 60 on the body, a flow element sits at least 163px above the document
bottom, and the bar is 53 plus the inset. So it now says which, because
leaving a reader to derive that by hand is what happened here.

Three engines rather than one. WebKit is the engine Safari is built on
and is the closest an automated run gets to an iPhone, which matters
because dvh, safe-area insets and the pointer media feature are exactly
where engines differ, and a Chromium-only run proves nothing about the
platform these fixes were written for. It takes the full set and reports
nothing. Firefox cannot be put into mobile emulation, so it reports a
fine pointer and takes the checks that do not need a thumb, and the run
says so in its own header.

Firefox is also why the touch floors are now written
(pointer: coarse), (max-width: 991.98px) rather than on the pointer
alone, matching the field rule. Below the shell breakpoint the product is
already in its mobile shape and should have mobile ergonomics whatever
the pointer claims.

And why the focus check now distinguishes AA from AAA. Entirely behind a
layer is SC 2.4.11, AA, and fails every engine: it is what this found in
the first place. Partly under one is SC 2.4.12, AAA, which the stylesheet
aims for and Chromium and WebKit reach; Firefox does not re-scroll
something already partly in view. Those are counted and printed every run
and do not fail it, because dropping them would hide the difference from
everyone.
…uess

A performance gate that measures the runner is worse than none, because
it reports a number and the number is about the machine. So this gates
the two things that are properties of the build and prints the one that
is not.

Compressed payload, gated at 480kB. develop at bea93c1 is 463kB
compressed and 1655kB raw; with this work it is 464kB and 1659kB. One
kilobyte, for the safe-area tokens, the compact rules, the clear control
and the viewport service. An earlier version of this file carried a
budget with a baseline I had not measured written next to it as though I
had. Both sides are measured now and the comment says so.

Layout shift, gated at 0.1, with one route recorded as over it. The
transaction route shifts 0.103 on develop and 0.108 here. At about three
seconds div.panel grows from 46 to 193 pixels as the tracker bar and the
confirmations render into it, pushing div.bottom-panel down 148 and
shrinking it by the same. Reserving that room means knowing the panel's
height before the transaction is read, and an alert, a tracker bar and a
confirmation count are three different heights: a single min-height would
trade a measured shift for unmeasured dead space on the shorter states.
That is work on the tracker's loading design rather than on the shell, so
it is written down with its figure, printed every run, and the route is
held to it, which means drifting further fails even while it is over.

Largest paint is printed and not gated. The same commit measured twelve
seconds and twenty on two routes whose shells are identical, because this
machine was building and serving at the time. A number that swings by
eight seconds between runs of the same code cannot gate anything. What is
still failed is a paint that never happens at all, which is a shell that
did not render rather than one that rendered slowly.
The release procedure verified the origin, the chain routes and the build
identity, and nothing in it looked at the product the way most visitors
hold it.

Two steps now. The mobile gate against the public origin, which is worth
running there rather than only in CI because the two faults it catches
that CI cannot see are a gateway serving a stale index.html, which shows
up the moment the viewport meta loses viewport-fit=cover, and a
configuration difference that changes which destinations the bar carries.

Then somebody opens it on a phone. Written down as a step because
emulation is not a device and should not be allowed to read like one: it
cannot tell you whether a thumb reaches the bar one-handed, whether the
keyboard covers the result you were reading, or whether the page zooms
when you tap the field.
The WebKit pass failed on runner-02 with a list of missing shared
objects: libflite, libavif, libhyphen, libmanette, libenchant and the
rest. A Chromium-only host has never needed them, and WebKit does not
degrade when they are absent, it refuses to launch.

playwright install --with-deps installs them. It is idempotent and costs
nothing once they are present.

Worth the step rather than dropping the engine: WebKit is what Safari and
Chrome on iOS are both built on, and the rules this work turns on, dvh,
safe-area insets and the pointer media feature, are exactly the ones
where engines disagree.
Three copies of the same seven-selector list, one per header shape, is
three chances for them to drift apart, and the copy that drifts is the
one nobody looks at. It is a Sass variable interpolated into each block
now, so the list has one definition and the three blocks differ only in
the numbers they are there to state.
The fleet cannot run WebKit. It needs system libraries a Chromium-only
host has never had, it does not degrade without them but refuses to
launch and prints the list, and installing them needs sudo the runner
user does not have: playwright install --with-deps fails on
universe-linux-ultra-03 with "sudo: a password is required".

So WebKit and Firefox move to their own job on a hosted runner, where
that command works, and the fleet keeps the Chromium pass and the
performance gate. Two jobs rather than one so the exception is visible
and so a hosted outage cannot take the Chromium gate down with it. When
the fleet image gains the libraries, delete the job and put the steps
back where they belong; the comment says so.

Worth the exception rather than dropping the engine. WebKit is what
Safari and Chrome on iOS are both built on, and dvh, safe-area insets and
the pointer media feature are exactly the rules where engines disagree:
a Chromium-only run proves nothing about the platform half of this work
was written for.

Also three declarations I had left sitting after a nested rule, in the
lens option, the protocol chip and the flow disclosure. Sass warns that
its order there will change to match CSS, and the warning was mine. The
build is clean of them now.
Two lanes touched the same three files and one of them needed a decision.

Both added a step 7 to the release procedure, which is not a conflict of
intent: the chain page smoke checks what the origin renders for a chain,
and the mobile gate checks what the shell is like to hold. They are step
7 and step 8 now, in that order, because a page that is serving the wrong
release is a worse thing to find later than a bar that is hard to reach.
Step 6 takes develop's wording, which is the same sentence with one more
failure condition on it.

The workflow and the multichain stylesheet merged without a decision to
make: separate jobs, separate rules.

Rebuilt and re-measured on the merged tree rather than assuming the two
halves compose: 343 unit tests, 11 release manifest tests, 22 chain page
audit tests, the mobile gate clean across 77 pages, performance inside
budget, and no Sass warnings.
The chain page gate was added to the production smoke and never ran. Its first
scheduled run stopped one step earlier: index-doge-tap could publish no
checkpoint, because ord-dogecoin is rebuilding and its tip does not yet agree
with Dogecoin Core, so the protocol step failed honestly and every step after
it was skipped.

Skipped included the gate whose entire reason for existing is to notice that
the origin is serving a dashboard from an older release. A defect that hid
behind fixtures for a day would have gone on hiding behind an unrelated red
check, which is a worse place to hide.

The two questions are not the same question. One asks whether the authorities
behind these pages can answer; the other asks whether the pages tell the truth
about what they can answer. A chain in recovery is a legitimate no to the first
and says nothing about the second.

They run independently now. Nothing is weakened: the job still fails if either
fails, and it now reports both rather than the first.
…shell

The shell fits a phone, not a picture of one
…s-on-its-own

A red check must not blind the check after it
@bitcoinuniverseadmin bitcoinuniverseadmin changed the title Promote: the chain page gate runs on its own Promote: the chain page gate runs on its own, and the adaptive shell Aug 30, 2026
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Coordination note from the mobile lane, since this promotion carries it.

One thing worth holding for. The performance step of the mobile job is unsound on a loaded runner, and #26 fixes it. It is not a threshold problem, it is a measurement problem, and it produced a red develop on a tree byte-identical to a green pull request (854fe964 both sides).

Two causes, both mine:

  • The gate did not threshold largest paint because that number swings with machine load, and then failed a route for reporting no paint, on the written grounds that absence "does not move with the load". It does. On the busy runner the Dogecoin transaction route fired load at 10,462ms and had no contentful paint six seconds later, on a build that measured 12,132ms on a quieter runner.
  • The header called layout shift "nearly deterministic, a property of the stylesheet rather than of the clock". The same tree measured 0.042, 0.046 and 0.069 on the blocks route across three machines.

#26 waits for the paint properly and, when it still has not arrived, asks the page what is on it, so a rendered page with no paint entry is reported as a measurement artefact while a blank one still fails. For layout shift it uses the asymmetry that load can only add shifts and never remove one: a route over its ceiling is measured again and judged on the smallest reading, and the table says when that happened. No threshold moves.

This PR's head is develop, so merging #26 there folds it into this promotion with no action needed here. If you would rather promote now, that is fine and nothing in production is affected: the fault is a CI gate that can go red on a busy runner, not anything a visitor sees. It would just mean main carries a flaky check until the next promotion.

Two notes for whoever runs the release, from the mobile side:

Step 8 of the release procedure is new and needs a phone, not an emulator. Three engines are covered automatically, WebKit included, which is what Safari and Chrome on iOS both run on, but no physical device was available here. The step says what to check by hand.

index-alkanes and index-doge-tap will fail the synthetic check with no published checkpoint, as they do today. Both services are active and running on the indexer host, which is to say mid-index. They predate all of this, a frontend release does not affect them, and they are not to be interrupted for one.

@bitcoinuniverseadmin
bitcoinuniverseadmin merged commit ac2bcfa into main Aug 30, 2026
17 checks passed
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