Mobile Notifications UI
Mobile mirror of the desktop NotificationBell + NotificationPopover pattern. Adds an in-app notification list (previously absent on mobile) — users no longer have to rely on push delivery or hop to desktop. Shipped to production at wrcm.levandor.io on 2026-05-10.
For Agents
Before this,
web/src/mobile/MobileNotificationsScreenwas settings-only (push toggle, in-app preferences, push types). There was no list of recent notifications anywhere on mobile. The addition here mirrors desktop’sNotificationBellbutton →NotificationPopoverlist pattern, but uses a vaul Drawer (the canonical mobile sheet — see Drawer Pattern).
What’s new
Components added
| Component | Path | Role |
|---|---|---|
MobileNotificationButton | web/src/mobile/MobileNotificationButton.tsx | Bell icon + copper unread-count badge; opens the sheet |
MobileNotificationsSheet | web/src/mobile/MobileNotificationsSheet.tsx | vaul Drawer at 85vh containing the list |
Components extracted (DRY)
| Component | New path | Used by |
|---|---|---|
PushDiscoveryNudge | web/src/components/crm/notifications/PushDiscoveryNudge.tsx | Both desktop NotificationPopover and mobile MobileNotificationsSheet |
Previously this nudge was inlined inside NotificationPopover. Extracted to a shared component so the mobile sheet doesn’t fork the copy or behaviour.
Components reused as-is
NotificationItem— pure presentation, no changes. Used in both desktop popover and mobile sheet.- The
useNotificationshook (and friends) — same data layer.
Sheet anatomy
MobileNotificationsSheet mirrors the desktop popover top-to-bottom:
┌─────────────────────────────────────────┐
│ [PushDiscoveryNudge] │ ← only when push not yet enabled
├─────────────────────────────────────────┤
│ Notifications [Mark all read] │ ← header
├─────────────────────────────────────────┤
│ [All] [Tasks] [PRs] [System] │ ← tabs (TAB_FILTERS)
├─────────────────────────────────────────┤
│ ┌─ NotificationItem ─────────────────┐ │
│ │ ... │ │ ← ScrollArea, virtualized list
│ └────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────┤
│ → /settings/notifications │ ← footer link
└─────────────────────────────────────────┘
- Sheet height: 85vh (same as
SearchSheet). - Tab filters: identical mapping to desktop — same
TAB_FILTERSconst is referenced. Don’t fork. - Scrollable area: ScrollArea of
NotificationItems. Pure presentation reuse, no mobile-specific variant. - Footer: link out to
/settings/notifications(the existingMobileNotificationsScreen, which becomes the settings destination only).
Integration — MobileHeader rightSlot fallback
Pattern worth recording
MobileHeader.rightSlotnow uses a default-fallback pattern:{rightSlot ?? <DefaultComponent />}. Pages opt into the default by not passingrightSlot; pages that need something else pass it explicitly. This is now the canonical way to default a header slot across the mobile app.
// MobileHeader.tsx (sketch)
<header>
<h1>{title}</h1>
<div>{rightSlot ?? <MobileNotificationButton />}</div>
</header>Effect: every standard mobile page (Home, Plan, Hours, Money, More) gets the bell automatically. Pages with their own action (e.g. a sheet’s close button) pass rightSlot explicitly and override the default.
Architectural patterns codified
vaul Drawer is the canonical mobile sheet
The codebase now has two production examples of this template — SearchSheet and MobileNotificationsSheet. When adding a new mobile sheet, follow this template. Key rules already in Drawer Pattern (vaul):
- Use the shadcn-style named-export wrapper from
@levandor/ui:Drawer,DrawerContent,DrawerHeader,DrawerTitle. NotDrawer.Root/Drawer.Portal. <DrawerContent>already wraps Portal + Overlay and renders its own drag handle — don’t add another.- Heights: 85vh is the default for tab-style sheets (Search, Notifications). Use 90vh only when the content is genuinely “full-screen-ish” (e.g. the original StandupStreamSheet).
MobileHeader rightSlot fallback
{rightSlot ?? <DefaultComponent />}Pages opt-in by omission. This pattern is reusable for any future global-mobile UI element that should default-render but be overridable per page (e.g. a future search button, or a context-aware action).
Test gotcha — provider-less page tests
Bare-render tests break when a default-rendered component pulls in providers
Tests that render a mobile page without wrapping in
SupabaseProvideretc. (theMobile<X>.test.tsxfamily) will now break becauseMobileHeaderdefaults to rendering<MobileNotificationButton />, which callsuseSupabase()internally.
Two viable fixes:
- Wrap every test in the full provider stack — verbose, slow, and many of the existing tests are intentionally render-only.
- Mock the new component to
() => nullat the test-file top. Lighter touch.
The codebase chose option 2. Pattern:
vi.mock('@/mobile/MobileNotificationButton', () => ({
MobileNotificationButton: () => null,
}))Applied in:
web/src/mobile/__tests__/MobileMoney.test.tsxweb/src/mobile/__tests__/MobilePlan.test.tsx
Rule of thumb for default-rendered UI
If you ever add a component to a default-rendered surface (header, footer, shell-level wrapper) that touches
useSupabase,useTranslation, or any other provider-dependent hook, you must update the bare render tests inweb/src/mobile/__tests__/. Either expand the provider wrappers or add thevi.mock(... () => null)shim — pick one and apply consistently.
Routing impact
MobileNotificationsScreen(the existing settings screen at/settings/notifications) is unchanged — it remains the settings surface (push toggle, preferences, push types).- The footer link in the new sheet points to that screen.
- The bell does not route — it opens the sheet inline. This matches desktop’s popover behaviour.
Commits
| SHA | Subject |
|---|---|
f2d0512 | Extract PushDiscoveryNudge from NotificationPopover (DRY prep) |
1a8c1f0 | Add MobileNotificationButton + MobileNotificationsSheet; wire as MobileHeader rightSlot default |
1d4bb18 | Mock MobileNotificationButton in bare-render mobile tests |
Spec & Plan
- Spec —
docs/superpowers/specs/2026-05-10-mobile-notifications-ui-design.md - Plan —
docs/superpowers/plans/2026-05-10-mobile-notifications-ui.md
Notes for future work
- The notifications sheet currently uses the desktop
NotificationItem1:1. If we later add swipe-to-dismiss or swipe-to-mark-read on iOS, treat that as a mobile-specific subclass — don’t forkNotificationItem. Layer swipe behaviour at the row level (similar toLaterTodayListin Phase 5). - Push-related affordances (re-prompt for permission, etc.) belong in
PushDiscoveryNudge, which is now the shared surface for those callouts in both desktop and mobile. - Settings link in the footer →
/settings/notifications. If the settings IA changes, update both the desktop popover footer and the mobile sheet footer.
Related
- mobile-native-feel — Mobile shell, BottomTabs, MobileHeader, vaul Drawer pattern
- push-notifications — Notifications backend research (Supabase primitives, Web Push, iOS gotchas)
- incident-2026-05-10-sw-cf-access-lockout — Same-day fix this UI sits on top of; the SW behaviours described there still apply
- pwa-update-prompt — Sibling shipped feature on 2026-05-10
- levandor-brand — Copper unread-badge color
- levandor-crm — Project overview