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/MobileNotificationsScreen was settings-only (push toggle, in-app preferences, push types). There was no list of recent notifications anywhere on mobile. The addition here mirrors desktop’s NotificationBell button → NotificationPopover list pattern, but uses a vaul Drawer (the canonical mobile sheet — see Drawer Pattern).

What’s new

Components added

ComponentPathRole
MobileNotificationButtonweb/src/mobile/MobileNotificationButton.tsxBell icon + copper unread-count badge; opens the sheet
MobileNotificationsSheetweb/src/mobile/MobileNotificationsSheet.tsxvaul Drawer at 85vh containing the list

Components extracted (DRY)

ComponentNew pathUsed by
PushDiscoveryNudgeweb/src/components/crm/notifications/PushDiscoveryNudge.tsxBoth 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 useNotifications hook (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_FILTERS const is referenced. Don’t fork.
  • Scrollable area: ScrollArea of NotificationItems. Pure presentation reuse, no mobile-specific variant.
  • Footer: link out to /settings/notifications (the existing MobileNotificationsScreen, which becomes the settings destination only).

Integration — MobileHeader rightSlot fallback

Pattern worth recording

MobileHeader.rightSlot now uses a default-fallback pattern: {rightSlot ?? <DefaultComponent />}. Pages opt into the default by not passing rightSlot; 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. Not Drawer.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 SupabaseProvider etc. (the Mobile<X>.test.tsx family) will now break because MobileHeader defaults to rendering <MobileNotificationButton />, which calls useSupabase() internally.

Two viable fixes:

  1. Wrap every test in the full provider stack — verbose, slow, and many of the existing tests are intentionally render-only.
  2. Mock the new component to () => null at 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.tsx
  • web/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 in web/src/mobile/__tests__/. Either expand the provider wrappers or add the vi.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

SHASubject
f2d0512Extract PushDiscoveryNudge from NotificationPopover (DRY prep)
1a8c1f0Add MobileNotificationButton + MobileNotificationsSheet; wire as MobileHeader rightSlot default
1d4bb18Mock 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 NotificationItem 1:1. If we later add swipe-to-dismiss or swipe-to-mark-read on iOS, treat that as a mobile-specific subclass — don’t fork NotificationItem. Layer swipe behaviour at the row level (similar to LaterTodayList in 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.