The shell fits a phone, not a picture of one - #21
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.
Production baseline, measuredThe same gate run against the live origin (
The largest groups on production, in order:
One finding on production is not in this branch and is not fixed by it either: The report for both runs is attached to the |
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.
|
Coordination, not a review request. #22 touches This branch adds a coarse-pointer target to One deliberate near-duplication worth knowing about. #22 gives its own Nothing here needs a decision from this lane. If the mobile pass turns out to |
|
Correction to the line above about no conflict. That was measured against an The stylesheet and the workflow still merge cleanly. The one conflict is 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.
Merged develop, and moved two engines off the fleetdevelop 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 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 whyThe Measured on So there are two jobs now. Worth the exception rather than dropping the engine: WebKit is what Safari and Chrome on iOS are both built on, and Also since the last updateFirefox reports a fine pointer under emulation, which exposed something worth fixing rather than working around: the touch floors were keyed on 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. |
|
Heads up, and not a fault in this change. Promoting this to
The theme bootstrap was blocked on every page. Fixed in Nothing is needed from this lane. The release that carries this work goes back |
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=coverand the repository contained noenv(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 ofmain. 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-paddingfor 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 nomaximum-scaleand nouser-scalable=no. Four safe-area tokens, spent throughmax()so a phone with no cutout keeps its ordinary padding.--u-bottom-nav-spacefor what the bar occupies,--u-touch-targetfor the floor,--u-text-fieldfor the 16px rule. The insets go on the chrome and on the content column, never onbody: 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
localover 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, thendvh, then the visual viewport where the browser can report it.One TypeScript service.
UniverseViewportServicepublishes--u-visual-viewport-heightfromvisualViewport. 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-heightandmin-width; text links in dense data take block-axis room and the row grows with them.Viewport units.
100lvhwhere the intent is the large viewport and it says so,100dvhfor ceilings,100vhunderneath both.mainusesmax-width: 100%rather than100vw, which was itself counting the desktop scrollbar.The gate
scripts/universe/visual-qa/mobile-check.mjs, and amobilejob 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 justhasTouch. That distinction matters and cost a full cycle to find:hasTouchalone leaves the CSS pointer reportingfine, 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
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-shadowis 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-fieldon 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
feat/zrc20-ruleset-reading, draft) touchesruleset-assets.tsandruleset-assets.spec.tsonly. No overlap with anything here.preserve/uncommitted-fiat-price-guardis untouched. The four frontend edits it preserved were already adapted into develop; the fifth, the backendFIAT_PRICEguard, still has no owner and is not merged here._audit/mempool-mobile-go-state/.Documentation
docs/product/DESIGN-SYSTEM.mdgains "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.