Mobile Native-Feel Transformation

For Agents

Implementation has shipped. The brainstorm + spec phase moved to the Decision Log section below; the rest of this note describes the as-built mobile architecture. Source tree: web/src/mobile/. Active topic — additions arriving as more polish ships.

Architecture (As-Built)

web/src/mobile/
├── shell/        # MobileShell, MobileHeader, BottomTabs, SheetStack
├── lib/          # useIsMobile, usePullToRefresh, useSafeArea, formatTime
├── home/         # MobileHome + StatTileGrid, ForecastHero, UpNextCard, ProjectHealthCard
├── plan/         # MobilePlan + NowHeroCard, UpNextBlockCard, LaterTodayList,
│                 # DoneTodayCollapse, BlockCreateSheet, BlockEditSheet,
│                 # MorningCheckinSheet, StandupNotesSheet, LunchCheckinSheet,
│                 # EveningReflectionSheet, EveningChecklistSheet
├── hours/        # MobileHours + WeekStrip, useTimesheetWeek, EntryEditSheet
├── money/        # MobileMoney + BudgetGlanceCard, InvoiceListItem, BudgetDetailSheet
├── more/         # MobileMore + SectionLink
└── home/UpNextCard.tsx, etc.

Routing

App.tsx branches on useIsMobile() (matchMedia('(max-width: 768px)')):

  • Mobile branch<MobileApp> — wraps <Routes> in <MobileShell>. Same URLs render mobile components for the 5 tabs (/, /standup, /timesheets, /invoices+/budget, /more).
  • Desktop branch<DesktopApp> — existing <AppShell> sidebar layout, zero changes.

AppShell is NOT mounted on mobile

MobileApp deliberately skips AppShell. Mounting it on mobile produced a vaul + Radix Dialog scroll-lock conflict on iOS that broke scroll. The mobile shell composes the equivalent providers itself.

Drawer Pattern (vaul)

@levandor/ui exposes a shadcn-style named-export wrapper around vaul:

import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@levandor/ui'

Not the namespaced form (Drawer.Root, Drawer.Portal, etc). <DrawerContent> already wraps Portal + Overlay and renders its own drag handle — don’t add another portal or handle.

Added as a peer dep to @levandor/ui in commit b2b8b7c.

iOS Gotchas (Codified)

GotchaWorkaround
Long-press drag selecting textWebkitTouchCallout: 'none' + userSelect: 'none' on swipeable rows
Vertical scroll fights horizontal swipetouchAction: 'pan-y' on swipeable rows
Input zoom-in on focusInputs ≥16px text size
ISO 'YYYY-MM-DDTHH:MM' to UTC driftUse datetime-local inputs and round-trip through local-time helpers; never .toISOString().slice(0,10) (see debugging-log-crm)
window.confirm() blocked / off-style in PWAReplaced with inline two-step: Cancel + Confirm-delete (rose-600). See BlockEditSheet, EntryEditSheet. Commit dcdbec7.
<object> PDF embed doesn’t render in PWA standaloneLink-out instead
<select> with appearance-none + Lucide ChevronDown overlay → invisible textExplicit text-zinc-900 + placeholder:text-zinc-400. Commit 7f4bc84.

Patterns

lastEditIdRef (sheet close-animation)

vaul’s drawer close animation runs ~250ms. If we drop the data prop on close, the sheet flickers blank. Pattern:

const lastEditIdRef = useRef<string | null>(null)
const [editId, setEditId] = useState<string | null>(null)
 
useEffect(() => {
  if (editId) lastEditIdRef.current = editId
}, [editId])
 
// Sheet stays mounted while standupId truthy:
<Drawer open={!!editId} onOpenChange={(o) => !o && setEditId(null)}>
  <BlockEditSheet blockId={editId ?? lastEditIdRef.current} />
</Drawer>

__setPlanQuickAdd typed-global hatch

BottomTabs needs to fire a quick-add action on long-press of the Plan tab, but lives outside MobilePlan’s tree. Solution: typed window global installed by MobilePlan:

declare global {
  interface Window {
    __setPlanQuickAdd?: (fn: () => void) => void
  }
}

MobileShell exposes it via context; MobilePlan mounts/unmounts a handler; BottomTabs long-press calls it (no-op if not installed).

usePullToRefresh (iOS-style elastic)

Hook that binds touch listeners with stable effect deps — early version rebound 50+ touch listeners per gesture, breaking iOS PtR. Commit ee1652e fixed the deps.

Time-aware Plan CTAs

In MobilePlan:

  • active and doneOrCancelled collections are status-only and memoized
  • upcomingPlanned and later are time-dependent and intentionally NOT memoized — they roll forward as time passes within the same render

Phase 5-10 Implementation (18 commits)

Phase 5 — Plan tab

SHAItem
b2b8b7cT5.1 shadcn Drawer (vaul wrapper) added to @levandor/ui peer deps
f05fe97T5.2 NowHeroCard
3568efcT5.3 UpNextBlockCard
10492a6T5.4 BlockCreateSheet + BlockEditSheet (datetime-local + timezone roundtrip)
38bb774T5.5 LaterTodayList swipe-left actions (drag-reorder deferred)
668df40T5.6 DoneTodayCollapse
9f39ff3T5.7 StandupStreamSheet (later replaced — see mobile-day-rituals)
b14bfd8T5.8 MobilePlan composition with lastEditIdRef + __setPlanQuickAdd global

Phase 6 — Hours tab

SHAItem
c0635e1T6.1 WeekStrip (7 day-pills)
69bad06T6.2 MobileHours + useTimesheetWeek + TimesheetWeekRow extension type

Phase 7 — Money tab

SHAItem
a790871T7.1 BudgetGlanceCard
c76fd78T7.2 MobileMoney + InvoiceListItem (built ourselves — InvoiceCard doesn’t exist) + BudgetDetailSheet v1

Phase 8-10

SHAItem
2e293c8T8.1 MobileMore + SectionLink (CF Access logout)
2388d77T9.1 SearchSheet (per-tab v1 stub via SearchPlaceholder)
272d350T9.2 Sonner top-center on mobile
e58977eT10.1 Mock factories
d546a58T10.2 MobileShell test
04d027bT10.3 MobileHome/Plan/Hours/Money render+state tests + IntersectionObserver stub

Audit Pass (post-Phase 10)

SHAAudit fix
60a1acdSync pnpm-lock.yaml with vaul peer dep — was blocking CF Pages frozen-lockfile build
01880a0Hours-tab “garbage hours” bug — fixed UTC date drift via new toLocalDateString() util in web/src/lib/date-utils.ts. Same bug in useDashboardSummary.ts. Dedup startOfWeek. 8 unit tests added.
8e7b0adDedup formatTime from 4 mobile sites into web/src/mobile/lib/formatTime.ts (mobile uses toLocaleTimeString, distinct from desktop UTC version)
dcdbec7Replace window.confirm() with inline two-step in BlockEditSheet + EntryEditSheet
7da8153StandupStreamSheet render-time setState → useEffect for summary sync
2c5960cType __setPlanQuickAdd via declare global + memoize MobilePlan status-only collections
ee1652eStabilize usePullToRefresh effect deps + MobileHeader duplicate-h1 a11y fix

Mobile UI Redesign (4 commits)

Brand alignment to copper/warm-black — see levandor-brand for tokens.

SHAItem
7f4bc84Drawer surface tokens (bg-white + warm scrim) + explicit input/select text colors (the invisible-dropdown-text bug)
2a43df8Unified hero/card/list visual treatment
60f7fa6Refined header, bottom tabs, sheet internals (BottomTabs got 2px-tall active-pill indicator)
31936a5Polish: WeekStrip, StatusFilterPills, settings list (Sign out → rose-600)

Bug Fixes + Brand Pass (2 commits)

SHAItem
2c82bd0Hours card respects selected day (“Today” only when active === today) + wire CurrencyProvider for HUF support (replacing hardcoded EUR Intl.NumberFormat)
9448637Levandor copper brand pass — discovered actual brand in packages/ui/src/styles/globals.css. See levandor-brand.

Forecast Hero Iterations (2 commits)

See dashboard-forecast for the full computation gotcha.

SHAItem
1697360First fix: netThisMonth / netForecast (was apples-to-oranges weekly/monthly)
6f1fdb5Final fix: mirror desktop dashboard hero exactly — no invented ratios

Day Rituals Rebuild (2 commits)

See mobile-day-rituals and evening-checklist for full descriptions.

SHAItem
b7626d9Replaced StandupStreamSheet with 4 phone-native sheets (Morning, Standup notes, Lunch, Evening reflection)
482e592Evening Checklist v1 (manifestation tracker) — both mobile sheet + desktop card

Evening Checklist Backend (2 commits)

SHAItem
3c3501dMigration 008 — widened CHECK constraint, seeded 3 prompts. See evening-checklist.
2849661Migration 009 — added 4th “Shit I overspent on” prompt

Debounced Autosave (2 commits)

See mobile-autosave for full description.

SHAItem
f718425New useDebouncedAutosave hook + EveningChecklistSheet/Card refactor
7d18acbMorningCheckinSheet + EveningReflectionSheet summary + desktop standup page parent-level orchestration

Locked Decisions (Brainstorm Phase)

Decision Log

Settled choices from the brainstorm. Don’t relitigate without reason.

#TopicDecision
1Native depthInstallable PWA — same React codebase, mobile-first; Add-to-Home-Screen, fullscreen, splash, theme color, smooth gestures, offline shell. No Capacitor. No native rewrite.
2Hot surfacesDay plan (/standup), Dashboard (/), Timesheets (/timesheets), Money (Invoices + Budget). Tasks/Kanban explicitly NOT a hot surface.
3Target deviceiPhone only. iOS Safari focus. iOS conventions: safe-area-inset, status-bar tint, large-title pattern.
4Offline scopeShell only. Service worker precaches build assets; no data caching, no mutation queue.
5Visual styleLevandor-branded mobile (copper/warm-black). Layout/gestures iOS-native; visual identity stays Levandor. See levandor-brand.
6Cold-start screenDashboard. Home tab is the first tab and the default route.
7Bottom tabs (5)Home · Plan · Hours · Money · More
8Day-planner shapeNow-focused hybrid. Hero “Now” card → Up Next → Later today → collapsed Done today.
9Timesheet shapeDay-first. Week strip on top, tap a day to switch, totals card, project rows.
10StrategyMobile-first rebuild of hot surfaces. New components in web/src/mobile/.

Spec & Implementation Plan

  • Spec — docs/superpowers/specs/2026-05-09-mobile-native-feel-design.md (post-pre-mortem-patched)
  • Pre-mortem (4-judge council, WARN×4, 46 findings) folded into spec body
  • Plan executed end-to-end through Phase 10 + multiple audit/polish/brand passes