Skip to content

Give phones a documents sheet and action sheets - #478

Merged
MaggieAppleton merged 5 commits into
mainfrom
mobile/documents-sheet
Oct 11, 2026
Merged

MaggieAppleton merged 5 commits into
mainfrom
mobile/documents-sheet

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On a phone, documents lived in a 280px left drawer built for a mouse: 12px rows, a ⌘K search palette, a "Keyboard shortcuts" item, desktop ··· popovers, no recents, Archived replacing the whole tree, and a drawer that ignored the top safe area (M-07, M-08, M-10, M-12). The page under it wasn't marked inert (M-11). The document ⋯ menu was a small desktop popover (M-13).

Was stacked on #475, which has merged; this is now based on main.

What changed

  • Documents sheet (documents-sheet.tsx). On a phone, the top bar's button (now a documents icon labelled "Documents") opens a bottom sheet at a ~92% detent instead of the drawer. It's built on Base UI's Drawer, the same primitive as the comment sheet, which handles drag and flick to dismiss, Escape, focus trapping and scroll-vs-swipe. It has:

    • a project switcher row (owner/repo ⌄). Tapping it lists your projects with a checkmark and an "Add project" row.
    • a 16px search field that filters the tree inline. It replaces the ⌘K palette on phones.
    • Recent: the documents you most recently opened in that project, not counting the one on screen. They're recorded per user in localStorage (wrapped in try/catch) when a document workspace opens, and the section only shows with at least two entries. The cards show titles only: they're ordered by when you opened them, so an edit time would contradict the order. It's hidden on landscape phones.
    • the tree, with 44px rows at --text-base, research children indented, decision counts, and a trailing 44px ··· on each row.
    • Archived as a row that slides in its own page from the right with a back chevron. The tree stays underneath.
    • the account row, which opens an action sheet with Manage repository access and Sign out. There's no Keyboard shortcuts item on phones.
    • a pinned primary New document button, padded for the safe area.

    Picking a document closes the sheet and navigates. The page beneath is inert while the sheet is open, the scrim fades with drag progress, and the sheet's height respects the top safe area. Under reduced motion it fades instead of sliding. The data comes from the same catalogue as the sidebar (useProjectDocuments, documentGroups). The pure helpers (documentGroups, filterDocumentGroups, readRecentIds, rememberOpened, recentDocuments) live in documents-sheet-model.ts, so they stay out of the initial bundle, and they have unit tests.

  • Action sheet (action-sheet.tsx). A small reusable iOS-style sheet: an opaque grouped list, 56px rows, the document title as its header, danger ink only for Delete (Archive has Undo, so it uses normal ink, as on desktop), a separate Cancel, safe-area padding, and a spring-like rise. On phones, DocumentActionsMenu renders the same documentMenuItems() list into it instead of the popover, so there are no duplicate item definitions. Copy link starts navigator.clipboard.writeText synchronously inside the tap. copy-link.ts now only reports the result, so iOS Safari keeps the gesture even when the module isn't cached yet. Other actions run once the sheet has gone, so Rename's inline field keeps focus. When a row's action sheet opens over the documents sheet, the documents sheet dims.

  • Rename (M-13). Tapping the top-bar title does nothing. Rename lives in the action sheet and edits inline in the top bar at 16px. The title is forced visible while renaming (:has(input) overrides Give phones a bottom tab bar and a collapsing top bar #475's scroll-hidden title). The e2e asserts its computed opacity.

  • No dead first tap. On phones the top bar prefetches the documents-sheet and action-sheet chunks when idle and on pointerdown of Documents. Row and top-bar ⋯ buttons prefetch the action sheet and copy-link on pointerdown. lazy() suspends a component's first render even when its module is already loaded, and React's Suspense throttle then holds the reveal. To avoid that, the shell and the document menu keep the module the prefetch resolves and render the sheet directly. The component is chosen once per open, so a load that finishes late never remounts an open sheet; lazy() is only the fallback for a tap before the prefetch lands. Measured from tap to sheet in the DOM (headless iPhone 15 Pro, localhost, 3 runs): documents sheet 313ms → 25–29ms, action sheet 306ms → 10ms.

  • Polish.

    • a new DocumentsIcon (a page with another behind it) for the trigger, while the project row keeps the book glyph
    • the close button is a filled grey circle with a 44px target
    • triggers no longer stay filled from sticky touch :hover
    • both sheets cap at 35rem, so landscape sheets are centred
    • the bottom safe area is padded even without the New document footer
  • No header jump (M-09). The trigger stays in the bar while the sheet is open, so the bar's box doesn't move (asserted in the e2e).

  • Trigger focus fix. The phone bar and the shell shared one RefObject for the sidebar trigger, so when the shell's own trigger unmounted it nulled the ref the bar had just set. Both now share a guarded callback ref, so focus returns to the Documents button after a dismissal.

  • New tokens: --color-scrim, --radius-sheet, --sheet-large-detent, --sheet-gap, --sheet-max-width, --action-sheet-row-height, --sheet-rise-dur, --sheet-fall-dur, --sheet-page-dur. @base-ui/react is now a direct dependency of apps/web, at the same version as the editor's. All phone-only code is lazy. The shell only passes the props object it already shares with the Projects sidebar, and the drawer and sheet share one lazy boundary. Initial JS is 255,063 B raw against a 256,000 B budget: +677 B over Give phones a bottom tab bar and a collapsing top bar #475 (254,386), leaving 937 B of headroom.

Desktop and tablets are unchanged. They still get the inline sidebar or the drawer and popover menus.

Screenshots

BEFORE is main on 8811 (critique shots). AFTER is this branch, emulating an iPhone 15 Pro.

Drawer → documents sheet
Documents sheet

⌘K palette → inline search
Search

Archived replaced the tree → Archived page inside the sheet
Archived

Top-bar ⋯ popover → action sheet
Action sheet

Row ··· popover → action sheet over the dimmed documents sheet
Row actions

Rename: inline in the top bar at 16px, opened from the action sheet
Rename

Project switcher
Projects

iPhone SE 375×667, and landscape 844×390 (Recent hidden)
SE
Landscape

Desktop 1440×900 still uses the sidebar and popover
Desktop

Testing

  • bun test apps/web: 634 pass, 0 fail. New documents-sheet-model.test.ts covers filtering, recently opened storage (per user and project, broken storage) and recents.
  • bun run types: pass. bun run ci: dprint, oxlint, tokens and the design-policy rules pass. The design contract fails only on the hash pins listed below. check-design-record and design:check pass.
  • New e2e/documents-sheet.e2e.ts:
    • open/close with the button, Escape (focus returns) and drag-down
    • inert page and no header jump
    • 44px rows and a 16px search field
    • search filtering and navigating to a document
    • Recent showing documents you opened, excluding the current one
    • the Archived page and back (inert and focus)
    • action sheet items (56px), Cancel, Copy link → "Link copied", tapping the title doesn't rename, Rename → a 16px inline field whose title has opacity 1
    • Archive from a row → "Archived …" with Undo
    • desktop keeps the sidebar and menu, and a 900px window keeps the Projects drawer
  • Deliberately updated phone-workspace.e2e.ts and responsive-workspace.e2e.ts, because the phone trigger is now "Documents" and opens the sheet. The two drawer-exit tests now run without touch at 390px, so drawer coverage is kept.
  • Local e2e wasn't run: another session's servers hold 8788, 8789, 8792 and 8797. I checked the same flows by hand with headless Playwright against my own server (8828). I'm relying on CI for the suite.

Needs a real device: drag/flick feel and rubber-band; scroll vs. swipe inside the list; the iOS keyboard with the search field; safe areas on a notched phone in both orientations; Copy link's clipboard gesture in Safari.

Design-contract review

Reviewed and renewed in Renew design-contract review hashes for project-sidebar.tsx, document-actions-menu.tsx and navigation-shell.tsx (dynamic-web.json):

  • apps/web/src/project-sidebar.tsx: documentGroups moved to the documents sheet model. The reviewed class and style sites are unchanged.
  • apps/web/src/document-actions-menu.tsx: phones open a lazily loaded action sheet instead of the menu. No new class or style expressions.
  • apps/web/src/navigation-shell.tsx: phones render the documents sheet in place of the drawer, which keeps the same presence motion.

Rebase notes

#475 changed phone-header.tsx on its way in: the root's title and actions stay while a child is open, and the child's "Return to" button is gone, because the stack bar handles the way back. This PR's Documents button and DocumentsIcon sit on top of that.

🤖 Generated with Claude Code

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Changes needed

Checked the diff, built it, and drove it with headless Playwright on 8837 (iPhone 15 Pro, iPhone SE, 15 Pro landscape, 1440, 1280 and 1024 touch). It's a solid slice overall. Base UI drag, Escape and backdrop dismissal, the inert page, the Archived push, the account sheet, search, focus return to Documents, no header jump, and desktop/tablet are all unchanged and work. Two things need fixing first.

Must fix

  1. Rename from the action sheet opens an invisible field. phone-workspace.css:113 sets .workspace-root[data-workspace-title="hidden"] .phone-top-bar-title { opacity: 0 }. At the top of a document, before you scroll, the title is hidden. So when you tap ⋯ → Rename, the field is focused at 16px and the keyboard comes up, but the field sits inside an opacity: 0 parent and you can't see it. I checked this from the top bar and from a row's ⋯ on another document; a probe of the ancestor chain gives phone-top-bar-title op=0. The e2e only asserts focus and font size, and Playwright counts opacity: 0 as visible, so the test passes anyway. Fix: force the title visible while renaming (for example .phone-top-bar-title:has(input) overrides the hidden rule, or have the header set a renaming flag). Add an e2e assertion on the computed opacity of .phone-top-bar-title.
  2. "Recent" repeats the first rows of the list right below it. The catalogue is already ordered by updated_at DESC (see the channels_repository_active_listing index), so recentDocuments() (documents-sheet-model.ts:42) picks the same three documents that open the Documents list. Raising the cutoff to >4 doesn't change that. With 5–7 documents, half the sheet shows the same items twice, and the current document appears in both places. Options: base Recent on what this user opened (there's already last-document state), exclude the current document, and/or sort the phone tree by title so the two sections differ. If none of those fits, drop Recent from this slice.

Should fix

  1. Bundle. I measured initial JS at base Give phones a bottom tab bar and a collapsing top bar #475 = 254,386 B raw / 80,171 gz, and here = 255,359 / 80,449: +973 B raw, leaving 641 B of headroom under the 256,000 limit. @base-ui/react is not in the initial chunk. It ships in the already-lazy editor chunk (decision-view-control-*.js), and documents-sheet (8.7 KB) and action-sheet (1.3 KB) are lazy and phone-only, which is right. But the next small phone PR will hit the limit. Two ways to get bytes back:
    • Move the always-loaded phone-only code into the lazy chunks. documentSheetItems() could be built inside action-sheet, or a small phone-menu wrapper could own the mapping. The ~25-prop <DocumentsSheet …/> call in navigation-shell.tsx:1008 could move into a lazily loaded wrapper too.
    • Or raise INITIAL_JAVASCRIPT_BUDGET deliberately, with a comment, instead of shipping at 99.75%.
  2. The first open is a dead tap. navigation-shell goes inert on the same frame as the tap, but the sheet only renders after documents-sheet.js loads, then action-sheet.js, its CSS and documents-sheet-model.js one after another. On localhost that was ~310 ms before the first frame; on cellular it could be a second or more of a dimmed-looking, unresponsive page. Prefetch the chunk on phones once idle, or on pointerdown on the Documents button. Also consider setting inert only once the sheet has mounted.
  3. Copy link may still lose the gesture on iOS Safari. ActionSheet correctly calls onSelect inside the tap, but documentAction then runs import("./copy-link").then(… navigator.clipboard.writeText) (navigation-shell.tsx:652). If that chunk isn't cached, the network round trip can expire Safari's transient activation. Preload copy-link when the action sheet opens, or write via ClipboardItem with a promise. Chromium e2e won't catch this.

Nice to have / design polish (judged as a demanding iOS designer)

  • The action-sheet group is --color-glass (72% page) over the scrim, and over the nested documents scrim as well. It reads muddy grey next to the opaque white Cancel button (compare jig/shots/11-action-sheet.png, where the group is near-white). Make the group close to opaque.
  • Archive in danger red is phone-only and goes against iOS, which keeps destructive red for actions you can't undo; Archive has Undo. Keep red for Delete only so phone and desktop agree.
  • The close X has no tinted circle, while the prototype and iOS sheets use a filled gray 30pt circle. The project row reuses DocumentIcon, the same glyph as the Documents button; the prototype used a book/repo glyph.
  • The Documents and ⋯ triggers keep a grey filled circle while their sheet is up (aria-expanded styling). iOS doesn't do this, and it shows through the scrim.
  • In landscape (852 wide) the documents sheet spans the full width while the action sheet caps at --phone-tab-row-max. Cap both.
  • When a project can't be managed (no New document footer), the documents page's scroll padding leaves out env(safe-area-inset-bottom) (only the archived page adds it). The account row can end up under the home indicator.
  • Unification later: Dock phone selection actions and give the comment sheet detents #474's comment sheet has its own backdrop opacity (--backdrop-opacity: .24/.36), grabber, swipe-progress maths and timings. This PR adds --color-scrim, .phone-sheet-grabber and --sheet-*-dur. Same primitive, two dialects. When both land, fold them into shared tokens and a small sheet shell. Because of the package boundary, that would live in packages/editor or visuals.
  • project-sidebar.tsx:31 re-exports documentGroups only for research-sidebar.test.ts. Import from documents-sheet-model in the test instead (no compatibility re-exports), and the export also sits awkwardly between import lines.

Verified OK

The trigger focus-ref fix works: Escape and dismissal return focus to Documents (React 19 callback-ref cleanup is fine). Escape, backdrop tap and drag all dismiss. The search field is 16px, and filtering and the empty state work. Archived pushes with focus moving to Back, and Back returns focus to the Archived row. The account sheet opens Manage access on the tap, so no popup block. A row's ⋯ dims the documents sheet. Add project closes the sheet and the dialog isn't inert. Row Rename navigates and focuses (once #1 is fixed). Reduced motion swaps to fades. 1440 and 1280 keep the sidebar and menu popover; 1024 touch keeps "Show sidebar" with the drawer and popover. documentMenuItems() stays the single source and the desktop popover is unchanged.

@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Looks good

Re-verified head 1afd26b8 (on mobile/bottom-tabs bcbd2b84) with a fresh build, a server on 8837 and headless Playwright (iPhone 15 Pro, SE, 1440, 1024 touch).

Fixed and confirmed

  • Rename: ⋯ → Rename now shows the field. .phone-top-bar-title is at opacity 1, the field is focused at 16px, and the title hides again after Escape. This works from both the top bar and a row.
  • Recent: it now lists this user's opened documents, newest first, excluding the current one, and appears only with two or more. In a fresh browser it is absent. Tapping a card navigates (through documentDestination, so child documents route correctly too). Storage access is wrapped in try/catch.
  • Bundle: initial JS is 254,999 B raw / 80,415 gz, so 1,001 B of headroom. Base UI is still only in the lazy editor chunk.
  • Copy link: writeText now starts synchronously inside the tap, then the notice module loads. The clipboard gets the URL and "Link copied" shows.
  • Action sheet: the group is opaque (oklch(1 0 0)), which matches the prototype, and Archive is in normal ink.
  • Rest of the PR: the close X now has its filled circle, the Documents glyph is new, the documentGroups re-export is gone, and the sidebar and sheet share the catalogue props.
  • Unchanged and still fine: focus returns to Documents after Escape. 1440 keeps the sidebar and menu popover. 1024 touch keeps Show sidebar → Projects drawer and the popover.

One remaining should-fix (not blocking)

  • First open still waits ~300 ms. The idle prefetch works: documents-sheet and action-sheet JS and CSS are all fetched before the tap. But the first tap still takes ~308 ms to mount (the second takes ~9 ms). The prefetch warms the module, but lazy() in navigation-shell has never been rendered, so it still suspends on first render. React 19 then holds the reveal for its ~300 ms Suspense throttle. Fix: have the prefetch keep the resolved module and render DocumentsSheet directly once it's loaded, falling back to the lazy component only before that. The same applies to the action sheet's loadActionSheet.

Nits

  • Recent cards are ordered by when you opened them, but labelled "Edited …", so they can look out of order ("18 min ago" before "16 min ago"). Say "Opened …" or drop the time.

CI: format, lint, types, tests fails only on the "reviewed dynamic owner changed" design-contract hash pins listed in the PR body. That's acceptable per the brief. e2e and container were still pending when I checked.

@MaggieAppleton
MaggieAppleton force-pushed the mobile/bottom-tabs branch 3 times, most recently from 1bae625 to a96ebe6 Compare October 11, 2026 10:55
Base automatically changed from mobile/bottom-tabs to main October 11, 2026 11:09
MaggieAppleton and others added 5 commits October 11, 2026 12:33
On a phone the top bar's Documents button opens a bottom sheet instead of the
left Projects drawer: a project switcher, an inline search, recents derived
from edit times, the document tree with 44px rows, Archived as a page pushed
inside the sheet, the account row, and a pinned New document button. Base UI's
drawer owns dragging, focus and dismissal, and the page beneath is inert.

Document menus, in the top bar and on each row, render the same item list as
an iOS-style action sheet on phones. Desktop and tablets are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renaming forces the phone top bar's title visible. Recent lists the documents
this person opened in the project, kept per user in local storage, without the
one on screen, and only from two. The documents sheet is prefetched on phones,
copying starts its clipboard write inside the tap, and the sidebar and sheet
share one catalogue props object to recover initial-bundle bytes. Archive uses
normal ink; action sheet groups are opaque; sheets cap their width in landscape.

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

lazy() suspends a component's first render even when its module is already
loaded, and React holds the reveal, so the first tap took about 300ms. The
shell and document menus keep the module their prefetch resolves and render the
sheet directly, choosing the component once per open so a late load never
remounts it. Phones prefetch the action sheet on idle too. Recent is ordered by
when you opened a document, so its cards no longer claim an edit time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-actions-menu.tsx and navigation-shell.tsx

- apps/web/src/project-sidebar.tsx: documentGroups moved to the documents
  sheet model; the reviewed class and style sites are unchanged.
- apps/web/src/document-actions-menu.tsx: phones open a lazily loaded
  action sheet instead of the menu; no new class or style expressions.
- apps/web/src/navigation-shell.tsx: phones render the documents sheet in
  place of the drawer; the drawer keeps the same presence motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The phone top bar's rename field takes the base text step, so the touch
  field floor (now in the base layer, from #470) reaches it and iOS does
  not zoom on focus; the bar's inherited small step had overridden it.
- pwa: phones now reach Documents from the top bar rather than a floating
  sidebar button, so the safe-area check follows that button.
- document-activity: the Document tab's name no longer matches the top
  bar's Documents button.
- documents-sheet: wait for the outgoing document's bar to leave after a
  route swap, and measure action-sheet rows once the entrance settles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton
MaggieAppleton merged commit d7e02e9 into main Oct 11, 2026
3 checks passed
@MaggieAppleton
MaggieAppleton deleted the mobile/documents-sheet branch October 11, 2026 11:51
MaggieAppleton added a commit that referenced this pull request Oct 11, 2026
- phone-read-mode: phones open document actions as a sheet (#478), so
  Rename is a button in that dialog, not a menu item.
- wide-content: entering editing puts the caret in the first block in view,
  which can already open the code's source; accept either state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaggieAppleton added a commit that referenced this pull request Oct 11, 2026
- phone-read-mode: phones open document actions as a sheet (#478), so
  Rename is a button in that dialog, not a menu item.
- wide-content: entering editing puts the caret in the first block in view,
  which can already open the code's source; accept either state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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