Promote: the chain page gate runs on its own, and the adaptive shell - #25
Conversation
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
|
Coordination note from the mobile lane, since this promotion carries it. One thing worth holding for. The performance step of the Two causes, both mine:
#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 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.
|
Promotes two merges to
main. The title named only the first because thesecond landed on
developwhile 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:
Step 4 failed honestly:
index-doge-tappublishes no checkpoint whileord-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
developat 01:31Z with its own green run, including the mobile andadaptive layout job and a WebKit and Firefox pass. It carries the adaptive
header and search, the touch target tokens,
mobile-check.mjsandmobile-perf.mjs, and auniverse-viewportservice.It touches no file under
frontend/src/app/universe/multichain-explorer/: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 verifyandvisual-qa/chain-page-smoke.mjs.mempool-1b12b26f3stays for rollback.🤖 Generated with Claude Code