UI-SPEC — Goal card view states (standard / expanded / minimized)
Issue: OBJ-1945 · Task package: Orion, comment 206c26d6 on OBJ-1945 · Branch: feat/goal-card-view-statesMockup: mockups/goal-card-view-states/
Scope
Every goal card in the dashboard's condensed goals column gets a user-controlled view state — standard, expanded, or minimized — set per goal by a single control on the card and persisted per goal publicId. This restores the expanded card the v4.15 hierarchy made unreachable (GoalFocusCard is rendered only by GoalsSection's featured layout, which no dashboard branch reaches — see Orion's §1) and generalizes it so the user decides which goals deserve the room.
In scope: the three card states as they render inside the condensed dashboard column (GoalsSection.vue:87-101), the control that switches them, the transition between them, and the accessibility contract for putting interactive controls inside a card that is itself clickable.
Out of scope (unchanged): the 3-goal condensedGoals slice, the "7 active" totalCount, the "View all →" affordance, the carousel / grid / featured branches, DashboardSparseState, and /goals.
Component / screen inventory
| Component | Status | Note |
|---|---|---|
GoalGridCard.vue | modified | Gains a view prop and renders all three states. See "Binding contract" — this is one component, not three. |
GoalCardStory.vue | new (recommended) | Presentational child: milestone callout + activity timeline. Extracted from GoalFocusCard, consumed by both. |
GoalFocusCard.vue | modified (light) | Keeps its public prop/emit surface and the featured hero slot; only swaps its inline story markup for GoalCardStory. |
GoalsSection.vue | modified | Condensed branch passes view per goal, handles the toggle emit, owns the aria-live region, and gains two grid classes. |
useGoalCardView.ts | new | Per-goal view state, localStorage-backed. Behaviour per Orion A3/A4 — not re-decided here. |
GoalCoverPlaceholder.vue | reused | Unchanged. |
Layout
Phone (375–639px) — condensed column is single-column
┌───────────────────────────────────────┐
│ Your goals 7 active [+ New goal] [View all →]
└───────────────────────────────────────┘
┌───────────────────────────────────────┐ EXPANDED
│ ┌───────────────────────────────────┐ │
│ │ cover h-24 │ │
│ └───────────────────────────────────┘ │
│ Morning run [ ⌃ ]│ ← size-cycle, 44×44
│ Health · ON TRACK · 🔥 12-day streak │
│ PROGRESS 68% │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░ │
│ ┌───────────────────────────────────┐ │
│ │ ⚑ MILESTONES · 2 OF 5 │ │
│ │ Run 5K without stopping │ │
│ │ ▓▓▓▓▓░░░░░░░░ │ │
│ └───────────────────────────────────┘ │
│ ⏱ RECENT ACTIVITY │
│ ● 3.2 km — easy pace Today │
│ ● 5.0 km — new best Sat │
│ ● 2.8 km — recovery Thu │
│ ───────────────────────────────────── │
│ LAST UPDATE DAYS LEFT [ Check in ]│
└───────────────────────────────────────┘
┌───────────────────────────────────────┐ STANDARD (today's card)
│ cover h-20 · title · meta · progress │
│ footer + [Check in] [ ⌄ ]│
└───────────────────────────────────────┘
┌───────────────────────────────────────┐ MINIMIZED — 56px
│ ● Read 20 pages 82% [✓] [ ⌄ ]│
│▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁░░░░░░░│ ← 2px progress rule, bottom edge
└───────────────────────────────────────┘sm+ (640px+) — condensed column is sm:grid-cols-2
- Expanded cards span both columns (
sm:col-span-2). This gives the hero back its width without re-introducing thefeaturedlayout, and prevents a tall expanded card from creating a ragged row next to a short one. - The grid gains
items-start. Without it, gridalign-items: stretchmakes a standard card next to an expanded one stretch to match — a tall, mostly-empty card. Each card must be its natural height. - Standard and minimized cards flow normally in the two columns.
sm+ : ┌─────────── EXPANDED (col-span-2) ───────────┐
└─────────────────────────────────────────────┘
┌──── STANDARD ────┐ ┌──── MINIMIZED ────┐
└──────────────────┘ └───────────────────┘Grid class (condensed branch):grid grid-cols-1 sm:grid-cols-2 gap-4 items-start — the only change is items-start.
Design tokens
No new tokens. Everything below already exists in design_system/css/tokens.css.
| Surface | Token / class | Note |
|---|---|---|
| Card surface | fm-glass-sub fm-goal-tile | Unchanged across all three states. |
| Category tint | --g-tint ← categoryTintVar(category) | Already set by tileStyle. Drives cover, progress bar, milestone callout, activity dots, minimized progress rule. |
| Primary CTA | fm-btn-primary | Standard + expanded "Check in" only. |
| Check-in, minimized | bg-primary/12 text-primary border-primary/28 | Tinted, not the solid gradient — a minimized card must not outweigh an expanded one. |
| Logged-today, minimized | bg-success/14 text-[hsl(var(--success-accessible))] border-success/30 | |
| Status chip | fm-chip + fm-chip-success / -warning / -danger / -neutral | Standard + expanded only. |
| Streak chip | .gc-streak-chip (accent) | Expanded only. |
| Size-cycle button | text-muted-foreground, hover bg-foreground/6, focus ring ring/30 | |
| Minimized progress rule | bg-muted/70 track, --g-tint gradient fill | Paused goals use bg-muted-foreground/45 — a paused card reads inert. |
| Focus ring | outline-2 outline-ring outline-offset-[-3px] | On the stretched open-goal control. |
Token-drift finding (not part of this change):
ionic_frontend/.mockups/primitives.htmlcarries--muted-foreground: 215 16% 47%(light) and215 16% 65%(dark). The canonical values indesign_system/css/tokens.cssare215 16% 40%and215 20% 65%. The stale light value fails WCAG AA at 4.48:1 on--background— it produced sixcolor-contrastviolations in this mockup's first render and cleared once the canonical values were used. The shipping app is unaffected; the mockup sheet is not. Worth a one-line follow-up, tracked separately from OBJ-1945.
Typography
| Element | Font | Class | State |
|---|---|---|---|
| Card title | Outfit | font-display font-semibold text-xl sm:text-2xl line-clamp-2 | Expanded |
| Card title | Outfit | font-display font-semibold text-base line-clamp-2 | Standard (unchanged) |
| Card title | Inter | text-sm font-semibold truncate | Minimized |
| Progress % | Outfit | font-display text-base font-bold tabular-nums | Expanded |
| Progress % | Outfit | font-display text-sm font-bold tabular-nums | Standard, minimized |
| Meta line | Inter | text-xs text-muted-foreground | Standard, expanded |
| Eyebrow | Inter | text-xs font-bold tracking-eyebrow uppercase text-muted-foreground | Progress / Last update / Recent activity |
| Milestone title | Inter | text-[0.9375rem] font-semibold line-clamp-2 | Expanded — line-clamp-2, not nowrap. At column width the current white-space: nowrap ellipsizes most milestone names into uselessness. |
| Activity note | Inter | text-sm font-semibold truncate | Expanded |
Expanded title drops to text-xl at phone width; text-2xl (the featured size) only applies at sm+, where the card spans both columns.
The size-cycle control
Decision (Orion Q2): a single cycle button, not a three-way segmented control.
Rationale — a segmented control that honours the ≥44px tap-target floor is 132px wide, which does not fit a card header at 375px alongside a two-line title, and it adds three tab stops per card (nine on the dashboard) before the user reaches "Check in". One 44×44 button gets the tap target for free, adds one tab stop, and sits quietly beside the title.
Cycle order and glyph:
| Current state | Glyph | Accessible name | Lands on |
|---|---|---|---|
minimized | chevron-down | "Show more of <title>" | standard |
standard | chevron-down | "Show more of <title>" | expanded |
expanded | chevron-up | "Collapse <title>" | minimized |
The semantics stay honest at every press: chevron-down always grows one step; chevron-up collapses to the smallest state. The accepted trade is that returning from expanded to standard costs two presses — inherent to any single-button three-state control, and low-cost in practice because the default resolution (Orion A4) means most users set a card once and leave it.
Placement. Top-right of the card, baseline-aligned with the title (-mt-2 -mr-2 to pull the 44px hit box into the existing padding without adding height). On the minimized row it is the last element, after the check-in button.
States
- Default:
bg-transparent,text-muted-foreground,rounded-xl, 44×44. - Hover (desktop):
bg-foreground/6,text-foreground, 150ms. - Focus:
border-ring/55+ring-3 ring-ring/30,text-foreground, no outline suppression without a replacement. - Active / pressed:
scale-[0.94]. - Disabled: none. The control is never disabled — every state has a next state.
- Dark mode: identical classes;
--ringand--muted-foregroundcarry the theme. - Motion-reduced:
transition: none; state still changes instantly.
Accessibility contract
1. The card root must stop being role="button"
GoalGridCard.vue:9 sets role="button" on an <article> that already contains a nested <button> ("Check in"). Adding the size-cycle button makes it two. axe-core flags this today and flagged it in this mockup's first render:
nested-interactive(serious) — "Element has focusable descendants" × every cardaria-allowed-role(minor) — "ARIA role button is not allowed for given element"
Required change — the stretched-control pattern:
<article data-testid="goal-card" class="fm-glass-sub fm-goal-tile …" @click="$emit('click')">
<h3 …>
<button type="button" class="gc-open"
:aria-label="`${title} — ${statusLabel}, ${Math.round(progress)} percent. Open goal`"
@click.stop="$emit('click')">{{ title }}</button>
</h3>
…
<button type="button" class="gc-ctl …" @click.stop="$emit('check-in')">Check in</button>
<button type="button" class="gc-ctl gc-cycle" @click.stop="$emit('cycle-view')">…</button>
</article>.gc-open::after { content: ''; position: absolute; inset: 0; z-index: 1; border-radius: inherit; }
.gc-open:focus-visible::after { outline: 2px solid hsl(var(--ring)); outline-offset: -3px; }
.gc-ctl { position: relative; z-index: 2; } /* real controls sit above the overlay */- The
<article>losesrole="button",tabindex="0",@keyup.enterand@keydown.space— the title button handles keyboard natively. - The
<article>keeps@click(mouse convenience anywhere on the card) and keepsdata-testid="goal-card"on the root in all three states. - The title button's accessible name carries what the old root
aria-labelcarried (title, status, progress) and satisfies WCAG 2.5.3 — the visible title is contained in the accessible name. - On the minimized row the title is the
.gc-openbutton (there is no heading at that density). - Existing
GoalGridCard.spec.ts/GoalFocusCard.spec.tsassertions that firekeyup.enteron the root need updating to target the title button. Update them; do not delete them.
Verified: the mockup renders 0 axe-core violations at 390 / 768 / 1280 × light + dark after this change.
2. Announce the landed state once, in the section
One aria-live="polite" role="status" region in GoalsSection (not one per card), updated on the toggle emit:
<p class="sr-only" role="status" aria-live="polite">{{ viewAnnouncement }}</p>Content: "<title> — expanded." / "— standard." / "— minimized."
Do not use aria-pressed or aria-expanded on the cycle button — neither models three states honestly. The changing accessible name is the sanctioned pattern; the live region covers the fact that the name alone does not announce what just happened.
3. Everything else
- Focus order per card: title (opens goal) → check-in → size-cycle. Same order in all three states.
- Tap targets: size-cycle 44×44, minimized check-in 44×44, standard/expanded "Check in" 32/36px tall × ≥64px wide (unchanged from today).
- Colour is never the only signal. The minimized status indicator changes shape, not just hue: on-track = filled dot, needs-attention / at-risk =
alert-triangleglyph, paused =pause-circleglyph, all at 14–16px. Status is also in the title button's accessible name. - Contrast: WCAG AA verified by axe at all six viewport × theme combinations. Success text uses
--success-accessible, not--success. - Motion-reduced: see below.
State catalog
Minimized (new)
56px row, rounded-[18px], padding: 6px 8px 6px 14px, gap: 8px, overflow: hidden.
| Slot | Content |
|---|---|
| Status indicator | 10px dot (on-track, --success-accessible) / 16px alert-triangle (needs-attention, at-risk) / 16px pause-circle (paused, --muted-foreground) |
| Title | .gc-open button, text-sm font-semibold truncate, flex-1 min-w-0 |
| Progress | font-display text-sm font-bold tabular-nums, flex-none |
| Check-in | 44×44 tinted icon button, check glyph |
| Size-cycle | 44×44 |
| Progress rule | 2px, absolutely positioned on the bottom edge, full width, --g-tint fill |
- No cover image, no category name, no status chip, no footer. Enough to see where a goal stands and check in; not enough to browse.
- Paused: title and progress drop to
text-muted-foreground; the progress rule drops the tint formuted-foreground/45. - Loading: not applicable — the dashboard's own skeleton gates the whole section.
- Empty: not applicable — a card only exists for a goal that exists.
- Focus: ring on whichever of the three controls has focus; the row itself is not focusable.
- Hover (desktop):
fm-goal-tilelift (translateY(-2px)), unchanged. - Dark mode: identical structure; tokens carry it.
Standard
Today's GoalGridCard, unchanged except: the root a11y restructure above, and the size-cycle button added to the header row. Title stays text-base, cover stays h-20, "Check in" stays fm-btn-primary h-8.
Expanded
GoalFocusCard's content set, re-fitted to the condensed column:
| Block | Phone | sm+ |
|---|---|---|
| Cover | h-24 | h-32 |
| Title | text-xl | text-2xl |
| Streak chip | shown when streak > 0 | same |
| Progress % | text-base | same |
| Milestone callout | line-clamp-2 title, bar mt-3 | same |
| Activity timeline | 3 items, .gc-act-body padding-bottom 12px | same |
| "Check in" | h-9 px-5 text-sm | same |
Empty / degraded expanded (Orion's open item). Today both body blocks are v-if-gated, so a goal with no milestones and no activity expands into a card barely taller than standard with one grey line — the expand reads as broken. Expanded must always render both blocks, with designed empties:
- No milestones — the callout renders in a dashed variant (
border-dashed border-g-tint/40, transparent fill): eyebrow "MILESTONES · NONE YET", title "Break this into steps — open the goal to add one.", no bar. No new control: the card's own open-goal affordance is where you add one. - No activity — a muted timeline dot plus "No check-ins yet" (
text-sm font-semibold) and "Log your first one — takes 10 seconds." (text-xs text-muted-foreground). This replaces today's single greytext-xsline.
Other states — loading, error, success, disabled, focus, hover, active, dark, motion-reduced — are inherited unchanged from GoalGridCard / GoalFocusCard; nothing in this change touches them.
Binding contract — one component, three states
Requirement (not a suggestion): switching a card's view must not unmount and remount the card.
GoalsSection's condensed branch renders inside <TransitionGroup name="goal-list">. If Codi renders three different components selected by v-if / <component :is>, Vue tears down the old one and mounts the new one even with a stable :key, goal-list's enter transition re-fires, and every toggle reads as a flicker (Orion's risk #4). Therefore:
GoalGridCard.vuetakes aview: 'minimized' | 'standard' | 'expanded'prop (default'standard') and renders all three internally.:keystaysg.idin every state.- One root
<article>withdata-testid="goal-card"in every state. - Prop surface and emits are identical in every state;
check-inandclickbehave the same everywhere. New emit:cycle-view(no payload — the composable owns the next-state math). GoalGridCard's existingfeaturedboolean and full prop surface stay backward-compatible (DashboardSparseStatestill passes them).
Recommended file structure: extract the milestone callout + activity timeline (markup and scoped CSS) from GoalFocusCard.vue into GoalCardStory.vue, consumed by GoalGridCard (expanded) and GoalFocusCard (featured hero). This avoids duplicating ~80 lines of markup and ~50 lines of CSS and keeps the two surfaces from drifting. GoalFocusCard's public prop/emit surface must not change — the featured layout and DashboardSparseState depend on it. If Codi finds the extraction riskier than it looks, duplicating is acceptable; the binding contract above is what is non-negotiable.
Motion
| Transition | Treatment |
|---|---|
| Any view change | The blocks that appear animate in: opacity 0 → 1 + translateY(-6px) → 0, 260ms cubic-bezier(0.22, 1, 0.36, 1) (ease-out). |
| Blocks that disappear | Removed immediately — no exit animation. Animating a collapse fights the card's own height change and reads as lag. |
| Card height | Let it reflow naturally. Do not animate height/max-width; the surrounding TransitionGroup already moves siblings. |
| Cycle button | background-color / color / border-color 150ms ease. |
prefers-reduced-motion: reduce | All of the above become instant. Gate with the motion-safe: variant, matching how the cards already gate their progress-bar transition. |
The mockup's motion GIFs record standard → expanded → standard at 390px in both themes.
Microcopy
Every user-facing string. No placeholders.
| Surface | String | Tone check |
|---|---|---|
| Size-cycle, growing | "Show more of Morning run" | action verb ✅, brief ✅ |
| Size-cycle, collapsing | "Collapse Morning run" | action verb ✅, brief ✅ |
| Live region | "Morning run — expanded." | states the result, no praise ✅ |
| Live region | "Morning run — standard." | ✅ |
| Live region | "Morning run — minimized." | ✅ |
| Open goal (title button) | "Morning run — on track, 68 percent. Open goal" | contains visible label ✅ (WCAG 2.5.3) |
| Check in, minimized | "Check in to Morning run" | unchanged from standard ✅ |
| Checked in today, minimized | "Checked in today to Morning run. Log another update" | unchanged from standard ✅ |
| Expanded, no milestones — eyebrow | "MILESTONES · NONE YET" | factual, not scolding ✅ |
| Expanded, no milestones — body | "Break this into steps — open the goal to add one." | action-forward ✅, one sentence ✅ |
| Expanded, no activity — title | "No check-ins yet" | ✅ |
| Expanded, no activity — body | "Log your first one — takes 10 seconds." | action-forward ✅, concrete ✅ |
Strings that already exist and must not change: "Check in", "Checked in", "Updated", "Progress", "Last update", "Days left", "Recent activity", "On track", "Needs attention", "Paused", "Completed".
Storybook story brief
Path: ionic_frontend/src/components/dashboard/GoalGridCard.stories.ts (extend the existing file)
| Story | Args |
|---|---|
Standard | default (existing story, add view: 'standard') |
Expanded | view: 'expanded', streak: 12, 5 milestones (2 complete), 3 activity items |
ExpandedEmpty | view: 'expanded', milestones: [], recentActivity: [], progress: 0, lastCheckIn: '' |
Minimized | view: 'minimized' |
MinimizedLoggedToday | view: 'minimized', loggedToday: true |
MinimizedNeedsAttention | view: 'minimized', status: 'needs-attention' |
MinimizedPaused | view: 'minimized', status: 'paused' |
Featured | existing featured: true story — proves the back-compat path still renders |
argTypes to expose: view (select: minimized / standard / expanded), status (select, all five), progress (range 0–100), loggedToday (boolean), streak (number), kind (select: goal / habit), featured (boolean). tags: ['autodocs'].
Add GoalCardStory.stories.ts if the extraction happens: Default, NoMilestones, NoActivity, Empty.
Acceptance criteria for Codi
- [ ] All three states render at 375px width with no horizontal scroll.
- [ ] All three states render from one component, one root
<article>, one stable:key— toggling never remounts the card and never re-fires thegoal-listenter transition. - [ ]
data-testid="goal-card"is on the root in all three states. - [ ] The card root no longer carries
role="button"/tabindex="0"; the title is a real<button>with a stretched::after; controls sit above it with@click.stop. - [ ] axe-core reports zero
nested-interactiveand zeroaria-allowed-roleviolations on the dashboard after the change. - [ ] Toggling a card's view never navigates to goal detail.
- [ ] Focus order per card is title → check-in → size-cycle, in every state.
- [ ] Size-cycle and minimized check-in are ≥44×44; every control has a visible focus ring.
- [ ]
GoalsSection's condensed grid gainsitems-start; expanded cards getsm:col-span-2. - [ ] One
aria-live="polite"region inGoalsSectionannounces the landed state. - [ ] Expanded renders both body blocks with the specified empty states when a goal has no milestones and no activity.
- [ ] Every microcopy string matches the table verbatim.
- [ ] Dark-mode parity verified in Storybook for all three states.
- [ ] Storybook stories cover every variant in the brief;
tags: ['autodocs']. - [ ] View changes respect
prefers-reduced-motion. - [ ]
npm run test:unit, ESLint andvue-tscpass; statement coverage ≥80%. - [ ]
DashboardSparseState.spec.tspasses untouched.
Open questions
None blocking. Two decisions were mine to make and are made above — Q1 (minimized content) in the state catalog, Q2 (toggle affordance) in the size-cycle section.
One item for Maggie to route separately: the primitives.html --muted-foreground drift documented under Design tokens. It is a mockup-tooling bug, not an app bug, and does not gate this work.