What the gap 10 build actually taught, written down because the expensive parts were not the code. Twelve versions shipped over one session (v515 → v526); the code was mostly small, and nearly every hour went into finding out what was true. These are the patterns worth carrying forward — several of them cost multiple rounds each, and the same shapes will recur.
Five of the seven gesture findings were caught on a real phone, not here — and three of them after a check of mine had already reported green. That is the headline. The lessons below are the specific ways a passing check lied.
I swept 29 dialogs for Esc support by dispatching keydown at document. It
reported 29/29. But a real keypress goes to the focused element and bubbles from there — and
for two dialogs opened from inside the Studio iframe, focus never left the frame, so the parent document
never saw the key at all. My test skipped the exact step that was broken.
The same shape appeared in the affordance work: I verified the gap-10 header by measuring each element's rectangle and asking "is the ✕ left of centre?" — never "does the ✕ overlap the title?". It did. Geometry passes a collision it is not asked about.
Twice I saw a long-shipped, known-good layer fail its back-gesture test and concluded "the harness
can't do this". Both times the harness was fine and I had broken my own instrument — once by claiming
synthetic gestures cannot arm the back-stack (they can), once by leaving retract positive
through my own repeated history.back() calls, which silently swallows the next
popstate by design.
Explaining away a failing control is the most expensive move available, because it converts a real signal into permission to stop looking.
"Back quits the app from any page" was unreproducible on desktop, where Back on a tab is plain
browser-back and looks correct. Rather than guess at a fix, the move that broke it open was a
device probe (#backdebug) with one non-obvious property: the log survives the app
exiting, because the exit is the thing being measured. Every popstate plus the
final pagehide is written to localStorage and replayed on the next boot.
One reading solved it. pop g1 d0 s0 r0 carried three facts at once: arming worked, Back
reached us (so the browser was not skipping our entries — killing a hypothesis I had carried for
two rounds), and it found nothing open. That combination has exactly one meaning: a layer never
registered.
A known rule from the text-selection post-mortem, confirmed again and
extended. This session's additions: the preview harness reports maxTouchPoints: 0, so
pointer:coarse rules cannot be exercised at all; history.length does not move
even for a manual pushState; and focus behaves differently from a real click.
body.nav-back mutes transitions for the two frames a gesture-close takes. It was written as
a list of selectors, and it failed twice — v516 covered two, v521 added six more, and the owner
still saw the animation on three surfaces that were not on the list. The entries were never wrong. The
shape was: "nothing animates for two frames" is a universal statement, so it must be written as
one — body.nav-back, body.nav-back *.
The same failure recurred in three unrelated places: the Esc flags (one layer never flagged), the no-selection policy (room-ui only, so the Studio was exposed), and the scrollbar spec (room-ui only, so every other document kept the platform default).
An unregistered layer fails loudly — the app exits, and you go looking. An invisible registered layer fails silently: the gesture animates, nothing changes, and it gets reported as "the back gesture is broken". Two of these shipped at once — a tab layer left armed underneath an open chat (which covers the pane entirely on a phone), and Forward opening its page over a still-armed selection mode.
hasC("select-mode") is a flag;
!(NARROW() && hasC("chat-open")) is the question.Two distinct bugs from one fact. Assigning iframe.src pushes a session-history entry, so
every frame load wedged a phantom entry among our layer entries and a Back would spend itself navigating
the frame back to about:blank — the Studio went blank and the gesture appeared dead.
Separately, the frame's own dialogs were invisible to the parent's back-stack, so Back unwound the parent
while a destructive confirm sat there painted.
contentWindow.location.replace(), never src =. And an embedded document needs to
announce its layers to the parent and have the gesture relayed in — each side owning what it
can actually see. Keydowns do not cross the boundary either, so Esc needs both sides.The embedded builder gated on dataset.loaded — "we set src once". When iOS
reclaimed the frame's document under memory pressure, the latch kept saying loaded forever, so every later
open posted a message into an about:blank corpse. It failed completely silently:
contentWindow still exists, so nothing throws and there is no error to log. Nothing recovered
it short of killing the app, because the latch is DOM state.
The dismissal sweep expected wholesale drift and found 23 of 36 layers already compliant. The surfaces built most recently were correct before the rule was written down; the drift was concentrated in one older family that a well-meaning consistency pass had flattened — the 2026-07-10 systemic check folded ~11 close-button treatments into one scaffold, and in doing so erased the distinction between surface types.
Three attempts went into removing the back-gesture animation before a universal
transition:none — which changed nothing — proved by elimination that it was never our CSS. It
is the browser's own preview of the previous history entry, it applies to same-document traversals, and
the spec issue asking for an opt-out is open
and unanswered since 2023. It also cannot be traded away: a history entry is the only way to intercept
Android Back at all.
The sweep page carried "the horizontal reveal comes from our own CSS — fixed" for nine builds after both halves had been disproved. A future session would have read that and gone hunting for a transition that does not exist — the exact wrong turn, three times over. Two other cards had drifted too: a fix that was later reverted by decision still read "fixed", and one that took three passes still cited the first.
Twelve versions. Grouped by what went wrong rather than by feature, because the grouping is the point.
| Cause | Count | Where |
|---|---|---|
| Rule in one place, needed everywhere | 4 | nav-back list (×2) · no-selection policy · scrollbar spec |
| Invisible / phantom layer | 3 | tab under an open chat · forward over select mode · iframe history entries |
| Flag instead of a live question | 2 | dataset.loaded latch · card-cache cleared without its DOM |
| Missing registration | 3 | section tabs · quote dock · emoji tone popup |
| Cross-document boundary | 2 | focus for Esc · the builder's own layers |
| Genuinely new work | — | the dismissal grammar itself: 4 moves, 8 stamped |
Only one row is a feature. Everything else is a rule that was true in one place and false in another — which is the argument for the UI kit: a single place where every module is shown, so "does this apply everywhere?" is a question you can look at rather than remember.
| Practice | Why it paid |
|---|---|
| Mock before code, owner stamps each card | Gap 10's phase 2 caught the deliberate exceptions (room settings, the two-door overlays) before anything moved, and turned eight would-be arguments into eight decisions. |
| Opt-in variants on a shared component | The bare .dialog-x default stayed byte-identical, so nothing unclassified could shift. A shared component can only be changed safely by addition. |
| Breakpoint-guarded change | Every gap-10 rule sits inside max-width:760px. Desktop was verifiable as "unchanged" — a claim worth being able to make. |
| One code path per action | The header commit mirrors its footer twin rather than duplicating its logic; the phone-only ‹ runs the desktop ×'s own handler. Two copies of a rule is the bug from lesson 5, one scope smaller. |
| Batch the deploy, then verify by marker | Twelve versions shipped in one clean pass. "It responded 200" is not a deploy check — the build marker, the SW cache name and NRestarts 0 are. |
Written 2026-07-19 · from the gap 10 session (v515 → v526) · companion to the dismissal sweep and the UI kit