Skip to content

UX Overhaul — Phase C Design Spec (Consumer Tabs Shell + Empty/Error Maturity + Component Library Polish)

UX Overhaul — Phase C Design Spec (Consumer Tabs Shell + Empty/Error Maturity + Component Library Polish)

Section titled “UX Overhaul — Phase C Design Spec (Consumer Tabs Shell + Empty/Error Maturity + Component Library Polish)”

Status: Draft — ready for brainstorm review Created: 2026-04-20 Depends on: Phase A shipped (hero flow + SOS dispatch flow + credentials read surface) Related docs:

  • plans/ideony-ux-overhaul-plan.md (master plan, Phase C row)
  • plans/specs/2026-04-19-ux-phase-a-design.md (style continuity)
  • .impeccable.md (hard bans, brand locks, canonical constraints)
  • packages/design-tokens/src/index.ts (current token surface)

Phase C hardens the consumer tabs shell (Home · Search · Bookings · SOS · Profile) around shared chrome — nav bar, headers, empty states, error states — so every screen feels part of the same app. It ships a mature component library (NativeWind-migrated, dark-mode-ready, a11y-complete) and closes the design-token gaps that Phase A deferred (elevation names, motion curves, blur, extended radius+z-index). The user-visible win: no screen ever shows a blank list or a stack trace again; every boundary has a Sole-branded moment.

After Phase C a consumer opens the app and notices:

  1. Tab bar feels alive — active indicator glides, icons swap weight on focus, SOS tab pulses quietly when it’s the primary exit path, haptics on every tab press.
  2. Every empty screen (no bookings, no chat, no portfolio, no results) shows a warm Gambarino headline + illustrated moment + single clear CTA — not a grey void.
  3. Every error screen (offline, 500, 429, 403, stale session, denied location) shows a plain-Italian explanation + retry button + honest next step — no “Something went wrong” dead end.
  4. Dark mode works for the whole consumer flow, not just hero screens (SOS stays dark regardless).
  5. Keyboard, screen-reader, and reduced-motion users get first-class treatment: every pressable has label+role+hint, focus rings visible, motion respects prefersReducedMotion.

Current: TabBar.tsx — 5 tabs, Reanimated indicator shared value, haptic Light on press. StyleSheet-based, iOS safe-area hardcoded paddingBottom: 20.

Phase C upgrades:

ConcernSpec
Layout5 equal tabs; center tab (SOS) raised by -10px with siren icon + olive/terracotta circular halo (32 px) at rest, terracotta fill when focused. No FAB — SOS is a real tab, not a floating button.
Focus stateIcon weight regular → duotone (Phosphor), label color textTertiary → primary, subtle 200 ms spring on indicator bar (4 px pill 24 px wide) gliding to new tab.
Rest stateLabel 10 px Switzer-Semibold, icon 24 px. Inactive color neutral.600.
PressHaptic Light, 120 ms scale 0.96 → 1 spring on tab.
SOS tab pulseWhen user is in a category Home marked “emergency” (triage returned sos_* urgency within last 10 min) the SOS icon halo slowly pulses (olive 1 → 1.06 over 1.4 s, infinite, respects reduced-motion).
Safe areaUse useSafeAreaInsets().bottom — not hard-coded. Wrap contents in SafeAreaView edges={['bottom']}.
AccessibilityaccessibilityRole="tablist" on container, accessibilityRole="tab" + accessibilityState={{selected}} per tab, accessibilityHint="Apre la sezione X" per tab.
Tab-switch transitionNone beyond indicator glide. Tab content uses the route stack’s default crossfade (Expo Router). No shared-element transitions (they fight perceived speed). Explicitly not Lottie-driven — would break keyboard nav + adds 40 KB per icon.

Two header variants live in the shell. Move both to apps/mobile/components/chrome/:

Header (consumer tabs default) — exists. Keep logo + avatar + bell. Additions:

  • Unread badge on bell: 8 px olive dot, accessibilityLabel="Hai N notifiche non lette"
  • Pressable logo → always routes to Home (matches Airbnb pattern)
  • Scroll-shrink: on scroll > 8 px, header shrinks from 56 px → 44 px, logo fades 100% → 70%, border-bottom appears (1 px neutral.100)

SectionHeader (new, for detail screens within tabs) — replaces inline title rows currently duplicated across bookings.tsx, chat/[bookingId].tsx, settings.tsx, etc.

  • Back arrow (circle, rgba(253,248,239,0.9) bg, backdrop-blur 8 px)
  • Title (Gambarino 18 px, centered or left-aligned per screen)
  • Optional right action slot (ReactNode)
  • Optional subtitle row (Switzer 12 px, neutral.600)

Every top-level screen wraps in <SafeAreaView edges={['top']}> (or ['top','bottom'] for fullscreen like SOS tracking). Stop using paddingTop: Platform.OS === 'ios' ? 44 : 0 literals — ban this pattern in the review checklist.

Decision: no custom transition. Expo Router’s default crossfade + Reanimated indicator glide is enough. Rationale: Airbnb, Uber, Linear all use plain fade on tab switch. Shared-element adds jank on Android, Lottie adds bundle weight. Revisit post-launch if analytics show tab thrash.

Generic EmptyState component exists. Only 5 of ~51 screens wire it. Phase C ships 8 purpose-built empty states under apps/mobile/components/empty/, each a thin wrapper around EmptyState with screen-specific copy+illustration+CTA.

Illustration source decision: curated open-source pack (Storyset + Humaaans, recolored to Sole palette via inline SVG fill). Rationale: hiring an illustrator unresolved (open decision in master plan #1); Storyset Italian collection ships 40+ trade-relevant scenes free. Custom illustrator work can replace these 1-for-1 post-seed without touching the empty-state plumbing.

#ScreenKeyIllustration (Storyset slug)Headline ITHeadline ENBody ITBody ENCTA ITCTA ENCTA action
1Home (first open, no search yet)home-no-search-yetstoryset/hand-taking-notes-bro recolored terracotta”Descrivi il tuo primo problema.""Describe your first job.""Ti mostriamo i pro giusti in zona in 30 secondi.""We’ll match you to vetted pros in 30 seconds.""Inizia""Get started”Focus prompt input
2Results (no matches)results-no-matchesstoryset/search-engines-rafiki olive”Ancora nessuno in zona.""Nobody nearby yet.""Nessun pro disponibile ora. Attiva SOS o allarga il raggio.""No pro available now. Activate SOS or widen the area.""Attiva SOS""Activate SOS”Route to /sos
3Bookings (none yet)bookings-emptyKeep CalendarBlank duotone icon (already shipped, works well)“Nessuna prenotazione.""No bookings yet.""Le tue richieste e appuntamenti appaiono qui.""Your requests and appointments show up here.""Trova un pro""Find a pro”Route to /
4Chat (no messages after booking)chat-emptystoryset/messages-rafiki amber”Di’ ciao a {proName}.""Say hi to {proName}.""Condividi dettagli, foto o l’indirizzo. La chat è in-app finché il lavoro non è chiuso.""Share details, photos, or the address. Chat stays in-app until the job closes.""Invia prima messaggio""Send first message”Focus input
5Portfolio (pro, no items)portfolio-emptystoryset/camera-rafiki terracotta”Il portfolio racconta il tuo mestiere.""Your portfolio tells your craft.""Aggiungi 3–5 foto di lavori recenti per aumentare la fiducia.""Add 3–5 photos of recent work to boost trust.""Aggiungi foto""Add photo”Open image picker
6Reviews (pro, no reviews)reviews-emptystoryset/feedback-rafiki olive”Le recensioni arrivano presto.""Reviews land soon.""I consumatori lasciano una recensione dopo ogni lavoro completato.""Consumers leave a review after every completed job.”(none)(none)
7Notifications (none)notifications-emptystoryset/notify-bro neutral”Tutto tranquillo.""All quiet.""Ti avvisiamo qui quando un pro risponde o una prenotazione cambia.""We’ll ping you here when a pro replies or a booking changes.”(none)(none)
8Search history (no recent searches)search-no-recentstoryset/search-rafiki cream”Nessuna ricerca recente.""No recent searches.""Prova una categoria popolare per iniziare.""Try a popular category to start.""Mostra categorie""Show categories”Scroll to categories
  • Illustration 160 × 160 px, centered
  • Headline Gambarino 22 px, neutral.900, letterSpacing: -0.3
  • Body Switzer 15 px, neutral.600, lineHeight: 22, maxWidth: 280
  • CTA terracotta fill, borderRadius.md (12 px), Switzer-Semibold 15 px, min-height 44 px (tap target)
  • Respect reduced-motion: no illustration float animation if OS flag set
  • All strings live in apps/mobile/locales/{it,en}/empty.json

Generic ErrorState exists but only takes message + onRetry. Phase C extends it to 6 named variants under apps/mobile/components/error/, each with an icon, retry behavior, telemetry event, and analytics breadcrumb.

#VariantTriggerIconHeadline ITBody ITRetryTelemetry event
1NetworkOfflinenavigator.onLine === false OR fetch fails NetworkErrorWifiSlash (duotone, olive)“Sei offline.""Controlla la connessione. Riproveremo da soli quando torni online.”Auto-retry on online event; manual “Riprova” buttonerror.network_offline.shown
2ServerErrorHTTP 5xxCloudWarning (duotone, terracotta)“Qualcosa sui nostri server.""Non è colpa tua. Ci stiamo lavorando.”Manual retry; exponential backoff 1s/3s/7serror.server_5xx.shown w/ {status, route}
3RateLimitedHTTP 429HourglassHigh (duotone, amber)“Troppo in fretta.""Aspetta {seconds}s e riprova.”Countdown disabled button, auto-enables at 0error.rate_limited.shown w/ {retryAfter}
4ForbiddenHTTP 403Lock (duotone, neutral.600)“Accesso negato.""Questa azione non è disponibile con il tuo account.”No retry — “Contatta supporto” link to emailerror.forbidden.shown w/ {route}
5StaleSessionHTTP 401 w/ session_expired codeClockClockwise (duotone, terracotta)“Sessione scaduta.""Rientra per continuare.""Accedi di nuovo” button → routes to /(welcome)/continueerror.session_stale.shown
6GeolocationDeniedexpo-location status deniedMapPinSlash (duotone, olive)“Serve la posizione.""Per trovare pro vicini devi attivare la posizione nelle impostazioni.""Apri impostazioni” → Linking.openSettings()error.geolocation_denied.shown
  • Icon 48 px duotone, wrapped in 72 px circle w/ tinted bg (sos.50 / primary.50 / accent.50 per variant)
  • Headline Switzer-Bold 18 px, neutral.900
  • Body Switzer-Regular 15 px, neutral.600, maxWidth: 300
  • Retry button shipped inline except variants 4+6 (which do openSettings/mailto)
  • All variants log via Sentry.captureMessage in dev, Sentry breadcrumb in prod
  • Telemetry events dispatched via existing analytics helper (wire to PostHog when phase ships)

4.1 StyleSheet → NativeWind migration list

Section titled “4.1 StyleSheet → NativeWind migration list”

37 components use StyleSheet.create. Phase C migrates the chrome + empty + error + nav-adjacent ones. The rest migrate opportunistically in Phase D.

Must migrate in C (12 files):

  • chrome/TabBar.tsx
  • chrome/Header.tsx
  • EmptyState.tsx
  • ErrorState.tsx
  • empty/BookingsEmpty.tsx + 7 new empty variants (built as NativeWind from day 1)
  • ui/Button.tsx, ui/Badge.tsx, ui/Avatar.tsx, ui/Input.tsx, ui/StarRating.tsx
  • PaginationDots.tsx, SkeletonLoader.tsx, VerifiedBadge.tsx

Deferred to Phase D (25 files): sos/, triage/, results/, professional/, home/, welcome/, booking/, credentials/, admin/*.

Audit findings: useColorScheme() imported in 1 component (TabBar), but colors import (@/lib/colors) is a flat map that doesn’t branch by scheme. Phase C:

  1. Rewrite lib/colors.ts to return scheme-aware palette via useColorScheme hook; SSR/initial render defaults to light.
  2. Components receiving palette via hook, not module-level import:
    • Chrome: TabBar, Header, SectionHeader
    • UI primitives: Button, Badge, Avatar, Input, StarRating
    • State: EmptyState, ErrorState
    • Cards: ProCard, QuoteCard, ProfessionalCard, VerifiedBadge
  3. Dark palette values (draft, refine in milestone):
    • background: neutral.950 (#1A1008) — warm ink, not pure black
    • surface: neutral.900 (#2B1E10)
    • text: neutral.50 (#FAF6EE)
    • textSecondary: neutral.300
    • primary: primary.400 (#D48C5F) — brighter terracotta for contrast
    • olive: secondary.400 (#96AA57)
    • SOS red stays #E5484D — red reads correctly on dark too
  4. SOS screens pin colorScheme='dark' regardless of OS setting (blueprint rule).

Coverage: 134 accessibility attrs across 30 files, but distribution uneven. Gaps to fix:

ComponentGapFix
chrome/Header.tsx avatarMissing accessibilityRole, no hintAdd role="button" + hint “Apre il profilo”
ui/Button.tsxaccessibilityState={{disabled}} missingWire from disabled prop
home/PromptCard.tsxMultiline input no labelaccessibilityLabel from t('home.prompt_label')
sos/SOSButton.tsxHint missing (critical — panic UX)Add “Invia richiesta di emergenza immediata”
results/ProCard.tsxPressable card no composite labelConcatenate name + price + rating into single label
VerifiedBadge.tsxDecorative but readable as noiseSet accessibilityElementsHidden when inside a labelled card
All new empty statesN/A (built fresh w/ a11y)Include label + role + hint on CTA
Tab barContainer needs role="tablist"Phase C tab shell upgrade covers

WCAG AA contrast sweep: run axe-playwright over preview screenshots for every tab + every empty + every error variant; fix any pair below 4.5:1. SOS flow targets AAA (7:1).

Current token surface (packages/design-tokens/src/index.ts): colors, fontFamily, fontSize, lineHeight, letterSpacing, spacing (8 pt), borderRadius (xs/sm/md/lg/xl/2xl/full), shadows (sm/md/lg/sos), animation (spring/bouncy/gentle + duration fast/normal/slow), zIndex (base/card/sticky/overlay/modal/toast).

Gaps to close in Phase C:

GapAddValues
Semantic elevation nameselevationflat, raised (=sm), floating (=md), overlay (=lg), modal (new, deeper), sos (keep)
Extended radiusborderRadius.xl2 → rename existing 2xl, add xl3 (48 px for hero blocks)12/16/24/32/48
Motion easing curveseasingstandard: Easing.bezier(0.2, 0, 0, 1), decelerate: Easing.bezier(0, 0, 0, 1), accelerate: Easing.bezier(0.3, 0, 1, 1), emphasized: Easing.bezier(0.2, 0, 0, 1)
Motion durations (extended)animation.duration.instant: 80, xslow: 800Phase C animations use instant (haptic-paired) + xslow (map camera fly)
Blurblursm: 4, md: 8, lg: 16, xl: 24 — iOS BlurView intensity, Android fallback semi-opaque neutral.50 / 0.85
Z-index additionszIndex.nav: 15, zIndex.sos: 45 (above modal), zIndex.toast: 55 (bump existing)SOS banner must float over modals
Dark palette scalarsemanticColors.dark.*Mirror of light set, driven by useColorScheme
Focus ringfocus{ color: primary.400, width: 2, offset: 2 }
Pressable opacitypressable{ rest: 1, pressed: 0.82, disabled: 0.5 }

All new tokens land in packages/design-tokens/src/index.ts w/ JSDoc + exported const. Mirror-exported to NativeWind tailwind.config.js theme extension so classnames (rounded-xl3, shadow-overlay, duration-xslow) work.

All 1–3 days. All standalone PR-shippable. Dependency order strict.

M-C1 — Design token expansion + dark palette scaffold (1 day)

Section titled “M-C1 — Design token expansion + dark palette scaffold (1 day)”
  • Add elevation/easing/blur/focus/pressable tokens to packages/design-tokens
  • Add semanticColors light + dark maps
  • Wire lib/colors.ts to useColorScheme
  • Export tokens to NativeWind tailwind.config.js
  • Tests: token snapshot + useColors() hook unit test
  • Exit criteria: pnpm build in packages/design-tokens green; NativeWind classes shadow-overlay, rounded-xl3, duration-xslow available; existing light theme unchanged visually (Lost Pixel diff < 0.5 %).

M-C2 — TabBar + Header chrome rewrite (NativeWind, dark, a11y) (2 days)

Section titled “M-C2 — TabBar + Header chrome rewrite (NativeWind, dark, a11y) (2 days)”
  • Rewrite TabBar.tsx on NativeWind, scheme-aware, new SOS-tab halo, safe-area via inset hook, tablist a11y
  • Rewrite Header.tsx scroll-shrink + unread badge + logo-home route
  • New SectionHeader.tsx replacing duplicated title rows across 4 screens (bookings, chat, settings, portfolio)
  • Tests: snapshot (light+dark), haptic called on press, tab-switch a11y announce
  • Exit criteria: all 5 tabs render in both themes; reduced-motion honored; axe-playwright zero violations on tab shell.

M-C3 — EmptyState/ErrorState core extensions (1 day)

Section titled “M-C3 — EmptyState/ErrorState core extensions (1 day)”
  • Extend EmptyState.tsx w/ illustration prop (SVG/Image), migrate to NativeWind
  • Extend ErrorState.tsx → split into ErrorState (base) + 6 variant wrappers under components/error/
  • Wire telemetry event dispatch + Sentry breadcrumbs
  • Tests: variant snapshot, retry callback, telemetry dispatch, countdown behaviour for RateLimited
  • Exit criteria: all 6 error variants rendered in Storybook tour; manual QA offline/401/429/500 round-trip.

M-C4 — 8 empty-state screens wired (2 days)

Section titled “M-C4 — 8 empty-state screens wired (2 days)”
  • Build 8 variant wrappers in components/empty/
  • Curate + recolor 7 Storyset SVGs to Sole palette; commit under apps/mobile/assets/illustrations/empty/
  • Wire each to its screen (Home first-open, Results no-matches, Chat empty, Portfolio empty, Reviews empty, Notifications empty, Search no-recent — plus keep existing Bookings)
  • Copy to locales/{it,en}/empty.json
  • Tests: each variant renders + CTA routes correctly
  • Exit criteria: manual flow through every tab hits all 8 empty states at least once; Lost Pixel baselines captured.

M-C5 — Error-state screen wiring + network layer integration (1 day)

Section titled “M-C5 — Error-state screen wiring + network layer integration (1 day)”
  • Hook ErrorState variants into useApiClient + React Query onError callbacks
  • Offline detection via @react-native-community/netinfo
  • Stale-session interceptor → routes to /(welcome)/continue
  • 429 retry-after header parsing → RateLimited countdown
  • Tests: mock 401/403/429/500/offline responses, assert correct variant rendered
  • Exit criteria: simulate 5 error types in dev — each shows correct variant; zero uncaught promise rejections in Sentry.

M-C6 — UI primitives NativeWind + dark migration (2 days)

Section titled “M-C6 — UI primitives NativeWind + dark migration (2 days)”
  • Migrate ui/Button, ui/Badge, ui/Avatar, ui/Input, ui/StarRating, VerifiedBadge, PaginationDots, SkeletonLoader off StyleSheet
  • Each: scheme-aware, a11y complete, focus-ring token, pressable opacity token
  • Tests: snapshot light+dark, a11y pass
  • Exit criteria: zero StyleSheet.create calls remaining in components/ui/ + listed primitives; bundle size delta ≤ +2 KB.

M-C7 — Accessibility completion sweep (1 day)

Section titled “M-C7 — Accessibility completion sweep (1 day)”
  • Fix 8 gaps listed in §4.3
  • Run axe-playwright against every preview screenshot; fix contrast pairs < 4.5:1
  • SOS flow AAA sweep (7:1) — separate pass, document pairs that meet/miss
  • Tests: add a11y assertions to existing preview tour
  • Exit criteria: axe zero violations on preview tour; SOS screens logged at AAA w/ 100% of text pairs ≥ 7:1 (or justified exceptions).

M-C8 — Preview tour coverage + baselines (1 day)

Section titled “M-C8 — Preview tour coverage + baselines (1 day)”
  • Extend Playwright tour to visit: every tab, every empty variant (seed MSW fixture for each), every error variant (intercept fetch w/ MSW handler)
  • Capture Lost Pixel baselines on main merge of this PR
  • Document in plans/preview-coverage.md how to add new empty/error cases
  • Exit criteria: tour produces ~25 new screenshots (8 empty + 6 error + 5 tabs light + 5 tabs dark + 1 SOS AAA); CI report surfaces diffs on subsequent PRs.

Total: ~11 working days, 8 PRs, each independently mergeable.

  1. SOS as 5th tab vs. floating FAB — Phase A mobile screens spec referenced a FAB at -24 px over nav ((consumer)/index.tsx current home). Current (consumer)/_layout.tsx renders SOS as real tab w/ Siren icon. Phase C needs to lock one. Recommendation: real tab (current layout). Rationale: FAB breaks layout math on Web, adds z-index complexity, and the SOS trigger on Home screen already covers the “I’m on home, emergency hit” case. Needs user sign-off.
  2. Dark mode auto-switch vs. in-app toggle vs. both — Settings screen already has toggle wiring (per recent 6c8114f commit). OS-driven on by default? User-override persisted? Recommendation: both, OS-default w/ manual override persisted in AsyncStorage.
  3. Storyset vs. Humaaans vs. custom illustrator — master plan decision #1 still unresolved. Phase C proposes Storyset-recolored as placeholder; any change post-seed replaces assets 1:1 without plumbing churn. Needs user sign-off on “ship Storyset for now.”
  4. Error-state telemetry destination — PostHog confirmed but event namespace (error.* vs ui.error.*) not locked. Needs user decision.
  5. Tab-bar SOS pulse trigger — “Pulse while urgency=sos_*” — is 10 min the right window? Should it also pulse if a user opens app after an SOS was active in the last hour? Low-stakes tuning decision; default to 10 min, revisit with analytics.

Copied from master plan §Brand Decisions and .impeccable.md §Hard Bans. Every Phase C component must NOT:

  • Apply side-stripe borders > 1 px (border-left: 4px solid)
  • Use gradient text (background-clip: text)
  • Use generic glassmorphism (frosted blur w/ white tint)
  • Use stock purple → blue gradients
  • Use “big number + small label” hero metric decoration
  • Use pure #000000 or #FFFFFF — always tint toward Sole hue (min neutral.50 / neutral.950)
  • Use reflex-default fonts (no Inter, no Plus Jakarta Sans, no SF Pro defaults — Gambarino + Switzer only)

Additionally (Phase-C-specific):

  • No tab-switch shared-element transitions (jank risk)
  • No Lottie in chrome (bundle cost)
  • No empty-state illustration that’s purely decorative AI-art — all illustrations must be curated or custom, recolored to Sole
  • No error message that leaks stack traces or raw server strings — always Italian-first plain language
  • 2026-04-20 — Spec created. All 8 milestones drafted. 5 open decisions flagged. Empty/error catalogs + token gaps + a11y sweep scoped.