feat(android): the phone's "More" is a bottom sheet #211

Merged
logan merged 1 commits from feat/71-more-as-a-bottom-sheet into main 2026-08-25 17:55:31 +00:00
Collaborator

What the issue was

The "More" button in the phone's tab bar opened <app-sidebar> in a
wa-drawer sliding in from the side: a 200px column of a 424px
screen, opening away from the thumb that asked for it, with the rest of
its 400px band empty. The report's second half -- "only part of the
screen scrolls under my finger" -- is three nested scrollers, the
dialog, its body and the sidebar's own overflow-y: auto host, so
which box a drag moved depended on where the finger landed.

What changed, and why

commit issue
92665cc feat(android): the phone's "More" is a bottom sheet #71
  • The same element, another placement. wa-drawer is a native
    <dialog> opened with showModal() -- exactly the top layer #60's
    containment finding rests on -- so placement="bottom" plus
    without-header and a grip makes it the sheet this app already
    has
    , and the focus trap, Escape, tap-outside and wa-after-hide
    come along unchanged. Nothing new had to be proved about
    .main-panel's paint containment.
  • One scroller, the sheet's body, with overscroll-behavior: contain. :host([expanded]) is what turns the sidebar's off.
  • The sidebar is still mounted, not re-listed as data, which the
    Direction offers as the alternative. The shell's own <app-sidebar>
    is display: none below 600px rather than removed, so a second list
    drawing nav-* handles is the duplicate-testid failure this
    component already renders conditionally to avoid -- and it would be a
    second place to add the next view to, with its own copy of #25's
    filter.
  • expanded had to mean "the host owns the box", not just "keep
    the labels": app-sidebar writes an inline width and caps itself
    at 400px, and an inline width beats any rule the host could write, so
    without this the sheet would have drawn the old 200px column inside a
    full-width surface. The width, the scrolling and the mouse-only
    resize handle follow the attribute now.
  • 48px rows (#186's floor, #60's sheet rows), stated in the
    sidebar's own stylesheet under the phone query, since that is the
    only place it renders below 600px.

Verification

  • Measured in the running app at 424x439 (make dev-headless SEED=default): sheet 0, 65.9, 424, 373 -- full width, on the
    bottom edge, 85vh so there is an outside to tap; rows 48px; body
    scrollHeight 412 against clientHeight 373 with
    overscroll-behavior: contain and the sidebar at overflow-y: visible; scrolled to the end, Settings' row bottom is 431 in a 439px
    viewport; a backdrop tap closes it and unmounts the sidebar.
    Screenshots read in both states.
  • Desktop and Compact untouched, checked in the same app: 1280 ->
    200px sidebar, 34px rows, resize handle present; 700 -> 56px
    collapsed.
  • make ui-test -- 1097 passed, including 5 new cases in
    frontend/test/components/bottom-nav.test.ts (the placement and
    without-header, the one scroll container, the width, the parsed
    phone stylesheet, the hidden resize handle). Proved non-vacuous:
    with :host([expanded])'s box rules removed, the scroll-container
    and width cases fail.
  • make e2e -- 240 passed against make dev-headless SEED=default
    (chromium; WebKit is CI-only), including a new case in
    e2e/specs/phone-shell.spec.ts measuring the sheet's geometry.
    Proved non-vacuous: reverted to placement="start" and rebuilt,
    it fails on bottom 717 against the viewport's 844. It polls for the
    settled box, because the drawer's show animation translates the sheet
    a full height below the fold and a single measurement races it.
  • npx tsc --noEmit and make css-check clean. No Go, no .sql, no
    .templ and no bound signature, so make lint, make test,
    make generate and make bindings were not run.

Deliberately not done

  • No scroll affordance on the sheet, which it needs: 412 of content
    in a 373px body means most of a row is below the fold with nothing
    saying so. That is #207's answer one surface over, and #207 is an
    open PR -- a second copy of an unshipped rule is how two sheets come
    to disagree about what a fold looks like. Filed as #210, whose
    Direction is to share the fragment once #207 lands.
  • The back gesture still does not dismiss it. True of the side
    drawer as well, and it is #200's subject: the shell owns the history
    stack and a surface reaching for history on its own is the fault
    navStack was deleted for.

Closes #71

### What the issue was The "More" button in the phone's tab bar opened `<app-sidebar>` in a `wa-drawer` sliding in from the **side**: a 200px column of a 424px screen, opening away from the thumb that asked for it, with the rest of its 400px band empty. The report's second half -- "only part of the screen scrolls under my finger" -- is three nested scrollers, the dialog, its body and the sidebar's own `overflow-y: auto` host, so which box a drag moved depended on where the finger landed. ### What changed, and why | commit | issue | |---|---| | `92665cc` feat(android): the phone's "More" is a bottom sheet | #71 | - **The same element, another `placement`.** `wa-drawer` is a native `<dialog>` opened with `showModal()` -- exactly the top layer #60's containment finding rests on -- so `placement="bottom"` plus `without-header` and a grip makes it *the sheet this app already has*, and the focus trap, Escape, tap-outside and `wa-after-hide` come along unchanged. Nothing new had to be proved about `.main-panel`'s paint containment. - **One scroller**, the sheet's body, with `overscroll-behavior: contain`. `:host([expanded])` is what turns the sidebar's off. - **The sidebar is still mounted, not re-listed as data**, which the Direction offers as the alternative. The shell's own `<app-sidebar>` is `display: none` below 600px rather than removed, so a second list drawing `nav-*` handles is the duplicate-testid failure this component already renders conditionally to avoid -- and it would be a second place to add the next view to, with its own copy of #25's filter. - **`expanded` had to mean "the host owns the box"**, not just "keep the labels": `app-sidebar` writes an *inline* width and caps itself at 400px, and an inline width beats any rule the host could write, so without this the sheet would have drawn the old 200px column inside a full-width surface. The width, the scrolling and the mouse-only resize handle follow the attribute now. - **48px rows** (#186's floor, #60's sheet rows), stated in the sidebar's own stylesheet under the phone query, since that is the only place it renders below 600px. ### Verification - **Measured in the running app at 424x439** (`make dev-headless SEED=default`): sheet `0, 65.9, 424, 373` -- full width, on the bottom edge, 85vh so there is an outside to tap; rows 48px; body `scrollHeight` 412 against `clientHeight` 373 with `overscroll-behavior: contain` and the sidebar at `overflow-y: visible`; scrolled to the end, Settings' row bottom is 431 in a 439px viewport; a backdrop tap closes it and unmounts the sidebar. Screenshots read in both states. - **Desktop and Compact untouched**, checked in the same app: 1280 -> 200px sidebar, 34px rows, resize handle present; 700 -> 56px collapsed. - `make ui-test` -- **1097 passed**, including 5 new cases in `frontend/test/components/bottom-nav.test.ts` (the placement and `without-header`, the one scroll container, the width, the parsed phone stylesheet, the hidden resize handle). **Proved non-vacuous**: with `:host([expanded])`'s box rules removed, the scroll-container and width cases fail. - `make e2e` -- **240 passed** against `make dev-headless SEED=default` (chromium; WebKit is CI-only), including a new case in `e2e/specs/phone-shell.spec.ts` measuring the sheet's geometry. **Proved non-vacuous**: reverted to `placement="start"` and rebuilt, it fails on `bottom` 717 against the viewport's 844. It polls for the settled box, because the drawer's show animation translates the sheet a full height below the fold and a single measurement races it. - `npx tsc --noEmit` and `make css-check` clean. No Go, no `.sql`, no `.templ` and no bound signature, so `make lint`, `make test`, `make generate` and `make bindings` were not run. ### Deliberately not done - **No scroll affordance on the sheet**, which it needs: 412 of content in a 373px body means most of a row is below the fold with nothing saying so. That is #207's answer one surface over, and #207 is an open PR -- a second copy of an unshipped rule is how two sheets come to disagree about what a fold looks like. Filed as **#210**, whose Direction is to share the fragment once #207 lands. - **The back gesture still does not dismiss it.** True of the side drawer as well, and it is #200's subject: the shell owns the history stack and a surface reaching for `history` on its own is the fault `navStack` was deleted for. Closes #71
Author
Collaborator

CI is green on 92665cc (run 17674).

  • check — success: commit messages, lint, test (three build
    configurations), frontend typecheck, CSS literals, the component
    suite, bindings and the skill check.
  • e2e — success, and both engines ran: E2E — chromium and
    E2E — webkit are each completed/success. Worth stating rather
    than implying, since a chromium failure used to skip the WebKit step
    silently and this is the only tier that sees the renderer the app
    ships with.
**CI is green** on `92665cc` (run 17674). - `check` — success: commit messages, lint, test (three build configurations), frontend typecheck, CSS literals, the component suite, bindings and the skill check. - `e2e` — success, and **both engines ran**: `E2E — chromium` and `E2E — webkit` are each `completed/success`. Worth stating rather than implying, since a chromium failure used to skip the WebKit step silently and this is the only tier that sees the renderer the app ships with.
logan added 1 commit 2026-08-25 17:55:12 +00:00
feat(android): the phone's "More" is a bottom sheet
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m47s
CI / e2e (pull_request) Successful in 10m14s
52d095e3c6
The tab bar's fifth item opened `<app-sidebar>` in a `wa-drawer`
sliding in from the side, which is a desktop shape put on a phone: a
200px column of a 424px screen, opening away from the thumb that asked
for it, with the rest of its 400px band empty. It also had three nested
scrollers in it -- the dialog, its body, and the sidebar's own
`overflow-y: auto` host -- so which box a drag moved depended on where
the finger landed, which is the "only part of the screen scrolls under
my finger" in the report.

It is the same element with `placement="bottom"` and `without-header`,
so the surface is the sheet #60 already built rather than a second
pattern: a `wa-drawer` is a native `<dialog>` opened with `showModal()`,
which is exactly the top layer that finding rests on, so the focus
trap, Escape, tap-outside and `wa-after-hide` come along unchanged and
nothing new has to be proved about paint containment.

The sidebar is still mounted rather than re-listed as data, because the
shell's own copy is `display: none` below 600px rather than removed --
a second list drawing `nav-*` handles is the duplicate-testid failure
this component already renders conditionally to avoid. What `expanded`
means had to grow to say the host owns the *box*: `app-sidebar` writes
an inline width and caps itself at 400px, which beats any rule the host
could write, so the width, the scrolling and the mouse-only resize
handle now follow that attribute. The rows are 48px below 600px, stated
in the sidebar's own stylesheet since that is the only place it renders
there.

Measured in the running app at 424x439: the sheet is 424 wide, 373 tall
(85vh, so there is an outside to tap), rows 48px, one scroller with
`overscroll-behavior: contain`, and Settings' row reachable at the end
of it. Desktop and Compact are untouched.

Closes #71
logan force-pushed feat/71-more-as-a-bottom-sheet from 92665cc872 to 52d095e3c6 2026-08-25 17:55:12 +00:00 Compare
logan merged commit e23e6f9a54 into main 2026-08-25 17:55:31 +00:00
Sign in to join this conversation.