Skip to content

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-product flag 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 panelHub-navigable?Notes
profileprofile✅ (via hero)Legacy value, unchanged
securitysecurityLegacy-only; still resolves, no hub tile
accountsecurityNew alias for the same panel as security
privacyprivacyLegacy value, unchanged
personalizationpersonalizationLegacy-only; still resolves, no hub tile
coachingpersonalizationNew alias — same panel as personalization, restyled as the Coaching accordion on mobile
supportersupporterLegacy value, unchanged
feedbackfeedbackLegacy-only; reachable via the /feedback redirect, no hub tile
helpfeedbackNew alias — same panel as feedback, includes Tours when enabled
tourstoursLegacy-only, gated on featureTourEnabled
notificationsnotifications✅ (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 to profile on desktop and the hub (null slug) on mobile.
  • A value gated behind a disabled feature flag (tours without featureTourEnabled, notifications without the reminder flag) resolves exactly like an unrecognized value — never a dead tile, never a broken sub-page.
  • SUBPAGE_META supplies 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 /settings with 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.

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: one SettingsSearchEntry (id, label, hint?, keywords?, slug, section, an optional visible(ctx) gate) per searchable destination, plus a SETTINGS_SEARCH_INDEX export that concatenates the hand-authored static entries with tour entries read live from TOUR_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 — the useSettingsSearch(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:

  1. How <Persona> works — persona detail card.
  2. Voice & focus — read-and-route rows (tone/depth, focus areas, check-in rhythm) that navigate to /meet-coach rather than embedding inline editors; every displayed value still resolves live from the same tone/depth/frequency/focus-area label tables the desktop view uses.
  3. Personality type — the Enneagram result card and history, or the start-assessment prompt.
  4. 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: reduce collapses 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 one ThemeSelector instance 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() gates PLANS_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, not v-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 clickable divs.
  • 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's page-has-heading-one check.
  • Only one role="banner" landmark exists at a time — the sub-page header carries no landmark role of its own, since AppHeader already owns it during the crossfade.
  • :inert="!expr" is a footgun — Vue renders the literal string inert="false" for a bound falsy value, and per the HTML spec any value of the inert attribute (including the string "false") makes the element inert. The correct pattern, used throughout this surface, is :inert="open ? undefined : true"undefined removes 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 bare opacity-* (which drops contrast below AA).
  • prefers-reduced-motion: reduce is 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 required a11y job — 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.
  • 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.

Loading…