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)
Executive Summary
Section titled “Executive Summary”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.
User-Visible Outcome
Section titled “User-Visible Outcome”After Phase C a consumer opens the app and notices:
- 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.
- 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.
- 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.
- Dark mode works for the whole consumer flow, not just hero screens (SOS stays dark regardless).
- Keyboard, screen-reader, and reduced-motion users get first-class treatment: every pressable has label+role+hint, focus rings visible, motion respects
prefersReducedMotion.
1. Tab Shell Design
Section titled “1. Tab Shell Design”1.1 Nav bar (bottom tab)
Section titled “1.1 Nav bar (bottom tab)”Current: TabBar.tsx — 5 tabs, Reanimated indicator shared value, haptic Light on press. StyleSheet-based, iOS safe-area hardcoded paddingBottom: 20.
Phase C upgrades:
| Concern | Spec |
|---|---|
| Layout | 5 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 state | Icon 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 state | Label 10 px Switzer-Semibold, icon 24 px. Inactive color neutral.600. |
| Press | Haptic Light, 120 ms scale 0.96 → 1 spring on tab. |
| SOS tab pulse | When 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 area | Use useSafeAreaInsets().bottom — not hard-coded. Wrap contents in SafeAreaView edges={['bottom']}. |
| Accessibility | accessibilityRole="tablist" on container, accessibilityRole="tab" + accessibilityState={{selected}} per tab, accessibilityHint="Apre la sezione X" per tab. |
| Tab-switch transition | None 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. |
1.2 Header patterns
Section titled “1.2 Header patterns”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)
1.3 Safe-area handling (uniform rule)
Section titled “1.3 Safe-area handling (uniform rule)”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.
1.4 Tab-switch transition decision
Section titled “1.4 Tab-switch transition decision”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.
2. Empty State Catalog
Section titled “2. Empty State Catalog”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.
| # | Screen | Key | Illustration (Storyset slug) | Headline IT | Headline EN | Body IT | Body EN | CTA IT | CTA EN | CTA action |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Home (first open, no search yet) | home-no-search-yet | storyset/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 |
| 2 | Results (no matches) | results-no-matches | storyset/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 |
| 3 | Bookings (none yet) | bookings-empty | Keep 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 / |
| 4 | Chat (no messages after booking) | chat-empty | storyset/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 |
| 5 | Portfolio (pro, no items) | portfolio-empty | storyset/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 |
| 6 | Reviews (pro, no reviews) | reviews-empty | storyset/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) | — |
| 7 | Notifications (none) | notifications-empty | storyset/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) | — |
| 8 | Search history (no recent searches) | search-no-recent | storyset/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 |
Shared empty-state visual rules
Section titled “Shared empty-state visual rules”- 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
3. Error State Catalog
Section titled “3. Error State Catalog”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.
| # | Variant | Trigger | Icon | Headline IT | Body IT | Retry | Telemetry event |
|---|---|---|---|---|---|---|---|
| 1 | NetworkOffline | navigator.onLine === false OR fetch fails NetworkError | WifiSlash (duotone, olive) | “Sei offline." | "Controlla la connessione. Riproveremo da soli quando torni online.” | Auto-retry on online event; manual “Riprova” button | error.network_offline.shown |
| 2 | ServerError | HTTP 5xx | CloudWarning (duotone, terracotta) | “Qualcosa sui nostri server." | "Non è colpa tua. Ci stiamo lavorando.” | Manual retry; exponential backoff 1s/3s/7s | error.server_5xx.shown w/ {status, route} |
| 3 | RateLimited | HTTP 429 | HourglassHigh (duotone, amber) | “Troppo in fretta." | "Aspetta {seconds}s e riprova.” | Countdown disabled button, auto-enables at 0 | error.rate_limited.shown w/ {retryAfter} |
| 4 | Forbidden | HTTP 403 | Lock (duotone, neutral.600) | “Accesso negato." | "Questa azione non è disponibile con il tuo account.” | No retry — “Contatta supporto” link to email | error.forbidden.shown w/ {route} |
| 5 | StaleSession | HTTP 401 w/ session_expired code | ClockClockwise (duotone, terracotta) | “Sessione scaduta." | "Rientra per continuare." | "Accedi di nuovo” button → routes to /(welcome)/continue | error.session_stale.shown |
| 6 | GeolocationDenied | expo-location status denied | MapPinSlash (duotone, olive) | “Serve la posizione." | "Per trovare pro vicini devi attivare la posizione nelle impostazioni." | "Apri impostazioni” → Linking.openSettings() | error.geolocation_denied.shown |
Shared error-state visual rules
Section titled “Shared error-state visual rules”- Icon 48 px duotone, wrapped in 72 px circle w/ tinted bg (
sos.50/primary.50/accent.50per 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.captureMessagein dev, Sentry breadcrumb in prod - Telemetry events dispatched via existing analytics helper (wire to PostHog when phase ships)
4. Component Library Maturity
Section titled “4. Component Library Maturity”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.tsxchrome/Header.tsxEmptyState.tsxErrorState.tsxempty/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.tsxPaginationDots.tsx,SkeletonLoader.tsx,VerifiedBadge.tsx
Deferred to Phase D (25 files): sos/, triage/, results/, professional/, home/, welcome/, booking/, credentials/, admin/*.
4.2 Dark-mode color pass
Section titled “4.2 Dark-mode color pass”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:
- Rewrite
lib/colors.tsto return scheme-aware palette viauseColorSchemehook; SSR/initial render defaults to light. - Components receiving
palettevia hook, not module-level import:- Chrome: TabBar, Header, SectionHeader
- UI primitives: Button, Badge, Avatar, Input, StarRating
- State: EmptyState, ErrorState
- Cards: ProCard, QuoteCard, ProfessionalCard, VerifiedBadge
- Dark palette values (draft, refine in milestone):
background:neutral.950(#1A1008) — warm ink, not pure blacksurface:neutral.900(#2B1E10)text:neutral.50(#FAF6EE)textSecondary:neutral.300primary:primary.400(#D48C5F) — brighter terracotta for contrastolive:secondary.400(#96AA57)- SOS red stays
#E5484D— red reads correctly on dark too
- SOS screens pin
colorScheme='dark'regardless of OS setting (blueprint rule).
4.3 Accessibility gaps
Section titled “4.3 Accessibility gaps”Coverage: 134 accessibility attrs across 30 files, but distribution uneven. Gaps to fix:
| Component | Gap | Fix |
|---|---|---|
chrome/Header.tsx avatar | Missing accessibilityRole, no hint | Add role="button" + hint “Apre il profilo” |
ui/Button.tsx | accessibilityState={{disabled}} missing | Wire from disabled prop |
home/PromptCard.tsx | Multiline input no label | accessibilityLabel from t('home.prompt_label') |
sos/SOSButton.tsx | Hint missing (critical — panic UX) | Add “Invia richiesta di emergenza immediata” |
results/ProCard.tsx | Pressable card no composite label | Concatenate name + price + rating into single label |
VerifiedBadge.tsx | Decorative but readable as noise | Set accessibilityElementsHidden when inside a labelled card |
| All new empty states | N/A (built fresh w/ a11y) | Include label + role + hint on CTA |
| Tab bar | Container 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).
5. Design Token Gaps
Section titled “5. Design Token Gaps”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:
| Gap | Add | Values |
|---|---|---|
| Semantic elevation names | elevation | flat, raised (=sm), floating (=md), overlay (=lg), modal (new, deeper), sos (keep) |
| Extended radius | borderRadius.xl2 → rename existing 2xl, add xl3 (48 px for hero blocks) | 12/16/24/32/48 |
| Motion easing curves | easing | standard: 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: 800 | Phase C animations use instant (haptic-paired) + xslow (map camera fly) |
| Blur | blur | sm: 4, md: 8, lg: 16, xl: 24 — iOS BlurView intensity, Android fallback semi-opaque neutral.50 / 0.85 |
| Z-index additions | zIndex.nav: 15, zIndex.sos: 45 (above modal), zIndex.toast: 55 (bump existing) | SOS banner must float over modals |
| Dark palette scalar | semanticColors.dark.* | Mirror of light set, driven by useColorScheme |
| Focus ring | focus | { color: primary.400, width: 2, offset: 2 } |
| Pressable opacity | pressable | { 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.
6. Milestones
Section titled “6. Milestones”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
semanticColorslight + dark maps - Wire
lib/colors.tstouseColorScheme - Export tokens to NativeWind
tailwind.config.js - Tests: token snapshot +
useColors()hook unit test - Exit criteria:
pnpm buildinpackages/design-tokensgreen; NativeWind classesshadow-overlay,rounded-xl3,duration-xslowavailable; 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.tsxon NativeWind, scheme-aware, new SOS-tab halo, safe-area via inset hook, tablist a11y - Rewrite
Header.tsxscroll-shrink + unread badge + logo-home route - New
SectionHeader.tsxreplacing 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.tsxw/illustrationprop (SVG/Image), migrate to NativeWind - Extend
ErrorState.tsx→ split intoErrorState(base) + 6 variant wrappers undercomponents/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
ErrorStatevariants intouseApiClient+ React QueryonErrorcallbacks - 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,SkeletonLoaderoff StyleSheet - Each: scheme-aware, a11y complete, focus-ring token, pressable opacity token
- Tests: snapshot light+dark, a11y pass
- Exit criteria: zero
StyleSheet.createcalls remaining incomponents/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.mdhow 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.
7. Open Decisions
Section titled “7. Open Decisions”- SOS as 5th tab vs. floating FAB — Phase A mobile screens spec referenced a FAB at -24 px over nav (
(consumer)/index.tsxcurrent home). Current(consumer)/_layout.tsxrenders 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. - Dark mode auto-switch vs. in-app toggle vs. both — Settings screen already has toggle wiring (per recent
6c8114fcommit). OS-driven on by default? User-override persisted? Recommendation: both, OS-default w/ manual override persisted in AsyncStorage. - 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.”
- Error-state telemetry destination — PostHog confirmed but event namespace (
error.*vsui.error.*) not locked. Needs user decision. - 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.
8. Hard Bans — Reconfirm
Section titled “8. Hard Bans — Reconfirm”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
#000000or#FFFFFF— always tint toward Sole hue (minneutral.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
Change Log
Section titled “Change Log”- 2026-04-20 — Spec created. All 8 milestones drafted. 5 open decisions flagged. Empty/error catalogs + token gaps + a11y sweep scoped.