Dialogue · Dismissal sweep

The dismissal sweep

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.

the thesis Users should spend zero neurons learning our way. Native muscle memory differentiates by surface: a page goes back top-left; a task dialog cancels top-left and commits top-right; only overlays close top-right; sheets swipe down. An × in the top-right of a task dialog steals the commit slot — that's the "did closing save my changes?" ambiguity the Material layout exists to prevent. Dismissal has two halves: the affordance (where the glyph sits) and the gesture (what the thumb does). This page covers both.
v517 · the on-device correction v516's header was wrong on a real phone, and the emulator said it was fine. The ✕ and the CTA shipped as absolutely-positioned corners while the title kept flowing underneath them, so on a 375px screen the glyph painted on top of the title, sat off its baseline, and — because an absolute element is out of flow — left the title no width to shrink into. The mocks on this page had always drawn a flex bar; the code simply never built one.

Fixed in the shared layer (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).

The lesson, recorded: my v516 check measured each element's rect and asked only "is the ✕ left of centre?" — never "does the ✕ overlap the title?". Geometry passes a collision it isn't asked about. The v517 check measures pairwise overlap, vertical spread, and scrollWidth vs clientWidth; all eight heads read 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.

The four types

type 1
Page
Navigated into. ← top-left, never ×. The gesture and the chevron do the same thing.
type 2
Task dialog
Has a commit. ✕ / Cancel top-LEFT · Done / Save top-RIGHT. The one type where ×-right actively misleads.
type 3
Overlay / viewer
Content over content, nothing to commit. × top-right (or tap-out) is correct and stays.
type 4
Sheet / card
Rises from the bottom. Grabber + swipe-down; × optional and usually absent.

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.

The scoreboard

VerdictnWhich
✓ moved the × went left, the commit went right4new-chat picker · people picker · account editor · Seen writer confirm
✓ stamped put to the owner, decided, then applied8library overlay · profile page · Archived & Trash · Seen writer picker · Studio browser · New persona · confer drawer · Me sheet
decided, keep recorded, not relitigated1chat info / room settings (Telegram-style by choice)
compliant already right, by construction23every page layer · the confirms · crop editor · selection bar · the viewers · the popovers
gesture systemic findings — 6 fixed, 1 accepted7the 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.

The gesture half — seven systemic findings

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.

the symptom you told me to hunt for "An edge swipe that leaves the room / kills the whole view instead of closing the open sheet." Found it — G3. The Studio's builder iframe has no back-stack, no popstate, no Esc anywhere in the file, and it shares the top-level history.

G1 · The horizontal reveal is the BROWSER’s, not ours · accepted, unfixable

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.

what it actually is The browser’s own animation for history navigation. Safari (iOS and desktop) shows a screenshot preview of the previous history entry on an edge drag — and per w3c/csswg-drafts#8333, raised by Jake Archibald in 2023, that includes same-document traversals. Our layers are pushState entries, so they get the preview. Chrome shipped an equivalent back-forward animation on Android around v138, still being A/B tested.

There is no opt-out. That issue asks "is there something developers should be doing today to avoid this?" and is still open, unanswered. No CSS property, no meta tag; the manifest proposal to disable built-in navigation gestures has sat un-adopted since 2022. The only lever on iOS is preventDefault() on a near-edge touchstart, which kills the gesture outright — and the gesture is the feature.

And it cannot be traded away: a history entry is the ONLY way to intercept Android Back. No entry, no 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.

G2 · One Back closes two layers (the skin-tone popup) ✓ fixed v516

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.

G3 · The Studio iframe is a back-gesture dead zone ✓ fixed v519

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.

fixed · v519 The frame now announces its open layers (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.

Verified: the announcement round-trip, the relay in both directions, frame-Esc and parent-Esc. Not verified off-device: the 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.
rode with it · fixed v519 "New persona" sometimes loaded an EMPTY frame — no content, on iOS only, intermittently. Solved in the same pass as G3, and it turned out to be one of the suspects exactly: no reload after a discarded first load. The cause was 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 silentlycontentWindow 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.

Fixed by asking the frame instead of trusting a flagframeAlive() 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.

Reproduced deterministically without iOS, which was the real blocker — you don't need the browser to run out of memory, only the state a reclaim leaves behind: 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.

G4 · The quote-reply dock has no gesture ✓ fixed v516

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

G5 · The save pill — layer or transient? · decided: leave transient

#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?

correction to the brief There is no "desktop selection pill" element as a separate layer — 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.

G6 · Registry coverage is otherwise complete — Esc was not ✓ v516 → v526

it took three passes, and the last one was owner-diagnosed v516 gave every uncovered layer one Esc handler through 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.

Why the sweep missed it: it dispatched the keydown at 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.

Fixed at v526 by making a dialog take focus when it opens (keyed on [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.

G7 · The section tabs — Back quit the app ✓ resolved v521 → v525

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.

how the probe settled it Unreproducible on desktop, where Back on a tab is plain browser-back and looks fine. The #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.

the rule that came out of it 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 one fails silently and reads as “the back gesture is broken”, which is exactly how it was reported. A layer predicate must answer “is this on screen?”, not merely “is this flag set?”

Cards · moved ✓ shipped v516–v517

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.

#modalScrimHand-pick your panel — the new-chat pickertask dialog✓ moved

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.

was · v515
9:41● ● ●
✦ new chat
What's on your mind?
Pick one or more from the persona library — each chat keeps its own panel.
Search name, role, or what they do…
小胖看房buying prep
EPEsther Perelrelationships
RDieter Ramsdesign
2 selectedCancelStart chat
9:41● ● ●
Hand-pick your panelStart (2)
✦ new chat
What's on your mind?
Pick one or more from the persona library — each chat keeps its own panel.
Search name, role, or what they do…
小胖看房buying prep
EPEsther Perelrelationships
RDieter Ramsdesign
2 selectedCancelStart chat
affordance.dialog-x #closePick top-right · commit = footer #startChat
gestureregistered ✓ closePicker, correct depth (library overlay peels first)
escbound ✓
open decisionkeep the footer pair on narrow too (belt-and-braces), or drop it once the header commits? Struck through in the mock as the cleaner read.
#pplScrimInvite / remove peopletask dialog✓ moved

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.

was · v515
9:41● ● ●
Invite people
Friends who aren't in this chat yet.
Search friends by name…
MMary
JJun
1 selectedCancelInvite
9:41● ● ●
Invite peopleInvite (1)
Friends who aren't in this chat yet.
Search friends by name…
MMary
JJun
1 selectedCancelInvite
affordance.dialog-x #pplClose top-right · commit = footer #pplDone
gestureregistered ✓ closePeoplePicker — the 2026-06-22 miss, fixed then
escbound ✓
#acctScrimAccount editor — display name / passwordtask dialog✓ moved

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.

was · v515
9:41● ● ●
● account
Change display name
Display name
Mary Chen
How others see you
Mary Chen · @marychen
Save
9:41● ● ●
CancelSave
● account
Change display name
Display name
Mary Chen
How others see you
Mary Chen · @marychen
Save
affordance.dialog-x #acctX top-right (title="cancel") · commit = footer #acctSave
gestureregistered ✓ closeAcct
escbound ✓
flaggedSilent discard. Per the gap's own rule I am flagging, not redesigning — the sweep moves the glyph, it does not add a confirm. Say the word if you want the confirm as a separate change.
#seenScrimSeen — "Your Seen" writer confirmtask dialog✓ moved

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.

was · v515
9:41● ● ●
✦ seen
Your Seen
14–20 July · 6 chats, 41 notes
EPEsther Perelsuggested
She'll read the fortnight and write it back to you.
↻ Other writers…CancelWrite it
9:41● ● ●
Your SeenWrite it
✦ seen
14–20 July · 6 chats, 41 notes
EPEsther Perelsuggested
She'll read the fortnight and write it back to you.
↻ Other writers…CancelWrite it
affordance.dialog-x #seenDgX top-right · commit = footer #seenDgWrite
gestureregistered ✓ closeSeenConfirm, correct depth (picker peels first)
escmissing ✗

Cards · stamped ✓ shipped v516–v517

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.

#libLayerHand-pick library overlay — "Add to your panel"task dialog✓ stamped

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.

was · v515
9:41● ● ●
Add to your panel
Search name, role, or what they do…
FFeddomortgage prep
Terence Taomath
2 in your panelDone
9:41● ● ●
Add to your panelDone (2)
Search name, role, or what they do…
FFeddomortgage prep
Terence Taomath
…but then where does "leave the whole flow" live?
gestureregistered ✓ closeLibOverlay, peels before the picker
escmissing ✗
questionIs the ←/× pair deliberate (back one step vs. leave the flow)? If yes → decided, keep, and it becomes the house pattern for nested pickers. If no → Done moves to the header and the × goes.
#vibeProfileProfile page — persona & user dossierpage✓ stamped

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.

was · v515
9:41● ● ●
Mary
M
Mary
● online
"Renovating. Slow replies."
Message privately
9:41● ● ●
Mary
M
Mary
● online
"Renovating. Slow replies."
Message privately
affordance.dialog-back #vpBack left + .dialog-x.flow #vpClose right
gestureregistered ✓
escmissing ✗
questionDrop the × on narrow (pure page), or keep it as the stack-escape? Gap 11's own text says no ×.
#storeScrimArchived & Trashpage?✓ stamped

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.

was · v515
9:41● ● ●
▤ archived & trash
Archived & Trash
Archived · Trash can
FFlat-hunt crewarchived
1 archivedDone
9:41● ● ●
Archived & TrashEmpty
Archived · Trash can
FFlat-hunt crewarchived
page (← + no commit) or task (✕ + Done)?
gestureregistered ✓ closeStore
escmissing ✗
questionPage or task? My read: page — nothing is staged, every action commits on the spot, so "Done" is really "close".
#seenPickScrimSeen — choose-writer pickersub dialog✓ stamped

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.

was · v515
9:41● ● ●
Who should write it?
Search name, role, or what they do…
EPEsther Perelsuggested
RDieter Ramsdesign
9:41● ● ●
Who should write it?
Search name, role, or what they do…
EPEsther Perelsuggested
RDieter Ramsdesign
gestureregistered ✓ peels before the confirm — depth correct
escmissing ✗
questionMake it ← (sub dialog, returns to the confirm) — or is ✕ deliberate because a pick also commits the writer?
#pbScrim · #npScrimStudio — persona browser & "New persona"overlay?✓ stamped

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.

was · v515 (both)
9:41● ● ●
New persona
the builder's create flow
— its own iframe, its own buttons —
9:41● ● ●
New persona
…does the iframe already have a Cancel here?
gestureboth registered ✓
escmissing on both ✗
questionBrowser → overlay, keep (my read). New persona → does the embedded flow own the cancel? If yes, leave our × alone.
#conferDrawerConfer / deliberation drawerdrawer✓ stamped

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.

was · v515
9:41● ● ●
✦ confer
The question(s)
What the panel is deliberating right now.
9:41● ● ●
Confer
The question(s)
…matching the wildcard drawer's ← ?
gestureregistered ✓ closeConfer; slide muted by nav-back
escmissing ✗
questionMatch the wildcard drawer's ←, or is the × deliberate for the left drawer?
#mePageThe Me sheetsheet✓ stamped

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.

was · v515
9:41● ● ●
M
Mary Chen ✎
@marychen
Energy1.2k today
Preferenceslanguage · voice
9:41● ● ●
M
Mary Chen ✎
@marychen
Energy1.2k today
grabber + swipe-down = new capability
gestureregistered ✓ · language dropdown nests correctly above it ✓
escbound ✓ — and it politely yields to layers stacked over it
questionLeave as-is (× in the bar), or spend the swipe-down? My read: leave it — the ≡ toggle is the real door and the gesture already works.

Card · decided, keep

body.info-open · #topMenuChat info / room settingspagedecided · keep

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.

v516 — unchanged, by decision
9:41● ● ●
Flat-hunt crew
F
Flat-hunt crew
3 humans · 2 personas
MMaryowner
小胖看房persona
gestureregistered ✓ closeMenu · the people picker correctly peels above it
escmissing ✗ — the one thing worth adding, desktop only

Compliant by construction

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.

Pages — ← top-left, no ×

Forward · "Select a chat"page
Select a chat
#fwdPage · .pp-bar ← · registered ✓ · Esc missing · slides ✗
Notification centrepage
NotificationsMark all read
#notifPage · registered ✓ · Esc missing · slides ✗ · hosts the push soft-ask card as content, not a layer
Poster pagepage
Poster
#posterPage · registered ✓ · Esc missing · slides ✗
Chats from Vibespage
Chats from Vibes
#vgPage · registered ✓ · Esc missing · slides ✗
News readerpage
the story
#nwScrim (narrow) · registered ✓ · Esc missing · slides ✗
Double-tap text sheetpage
message text
#txtSheet · registered ✓ · Esc ✓ · no slide (display toggle) — crisp already
Narrow chat → chats listpage
Flat-hunt crew
body.chat-open · registered ✓ (skipped in peek, correctly) · slide muted by nav-back

Task dialogs already reading left-to-right — the exemplars

Post to Vibes — composetaskexemplar
Post to VibesPost
#vcpPage · ← left + Post right — the shape every moving card is aiming at. Registered ✓ · Esc missing · slides ✗
Avatar crop editortaskexemplar
CancelMove and scaleDone
#cropLayer (v512) · textbook Cancel/Done. Registered ✓, peels above Edit profile · Esc missing
Selection action bartask modeexemplar
3 selectedDone
body.select-mode / #selBar (v510) · ✕ left + Done right. Registered ✓ · Esc ✓ · #reactBar rides it and dies with it
Fork / Archive / Delete confirmconfirm
CancelConfirm
#actScrim · Cancel left / Confirm right. Registered ✓ · Esc missing
Vibes private-chat confirmconfirm
CancelStart chat
#vcScrim · Registered ✓ · Esc missing
Feedback check-in surveyconfirm
LaterSend
#surveyScrim · Later left / Send right. Registered ✓ · Esc missing

Overlays — × top-right is correct and stays

Diagram zoomoverlay
Diagram
#diaZoom · .dialog-x.flow right · registered ✓ · Esc ✓
Image lightboxoverlay
poster
#imgLightbox · circular .lb-x — the documented scaffold exception · registered ✓ · Esc missing
Avatar vieweroverlay
full-screen photo
#avView (v512) · circular .lb-x + tap-out · registered ✓ · Esc ✓ · FLIP transition, no horizontal reveal

Sheets & popovers — grabber or tap-out

Reaction details sheetsheet
▬ who reacted
#rxScrim · .rx-grip grabber, no × — correct sheet grammar. Registered ✓ · Esc missing
Emoji panelsheet
😀 keyboard-space occupant
#emojiPanel · no glyph by design. Registered ✓ · Esc ✓ · see G2 — its tone popup shares one Back
∞ Wildcard drawerdrawer
wildcard
body.wildcard-open · ← on a right-slide drawer. Registered ✓ · slide muted ✓ · Esc missing
Per-chat ⋮ menupopover
Archive · Delete
#popMenu · tap-out. Registered ✓ · Esc ✓
@-mention autocompletepopover
@小胖看房
#mentionPop · tap-out. Registered ✓ · Esc ✓
Model switcherpopover
deepseek · sonnet
#modelMenu · tap-out. Registered ✓ · Esc missing
Language dropdownpopover
English · 中文
#asLangDD · nested in the Me sheet, peels first ✓ — the depth-order reference
Skin-tone popupG2 ✓ fixed
✋🏻 ✋🏼 ✋🏽 ✋🏾 ✋🏿
#epTone · now registered above the panel — one Back peels the popup alone. Was: closePanel()'s hideTone() took both at once.

The gesture misses — two fixed, one deferred, one decided

Studio · "Delete persona"G3 ✓ fixed
CancelDelete persona?Delete
#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.
Quote-reply dockG4 ✓ fixed
↩ Mary — "the survey's back…"
#quoteBar · now registered, running its own ✕ (#qbClear) so there is one code path. Back clears the quote before peeling anything behind it; Esc too.
Save-line pillG5 · left transient
✦ Save line · ⧉ Copy
#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.

What shipped

ChangeWhere it landed
Variant classes.dialog-x.task · .dialog-back.corner · .dialog-commit · .hide-narrow, each surface opting inlib/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 dialogslib/room-ui.htmlmirrorCommit() 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 layerslib/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 #quoteBarregisterBackLayers() — one reg(…) line each, reusing the existing close path
G3 · the Studio iframefixed v519A 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 typesdocs/style-guide.html + the dialog-styles memory
the constraint that governed it — and held The × chrome is one reused component. A single edit to .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)