Skip to content

The shell fits a phone, not a picture of one - #21

Merged
bitcoinuniverseadmin merged 12 commits into
developfrom
design/mobile-adaptive-shell
Aug 30, 2026
Merged

bitcoinuniverseadmin merged 12 commits into
developfrom
design/mobile-adaptive-shell

Conversation

@bitcoinuniverseadmin

Copy link
Copy Markdown

The layout has had phone widths in its test matrix for some time and has looked correct 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.

Base: bea93c1ec (develop, after #18 and #19 merged).

What was wrong

In the order it costs a visitor something.

Every search on an iPhone left the page zoomed. 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 the visitor was left panning a page twice as wide as their screen to read the answer they had just asked for. The 13px was there to fit more of the placeholder in; that trade is the wrong way round, because a placeholder is a hint and the result list does the real explaining.

Nothing knew about the notch. The viewport meta did not ask for viewport-fit=cover and the repository contained no env(safe-area-inset-*) at all. The header sat inside a letterbox and the bottom bar sat under the home indicator.

The footer sat behind the bottom bar. The reservation was a hard-coded 68px written on main, and the footer is a sibling of main. Every settings control and the whole last row of links was underneath the bar on every phone, and 68px was short of the bar plus a home indicator anyway.

Tabbing near the bottom of a page hid the focused control. Nothing set scroll-padding for either fixed layer. That is a WCAG 2.2 focus-obscured failure.

Destinations past the edge of the bottom bar were unreachable. It scrolled sideways with its scrollbar hidden and nothing else to say so, and arriving on a destination from a link left the bar scrolled away from it, so the bar disagreed with the page about where the visitor was.

Controls were sized for a cursor. The chain and network pickers at 38px, pagination arrows at 35, documentation tabs at 42, the copy control beside every identifier at 21 by 17, protocol chips at 34, and rows of transaction ids nineteen pixels apart.

The block timeline scrolled sideways as an unnamed div. To a screen reader it was nothing, and to anyone not already expecting a timeline it was a row that ended.

Menus had no height cap, so a chain list on a short screen ran off the bottom with no way to reach the rest, because a positioned surface does not scroll with the page. The search results were pinned 42px below a field that is 48 tall on touch.

What changed

Foundation. viewport-fit=cover, with no maximum-scale and no user-scalable=no. Four safe-area tokens, spent through max() so a phone with no cutout keeps its ordinary padding. --u-bottom-nav-space for what the bar occupies, --u-touch-target for the floor, --u-text-field for the 16px rule. The insets go on the chrome and on the content column, never on body: padding the body pulls the header and bar in from the edges and leaves a strip of page colour down the side of both.

Shell. Header pads for the top inset so its background reaches under the status bar; compact landscape drops it to 48px and puts each bar icon beside its label rather than removing anything. The bar pads bottom and sides. The bottom reservation moved to the shared parent of the router outlet and the footer. Scroll padding is stated per header shape rather than once for the tallest: writing one number caused the fault it was meant to prevent on the shortest window, because the reserve comes out of the room the browser has to place the element in.

The bar's scroller says it scrolls, using the two-layer background technique: opaque covers attached local over static shadows, so a fade appears on exactly the side that has more and never when everything already fits. No measurement, so it stays correct through a rotation, a fold, a split-screen resize and a chain switch that changes how many destinations there are. The active destination is scrolled into view after every navigation.

Search. 16px field, 48px on touch, enterkeyhint, and a full-size clear control that returns focus to the field. Results hang from the field rather than from a copied 42px, and are capped by a three-step ladder: vh, then dvh, then the visual viewport where the browser can report it.

One TypeScript service. UniverseViewportService publishes --u-visual-viewport-height from visualViewport. It exists because on iOS the software keyboard is painted over the page and every viewport unit keeps reporting the full height. It listens outside Angular, writes only on change, and where it is absent the CSS fallback is the answer.

Targets, to two floors: WCAG 2.2's 24 with its spacing exception everywhere, and 44 for the shell, pagination, and anything repeated three or more times. Box-like controls take a min-height and min-width; text links in dense data take block-axis room and the row grows with them.

Viewport units. 100lvh where the intent is the large viewport and it says so, 100dvh for ceilings, 100vh underneath both. main uses max-width: 100% rather than 100vw, which was itself counting the desktop scrollbar.

The gate

scripts/universe/visual-qa/mobile-check.mjs, and a mobile job that runs beside the visual matrix rather than inside it.

The matrix job is at 60 minutes of its 100 minute cap, and 78 percent of that is the deep pass (recorded in #19). Adding window sizes, device emulation, a cutout and a rotation to it is how a gate stops finishing. This one is built the other way round: few page loads, many assertions each. Eleven routes across seven window sizes is 77 pages in about nine minutes, six of them its own build, on a second runner that would otherwise be idle. The workflow's wall clock does not move.

Windows: 320x568, 360x740, 390x844 (with a simulated 59/34 cutout), 430x932, 844x390 landscape (with side insets), 768x1024, and 1024x900 as the desktop control.

Compact windows run with isMobile, not just hasTouch. That distinction matters and cost a full cycle to find: hasTouch alone leaves the CSS pointer reporting fine, so every @media (pointer: coarse) rule in the product was going unexercised while appearing to be covered. A run green about rules it never ran is worse than no run.

It fails on: page-level sideways scroll (naming the outermost elements responsible), an undeclared sideways scroller, a field under 16px, a target under either floor, content or focus resting behind a fixed layer, a header or bar not reserving a simulated inset, a menu taller than its window that does not scroll, a scrolling bar with no affordance or with its current destination out of sight, and a rotation that changes the route or loses the reading position.

Results

Before After
Mobile gate, 77 measured pages 346 failures 0
Broad matrix, 375 and 1440, contrast 0 blocking 0
Broad matrix at 375, three themes 14 contrast failures 0
Frontend unit tests 316 pass 316 pass
Frontend lint 0 errors 0 errors
Production build clean clean

The three-theme contrast run at 375 is worth reading twice. The first version of the bar's scroll shadow used the general shadow token at 28 percent, which composites to rgb(194, 191, 196) under the active label and takes it from 6.5:1 to 3.29:1, under AA, on fourteen routes. The contrast probe caught it, which is exactly what it is for. The shadow band now has padding of its own so it falls on nothing at rest, and --u-nav-scroll-shadow is 14 percent so a label passing under it while the bar scrolls still clears the floor. The number came from the measurement, not from how it looked.

Gates run and passing: frontend type check, unit tests, lint, production build, text gate over source and over built output, branding, origins, colours, palettes (346 pairings), fills (65), build assets, protocol coverage, gateway tests, gateway restart test, forced colours and 200 percent zoom, the broad visual matrix, the deep visual matrix, and the new mobile gate.

Desktop

Every rule is gated on (pointer: coarse), (max-width: 991.98px), or both. The 1024x900 window is in the mobile gate as the control: a mobile fix that regresses the wide layout fails there. The broad matrix at 1440 is unchanged.

The one deliberate exception is --u-text-field on form fields, which applies to any coarse pointer or any window under 992px. Pointer reporting is not perfectly reliable across mobile browsers and desktop-mode requests, and being wrong about it costs a zoomed page, while being wrong the other way costs a slightly larger control in a narrow window.

Coordination

Documentation

docs/product/DESIGN-SYSTEM.md gains "The adaptive window" and "The mobile gate": the three header shapes and the one shell breakpoint, where each inset is spent and why they are not on the body, which viewport unit means what, the two target floors and which controls each applies to, the two conditions under which a region may scroll sideways, the window set with the reason each size is in it, and the real-device list, because emulation is the gate and devices are the proof.

Still to do before promotion

Real-device verification on current Safari on iPhone and iPad, Chrome on Android, Samsung Internet and Chrome on iPhone, against the deployed build.

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.
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Production baseline, measured

The same gate run against the live origin (explorer.bitcoinuniverse.io, build 0c2940e8df7c4884, backend 521a091) and against this branch, same eleven routes, same seven window sizes, same seventy-seven pages:

Live production This branch
Mobile gate failures 560 0

The largest groups on production, in order:

Count Finding
280 the five footer selects computing 14px, on every route and window
62 the search field computing 13px at narrow widths and 15px elsewhere
24 the block timeline scrolling sideways without saying so
21 the bottom bar reserving nothing for a 21px or 34px home indicator
16 the bottom bar scrolling with nothing to say that it does
11 the bottom bar reserving nothing at the sides for a 59px cutout in landscape
11 the copy control beside every identifier at 21 by 17, crowded
10 the header reserving nothing for a 59px cutout
8 the chain toggle at 42, the search submit at 40, in the shell
4 a link resting under the bottom bar with focus on it

One finding on production is not in this branch and is not fixed by it either: div.table-wrap scrolls sideways without saying so, on the chain routes. That is a version difference rather than a defect being introduced or missed. Production runs main, which predates the chain-page redesign; develop already gives those three wrappers tabindex, role="region" and an aria-labelledby. Promoting this branch carries that fix to production along with everything above.

The report for both runs is attached to the mobile job as universe-mobile-qa.

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.
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Coordination, not a review request. #22 touches
frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss,
which this branch also touches, so here is exactly where, to save anyone
working out whether the two collide.

This branch adds a coarse-pointer target to .evidence summary. #22 adds new
blocks: .readiness-band, .history-coverage, .source-details,
.source-list, .source-value, .source-exact, .source-note,
.source-copy, and it changes two lines inside the existing .read-grid. It
does not touch .evidence. git merge-tree on the two heads reports no
conflict in that file or any other.

One deliberate near-duplication worth knowing about. #22 gives its own
disclosure control, .source-details summary, the same 44 pixel minimum on a
coarse pointer that this branch gives .evidence summary, and writes the value
literally rather than using --u-touch-target, because that token arrives with
this branch and does not exist on develop yet. Once this merges, the literal
should become the token. That is a one-line follow-up and it belongs to
whichever of the two lands second.

Nothing here needs a decision from this lane. If the mobile pass turns out to
want a different structure for the new blocks, say so on #22 and it will move.

@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Correction to the line above about no conflict. That was measured against an
earlier head here. Re-run against cc42c7e2b:

$ git merge-tree --write-tree origin/design/mobile-adaptive-shell origin/fix/chain-dashboard-release-identity
Auto-merging .github/workflows/universe-ci.yml
Auto-merging docs/operations/DEPLOYMENT.md
CONFLICT (content): Merge conflict in docs/operations/DEPLOYMENT.md
Auto-merging frontend/src/app/universe/multichain-explorer/multichain-explorer.component.scss

The stylesheet and the workflow still merge cleanly. The one conflict is that
both branches insert a step 7 into the release procedure and renumber the old
one: this branch adds the mobile check against the public origin, #22 adds the
chain page smoke against the public origin. They are not alternatives and the
resolution is to keep both and number them 7 and 8. Whichever lands second does
that.

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.
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Merged develop, and moved two engines off the fleet

develop moved under this branch. #22 landed the chain page release-identity work while this was in review, and #23 is now open to promote it. Merged in rather than rebased, so the promotion of #23 and this branch stay independently reviewable.

Three files overlapped. The workflow and multichain-explorer.component.scss merged without a decision to make: separate jobs, separate rules. DEPLOYMENT.md had one real conflict, because both lanes added a step 7 to the release procedure. That is not a conflict of intent, so both are kept: the chain page smoke is step 7 and the mobile checks are step 8, in that order, because a release serving the wrong chain dashboard is a worse thing to discover late 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.

Rebuilt and re-measured on the merged tree rather than assuming the halves compose: 343 unit tests, 11 release-manifest tests, 22 chain-page-audit tests, mobile gate clean across 77 pages, performance inside budget, no Sass warnings.

WebKit runs on a hosted runner, and here is why

The mobile job's WebKit pass failed on the fleet, and the cause is not the product. WebKit needs system libraries a Chromium-only host has never had; it does not degrade without them, it refuses to launch and prints the list. Installing them is one apt command, and the fleet's runner user has no passwordless sudo:

sudo: a terminal is required to read the password
sudo: a password is required

Measured on universe-linux-ultra-03. I could not install them on the runner VM either: the key for it was rotated on 2026-08-28 and rotating credentials is not something I will do to get a gate green.

So there are two jobs now. mobile keeps the Chromium pass and the performance gate on the fleet. mobile-engines runs WebKit and Firefox on ubuntu-latest, where playwright install --with-deps works. Two jobs rather than one so the exception is visible in the job list, and so a hosted-runner outage cannot take the Chromium gate down with it. The workflow comment says to delete the job and fold the steps back when the fleet image gains the libraries.

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. Locally, WebKit reports zero findings across all 77 pages, and zero of the two AAA focus grazes Chromium reports.

Also since the last update

Firefox reports a fine pointer under emulation, which exposed something worth fixing rather than working around: the touch floors were keyed on (pointer: coarse) alone while the 16px field rule already used (pointer: coarse), (max-width: 991.98px). They match now. Below the shell breakpoint the product is already in its mobile shape and should have mobile ergonomics whatever the pointer claims.

Firefox also does not re-scroll an element that is already partly in view, so it leaves focused links overlapping the bottom bar where Chromium and WebKit place them clear. That split the focus check in two along the line WCAG already draws: entirely behind a layer is SC 2.4.11, AA, and fails on every engine, which is what this found in the first place. Partly under one is SC 2.4.12, AAA, which the stylesheet aims for and two of three engines reach. Those are counted and printed every run and do not fail it, because dropping them would hide the difference from everyone.

Three Sass declarations of mine were sitting after a nested rule. The build is clean of them.

A seven-selector list that appeared three times, once per header shape, is a Sass variable now. Three copies that must stay in sync is three chances for the one nobody looks at to drift.

@bitcoinuniverseadmin
bitcoinuniverseadmin merged commit fca66c9 into develop Aug 30, 2026
9 checks passed
@bitcoinuniverseadmin

Copy link
Copy Markdown
Author

Heads up, and not a fault in this change.

Promoting this to main and cutting it over broke every page in production,
and it was rolled back within a minute. The cause is in gateway.mjs, not
here, but it is worth knowing because this is the change that exposed it.

index.mempool.html gained an inline script here, correctly. The gateway
allows inline script by hash and computed that hash once at start-up, while
ROOT is a fixed path whose contents a release swaps underneath it. A release
that changes the document and leaves gateway.mjs byte identical therefore
leaves the gateway running, by design, still enforcing the previous build's
hash:

FAIL  page:dogecoin: 1 console error(s): Executing inline script violates the
following Content Security Policy directive 'script-src 'self'
'sha256-TLx68/...''. Either the 'unsafe-inline' keyword, a hash
('sha256-F5AsGcf/...'), or a nonce is required.

The theme bootstrap was blocked on every page. Fixed in
#27: the policy follows
the file behind the path rather than remembering the one that was there at
start-up, with a test that fails against the previous behaviour.

Nothing is needed from this lane. The release that carries this work goes back
out on top of #27.

@bitcoinuniverseadmin
bitcoinuniverseadmin deleted the design/mobile-adaptive-shell branch August 30, 2026 06:11
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