Skip to content

Give phones a bottom tab bar and a collapsing top bar - #475

Merged
MaggieAppleton merged 10 commits into
mainfrom
mobile/bottom-tabs
Oct 11, 2026
Merged

MaggieAppleton merged 10 commits into
mainfrom
mobile/bottom-tabs

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On a phone the workspace was desktop-shaped: top segmented tabs out of the thumb zone with Build as a 20px link (M-15, M-16), ~113px of chrome plus card margins that never collapse (M-06), a separately fixed 28px sidebar trigger that ignored the top safe area (M-04, M-07), a directionless crossfade between views (M-17), and landscape phones (734×343) fell into the desktop split with 24px tabs and a 4px resize handle (M-40).

What changed

  • Phone class: isPhone() / usePhone() in @chopin/editor/pointer = primary coarse pointer AND (width ≤ 600 OR height ≤ 500), mirrored as :root[data-phone] and .workspace-root[data-workspace-phone]. A phone always gets the compact workspace, so landscape stays compact. Desktop, touch laptops and tablets keep their layouts.
  • Bottom tab bar (phone-workspace.tsx): one floating glass bar in the thumb zone with Chat · Document · Decisions · Build (icons + small labels, Decisions count badge, Chat unread/working dot, Document activity dot), 52px tabs in a 60px bar, safe-area aware, sliding active indicator, :active press feedback. Tapping the current tab scrolls that pane to the top (Chat: to the newest message). Same nav "Workspace view" + aria-current semantics as the old compact tabs. Build disabled state keeps the existing aria-describedby reason. The bar steps aside while the software keyboard is up (new :root[data-keyboard-open] from the existing visualViewport adapter). Landscape centres it at a 30rem max width.
  • Extension point for slice 3: Workspace takes phoneAction?: ReactNode, rendered after the bar; style it with .phone-tab-action (60px glass circle, already defined). Verified at 375 and 320 with a placeholder: every tab stays ≥ 44px (58×52 at 320).
  • Top bar (phone-header.tsx): 44px translucent bar with backdrop blur and safe-area-inset-top; the existing sidebar trigger now lives in the bar (the shell's floating trigger steps aside via a small SidebarTrigger claim context), a small centred title that fades in only once the document's H1 has scrolled under the bar (always shown on Chat, Decisions and Build), and the existing document actions menu behind ⋯. It hides entirely on scroll down (a glass strip stays under the status bar) and returns on scroll up or when focus moves into it (transform only, 250ms ease-out; never hides under reduced motion; rubber-banding is ignored), listening to the active pane's scroller (Document, Decisions, Build; Chat keeps its bar). On a child document the parent's bar keeps both the sidebar trigger and the way back.
  • Landscape (height ≤ 500): 36px top bar and a 48px one-line tab bar (icon beside label), every tab still 44px tall; top + bottom chrome is 84px.
  • Full-bleed panes: no card margins or rounding on phones, content scrolls under both bars. Gutters stay at the existing 24px compact gutter: at 20px the margin comment markers overlapped prose (caught by responsive-comments.e2e.ts). DESIGN.md Layout updated.
  • Transitions: destinations slide fully sideways by tab order (380ms, --tab-slide-ease: cubic-bezier(.32,.72,0,1)), crossfade under reduced motion. Scroll positions are kept per pane.
  • New tokens in packages/visuals/theme.css: --color-glass, --glass-filter, --phone-bar-height, --phone-tab-bar-height, --phone-tab-bar-gap, --phone-tab-row-max, --tab-slide-dur, --tab-slide-ease, --phone-bar-dur.

Not in this PR: M-18 skeleton (not trivial here; skipped). M-02 window scrolling (#root stays fixed; follow-up). Overlaps with open #465 (workspace.tsx layout); phone changes are kept behind the phone condition and in new files to keep the rebase small.

Screenshots

BEFORE (main, 8811) left, AFTER right, iPhone 15 Pro emulation.

Document
Document before and after

Chat
Chat before and after

Decisions
Decisions before and after

Build
Build before and after

Landscape: before at 734×343 (desktop split), after at 852×393 with simulated side insets (compact phone chrome)
Landscape before and after

Top bar with simulated iPhone insets (59px top, 34px bottom): at rest with no small title (left), hidden while reading down with the glass strip under the status bar (middle), back on scroll up with the title faded in (right)
Top bar rest, hidden and shown

320px with a placeholder action button in the slice-3 slot
320px with action slot

Desktop 1440×900 is unchanged (ImageMagick AE 0 at 2% fuzz; 5 anti-aliased pixels differ unfuzzed).

Testing

  • bun test apps/web packages/editor: 1313 pass, 0 fail (new unit tests for isPhone and keyboardOpen).
  • bun run types: pass. bun run ci: dprint, oxlint, tokens, design record pass; design contract fails only on the reviewed-hash pins below.
  • New e2e/phone-workspace.e2e.ts: full-bleed chrome and in-bar controls, tab navigation (Chat/Document/Decisions/Build, scroll kept, reselect scrolls to top), landscape stays compact, top bar hides/returns, keyboard hides the tab bar (visual-viewport fake), desktop and touch tablet unaffected. e2e/responsive-workspace.e2e.ts updated deliberately for phone chrome (full-bleed frame, tab bar hidden under a keyboard-sized viewport).
  • CI browser integration (e2e) passes on the latest commit (run 38121945859). Local e2e could not run because another worktree held the e2e database ports. Existing phone-width touch tests were updated where they asserted old chrome: tab bar placement and keyboard hiding (responsive-workspace), the comment backdrop covering the bar (responsive-comments), a slash-menu start position that no longer flips while it follows scroll (toolbar), and the compact research-child test now uses a fine primary pointer so it keeps covering the non-phone compact chrome (research-child-surface).

Needs a real device: safe-area insets, backdrop blur cost while scrolling, the keyboard-open threshold (120px) on iOS Safari and Android Chrome, and scroll preservation across tab switches on WebKit.

Design-contract review

Reviewed and renewed in Renew design-contract review hashes for navigation-shell.tsx, viewport.ts and workspace.tsx (dynamic-web.json):

  • apps/web/src/navigation-shell.tsx: the sidebar trigger claim context wraps the shell. The sidebar motion classes and width style are unchanged, only re-indented.
  • apps/web/src/viewport.ts: adds a boolean data-keyboard-open attribute. The --app-* property writes are unchanged.
  • apps/web/src/workspace.tsx: the phone branch chooses between literal frame and motion classes and uses the tab-slide motion contract.

Rebase onto the paper layout (#465)

🤖 Generated with Claude Code

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Changes needed

The foundation is solid. The phone class is right: 1440, 1280, a 1024 touch tablet and a 1180×820 touch iPad all keep their split layouts, while iPhone 15 Pro, SE and landscape get the phone layout. Rotating the device in place keeps the phone layout and the active tab. Scroll position survives tab switches (Document at 700px and Decisions at 150px both came back intact). Tapping the current tab returns to the top. The tab bar has the right semantics: nav + aria-current, label-in-name such as "Decisions, 2 unanswered", and a visible 2px focus ring. Tab and Enter work. The indicator glide and the 380ms cubic-bezier(.32,.72,0,1) slide feel quick, with most of the travel in the first ~150ms. Reduced motion crossfades. The CI failure is hash-only, as listed. I tested on headless Chromium against 8811 (BEFORE).

Two must-fix issues, both in the collapsing top bar.

Must fix

  1. The hidden top bar slides its contents under the status bar (phone-workspace.css:568). The bar is 44px + safe-area-inset-top tall, with the controls in the bottom 44px. translateY(-var(--phone-bar-height)) moves that row up into the safe-area band, so the title, ⋯ and sidebar button end up drawn behind the clock and Dynamic Island. I simulated a 59px top inset: when hidden, the bar's row sits at y≈15–59 over the status bar. This shows in standalone/PWA (Make Chopin installable and fix the phone platform baseline #470) and anywhere viewport-fit=cover gives a top inset. Fix: translate by the full --phone-top-chrome and paint the status-bar ground with a separate band (a ::before fixed at height: env(safe-area-inset-top) with the glass). Or fade the row out (opacity: 0) as it lifts.
  2. Keyboard or VoiceOver focus doesn't reveal the hidden bar (phone-workspace.tsx:940-966). While it is hidden, focusing "Show sidebar" leaves the button at y: -44. It is focused and announced but off-screen. Add a focusin listener on the bar (or the root, checking .phone-top-bar) that calls setHidden(false). Also add an e2e step to the "top bar hides" test: hide the bar, focus "Show sidebar", and expect topOffset === 0.

Should fix (this PR or the next slice)

  1. Child documents get the floating trigger over the bar (phone-header.tsx:420). hostsTrigger is only true for type === "document". For type === "child" (research children), the left slot stays empty, the shell's floating ProjectSidebarExpandButton comes back over the top bar, and it doesn't hide with the bar. I found this by reading the code, not in the browser. Claim the trigger for child too, or give the child a back control like parent-with-child has.
  2. Landscape chrome is heavy. At 734×343, the 44px top bar plus the 60px tab bar and its offset take about 115px, roughly a third of the height. iOS shortens its tab bar in compact height, with icons and labels inline at about 44px. Add a @media (max-height: 500px) variant that drops the stacked label layout and the bar to about 44px.
  3. The top-bar title duplicates the document H1 while at rest. The spec says the title is "small, appears after scroll", and variant B shows no title at rest. Fade the title in once the H1 leaves, for example with y > TOP_BAR_REVEAL or an IntersectionObserver on the first heading.

Nice to have

  • Rubber-band at the bottom (phone-workspace.tsx:955): clamp y to [0, scrollHeight - clientHeight] before comparing. Otherwise an iOS bounce at the end of a document can read as "scroll up" and pop the bar back. Chromium can't reproduce the bounce; check on a device.
  • Chat moves differently from the other tabs. Chat slides over a static Document (.workspace-chat-panel.motion-tab-slide is absolute and overlays), while Document, Decisions and Build push each other. Either push the document pane out too, or accept it.
  • Re-tapping Chat jumps to the oldest message. For a transcript, re-tapping should probably go to the latest message.
  • Glass. At 84% --color-glass, the blur barely shows, yet both bars pay for blur(20px) saturate(180%) over a sliding, will-change pane. Either lower the opacity to look like variant B, or drop the saturate. Check the cost on a real device during slides.
  • Shortcut tooltip on phone. "Show sidebar ⌘" appears on phone whenever focus returns to the trigger, for example after the drawer closes. Suppress shortcut tooltips under :root[data-phone].
  • Raw values. phone-workspace.css has raw values: the 2px dot border, -0.02em letter-spacing, and a tab :active scale of 0.94 against 0.96 on the action and in the spec. Use tokens and one press scale.
  • aria-label on a generic div (phone-header.tsx:457) is ignored by assistive tech. Drop it, or give the title a role or heading.
  • Sidebar trigger glyph. It is still the desktop panel glyph in tertiary grey. It is fine as an interim, since slice 6 replaces it with the Documents sheet.

🤖 Generated with Claude Code

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Looks good

I re-checked on bcbd2b84 (port 8834, headless Chromium, with a 59px top safe-area inset simulated). Every earlier finding is fixed:

  • Status bar: the hidden bar now moves its full 103px off-screen (top −103, bottom 0). The glass strip stays under the status bar, and no controls sit behind the clock.
  • Focus reveal: focusing "Show sidebar" while the bar is hidden brings it back to top 0.
  • Child pages: the bar takes over the sidebar trigger. parent-with-child shows the trigger and the back arrow side by side.
  • Landscape: the bars are 36px (top) and 48px (tabs), with labels inline and every tab still 44px tall.
  • Title: it stays hidden at rest on Document and fades in once the H1 passes under the bar. Chat and Decisions always show it.
  • Rubber-band: scroll positions are clamped, and the first scroll event now has a direction.
  • Re-tapping Chat goes to the newest message.
  • Press feedback uses the shared data-press="small".
  • Glass is at 72% and reads more like variant B.
  • No shortcut tooltip on phone.
  • The ignored aria-label is removed.
  • Keeping the 24px gutter for the comment-marker lane is reasonable.
  • Desktop at 1440 has no data-phone and no ::before strip.

CI: e2e and container pass. The validation job fails only on the reviewed design-contract hashes listed in the PR body.

Nice to have, none blocking:

  • Heading observer cost. usePhoneTopBar observes the whole plan subtree (MutationObserver, subtree: true), and measure() forces layout with getBoundingClientRect on every collaborative edit. Throttling it to one requestAnimationFrame would keep typing cheap.
  • Duplicate tab state. The tabs now carry both aria-pressed and aria-current="page", so VoiceOver announces "toggle button, selected" on a navigation tab. This matches the old compact nav, but aria-current alone would be cleaner.
  • Landscape badge. The Decisions count sits on the icon's leading corner and nearly touches the active Document pill. Try nudging it to the trailing corner of the icon.
  • Doubled glass band. While the bar is shown, the status-bar strip draws a second glass layer over the bar's own safe-area band. The cost is tiny, but the strip could show only when data-workspace-bar="hidden".

🤖 Generated with Claude Code

MaggieAppleton and others added 10 commits October 11, 2026 11:54
Phone class (primary coarse pointer, width <= 600 or height <= 500) now keeps
the compact layout in landscape and replaces the top segmented tabs with one
floating bottom bar: Chat, Document, Decisions, Build. Panes run full-bleed
under a translucent 44px top bar that holds the sidebar trigger, title and
document actions, and tucks away while reading down. Destinations slide by
tab order, crossfade under reduced motion, and the bottom bar steps aside for
the software keyboard. Desktop and tablets are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hidden top bar now leaves the screen entirely, with a glass strip kept
under the status bar, and comes back when focus moves into it. Its small
title fades in only once the document's heading has scrolled under it.
Landscape phones get a shorter top bar and a one-line tab bar that keep
44px targets. Child documents keep the sidebar trigger in the parent's bar,
tapping Chat again returns to the newest message, rubber-banding no longer
toggles the bar, and tabs use the shared press scale. Phones keep the 24px
document gutter so margin comment markers keep their lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measure the top bar title at most once a frame, mark the current tab with
aria-current alone, keep the landscape Decisions count beside its label, and
show the status-bar glass strip only while the top bar is tucked away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t.ts and workspace.tsx

- apps/web/src/navigation-shell.tsx: wraps the shell in the sidebar trigger
  claim context; the sidebar motion classes and width style are unchanged,
  only re-indented.
- apps/web/src/viewport.ts: adds a boolean data-keyboard-open attribute; the
  --app-* property writes are unchanged.
- apps/web/src/workspace.tsx: the phone branch chooses between literal
  frame and motion classes and the tab-slide motion contract; the paper
  layout's expand control and other reviewed sites are main's, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- chat-phone: the phone top bar carries no presence, so the picker's own
  wait for cy replaces the presence-group check.
- comment-sheet: the selection bar docks just above the tab bar (it already
  reads --phone-bottom-chrome), so measure against the tab bar, and seat
  the low passage where the bar now docks.
- decisions-phone: phone tabs mark the current view with aria-current.
- sidecar: poll the reply frame until it has grown around Send, instead of
  measuring once mid-growth (flaky in CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is open

Nested documents now open as cards on the root sheet, with the stack bar
carrying the way back, so the phone top bar no longer swaps in the child's
title and a Return button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nested documents put a stack bar with the way back at the top of the
document panel; on phones that is under the translucent top bar, and the
child card covered it. Place it below the top bar, above the card, and let
it follow the bar when it tucks away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tucked stack bar now leaves the screen and hides, so nothing shows
through the glass top bar, and the child card starts at the top edge like
other phone panes, with its document inset below both bars, so no second
band shows between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton
MaggieAppleton merged commit 4e4a2b9 into main Oct 11, 2026
3 checks passed
@MaggieAppleton
MaggieAppleton deleted the mobile/bottom-tabs branch October 11, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant