Dashboard & Navigation — Feature & Testing Guide
Overview
The Dashboard is the central hub of Objectuve, designed for quick check-ins (~10 min/day). It adapts its layout based on the user's state — empty for first-timers, sparse for users with 1–2 active goals, and a populated layout for users with 3+ active goals. Navigation uses a bottom tab bar on mobile and a side menu on desktop.
v4.15 — Focus / Standard / Detailed / Auto. The populated layout (Branch D) ships as one hierarchy with two densities, plus the pre-existing card-stack view, selectable from a single 4-option Settings control or, since OBJ-1770, the dashboard's own in-context mode switcher (tap the header's mode chip):
- Focus — the streamlined card-stack view (
StreamlinedDashboard.vue), unchanged from before v4.15. - Standard (default for new users) — a lean primary → secondary → tertiary hierarchy in one centered column; the tertiary layer (week shape, upcoming events, AI insight) is collapsed behind a "More on your week" disclosure.
- Detailed — the same primary/secondary content, with the tertiary layer expanded inline everywhere (a real full-width mobile home, not just a desktop-only rail). Existing
full-mode users are backfilled to Detailed once; see Migration behavior below. - Auto — shows Focus when every habit is checked in today and no goal needs attention; otherwise shows the Standard/Detailed hierarchy (whichever density is persisted).
This ships unconditionally — the dashboard_hierarchy PostHog flag that originally gated it reached 100% rollout and was retired in Phase 5 (OBJ-1747). See Dashboard Hierarchy — Standard & Detailed (v4.15) for the full model.
The current page has been pixel-tuned against the populated-state screenshots in OBJ-64 — Dashboard Overhaul (Phase A) and the first-week empty-state screenshot in Phase B.
Page composition
The Dashboard renders one of four mutually exclusive branches based on activeGoals.length and a few derived flags. All branches live in src/views/Dashboard.vue and share the same header chrome.
| Branch | Condition | What renders |
|---|---|---|
| Loading | userLoading || goalsLoading | Pulsing skeleton tiles + 6 LoadingSkeletonCards |
| Branch A — empty | activeGoals.length === 0 (hasNoGoals) | FirstWeekInvitation (Sage eyebrow + serif-italic-blue-accent headline + body + dual CTA) composing SuggestedPracticeList of three SuggestedPracticeRows — see Branch A — first-week empty state |
| Branch B — first goal | activeGoals.length === 1 && hasNewGoal && !setupDismissed | GettingStartedCard with autoExpand: true |
| Branch C — sparse | activeGoals.length 1–2, not new | DashboardHero (4-tile momentum strip) + CoachDockCard + CriticalPathTile + CheckInPromptCard + TodayActions + (CoachContextCard when urgentGoal) + (PartnerWidget) + GoalsSection (grid layout) + WelcomePrompt — see DashboardSparseState.vue. Out of scope for the v4.15 hierarchy (folding Branch C into it is a flagged follow-up). |
| Branch D — populated | activeGoals.length >= 3 | The Standard/Detailed hierarchy (DashboardHierarchy.vue) |
Branch A (empty) and Branch D (populated, the v4.15 hierarchy) are documented in detail below. Branch C (sparse) gets its own pixel-pass in a subsequent dashboard phase — the contract here describes its current shipped composition only at a high level.
The streamlined "focus mode" view is an orthogonal experience activated through useFocusMode and rendered by StreamlinedDashboard.vue. It replaces the entire Branch A–D tree when resolvedView === 'streamlined'. On session completion, the DoneCard displays four stat chips: habits checked in, mood logged, current streak, and XP earned during this session. The XP chip (calculated as habitsCheckedInSession × 25, sourced from subscribers.rb:57) appears conditional on xpEarned > 0, providing immediate gamification feedback to mobile focus-mode users. Documentation for that surface lives with its phase.
Branch A — first-week empty state
Replaces the prior centered "What do you want to work on?" placeholder card. Renders when activeGoals.length === 0 (hasNoGoals === true).
Visual reference: dashboard-desktop-empty.png (see OBJ-64 issue body). Canonical design contract: ionic_frontend/.planning/phases/dashboard-empty/UI-SPEC.md.
Composition
<div v-if="hasNoGoals"> in Dashboard.vue mounts a single component:
<FirstWeekInvitation
v-if="hasNoGoals"
:user-name="user.firstName || ''"
@create-goal="openGoalModal"
@open-coach="openCoach"
@quick-add="handleQuickAddPractice"
/>| Component | Source | Role |
|---|---|---|
FirstWeekInvitation | src/components/dashboard/FirstWeekInvitation.vue | Hero (eyebrow + headline + body) + dual CTA, mounts SuggestedPracticeList |
SuggestedPracticeList | src/components/dashboard/SuggestedPracticeList.vue | glass-card panel with OR BEGIN WITH A SMALL PRACTICE eyebrow and three rows; owns the static SUGGESTED_PRACTICES constant |
SuggestedPracticeRow | src/components/dashboard/SuggestedPracticeRow.vue | Single full-row <button>: + disc + title + subtitle + chevron-right |
Component contracts
FirstWeekInvitation props + events:
defineProps<{ userName?: string }>() // default: ''
defineEmits<{
(e: 'create-goal'): void
(e: 'open-coach'): void
(e: 'quick-add', payload: { title: string; subtitle: string; slug: string }): void
}>()SuggestedPracticeList re-emits quick-add from any row click. SuggestedPracticeRow props are title, subtitle, slug and emits select on click or Enter keyup.
Event wiring in Dashboard.vue
Three handlers route the events to existing surfaces. None of these handlers were introduced in Phase B besides handleQuickAddPractice; openGoalModal and useAiCoach().open() already shipped in Phase A.
| Event | Handler | Behavior |
|---|---|---|
create-goal | openGoalModal | router.push('/goals/create') |
open-coach | openCoach (alias of useAiCoach().open) | Opens the existing AiCoachModal (mounted globally in App.vue, carries data-testid="ai-coach-modal") |
quick-add | handleQuickAddPractice(payload) | router.push({ path: '/goals/create', query: { title, description: subtitle, kind: 'habit', recurrence: 'daily' } }) |
Prefill follow-up. The
quick-addroute format is shipped, and OBJ-77 landedtitle,kind, andrecurrencequery-param consumption inGoalCreate.vue(alongside the pre-existingcoachPersonaandcategoryhandling). Thedescriptionquery param is still not read — clicking a suggested practice prefills the title, habit type, and daily recurrence, but the description text must still be entered manually.
Microcopy
Every user-facing string is fixed and lives in source. The body greeting templates userName with friend (lowercase) as the fallback when the prop is empty.
| Surface | String |
|---|---|
| Eyebrow | THE SAGE · JUST FOR YOU (text-coach) |
| Headline (regular clause) | A blank week is (Outfit, font-display) |
| Headline (accent clause) | a kind invitation. (font-display italic text-primary — same vocabulary as the header greeting accent) |
| Body | Hello, {{ userName || 'friend' }}. Let's not start with a grand plan. Let's start with one small practice you'd like to keep — and we'll grow from there. |
| Primary CTA | Set your first goal with Lucide Plus icon, bg-gradient-to-r from-primary to-secondary (brand blue gradient #177bbb → #05cbfc) |
| Secondary CTA | Talk to The Sage with Lucide MessageCircle icon, bg-card border border-border/60 |
| Practice card eyebrow | OR BEGIN WITH A SMALL PRACTICE (text-muted-foreground) |
| Practice 1 | A 10-minute morning walk · For energy that shows up later · slug morning-walk |
| Practice 2 | Two pages of writing · For the thoughts that stay otherwise · slug pages-writing |
| Practice 3 | Lights out by 10:30 · For the day after this one · slug lights-out |
The Coach is referred to as The Sage (proper noun) per the in-app naming for the coach persona — never as "AI" in user-facing copy.
Layout + responsive behavior
- Desktop (
md+):flex flex-col items-start gap-5 md:gap-6 max-w-2xl mx-auto py-6 md:py-8. CTAs sit side-by-side. Headline scalestext-3xl md:text-4xl lg:text-5xl. - Mobile (
<md): Same wrapper. CTAs stack vertically full-width. Headline stays attext-3xl. Body usestext-base; desktop bumps tolg:text-lg. - Practice rows: stack vertically at every breakpoint inside the
glass-cardwrapper, dividers between (not above first or below last).
Accessibility
- Headline is
<h2 id="empty-headline">(the page already carries the<h1>greeting in the header chrome). - Both CTAs are real
<button type="button">elements; tab order is primary → secondary → first practice row → second → third. - Each
SuggestedPracticeRowis the button itself, witharia-label="Add practice: {title}". Decorative+disc, chat icon, and chevron all carryaria-hidden="true". - Focus rings: browser default on the CTAs; explicit
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2on the practice rows. - The empty branch inherits the parent
<div aria-live="polite" aria-atomic="false">wrapper fromDashboard.vue, so the surface announces on first render. - Scoped
@media (prefers-reduced-motion: reduce)guards onFirstWeekInvitationandSuggestedPracticeRowdisable hover transforms and transitions.
Tokens used
No new tokens. Reuses text-coach, text-foreground, text-primary, text-muted-foreground, bg-gradient-to-r from-primary to-secondary, bg-card, border-border/60, and glass-card — all shipped in Phase A.
Stories + tests
- Storybook:
FirstWeekInvitation.stories.ts(Default, Mobile, Dark, LongName, NoName),SuggestedPracticeList.stories.ts(Default, Mobile, Dark),SuggestedPracticeRow.stories.ts(Default, Hover, Focus, LongTitle, LongSubtitle). - Vitest specs:
FirstWeekInvitation.spec.ts,SuggestedPracticeList.spec.ts,SuggestedPracticeRow.spec.ts, plus updated branch-A coverage inDashboard.spec.ts. - Cypress: none — the original
tests/e2e/specs/dashboard/empty.cy.tsspec was retired under OBJ-568 (19 stale Cypress specs rescoped to Vitest/Playwright); its assertions were backfilled into Vitest. Coverage for the eyebrow + hero render, CTA emits, and suggested-practice routing now lives in:FirstWeekInvitation.spec.ts— hero render, primary CTA emitscreate-goal, secondary CTA emitsopen-coachSuggestedPracticeList.spec.ts/SuggestedPracticeRow.spec.ts— row render,quick-addpayload emitDashboard.spec.ts(describe('Dashboard.vue — Branch A (no goals)'), lines 285+) — integration wiring:create-goal→router.push('/goals/create'),open-coach→ opens AI coach,quick-add→router.push('/goals/create', { query: {...} })Dashboard.render.spec.ts— rendersFirstWeekInvitationwhen there are no active goals
Shared populated-state components
The components below are used by both Branch C (sparse, DashboardSparseState.vue) and Branch D (populated, the Standard/Detailed hierarchy, DashboardHierarchy.vue) — each renders a subset in its own composition. Branch C's composition is listed at a high level in the Page composition table above; Branch D's full primary/secondary/tertiary composition is documented under Dashboard Hierarchy — Standard & Detailed (v4.15) below.
Header chrome
The <header> row sits above every branch and never re-flows.
| Element | Component / source | Behavior |
|---|---|---|
| Eyebrow date | Dashboard.vue eyebrowDate / eyebrowDateShort computeds | text-xs font-semibold tracking-[0.16em] uppercase text-primary. Mobile (<lg) renders short form THU · APR 24, desktop (lg+) renders long form THURSDAY · APRIL 24. Two spans, one lg:hidden, one hidden lg:inline. |
| Greeting + name accent | Dashboard.vue greeting computed | Good morning/afternoon/evening, followed by <span class="text-primary italic">{firstName}.</span>. Falls back to Friend when firstName is missing. The trailing period sits inside the italic-blue span. |
| Daily quote | useDailyQuote() composable | Italic muted text below the greeting, line-clamp-2. Hidden when showStreamlined === true. Rendered as "<text>" — <author> when dailyQuote.author is present. |
| Dashboard mode chip | inline <button> + DashboardModeSwitcher.vue, showHierarchy only | Reads Standard/Detailed/Auto. Tapping it opens an in-context picker (OBJ-1770) in place — no more navigating to Settings; see In-context mode switcher. Only ever shown on Branch D (the hierarchy) — replaces the streak pill below whenever it renders; the momentum strip's day streak tile is the sole place the streak number renders while the hierarchy is active. |
| Streak pill | inline <button> | !hasNoGoals && !showStreamlined && !showHierarchy — i.e. shown only on Branch C (sparse), since Branch D always shows the mode chip instead. Opens StreakDetailsModal. |
| Focus mode pill | inline <button> | lg: only, conditional on !hasNoGoals && resolvedView !== 'streamlined'. Opens Focus mode via setDashboardMode('focus'). Uses locateOutline icon. Hidden on mobile and on the empty-state dashboard. |
| Notification bell | inline <ion-button> triggering NotificationsPanel | Badge <span> only renders when notificationCount > 0 — at zero the entire badge node is omitted from the DOM, not just visually hidden. Badge is visually rendered red with white border + aria-live="polite". |
| Theme toggle | useTheme() | Round 44×44 icon button (.header-icon-btn). Swaps between sunnyOutline and moonOutline; persists via the composable's setTheme. |
Per-component contracts
DashboardHero
src/components/dashboard/DashboardHero.vue — the momentum strip. Superseded and replaced the standalone MomentumBar.vue component; MomentumBar.vue is now deleted (OBJ-3016, retargeting the UI-SPEC's Concept Legibility explanation grammar onto the live tile per docs/ui-specs/v4.47-concept-affordance-grammar.md §7a), not merely unused on disk.
Two variants, chosen by the honest prop:
- 4-tile (
honest: false— Branch C sparse only):this week x/7·completed {completedCount}/{completedCount + activeCount}·active {activeCount}·total progress {totalProgress}%(aMiniRing). Thetotal progresstile isMath.round((completedGoals.length / goals.length) * 100)— structurally 0% for habit-only users, since habit goals never flipcompleted. This tile is dropped entirely in the hierarchy's honest variant below rather than fixed, per the UI-SPEC's metric decision. - "Honest" 3-tile (
honest: true— hierarchy, Branch D only):this week x/7·day streak·active goals. Dropscompletedandtotal progressentirely; theday streaktile is the only place the streak number renders while the hierarchy is active (the header's streak pill is replaced by the mode chip).
Both variants are a glass-card-style panel using MomentumStat tiles; grid is grid-cols-2 md:grid-cols-4 (4-tile) or grid-cols-3 (3-tile, "honest"). Each tile sets MomentumStat's action prop to one of three states (UI-SPEC §7a.2, replacing the old interactive boolean):
action="modal"— theday streaktile only, in both variants. Raised plinth badge + corner chevron, opens a modal. EmitsstreakClicked, whichDashboard.vuewires to the existinghandleStreakPillClick()— openingStreakDetailsModal(or callingretryStreak()when the streak state isunavailable). No tooltip, no ⓘ marker, in any of the tile's three states — this restores the entry point the v4.15 hierarchy launch briefly dropped (OBJ-1918) — see Known behaviors.action="explain"— every non-streak tile in both variants (this week,active/active goals, and, sparse-only,completed/total progress). Flat tint chip (visually identical toaction="none"at rest) plus a 12px ⓘ hint marker at the tile's top-right corner, wrapped in aTooltipthat opens on tap and carries per-tile microcopy (e.g.total progress's copy states the habit-only 0%-forever case explicitly rather than papering over it). Drops the press transform other buttons use, since the tooltip bubble anchors to the tile itself.action="none"(default, unused on this component today) — inert, non-interactive tile.
The chevron and ⓘ marker are mutually exclusive per tile by construction — one action per tile maps to at most one corner glyph. Every modal/explain button carries min-h-11 so the 44px tap-target floor is assertable by computed dimension, not just a class check.
TodayActions
src/components/dashboard/TodayActions.vue
glass-card panel labeled "Today's practice — Three small things. Pick one to begin."
- Counter —
{checkedInCount} / {habits.length} DONEin muted uppercase tracking. Counter binds tohabits.lengthdirectly so the denominator always matches the rendered row count (a0 / 2denominator can't show next to three rows). - Empty state — when
habits.length === 0, renders a compact CTA card with "Nothing scheduled today. Take the win — or add a habit you've been putting off." and anAdd a habitbutton that emitscheckIn(null). - Practice rows — one row per habit. Each row is a clickable
role="button"that opens the goal detail page (openGoalemit). Rows expose:HabitCompletionRing(44×44, 3px stroke) on the left, filled with a green check whencheckedInToday.- Habit name (
text-base font-semibold text-foreground truncate). - Meta line:
{Xd} · {N min} · {Category}joined with·(per UI-SPEC §6.3). Each part is conditional — streak is omitted whenhabitStreak <= 0, duration is omitted whendurationMinutesis null or0, category is omitted whenhabit.category?.nameis missing. When all three are absent the meta<p>is not rendered at all (thev-else-if="metaLine(habit)"guard handles it). The eight edge cases — every combination of present/absent for the three parts — are exercised by theMetaLineEdgeCasesStorybook story andTodayActions.spec.ts. - 44×44 round Check button on the right with three states: outline-blue idle / primary hover-border /
bg-success border-success text-whitewhencheckedInToday. Spinner replaces the check while theCHECK_IN_HABIT_MUTATIONis in flight.
- Mood prompt row — when
habits.length > 0andhasTodaysMood === false, a mood prompt row is rendered between the habit list and the footer. The row has a primary-tinted mood icon disc on the left, the label "How's today feeling?" and a chevron-right. Clicking the row or pressing Enter/Space emitsopenMoodCheckInwhich opensMoodCheckIn.vuewith no goal preselected, allowing the user to log a general daily mood. When a mood has already been logged (hasTodaysMood === true), the prompt is replaced with a confirmation row showing a success icon and "Mood logged today" in muted text. The mood prompt does NOT count toward theX / Y DONEtally. - AI insight footer — when an
insightprop is non-null, anInsighteyebrow + plain-text body is rendered below the divider. Used as a single quiet line under the practice rows, not a major card.
Rows source from Dashboard.vue's todaysHabits computed:
const todaysHabits = computed(() =>
activeGoals.value.filter((goal: any) => goal.recurrenceType && goal.dueToday)
)The list is fed by GOALS_QUERY (fetchPolicy: 'cache-and-network') — a Goal becomes a Habit when its recurrenceType is set and the backend marks it dueToday. There is no separate "habits" query; Habits are a view on Goals.
CheckInPromptCard
src/components/dashboard/CheckInPromptCard.vue — Branch C (sparse) only. The hierarchy (Branch D) does not render this component standalone; its AI check-in nudge is merged into TodayActions' coach-nudge line instead — see Composition: primary → secondary → tertiary.
glass-card panel for the latest unacknowledged ai_check_in notification. The component takes no props — it reads from useNotifications() directly and finds the first unacknowledged AI prompt.
Composition:
- Coach-purple Sparkles disc (
bg-coach/15 text-coach) on the left. - Eyebrow
FROM THE SAGE · TODAYintext-coach. - Italic insight quote with
aria-live="polite". - Optional
View goal →button when the prompt'sdetailsJson.goal_public_idis set. - Dismiss button (top-right) acknowledges the notification on the server and hides the card locally.
The card disappears entirely when there is no unacknowledged AI check-in.
CoachContextCard
src/components/dashboard/CoachContextCard.vue — renders in the dashboard slot StaleGoalAlert.vue used to occupy (same trigger: urgentGoal, the most overdue goal, non-null), in the Coach's voice instead of a plain alert pill. StaleGoalAlert.vue is now dead code (still on disk, unused anywhere in the app).
- Coach-purple gradient card, dismissible (
Xbutton, top-right) — hides for the session only, no server-side ack. - Eyebrow
{personaName || 'Coach'} · noticed something. - Body:
Your {goalName} goal hasn't moved in {daysSinceUpdate} day(s). A quick check-in keeps it from stalling. - Two actions:
Talk it through(emitstalk— opens the Coach chat pre-loaded with the stalled goal's context) andLog progress(emitslog— opens the goal-update form).
Used identically across Branch C (sparse) and the hierarchy's Detailed density (Standard has no dedicated urgent-goal slot outside the merged coach-nudge line — see the tertiary tier table) — only its position in the layout changes.
WeekShapeChart
src/components/dashboard/WeekShapeChart.vue — hierarchy (Branch D) only; not used by Branch C. Renders at every breakpoint in Detailed density and inside the "More on your week" disclosure in Standard — see Tertiary & the mobile home.
7-bar mini-chart wrapped on a glass-card with role="img" and an aria label that mirrors the caption.
- Eyebrow
SHAPE OF YOUR WEEKin standardtracking-[0.16em]uppercase muted form. - Bars: width 16px, today is 20px, min height 8px, max height 64px. Today's bar uses a primary gradient (
linear-gradient(to top, hsl(var(--primary)), hsl(var(--primary-light, var(--primary) / 0.7)))); past completed bars usebg-primary/80; past incomplete usebg-muted/40 dark:bg-muted/30; future days arebg-muted/20. - Day labels
M T W T F S S, today's label istext-primary, the rest are muted. - Caption:
You're {weeklyCheckIns} of 7. {strongestDay} is your strongest day — keep it light.The value is the number of distinct calendar days the user was active (0–7), not a raw event count. The denominator is always/7(fixed) —weeklyCheckInsis plumbed fromDashboard.vueso the user-facing number matches the momentum strip'sthis weektile. When the chart has no data, the caption falls back to"A blank week. The first bar's the smallest hill — and it's right in front of you.".
Dashboard.vue plumbs dailyPercents, todayIndex, weeklyCheckIns, and strongestDayLabel from corresponding computeds (activityDays, weeklyCheckIns, strongestDayLabel).
UpcomingEventsList
src/components/dashboard/UpcomingEventsList.vue — hierarchy (Branch D) only; not used by Branch C. Renders at every breakpoint in Detailed density and inside the "More on your week" disclosure in Standard — see Tertiary & the mobile home.
glass-card panel listing up to 3 upcoming milestones, sorted by date.
- Eyebrow
UPCOMING. - Each row uses a two-line date pill (
FRI/Apr 28) on the left + name + optional category on the right. - Empty state: italic "Nothing on the calendar yet." with an
Add a milestone →link emittingaddMilestone. - Error state: muted "Calendar didn't load — try again." + retry button emitting
retry.
Events are aggregated client-side in Dashboard.vue from goals.flatMap(g => g.milestones), filtered to future dates and sliced to 3.
GoalGridCard + "Your goals" section
src/components/dashboard/GoalsSection.vue wraps GoalGridCard.vue (and, in featured layout, GoalFocusCard.vue for the hero slot).
The "Your goals" heading row shows {N} active, a + New goal button, and a link to /goals — View all → in the hierarchy's condensed treatment, See all → otherwise. Branch C passes a fixed layout="grid" (DashboardSparseState.vue); the hierarchy always renders condensed (grid-only, top 2–3 goals by urgency — see Composition: primary → secondary → tertiary). GoalsSection.vue also supports a segmented Feature / Grid toggle (seg-toggle, hidden below sm, gated on !condensed) between featured (top goal large via GoalFocusCard, md:col-span-2, next two beside it, rest in a row below) and grid (equal-weighted md:grid-cols-2 xl:grid-cols-3) — this toggle is present in the component but neither current dashboard consumer wires up an @update:layout handler, so it is not reachable from either Branch C or Branch D today. A carousel layout value also exists in GoalsSection.vue's type but is not selectable from the toggle either.
Each card is an <article data-testid="goal-card"> (fm-glass-sub fm-goal-tile), composed of a cover strip (an image or GoalCoverPlaceholder fallback, tinted by --g-tint), a title, a meta line (category + a fm-chip status pill in Title Case — On track, Needs attention, Paused, Completed), a progress row, and a footer with Last update / {daysLeft}d left and a Check in button. The card root is not role="button" — the title is a real <button class="gc-open"> with a stretched ::after overlay (WCAG 2.5.3-compliant accessible name carrying title, status, and progress); the <article> keeps a bare @click for mouse convenience and data-testid="goal-card" in every state. See Goal card view states below for why.
The progress row renders a filled bar + percentage when the goal has a progress signal (see goals.md — Progress Tracking for the habit → roadmap → target-amount precedence), or a dashed empty rail + "Not tracked" when it doesn't — never a 0% fill for a goal that has no target amount. The row's box is identical across both states so a card never reflows when its state resolves, and the accessible name (carried on the title button, not the rail) drops the percentage entirely when not tracked rather than reading "0 percent." See docs/ui-specs/honest-goal-progress.md for the full visual contract, light/dark and loading vs. not-tracked.
Card tint is driven by --g-tint: categoryTintVar(category) when a category is set, else a color-prop fallback map (blue/coral/gold/emerald, back-compat aliases for older 6-tone values) — both resolve to a design_system/css/tokens.css CSS variable, not a hardcoded Tailwind colour utility. Dashboard.vue sets color via categoryColorFor(goal.category?.title), not an index-based cycle.
Goal card view states — standard / expanded / minimized (OBJ-1945)
Every goal card in the condensed dashboard column (GoalsSection.vue's condensed branch) has a per-goal, user-controlled view state — standard, expanded, or minimized — set by a single 44×44 size-cycle button on the card. This restores the "hero" card the v4.15 hierarchy made unreachable (GoalFocusCard's expanded content set was rendered only by the featured layout, and no dashboard branch reached it once DashboardHierarchy GA'd — see Known behaviors item 9 below) and generalizes it so any goal, not just the top one, can take the room. Design contract: docs/ui-specs/goal-card-view-states.md.
All three states render from one GoalGridCard.vue instance via a view prop — never three separate components — so toggling a card never unmounts/remounts it inside GoalsSection's TransitionGroup name="goal-list" and never re-fires its enter transition. GoalGridCard's pre-existing featured boolean and prop surface are unchanged, so DashboardSparseState.vue's non-condensed usage is unaffected.
| State | What it shows |
|---|---|
| Standard | Today's card, unchanged: cover, title, meta, progress, footer with Check in. |
| Expanded | Adds a streak chip (when streak > 0), a milestone callout, and a 3-item recent-activity timeline. Both blocks always render — a goal with no milestones or no check-ins gets a designed empty state ("Break this into steps — open the goal to add one." / "Log your first one — takes 10 seconds.") rather than omitting the block, so an expanded card never reads as broken. |
| Minimized | A 56px row: status glyph (dot / alert-triangle / pause-circle, colour is never the only signal), title, progress % (or an aria-hidden — when not tracked — the words live in the title button's accessible name instead), a 44×44 check-in button, the size-cycle button, and a 2px tinted progress rule on the bottom edge. No cover, category, status chip, or footer. |
The size-cycle button's accessible name changes with state ("Show more of <title>" growing, "Collapse <title>" from expanded straight to minimized) and a single shared aria-live="polite" region in GoalsSection (not one per card) announces the landed state ("<title> — expanded."). Cycle order: minimized → standard → expanded → minimized.
Persistence. useGoalCardView.ts stores each goal's chosen view in localStorage, keyed per goal publicId (goalCardView:<id>) — per-device only; a toggle does not follow the user to another browser or device. There is no server-side field or migration for this preference (contrast with the dashboard mode/density, which does sync server-side). Reads and writes are wrapped in try/catch; a missing, corrupted, or invalid stored value falls back to the default below rather than erroring.
Default (no stored value yet): the first card in the condensed column — the most urgent goal, since the list is already urgency-sorted — defaults to expanded; every other card defaults to standard. Once a user toggles a card, that choice persists and overrides the default from then on.
Out of scope for this change: the carousel / grid / featured layouts, DashboardSparseState.vue, the 3-goal condensedGoals slice, and the "7 active" totalCount / View all → affordance.
FAB stack
The floating FAB pair (BrandFabStack.vue) was retired in v3.12.0. The Coach is now a permanent, scrollable surface on the dashboard (CoachDockCard); the Goal FAB was removed entirely. See docs/development/fab-stack.md for the new Coach dock, contextual card, and Trail mark surfaces that replaced it.
Branch C's responsive behavior is out of scope for this page (see Page composition); Branch D's responsive behavior is documented under Composition: primary → secondary → tertiary and Tertiary & the mobile home below.
Dashboard Hierarchy — Standard & Detailed (v4.15)
Restructures the populated dashboard (Branch D, activeGoals.length >= 3) into one layout with two densities. This ships unconditionally — see the Overview note on the retired dashboard_hierarchy flag. Branches A (empty), B (first goal), and C (sparse) are unaffected — Branch C folding into this hierarchy is a flagged follow-up, not part of this milestone. Focus mode (StreamlinedDashboard.vue) is untouched.
Rendered unconditionally by DashboardHierarchy.vue whenever showHierarchy is true — Dashboard.vue's showHierarchy computed is !showStreamlined && !hasNoGoals && !hasNewGoal && !isSparseState. There is no flag-gated fallback component; DashboardPopulatedState.vue was deleted in Phase 5 (OBJ-1747) when dashboard_hierarchy retired.
The four modes: Focus / Standard / Detailed / Auto
Selected from a single 4-option control in Settings (DashboardModeControl.vue) and resolved by useFocusMode.ts:
| Mode | What it renders | Notes |
|---|---|---|
| Focus | The StreamlinedDashboard card stack | Unchanged from before v4.15. One card at a time. |
| Standard (default for new users) | The hierarchy below, density: 'standard' | The essentials. |
| Detailed | The hierarchy below, density: 'detailed' | Everything, expanded. Legacy full-mode users are backfilled here once — see Migration behavior. |
| Auto | Focus when shouldShowStreamlined() is true (every today's habit is checked in and no goal needs attention); otherwise the hierarchy at the persisted density | Adapts to your day. Settings copy: "Auto shows the Focus card stack when you're all caught up, and the Standard dashboard when there's work to do." |
dashboardMode ('focus' | 'full' | 'standard' | 'detailed' | 'auto') and dashboardDensity ('standard' | 'detailed') are separate axes. 'full' is the pre-v4.15 legacy mode value, kept only so an unmigrated saved preference still resolves correctly. resolvedDensity is the explicit standard/detailed mode when picked; for focus/auto/full it falls back to the persisted dashboardDensity (defaulting standard). Both axes read through the server on load (syncFromServer) and persist via UPDATE_DASHBOARD_PREFERENCES_MUTATION on every change (Settings.vue's selectDashboardMode). DashboardModeControl.vue always shows the 4-option control — there is no flag-gated fallback.
Composition: primary → secondary → tertiary
| Tier | Standard | Detailed |
|---|---|---|
| Primary | Mode chip in header (no streak chip) · daily quote · DashboardHero (3 "honest" tiles: this week x/7, day streak, active goals — no total progress % tile) · TodayActions (habit rows, remaining sorted above completed, mood check-in row, merged coach-nudge line) | Identical to Standard |
| Secondary | CriticalPathTile · CoachDockCard (single instance) · PartnerRowCompact (one-line "{name} checked in…" + Nudge, v-if="partner") · GoalsSection condensed (top 2–3 goals by urgency, compact + New goal, View all →) | Identical to Standard (no PartnerRowCompact — the full PartnerWidget lives in Detailed's tertiary instead) |
| Tertiary | Collapsed behind a "More on your week" disclosure (DashboardTertiaryDisclosure.vue, closed by default): WeekShapeChart, UpcomingEventsList, AiInsightCard (mood/goal insight) | Expanded inline everywhere: CoachContextCard (v-if="urgentGoal") in the main column; WeekShapeChart + UpcomingEventsList + AiInsightCard in a lg:grid-cols-[minmax(0,1fr)_320px] right rail at lg+, stacking full-width below lg; PartnerWidget (full) below the grid, v-if="partner" |
| (both) | SupporterStreakNudge at the very bottom — demoted out of the prime slot, frequency-capped (unchanged internal logic) | Same |
| (both) | RelocationHint rows for Community/Achieve when not yet dismissed — see Relocation hints | Same |
The DashboardHero "honest" variant drops the total progress % tile rather than fixing its underlying math — that metric is completedGoals.length / goals.length, which is structurally 0% for habit-only users (habits never flip completed) and stays that way in the legacy layout and Branch C.
Tertiary & the mobile home
Detailed's tertiary layer previously only existed as a hidden lg:flex right rail with no mobile equivalent — on mobile it simply didn't render. The v4.15 hierarchy fixes this: the same tertiary components render full-width in the mobile feed instead of a lg:*-only wrapper.
| Component | Standard | Detailed — mobile | Detailed — desktop (lg+) |
|---|---|---|---|
WeekShapeChart | Inside the "More on your week" disclosure | Inline in the feed | Right rail |
UpcomingEventsList | Inside the disclosure | Inline in the feed | Right rail |
AiInsightCard (mood/goal) | Inside the disclosure | Inline in the feed | Right rail |
CoachContextCard | Hidden (Standard has no dedicated urgent-goal slot outside the merged coach-nudge line) | Inline, main column | Main column |
PartnerWidget (full) | Hidden (PartnerRowCompact instead) | Full-width below the grid | Full-width below the grid |
Header changes
Both densities show a mode chip (Standard/Detailed/Auto) next to the wordmark. Tapping it opens the in-context mode switcher in place — it no longer navigates to Settings (OBJ-1770). It replaces the streak pill that Branch C and the legacy Branch D layout show — the header no longer carries a streak number while the hierarchy is active; DashboardHero's day streak tile is the sole place it renders, and (OBJ-1918) the sole place it's tappable — the tile itself opens StreakDetailsModal. The Focus mode pill (lg:-only, opens Focus mode) and notification bell/theme toggle are unchanged.
In-context mode switcher (OBJ-1770)
Tapping the header's mode chip opens DashboardModeSwitcher.vue — a second entry point to the same preference as the Settings control below, not a replacement for it.
- Surface: an anchored
ion-popover(≥768px,side="bottom"alignment="end") or a draggableion-modalbottom sheet (<768px,breakpoints: [0, 1]).Dashboard.vuetogglesmodeSwitcherOpen; the switcher isv-if="showHierarchy", so it only exists alongside the chip. - Options: all four modes — Focus / Standard / Detailed / Auto — using the same labels, icons, and descriptions as
DashboardModeControl.vuein Settings, so the two entry points never drift. Each option isrole="menuitemradio"witharia-checkedon the current mode; roving arrow-key focus and a check icon mark the selection. - Apply behavior: selecting an option calls
Dashboard.vue'sselectDashboardMode, which applies immediately throughuseFocusMode'ssetDashboardMode/setDashboardDensityand persists via the sameUPDATE_DASHBOARD_PREFERENCES_MUTATIONthe Settings control uses — no new GraphQL surface. On mutation failure, it reverts to the previous mode/density and shows a toast ("Couldn't save that view. Tap to retry.") with a working retry action. - Chip label: reads
Auto,Detailed, orStandard(modeChipLabel) — neverFocus, since the chip and switcher only render whileshowHierarchyis true, and selecting Focus enters theStreamlinedDashboardcard stack, unmounting the chip/switcher along with the rest of the hierarchy. - Motion + a11y: popover entrance is 200ms opacity + translateY + scale, sheet entrance is a 300ms slide-up; both drop to a 160ms opacity-only fade under
prefers-reduced-motion. Focus returns to the chip on dismiss (Esc, backdrop tap, or re-tapping the chip). - Known nit (non-blocking): selecting Focus from the switcher skips the picker's normal close animation — the chip and switcher unmount immediately (both are
v-if="showHierarchy", which flips tofalsein the same reactive flush as the dismiss) instead of playing the leave transition. Not a functional bug — the view still switches to Focus correctly. Flagged in code review (OBJ-1770, PR #1800) as a follow-up, not yet scheduled. - Settings control is unchanged.
DashboardModeControl.vueinSettings.vuestill works exactly as before — the chip switcher and Settings write through the sameuseFocusModerefs and mutation, so a change made from either surface is reflected in the other.
Migration behavior (full → Detailed backfill)
A one-time, per-browser backfill (useFocusMode.ts's resolveBackfill / syncFromServer, guarded by the localStorage flag dashboard_hierarchy_migrated_v1): the first time syncFromServer runs on a browser whose raw (undefaulted) saved dashboardMode was the legacy 'full', it sets mode/density to 'detailed' and persists migratedFromFull: true via UPDATE_DASHBOARD_PREFERENCES_MUTATION. A brand-new browser with no saved mode at all is not treated as a legacy full user — it defaults to Standard. Once the migration flag is set (regardless of outcome), it never fires again for that browser; after that, the server's persisted dashboard_preferences is authoritative.
Migrated users see a one-time, dismissible DashboardMigrationNote banner in the feed ("Your dashboard got a cleanup." / "Want every section back? Switch to Detailed in Settings." / Take me there →), and an echo line in the Settings mode control ("We led with today and tucked the extras away. You were on the full layout — that's Detailed now. Switch back any time above."). Dismissal is acknowledged server-side (UserDetail#dashboard_migration_note_dismissed, via DISMISS_DASHBOARD_HINT_MUTATION), not localStorage, so it stays dismissed across devices.
Relocation hints
SocialSection (encouragements + ally feed) and ShowcasedAchievements are not rendered inline in the hierarchy at all — they've moved to the Community and Achieve pages respectively. In their place, a one-time dismissible RelocationHint row points the user to each new home ("Your circle lives in Community now." / "Find your badges in Achieve.", both with a Visit → link). Each dismissal is acknowledged server-side (UserDetail#dashboard_relocation_hint_community_dismissed / #dashboard_relocation_hint_achieve_dismissed, same DISMISS_DASHBOARD_HINT_MUTATION).
Settings control
Settings.vue renders DashboardModeControl as a role="radiogroup" of 2×2 (mobile) / 4-across (lg+) cards when the flag is on (3-across, no grid breakpoint change, when it's off). Selecting a mode calls setDashboardMode (updates local state + localStorage) and, for standard/detailed, also calls setDashboardDensity, then persists both via UPDATE_DASHBOARD_PREFERENCES_MUTATION.
GraphQL surface
type DashboardPreferences {
mode: String! # focus | standard | detailed | auto
density: String! # standard | detailed
}
# On User:
dashboardPreferences: DashboardPreferences!
details: UserDetailType # dashboardMigratedFromFull, dashboardMigrationNoteDismissed,
# dashboardRelocationHintCommunityDismissed, dashboardRelocationHintAchieveDismissed
mutation UpdateDashboardPreferences($mode: String, $density: String, $migratedFromFull: Boolean) {
updateDashboardPreferences(mode: $mode, density: $density, migratedFromFull: $migratedFromFull) {
dashboardPreferences { mode density }
errors
}
}
mutation DismissDashboardHint($hint: String!) {
# hint: migration_note | relocation_community | relocation_achieve
dismissDashboardHint(hint: $hint) { success }
}Server-side, both preferences and the one-time surface acks live on UserDetail#data (JSONB) via store_accessor — dashboard_mode, dashboard_density, dashboard_migrated_from_full, dashboard_migration_note_dismissed, dashboard_relocation_hint_community_dismissed, dashboard_relocation_hint_achieve_dismissed (rails_api/app/models/user_detail.rb). User#dashboard_preferences defaults both fields to 'standard' when unset. The UserIdentity::UpdateDashboardPreferences interaction validates mode/density against fixed allow-lists and rejects anything else.
Navigation
Bottom tab bar (mobile)
Fixed bottom navigation with 5 tabs (src/components/BottomTabBar.vue):
| Tab | Icon | Route |
|---|---|---|
| Dashboard | Home | /dashboard |
| Goals | Target | /goals |
| Communities | Users | /community |
| Badges | Trophy | /achievements |
| Menu | Menu | Opens side menu |
Side menu (desktop / Menu tab)
Slide-out navigation panel:
- User profile section (avatar, name)
- Navigation links: Dashboard, My Goals, Communities, Achievements, Settings
- Admin link (if
user.admin === true) - Theme toggle
- Help/Support links
- Sign out
App header
src/components/ui/AppHeader.vue mounts below 768px only (md:hidden) and carries the app's role="banner" landmark there: a centered wordmark plus an <ion-menu-button> that renders empty, since the app has no <ion-menu> for it to open — the toolbar is effectively a brand bar. At 768px and up, AppHeader unmounts and the side menu's .nav-header takes over as role="banner", with its .logo-section as the sole logo/home affordance — so the rail never stacks a second wordmark above itself. The Dashboard's own page-level header (eyebrow + greeting + quote + actions) sits inside the content area, below whichever chrome is active.
Theme toggle
- Light/dark switch via
useTheme()composable. - Button in header (44×44 round) and in Settings.
- State is persisted in localStorage and reflected on
document.documentElementas thedarkclass. - All Phase A tokens have explicit dark-mode counterparts in
tailwind.css.dark.
Modals accessible from the dashboard
| Modal | Trigger |
|---|---|
BadgeModal | Click a showcased badge |
MoodCheckIn | Streak click → mood log; or TodayActions' mood prompt row |
GoalEventForm | CoachContextCard's Log progress button (goal-update form, scoped to the stalled goal) |
StreakDetailsModal | Click the header streak pill (Branch C); click the DashboardHero day streak tile while the v4.15 hierarchy is active (Branch D, OBJ-1918) — see Known behaviors below. |
NotificationsPanel | Click the notification bell |
PushPermissionPrompt | First-time native install with permission prompt |
AcceptPartnershipModal | Triggered by partner_request notifications (mounted in App.vue) |
AiCoachModal | Coach FAB click (mounted in App.vue) |
GraphQL API reference
Queries
query GoalsQuery {
goals {
publicId name description currentAmount targetAmount targetDateTime
completed updatedAtTime daysToUpdate imageUrl
recurrenceType habitStreak checkedInToday dueToday completionRate
kind { id name }
category { id name title }
allEvents { publicId content createdAtTime }
milestones { publicId name title targetDateTime completed }
encouragements { id }
}
}
query UserQuery($id: ID!) {
user(id: $id) {
firstName lastName username email emailVerified admin
photo { imageUrl }
streak longestStreak signInDates
actions { id action acknowledged createdAtTime }
notifications { id kind content detailsJson acknowledged createdAtTime }
feedItems { content createdAtTime }
showcasedAchievements
isSupporter supporterTier
coachingPreferences { configured persona }
}
}
query AllyActivityFeed($userId: ID!, $limit: Int) {
allyActivityFeed(userId: $userId, limit: $limit) {
id allyId allyName allyPhoto action target timestamp
}
}Subscriptions
subscription NotificationUpdate($userId: ID!) {
notificationUpdate(userId: $userId) {
id kind content detailsJson acknowledged createdAtTime
}
}Mutations
CHECK_IN_HABIT_MUTATION— used byTodayActionsper-row Check button.UPDATE_GOAL_MUTATION— used byGettingStartedCard(Branch B) and goal-detail flows.UPDATE_DASHBOARD_PREFERENCES_MUTATION/DISMISS_DASHBOARD_HINT_MUTATION(v4.15) — dashboard mode/density persistence and one-time surface acks; see GraphQL surface under Dashboard Hierarchy.
Verify the canonical SDL in ionic_frontend/src/constants/graphql/ before depending on any field.
Testing the feature
Manual checklist — Dashboard Hierarchy (Branch D, v4.15)
- [ ] With ≥3 active goals, Settings shows the 4-option control (Focus/Standard/Detailed/Auto).
- [ ] Header shows a
Standard/Detailed/Automode chip instead of the streak pill; the streak number renders only in theDashboardHeroday streaktile, and tapping that tile opensStreakDetailsModal(OBJ-1918). - [ ] Tapping the mode chip opens the in-context switcher in place (popover ≥768px / bottom sheet <768px) — no navigation to Settings; selecting a mode applies immediately and stays in sync with the Settings control both directions.
- [ ] Force a mutation failure (e.g. go offline) and select a mode from the switcher — it reverts to the previous mode/density and shows "Couldn't save that view. Tap to retry."; tapping Retry re-applies successfully once back online.
- [ ] Standard renders one centered column (
max-w-2xl) with no right rail at any breakpoint;DashboardHeroshows exactly 3 tiles (no total-progress tile). - [ ] Standard's tertiary layer ("More on your week") is collapsed by default; expanding it reveals
WeekShapeChart+UpcomingEventsList+ the mood/goalAiInsightCardinline. - [ ] Detailed renders the same primary/secondary content, plus the tertiary layer expanded inline on every breakpoint — confirm
WeekShapeChart/UpcomingEventsListare visible in the mobile feed (not just hidden in a desktop-only rail). - [ ]
GoalsSectionin both densities is condensed to the top 2–3 goals by urgency, with a compact+ New goalandView all →(notSee all →). - [ ]
SocialSectionandShowcasedAchievementsdo not render inline; a dismissible relocation hint points to Community/Achieve instead, and dismissing one is remembered after a refresh (server-side ack). - [ ] A user whose saved mode was legacy
fullsees the migration note once, and the Settings echo line, after their first load post-flag; dismissing the note is remembered across devices (or at least after clearing localStorage, since the ack is server-side). - [ ] A brand-new user (no saved mode) defaults to Standard, not Detailed, and never sees the migration note.
- [ ] Switching Settings between Focus/Standard/Detailed/Auto updates the dashboard immediately and survives a refresh.
- [ ]
CoachDockCardrenders exactly once (not duplicated between mobile/desktop). - [ ] No standalone
CheckInPromptCard; its coach-nudge line renders insideTodayActionsonly when an unacknowledgedai_check_innotification exists. - [ ] Light + dark mode parity for every surface above.
Manual checklist — goal card view states (OBJ-1945)
- [ ] On first load with no stored preference, the first card in the condensed column renders expanded; the rest render standard.
- [ ] Tapping a card's size-cycle button cycles
minimized → standard → expanded → minimized; the accessible name reads "Show more of<title>" while growing and "Collapse<title>" from expanded. - [ ] Toggling a card's view never navigates to goal detail, and never remounts the card (no flicker / re-fired entrance animation).
- [ ] Reload the page after toggling a card — its view state persists; toggling one card does not change any other card's state.
- [ ] Clear
localStorage(or use a fresh browser) — the default resolution above re-applies. - [ ] Expanded renders both the milestone callout and the recent-activity block for every goal, including one with no milestones and no check-ins (verify the designed empty copy, not a blank block).
- [ ] Focus order per card is title → check-in → size-cycle, in every state.
- [ ] Light + dark mode parity for all three states.
Manual checklist — first-week empty state (Branch A)
- [ ] Sign in as a user with zero active goals (
hasNoGoals === true). - [ ] Eyebrow
THE SAGE · JUST FOR YOUrenders intext-coach. - [ ] Headline reads
A blank week is a kind invitation.with the trailing clause in italic blue (font-display italic text-primary). - [ ] Body greeting renders the user's first name; falls back to
Hello, friend.whenfirstNameis empty. - [ ] Primary CTA
Set your first goal(brand-blue gradient, white text) navigates to/goals/create. - [ ] Secondary CTA
Talk to The SageopensAiCoachModal(verify with[data-testid="ai-coach-modal"]). - [ ] Three suggested practices render in the
glass-cardpanel below the CTAs, with dividers between rows only. - [ ] Clicking any practice routes to
/goals/create?title=…&description=…&kind=habit&recurrence=daily; the form prefillstitle,kind, andrecurrence(per OBJ-77) but thedescriptionparam is not yet consumed. - [ ] At 375px: CTAs stack vertically full-width; no horizontal scroll.
- [ ] At ≥768px: CTAs sit side-by-side; headline scales up to
text-4xl lg:text-5xl. - [ ] Light + dark mode parity for the hero, both CTAs, and the practice card.
- [ ]
prefers-reduced-motiondisables CTA hover transforms and the row chevron-shift animation. - [ ] Focus-mode pill is not rendered in the header (Branch A condition
!hasNoGoalsexcludes it).
Smoke + unit suites
cd ionic_frontend
npm run test:unit -- --run -t "Dashboard"
npm run test:unit -- --run -t "DashboardHero"
npm run test:unit -- --run -t "TodayActions"
npm run test:unit -- --run -t "CheckInPromptCard"
npm run test:unit -- --run -t "WeekShapeChart"
npm run test:unit -- --run -t "UpcomingEventsList"
npm run test:unit -- --run -t "GoalGridCard"
npm run test:unit -- --run -t "GoalsSection"
npm run test:unit -- --run -t "CoachDockCard"
npm run test:unit -- --run -t "CoachContextCard"
npm run test:unit -- --run -t "FirstWeekInvitation"
npm run test:unit -- --run -t "SuggestedPracticeList"
npm run test:unit -- --run -t "SuggestedPracticeRow"
npm run test:unit -- --run -t "Dashboard.vue — Branch A"
# v4.15 Dashboard Hierarchy
npm run test:unit -- --run -t "DashboardHierarchy"
npm run test:unit -- --run -t "DashboardModeControl"
npm run test:unit -- --run -t "DashboardMigrationNote"
npm run test:unit -- --run -t "DashboardTertiaryDisclosure"
npm run test:unit -- --run -t "RelocationHint"
npm run test:unit -- --run -t "PartnerRowCompact"
npm run test:unit -- --run -t "useFocusMode"
# In-context mode switcher (OBJ-1770)
npm run test:unit -- --run -t "DashboardModeSwitcher"MomentumBar.vue and its spec/story files are deleted outright (OBJ-3016) — it isn't dead code on disk anymore, it's gone. StaleGoalAlert (still dead code, see above) keeps its own spec/story files even though it isn't reachable from Dashboard.vue's render tree.
No Cypress spec covers Branch A — the empty-state assertions live in the Vitest specs above (see testing.md for why: 19 stale Cypress specs, including dashboard/empty.cy.ts, were retired under OBJ-568 and backfilled into Vitest/Playwright).
Storybook stories cover every Phase A surface and the three Phase B components — light + dark variants, plus the populated Dashboard.stories.ts PopulatedDesktop and PopulatedMobile fixtures and the FirstWeekInvitation Default / Mobile / Dark / LongName / NoName stories. The v4.15 hierarchy adds DashboardHero.stories.ts (Default, HabitOnlyUser, Loading), DashboardTertiaryDisclosure.stories.ts (Collapsed, Expanded), DashboardModeControl.stories.ts (FocusSelected, StandardSelected, DetailedSelected, AutoSelected), DashboardMigrationNote.stories.ts (Default, Dismissed), and RelocationHint.stories.ts (Community, Achieve). The in-context mode switcher (OBJ-1770) adds DashboardModeSwitcher.stories.ts (PopoverStandard, PopoverDetailed, PopoverAuto, SheetMobile, ReducedMotion, ApplyError, Dark). The goal card view states (OBJ-1945) extend GoalGridCard.stories.ts with Standard, Expanded, ExpandedEmpty, Minimized, MinimizedLoggedToday, MinimizedNeedsAttention, MinimizedPaused, and Featured (proves the pre-existing featured-prop path still renders unchanged).
cd ionic_frontend && npm run storybookKnown deferrals
These were called out and intentionally left for follow-up phases or backend work — they are not Phase A regressions.
- WCAG AA axe verification on Phase A surfaces. Token contrast was set with WCAG AA in mind, but full axe-core sweeps and dark-mode visual verification are scheduled for the release-prep hop.
- Cypress
populated.cy.ts. End-to-end populated-state spec is deferred — populated assertions are covered by component-level Vitest specs in this phase. - Quick-add prefill consumption. Branch A's
quick-addevent routes to/goals/createwithtitle/description/kind/recurrencequery params. OBJ-77 landed consumption oftitle,kind, andrecurrencein the goal-create form; only thedescriptionparam still goes unread. - Focus-mode pixel pass. Phase C deliverable; this page describes the existing Focus mode entry points only at a high level.
- Branch C folding into the v4.15 hierarchy. Flagged as a follow-up in OBJ-1719 §7 — Branch C (sparse) keeps its own composition for now.
Known behaviors & edge cases
- Branch boundaries are computed on every render. Adding a 3rd active goal flips the page from sparse (Branch C) to populated (Branch D) on next render — no manual refresh needed.
- Greeting time zones. Greeting uses the browser's local time —
Good morning(<12),Good afternoon(12–17),Good evening(18–23). - Setup-dismissed persistence. Branch B's "skip" writes to
localStoragekeyed by the goal'spublicId(setup_dismissed_<publicId>), so the card stays dismissed even after a refresh. DashboardHerostreak tile. Counts the user's sign-in streak (consecutive days visiting the app), not a habit streak. Habit streaks are per-goal and surface in theTodayActionsmeta line as{habitStreak}d.urgentGoalselection.Dashboard.vuepicks the goal with the highestdaysSinceamong goals that are eitherisFallingBehindorisAtRisk. If nothing qualifies,CoachContextCarddoes not render.- Real-time notifications. Notifications arrive over the
notificationUpdatesubscription. That subscription has no reconnect catch-up of its own —cache-and-networkalone doesn't refetch on a WebSocket reconnect, andActionCableLinkonly resubscribes the channel, recovering the stream but not events pushed while it was down. The actual catch-up is a side effect ofAchievementWatcher.vue'suseLiveQueryreconnect refetch (OBJ-2879, see notifications.md § Real-Time Delivery): it re-runsUSER_QUERY, which also selectsnotifications. This is incidental, not a notifications-owned mechanism — ifAchievementWatcher.vueis ever removed or its query narrowed, notifications lose reconnect catch-up with it. - The
full→Detailed migration is per-browser, not per-account. The one-time backfill (see Migration behavior) reads localStorage, so a legacyfulluser signing in on a second, never-before-used browser is treated as a new user (defaults Standard) on that browser, even though their account already has a server-side preference from the first backfill. This is intentional —syncFromServermakes the server authoritative once any preference exists, so the second browser picks up the already-migrated server value instead of re-running the backfill. StreakDetailsModal's hierarchy entry point (fixed, OBJ-1918). The v4.15 hierarchy launch (Phase 1–4) hid the header streak pill whenevershowHierarchyis true (replaced by the mode chip) without wiring an alternate entry point onDashboardHero's streak tile — a same-milestone gap, not an intentional removal, live for a few hours on 2026-07-30 before being caught from a user report and fixed same-day. Theday streaktile is now itself a tap target: it emitsstreakClicked, whichDashboard.vueroutes through the samehandleStreakPillClick()the header pill used (streakDetailsOpen = true, orretryStreak()when the streak state isunavailable).- The dashboard's expanded goal card went unreachable, then got restored and generalized (fixed, OBJ-1945).
GoalFocusCard's expanded content set only ever rendered insideGoalsSection'sfeaturedlayout. Once the v4.15 hierarchy GA'd and its flag retired (Phase 5, OBJ-1747),DashboardHierarchybecame the unconditional Branch D render, and its condensed branch is grid-only with the Feature/Grid toggle suppressed — soGoalFocusCardbecame Storybook-only dead code with no wiring regression to point at. The fix didn't just restore the old hero card; it generalized the idea into the per-goal standard/expanded/minimized view state described above, so any goal (not just the first) can take the room.
Last updated: 2026-08-30 — MomentumBar.vue deleted (superseded by DashboardHero.vue/MomentumStat.vue); documented the action (none/modal/explain) tile-explanation contract (OBJ-3016).