Version: v4.4.5 · Last updated: 2026-08-04
Settings — mobile IA (hub + sub-pages)
Below the lg breakpoint, /settings renders as a hub of priority tiles that push into sub-pages, replacing the icon-only tab strip that used to overflow-scroll on a 390px viewport. At lg and up, /settings is unchanged — same tab strip, same multi-column panels, byte-for-byte identical to before this redesign. This page documents the mobile IA, the ?tab= slug map it's built on, and the decisions a future contributor would otherwise re-litigate. For the desktop tabbed interface (Profile / Personalization / Supporter / Account / Privacy / Feedback tabs) and the shared form fields, avatar upload, and username validation behind both surfaces, see User Profile & Settings.
Shipped as v4.28 — Mobile Settings & Coaching Refresh, five phases, frontend-only, no backend/GraphQL changes, no PostHog flag (direct to master).
Everything is one route
There is exactly one route, /settings, and the mobile hub/sub-page model does not add a second one. A sub-page is a ?tab=<slug> state of that same route, rendered by ionic_frontend/src/views/Settings.vue. Tapping a hub tile calls router.push({ path: '/settings', query: { tab: slug } }); the sub-page's back control calls router.replace({ path: '/settings' }). No new routes were added, and per the milestone's fidelity contract, none may be — see router/index.ts's existing /settings entry.
Hub → sub-page model
Hub (views/settings/SettingsHub.vue, mounted when no ?tab= is present, lg:hidden):
- Profile hero — brand-gradient card with avatar, name, handle/email, rank + XP — tapping it opens the Profile sub-page. Hidden while a search query is active.
- A live search field ("Search settings") that filters settings destinations as you type and swaps the hub body for a ranked results list — see Settings search below.
- A 2×2 (or 2+1) grid of priority tiles: Coaching, Notifications (only when the
critical-path-full-productflag is on), Privacy & data, Supporter. - An optional "Quick toggles" card (daily reminder, haptics) when applicable.
- An "Everything else" quiet list: App & appearance, Help & feedback, Account.
- Footer: Sign out, and the version/changelog link.
Sub-page shell (views/settings/SettingsSubPage.vue, mounted when ?tab= resolves to a sub-page slug, lg:hidden): an intro line plus the panel content, rendered inside a shared scroll container. The chrome above it — back button and title — is not part of this component; see Pinned header below.
Each sub-page reuses the exact same wired components/state the desktop tab strip uses — nothing was rebuilt from the Design Canvas mocks. Desktop and mobile branches live side by side in the same components via CSS-only hidden lg:block / lg:hidden pairs, so the desktop diff is nil by construction, not by discipline.
The ?tab= slug map
views/settings/slugMap.ts is the single source of truth. It is additive-only — no existing value may stop resolving; a broken deep link is a shipped regression. New hub slugs alias existing legacy panels through SLUG_TO_PANEL; nothing was renamed.
| Slug (used today) | Resolves to panel | Hub-navigable? | Notes |
|---|---|---|---|
profile | profile | ✅ (via hero) | Legacy value, unchanged |
security | security | — | Legacy-only; still resolves, no hub tile |
account | security | ✅ | New alias for the same panel as security |
privacy | privacy | ✅ | Legacy value, unchanged |
personalization | personalization | — | Legacy-only; still resolves, no hub tile |
coaching | personalization | ✅ | New alias — same panel as personalization, restyled as the Coaching accordion on mobile |
supporter | supporter | ✅ | Legacy value, unchanged |
feedback | feedback | — | Legacy-only; reachable via the /feedback redirect, no hub tile |
help | feedback | ✅ | New alias — same panel as feedback, includes Tours when enabled |
tours | tours | — | Legacy-only, gated on featureTourEnabled |
notifications | notifications | ✅ (as a tile, when enabled) | Gated on the critical-path-full-product flag |
appearance | (none — mobile-only) | ✅ | Genuinely new; no legacy panel backs it. Desktop has no equivalent tab. |
Two resolvers do the work (resolveTab picks which desktop panel is active; resolveSlug picks which mobile sub-page is open — only consulted on the mobile branch):
- An unrecognized or absent
?tab=value resolves toprofileon desktop and the hub (nullslug) on mobile. - A value gated behind a disabled feature flag (
tourswithoutfeatureTourEnabled,notificationswithout the reminder flag) resolves exactly like an unrecognized value — never a dead tile, never a broken sub-page. SUBPAGE_METAsupplies the sub-page title and intro copy for every slug, including the legacy-only ones (security,personalization,feedback,tours) — a legacy?tab=bookmark that has no hub tile still gets the same title as its new-hub-slug alias, so the same panel doesn't read as two different destinations depending on which URL got you there.
Redirects that route into this map and must keep resolving:
/feedback→/settings?tab=feedback(router/index.ts)/checkout/cancel→/settings?tab=supporter(router/index.ts)- The profile-completion auth guard (
evaluateProfileCompleteGate,router/index.ts) lands on/settingswith no?tab=— the hub, never a sub-page.
Coverage for the whole table above — every legacy value, every new slug, both redirects, the auth-guard landing, and the flag-off states — is a Vitest suite (tests/unit/settingsSlugMap.spec.ts), not a manual/eyeball check.
Settings search
Live on the hub only (?tab= sub-pages have no search field of their own). Typing filters a hand-authored, client-side index — there is no runtime registry of settings rows to derive it from — down to a ranked results list; an empty query renders today's hub unchanged. Three files carry the whole feature:
ionic_frontend/src/views/settings/settingsSearchIndex.ts— the index itself: oneSettingsSearchEntry(id,label,hint?,keywords?,slug,section, an optionalvisible(ctx)gate) per searchable destination, plus aSETTINGS_SEARCH_INDEXexport that concatenates the hand-authored static entries with tour entries read live fromTOUR_REGISTRY(ionic_frontend/src/composables/tourRegistry.ts) — the tour entries are the one part of the index that's self-maintaining: rename or add a tour in the registry and the index updates with it, no drift risk.ionic_frontend/src/composables/useSettingsSearch.ts— theuseSettingsSearch(query, context, index?)filter/rank composable. Matching is case- and diacritic-insensitive substring matching (fold()), tiered 0–3 (exact label → label-prefix → label-substring → hint/keyword substring), sorted by tier with a stable sort preserving the index's own authoring order as the tiebreak. No minimum query length, no result cap, no debounce — the index is a synchronous in-memory array under 100 entries.ionic_frontend/tests/unit/settingsSearch.spec.ts— coverage plus the drift guard (below).
The SettingsSearchContext shape gates visibility, evaluated per-entry via each entry's optional visible(ctx): reminderEnabled, featureTourEnabled, privacyControlsEnabled, homeWidgetsEnabled, isNativePlatform, isAdmin. SettingsHub.vue builds this context from the same feature-flag refs and Capacitor.isNativePlatform() the hub tiles themselves already gate on — a query never returns a result the user couldn't otherwise reach (a gated flag off, a native-only pricing entry on native, an admin-only entry for a non-admin).
Hub-only quick toggles are deliberately excluded. SettingsHub.vue's "Quick toggles" card (Daily reminder, Haptics) lives on the hub itself, not on one of the 8 ?tab= sub-pages the index targets — those two rows are reachable by search through their sub-page counterparts (notifications.daily-reminder, appearance.haptics) instead of a duplicate hub-only entry.
The maintenance contract: add a row → add an index entry
Adding a row to a mobile settings sub-page requires adding a matching entry to SETTINGS_SEARCH_INDEX, or the drift guard in ionic_frontend/tests/unit/settingsSearch.spec.ts fails CI. The guard is a source scan, not a manual checklist: it reads every file in a fixed SOURCE_FILES list (Settings.vue and its wired sub-page children — ReminderPreferences.vue, WeeklyDigestSettingsSection.vue, PrivacyControlsCard.vue, DataExportCard.vue, ToursSettingsSection.vue, CoachingAccordion.vue, ThemeSelector.vue, StreakMetaphorSelector.vue), and extracts every literal label="…" attribute on a SettingsRow* element, then fails if any extracted label has no SETTINGS_SEARCH_INDEX entry with that exact label. SettingsGroup/CoachingAccordionSection tags are tracked by the same regex (they open a "current tag" the scan uses to decide whether a following label belongs to a row) but their own labels are never required — a group header is not an individual control. A dynamic :label="…" binding is exempt by construction — the guard's regex only matches the literal attribute form. SettingsHub.vue itself is deliberately not scanned, matching the hub-only-toggle exclusion above.
So: add a new SettingsRow* to any file in that list, and forget the matching index entry, and settingsSearch.spec.ts fails with an "unindexed SettingsRow* labels found" message naming the exact label — not a silent gap that only surfaces when a user searches for a row that never appears.
Mobile/desktop boundary
Everything mobile-specific is gated on Tailwind's lg:hidden / hidden lg:block — there is no matchMedia/useMediaQuery composable in this codebase, and this redesign didn't introduce one. At lg and up, Settings.vue renders today's header + 8-tab strip (icon + label) and multi-column panel layout exactly as before; the hub and sub-page shell components never mount there.
Coaching accordion (mobile ?tab=coaching/personalization)
On mobile, the Coaching sub-page (components/settings/CoachingAccordion.vue) replaces the desktop's stacked cards with a one-at-a-time accordion: an identity card (Coach persona + tagline), a horizontally-scrollable glance strip, then four sections that open exclusively — opening one closes any other:
- How
<Persona>works — persona detail card. - Voice & focus — read-and-route rows (tone/depth, focus areas, check-in rhythm) that navigate to
/meet-coachrather than embedding inline editors; every displayed value still resolves live from the same tone/depth/frequency/focus-area label tables the desktop view uses. - Personality type — the Enneagram result card and history, or the start-assessment prompt.
- Goal style — the goal motivation snapshot.
No coaching content changed (persona copy, Enneagram scoring, goal-style questions) — this was a re-layout only. EnneagramResultCard and GoalMotivationSnapshotCard gained a chrome="none" prop so their existing card shells can be suppressed inside an accordion body instead of duplicating markup; their desktop rendering is unchanged.
Pinned header and motion contract
The sub-page header (back button + title) is a flex sibling of ion-content at the ion-page level in Settings.vue — not part of SettingsSubPage.vue, and not inside the same <Transition> as the hub header. This means:
- The header stays on screen through any amount of scroll on a sub-page (it does not scroll away with the content, unlike the shell's first shipped version).
- Swapping between the hub header (
AppHeader) and the sub-page header is an opacity-only crossfade, 160ms,cubic-bezier(0, 0, .2, 1)— never a transform/slide.prefers-reduced-motion: reducecollapses this to an instant swap. - The sub-page body itself does not animate on push/pop — a settings screen is not a place to spend time on choreography.
- Focus contract: pushing into a sub-page moves focus to that sub-page's
<h1>(not the back button — the user chose "Account", they should hear "Account"); popping back to the hub returns focus to the tile/row that opened it.
This was a deliberate ruling (not a default): the header used to live inside the same <Transition> as the sub-page body, which meant the back affordance could scroll out of reach on a long sub-page (Account, Privacy & data, the Coaching accordion) with no iOS swipe-back available (?tab= is a query change on the same route, not a distinct routable page). Making the body itself genuinely slide with the header would require moving the panel content back inside a shared transition — deliberately not done; a real push/pop for the sub-page body is an explicit backlog item, not part of this milestone.
Locked decisions
These were decided once, for the whole milestone, and should not be re-opened without a new ruling:
- Themes, dashboard mode, and the widget row live under App & appearance (
?tab=appearance), not Supporter. Supporter is entitlements-only (badge, plan, billing) and links across to App & appearance for anything visual. Exactly oneThemeSelectorinstance exists app-wide. - No new routes, ever. Every sub-page is a
?tab=state of/settings. - The slug map is additive-only. A rename is the failure mode that breaks a bookmark or an external deep link; new slugs alias existing panels instead.
- The native Supporter gate survives verbatim.
Capacitor.isNativePlatform()gatesPLANS_QUERY(enabled: !isNativePlatform); on native, all pricing/purchasing comes from the store (useInAppPurchases), never the backend's Stripe plans — required by App Store Guideline 3.1.1. A native build with no resolved store packages shows an honest "Plans aren't loading right now" state rather than falling back to web pricing. - Export state survives hub ↔ sub-page navigation. The Privacy panel stays
v-show, notv-if-remounted, specifically so an in-flight data export isn't lost when the user navigates away and back. - Consumer-facing copy says "Coach," never "AI."
Known deferred items
One control in the mocks has no real feature behind it yet. It ships visibly disabled — present, greyed, honest aria-label, never hidden and never a fake control that looks interactive:
- Streak metaphor picker (App & appearance) — only Stones and the supporter-gated Flames metaphor are real; Sprout/Mountain/Waves render disabled. OBJ-2037 tracks adding the remaining metaphors.
The hub's search pill was the other deferred control at initial ship — OBJ-2009 replaced it with the live Settings search documented above.
Linked from the milestone anchor, OBJ-1993.
The export-state live authenticated walk (start a real export, navigate hub ↔ sub-page, confirm it survives) is machine-proven by tests/unit/views/SettingsPanelPersistence.spec.ts but has never been performed against a live backend + Clerk session — every agent that has tried hit an environment wall (no rails_api/config/master.key provisioned). Re-deferred as OBJ-2076, owner: whoever next has real backend + Clerk access in their runtime.
Accessibility
- Hub tiles and "Everything else" rows are real
<button>s with accessible names — not clickabledivs. - The Coaching accordion uses
aria-expanded/aria-controls, one section open at a time, roving keyboard navigation between headers, and:inert(not a bare:inert="boolean"— see below) on closed panel content so it leaves the tab order without unmounting. - Every sub-page renders its title as
<h1 tabindex="-1">inside the header toolbar — sub-pages previously started their heading hierarchy at<h2>with no<h1>above it, which fails axe'spage-has-heading-onecheck. - Only one
role="banner"landmark exists at a time — the sub-page header carries no landmark role of its own, sinceAppHeaderalready owns it during the crossfade. :inert="!expr"is a footgun — Vue renders the literal stringinert="false"for a bound falsy value, and per the HTML spec any value of theinertattribute (including the string"false") makes the element inert. The correct pattern, used throughout this surface, is:inert="open ? undefined : true"—undefinedremoves the attribute entirely instead of stringifying it.- 44px (
h-11)/48px (h-12) minimum touch targets throughout; disabled affordances are expressed with a muted label + hollow control +aria-disabled+ a one-line reason, never bareopacity-*(which drops contrast below AA). prefers-reduced-motion: reduceis honored on the header crossfade; the sub-page body has no motion to reduce.- Hub ↔ sub-page and the Coaching accordion are covered by a blocking jest-axe suite (
tests/unit/views/settings/a11y.spec.ts), run in CI's requireda11yjob — not a Storybook/manual spot-check. - Settings search results announce via a debounced (300ms)
role="status" aria-live="polite"region (SettingsSearchResults.vue) — one announcement per typing burst, not one per keystroke — so a non-sighted user hears the match count without the field's own value being re-read.
Related pages
- User Profile & Settings — the desktop tabbed interface and the form fields/mutations shared by both surfaces.
- Data Export — the export flow behind the Privacy & data sub-page.
- Enneagram Assessment — the assessment behind the Coaching accordion's "Personality type" section.
- Meet Coach — the wizard the "Voice & focus" section routes to.
- Supporter Tier — billing/entitlements behind the Supporter sub-page.