Skip to content

feat(mobile): the phone shell - #6767

Merged
tsahimatsliah merged 119 commits into
mainfrom
claude/mobile-shell-1
Oct 7, 2026
Merged

tsahimatsliah merged 119 commits into
mainfrom
claude/mobile-shell-1

Conversation

@tsahimatsliah

@tsahimatsliah tsahimatsliah commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Under 656px the webapp gets the phone shell: a floating bottom bar and one top block, in place of the docked footer and the header each page drew for itself. Tablet, laptop, layout v2 and the extension keep what they run today.

The parts that did not depend on the shell were reviewed and merged on their own (#6765, #6775, #6776, #6777), so this diff is the shell alone.

Changes

What a phone reader gets

  • Bottom bar. Home, Explore, Squads, Activity and a Create square. It shrinks as you read down and returns to full size on a short scroll up. The lit tab is the root that owns the page (Squads lights on a squad page).
  • Top block. One fixed block on every page. On the four roots it is their row (Home's feeds, Explore's search and places, Squads' categories, Activity's types). On every other page it is back, the title and the page's actions. It slides away as you read down and returns with the bar.
  • You. /you, behind the avatar: profile, Plus, feed settings, bookmarks, history, analytics, game center, DevCard and settings in one list. The gear leaves the Home row.
  • Sheets. Drawers follow the finger, have a grabber and one title row. A DropdownMenu opens as a bottom sheet on a phone.
  • Sign-up. The gated sign-up closes with an X and says why it opened.
  • People are rounded squares. The last circles on people take ProfilePicture's radius.

Where to start reading (packages/shared/src/components/shell/)

  • ShellCluster.tsx: the bottom bar. Publishes --shell-bottom.
  • ShellBlock.tsx: the top block. It is in the server HTML and publishes --shell-top.
  • ShellPageContext.tsx: a page renders <ShellPage title actions row /> to say what the block shows. Most of the modified files are this one small change per page: the page drops its own phone header and registers instead.
  • shellNav.ts: which root owns a route, and where back goes.
  • useShellBlockPlan.ts and constants.ts: the height the layout reserves for the block before it has measured itself, so nothing moves on hydration.
  • shell.css: phone-only styles, including the menu-as-sheet.
  • MainLayoutHeader.tsx and MainLayout.tsx: where the block mounts and the column is padded.

Removed

  • The docked footer nav (MobileFooterNavbar, FooterNavBarTabs, FooterNavBarItem, FooterPlusButton) and its test; its events are asserted in ShellCluster.spec.
  • The old logged-out phone header (MobileAppHeader) and its 19 mounts. The block carries Log in and Open app.
  • SquadHeaderMenu (one squad menu now, SquadOptionsMenu).

Decisions a reviewer should know

  • No feature flag and no kill switch, by product decision. It launches to every phone on merge and the rollback is a revert.
  • The extension does not get the shell. In a narrow new tab it keeps main's logo, streak and profile row.
  • Phones get color-scheme from shell.css, so native controls and the overscroll area follow the theme.
  • /you adds one query, USER_FOLLOW_STATS_QUERY, for the follow counts.

Events

No new event names.

  • A tab tap is Click with TargetId.MobileFooterNav and {tab, logged_in}.
  • Activity keeps ClickNotificationIcon with NotificationTarget.Footer.
  • Create still fires OpenSmartComposer from the composer. Visitors get the sign-up with one new trigger value, AuthTriggers.CreatePost.
  • ClickFeedTagChip sends {origin} without variant on phones, because the block's row has one layout for every arm.

Experiment

None.

Manual Testing

At phone width, logged in and logged out:

  • Scroll a feed down and up: the block slides away and the bar shrinks, and both return together.
  • Open a post, a squad, a tag, a source and a profile: each shows back, its title and its actions in the block, also after scrolling.
  • Tap the avatar: You opens. Tap the lit tab: back to the root, then to the top.
  • Open a post's menu and a drawer: both are sheets that follow the finger.
  • Logged out, move between a tag, a source and Happening now: no second header flashes above the block.
  • At tablet and laptop width, and in the extension: nothing changed.

Verified

  • Lint, the strict typecheck guard and both typechecks are clean; the shared and webapp suites pass (4,480 tests).
  • Phone, tablet and laptop sweep for hydration errors: clean on phone and tablet; laptop search fails hydration as it does on production.
  • The server HTML and the hydrated page put the content at the same offset on the routes swept, as a visitor and as a member.
  • Checked on a real iPhone inside the iOS app on 3 Oct. Not re-run on a device since the later review rounds.
Review history and plan audit

Step 1 of the mobile shell work (Storybook Mobile UX / 10. The build, PRs 1.1 to 1.8 delivered together as agreed): the phone shell under 656px. Tablet, laptop, layout v2 and the extension keep the code they run today.

#6765 (the four fixes) merged to main on 2 Oct and is merged in here, so this diff is the shell alone.

What changed

The bottom cluster (1.1) ShellCluster replaces the docked footer: Home · Explore (compass) · Squads · Activity as a floating bar on the flat blur material, the Create square beside it opening the composer directly, the brand bubble on Activity only, Tint selection (filled glyph, the rest at 72%), the shrink on scroll (56→44px, inset 20→40, radius 22→18 over 96px), re-tap the lit tab to scroll to top, the lit tab from the root that owns the URL (Squads lights on a squad page, Home on a post). Visitors: Explore and Squads open; Home, Activity and Create open the sign-up with a context line. The cluster is in the server HTML (tablet:hidden), so it paints with the page. The Charm footer still replaces it at its trigger; the post action bar sits above it as before. Deleted: MobileFooterNavbar, FooterNavBarTabs, FooterNavBarItem, FooterPlusButton, MobileFeedActions.

The You page (1.2) /you, behind the avatar, with the block's back button; Invite (pill) and Help (icon square) are its actions. Name and handle, Following and Followers (opening the follow lists), Reputation and Cores as two small pills, then one 44px list: Profile, daily.dev Plus (Upgrade / Manage / Renew), Feed settings, Bookmarks, History, Analytics, Game center, DevCard, Settings (opens the settings menu). Settings is on screen without a scroll on a 393×660 viewport. Help opens a sheet with the desktop Support menu: Send feedback (the in-app form), Report a bug, Docs, Changelog, Get the mobile app (phone browsers only), Privacy policy, Terms of service. A slide-aside menu was tried and reverted the same day. The settings gear leaves the Home row.

The top block on roots (1.3) ShellBlock replaces FeedNav's phone branch: Home = logo (Plus mark for members), streak, the Plus square for free members (opens Plus, fires UpgradeSubscription), the 38px square avatar; Explore, Squads and Activity get the name row they never had (Activity with the notification-settings square). Today's rows ride under it (the chip strip on Home, the Spotlight trigger on Explore). The block is a fixed overlay; content takes --shell-top padding; it hides as one solid piece while reading down (64px travel, 96px dead zone, 24/8px tolerances, 300ms stop, 220ms snap on the interaction curve) and returns on any short scroll up, never on arrival, and reveals when a field takes focus. useShellScroll is the one scroll reader for block and cluster.

The top block on pages (1.4) The page row: back (38px square), the title (20px), the page's actions. GoBackHeaderMobile feeds it under 656px (title prop for the profile sub-pages), as do the profile header, the no-sidebar layout, the squad sub-page header and the settings container. Every leaf has a back now; back is history when the previous entry is ours, otherwise the owning root. Inside settings, back is hierarchical as in iOS Settings and X: a section's back opens the sections menu, the menu's back leaves settings as a whole in one history move (to the page the member came from, You when there is none), You's back goes home; feed settings goes one level up the same way, to the menu when it was opened there, otherwise to the feed. A small ordered route stack in sessionStorage (shellNav.ts, mounted once in _app) gives every back its target, since Next no longer numbers history entries. Feed settings on a phone is one header row the width of the page (back, the feed's name or the section's, Save) and its sections are the settings menu's rows. The old sticky bars stay for tablet.

Sign-up, two touches (1.5) On a phone the gated sign-up closes with an X top left (not a chevron) and shows one line under the title keyed by the trigger (Follow, Bookmark, Upvote, Comment, Join squad, Create…). Providers, order, email, terms, flow and analytics untouched.

The sheet (1.6) Drawer: 300ms in on the travel curve, 200ms out, scrim 150ms, a grabber on bottom sheets, drag to dismiss past a third of the height or on a fast flick (up barely moves, back springs in 220ms), inner lists keep scrolling. Every Drawer consumer gets it.

Menus as sheets (1.7) DropdownMenu on a phone is modal and renders its Radix content as a bottom sheet (48px rows, grabber, the sheet's timings) through shell-menu-sheet; it no longer closes on the scroll the collapsing address bar fires. Items, handlers and events are untouched, which keeps PostOptionButton and the other 35 consumers as they are.

People are rounded squares (1.8) Referral slots, embedded tweets, profile metadata and the feedback widget drop their circles; squads and sources keep theirs.

The rows (added on review, from 2.1, 2.2, 2.4, 2.5 and 2.8) ShellRow with the three weights of the 28px chip: Segments (which list, replacing history), Chips (narrow this list), MenuLabel (a sheet). Home's row is For you · Happening now ▾ · Following · your custom feeds · +; the chevron on Happening now opens the channel sheet (Headlines, All, every channel), and the Highlights page shows the channel on its own line instead of its old title and tab strip. Explore's row is the search field and the places (Tags, Sources, Leaderboard, Discussions); its sort is a text menu on the feed line (Popular ▾, with the period inside for the two sorts that have one) instead of the sticky tab strip. Squads root puts its categories in the block as chips; Activity its types; Bookmarks its lists as segments under a Bookmarks title; Tags, Sources, Leaderboard and History get their names in the block and lose their in-page navbars and breadcrumbs. The old strip's other chips are reachable from Explore, You or the bar. Tablet keeps every old row.

The buttons (second review) A page keeps no button row of its own above the content once the block exists. The Squads root carries New Squad as a plus square (the Primary button under the title is gone on phones). Home shows the streak and the quest count beside the Plus square. Happening now loses the channel line and the copy link under the segments; the chevron sheet is the one channel control and marks the channel you are on. Search results are a page: back, the query as the title, a Filters square (the same drawer), the field in the row. The squad page block carries Search and the menu; the hero keeps the link and Join. The profile block carries the menu only; the hero keeps Follow and Award. Bookmarks carries Sort, Share and the folder menu in the block; the search field stays in the content. Explore loses the 40px band the old sticky tabs reserved. ShellPage now portals actions into the block's slot from the page's own tree, so an action keeps its page's providers (the squad menu needs SquadPageContext); the hero copies are gated by viewport in JS so a label exists once in the DOM.

Every page (third review) A route-by-route pass over 110 member routes at 393px. The pages that still drew a header row of their own under the block now register with it: Sources (Suggest new source as a plus square), Jobs (title, How it works, preferences), the Core wallet (title, Buy Cores), the briefings (title, Generate, settings), squad moderation, post analytics (title, Boost), the gear rankings, the best-of archives (title and copy link in the block, breadcrumbs gone on phones), the experience lists (the second back arrow gone), the squad form (title and Create Squad in the block, the back square replaces Cancel), the leaderboard detail and the tag explore page (titles). The composer page hides the block over its own chrome. FormWrapper gained an inBlock mode for page forms. Pages with their own standalone layout (Plus, Cores) are unchanged: they never render the block.

Step 1 complete (fourth review) Everything the plan lists for 1.1 to 1.8 is in this PR now. The post menu on a phone is one sheet with two levels: Share · Read it later · Follow the source · Not interested · Report, then the owner's rows (Delete in the error colour), then More; Not interested gathers hide, block source, block author, block tags and content type, More the rest; the sub-level slides in and its title row carries the back chevron (PostOptionsSheet, grouping by option id in postOptionGroups). Every option keeps its handler and event; desktop keeps the flat menu. One squad menu serves the page and the card (SquadOptionsMenu gained Share and Notifications rows and a styled trigger; SquadHeaderMenu is deleted). Copy link lives inside Share on tag and source pages. Report post, user and comment, Add to custom feed, Move bookmark, Bookmark folder and Bookmarks sharing open as sheets on phones; sheets no longer carry a bottom Close button. The block's last Float buttons are 38px squares. The bar publishes --shell-bottom for the footer spacer.

Plus: shell/constants.ts (the numbers), shell.css (material, hit areas, sheet timings, menu sheet), ShellSquare, ShellPageContext, ShellRow, HomeSegments, ExploreSortMenu.

Fifth review (sheets, glass, back to top, the plan audit) A sheet is a header (grabber, title), a scroller and a foot, so a row pinned inside it reaches the panel's edges: nothing shows through above the grabber or below a form's Cancel/Submit bar once a sheet has grown to the top (the panel's own padding, the status-bar inset on iOS, used to leave a strip). Titles sit at the left in every sheet; list sheets and menu sheets run their rows edge to edge like the settings lists. .shell-material is one recipe for the bottom bar, its squares, the block's squares and pills and the post page's floating bar (which now uses the class), so the Create square no longer reads darker than the bars. ShellTopButton: a square of that material above the Create square, at the bar's inset, on the main feed only, once a screen of feed has gone by. Tab switches no longer jump: the active feed name derives from the route in the same render, the Explore search slot reserves its height, and the block publishes --shell-top in a layout effect. owningRoot asked the /posts/ prefix before the Explore sorts, so /posts/upvoted, /posts/discussed, /posts/latest and /posts/best-of lit Home; it asks isRootView first now. The channel chevron on Happening now is one button rather than a button inside a link. New specs: shellNav.spec.ts, YouPage.spec.tsx. react-flip-toolkit left with the old footer and is removed from the webapp package.

Plan audit (Storybook chapter 10, step 1)

Every technical claim in the build plan for 1.1 to 1.8 was checked against this branch. Legend: Done, Diverged (the build moved on; plan text updated), Open (a decision for review).

Area Plan Code Status
/you new route, visitors away pages/you.tsx renders YouPage, noindex, visitors replace('/') Done
/squads/create "redirects to /squads/new" it is the Create post composer; no redirect needed Diverged
Happening now channels ?channel= /highlights/[channel] path routes, unchanged from production Diverged
Explore sorts one page, 8 paths ExploreSortMenu maps all 8; isRootView(Explore) covers them; owningRoot fixed Done
Owning root extends useActiveNav shellNav.owningRoot (+spec) Diverged
Squads tab href /squads/discover /squads/discover/my when the member has squads Diverged
Visitors on the bar Activity and Create open sign-up Home too (chapter 4 decision) Diverged
No-cluster pages layout without the bar plus hidesCluster for settings and forms (+spec) Done
Tab click event Click, TargetId.MobileFooter TargetId.MobileFooterNav (PR 0's id), extra {tab, logged_in} Done
Activity event ClickNotificationIcon, NotificationTarget.Footer, count as planned Done
Plus square event UpgradeSubscription, mobile header target TargetId.MobileHeader Done
Create event OpenSmartComposer unchanged fired by the composer as before; AuthTriggers.CreatePost for visitors Done
ClickFeedTagChip {variant, origin} {origin} only, the block row has one layout for every arm Diverged
Flags none added; none default true featureManagement.ts untouched; feed_chips not evaluated in the block; mobile_app_footer replaces the cluster Done
Specs named ShellCluster, useShellScroll, ShellBlock, Drawer, DropdownMenu, PostOptionButton, GoBackHeaderMobile, auth line all present; drag has no unit test (covered by the Playwright walk) Done
Specs named, missing YouPage, goBack, MainLayoutHeader block cases, people-circles grep YouPage.spec.tsx, shellNav.spec.ts, sheetDrag.spec.ts, ModalSidebar.spec.tsx added; MainLayoutHeader block cases and the circles grep still not written Diverged
Deleted tests must be named __tests__/MobileFooterNavbar.tsx (events now in ShellCluster.spec), MobileFeedActions.spec.tsx (component deleted, rows live in HomeSegments), SquadHeaderMenu.spec.tsx (one squad menu now, SquadOptionsMenu) Done
Where things live constants.ts, shell.css, useShellScroll + provider, ShellBlock rows, --shell-top, ShellRow as planned; a module store instead of a provider; no useActiveFromRoute Done
Sheet always on the root portal Modal-as-drawer mounts on the root portal always; Drawer sheets wrap RootPortal; sheets follow the finger between rest and full and size to the visual viewport Done
Back history when the previous entry is ours, else the owning root plus a hierarchical back inside settings and feed settings on a per-tab route stack (shellNav.ts, spec) Diverged
Events (re-check, 3 Oct) same name, target, origin all hold; ClickFeedTagChip drops variant on phones on purpose; AuthTriggers.CreatePost is one new trigger value Done
DropdownMenu on phones renderer switch to Drawer Radix kept, restyled as a sheet with the drawer's drag and scrim Diverged
Re-tap the lit tab scroll to top, no refresh leaf to root, scroll to top, refresh at the top (decided on review) Diverged
Block in server HTML cluster and block cluster yes; the block mounts after hydration like the old phone FeedNav Open
No new data no new queries /you adds USER_FOLLOW_STATS_QUERY for the follow counts Open
Wrappers untouched until step 4 lib/ios.ts and safeArea.css untouched; _app status-bar meta changed Done
Accessibility labels, 44px hits, reduced motion present; nested interactive in the segment chevron fixed Done
Sweep script, stories/mobile-shell/ land with 1.1 not built; the QA report's Playwright scripts did the sweep Diverged

Verified

Review rounds (4 Oct): an independent review of the four PRs, then a second reviewer's 8 points here. Fixed in ccd627e and 334e4ab: Home's block held one row then two while settings loaded; an empty h1 in the block on pages with no title; Bookmarks' resting height; the reminder sheet's accessible name; the search filters sheet mounts on the root; one predicate for which root shows its row (useShellBlockPlan reads shellNav.isRootView, and the best-of archive is Explore's page, not one of its views); the block keeps one ResizeObserver; the post menu's off-screen level is inert; the extension keeps its own chrome at every width; color-scheme is set in CSS for phones only instead of a document-wide meta; the menu scrim and the shell's shadows read theme tokens. No runtime kill switch, by product decision: the shell launches to everyone; rollback is a revert of this PR, which is why the three app-wide pieces land separately first.

Sheet title rows (4 Oct): every modal and drawer that opens as a sheet on a phone was opened at 393px and its top row measured. Seven did not use the sheet's title row (20px bold at left 16, the close square at right 8) and do now: Set a reminder (its title showed twice), Squad notifications (28px title, X beside it), the achievement picker, showcase and comparison and Intro quests (heading in the body, no X on a phone; ModalSheetTitle adds the row on a phone only), search Filters (own row with a plain icon button), and the form sheets (report, New Folder) whose title was 17px. Wider screens are unchanged. Hero and celebration sheets, confirmations and action menus keep no title by design.

The composer's Post to sheet (4 Oct): the audience picker's list is capped at 240px and scrolls under its header in the desktop popover; on a phone the capped list stayed a second scroller inside the sheet, so a swipe up grew the sheet instead of scrolling the list and the list was cut mid-row at full height. The cap and the list's own scroll apply from tablet up; on a phone the sheet scrolls as one piece with the sheet's 48px rows and 16px inset. Checked with 14 squads at 393x760 and 375x568 (every squad reachable) and at desktop width (unchanged). A menu sheet's list also kept its own height cap until the sheet was marked expanded, so a pull up showed empty space until release: the sheet owns the height now (up to 70% of the screen at rest) and the list fills it, at rest and under the finger. With 18 squads: 10 rows at rest, 12 and 14 as the finger pulls 100 and 200px, the end reachable by scroll; short menus measure as before. On a phone the picker also takes a sheet's title row: a 20px bold title and Reset as a pill matching the close square (dimmed and inert with nothing to reset); the desktop popover's header is unchanged.

Posts from widgets and lists (4 Oct): Similar posts, a collection's related posts and the squad post list were plain anchors (a full document load on production); a highlight's Read more, the reposts list, the topic sign-up previews and the two creator analytics links were router links to the absolute permalink. All use getPostPath now, with viewport prefetch off on the widgets. Six queries return slug (FurtherReading, RelatedPost, the highlight feed card, creator achievements, creator analytics, the topic sign-up previews) and their hand-written types carry it. New-tab links, share and copy-link values and external article links keep the absolute permalink. On the preview at phone and desktop size a tap on a Similar posts row navigates in-app. The phone ad strip is unchanged by decision: holding its 66px from the server paint would spare logged-out visitors the push but make logged-in members' pages slide up once on a cold load; Tsahi chose to keep members' pages still, so the strip behaves as on production.

The top block in the server HTML (4 Oct): a hard-loaded phone page used to paint without its top block, which mounted once hydrated and settings had loaded. The server and the first client render now emit both the block and the wider header and CSS shows one of them; from the second render on only this screen's stays mounted. This is the rule layout v2 follows for its own chrome (#6453, #6587, #6726): the first render is the same on the server and the client, client-only answers apply after it, and the room for the frame is held up front. The column holds the height the block rests at on the route (useShellBlockPlan: page row, root row, the rows Home, Squads and Explore keep, the field on search). Rows and spacing that were switched by the phone flag in JavaScript and showed on a phone in the server paint are CSS (Squads directory padding; the sources, search, Game center and Analytics rows wider screens use). On the preview build at 393px: the block is in the JavaScript-off paint and main has the same top before and after hydration on ten routes; layout shift on a hard load is at or below production on all nine routes measured (Explore 0.071 to 0, tags 0.173 to 0, squad 0.098 to 0.025, Squads directory 0.009 to 0); no hydration failure at phone or tablet width on ten routes, and at desktop width only the search page fails, as it does on production (production also fails on the profile page). A new spec hydrates the header's server HTML at phone width with zero recoverable errors. Page titles, page actions and the session's controls still fill in after hydration, inside the row.

Post open and the server paint (3 Oct): opening a post from the feed on a phone, and what a hard load paints before React runs. The list cards linked to the absolute permalink; Next prefetched the post's data as posts/slug.json and navigated with posts/slug.json?id=slug, so the prefetch was never reused: four requests per tap and a round trip before the post showed (150 to 290ms on production), and on a preview or a wrapper test build every tap was a full document load. Cards, their comment link, the grid overlay and the reading history rows now use the same-origin path (getPostPath): about 15ms from touch to post on the preview build, on a normal and a throttled network. Comparing each page with JavaScript off against the hydrated page found five things that moved on a hard load, all fixed: the toast's inert-exempt wrapper (from the second review) rendered inside a body portal and failed hydration on every page (React 418/423), so the attribute is on the toast itself; the post action bar took its clearance above the bottom bar after hydration and painted behind it first (on production too), now CSS; the column held no room for the top block, now one row from the server paint with hidden blocks publishing zero; the desktop header painted as an empty bar on phones and the post page held a 48px placeholder for the old back header, both hidden below tablet. The action bar ships with the footer chunk instead of a nested lazy chunk (late on the first post of a session, and a hydration error under Turbopack). Measured on the preview at 393px with 4x CPU: zero console errors on a hard-loaded post, the action bar 72px above the bottom from the first frame, layout shift down against production on tag, source, squad, profile, Explore and search pages (source 0.094 to 0.022, Explore 0.066 to 0), unchanged on the post page where all of it is the ad strip on both (0.1 to 0.6 depending on the post), and up on the Squads directory (0.009 to 0.035, a two-row block with one row held), padding equal to the block on 27 routes; in the iOS Simulator a tap on an Explore card opens the post in-app. The phone ad strip's push on a cold load stays (production behaviour, see above).

Touch feedback (3 Oct): a touch screen keeps :hover on whatever lands under the last tap, so a row looked selected on the screen that opened under the finger. The hover: variant is redefined for the whole app (packages/shared/tailwind/hover.ts): hover where a pointer can hover, the pressed state on touch, in its original place in the cascade (hover.spec.ts holds it there). Buttons take their CSS hover only where a pointer can hover. group-hover: is unchanged on purpose, since about a dozen controls are revealed by it. On a phone the settings menu marks no current row; desktop keeps the marker (9b24f08, fe5b3f8). Reviewers: this one changes every hover: utility on touch devices, desktop is unaffected.

Inset sweep (3 Oct): after the device pass, a code sweep plus an on-device scan (25 routes at three scroll positions) for anything pinned to an edge, sized to the viewport or offset from the bars by a fixed number. Fixed: the post action bar behind the bottom bar (--shell-bottom now includes the home-indicator inset), the Spotlight sheet leaving the screen with the keyboard up, the phone ad strip covering the block on visitor post pages, pages without the bar ending flush, the composer's link and poll footer and the GIF picker, the job page's footer over the bar, quest particles, the agent workspace height (61efdf6, 84f402d). Older wrapper inset bugs outside the shell were fixed in the same PR at Tsahi's request: the shared safe-area rules, full-height modal footers, scroll targets, the DevCard and welcome headers, the World chips, and the bars keeping their state while a sheet pins the page (f3d9f50). The scan is clean after the fixes.

Real iPhone, inside the iOS app (3 Oct): the wrapper built at this preview on an iPhone 16 Pro (iOS 27), its web view driven and measured over USB, then used by hand. 17 routes clean. It found five bugs that only the app's 62px status-bar inset produces (Safari has none): sheets 62px too low, a doubled inset on a full-height sheet, the hidden block's last row under the clock, the page behind a menu jumping by the inset, and the menu sheet's rows jumping at full height. All fixed (fb831e4, 533107a, 8e80d28) and re-measured on the phone. The real feed settings page, the Bookmarks sharing sheet and the settings back flow were verified there too.

Device pass (3 Oct): a Playwright sweep in Chromium with ten device profiles (Samsung Galaxy S8 and S9+, Pixel 7, iPhone SE, 13 mini, 14, 15 Pro Max, iPhone 14 with the Chrome-on-iOS UA, and two landscapes), 22 member routes and 9 visitor routes each, eight geometry checks (content under the block, overflow, bar inside the viewport, spacer above the bar, fixed elements wider than the screen) and five interactions per device (You fit, Help sheet, post menu and Report sheet, composer header and formatting sheet, settings menu); Safari on the iPhone 17 Pro simulator on 40 pages. Portrait is clean on every size; the You list scrolls only on a 568px-tall screen. One fix came out of it (08a6389): the old phone visitor header no longer doubles the block on tag, highlights, best-of and source pages. Landscape above 656px is the tablet layout by the breakpoint, as on main.

  • New specs: useShellScroll (dead zone, tolerance, hide, snap, reveal), ShellCluster (lit root, badge, both events, composer, visitor sign-up), ShellBlock (brand row, root names, page title and actions, hidden pages, the slide). Shared and webapp suites green, strict typecheck and lint clean, extension builds.
  • Local webapp at 393px (Pixel 5, Chromium) as a member and a visitor, light and dark: every root and the main leaves screenshotted at rest and scrolled, 0 horizontal overflow, no new page errors; the squad page, a profile, search results and Bookmarks re-shot after the buttons moved. A recorded walkthrough and the screenshots are attached to the review thread.
  • iOS Simulator (iPhone 17 Pro, Safari): Explore and You rendered and checked for the material, safe areas and the cluster above the toolbar.

Known and deferred (by the plan)

  • The cluster and the block paint with the server HTML (the block is hidden by CSS from tablet up and unmounts there after hydration). Titles, actions and rows a page registers through ShellPage, and what needs the session, fill in after hydration inside the row.
  • Explore keeps its 40px top padding under the places row (the page container's own padding); covers and the name in the block on things are 2.6 and 2.7; the post page capsule is 3.1; the pager (segments following the thumb) is 2.9. Sub-sheets inside the post menu are built (see Step 1 complete).
  • In the iOS wrapper the status bar keeps today's opaque cover (safeArea.css); the no-frame look there is step 4.

🤖 Generated with Claude Code

Preview domain

https://claude-mobile-shell-1.preview.app.daily.dev

tsahimatsliah and others added 2 commits October 1, 2026 10:00
Step 0 of the mobile shell work (Storybook, Mobile UX / 10. The build):
the fixes that need no design decision, shipped together.

TabContainer's swipe decided from the total movement at touch end with a
40px threshold, so a thumb scrolling at a slight angle switched the
Highlights channel. The swipe now locks its axis on the first 10px, commits
past 56px inside a 27 degree cone or on a fast flick past 32px, and the
surface takes touch-action: pan-y. The numbers live in shell/constants.ts.

FooterNavBarLayout waited for the window load event before rendering the
bar; it renders once hydrated. The footer tabs fire a click with the footer
as target and the tab in extra, and Activity fires click notification icon
with NotificationTarget.Footer, which existed and was never used.

HighlightCardOptions and SquadOptionsButton were hidden until hover on
every device, so their actions did not exist on phones; they use the
laptop-and-mouse-only hover class the post cards use. The Explore period
drawer gets a Close, the notification menu icon stops being rotated,
Unfollow uses the remove-user icon, "Manade Ad" is "Manage ad" and the
settings menu labels match their page titles.

shell.css carries the shell's two curves and four durations and the press
class (scale 0.96, 150ms) applied to the footer tabs, the plus button, the
header controls, the feed chips and the post bar; the post titles balance.

Two pre-existing strict type errors in the touched files are fixed so the
strict guard passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Oct 7, 2026 8:29am UTC

Request Review

tsahimatsliah and others added 2 commits October 1, 2026 11:32
Step 1 of the mobile shell work (Storybook, Mobile UX / 10. The build,
PRs 1.1 to 1.8 together): the shell under 656px. Tablet, laptop, layout v2
and the extension keep the code they run today.

The floating cluster replaces the docked footer: Home, Explore, Squads and
Activity on the flat blur material, the Create square beside it opening the
composer, the brand bubble on Activity, the shrink on scroll, the lit tab
from the root that owns the URL, visitors sent to sign up from Home,
Activity and Create. It is in the server HTML so it paints with the page.

The top block replaces the phone header: the brand row on Home (logo,
streak, the Plus door for free members, the square avatar opening the new
You page), a name row on Explore, Squads and Activity, and back, title and
actions on every leaf, fed by GoBackHeaderMobile and the page headers that
drew their own bars. It is a fixed overlay that hides as one piece while
reading down and returns on any short scroll up; content keeps a constant
top padding from --shell-top. One scroll reader drives block and cluster.

Drawer becomes the sheet (travel curve in, faster out, a grabber, drag to
dismiss), DropdownMenu renders as a modal bottom sheet on phones, the gated
sign-up closes with an X and names what the action gets you, and people's
pictures are rounded squares where they were circles.

Deleted: MobileFooterNavbar, FooterNavBarTabs, FooterNavBarItem,
FooterPlusButton, MobileFeedActions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	packages/shared/src/components/MainFeedLayout.tsx
#	packages/shared/src/components/feeds/FeedNav.spec.tsx
#	packages/shared/src/components/feeds/FeedNav.tsx
#	packages/shared/src/components/feeds/MobileFeedActions.tsx
#	packages/shared/src/components/post/GoBackHeaderMobile.tsx
#	packages/shared/src/components/profile/Header.tsx
On review Tsahi asked for the header's rows in the same PR so no page
shows a second header. ShellRow carries the three weights of the 28px
chip (segments, chips, a menu label) and a sheet of choices.

Home's row is For you, Happening now with its channel sheet, Following,
the member's custom feeds and the plus; the Highlights page shows the
channel on its own line instead of its title and tab strip. Explore's row
is the search field and the places, and its sort is a text menu on the
feed line instead of the sticky tab strip. Squads root puts its categories
in the block, Activity its types, Bookmarks its lists as segments under a
title; Tags, Sources, Leaderboard and History take their names in the
block and lose their in-page navbars. Tablet keeps every old row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On review every phone page still kept a button row of its own under the
block: New Squad under the Squads title, the channel line and copy link
under Happening now, Search and the menu in the squad hero, Follow, Award
and the menu twice on a profile, Sort and Share in the Bookmarks header,
the full-width Filters on search results. They move into the block.

ShellPage now portals its actions into a slot the block exposes instead of
passing the nodes through context, because the block renders outside the
page's providers and the squad menu reads SquadPageContext. The hero
copies are gated by viewport in JS rather than CSS so each label exists
once in the DOM, which is also what the page tests query.

The streak was never hidden: the local proxy's fake member had no streak
response. The proxy stubs one now so the shots show it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A route-by-route pass at 393px over the member app found the pages that
still drew a header row of their own under the block: Sources (a
full-width Suggest new source button), Jobs, the Core wallet, the
briefings, squad moderation, post analytics, the gear rankings, the
best-of archives (breadcrumbs and a copy link row), the experience lists
(a second back arrow), the squad form (Cancel and Create under a back
square), the leaderboard detail (no title at all), the tag explore page
(no title), and the composer page (the block over the composer's own
chrome). Each now registers its title and actions with the block on a
phone and keeps its row for tablet and up; the composer hides the block.

FormWrapper gets an inBlock mode for page forms so the squad form's
submit rides in the block and the back square replaces Cancel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tsahimatsliah and others added 2 commits October 1, 2026 13:56
# Conflicts:
#	packages/webapp/pages/sources/index.tsx
#	packages/webapp/pages/users.tsx
# Conflicts:
#	packages/shared/src/components/MainLayout.tsx
The 72% rest opacity sat on the whole tab, so the unread bubble dimmed
with the glyph and read as disabled next to the lit tab. The dim moves
onto the glyph alone; the bubble is the same colour in both states.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BootProvider.spec "refetch boot on focus within the interval when another
tab changed the Plus status" counted three boot requests instead of two
on CI; it passes alone and this branch does not touch contexts/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…finger

The lit tab now has a soft pill behind it (the Instagram reference Tsahi
gave), sliding on the travel curve when the root changes. Press and hold
the bar, move along it, and the pill follows the finger with the glyph
under it lit; lifting selects that tab. A 6px travel threshold keeps a
tap a tap, and the click the browser fires for the release is swallowed
so the drag's choice is not navigated twice. No glass: the flat material
only.

The Squads root also loses most of its top padding on phones, since the
chips moved into the block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Suggest new source, New Squad and Job preferences need an account, so
their squares in the block render for members only; visitors keep Log in
and Open app in the row and nothing they cannot use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A finger on the bar scales the whole bar to 1.04 from its bottom edge for
as long as it stays down (150ms ease-out in, 220ms on the no-bounce curve
out), the lift Instagram's and iOS 26's bars give; the per-tab 0.96 press
goes, since the bar now answers as one piece. The pill slides on the same
no-bounce curve, per chapter 9l. Reduced motion drops the lift.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Hover on touch
- Four button rules still took :hover on a touch screen (the quaternary
  button's label, the tag buttons, the sign-back button), so an upvote
  count stayed lit after its button had cleared.

Insets in the wrappers
- The h-screen and min-h-screen overrides applied on the web too and
  displaced the iOS 100vh fix that follows Tailwind's own rules, so
  full-height pages ran under Safari's toolbar. They apply only where
  there is an inset.
- The modal footer's bottom inset was done by swapping its fixed height
  for a minimum and adding bottom padding, which changed footers whose
  callers set their own height or padding, on desktop too. The footer
  keeps main's classes; on a phone its last instance takes the home
  indicator as a margin.

Phone shell
- Home's block rests at two rows but its segments wait for settings, so
  the block measured one row first and the page jumped up and back. The
  row holds its height while it waits.
- A page with no title rendered an empty h1 in the block, now in the
  server HTML of every leaf page.
- Bookmarks' block has a row of its own; the layout holds its height.
- Set a reminder lost its accessible name with the duplicate title.
- The search filters sheet mounts on the root, out of the block's
  transform.

Post links
- A spec for the slug form and the id fallback of the post path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@tsahimatsliah tsahimatsliah left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of ccd627eb8

Read against the four earlier rounds (lit-tab re-tap, quest button, banners under the block, canGoBackInApp, feed_chips, useIsPhone, sheet drag, named group-hover, route-stack keys, refresh cost, transitionend, inert toast, aria-hidden peer, hover media split). Those threads look resolved in the code; I did not re-raise them. CI is green, no committed binaries under public/, no TODO … before merge left in the diff. Below are the new points; none of them is a correctness blocker on its own.

Reviewability and scope

227 files. By the PR's own account, #6775 (hover is pressed on touch, 5 files), #6776 (posts open in-app, 32 files) and #6777 (wrapper insets, 14 files) are still inside this diff while they wait as drafts. I did not review those three areas here; please do take them in their own PRs. Recommendation: land the three split PRs first, merge main back in, and then do the final pass on the shell-only diff. A diff this size tends to get approved unread, and the ~50 files that leave make the remaining shell reviewable as one piece.

No runtime kill switch

The plan audit says "none added" for flags, and that is accurate: the new shell (ShellBlock in MainLayoutHeader, ShellCluster via ClusterSlot, the sheet behaviour in Drawer/DropdownMenu) ships to every visitor under 656px the moment this merges, with no rollback short of a revert deploy. The repo's convention (AGENTS.md, and how layout v2 landed) is a GrowthBook flag with the control as default. Even if the product decision is "everyone", a single kill-switch flag read at the two mount points (MainLayoutHeader's block and FooterNavBarLayout's ClusterSlot, falling back to the old FeedNav phone branch and the deleted footer) would give a same-day rollback for a change this wide. The one piece that cannot be flagged is the hover: plugin, which is another reason to land #6775 separately so it can be reverted alone. Question for the team rather than a code finding.

Inline

6 non-blocking comments on the diff: a second copy of the root-ownership predicate (useShellBlockPlan vs shellNav) that already disagrees on /posts/best-of; ShellPage re-publishing config on every parent render; the off-screen level of PostOptionsSheet staying in the menu's focus order; the extension at phone width inheriting the block; the new color-scheme meta applying to desktop native controls; hardcoded scrim/shadow colours in shell.css.

Verification

  • Root and package AGENTS.md read
  • Diff, commits, all prior review threads and CI inspected
  • Shared consumers traced: MainLayout, MainLayoutHeader, FooterNavBarLayout, Drawer, DropdownMenu, Modal, FormWrapper, GoBackHeaderMobile, profile/squad headers, 35 DropdownMenu consumers
  • Committed-asset and _app/_document gates run
  • Flag and experiment hygiene checked (feed_chips no longer evaluated in the block; no truthy defaults)
  • Not run locally: Jest suites (CI ran them); the phone walkthroughs are from the PR's own evidence
  • Not verified: the extension's new tab at a window under 656px; desktop <select>/scrollbar rendering after the color-scheme meta

Verdict

Comment. No blocking code finding. I would hold approval until #6775/#6776/#6777 land and main is merged back, so the shell diff can be read on its own, and until the kill-switch question has an answer.

Reviewed by AI.

Comment thread packages/shared/src/components/shell/useShellBlockPlan.ts Outdated
Comment thread packages/shared/src/components/shell/ShellPageContext.tsx
Comment thread packages/shared/src/features/posts/PostOptionsSheet.tsx Outdated
Comment thread packages/shared/src/components/layout/MainLayoutHeader.tsx Outdated
Comment thread packages/webapp/pages/_app.tsx Outdated
Comment thread packages/shared/src/styles/shell.css Outdated
Shell
- One predicate for which root shows its row: the block's plan reads
  shellNav.isRootView instead of its own copy. The best-of archive is
  Explore's page, not one of its views, in both places now, so the lit
  tab returns from it to Explore as its back row already did.
- The block's measurement effect keeps one ResizeObserver for the life
  of the header instead of a new one on every page render.
- The post menu's off-screen level is inert rather than aria-hidden with
  focusable rows.
- The extension's new tab keeps its own chrome at every width: no block,
  no held padding, useIsPhone is false there.
- The colour scheme is set in CSS for phones only; the document-wide
  meta also restyled native controls and scrollbars on desktop.
- The menu scrim and the shell's shadows read theme tokens.

Hover on touch
- Checkbox, field, help card and mention hovers follow the same split.
- The variant's doc says why Tailwind's own flag is not used and that
  group-hover and peer-hover stay as they are.

Post links
- The helper falls back to the id for an empty slug and accepts a null
  one; five more in-app links use it; the boost campaign list opens its
  post in-app; the reposts row no longer gates on a field it does not
  read.

Insets
- The repo's safe-area utilities replace three hand-written values; a
  modal thread keeps its own scroll margin; the stylesheet comment states
  the cascade as it is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@tsahimatsliah

Copy link
Copy Markdown
Member Author

Kill switch: product decision. Tsahi's call is that the shell launches to everyone without a flag. A runtime switch would mean keeping the old phone footer and the FeedNav phone branch alive behind it, and the decision is not to carry both. Rollback is a revert of this PR. To keep that revert clean, the three app-wide pieces land separately first (#6775, #6776, #6777), so they can be reverted, or kept, on their own.

Scope. Agreed on the order: the three small PRs merge first, main is merged back in here, and the shell is then reviewed as a shell-only diff. Their code is still in this branch only so the preview keeps working until then.

The six inline points are answered on their threads (334e4ab).

Replied by AI (Claude).

tsahimatsliah and others added 12 commits October 5, 2026 11:32
main removed the best discussions widget (#6787) and the giveback footer
branch (#6782), and moved the experience page's empty state below its head
(#6781). The shell keeps its phone header on that page on top of main's change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Posts opened from a widget row now stay inside the app, so post to post
reuses the /posts/[id] route and the page instance. An ad slot runs its
auction once per mount and is keyed by surface, slot, format and sizes only,
so the next post kept the previous post's creatives, and the top
leaderboard kept its spent pin timer. Before, each of those taps was a
document load with fresh auctions.

The page's layout now keys the page and the pinned phone strip on the post
id, so every unit and all per-post state start again on each post, as a
full load did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main removed the gear page (#6790) and the explore tag feed (#6791). The
shell's phone title for that feed goes with it, and the feed's top padding
rule reads the phone check alone now that main dropped the mount flag it
was paired with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#6776 is on main now; the source page keeps the shell's phone check beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#6777 is on main now; the shell keeps its scroll-lock inset rule beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The profile page renders one header and flips its sticky prop once the page
scrolls. On a phone the header returned nothing while sticky, on the belief
that the page rendered two of them, so the block lost Profile and the menu
as soon as the page moved. The header registers with the block either way
now. Its visitor-header branches go too: they sat on the tablet path, where
that header never shows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The shell block carries Log in and Open app on phones, and it is in the
server HTML, so MobileAppHeader had nothing left to show. It still mounted
on 16 page types and, because the phone check is false on a component's
first render, it painted for one frame on every in-app navigation to them
before removing itself.

The component, its mounts and the offset variable it published are gone;
the three sticky rows that read the variable pin at their fixed offset. Its
spec stays as the spec of the actions and of the rule that shows them. Two
tablet-only spots that branched on the visitor header lose the branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… tab

The extension does not get the phone shell, but the shell had deleted
MobileFeedActions, the row that carries the logo, the streak and the way
to the profile under 656px. A narrow extension window was left without
them. The row is back as it is on main and renders in the extension only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An independent pass over the diff after the three split PRs merged.
Unused shell constants, the always-true snapping field, the sheet drag's
expandable option, a prefix for a route that no longer exists, props no
caller passes, default exports nothing imports, the footer's tab indicator
(its only user was the deleted footer nav) and a registration with no
content. The shell provider wraps the layout from outside, so MainLayout's
body keeps main's indentation. Two barrel imports and two import orders
fixed; hover already covers the pressed state the rows repeated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final pass at 99b0285cb, blockers only. Every earlier thread is resolved and CI is green. The commits since 334e4ab5d look good to me, including the post-to-post ad-auction remount and the 19 removed MobileAppHeader mounts. Each of those pages now gets Log in / Open app from the block, and the merge resolutions are clean.

Blocking

  1. Merge conflicts with main. MainLayout.tsx is trivial: keep SquadJoinSuggestionsPopup from #6807. SquadProfileHeader.tsx conflicts with the verified-squad branding from #6795 (useSquadBranding, the brand wash). After the rebase, please check a verified squad page on a phone. The brand wash and the badge need to sit correctly under the fixed block, and the squad's block actions need to sit beside the new branded header.
  2. Extension: phone sheets via useViewSize(ViewSize.MobileL). See the inline comment on DropdownMenu.tsx.
  3. Extension: logged-out Explore/search under 656px has no Log in. See the inline comment on MainLayoutHeader.tsx.
  4. Storybook import broken by the MobileAppHeader removal. packages/storybook/stories/mobile-header/MobileAppHeader.stories.tsx:15 still imports the deleted MobileAppHeader and renders it on line 152, so the storybook bundle no longer resolves. CI skips storybook, so nothing caught it. Drop that story, or point it at MobileAppActions, which line 14 already imports.

No non-blocking items this round.

Reviewed by AI.

Comment thread packages/shared/src/components/dropdown/DropdownMenu.tsx Outdated
Comment thread packages/shared/src/components/layout/MainLayoutHeader.tsx
tsahimatsliah and others added 2 commits October 6, 2026 16:38
MainLayout keeps the toast in its portal and takes SquadJoinSuggestionsPopup;
SquadProfileHeader keeps the block's actions and takes the verified squad's
brand wash.
…s its logo row

Chris's final pass on #6767. The menu sheet, the two-level post options
sheet and the audience chip's sheet read useViewSize(MobileL), which is true
in a narrow extension window too; they read useIsPhone now, which the
extension never is. A logged-out extension window under 656px on search
lost its logo and Log in when the old header left; the search header renders
MobileFeedActions there, as FeedNav does. The mobile header story renders
MobileAppActions instead of the removed MobileAppHeader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-check at dcc6fc26f. All four blockers from the last round are fixed:

  • The #6795 and #6807 conflicts are resolved cleanly. SquadProfileHeader keeps the block actions and takes the brand wash, and MainLayout keeps the toast portal and takes the join popup.
  • DropdownMenu, PostOptionButton and AudienceChip use useIsPhone().
  • The extension's search view has its logo and Log in row again.
  • The Storybook story no longer imports MobileAppHeader.

One blocker remains: a new conflict with main. #6802 ("show the mobile app footer on page load") landed after your merge. It rewrites MobileAppFooterContext: isRevealed and moment are gone in favour of title, and MobileAppFooterAnchor is deleted. That gives textual conflicts in FooterWrapper.tsx, FooterNavBarLayout.tsx and pages/users.tsx. It also gives one conflict git will not flag, on ClusterSlot (see the inline comment). When you resolve it, drop the MobileAppFooterAnchor mounts in users.tsx, as main does. HighlightsPage auto-merges correctly. Then check on a phone that the app footer and the cluster swap on page load.

Reviewed by AI.

Comment thread packages/webapp/components/layouts/FooterNavBarLayout.tsx Outdated
tsahimatsliah and others added 2 commits October 6, 2026 18:11
The app footer shows whenever the context has a title (#6802): FooterWrapper,
FooterSpacer and ClusterSlot read title, the footer keeps its pointer-events
wrapper over the shell's pass-through container, and the leaderboard drops
the removed MobileAppFooterAnchor.

This branch was successfully deployed

1 active deployment
Preview — d9112000 Deployed Oct 7, 2026 by vercel[bot]
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.

2 participants