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:
- Card progress row —
GoalGridCard.vue(standard + expanded) andGoalFocusCard.vue - Card minimized row —
GoalGridCard.vue(view === 'minimized'), a 56px row with different space constraints - Detail hero metrics row —
GoalHero.vue:42 - Avg Progress KPI tile —
Goals.vue:97viaKpiStat.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
| Component | Status | Change |
|---|---|---|
components/dashboard/GoalGridCard.vue | modified | progress prop accepts null; progress row + minimized row + openAriaLabel gain the not-tracked branch |
components/dashboard/GoalFocusCard.vue | modified | Same, plus the root aria-label |
components/goal/GoalHero.vue | modified | progress prop accepts null; the metrics row drops the percent token |
components/ui/KpiStat.vue | modified (light) | value accepts null; gains an absent boolean for the stepped-down absent treatment |
components/goal/GoalAtAGlance.vue | modified (copy) | 'No data yet' → 'Not tracked', rendered via KpiStat's absent |
views/Goals.vue | modified | Avg Progress tile renders the absent treatment on null |
components/dashboard/streamlined/UrgentGoalCard.vue | reused, unchanged | Already v-if="progress !== null". Precedent, not a target. |
components/RoadmapProgressBar.vue | reused, unchanged | Self-gates on hasRoadmap. The card and hero now agree with it. |
components/community/CommunityGoals.vue | modified (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.vue | modified (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.
loadinggets a skeleton.not-trackedgets 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.
| Surface | State | String | Tone check |
|---|---|---|---|
| Card progress row (standard / expanded / focus) | not-tracked | Not tracked | honest ✅, brief ✅, not apologetic ✅ |
| Card minimized row (56px) | not-tracked | — (em dash) | glyph-only — see the exception below |
| Detail hero metrics row | not-tracked | Progress not tracked | disambiguates in a row of mixed metrics ✅ |
GoalAtAGlance Progress tile | not-tracked | Not tracked | replaces No data yet ✅ |
| Avg Progress KPI tile | null | Not tracked | ✅ |
| Every surface | loading | (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 railThe 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 trackedAbsence 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-2xl → text-lg) and goes muted. This is a deliberate, two-part decision:
- Down one rung, not two.
Not trackedattext-2xloverflows a 4-across tile at 1280 and a 2-across tile at 375. Attext-lgit 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.
| Surface | State | Token / class | Note |
|---|---|---|---|
| Progress value | tracked | font-display font-bold tabular-nums text-foreground | unchanged |
| Progress value | not-tracked | text-xs font-semibold text-muted-foreground | body face, not display — cannot be misread as a metric |
| Progress value | loading | bg-muted-foreground/22 pill, h-3.5 w-9 rounded-md | sized to the number it replaces |
| Progress track | tracked | bg-muted/60 + tinted --g-tint fill | unchanged |
| Progress track | not-tracked | bg-transparent border border-dashed border-muted-foreground/50 | dark: /60. No fill element in the DOM. |
| Progress track | loading | bg-muted-foreground/16 + pulse | no fill element |
| Minimized rule | not-tracked | bg-muted/70, fill width 0 | tint dropped |
| Hero absent token | not-tracked | text-xs font-medium text-white/72 | peer of the status label |
| Hero loading token | loading | bg-white/30 pill, h-3 w-8 rounded-md | over the gradient/photo band |
| KPI absent value | not-tracked / null | text-lg font-semibold text-muted-foreground | one rung down from text-2xl |
| KPI loading | loading | existing KpiStat skeleton | unchanged |
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
| State | Value slot | Track | Notes |
|---|---|---|---|
| Loading | shimmer pill | shimmer rail, no fill node | motion-safe pulse only |
| Tracked | 68% display face | tinted fill, motion-safe:transition-all duration-700 | unchanged from today |
| Not tracked | Not tracked muted body face | dashed empty rail, no fill node | |
| Empty (0% observed) | 0% display face | tinted fill at 0 width | A 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 / paused | as above, text-muted-foreground on the title | as above | orthogonal to progress state; both apply |
| Focus | n/a — the row is not interactive | The card's .gc-open stretched control is unchanged | |
| Hover / active | no change | no change | Progress is not a control |
| Dark mode | dashed rail /60 | otherwise token-derived | |
| Motion-reduced | no pulse; static muted pill and rail | no width transition | see Motion |
GoalHero — metrics row
| State | Rendering |
|---|---|
| Loading | w-8 h-3 white/30 pill in the lead slot; other tokens render normally |
| Tracked | 68% lead token, unchanged |
| Not tracked | lead token removed; Progress not tracked appended after the status label |
| Dark mode | identical — the hero band is always dark-overlaid, both themes |
| Motion-reduced | no pulse on the loading pill |
KpiStat — Progress / Avg Progress
| State | Rendering |
|---|---|
| Loading | existing skeleton (loading prop). Unchanged. |
| Tracked | 3.2 / 5 km, 84% — unchanged |
| Not tracked / null | Not tracked, text-lg font-semibold text-muted-foreground |
| Dark mode | token-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.
| Element | Tracked accessible name | Not-tracked accessible name |
|---|---|---|
GoalGridCard title button (openAriaLabel) | Morning run — on track, 68 percent. Open goal | Read more this year — on track, progress not tracked. Open goal |
GoalGridCard minimized title button | same pattern | same pattern — this is where the em dash gets its words back |
GoalFocusCard root aria-label | Morning run, On track, 68% | Read more this year, On track, progress not tracked |
| Loading (any of the above) | — | …, progress loading |
Other requirements:
- The minimized
—isaria-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 noaria-valuenowis 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 witharia-valuenow="0". - Contrast:
text-muted-foregroundonbg-cardis 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:
| Transition | Treatment |
|---|---|
| loading → tracked | pulse 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 tracked | pulse 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:
- The card root is already a click target with a stretched
.gc-opencontrol; adding a second control inside it re-opens the nested-interactive problemGoalGridCardsolved under OBJ-1945. - 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.
- 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)
NotTracked—progress: null,view: 'standard'NotTrackedExpanded—progress: null,view: 'expanded'NotTrackedMinimized—progress: null,view: 'minimized'ProgressLoading—progressState: 'loading'argTypes:progress→control: { type: 'number' }withnullexplicitly allowed;progressState→control: 'select', options['loading', 'tracked', 'not-tracked']
components/dashboard/GoalFocusCard.stories.ts (existing — add)
NotTracked,ProgressLoading
components/goal/GoalHero.stories.ts (existing — add)
NotTracked—progress: nullNotTrackedWithMilestones— proves the metrics row still reads correctly when the lead token is gone but later tokens remainProgressLoading
components/ui/KpiStat.stories.ts (existing — add)
Absent—value: '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 whenprogress === null - [ ]
default: 0is removed from theprogressprop onGoalGridCard,GoalFocusCard, andGoalHero— 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 renders0%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 emits0 percent,NaN percent, ornull percent - [ ] The minimized
—isaria-hidden="true" - [ ] No
role="progressbar"survives without anaria-valuenow - [ ]
GoalAtAGlance's'No data yet'is replaced by'Not tracked'—grep -rn "No data yet" ionic_frontend/srcreturns zero - [ ]
Goals.vue's Avg Progress tile rendersNot trackedon null and the skeleton on loading — nevernull% - [ ] Untracked and tracked cards are the same height in the
sm:grid-cols-2column at 640px and 1280px - [ ] Every microcopy string matches the Microcopy table verbatim
- [ ] Storybook stories cover every variant above, light and dark
- [ ]
prefers-reduced-motion: reduceremoves 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:
// 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:
- 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.
- The 25/50/75 cuts are backed by no product concept. Nothing in the codebase defines those thresholds.
bg-warningat 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. - The product already has the labelled taxonomy the bands were groping for. Pace —
ahead | on_track | behind, server-derived, rendered in words byPaceIndicator.vue:33-38.GoalGridCard.vuesets 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 railThe eyebrow label stays Progress in sentence case at text-xs text-muted-foreground — deliberately 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:
| Case | Value slot | Rail | Caption |
|---|---|---|---|
| Every goal tracked | 62%, unchanged | tracked, unchanged | none |
| Some goals tracked | the average of the tracked ones | tracked | Averaged across 8 of 12 goals |
| No goal tracked | Not tracked | dashed empty rail | none |
| Loading | shimmer pill | shimmer rail | none |
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.
| Element | State | Token / class | Delta from today |
|---|---|---|---|
| Card value slot | tracked | font-display text-sm font-bold tabular-nums text-foreground | was text-xs font-bold text-foreground — adopts the card contract's display face |
| Card value slot | not-tracked | text-xs font-semibold text-muted-foreground | new |
| Card value slot | loading | h-3.5 w-9 rounded-md bg-muted-foreground/[22%] motion-safe:animate-pulse, aria-hidden | new |
| Card rail | tracked | h-2 rounded-full overflow-hidden, track hsl(var(--muted) / 0.6), fill --g-tint gradient | was bg-muted + one of four band colours |
| Card rail | not-tracked | h-2 rounded-full border border-dashed border-muted-foreground/50 dark:border-muted-foreground/60 | new. No fill element in the DOM. |
| Card rail | loading | h-2 rounded-full overflow-hidden bg-muted-foreground/[16%] motion-safe:animate-pulse | new |
| Card value row | all | flex items-baseline justify-between min-h-5 mb-2 | was flex justify-between text-xs mb-1.5 — mb-1.5 (6px) is off the spacing scale |
| Aggregate value | tracked | font-display text-3xl font-bold tabular-nums text-foreground | adds tabular-nums |
| Aggregate value | not-tracked | font-display text-2xl font-semibold text-muted-foreground | new — one rung down the ramp, per the KPI rule above |
| Aggregate caption | partial | mt-1 text-xs text-muted-foreground | new |
| Aggregate rail | not-tracked | h-3 rounded-full border border-dashed border-muted-foreground/50 dark:border-muted-foreground/60 | new |
getProgressColor | — | deleted | bg-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
| Surface | State | String | Tone check |
|---|---|---|---|
| Card value slot | not-tracked | Not tracked | reused verbatim from the table above — no new string ✅ |
| Aggregate value | no goal tracked | Not tracked | reused ✅ |
| Aggregate caption | partial | Averaged across 8 of 12 goals | states what the number is, brief ✅ |
| Category chip | uncategorised | Other | names a real category ✅ (was General, which names none) |
| Every surface | loading | (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:
| State | Accessible name |
|---|---|
| Tracked | Run 100 miles this month by Sarah Chen — 67 percent. Open goal |
| Not tracked | Read 12 books this year by Mike Johnson — progress not tracked. Open goal |
| Loading | Learn 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 noaria-valuenowis worse than no progressbar. - Contrast:
text-muted-foregroundon thebg-card/60community 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:
| Transition | Treatment |
|---|---|
| loading → tracked | pulse stops; the fill grows from 0 to percent over 700ms cubic-bezier(0.22, 1, 0.36, 1) |
| loading → not tracked | pulse 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 heightAllUntracked— every card untracked; the aggregate rendersNot trackedwith no captionPartiallyTracked— the aggregate renders a number plus theAveraged across N of M goalscaptionLoading—loading: true, whole grid skeletalargTypes:loading→control: 'boolean';goals→ object control with a fixture exercising eachuseGoalProgressbranch (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
targetAmountrendersNot trackedand a dashed rail — no0%, no filled-to-zero rail - [ ] A genuine, observed
0%still renders0%with a tinted fill at 0 width - [ ]
grep -n getProgressColor ionic_frontend/src/components/community/CommunityGoals.vuereturns nothing, and no progress→colour mapping replaces it - [ ] The tracked fill is the category tint via
--g-tint, set fromcategoryTintVar(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.vuepassesloadingfromuseCommunityGoalsintoCommunityGoals— the loading state must be reachable - [ ] The aggregate renders
Not trackedwhen no goal is tracked, and theAveraged across N of M goalscaption when only some are - [ ]
9 active goals • 3 completedis derived fromcompleted, never fromprogress - [ ] Untracked goals sort last under "Highest Progress"
- [ ] The card carries an explicit
aria-labelmatching the Accessibility table; none emits0 percent,NaN percentornull percent - [ ] The uncategorised chip reads
Other, notGeneral - [ ] 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: reduceremoves 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
PaceIndicatorin place of the deleted colour. The right categorical signal, the wrong surface for now —paceis 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 trackedvalue 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
Progresslabel to the uppercase eyebrowGoalGridCarduses. 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-165ships 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.encouragementsandcreatedAtare derived from fields the query never requests, so the "Popular" and "Recent" sorts are silent no-ops (:158-168)..goal-card'sgoalSlideInentrance animation has noprefers-reduced-motionguard (<style scoped>). It is an entrance animation, not an input-state transform, so the global neutralisation intheme/tailwind.cssdoes not cover it.- The card is
<div role="button" tabindex="0">with@keyup.enteronly — no Space-key handler, which a realrole="button"owes. - The card surface is
bg-card/60, one of the drifted glass alphas the.glass-cardrecipe was ruled to replace (light 0.70 / dark 0.40).
Open questions
None blocking.