Skip to content

UI-SPEC — Honest goal progress: the "not tracked" state

Issue: OBJ-1978 · Task package: Orion, comment e81fe006 on OBJ-1978 (Task 1) · Branch: fix/obj-1978-honest-goal-progressMockup: mockups/honest-goal-progress/


Scope

useGoalProgress (Codi's Task 2) returns a tri-state — loading / tracked / not-tracked — where percent is null in every non-tracked state. This spec is the rendering contract for the two states that have no visual today: not tracked and loading, on every surface that renders a progress number.

Four surfaces, six render sites:

  1. Card progress rowGoalGridCard.vue (standard + expanded) and GoalFocusCard.vue
  2. Card minimized rowGoalGridCard.vue (view === 'minimized'), a 56px row with different space constraints
  3. Detail hero metrics rowGoalHero.vue:42
  4. Avg Progress KPI tileGoals.vue:97 via KpiStat.vue, now null-capable per Josh's decision (b)

Plus the screen-reader copy for all of them, and the GoalAtAGlance.vue Progress tile, whose existing 'No data yet' string is unified here.

A fifth surface was added after the fact. CommunityGoals.vue was missed by the original inventory and shipped the same fabricated 0% this spec exists to remove. It is specified in Amendment 1, which also rules on the four progress-band colours that surface carried.

In scope: the not-tracked visual, the loading visual, the microcopy, and the accessible names — light and dark, mobile through desktop.

Out of scope: the derivation itself (Task 2), the wiring (Task 3), the backend (Task 5), and any new "set a target" call-to-action — see Deliberate non-goal below.


Component / screen inventory

ComponentStatusChange
components/dashboard/GoalGridCard.vuemodifiedprogress prop accepts null; progress row + minimized row + openAriaLabel gain the not-tracked branch
components/dashboard/GoalFocusCard.vuemodifiedSame, plus the root aria-label
components/goal/GoalHero.vuemodifiedprogress prop accepts null; the metrics row drops the percent token
components/ui/KpiStat.vuemodified (light)value accepts null; gains an absent boolean for the stepped-down absent treatment
components/goal/GoalAtAGlance.vuemodified (copy)'No data yet''Not tracked', rendered via KpiStat's absent
views/Goals.vuemodifiedAvg Progress tile renders the absent treatment on null
components/dashboard/streamlined/UrgentGoalCard.vuereused, unchangedAlready v-if="progress !== null". Precedent, not a target.
components/RoadmapProgressBar.vuereused, unchangedSelf-gates on hasRoadmap. The card and hero now agree with it.
components/community/CommunityGoals.vuemodified (Amendment 1)Card progress row and the Community Progress aggregate both gain the tri-state; the four progress-band colours (getProgressColor) are deleted, not re-tinted. See Amendment 1.
views/Community.vuemodified (light, Amendment 1)Passes loading through to CommunityGoals — without it the loading state is unreachable.

The rule

A number appears only when a number was observed.loading gets a skeleton. not-tracked gets words. Neither ever gets a digit, a percent sign, or a filled bar.

And its corollary, which is the acceptance criterion the reported bug actually violates:

Loading and not-tracked must never be confusable with each other. One is "we don't know yet", the other is "there is nothing to know". A user who can't tell them apart will wait for a number that is never coming.


Microcopy

One concept, one string. 'No data yet' is retired — it promises data is on the way, when the real situation is that nothing is being measured.

SurfaceStateStringTone check
Card progress row (standard / expanded / focus)not-trackedNot trackedhonest ✅, brief ✅, not apologetic ✅
Card minimized row (56px)not-tracked (em dash)glyph-only — see the exception below
Detail hero metrics rownot-trackedProgress not trackeddisambiguates in a row of mixed metrics ✅
GoalAtAGlance Progress tilenot-trackedNot trackedreplaces No data yet
Avg Progress KPI tilenullNot tracked
Every surfaceloading(no string — skeleton only)a word would be read as a value ✅

The one glyph exception. The 56px minimized row has a truncating title, a percent slot, and two 44px controls competing for ~340px at 375. Not tracked does not fit without pushing the title to two words. It renders , aria-hidden, and the words live in the row's accessible name instead. This is the only place a glyph substitutes for the string — do not generalize it. An em dash was specifically rejected in the KPI strip, where a short muted bar sitting beside a skeleton bar reads as a second skeleton.

Not used anywhere: 0%, —%, N/A, No data, Untracked, Nothing yet, Oops.


Layout

Card progress row — one slot, three renderings

The row's box is identical in all three states. Height never changes, so a card cannot reflow when its state resolves and a grid of mixed-state cards stays flush.

LOADING                        TRACKED                        NOT TRACKED
┌─────────────────────────┐   ┌─────────────────────────┐   ┌─────────────────────────┐
│ PROGRESS       ▓▓▓▓▓    │   │ PROGRESS           68%  │   │ PROGRESS    Not tracked │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │   │ ████████████░░░░░░░░░░░ │   │ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
└─────────────────────────┘   └─────────────────────────┘   └─────────────────────────┘
  shimmer pill + shimmer rail   display face + tinted fill    muted body face
                                                             + dashed empty rail

The dashed rail is doing the load-bearing work: a solid track with a 0-width fill is exactly what the bug renders today. A dashed outline with no fill node reads as an empty container awaiting a scale, not as zero progress against a known one.

Card grid — untracked cards are ordinary peers

An untracked card keeps its full width, its cover, its meta line, its footer and its check-in button. Only the value slot and the rail change. It does not hide the row, gain a border, span extra columns, or sort differently in the visual grid.

sm+ (640px+), GoalsSection condensed column, items-start

┌──────────────────┐ ┌──────────────────┐
│ Morning run      │ │ Ship the site    │   Row 1 — both tracked, flush
│ PROGRESS    68%  │ │ PROGRESS   100%  │
│ ████████░░░░░░░  │ │ ███████████████  │
└──────────────────┘ └──────────────────┘
┌──────────────────┐
│ Read more…       │                        Row 2 — untracked, identical box
│ PROGRESS  Not tr.│
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  │
└──────────────────┘

Orion's constraint — "whichever you pick must not make untracked cards ragged next to tracked ones" — is why hiding the row was rejected. UrgentGoalCard's v-if="progress !== null" is the right idiom for a single centred focus card with nothing beside it; inside a grid it produces two card heights and a visibly broken rhythm.

Minimized row (56px)

┌──────────────────────────────────────────────┐
│ ● Morning run              68%  [✓]  [⌄]     │  tracked
│▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁░░░░░░░░░░░░░░░░│  2px rule at 68%
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ ● Read more this year        —  [✓]  [⌄]     │  not tracked
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│  2px rule, bare track, no tint
└──────────────────────────────────────────────┘

The rule renders as bare track at zero width. It is 2px of muted grey with no category tint — visually inert, not "0% of the way along".

Detail hero metrics row

The row is already a variable-length token list (68% · On Track · 2/5 · 🔥 12). Tracked leads with the number. Not tracked drops the lead token entirely and appends the words as a peer of the status label:

tracked        68%  ·  On Track  ·  2/5
not tracked    On Track  ·  Progress not tracked

Absence should not be the largest, first thing on the page. Promoting it to the lead slot at text-sm font-bold would make "we have no metric" the loudest statement about the goal — the mirror-image mistake of fabricating a 0%.

KPI tiles — GoalAtAGlance and the /goals strip

The absent value steps one rung down the type ramp (text-2xltext-lg) and goes muted. This is a deliberate, two-part decision:

  • Down one rung, not two. Not tracked at text-2xl overflows a 4-across tile at 1280 and a 2-across tile at 375. At text-lg it fits both without truncating.
  • Muted, not text-foreground. A neighbouring tile showing a real metric must stay the heavier thing in the row.

The tile keeps its height, so GoalAtAGlance's 2×2 grid stays square and the /goals strip stays flush.


Design tokens

No new tokens. Every value below already exists in design_system/css/tokens.css.

SurfaceStateToken / classNote
Progress valuetrackedfont-display font-bold tabular-nums text-foregroundunchanged
Progress valuenot-trackedtext-xs font-semibold text-muted-foregroundbody face, not display — cannot be misread as a metric
Progress valueloadingbg-muted-foreground/22 pill, h-3.5 w-9 rounded-mdsized to the number it replaces
Progress tracktrackedbg-muted/60 + tinted --g-tint fillunchanged
Progress tracknot-trackedbg-transparent border border-dashed border-muted-foreground/50dark: /60. No fill element in the DOM.
Progress trackloadingbg-muted-foreground/16 + pulseno fill element
Minimized rulenot-trackedbg-muted/70, fill width 0tint dropped
Hero absent tokennot-trackedtext-xs font-medium text-white/72peer of the status label
Hero loading tokenloadingbg-white/30 pill, h-3 w-8 rounded-mdover the gradient/photo band
KPI absent valuenot-tracked / nulltext-lg font-semibold text-muted-foregroundone rung down from text-2xl
KPI loadingloadingexisting KpiStat skeletonunchanged

Dark-mode deltas. Only two: the dashed rail goes /50/60 (the dash needs more weight against a dark card), and --muted-foreground resolves to 215 20% 65% per the token file. Everything else is token-derived and inverts for free. Verified in both themes at all three viewports — 0 axe-core contrast violations.


State catalog

GoalGridCard / GoalFocusCard — progress row

StateValue slotTrackNotes
Loadingshimmer pillshimmer rail, no fill nodemotion-safe pulse only
Tracked68% display facetinted fill, motion-safe:transition-all duration-700unchanged from today
Not trackedNot tracked muted body facedashed empty rail, no fill node
Empty (0% observed)0% display facetinted fill at 0 widthA genuine, observed zero still renders 0%. This state is not the bug; fabricating it was.
Error(inherits not-tracked)(inherits not-tracked)A failed query yields no observation. Do not invent an error treatment on the card — the surrounding card already carries the section-level error path.
Disabled / pausedas above, text-muted-foreground on the titleas aboveorthogonal to progress state; both apply
Focusn/a — the row is not interactiveThe card's .gc-open stretched control is unchanged
Hover / activeno changeno changeProgress is not a control
Dark modedashed rail /60otherwise token-derived
Motion-reducedno pulse; static muted pill and railno width transitionsee Motion

GoalHero — metrics row

StateRendering
Loadingw-8 h-3 white/30 pill in the lead slot; other tokens render normally
Tracked68% lead token, unchanged
Not trackedlead token removed; Progress not tracked appended after the status label
Dark modeidentical — the hero band is always dark-overlaid, both themes
Motion-reducedno pulse on the loading pill

KpiStat — Progress / Avg Progress

StateRendering
Loadingexisting skeleton (loading prop). Unchanged.
Tracked3.2 / 5 km, 84% — unchanged
Not tracked / nullNot tracked, text-lg font-semibold text-muted-foreground
Dark modetoken-derived

Accessibility

The number is inside three accessible names today. GoalGridCard.vue:313 (openAriaLabel), GoalFocusCard.vue:14 (root aria-label), and the minimized title button all interpolate ${Math.round(props.progress)} percent. A null progress there produces "NaN percent" or "0 percent" — the bug, read aloud. All three need the branch.

ElementTracked accessible nameNot-tracked accessible name
GoalGridCard title button (openAriaLabel)Morning run — on track, 68 percent. Open goalRead more this year — on track, progress not tracked. Open goal
GoalGridCard minimized title buttonsame patternsame pattern — this is where the em dash gets its words back
GoalFocusCard root aria-labelMorning run, On track, 68%Read more this year, On track, progress not tracked
Loading (any of the above)…, progress loading

Other requirements:

  • The minimized is aria-hidden="true". A bare em dash is announced inconsistently across screen readers ("em dash", "dash", or silence). The words are carried by the title button's name instead.
  • The dashed rail is decorative. It carries no role="progressbar" when not tracked — a progressbar with no aria-valuenow is worse than no progressbar. Drop the role along with the number.
  • Where a role="progressbar" exists in the tracked state, remove the whole element when not tracked — do not render it with aria-valuenow="0".
  • Contrast: text-muted-foreground on bg-card is 7.4:1 light / 8.1:1 dark — AA with headroom in both. The dashed rail is a non-text decorative border paired with a text label, so it carries no contrast obligation of its own; it is nonetheless above 3:1 in both themes.
  • Tap targets: unchanged. This spec adds no controls.
  • Focus order: unchanged. The progress row is not focusable in any state.
  • axe-core: 0 violations across all 6 viewport × theme captures of the mockup.

Motion

The only motion this spec introduces is the skeleton → resolved transition, and it must read differently for the two outcomes:

TransitionTreatment
loading → trackedpulse stops; the fill grows from 0 to percent over 700ms cubic-bezier(0.22, 1, 0.36, 1) — the existing motion-safe:transition-all motion-safe:duration-700 on the bar, unchanged
loading → not trackedpulse stops; the rail cross-fades to its dashed state over 200ms ease-out. No width animation — there is nothing to animate toward.

That asymmetry is deliberate and is the reason motion evidence is attached: a growing bar means "here is your number", a settling dashed rail means "there is no number". If both animated identically, the not-tracked rail would read as a bar that failed to fill.

prefers-reduced-motion: reduce — skeleton pulses become static muted fills; the bar sets its width with no transition; the rail swaps with no cross-fade. Both end states are identical to the animated ones.


Deliberate non-goal: no CTA

The not-tracked state has an obvious forward action — give the goal something to measure. This spec does not add one, for three reasons:

  1. The card root is already a click target with a stretched .gc-open control; adding a second control inside it re-opens the nested-interactive problem GoalGridCard solved under OBJ-1945.
  2. The card is not an empty state — it has a title, a category, a status, a last-update and a check-in button. The "every empty state gets a forward action" rule governs empty lists, not an absent metric inside populated content.
  3. Tapping the card already opens the goal, which is where a target or a roadmap gets set.

If we want to nudge, the right surface is the goal detail page and the right ticket is a separate one. Flagging it here so it reads as a decision, not an omission.


Storybook story brief

components/dashboard/GoalGridCard.stories.ts (existing — add)

  • NotTrackedprogress: null, view: 'standard'
  • NotTrackedExpandedprogress: null, view: 'expanded'
  • NotTrackedMinimizedprogress: null, view: 'minimized'
  • ProgressLoadingprogressState: 'loading'
  • argTypes: progresscontrol: { type: 'number' } with null explicitly allowed; progressStatecontrol: 'select', options ['loading', 'tracked', 'not-tracked']

components/dashboard/GoalFocusCard.stories.ts (existing — add)

  • NotTracked, ProgressLoading

components/goal/GoalHero.stories.ts (existing — add)

  • NotTrackedprogress: null
  • NotTrackedWithMilestones — proves the metrics row still reads correctly when the lead token is gone but later tokens remain
  • ProgressLoading

components/ui/KpiStat.stories.ts (existing — add)

  • Absentvalue: 'Not tracked', absent: true
  • Include it in the existing side-by-side story so the type-ramp step-down is visible next to a real metric

Every story needs its dark variant via the existing theme decorator.


Acceptance criteria for Codi

  • [ ] No component renders 0%, —%, NaN%, or a filled bar when progress === null
  • [ ] default: 0 is removed from the progress prop on GoalGridCard, GoalFocusCard, and GoalHero — a card mounted without the prop must not claim 0%
  • [ ] Loading and not-tracked are visually distinguishable at 375px in both themes, without reading the label
  • [ ] A genuine, observed 0% still renders 0% with a tinted fill at 0 width — the fix must not swallow real zeros
  • [ ] The dashed not-tracked rail has no fill element in the DOM, not a fill at width: 0
  • [ ] All three accessible names carry progress not tracked; none emits 0 percent, NaN percent, or null percent
  • [ ] The minimized is aria-hidden="true"
  • [ ] No role="progressbar" survives without an aria-valuenow
  • [ ] GoalAtAGlance's 'No data yet' is replaced by 'Not tracked'grep -rn "No data yet" ionic_frontend/src returns zero
  • [ ] Goals.vue's Avg Progress tile renders Not tracked on null and the skeleton on loading — never null%
  • [ ] Untracked and tracked cards are the same height in the sm:grid-cols-2 column at 640px and 1280px
  • [ ] Every microcopy string matches the Microcopy table verbatim
  • [ ] Storybook stories cover every variant above, light and dark
  • [ ] prefers-reduced-motion: reduce removes both pulses and both transitions; end states unchanged
  • [ ] Tap targets ≥44px (unchanged — this spec adds no controls)
  • [ ] WCAG AA verified in both themes

Verification note for the manual pass

Orion's Task 1 and the issue's own acceptance criteria both require the reported goal to be checked end to end. On 2aua0oA, after this ships, all five of these must agree: the goal card, the detail hero, GoalAtAGlance, the roadmap bar, and the Avg Progress KPI tile. That goal has a completed 1/1 roadmap, so under the settled precedence every one of them should read 100% — not 0%, and not "Not tracked". The not-tracked treatment specified here is for a goal with no habit rate, no roadmap and no target; 2aua0oA is the control that proves the precedence works, not the example of the new state.



Amendment 1 — fifth surface: the community goal card (OBJ-3561)

Issue: OBJ-3561 · Task package: Orion, comment 2cb049dd on OBJ-3561 (Task 1) · Branch: fix/obj-3561-community-progress-honestyMockup: mockups/obj-3561-community-goal-progress/ — two HTML boards, 6 stills, 2 motion clips, 0 axe-core violations. Untracked per OBJ-3192; regenerate from the README, or read the rendered captures off OBJ-3561.

Why this surface was missed

The original inventory listed four surfaces. CommunityGoals.vue is a fifth, and it shipped the exact anti-pattern this spec exists to remove — one branch further along:

js
// CommunityGoals.vue:139-141, current
const progress = g.targetAmount && g.currentAmount
  ? Math.min(100, Math.round((g.currentAmount / g.targetAmount) * 100))
  : (g.completed ? 100 : (g.allEvents?.length || 0) > 0 ? 50 : 0)

COMMUNITY_GOALS_QUERY never requests allEvents, so the 50 branch is unreachable against live data. Every untracked community goal renders a solid 0% with a filled-to-zero rail — the reported bug, on a surface nobody inventoried.

The colour question that opened OBJ-3561 sits on top of that number. Fixing the palette first would only have made the fabrication legible.

The ruling: no legend, and the bands go

getProgressColor() (CommunityGoals.vue:190-195) returns four hues keyed to progress bands — bg-success ≥75, bg-primary ≥50, bg-warning ≥25, bg-muted-foreground below. The bands are deleted. No legend is added, now or later. Three reasons, in order of weight:

  1. The colour is redundant. The row already renders the number directly above the rail. A legend would be permanent chrome explaining a number back to a user who can already read it.
  2. The 25/50/75 cuts are backed by no product concept. Nothing in the codebase defines those thresholds. bg-warning at 25–49% asserts "at risk" — a judgment the product never makes. For a coaching product it is also the wrong default: telling someone 30% into a year-long goal that they are in a warning state manufactures anxiety out of an arbitrary cut.
  3. The product already has the labelled taxonomy the bands were groping for. Pace — ahead | on_track | behind, server-derived, rendered in words by PaceIndicator.vue:33-38. GoalGridCard.vue sets the house convention explicitly: the bar fill is the goal's category tint (--g-tint, line 329), while judgment lives in a labelled status chip (lines 332-345). Colour carries identity; words carry judgment. The community bands inverted both halves.

If a categorical signal is ever wanted on this surface, the answer is a PaceIndicator, not a re-tinted rail. Not in this change — pace comes from a per-goal goalProgressData query and adding it to a list surface risks an N+1.

The tracked fill token

--g-tint, the category tint — the same token GoalGridCard.vue:329 uses.

background: linear-gradient(90deg, hsl(var(--g-tint)), hsl(var(--g-tint) / 0.7))

Set on the card root: :style="{ '--g-tint': categoryTintVar(goal.category) }" (constants/goalCategoryColors.ts:49). COMMUNITY_GOALS_QUERY already selects category { name }, so nothing new is fetched.

Why the tint and not a flat bg-primary: a community goal card and a dashboard goal card are the same object seen from two places. Giving the same goal a blue rail in the feed and a green one on the dashboard makes colour mean "which screen am I on", which is the one thing it must not mean. categoryTintVar falls back to --cat-other for any unmapped name, so no new mapping is needed.

One copy correction that follows from it. normalizedGoals currently falls back to 'General' for a goal with no category — a label that exists nowhere in the product's own taxonomy, while the tint it resolves to is Other's. Use 'Other', so the chip and the tint agree and the chip names a real category.

Layout — card progress row

The row's box is identical in all three states, same as every other surface in this spec. That matters more here than anywhere else: these cards sit in a TransitionGroup grid (md:grid-cols-2 xl:grid-cols-3), so a 4px height difference between a tracked and an untracked card ripples through the whole row.

The row is pinned to min-h-5. The derivation, not a chosen number: the tracked value renders at text-sm, whose line-height is 1.25rem = 20px = min-h-5. Loading (h-3.5 pill) and not-tracked (text-xs) are both shorter, so without the pin the box collapses by 4px whenever a card resolves to anything but a number.

LOADING                        TRACKED                        NOT TRACKED
┌─────────────────────────┐   ┌─────────────────────────┐   ┌─────────────────────────┐
│ Progress        ▓▓▓▓    │   │ Progress           67%  │   │ Progress    Not tracked │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │   │ ██████████████░░░░░░░░░ │   │ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
└─────────────────────────┘   └─────────────────────────┘   └─────────────────────────┘
  shimmer pill + shimmer rail   display face + category tint   muted body face
                                                              + dashed empty rail

The eyebrow label stays Progress in sentence case at text-xs text-muted-foregrounddeliberately not converted to GoalGridCard's uppercase tracking-eyebrow eyebrow. This amendment changes the value slot and the rail; the surrounding card chrome is not in scope.

Sort placement. Under "Highest Progress", untracked goals sort last — not as 0. A goal with no number is not a goal at the bottom of the range.

Layout — the Community Progress aggregate

The aggregate is a second progress render site on the same surface, and excluding fabricated zeros from the average is only half the fix. The tile needs the same three renderings:

CaseValue slotRailCaption
Every goal tracked62%, unchangedtracked, unchangednone
Some goals trackedthe average of the tracked onestrackedAveraged across 8 of 12 goals
No goal trackedNot trackeddashed empty railnone
Loadingshimmer pillshimmer railnone

The denominator caption is what makes the number honest. Removing fabricated zeros will move a community's average visibly upward; without the caption, a 74% that was 31% yesterday looks like a bug. It appears only in the partial case — when every goal is tracked the denominator is the whole set and saying so is noise.

The supporting line (9 active goals • 3 completed) is unchanged and stays in all four cases, including "no goal tracked" — it is derived from the completed flag, which is a real observed field, not from progress. Do not re-derive those counts from the percentage; that is the same fabrication one level up.

Design tokens

No new tokens. Every value already exists; the deltas from the current component are listed so Codi can diff them.

ElementStateToken / classDelta from today
Card value slottrackedfont-display text-sm font-bold tabular-nums text-foregroundwas text-xs font-bold text-foreground — adopts the card contract's display face
Card value slotnot-trackedtext-xs font-semibold text-muted-foregroundnew
Card value slotloadingh-3.5 w-9 rounded-md bg-muted-foreground/[22%] motion-safe:animate-pulse, aria-hiddennew
Card railtrackedh-2 rounded-full overflow-hidden, track hsl(var(--muted) / 0.6), fill --g-tint gradientwas bg-muted + one of four band colours
Card railnot-trackedh-2 rounded-full border border-dashed border-muted-foreground/50 dark:border-muted-foreground/60new. No fill element in the DOM.
Card railloadingh-2 rounded-full overflow-hidden bg-muted-foreground/[16%] motion-safe:animate-pulsenew
Card value rowallflex items-baseline justify-between min-h-5 mb-2was flex justify-between text-xs mb-1.5mb-1.5 (6px) is off the spacing scale
Aggregate valuetrackedfont-display text-3xl font-bold tabular-nums text-foregroundadds tabular-nums
Aggregate valuenot-trackedfont-display text-2xl font-semibold text-muted-foregroundnew — one rung down the ramp, per the KPI rule above
Aggregate captionpartialmt-1 text-xs text-muted-foregroundnew
Aggregate railnot-trackedh-3 rounded-full border border-dashed border-muted-foreground/50 dark:border-muted-foreground/60new
getProgressColordeletedbg-success / bg-primary / bg-warning / bg-muted-foreground all removed

Dark-mode deltas. One, and it is the same one as every other surface here: the dashed rail goes /50/60. Everything else is token-derived and inverts for free. Verified in both themes at 390 / 768 / 1280 — 0 axe-core violations.

Microcopy

SurfaceStateStringTone check
Card value slotnot-trackedNot trackedreused verbatim from the table above — no new string ✅
Aggregate valueno goal trackedNot trackedreused ✅
Aggregate captionpartialAveraged across 8 of 12 goalsstates what the number is, brief ✅
Category chipuncategorisedOthernames a real category ✅ (was General, which names none)
Every surfaceloading(no string — skeleton only)

Not used anywhere on this surface: 0%, —%, N/A, No data, Untracked, Nothing yet, and any band label (At risk, On track, Behind). The em-dash glyph exception belongs to the 56px minimized row only and does not apply here.

Accessibility

The card has no accessible name today. It is a <div role="button" tabindex="0"> whose name is computed from its contents, so a screen reader currently announces the owner, the title, the category, the progress and the days-left as one run-on string — and after the tri-state lands, 0 percent would become NaN percent. Give it an explicit aria-label, mirroring GoalGridCard's openAriaLabel:

StateAccessible name
TrackedRun 100 miles this month by Sarah Chen — 67 percent. Open goal
Not trackedRead 12 books this year by Mike Johnson — progress not tracked. Open goal
LoadingLearn Spanish — 100 hours by David Kim — progress loading. Open goal

Pattern: ${title} by ${ownerName} — ${progressAriaFragment}. Open goal. The fragments (progress not tracked / progress loading) are the same two strings GoalGridCard.vue:358-362 already emits — reuse them, don't reword.

Other requirements:

  • The loading pill is aria-hidden="true". The words are carried by the card's own name.
  • The dashed rail is decorative and carries no role="progressbar", in any state. Neither the card rail nor the aggregate rail has one today — do not add one. A progressbar with no aria-valuenow is worse than no progressbar.
  • Contrast: text-muted-foreground on the bg-card/60 community card measures 6.9:1 light / 7.6:1 dark — AA with headroom in both. The dashed rail is decorative non-text paired with a text label and carries no obligation of its own; it clears 3:1 in both themes regardless.
  • Focus order and tap targets: unchanged. This amendment adds no controls and moves nothing.

Motion

Same two transitions as the card surfaces, same asymmetry, and it is the reason motion evidence is attached rather than stills alone:

TransitionTreatment
loading → trackedpulse stops; the fill grows from 0 to percent over 700ms cubic-bezier(0.22, 1, 0.36, 1)
loading → not trackedpulse stops; the rail cross-fades to its dashed state over 200ms ease-out. No width animation.

The 700ms is inherited from the card contract in the Motion section above, not chosen here — the community card and the dashboard card are the same object and must resolve at the same speed. It supersedes the component's current duration-500. The aggregate rail keeps its existing duration-1000; it is a different, larger element and this amendment does not retime it.

prefers-reduced-motion: reduce — skeleton pulses become static muted fills; the bar sets its width with no transition; the rail swaps with no cross-fade. Both end states identical to the animated ones.

Storybook story brief

components/community/CommunityGoals.stories.ts (existing — add)

  • MixedStates — one grid containing a tracked habit goal, a tracked completed goal, an untracked goal and a loading goal, proving all four cards are the same height
  • AllUntracked — every card untracked; the aggregate renders Not tracked with no caption
  • PartiallyTracked — the aggregate renders a number plus the Averaged across N of M goals caption
  • Loadingloading: true, whole grid skeletal
  • argTypes: loadingcontrol: 'boolean'; goals → object control with a fixture exercising each useGoalProgress branch (recurrenceType + completionRate, milestones, targetAmount/currentAmount, and none of the above)

Every story needs its dark variant via the existing theme decorator.

Acceptance criteria for Codi (Amendment 1)

  • [ ] A community goal with no habit rate, no roadmap and no targetAmount renders Not tracked and a dashed rail — no 0%, no filled-to-zero rail
  • [ ] A genuine, observed 0% still renders 0% with a tinted fill at 0 width
  • [ ] grep -n getProgressColor ionic_frontend/src/components/community/CommunityGoals.vue returns nothing, and no progress→colour mapping replaces it
  • [ ] The tracked fill is the category tint via --g-tint, set from categoryTintVar(goal.category) on the card root
  • [ ] The value row carries min-h-5; a tracked, an untracked and a loading card are pixel-identical in height at 390px, 768px and 1280px
  • [ ] The not-tracked rail has no fill element in the DOM, not a fill at width: 0
  • [ ] views/Community.vue passes loading from useCommunityGoals into CommunityGoals — the loading state must be reachable
  • [ ] The aggregate renders Not tracked when no goal is tracked, and the Averaged across N of M goals caption when only some are
  • [ ] 9 active goals • 3 completed is derived from completed, never from progress
  • [ ] Untracked goals sort last under "Highest Progress"
  • [ ] The card carries an explicit aria-label matching the Accessibility table; none emits 0 percent, NaN percent or null percent
  • [ ] The uncategorised chip reads Other, not General
  • [ ] No role="progressbar" is added to either rail
  • [ ] Every microcopy string matches the Microcopy table verbatim
  • [ ] Storybook stories cover every variant above, light and dark
  • [ ] prefers-reduced-motion: reduce removes both pulses and both transitions; end states unchanged
  • [ ] WCAG AA verified in both themes

Considered and declined

  • A legend for the bands. See the ruling above. Recorded here so it isn't re-opened as an oversight.
  • A PaceIndicator in place of the deleted colour. The right categorical signal, the wrong surface for now — pace is a per-goal query and this is a list. Its own ticket if the feed reads thin after this lands.
  • Stepping the aggregate's decorative trending-up glyph down in the not-tracked state. A full-weight "trending up" arrow beside a Not tracked value is a mild honesty wobble, but the glyph is state-independent chrome in all four cases and re-weighting it per state is card-chrome work this amendment doesn't own.
  • Converting the Progress label to the uppercase eyebrow GoalGridCard uses. Same reason — chrome, not the value slot.
  • A "set a target" CTA on the untracked card. The Deliberate non-goal reasoning above applies unchanged, and more strongly: this is someone else's goal.

Recorded, not fixed here

Found in the same code block, out of this amendment's scope — flagged rather than folded in:

  • CommunityGoals.vue:157-165 ships a four-item hardcoded mock-goal fallback to production, and it fires during loading, so a real user sees four fabricated goals before their community's own load. This amendment's loading state cannot be reached until that fallback is gated.
  • encouragements and createdAt are derived from fields the query never requests, so the "Popular" and "Recent" sorts are silent no-ops (:158-168).
  • .goal-card's goalSlideIn entrance animation has no prefers-reduced-motion guard (<style scoped>). It is an entrance animation, not an input-state transform, so the global neutralisation in theme/tailwind.css does not cover it.
  • The card is <div role="button" tabindex="0"> with @keyup.enter only — no Space-key handler, which a real role="button" owes.
  • The card surface is bg-card/60, one of the drifted glass alphas the .glass-card recipe was ruled to replace (light 0.70 / dark 0.40).

Open questions

None blocking.

Loading…