Skip to content

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 statestandard, 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

ComponentStatusNote
GoalGridCard.vuemodifiedGains a view prop and renders all three states. See "Binding contract" — this is one component, not three.
GoalCardStory.vuenew (recommended)Presentational child: milestone callout + activity timeline. Extracted from GoalFocusCard, consumed by both.
GoalFocusCard.vuemodified (light)Keeps its public prop/emit surface and the featured hero slot; only swaps its inline story markup for GoalCardStory.
GoalsSection.vuemodifiedCondensed branch passes view per goal, handles the toggle emit, owns the aria-live region, and gains two grid classes.
useGoalCardView.tsnewPer-goal view state, localStorage-backed. Behaviour per Orion A3/A4 — not re-decided here.
GoalCoverPlaceholder.vuereusedUnchanged.

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 the featured layout, and prevents a tall expanded card from creating a ragged row next to a short one.
  • The grid gains items-start. Without it, grid align-items: stretch makes 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.

SurfaceToken / classNote
Card surfacefm-glass-sub fm-goal-tileUnchanged across all three states.
Category tint--g-tintcategoryTintVar(category)Already set by tileStyle. Drives cover, progress bar, milestone callout, activity dots, minimized progress rule.
Primary CTAfm-btn-primaryStandard + expanded "Check in" only.
Check-in, minimizedbg-primary/12 text-primary border-primary/28Tinted, not the solid gradient — a minimized card must not outweigh an expanded one.
Logged-today, minimizedbg-success/14 text-[hsl(var(--success-accessible))] border-success/30
Status chipfm-chip + fm-chip-success / -warning / -danger / -neutralStandard + expanded only.
Streak chip.gc-streak-chip (accent)Expanded only.
Size-cycle buttontext-muted-foreground, hover bg-foreground/6, focus ring ring/30
Minimized progress rulebg-muted/70 track, --g-tint gradient fillPaused goals use bg-muted-foreground/45 — a paused card reads inert.
Focus ringoutline-2 outline-ring outline-offset-[-3px]On the stretched open-goal control.

Token-drift finding (not part of this change): ionic_frontend/.mockups/primitives.html carries --muted-foreground: 215 16% 47% (light) and 215 16% 65% (dark). The canonical values in design_system/css/tokens.css are 215 16% 40% and 215 20% 65%. The stale light value fails WCAG AA at 4.48:1 on --background — it produced six color-contrast violations 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

ElementFontClassState
Card titleOutfitfont-display font-semibold text-xl sm:text-2xl line-clamp-2Expanded
Card titleOutfitfont-display font-semibold text-base line-clamp-2Standard (unchanged)
Card titleIntertext-sm font-semibold truncateMinimized
Progress %Outfitfont-display text-base font-bold tabular-numsExpanded
Progress %Outfitfont-display text-sm font-bold tabular-numsStandard, minimized
Meta lineIntertext-xs text-muted-foregroundStandard, expanded
EyebrowIntertext-xs font-bold tracking-eyebrow uppercase text-muted-foregroundProgress / Last update / Recent activity
Milestone titleIntertext-[0.9375rem] font-semibold line-clamp-2Expanded — line-clamp-2, not nowrap. At column width the current white-space: nowrap ellipsizes most milestone names into uselessness.
Activity noteIntertext-sm font-semibold truncateExpanded

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 stateGlyphAccessible nameLands on
minimizedchevron-down"Show more of <title>"standard
standardchevron-down"Show more of <title>"expanded
expandedchevron-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; --ring and --muted-foreground carry 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 card
  • aria-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>
css
.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> loses role="button", tabindex="0", @keyup.enter and @keydown.space — the title button handles keyboard natively.
  • The <article> keeps @click (mouse convenience anywhere on the card) and keeps data-testid="goal-card" on the root in all three states.
  • The title button's accessible name carries what the old root aria-label carried (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-open button (there is no heading at that density).
  • Existing GoalGridCard.spec.ts / GoalFocusCard.spec.ts assertions that fire keyup.enter on 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-triangle glyph, paused = pause-circle glyph, 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.

SlotContent
Status indicator10px 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
Progressfont-display text-sm font-bold tabular-nums, flex-none
Check-in44×44 tinted icon button, check glyph
Size-cycle44×44
Progress rule2px, 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 for muted-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-tile lift (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:

BlockPhonesm+
Coverh-24h-32
Titletext-xltext-2xl
Streak chipshown when streak > 0same
Progress %text-basesame
Milestone calloutline-clamp-2 title, bar mt-3same
Activity timeline3 items, .gc-act-body padding-bottom 12pxsame
"Check in"h-9 px-5 text-smsame

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 grey text-xs line.

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.vue takes a view: 'minimized' | 'standard' | 'expanded' prop (default 'standard') and renders all three internally.
  • :key stays g.id in every state.
  • One root <article> with data-testid="goal-card" in every state.
  • Prop surface and emits are identical in every state; check-in and click behave the same everywhere. New emit: cycle-view (no payload — the composable owns the next-state math).
  • GoalGridCard's existing featured boolean and full prop surface stay backward-compatible (DashboardSparseState still 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

TransitionTreatment
Any view changeThe blocks that appear animate in: opacity 0 → 1 + translateY(-6px) → 0, 260ms cubic-bezier(0.22, 1, 0.36, 1) (ease-out).
Blocks that disappearRemoved immediately — no exit animation. Animating a collapse fights the card's own height change and reads as lag.
Card heightLet it reflow naturally. Do not animate height/max-width; the surrounding TransitionGroup already moves siblings.
Cycle buttonbackground-color / color / border-color 150ms ease.
prefers-reduced-motion: reduceAll 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.

SurfaceStringTone 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)

StoryArgs
Standarddefault (existing story, add view: 'standard')
Expandedview: 'expanded', streak: 12, 5 milestones (2 complete), 3 activity items
ExpandedEmptyview: 'expanded', milestones: [], recentActivity: [], progress: 0, lastCheckIn: ''
Minimizedview: 'minimized'
MinimizedLoggedTodayview: 'minimized', loggedToday: true
MinimizedNeedsAttentionview: 'minimized', status: 'needs-attention'
MinimizedPausedview: 'minimized', status: 'paused'
Featuredexisting 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 the goal-list enter 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-interactive and zero aria-allowed-role violations 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 gains items-start; expanded cards get sm:col-span-2.
  • [ ] One aria-live="polite" region in GoalsSection announces 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 and vue-tsc pass; statement coverage ≥80%.
  • [ ] DashboardSparseState.spec.ts passes 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.

Loading…