Dialogue· Lessons

Lessons

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.

the one-sentence version Most of the bugs were a rule living in one place when it belonged in the shared one; most of the lost time was a test shaped to agree with me.

Part 1 · Verification

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.

lesson 1 · the expensive one

A test that skips the mechanism under test passes no matter how broken that mechanism is

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.

the check Before trusting a green result, ask: which step of the real path does my test skip? If the answer is "the one that was broken", the test proves nothing.
Cost: shipped a visibly broken header (v516), and left Esc broken for two dialogs through three separate "fixed" claims.
lesson 2

When a control test fails, believe it

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.

the check A control failing means the instrument is broken or the thing is broken. Both demand investigation. "The harness can't do this" is a conclusion, never a starting assumption — and it needs its own evidence.
Cost: two rounds, and a wrong claim in a shipped doc that the reveal was our CSS.
lesson 3 · the one that worked

When you cannot reproduce it, build the instrument — not a guess

"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.

the pattern Instrument the surface where the bug lives, and make the evidence outlive the failure. A probe that dies with the crash tells you nothing about the crash.
Payoff: one screenshot ended a three-round hunt — and the same probe then found two further bugs nobody was looking for.
lesson 4

The emulator lies about touch — and about focus, and about history

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.

the split Verify logic off-device (does the predicate return the right thing, does the selector resolve, does the close function run). Hand feel, focus, touch and history to a real phone. Say plainly which half you did.

Part 2 · Architecture

lesson 5 · cost three separate failures

A maintained list is the wrong shape for a universal statement

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).

the test If the rule you are writing is really "all X", write all X. A list needs maintenance forever and fails silently when it does not get it — and every new surface starts off it.
Cost: three rounds on the same bug, plus two silent gaps found only on device.
lesson 6 · the subtlest one

A registered layer that cannot be seen is worse than an unregistered one

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.

the rule A layer predicate must answer "is this on screen?" — not merely "is this flag set?". hasC("select-mode") is a flag; !(NARROW() && hasC("chat-open")) is the question.
lesson 7

An iframe shares the parent's history — so it competes for the back gesture

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.

the rules Navigate a frame with 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.
lesson 8

A flag that records "did we ever" is not a flag that records "is it still"

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 rule For anything the platform can reclaim — an iframe, a cache, a decoded image, a connection — ask the thing, do not trust a flag. And notice that "it never recovers until restart" is a signature: it points at state that only a reload clears.

Part 3 · Product judgement

lesson 9

Most of the app was already right — the audit's value was proving it

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.

the caution A consistency pass can destroy real information by making genuinely different things look the same. "One treatment everywhere" is only right when the things really are the same.
lesson 10

Know when to stop: the slide was never ours to fix

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 pattern When a fix fails repeatedly, stop fixing and research whether it is fixable. "Accepted, unfixable, here is why" is a legitimate — and cheaper — outcome than a fourth attempt.
lesson 11

The record has to be corrected when the conclusion changes

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.

the rule A living doc is only worth its last correction. When a conclusion is overturned, fixing the record is part of the same change — not follow-up work.

What actually changed, by cause

Twelve versions. Grouped by what went wrong rather than by feature, because the grouping is the point.

CauseCountWhere
Rule in one place, needed everywhere4nav-back list (×2) · no-selection policy · scrollbar spec
Invisible / phantom layer3tab under an open chat · forward over select mode · iframe history entries
Flag instead of a live question2dataset.loaded latch · card-cache cleared without its DOM
Missing registration3section tabs · quote dock · emoji tone popup
Cross-document boundary2focus for Esc · the builder's own layers
Genuinely new workthe 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.

THE RECURRING SHAPE room-ui.html rule written here, correct every other document rule never arrives — fails silently builder-ui · seen · share admin · login · personas theme.css — the shared layer write it once here and every document inherits it, iframes included Four of this session's bugs were exactly this. The fix is never the rule — it is where the rule lives.
The shape behind four separate bugs — and the reason theme.css keeps growing.

Working agreements that earned their place

PracticeWhy it paid
Mock before code, owner stamps each cardGap 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 componentThe bare .dialog-x default stayed byte-identical, so nothing unclassified could shift. A shared component can only be changed safely by addition.
Breakpoint-guarded changeEvery gap-10 rule sits inside max-width:760px. Desktop was verifiable as "unchanged" — a claim worth being able to make.
One code path per actionThe 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 markerTwelve 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.
the note to a future session If you are about to say "the harness can't test this" or "that check passed, so it works" — stop. Those two sentences account for most of the wasted time recorded on this page. Build the probe, or hand it to a device, and say which one you did.

Written 2026-07-19 · from the gap 10 session (v515 → v526) · companion to the dismissal sweep and the UI kit