Gap 10 of the Proven-Better-New audit, shipped across v516–v526 — the standing record. One card per dismissible layer: what it looks like now, what it would look like proposed, its classification into the four surface types, and the state of its back gesture. Derived from registerBackLayers() and every user of the shared .dialog-x / .dialog-back scaffold in lib/theme.css, at v515. Every card was owner-stamped before a line moved. This page is now the record of which dialog is which type, so the next new dialog copies a precedent instead of reintroducing the drift.
theme.css .modal-head.hbar), so every
dialog of this style inherits it: flex order composes the bar out of the head's
existing children — glyphs go static and take the ends, the title gets
flex:1 1 0; min-width:0 + ellipsis as the only element allowed to shrink, and
kicker / sub / search / sort wrap to their own rows. No surface needed its markup restructured.
Also: the CTA became a real filled rectangle rather than a bare text button, the one long verb
carries a short form ("Start (3)"), and the now-duplicated "N selected" footer line is
hidden on phone (the chip roster stays — it names who).
overlap:false · spread:0 · clipped:false.
A pairing rule now sits in theme.css: .task/.corner on a head
that carries a title must be paired with .hbar.
Desktop is out of scope by design. In wide mode × top-right is fully correct for every type — that's where windows close. The whole sweep is a phone-layout change guarded by the existing max-width:760px breakpoint; desktop comes out visually identical, gaining only the missing Esc bindings.
| Verdict | n | Which |
|---|---|---|
| ✓ moved the × went left, the commit went right | 4 | new-chat picker · people picker · account editor · Seen writer confirm |
| ✓ stamped put to the owner, decided, then applied | 8 | library overlay · profile page · Archived & Trash · Seen writer picker · Studio browser · New persona · confer drawer · Me sheet |
| decided, keep recorded, not relitigated | 1 | chat info / room settings (Telegram-style by choice) |
| compliant already right, by construction | 23 | every page layer · the confirms · crop editor · selection bar · the viewers · the popovers |
| gesture systemic findings — 6 fixed, 1 accepted | 7 | the unregistered section tabs (Back quit the app) · the horizontal reveal (browser’s — accepted) · the Studio iframe dead zone · the collapsed tone popup · the quote dock · the save pill · 20 missing Esc bindings |
Most of the app is already compliant — the newer surfaces (pages with .pp-bar, the crop editor, the selection bar, the confirms) were built to this grammar without it being written down. The drift is concentrated in the older modal family that the 2026-07-10 systemic check standardized on .dialog-x, which put every × top-right regardless of type.
Six fixed; one (G1, the slide) proven to be the browser’s own and accepted. Five of the seven were found on a real device, not here — three of them after a check of mine had already reported green.
popstate, no Esc anywhere in the file, and it shares the top-level history.This card said the opposite for nine builds, and it was wrong. The original reading — "the
reveal is our own transform transition" — drove three attempts at a fix: v516 muted two selectors under
body.nav-back, v521 added the six translateX layers that had shipped since, and
v523 gave up on lists and made it universal (body.nav-back, body.nav-back *).
The slide survived all three. A universal transition:none that changes nothing is
proof by elimination: it was never our CSS.
pushState entries, so they get the preview. Chrome shipped an equivalent
back-forward animation on Android around v138, still being A/B tested.
preventDefault() on a near-edge touchstart, which kills the gesture
outright — and the gesture is the feature.
popstate, no interception — Back just exits the app. So the
animation is the price of Back working at all.Owner decision: accept it. It is platform-native behaviour that every web app on the device shows. What made it feel broken was never the animation — it was animating while nothing happened, which was two invisible layers (G7) and is fixed.
The emoji panel's long-press skin-tone popup (#epTone) is not registered with the back-stack, and closePanel() calls hideTone(). So with the tone popup open, a single Back closes the popup and the whole emoji panel — two layers, one gesture. That breaks the "exactly one layer, deepest first" rule. Fix: register #epTone so it sits above the panel in the LIFO stack.
The sharpest finding, and the one the brief predicted. lib/builder-ui.html — the persona builder, embedded twice (the Studio tab's #studioFrame and the "New persona" dialog's #npFrame) — contains zero occurrences of backStack, popstate, or Escape. Not a partial registration: none at all.
Inside it lives #delScrim, the "Delete persona" confirm — a real full-screen modal that closes only via its Cancel button or a scrim tap. An iframe shares the top-level history, so a Back with that confirm on screen unwinds the parent's stack — peeling the Studio dialog, or leaving the app — while the delete confirm stays painted. That is exactly "an edge swipe that kills the whole view instead of closing the open sheet", on a destructive confirm.
builder-layers — the card menu and the delete confirm); the parent mirrors that to one body class so its existing MutationObserver notices, registers it as a single back-stack layer, and relays the gesture back as builder-close-top. The parent never reaches into the frame's DOM; the frame never touches history. Esc is bound on both sides — a keydown inside an iframe never reaches the parent document. popstate leg — the back-stack deliberately refuses to arm without a trusted gesture (its Android rule), which no synthetic event can supply. A long-shipped control layer fails identically in the harness, so this is a harness limit rather than a code fault — but it still wants a real phone.dataset.loaded — a latch recording "we set
src once", never "is there a live document in there". Once WebKit reclaimed the frame
(which it does aggressively to offscreen subframes under memory pressure), the latch kept lying:
every later open postMessaged into an about:blank corpse and painted our chrome over an
empty box. It failed completely silently — contentWindow still exists on an
about:blank, so the message "succeeds" and nothing ever throws — and nothing recovered it short of
killing the app, because dataset.loaded is DOM state that only a page load clears.
That last symptom, reported by the owner, is what pinned the diagnosis: no other candidate cause
survives a reopen.
frameAlive() checks
for a real src, a URL that isn't about:blank, and a body with children;
loadFrame() guards the in-flight window and times that guard out, so a failed load can't
latch it on forever (the same bug one level down). A recovery reload costs nothing: builder state is
server-side, keyed by slug, so it lands on ?resume=<slug> and returns to the
same build rather than a blank form.
f.src = "about:blank" with the latch left at "1". That is now the regression
test, and it passes on both frames — plus it healed a real blanking mid-session, caused by
history.back() walking the iframe's own navigations.#quoteBar — its own source comment calls it "the dismissible reply-preview card" — docks above the composer when you reply to a message. It has an × (#qbClear) and nothing else: no Esc, no Back. With a quote armed, Back peels whatever is behind it instead. It is genuinely dismissible chrome, so the glyph and the gesture disagree.
#savePill (the floating ✦ Save line · ⧉ Copy pill riding a text selection, restyled at v510) is likewise unregistered. But unlike the quote dock it is selection-bound: it dies on selection-collapse, scroll, resize, or entering select-mode — the same shape as #reactBar, which is deliberately transitive. Question: should Back clear the selection and the pill, or is it correctly transient?
selPill / sel-pill / selectionBar return zero hits. v510 restyled #savePill into the text sheet's two-button chrome; that pill is the only one. Recorded so the inventory isn't chasing a ghost.backStack.escapeTop(). v525 caught
the emoji tone popup, which was registered but never flagged. Then the owner found what a sweep of
29 dialogs had reported as 29/29: on desktop, Esc could not close Browse personas or
New persona, because both open from inside the Studio iframe — focus stayed in the frame, and a
keydown never crosses that boundary, so the parent document never saw the key. The layer was
reachable; the KEY was not.
document — precisely
the step a real keypress does not take. A real key goes to the focused element and bubbles from there.
A test that skips the mechanism under test passes no matter how broken that mechanism is.
[role="dialog"][aria-modal="true"], the marker these surfaces already carry — not another
list), plus a relay so Esc pressed inside the frame is handed up to the parent's back-stack.Beyond G2/G4/G5, every element that toggles an open class is registered. #reactBar is covered transitively — it "lives and dies with selection", and select-mode is registered — and the push soft-ask card is in-flow content inside #notifPage, not a layer. So the classic miss is narrower than feared: three unregistered surfaces, one of them an entire embedded document.
Desktop Esc, however, reaches only 11 layers: the Me sheet · avatar viewer · account editor · people picker · mention popup · ⋮ menu · new-chat picker · select mode · text sheet · diagram zoom · emoji panel. Missing on 20: Archived & Trash · both Seen dialogs · both confirms · the news reader · reaction sheet · forward page · compose page · poster page · notification centre · image lightbox · crop editor · profile page · both Studio dialogs · the Vibes sub-page · library overlay · survey · both drawers.
The biggest miss in the inventory, and the sweep’s own method found it only on a device.
The four section tabs — Chats · Notes · Studio · Vibes — had zero presence in
registerBackLayers(), and Back from any of them exited the app.
#backdebug log (v520), which persists every popstate across the exit, read the
same line every time: pop g1 d0 s0 r0. Three facts in one line — g1, arming
worked; pop present, Back reached us, so the browser was not skipping our entries
(killing the Chrome-intervention hypothesis I had carried for two rounds); d0 s0, it found
nothing open. Exactly one meaning: a layer that was never registered.The resolution changed, and that matters more than the fix. v521 registered a non-Chats tab as a layer so Back returned to Chats. The owner’s call at v525 was that this is the wrong model: the four tabs are peer ROOTS, the way a bottom-nav tab is. You switch between roots; you do not back out of one. Back at a root exits, which is what every messenger does. The registration was removed.
So the tabs end where they began — unregistered — but for a decided reason rather than an oversight, and the round trip was not wasted: it produced the probe, and the probe flushed out two real bugs that were hiding behind the same complaint (v524): a tab layer left armed underneath an open chat, where peeling it changed nothing visible; and Forward never exiting select mode, so the forward page opened over an invisible layer and cost two Backs.
Four task dialogs where the × sat in the commit slot. Each already had a real commit — it just lived in a footer, so the header's top-right was spent on a close. All four now carry the verb in the header on phone; their footer twins hide there and are untouched on desktop.
The hero case, and the one the gap-10 mock draws. Full-screen on narrow, it reads as a page but behaves as a task: you select personas and commit. Today the × is top-right and the commit is a footer Start chat — so the slot muscle memory reserves for "yes, do it" holds "no, throw it away", and the commit is below the fold on a long cast list. Proposed: ✕ top-left (dismiss = go back, selection discarded) and Start (n) top-right in coral, carrying the count. The footer pair can stay for wide, or fold into the header on narrow — your call, noted below.
Same shape as the new-chat picker and the same fault: multi-select with a footer Invite, × top-right. It mirrors the persona picker deliberately, so it should move with it — the two must not diverge.
The textbook case for why this gap exists. It holds a form with real unsaved edits and a footer Save; the × top-right is titled cancel and discards silently — no confirm, no toast. That is precisely the "did closing save my changes?" ambiguity the Material layout prevents by putting cancel left and commit right. Proposed: Cancel top-left · Save top-right.
A confirm with a real commit (Write it) plus a secondary door (Other writers…). The footer already reads correctly left-to-right; the header's × is the odd one out. Proposed: ✕ top-left · Write it top-right, footer keeps "Other writers…" as the secondary.
Eight surfaces where the deviation might have been deliberate. Each was put to the owner as a question; the outcome is recorded on every card below. The mock labelled "the question" is what shipped in each case.
It carries both: .dialog-back ← left and .dialog-x × right, plus a footer Done. That reads as a deliberate two-door design — ← returns to the picker underneath, × leaves the whole new-chat flow. If that's the intent it is arguably more correct than the four-type table, and should be recorded as its own pattern.
Gap 11 declared this "born compliant with gap 10 — a page, ← back top-left, no ×". It has the ← … and also a × top-right. Same two-door question as the library overlay: it opens over the picker, over the chat-info member list, and over Vibes, so × may be the deliberate "close the whole stack" escape.
Type is genuinely ambiguous. It browses (tabs, a list, per-row verbs) which says page → ← left. But it also has a footer Done and Empty trash, which says task → ✕ left + Done right. Both readings are defensible and they land the glyph in the same place — the difference is whether Done is promoted to the header.
Opens over the Seen confirm, and picking a writer returns you to it. By the house "sub dialog" grammar that means ← back, not ✕ — you're going back one step, not cancelling. It currently shows ✕ top-right.
Two dialogs in the Studio family, both .dialog-x top-right. Browse personas has no commit at all (you browse; tapping a card opens the profile page over it) → that reads as an overlay, × right is correct, keep. New persona hosts the builder's create flow in an iframe that owns its own buttons — moving our × left could leave two competing cancels, or could be exactly right. I don't want to guess at an iframe's internal grammar.
The two drawers are asymmetric. The wildcard drawer slides in from the right and closes with a ← chevron; the confer drawer slides in from the left and closes with an × top-right. For a left-slide drawer, an × on the far right is the corner furthest from the thumb and furthest from where the drawer came from — but drawers are their own idiom and this may be a considered choice.
A sheet by the four-type table → grabber + swipe-down, × optional. It currently has a .dialog-x.flow in its own bar on narrow (hidden entirely on wide, where the ≡ toggle serves). There's no swipe-down, and adding one is a capability, not a glyph move — outside this sweep's "no new capability" line unless you want it.
Telegram-style by choice — recorded here, not relitigated. It grows out of the chat title into a full-screen screen with its own .ci-bar: ← back top-left, title centre, ⋯ more top-right. That happens to be exactly the page grammar the sweep would ask for, so the decided exception and the norm agree.
Twenty-three layers already in the right grammar — recorded so the register is complete and so the next new dialog has precedents to copy rather than a blank page. The four marked exemplar are the ones to imitate.
#fwdPage · .pp-bar ← · registered ✓ · Esc missing · slides ✗#notifPage · registered ✓ · Esc missing · slides ✗ · hosts the push soft-ask card as content, not a layer#posterPage · registered ✓ · Esc missing · slides ✗#vgPage · registered ✓ · Esc missing · slides ✗#nwScrim (narrow) · registered ✓ · Esc missing · slides ✗#txtSheet · registered ✓ · Esc ✓ · no slide (display toggle) — crisp alreadybody.chat-open · registered ✓ (skipped in peek, correctly) · slide muted by nav-back ✓#vcpPage · ← left + Post right — the shape every moving card is aiming at. Registered ✓ · Esc missing · slides ✗#cropLayer (v512) · textbook Cancel/Done. Registered ✓, peels above Edit profile · Esc missingbody.select-mode / #selBar (v510) · ✕ left + Done right. Registered ✓ · Esc ✓ · #reactBar rides it and dies with it#actScrim · Cancel left / Confirm right. Registered ✓ · Esc missing#vcScrim · Registered ✓ · Esc missing#surveyScrim · Later left / Send right. Registered ✓ · Esc missing#diaZoom · .dialog-x.flow right · registered ✓ · Esc ✓#imgLightbox · circular .lb-x — the documented scaffold exception · registered ✓ · Esc missing#avView (v512) · circular .lb-x + tap-out · registered ✓ · Esc ✓ · FLIP transition, no horizontal reveal#rxScrim · .rx-grip grabber, no × — correct sheet grammar. Registered ✓ · Esc missing#emojiPanel · no glyph by design. Registered ✓ · Esc ✓ · see G2 — its tone popup shares one Backbody.wildcard-open · ← on a right-slide drawer. Registered ✓ · slide muted ✓ · Esc missing#popMenu · tap-out. Registered ✓ · Esc ✓#mentionPop · tap-out. Registered ✓ · Esc ✓#modelMenu · tap-out. Registered ✓ · Esc missing#asLangDD · nested in the Me sheet, peels first ✓ — the depth-order reference#epTone · now registered above the panel — one Back peels the popup alone. Was: closePanel()'s hideTone() took both at once.#delScrim in lib/builder-ui.html · the whole file has no back-stack, no popstate, no Esc. Shares top-level history → Back unwinds the parent while this stays painted. Destructive confirm.#quoteBar · now registered, running its own ✕ (#qbClear) so there is one code path. Back clears the quote before peeling anything behind it; Esc too.#savePill · left transient by decision — selection-bound, dies on collapse/scroll/resize, exactly like #reactBar. Registering it would make Back mean "clear my selection", which no messenger does.| Change | Where it landed |
|---|---|
Variant classes — .dialog-x.task · .dialog-back.corner · .dialog-commit · .hide-narrow, each surface opting in | lib/theme.css #22b — the bare default is unchanged, so nothing unclassified could shift. All phone-only (max-width:760px). |
| Header commit slots for the four moving dialogs | lib/room-ui.html — mirrorCommit() mirrors each footer twin (same click, disabled state and label), so semantics are identical by construction, not by two copies staying in step |
G1 · widened the nav-back rule to the six sliding layers | lib/room-ui.html — one selector list. Verified: all eight layers compute transition:none under body.nav-back and keep their own duration without it. |
G2 · registered #epTone · G4 · registered #quoteBar | registerBackLayers() — one reg(…) line each, reusing the existing close path |
| G3 · the Studio iframe — fixed v519 | A postMessage contract, each side owning what it can see: the frame announces its open layers, the parent mirrors that to one body class (so its existing observer notices), registers it as a single back-stack layer, and relays the gesture back in. Esc bound on both sides — a keydown inside an iframe never reaches the parent. Shipped together with the blank-frame fix, as one pass. |
| G6 · one Esc handler (desktop only) | backStack.escapeTop() peels the same LIFO top layer Back does, across 24 flagged layers. Layers owning an Esc handler stay unflagged, so a keypress can never close two — verified nested. |
| Incidental cleanup | .addpicker (+ its .open rule) is dead CSS — no matching element anywhere in the markup, left behind by a retired picker. |
| Re-base the house naming — "main dialog / sub dialog" onto the four types | docs/style-guide.html + the dialog-styles memory |
.dialog-x moves the × on every surface at once — including the overlays where ×-right is correct. So v516 added opt-in variants and never changed the default — verified at paint level: at 1280px every × is still top-right, every header commit is display:none, and every footer button is where it was. Close semantics stay identical everywhere: position, pairing, gesture coverage and exit animation change; behaviour does not.Written 2026-07-18 as the phase-2 proposal · shipped v516 the same day · derived from lib/room-ui.html + lib/theme.css + lib/builder-ui.html · 36 registered layers → 38 · 4 moved · 8 stamped · 23 already compliant · 5 gesture fixes · 1 deferred (the Studio iframe)