UI-SPEC — v4.48 Phase 1: the streak motif accretion grammar
Issue: OBJ-3101 · Milestone: v4.48 · Author: Desi · Status: ready for Codi Design contract: Streak Motif System.dc.html + README.md (OBJ-3095 attachment 01a05a97-02d8-7abd-8b35-fac695b5e8cd) Mockups: .planning/phases/v4.48-phase-1-streak-motif-grammar/mockups/streak-motif-mark.htmlCaptures: .../mockups/screenshots/ — 6 stills (mobile/tablet/desktop x light/dark) + 2 motion clips (mobile light/dark), axe-core 0 violations. The mockup implements every ruling in this spec against the contract's own mark() geometry, so it is executable evidence rather than an illustration.
Scope
One component — StreakMotif.vue — renders any of the five streak motifs at any day, in any of three states, at any of three Mark sizes, driven by one grammar: a day is a unit, seven units close a group, the newest unit wears gold. This phase specifies the Mark render only (16 / 24 / 44px). Bloom (72px) and Field (140px) are Phase 3 and are out of scope here except where a Mark ruling constrains them, which is called out inline.
This phase is purely additive. StreakMetaphorIcon.vue, StoneBlock.vue, PALETTE_BY_TIER and every existing surface are untouched. No user sees a difference when this merges.
The design contract is the geometry source of truth and is not re-derived here. What this spec resolves is everything the contract could not know about this codebase: the tier axis, the token system, the accessibility contract, and what actually survives 16px.
Component / screen inventory
| Artifact | Status |
|---|---|
ionic_frontend/src/components/gamification/streak/StreakMotif.vue | new |
ionic_frontend/src/components/gamification/streak/streakMotifGeometry.ts | new (pure) |
ionic_frontend/src/components/gamification/streak/StreakMotif.stories.ts | new |
ionic_frontend/tests/unit/streakMotifGeometry.spec.ts | new |
ionic_frontend/src/components/gamification/streak/streakPalette.ts | modified — add MOTIF_PALETTE_BY_TIER alongside PALETTE_BY_TIER; do not touch the existing export |
ionic_frontend/src/components/gamification/streak/streakAnimations.ts | modified — append five entry keyframes to the CSS block |
design_system/css/tokens.css | untouched — zero new tokens (see §4) |
StreakMetaphorIcon.vue, StoneBlock.vue, all call sites | untouched |
1. The two axes — the tier × state matrix
This is the ruling the rest of the milestone depends on. Read it before anything else.
1.1 Why one axis is wrong
The design contract models a single three-value axis: standard | atrisk | broken, one palette each. This app has two independent axes and collapsing them deletes the rank ladder:
- Tier —
StreakTier(ionic_frontend/src/composables/streakRanks.ts:38):standard | rare | epic | legendary | rescued | atrisk. Produced bytierFor(day). Drives the palette. - State — the contract's axis:
standard | atrisk | broken. Drives the treatment (fill vs. hollow, dash, gold crown, opacity).
StreakMotif therefore takes both:
motif: StreakMetaphor // stones | sprout | flame | mountain | waves
day: number // >= 0
tier: StreakTier // palette
state: 'standard' | 'atrisk' | 'broken' // treatment
render: 'mark' | 'bloom' | 'field' // this phase implements 'mark' only
size: number // px
animate?: boolean1.2 Two facts about tierFor that change what the matrix means
Both were verified against streakRanks.ts:38-44, and both should be reflected in the Storybook stories so a reviewer sees the real behaviour rather than the assumed one:
- Tier promotion is momentary, not persistent.
tierForreturnsrare/epic/legendaryonly on the exact milestone days (3/7/14 →rare; 30/50 →epic; 100 and ≥365 →legendary). Day 101 is back tostandard. The ladder that must survive is a one-day flash on the milestone, not a permanent promotion. Day 100 must differ from day 99 and from day 101. tierFornever returnsrescuedoratrisk. Those two values only ever arrive from a call site (they areStoneState, laundered through the same enum). See the normalization rule in §1.4.
1.3 The matrix
Every cell has a stated render or is marked unreachable with a reason. Body/crown token names are defined in §4; contrast figures are in §5.
| tier ↓ / state → | standard | atrisk | broken |
|---|---|---|---|
standard | Body --streak-accent-accessible. Crown: --gold fill + --card knockout + halo. | Tier ignored — dormant render (§3.2). | The only reachable broken cell. Dormant hollow render (§3.3). |
rare | Identical to standard today — see the finding in §9.1. Crown --gold. | Tier ignored — dormant render. | Unreachable. state: 'broken' means day === 0; tierFor(0) === 'standard'. |
epic | Body --gold-accessible. Crown: no gold fill — body colour, --card knockout + --card outline + halo. Gold-on-gold carries no information (1.17:1 in dark, §5.3). | Tier ignored — dormant render. | Unreachable (same reason as rare). |
legendary | Body --coach-accessible. Crown: --gold fill + --card knockout + halo. | Tier ignored — dormant render. | Unreachable (same reason as rare). |
rescued | Body --success. Crown: never gold — body colour + --card knockout + --card outline. Contract rule: confirmed, not earned. | Unreachable. A rescued day and an at-risk day are mutually exclusive states of the same day. | Unreachable. A rescue means the streak survived; day > 0. |
atrisk | Normalized, not rendered — see §1.4. Coerced to tier: 'standard', state: 'atrisk'. | Same cell as above after normalization: dormant render. | Normalized then unreachable — coerces to standard × broken. |
1.4 Two normalization rules Codi must implement
tier: 'atrisk'is a legacy alias. It exists inStreakTierbecausePALETTE_BY_TIERwas extracted fromStoneBlock'sStoneState, not because the tier ladder has an at-risk rung.StreakMotifcoercestier === 'atrisk'→{ tier: 'standard', state: 'atrisk' }and does not branch on it again. Assert this in the unit spec.- State wins over tier whenever they conflict.
state !== 'standard'discards the tier palette entirely and renders the dormant ramp. The row of "tier ignored" cells above is deliberate, not an oversight — reasoning in §3.2.
2. Layout and geometry
Geometry is ported from the contract's mark() builder (Streak Motif System.dc.html:769-805), not restated here and not re-derived. viewBox 0 0 100 106, preserveAspectRatio="xMidYMax meet". The only geometric changes this spec makes are the two in §2.2.
streakMotifGeometry.ts stays pure — (day) => Unit[], no Vue, no DOM, no colour. Colour is applied in StreakMotif.vue. That split is what lets the unit spec assert counts without a renderer.
2.1 Group and cap table (from the contract — unchanged)
| Motif | Day unit | Week group | Cap before +N | Groups at cap |
|---|---|---|---|---|
| Stones | a stone | a course of 7 | 63 | 9 |
| Sprout | a leaf | a node of 7 | 42 | 6 |
| Flame | a coal | a hearth ring of 7 | 35 | 5 |
| Mountain | a step | a switchback of 7 | 32 | 5 (last is partial) |
| Waves | a crest | a line of 7 | 49 | 7 |
Mark renders groups, not units: groups = ceil(min(day, cap) / 7), displayed min(groups, maxGroups), with +N where N = groups - maxGroups. maxGroups is 3, or 2 at size <= 20 (§6).
Mark · stones · 44px · day 21 Mark · stones · 44px · day 35
┌────────────────────────┐ ┌────────────────────────┐
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ gold │ ← group 3 │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ gold │ +2
│ ▓▓▓▓▓▓▓▓▓▓▓▓ │ ← group 2 │ ▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ▓▓▓▓▓▓▓▓▓ │ ← group 1 │ ▓▓▓▓▓▓▓▓▓ │
└────────────────────────┘ └────────────────────────┘2.2 Two geometric deviations from the contract
(a) Drop the per-unit stroke: p.dark, strokeWidth: 2. The contract's Mark builder outlines every shape at 2 viewBox units. At 16px that renders at 0.32 device px — a hairline that either vanishes or aliases into a dirty edge, and it is the only thing carrying the shape in light mode (--streak-accent is 2.62:1 on --card). Replacing the failing edge with a passing fill (--streak-accent-accessible, §4) removes the need for it. Mark is a solid glyph; solid glyphs at 16px need ink weight, not outline.
Bloom/Field note for Phase 3: at 72/140px the edge is perceptible and the vibrant
--streak-accentfill is wanted. Bloom will need adarkstep token that flips with mode (#9A4A0Fis a raw literal today and does not). That token is a recorded gap, not minted here — Mark does not need it, and speculative tokens are how a palette rots.
(b) The crown carries a --card knockout ring. See §3.1. Where the geometry already leaves a gap between units (stones, flame, waves, sprout) this is visually free; on mountain, whose Mark triangles genuinely overlap, it is load-bearing.
3. State catalog
Every state is specified for both modes. Where a value differs by mode it is because the token flips, not because the component branches — StreakMotif contains no dark: variant and no isDark prop. That is the dark-mode parity mechanism for this component.
3.1 standard
| Layer | Treatment |
|---|---|
| Body units (all but the newest) | Fill = tier body token (§4.2). Opacity 1. No stroke. |
| Crown (the newest group) | Three layers, in paint order: 1. Knockout — a hsl(var(--card)) ring painted under the crown, max(3, 100/size) viewBox units, which opens a gap where the geometry overlaps (mountain). 2. Outline — a hsl(var(--card)) stroke on the crown shape itself, max(2.5, 100/size) units. 3. Fill — --gold on standard/rare/legendary; the tier body colour on epic and rescued. Plus a halo, drop-shadow(0 0 5px hsl(var(--gold) / 0.55)), suppressed at size <= 20. |
+N | text-2xs font-semibold in the tier's ink token, baseline-aligned to the motif's bottom edge, gap = round(size * 0.09). Omitted at size <= 20. |
Why the knockout ring is the primary crown signal, and gold is secondary. Three independent reasons, all measured or sourced:
- Gold does not always separate.
--goldagainst the tier body is 3.21:1 (standard, light) but 1.55:1 in dark, 1.17:1 onepic, 1.26:1 onlegendarydark (§5.3). A signal that disappears in half the matrix cannot be the primary one.- The halo does not survive reduced motion.
App.vue:566-575forcesanimation-iteration-count: 1andanimation-duration: 0.01mson everything. A loopingdrop-shadowhalo therefore plays once, instantly, with no fill mode — and the crown ends with no drop-shadow at all. Anything load-bearing must be static geometry.- The
--cardlayers are correct by construction. Every body token clears 3:1 against--card(§5.2), so a--cardring or stroke is guaranteed ≥3:1 against the crown's own fill and against its neighbours, in every tier and both modes, with no per-cell verification.Why the outline is needed on top of the knockout. The knockout only does work where units overlap — which, at Mark, is mountain alone; the other four motifs already leave a gap between group glyphs. Rendering cycle 1 confirmed the consequence: on
epicandrescued, where the crown carries no gold, it was completely indistinguishable from its neighbours. The outline on the shape closes that cell with no per-tier branch. Verified in both modes in the attached captures.Gold is layered on top where it adds information and omitted where it does not. "The newest unit wears gold" survives intact for the four cells a user actually spends their streak in.
3.2 atrisk — tier is deliberately ignored
Body = hsl(var(--muted-foreground)) at opacity 1.0. Crown = hollow, --muted-foreground stroke at opacity 0.7, dashed (§6.2). No gold. No knockout ring (there is no colour to separate). No halo, no loop, no pulse, no red.
Why the tier palette is discarded rather than desaturated. At-risk is a call to action, not a status. Its entire job is "this is about to be gone." Rank pride works directly against that message — a legendary user's at-risk motif should not look like a trophy. The tier is still carried by the adjacent count, the rank chip, and the accessible name (§8); none of those are the motif's job. This is also nearly moot in practice: tierFor promotes only on exact milestone days, so a non-standard tier co-occurring with atrisk requires being at risk on precisely day 30, 50, 100 or 365.
Opacity floor — a deliberate deviation. The contract renders at-risk bodies at opacity: 0.5 (waves band, mountain silhouette) and the crown at 0.4-0.5. At 0.5, --muted-foreground measures 2.16:1 in light mode — below the 3:1 legibility floor (§5). Raised to 1.0 for bodies, 0.7 for the crown. The relative fade the contract wanted is preserved (6.08:1 vs 3.15:1 is a clear step); the absolute floor is now met.
3.3 broken — day 0
Mark renders exactly one group glyph, hollow and dashed, in hsl(var(--muted-foreground)) at opacity 1.0 — max(4, 100/size) units for a unit outline, max(6, 100/size) for a trunk or a stroke-built form (sprout's stem, waves' crest). Never a zero. Never an empty frame.
Cycle-1 rendering drove those weights up from the contract's 1.0–3.2: at 44px a 2.4-unit dashed outline read as a faint scribble rather than an empty slot, which is the one reading this state cannot afford.
Gap in the contract, resolved. The DC's
mark()builder (line 769) has nobrokenbranch at all — it falls through toPAL[st]withn = max(1, min(0, 3)) = 1and draws one filled unit in the broken palette's mid tone. The "seven dashed outlines" rule lives only in the five Bloom builders. Translating that rule into Mark's vocabulary: Mark's unit is the group, so "the first group drawn as an empty outline" is one hollow group glyph, not seven. Seven dashed outlines at 16px would be 0.2px apart and read as a smudge.
Per-motif hollow forms (all from the same markGeometry(motif, 1) call, fill: none): stones → one dashed rounded bar. sprout → dashed stem + one dashed leaf. flame → dashed flame body + one dashed coal. mountain → one dashed triangle. waves → one dashed crest stroke.
3.4 Remaining states
| State | Treatment |
|---|---|
| Loading | None. StreakMotif is a pure function of its props and renders synchronously. A skeleton would be a lie about latency this component does not have. The caller owns the skeleton while day is unresolved — see §8.3. |
| Empty | state: 'broken' is the empty state. There is no other. |
| Error | Not a component state. An unknown motif falls back to stones (the DEFAULT_METAPHOR in useStreakMetaphor.ts:11); a negative day clamps to 0 → broken. Both assert in the unit spec. |
| Disabled / Focus / Hover / Active | None. StreakMotif is aria-hidden decoration and is never itself interactive (§8). Interactive affordances belong to the wrapping button or card, which own their own focus ring. Codi must not add a tabindex, a :hover rule, or a role to this component. |
| Dark mode | No delta. Every colour is a mode-aware token; nothing in the component branches on theme. |
| Motion-reduced | Entry keyframes collapse to their end state via App.vue's global reset. Halo stops (and, as noted, disappears — which is why it is not load-bearing). Nothing is lost. |
4. Design tokens — the DC-hex → token map
Zero new tokens. Every colour in the contract maps onto a token that already ships. The one place a new token would be needed — a mode-flipping dark step for Bloom's strokes and shades — is out of Mark's scope and is recorded in §9.3 rather than minted speculatively.
4.1 Direct map
| DC value | DC role | Token | Note |
|---|---|---|---|
#F28529 | PAL.standard.mid | hsl(var(--streak-accent-accessible)) | Not --streak-accent: 2.62:1 on --card in light (§5.2). --streak-accent-accessible flips 35%→55% L with mode and clears the floor in both. |
#F9A857 | PAL.standard.light | hsl(var(--streak-accent-light)) | Bloom only — Mark has no gradient. |
#9A4A0F | PAL.standard.dark | (unused at Mark — §2.2a) | Recorded gap for Phase 3, §9.3. |
#FCC419 | GOLD | hsl(var(--gold)) | 45.2° 97% 54% vs the token's 43° 96% 56% — visually identical. |
#FEF3C7 | flame gradient 0% stop | hsl(var(--gold-light)) | Bloom only. Preserves the hot-core → gold ramp one step less blown-out. |
#94A3B8 | PAL.atrisk.mid and PAL.broken.dark | hsl(var(--muted-foreground)) | The contract's at-risk mid and its broken stroke are the same value, and every broken builder uses only p.dark. One token covers both states. |
#CBD5E1 | PAL.atrisk.light | (unused — §3.2 raises the at-risk body to a single opaque tone) | |
#64748B | PAL.atrisk.dark | (unused — the at-risk crown is a --muted-foreground dash, §3.2) | |
#E2E8F0, #CBD5E1 | PAL.broken.light/mid | (unused — broken is hollow; only the stroke is painted) | |
#FFF6E8 | mountain snow cap / step ticks | hsl(var(--streak-accent-accessible-foreground)) | Bloom only. Mode-invariant white, already declared, and semantically "ink on a streak-accent-family fill" — which is exactly the role. |
rgba(255,255,255,0.42) | stone top highlight | hsl(var(--streak-accent-accessible-foreground) / 0.42) | Bloom only. Same token, same reasoning. |
rgba(252,196,25,0.78) | crown halo shadow | hsl(var(--gold) / 0.55) | Alpha lowered from 0.78; at 0.78 the glow bleeds past the 44px box into adjacent text. |
| (none) | crown separator | hsl(var(--card)) | New usage, not a new token. §3.1. |
4.2 MOTIF_PALETTE_BY_TIER — new export in streakPalette.ts
PALETTE_BY_TIER is not modified: it is consumed by StoneBlock.vue on live surfaces, and two of its entries fail the legibility floor (epic at 1.69:1 light, standard at 2.62:1 light). Fixing those is a visible change to shipped UI and violates the phase's additive constraint (A2). StreakMotif reads a new sibling export instead; Phase 2, which rewires the surfaces, is where the two converge.
// Additive sibling of PALETTE_BY_TIER (OBJ-3101). Every entry clears 3:1
// against --card in BOTH modes — see docs/ui-specs/v4.48-...md §5.
// PALETTE_BY_TIER is deliberately left alone: `standard` (2.62:1) and `epic`
// (1.69:1) fail that floor in light mode, and correcting them would change
// shipped surfaces. Phase 2 converges the two.
export const MOTIF_PALETTE_BY_TIER: Record<string, { body: string; crown: string | null }> = {
standard: { body: 'hsl(var(--streak-accent-accessible))', crown: 'hsl(var(--gold))' },
rare: { body: 'hsl(var(--streak-accent-accessible))', crown: 'hsl(var(--gold))' },
epic: { body: 'hsl(var(--gold-accessible))', crown: null }, // gold-on-gold: 1.17:1 dark
legendary: { body: 'hsl(var(--coach-accessible))', crown: 'hsl(var(--gold))' },
rescued: { body: 'hsl(var(--success))', crown: null }, // confirmed, not earned
dormant: { body: 'hsl(var(--muted-foreground))', crown: null }, // atrisk + broken
}crown: null means "the crown is distinguished by knockout + halo + position, not by fill hue."
No raw hex anywhere in StreakMotif.vue, streakMotifGeometry.ts, or the new palette export. The five guards under ionic_frontend/tests/unit/ are the enforcement, and a failing guard is the intended behaviour, not a blocker to route around.
5. Legibility floor — measured
5.1 The rule, and why it is ours rather than WCAG's
StreakMotif is aria-hidden="true" (§8), so SC 1.4.11 does not gate it — decorative graphics are exempt, and that exemption is already documented in docs/reference/desi-design-context.md §13.
We hold a 3:1 floor anyway, because a motif you cannot see is a motif that is not doing its job:
Every painted layer of the motif must clear 3:1 against
hsl(var(--card))in both light and dark mode. Where two layers overlap, the floor applies to the layer a user reads the form from — the fill, not the hairline.
5.2 Body tokens against --card
| Token | Light | Dark | Verdict |
|---|---|---|---|
--streak-accent | 2.62:1 | 6.83:1 | ✗ light — not used |
--streak-accent-accessible | 5.43:1 | 6.83:1 | ✅ |
--gold | 1.69:1 | 10.59:1 | ✗ light — crown fill only, never a body (§5.3) |
--gold-accessible | 6.61:1 | 12.44:1 | ✅ (epic body) |
--coach | 3.96:1 | 4.52:1 | ✅ but shallow — --coach-accessible used instead |
--coach-accessible | 7.33:1 | 8.40:1 | ✅ (legendary body) |
--success | 5.03:1 | 3.56:1 | ✅ (rescued body) |
--muted-foreground @1.0 | 6.08:1 | 6.96:1 | ✅ (dormant body) |
--muted-foreground @0.7 | 3.15:1 | 4.04:1 | ✅ (dormant crown / broken scaffold — this is the floor for the alpha, §3.2) |
--muted-foreground @0.5 | 2.16:1 | 2.68:1 | ✗ both — the contract's value, rejected |
5.3 Crown fill against its own tier body — why gold cannot be the primary signal
| Pair | Light | Dark |
|---|---|---|
--gold vs --streak-accent-accessible (standard/rare) | 3.21:1 | 1.55:1 |
--gold vs --coach-accessible (legendary) | 4.34:1 | 1.26:1 |
--gold vs --gold-accessible (epic) | 3.91:1 | 1.17:1 |
Every cell collapses in dark mode, because the -accessible variants lighten with the mode and gold does not. Hence §3.1: the --card knockout ring is the primary crown signal and is correct by construction; gold is the secondary, semantic one and is dropped on epic where it says nothing.
(Contrast computed with the WCAG 2.x relative-luminance formula against --card = 0 0% 100% light / 222 47% 11% dark, from design_system/css/tokens.css.)
6. Mark at 16px
6.1 What Mark claims, and what it stops claiming
The contract caps Mark at 2 groups below 20px. That cap is kept. What changes is what Mark is asserting at that size.
At
size <= 20, Mark is an identity-and-state glyph, not a counter. At 16px a flame coal renders at 2.9 × 1.9 device px and a sprout leaf at 2.9 × 1.4. Two of those do not communicate "two weeks"; they communicate "there is something here." What Mark must still carry at 16px is state — warm (standard) / slate (at risk) / hollow-dashed (broken) — because those are whole-glyph fill and value differences, and they survive the size intact. This is safe because every 16px call site already prints the day count as adjacent text (GoalEventForm.vue,StreakVisualization.vue's header). The count is never Mark's only carrier.
Per-motif verdict at 16px, 2 groups:
| Motif | Form at 16px | Count readable? | State readable? |
|---|---|---|---|
| Stones | two stacked bars, 9.3 × 3.4 and 12.2 × 3.4 px, 0.8px gap | yes | yes |
| Mountain | two overlapping triangles, 5.4 × 7 and 4.8 × 10.6 px | yes — the knockout ring separates them | yes |
| Waves | two 2.1px wavy strokes, 4.2px apart | yes | yes |
| Sprout | 1.3px stem + two 2.9 × 1.4px tilted leaves | no | yes, with §6.2 |
| Flame | 5 × 11px body dominating two 2.9 × 1.9px coals | no | yes, with §6.2 |
6.2 The stroke and dash scaling rule — the thing that actually breaks at 16px
Sprout and flame fail the state test too, unless strokes scale. The contract's broken scaffold is a 1.0–3.2 unit dash pattern of 2.4,1.8. At 16px that is a 0.38px stroke with a 0.29px dash gap — which does not read as "empty slot," it reads as a faint solid line, i.e. exactly the wrong meaning. A broken streak would look like a very thin healthy one.
strokeWidth = max(base, 100 / sizePx) // >= 1 device px, always
dashArray = sizePx <= 20 ? none // solid hollow — see below
: `${a*f},${b*f}`, f = max(1, (100/sizePx) / 1.4)Below 20px the dash is dropped entirely, not scaled. Scaling it was the first attempt and it is wrong: a dash whose gaps land near 1 device px is indistinguishable from stroke noise, so you get a dirty line rather than a broken one. The meaning that must survive is hollow, and hollow is carried by fill: none alone. At ≤20px the scaffold is a solid, hollow, full-weight outline.
At 16px strokeWidth >= 6.25 viewBox units. At 44px both terms fall back to their base values and the rule is a no-op. Applies to every dashed stroke in the broken scaffold and to the atrisk crown dash.
6.3 Suppressions at size <= 20
Superseded (OBJ-3358): the
+Nlabel row below no longer describes current behavior — it's omitted at every Mark size, not just<= 20. See.planning/phases/v4.53-obj-3358-streak-mark-overflow-chip/UI-SPEC.md. The label countedtotalGroups − displayGroups, a value with no user-facing meaning that changed across renders while the underlying streak didn't; removing it also fixed a centring bug at 24px and 56px, the two sizes this table's<= 20threshold never covered. The remaining rows are unaffected.
| Feature | At ≤20px | Reason |
|---|---|---|
| Third group | not rendered (cap 2) | contract |
+N label | no room; adjacent numeral carries it | |
| Crown halo | suppressed | a 5px glow on a 16px glyph is a smudge, not a signal |
| Crown gold fill | kept | it is a whole-shape hue difference and survives |
Knockout + --card outline | kept, scaled by §6.2 | together they are the load-bearing crown signal |
Dash on broken / atrisk | dropped — solid hollow instead | §6.2 |
7. Typography
Superseded (OBJ-3358): "exactly one text node" now holds at Bloom and Field only — Mark renders no text node at all. See
.planning/phases/v4.53-obj-3358-streak-mark-overflow-chip/UI-SPEC.md.
StreakMotif renders exactly one text node at Bloom and Field: the +N overflow label. Mark renders none.
| Element | Font | Class | Token |
|---|---|---|---|
+N overflow | Inter | text-2xs font-semibold | tier ink: --streak-accent-accessible / --gold-accessible / --coach-accessible / --success / --muted-foreground |
(retired at Mark) +N overflow | text-2xs font-semibold | — |
text-2xs (11px) is the declared legibility floor in tailwind-tokens.cjs. Nothing here goes below it, and no arbitrary text-[Npx] is introduced.
8. Accessibility
8.1 The decision: the motif is decorative; the accessible name lives on the container
StreakMotif renders aria-hidden="true" on its root, exactly as StreakDetailsModal.vue:64 already wraps its hero (<div class="…" aria-hidden="true"> with the count in the <h2> directly below). We follow that precedent rather than break it.
Justification — three reasons, in priority order:
- A screen reader user needs the quantity, not the picture. "12-day streak" is the information. "Twelve stones arranged in two courses, the newest in gold" is a description of a decoration that encodes it. Announcing the motif would make the app slower to use for the users who can least afford it.
- Every Mark call site already announces the count. All five 16/24px surfaces render the day number as adjacent text. Naming the SVG produces a duplicate announcement — the classic redundant-alt failure, and a worse outcome than staying silent.
- Naming the motif would pull it under SC 1.4.11, and the whole existing streak palette fails that at 3:1 in light mode (§5.2). We would be trading a real, measurable accessibility improvement (the container name, below) for a nominal one that forces a repaint of shipped UI.
Where the precedent is deliberately extended: the contract's atrisk and broken states carry information the adjacent numeral does not — "at risk" and "broken" are not numbers. So:
Contract for every call site (enforced in Phase 2, stated here so Codi builds for it): the element wrapping
StreakMotifmust carry an accessible name that includes the streak state wheneverstate !== 'standard'.StreakMotifexposes asrLabelcomputed value for the caller to bind; it never renders it itself.
8.2 Accessible-name templates (brand voice — see §10)
| State | Template |
|---|---|
standard | "{n}-day streak" |
standard, milestone tier | "{n}-day streak — {rankName}" |
atrisk | "{n}-day streak, at risk. Check in today to keep it." |
broken | "Streak broken. Start again today." |
rescued | "{n}-day streak. Yesterday's rest day is covered." |
8.3 The rest
- Focus order: unchanged.
StreakMotifis not focusable and adds no tab stop. Codi must not addtabindex,role, or atitleelement. - Keyboard: no shortcuts. Nothing to operate.
- Tap targets:
StreakMotifis never itself a tap target. Where a caller makes it tappable, the ≥44px target is the caller's wrapper, not the 16px glyph. - Colour is never the only channel.
standard/atrisk/brokendiffer by fill vs. hollow and solid vs. dashed, not only by hue — so the three states are distinguishable in greyscale and under any colour-vision deficiency. The crown likewise differs by geometry (knockout ring + terminal position), not only by gold. This is SC 1.4.1, and it is the reason §3.1 is written the way it is. - Motion-reduced: §3.4. No animation is load-bearing.
- Query-state honesty:
StreakMotifmust never be rendered with a placeholderday.dayis required, has no default, and a caller with an unresolved streak renders its own skeleton instead. Renderingday: 0while loading would show the user a broken streak they do not have — the single worst failure mode this component has.
9. Findings recorded, not fixed here
9.1 rare is a no-op tier
PALETTE_BY_TIER.rare is byte-identical to PALETTE_BY_TIER.standard (streakPalette.ts:6-7). Days 3, 7 and 14 already render exactly like day 2 — the rank ladder is one rung shorter than it reads on paper, and it has been since OBJ-2037. MOTIF_PALETTE_BY_TIER reproduces this rather than silently inventing a rare treatment nobody specified. Route to Maggie: either give rare a palette or drop the rung; not a Phase 1 decision.
9.2 PALETTE_BY_TIER fails the legibility floor in light mode
standard (2.62:1) and epic (1.69:1) on --card. Shipped today on StoneBlock and every hero. Not corrected here — A2 forbids touching existing surfaces. Phase 2 is the natural place. Route to Maggie.
9.3 Bloom needs a mode-flipping dark step; Mark does not
#9A4A0F is a raw literal in PALETTE_BY_TIER.standard.dark/rare.dark. It does not flip with mode, so in dark mode it measures 2.82:1 against --card — a stroke that all but vanishes. Mark sidesteps this by dropping the per-unit edge (§2.2a). Phase 3 cannot. Naming that token is a Phase 3 decision made with Bloom's geometry in hand; minting it now would be a guess. Recorded as a vocabulary gap, not invented.
9.4 Do not port the contract's reduced-motion keyframes
The DC redefines omDrop/omUnfurl/omSettle/omStep/omSweep/omHalo under @media (prefers-reduced-motion: reduce) as opacity fades (lines 26-31). App.vue:566-575 already forces animation-duration: 0.01ms globally, so those redefinitions can never visibly play. Porting them ships dead CSS that looks like an accessibility feature. Rely on the global reset plus the existing [data-streak-anim] block in streakAnimations.ts — and verify StreakMotif's root actually carries data-streak-anim; do not assume it.
9.5 The contract's mark() has no broken branch
Resolved in §3.3. Noted here so a reader comparing this spec against the DC does not read the difference as a transcription error.
10. Microcopy
Every user-facing string this component and its immediate contract introduce. No placeholders.
| Surface | String | Tone check |
|---|---|---|
| Accessible name — standard | "21-day streak" | brief ✅, factual ✅, no praise inflation ✅ |
| Accessible name — milestone | "100-day streak — Monument" | earned, specific ✅ |
| Accessible name — at risk | "21-day streak, at risk. Check in today to keep it." | action verb ✅, one sentence ✅, no alarm ✅ |
| Accessible name — broken | "Streak broken. Start again today." | action-forward ✅, no blame ✅, no "unfortunately" ✅ |
| Accessible name — rescued | "21-day streak. Yesterday's rest day is covered." | confirms without congratulating ✅ |
| Overflow label | "+2" | numeral only; the container name carries the meaning ✅ |
| Storybook — broken story description | "Day zero. One hollow group, dashed — never a number, never an empty frame." | internal ✅ |
Deliberately absent: no "Oh no!", no "Don't lose your streak!", no countdown, no "You're on fire!". At-risk states are where habit apps reach for fear; the brand does not. "Check in today to keep it" states the fact and the action and stops.
11. Storybook story brief
Path: ionic_frontend/src/components/gamification/streak/StreakMotif.stories.ts
argTypes Codi should expose in the controls panel:
| Arg | Control | Options |
|---|---|---|
motif | select | stones sprout flame mountain waves |
day | range | min: 0, max: 70, step: 1 — the reviewer must be able to drag it |
tier | select | standard rare epic legendary rescued atrisk |
state | select | standard atrisk broken |
size | select | 16 20 24 44 |
animate | boolean | — |
Named stories (the acceptance evidence — a reviewer opens Storybook, picks any motif, drags day 0 → 70, and watches it accrete correctly):
Playground— all controls live. This is the story the acceptance criterion is read against.Grammar— 5 motifs × {standard, atrisk, broken} at 44px, day 21 / 21 / 0.Sizes— 5 motifs × {16, 20, 24, 44} at day 21, standard.Accretion— 5 motifs × days {0, 1, 6, 7, 8, 21, cap−1, cap, cap+1, cap+5}. The+Nboundary is visible here.TierLadder— stones at 44px across all six tiers, with the §1.3 unreachable cells omitted, not rendered blank, each with its reason as the story description.SixteenPixel— all five motifs × three states at 16px only, on both abg-cardand abg-mutedsurface. This is the story that catches a §6.2 regression.ReducedMotion—Grammarwithanimate: trueunder aprefers-reduced-motiondecorator. Asserts the crown is still identifiable with the halo gone.
A play() function is required on Grammar, Accretion and SixteenPixel — v4.46's story-test CI gate is in flight and this file is large. Run npm run test:storybook locally before opening the PR.
12. Entry motion
Five keyframes, ported verbatim from the contract (Streak Motif System.dc.html:18-22), appended to the CSS block in streakAnimations.ts. Names and transform origins are the contract's KF / ORIGIN maps (lines 511-512), unchanged.
| Motif | Keyframe | Transform origin | Vector |
|---|---|---|---|
| Stones | omDrop | 50% 100% | drops in from −30px with a settle bounce |
| Sprout | omUnfurl | 50% 50% | scales 0.12 → 1.14 → 1 |
| Flame | omSettle | 50% 50% | scales down 2.2 → 0.9 → 1 |
| Mountain | omStep | 50% 50% | rises 9px with a 1.12 overshoot |
| Waves | omSweep | 50% 50% | sweeps in from −16px on x |
- Timing:
460ms cubic-bezier(0.34, 1.56, 0.64, 1) both— the celebratory overshoot curve already declared inDESIGN.md§ Motion. No new curve is introduced. transform-box: fill-boxis required on every animated SVG child, or the transform origin resolves against the viewport instead of the shape.- Only the newest unit animates. Re-animating earned units makes the app ask for attention, which breaks the ten-minute contract. (Rank-up restage is the single exception and is Phase 4.)
- The halo reuses the existing
streakStoneHalokeyframe instreakAnimations.ts— do not port the DC'somHalo. The two are functionally identical (0 0 5px → 14pxat 0.40 → 0.85 alpha) andstreakStoneHalois already written in tokens. - State transitions (crown handoff, group close, drain-to-slate, break) are Phase 4. Do not build them here.
- Reduced motion: §3.4 and §9.4.
13. Acceptance criteria for Codi
- [ ] Renders all five motifs at
render: 'mark'for anyday >= 0;bloom/fieldrender nothing and are documented as Phase 3. - [ ]
day: 0renders one hollow dashed group glyph (§3.3) — not a zero, not an empty frame, not seven outlines. - [ ] Unit and group counts match §2.1 exactly at days 0, 1, 6, 7, 8, cap−1, cap, cap+1 for all five motifs, asserted in
tests/unit/streakMotifGeometry.spec.ts. - [ ]
+Nrenders past each motif's cap, and is omitted atsize <= 20. - [ ] The crown is the only unit carrying the
--cardknockout +--cardoutline, in every tier. Verified onepicandrescuedspecifically, where there is no gold fill to fall back on. - [ ] Gold fills the crown on
standard/rare/legendaryonly; never onepic,rescued,atrisk, orbroken. - [ ]
tier: 'atrisk'normalizes to{ tier: 'standard', state: 'atrisk' }(§1.4), asserted. - [ ] Every unreachable cell in §1.3 is either unrepresentable in the prop types or asserted to throw / fall back — not silently rendered.
- [ ] Stroke widths scale and the dash is dropped below 20px per §6.2; verified at 16px in the
SixteenPixelstory. - [ ] Renders at 375px width without horizontal scroll at every size.
- [ ] Zero raw hex in
StreakMotif.vue,streakMotifGeometry.ts, and the newMOTIF_PALETTE_BY_TIER. All five token guards undertests/unit/green. - [ ]
PALETTE_BY_TIERis not modified.StreakMetaphorIcon.vue,StoneBlock.vueand every existing surface are untouched (A2). - [ ]
StreakMotif's root carriesdata-streak-anim— verified, not assumed (§9.4). - [ ] Root carries
aria-hidden="true"; notabindex, norole, no<title>.srLabelis exposed for callers but never rendered by the component. - [ ]
dayhas no default; rendering with an unresolved streak is a caller-side skeleton (§8.3). - [ ] Dark-mode parity verified in Storybook with no
dark:variant and no theme prop in the component. - [ ] All seven stories in §11 exist;
npm run test:storybook,npm run test:unitandnpm run lintgreen.
14. Open questions
None blocking. Three findings are recorded for Maggie to route (§9.1 rare is a no-op tier, §9.2 PALETTE_BY_TIER's light-mode contrast, §9.3 the Bloom dark-step token gap). All three are outside Phase 1's additive boundary and none of them block Task 2.
Throughline: n/a — single-surface component spec, one state catalog, no navigation and no sequence. State transitions between the frames (crown handoff, group close, drain-to-slate, break) are Phase 4 and will need the sequence pass then. The motion-vocabulary check that was run against this spec produced §9.4 and the reduced-motion finding in §3.1.
Last updated: 2026-08-31 · Version: v4.48.0
Addendum — v4.48 Phase 2: Mark placement parity
Issue: OBJ-3102 · Milestone: v4.48 · Author: Desi · Status: ready for Codi Task package: Orion's Phase 2 comment on OBJ-3102, task 2a Mockup: .planning/phases/v4.48-phase-2-mark-placement-parity/mockups/mark-placement-parity.htmlCaptures: .../mockups/screenshots/ — 6 stills (mobile/tablet/desktop × light/dark) + 2 motion clips (mobile light/dark). axe-core: 0 violations, 0 console errors after one refine cycle.
Everything above this line is Phase 1 and is unchanged. Phase 1 specified the glyph; this addendum specifies the hosts. Nothing here re-derives geometry, re-picks a token, or re-opens a Phase 1 ruling. Where a Phase 1 section governs, it is cited rather than restated.
Verified against merge d6049e3f3. Every file path, line number, prop name and contrast figure below was read or measured at that SHA.
15. The four rulings Orion asked for
15.1 RankChip — the chip carries no motif
Ruling: the ten rungs stay abstract. RankChip gains no motif awareness and no motif prop.
Orion's open question asked for a fallback if the 10-slot metaphor can't survive chip scale. It can't, for any motif, and the reason is not aesthetic:
- Arithmetic. Ten rungs across a
viewBox="0 0 20 14"is 2 viewBox units of pitch and 1.2 of width per rung — at the chip's rendered 20px that is a 1.2 × 3 device-px mark. Phase 1 set 16px as the floor for a whole Mark and showed (§6.1) that sprout and flame already stop carrying their count there. A single group glyph at 1.2px is eight times below that floor. This fails identically for all five motifs, so there is no per-motif ruling to make. - Semantics — the load-bearing reason. The rungs count rank-ladder rungs, not days and not week-groups:
rank.stonesis 1–10 across the tenRANKSentries (streakRanks.ts:11-22), and day 21 and day 29 are both rung 5. Rendering the accretion grammar there would assert a day is a unit, seven close a group over a scale that measures neither. §1.1's whole argument is that collapsing two independent axes deletes information; motif-shaping the rank meter is the same error one level up. - It is already carried by adjacency. All three
RankChipcall sites —CelebrationBalanced.vue:12,CelebrationCinematic.vue:29,StreakDetailsModal.vue:54— render the user's motif Hero within ~80px of the chip. The chip repeating it adds nothing.
The rank names are themselves stone-flavoured ("First Stone", "Cornerstone", "Keystone", "Monument"). That is a copy question about the ladder, not a render question about the chip, and it is out of scope here. Recorded in §21.7.
What the chip gets instead — the defect that is actually there. The empty rungs sit at opacity: 0.18 (RankChip.vue:11), measuring 1.30:1 light / 1.59:1 dark against the chip's own fill. A meter whose track is invisible is not a meter — it renders "5 marks", never "5 of 10", so the ladder has no length and no destination.
| Change | From | To |
|---|---|---|
| Empty-rung opacity | 0.18 | 0.32 |
0.32 is the measured maximum that still clears 3:1 on filled-vs-empty in all eight tone × mode cells. This is the same trade §3.1 makes for the crown: no single absolute value clears in every cell, so the relative signal is the one held to the floor.
| Tone × mode | filled vs empty @0.32 | empty vs chip fill @0.18 → @0.32 |
|---|---|---|
| gold · light | 3.79:1 | 1.30 → 1.62 |
| gold · dark | 4.12:1 | 1.59 → 2.29 |
| coral · light | 3.07:1 (binding constraint) | 1.27 → 1.55 |
| coral · dark | 3.21:1 | 1.35 → 1.75 |
| coach · light | 3.54:1 | 1.33 → 1.70 |
| coach · dark | 3.61:1 | 1.44 → 1.95 |
| onDark | 3.83:1 | 1.57 → 2.22 |
| legendaryDark | 3.94:1 | 1.68 → 2.44 |
(At 0.35 coral·light falls to 2.93:1 and at 0.40 to 2.72:1 — hence 0.32, not a rounder number.)
And the three raw hex literals, which Codi is in the file anyway. RankChip.vue:34-44 carries three, and one of them is a live AA failure:
| Line | Raw | Token | Light | Dark |
|---|---|---|---|---|
:36 coral.fg | #9A4A0F | hsl(var(--streak-accent-accessible)) | 5.49 → 4.76 | 2.36 → 5.62 |
:38 onDark.fg | #FDE68A | hsl(var(--gold-light)) | — | 9.82 → 8.50 |
:42 legendaryDark.fg | #FFFBEB | hsl(var(--streak-accent-accessible-foreground)) | — | 9.27 → 9.62 |
#9A4A0F is the same literal Phase 1 flagged in §9.3 as not flipping with mode. Here it is as a chip foreground, measuring 2.36:1 in dark — a failure, today, on a shipped surface. The token substitution both removes the hex and fixes it. The other two are mode-invariant inks on a celebration scrim; the substitutes are within 1.3 of the raw value and gain a token.
onDark and legendaryDark render only inside CelebrationBalanced/CelebrationCinematic, which force a dark scrim regardless of theme — so their "light" column is not a context that exists.
15.2 WeekRibbon — the NOW pip is exactly one group glyph
Ruling: the NOW pip renders StreakMotif at size: 18, day: 1. Everything else about the ribbon is unchanged, with the single exception in §15.2b.
Mark's atom is the group glyph (§3.3 — "Mark's unit is the group"); there is no smaller thing the renderer draws and no day-unit render in its API. So the ribbon's day slot takes one group glyph, and day: 1 is the prop that produces exactly one. At 18px the size branch does the rest for free: cap 2 (only 1 drawn), halo suppressed, +N omitted, dash dropped, gold crown fill kept (§6.3).
The result is the grammar stated literally: today wears gold.
| Ribbon state | StreakMotif props | Reads as |
|---|---|---|
| Today marked | { motif, day: 1, state: 'standard', size: 18 } | one gold-crowned motif unit |
| Today not marked yet | { motif, day: 0, state: 'broken', size: 18 } | one hollow unit, solid outline (dash dropped at ≤20px) |
The hollow render maps onto today's 1px dashed off-pip border and is a render selection, not a state claim — the ribbon's NOW cell carries the truthful name (§17). The six past pips, the glow ring, the 4px halo and streakPipPulse are all unchanged.
The accent prop goes partly dead, and Codi must know that. WeekRibbon's accent prop (:20, passed by CelebrationBalanced.vue:29 and CelebrationCinematic.vue:46) currently paints the NOW pip's fill. It no longer does — the NOW pip's colour comes from the tier body token plus the gold crown. accent continues to drive the glow ring only. Do not delete the prop; do not try to push it through StreakMotif.
Contrast of what lands: --streak-accent-accessible body is 5.43:1 light / 6.83:1 dark against --card, against the outgoing --streak-accent at 2.62:1 light. The NOW pip gets heavier, not lighter, than the six past pips — which is correct, since NOW is already the emphasised slot by size and glow.
15.2b The one thing about the ribbon that does change: its day labels
Rendering cycle 1 returned 42 serious axe-core color-contrast violations, all of them the ribbon's own day labels — 6 ribbons × 7 labels. WeekRibbon.vue:53-60 sets them at 9px in hsl(var(--muted-foreground) / 0.7), measuring 3.09:1, against a 4.5:1 AA floor for text. This is shipped today and has nothing to do with the motif.
Ruling: drop the / 0.7 alpha. --muted-foreground at full opacity is 6.08:1 light / 6.96:1 dark. One token change, no new token, no layout change, and it takes the addendum's evidence from 42 violations to zero. WCAG AA is a hard floor and I will not ship a spec whose own captures fail it on a component I am editing.
The 9px size is below the declared 11px text-2xs floor and is not changed here — that is a layout change and Orion scoped the ribbon's layout out. Recorded in §21.2.
15.3 StreakVisualization — "motif tint" resolves to tier, never to motif
Ruling: there is no per-motif hue in this system, and Phase 2 does not invent one. The current-streak bar takes the tier body token from MOTIF_PALETTE_BY_TIER as a flat fill.
§1.1 is explicit that this app has exactly two axes: tier drives the palette, state drives the treatment. Motif drives form — geometry — and nothing else; there is no motif→colour mapping anywhere in Phase 1, in MOTIF_PALETTE_BY_TIER, or in the design contract. Minting five per-motif hues to satisfy the phrase "motif tint" would add a third colour axis nobody specified, and it would have to be re-derived for Bloom and Field in Phase 3.
So the bar is tinted by the same thing the Mark beside it is tinted by. That is what makes it read as one surface.
| Element | From | To |
|---|---|---|
Header icon (:5) | <AppIcon name="streak" class="w-4 h-4 text-accent" /> | StreakMotif, size: 16, day: currentStreak |
Current bar (:19) | bg-gradient-to-r from-accent to-warning | flat MOTIF_PALETTE_BY_TIER[tierFor(currentStreak)].body |
Best bar (:33) | bg-gradient-to-r from-coach to-primary | unchanged — see below |
Flat, not a gradient. A tier-aware gradient would need a -light step per tier; --gold-accessible and --coach-accessible have none, and minting three is exactly the speculative rot §2.2 warns about. Mark itself has no gradient (§4.1 — the gradient is Bloom-only), so a flat fill is also the consistent choice, not merely the cheap one.
This is a legibility fix as much as a grammar fix. Measured against --muted, the bar's track:
| Fill | Light | Dark |
|---|---|---|
--accent (today, from-) | 2.39:1 | 5.29:1 |
--warning (today, to-) | 1.95:1 | 7.52:1 |
--streak-accent-accessible (standard/rare) | 4.94:1 | 5.29:1 |
--gold-accessible (epic) | 6.02:1 | 9.64:1 |
--coach-accessible (legendary) | 6.67:1 | 6.51:1 |
--success (rescued) | 4.58:1 | 5.35:1 |
The shipped bar is under 2.5:1 in light across its whole ramp. Every replacement clears 4.5:1.
--gold-accessible(38 84% 27%) reads as a deep brown on a 12px bar in light mode rather than as "gold" — visible in the §3 captures. That is deliberate and it is the same token theepicMark body uses, so the bar and the glyph match. Breaking the match to make the bar prettier would reintroduce the divergence this phase exists to remove.
16px is Mark's floor and the condition for it is met. §6.1 permits ≤20px only where the count is carried by adjacent text; this header prints {{ currentStreak }} days at :15, immediately below.
The "Best" bar is deliberately untouched. It carries no motif and no tier — it is a historical maximum, not the user's live streak — so a tier tint there would be a false claim. Listed so nobody "fixes" it, the same way Orion listed PathToNextBar.
15.4 GreetingCard — Mark extends to 56px
Ruling: Mark's sanctioned size ladder becomes 16 / 20 / 24 / 44 / 56. The group cap stays at 3 and the crown is unchanged.
Update (OBJ-3567). The ladder gained 96 —
RepairConfirmationModal.vueandWelcomeBackInterstitial.vue(§16.1, surfaces 11–12). Same reasoning as this section: every size-dependent clamp is already a no-op above ~34px, so 96 needs no new branch either. Ruled in.planning/phases/v4.8-obj-3567-streak-motif-hero-surfaces/UI-SPEC.md§1.2 — Mark, not Bloom, because the day count is always carried by adjacent text on both call sites.
56px needs no new branch, because every size-dependent clamp in the component is already a no-op above ~34px:
| Term | At 44px | At 56px |
|---|---|---|
maxGroups (§2.1) | 3 | 3 — the cap is a function of size <= 20, not of size |
edge(size, 3) — knockout ring | 3 | 3 |
edge(size, 2.5) — crown outline | 2.5 | 2.5 |
edge(size, 4 / 6) — broken scaffold | 4 / 6 | 4 / 6 |
dash(size, …) factor | 1.62 | 1.28 |
| Crown gold + halo | on | on |
+N | shown | shown |
The viewBox is fixed at 0 0 100 106 with preserveAspectRatio, so 56px is 44px at a larger scale. The halo's 0 0 5px blur is a fixed pixel radius, so at 56px it is proportionally tighter than at 44 — still legible, less likely to bleed.
What does change is the card, and it follows directly from the Mark landing there.
GreetingCard.vue | From | To | Why |
|---|---|---|---|
:25-28 | <span class="absolute -inset-3 rounded-full opacity-50 blur-xl bg-gradient-to-br from-amber-400 to-orange-500 -z-10"> | removed | Two gold glows at hero scale is mush. The blur was standing in for a glyph that had no glow of its own; Mark brings the crown halo, which is the one that carries meaning (§3.1). |
:18 | ring-2 ring-amber-400/55 | ring-2 ring-gold/55 | Raw Tailwind palette class |
:94 streakHeroStyle | hsl(43 96% 56% / 0.10), hsl(16 84% 62% / 0.05) | hsl(var(--gold) / 0.10), hsl(var(--streak-accent) / 0.05) | Raw literals |
hsl(43 96% 56%) is --gold, exactly. hsl(16 84% 62%) has no token; --streak-accent (27 89% 55%) is the correct family and at 5% alpha the 11° hue difference is below perceptual threshold. Stated rather than glossed.
No guard catches these.
tokenAlphaGuardscanssrc/views/**fortext-foreground/NNonly; none of the five guards look for Tailwind palette classes likeamber-400. This is DESIGN.md's tokens-never-hex rule, enforced by review. Recorded in §21.6.
The w-16 h-16 (64px) grid place-items-center cell is unchanged and holds 56px comfortably.
16. Where each surface gets its motif — the two rules
This is the question underneath all four rulings, and getting it wrong reintroduces the exact bug the phase exists to fix. effectiveMetaphor(isSupporter) returns 'stones' whenever isSupporter is false (useStreakMetaphor.ts:35-37), so a surface that resolves locally without a real supporter flag silently forces stones on every supporter. StreakMetaphorIcon's current isSupporter: false default (:44) is that footgun, live today.
Verified at d6049e3f3: RankChip, WeekRibbon, CelebrationToast, StreakRepairCard, StreakVisualization, GoalGridCard and GoalFocusCard have no isSupporter prop, and Goals.vue, GoalsSection.vue, GoalCoverPlaceholder.vue, GoalHabitSection.vue and Goal.vue have no isSupporter in scope either. So "just call useStreakMetaphor" — Orion's note on tasks 4a and 4c — is not implementable as written for those surfaces. Two rules cover all sixteen.
Correction (OBJ-3567). The
d6049e3f3inventory below was incomplete, not wrong about what it checked — three signed-in-user's-own-streak surfaces onDashboard.vue(RepairConfirmationModal.vue,WelcomeBackInterstitial.vue,WelcomeBackBanner.vue) carried neither theStreakMetaphorIconconversion nor theP2-A1: out of scopeannotation this phase's own convention created — they were simply missed by the pass. A prose inventory is not a mechanical guarantee; see the matching gotcha indocs/development/gotchas.md.ionic_frontend/tests/unit/streakMotifSurfaceGuard.spec.tsis now the mechanical check that replaces re-reading this table by eye.
16.1 Rule M1 — preference-resolving surfaces render through StreakMetaphorIcon
Thirteen surfaces. They render the adapter, not StreakMotif directly, so there is exactly one motif-resolution point in the app.
StreakMetaphorIcon becomes self-sufficient. It resolves the supporter flag itself from useSupporter(Session.decoded().id) rather than requiring every caller to thread it. isSupporter stays as an optional override for Storybook and tests; it stops being a required thread whose default is the bug.
This is what removes the threading problem: Goals.vue, GoalsSection.vue, GoalCoverPlaceholder.vue, GoalHabitSection.vue and Goal.vue need no change at all. The alternative — adding an isSupporter prop to four leaves and threading it from five parents, two of which would need a new useSupporter call — touches strictly more files for a worse invariant.
Cold-load honesty rule.
USER_QUERYis cached and already in flight on every authenticated screen, but before it settlesisSupporterreadsfalse. Resolving througheffectiveMetaphor(false)at that moment would flash stones at a waves user — the precise defect this phase removes, briefly. So:resolved = supporterKnown ? effective(isSupporter) : metaphor.valueThe stored preference is what the user chose. The downgrade guard exists to stop a lapsed supporter keeping a paid motif, and
setMetaphor(:28-33) already gates that at selection time — so applying it one tick late costs nothing, while applying it one tick early is a visible lie. Same reasoning as §8.3: never render a placeholder that shows the user a state they are not in.
| # | Surface | Size | day source (verified d6049e3f3) |
|---|---|---|---|
| 1 | dashboard/DashboardHero.vue:46 | 24 | props.streakCount (:118) |
| 2 | GoalEventForm.vue:20 | 16 | streak computed (:352) |
| 3 | dashboard/streamlined/HabitCard.vue:7 | 18 | props.habit.habitStreak (:203) — per-habit, correct |
| 4 | dashboard/streamlined/GreetingCard.vue:23 | 56 | props.streak (:78) |
| 5 | dashboard/streamlined/AmbientSidebar.vue:14 | 18 | props.streak (:128) |
| 6 | views/Dashboard.vue:110 | 18 | userStreak (:340) |
| 7 | dashboard/StreakRepairCard.vue:31 | 20 | props.preBreakStreak (:111) |
| 8 | goal/StreakVisualization.vue:5 | 16 | props.currentStreak |
| 9 | dashboard/GoalGridCard.vue:105 | 16 | props.streak (:280) |
| 10 | dashboard/GoalFocusCard.vue:43 | 16 | props.streak (:178) |
| 11 | dashboard/RepairConfirmationModal.vue:27 | 96 | props.preBreakStreak default / displayStreak success (:31) |
| 12 | dashboard/WelcomeBackInterstitial.vue:33 | 96 | props.streakDays default / displayStreak success (:37) |
| 13 | dashboard/WelcomeBackBanner.vue:10 | 24 | props.streakDays default / displayStreak success (:14) |
Surfaces 11–13 were added by OBJ-3567 (see the correction note above); their render/state/tier rulings live in .planning/phases/v4.8-obj-3567-streak-motif-hero-surfaces/UI-SPEC.md. 96 joins the Mark size ladder — see §15.4.
Surfaces 9 and 10 move from 12px to 16px. AppIcon :size="12" is below Mark's documented floor; §6.1's smallest verified size is 16. The .gc-streak-chip is height: 22px with zero vertical padding (GoalGridCard.vue:567, GoalFocusCard.vue:245), so 16px fits with 3px either side. Do not render Mark at 12.
16.2 Rule M2 — metaphor-threaded surfaces take a motif prop
Three surfaces, all inside a parent that has already resolved the metaphor. They take motif: StreakMetaphor as a prop and render StreakMotif directly. They must not call useStreakMetaphor.
| # | Surface | Parent(s) that already hold metaphor |
|---|---|---|
| 11 | streak/RankChip.vue | n/a — §15.1, no motif |
| 12 | streak/WeekRibbon.vue | CelebrationBalanced.vue:29, CelebrationCinematic.vue:46, StreakDetailsModal.vue:84 (prop at :207) |
| 13 | streak/CelebrationToast.vue | StreakCelebration.vue:28 (prop at :76) |
The chain is already correct upstream: Dashboard.vue:969 computes effectiveMetaphor(!!user.value?.isSupporter) and Dashboard.vue:457 passes it to StreakCelebration, which forwards it to Balanced and Cinematic but not to Toast. That single missing binding is the whole defect for surface 13 — pass it. StreakDetailsModal must likewise pass its own metaphor prop down to WeekRibbon.
Do not add a motif default of 'stones' to these three. A silent default is how StreakCelebration.vue:79 produced a stones celebration for a waves user in the first place.
17. Accessible-name host, per surface
§8.1's contract: StreakMotif is aria-hidden and never renders srLabel; the wrapping element carries a name that includes the state whenever state !== 'standard'. §8.1's own reasoning is that a duplicate announcement is a worse outcome than silence — so a surface whose visible text already carries the fact gets nothing new. Eight of thirteen are in that class, and that is the correct answer, not a gap.
Templates are §8.2's, verbatim.
| # | Surface | Name host | Action | Reachable non-standard state |
|---|---|---|---|---|
| 1 | DashboardHero | streakTileAriaLabel (:143), on the tile button | Extend — add a broken branch beside the existing loading / unavailable ones | broken (streakCount 0) |
| 2 | GoalEventForm | :aria-label on the chip span (:18) | None — chip is v-if="streak && streak > 0" | none |
| 3 | HabitCard | the flex items-center gap-2 wrapper (:6) | Conditional — aria-label only when habitStreak === 0; otherwise the visible "N day streak" is the name | broken |
| 4 | GreetingCard | the streak-hero div (:17) | Conditional — same rule; visible {{ streak }} + "day streak" is the name otherwise | broken |
| 5 | AmbientSidebar | the streak block (:9) | None — "Streak" eyebrow + tweenedStreak is the name; at 0 the text is truthful | broken, text-covered |
| 6 | Dashboard.vue:104 | the <button> | None — its text spans are the name | broken, text-covered |
| 7 | StreakRepairCard | role="region" :aria-label (:6) | Extend — "Streak repair — your {n}-day {goal} streak broke." | broken (always) |
| 8 | StreakVisualization | the <h3> (:4) + the bars' own text | None — "Streak", "Current N days", "Best N days" is complete | broken, text-covered |
| 9 | GoalGridCard | the .gc-streak-chip span | None — v-if="streak > 0" | none |
| 10 | GoalFocusCard | the .gc-streak-chip span | None — v-if="streak > 0" | none |
| 11 | RankChip | the chip span; SVG already aria-hidden (:3) | None — no motif, rank name is text | n/a |
| 12 | WeekRibbon | the NOW cell (the flex-col div, :3) | New — aria-label="Today — marked" / "Today — not marked yet". The hollow render says something "NOW" does not. Six past cells unchanged. | hollow / "not yet" |
| 13 | CelebrationToast | root role="status" aria-live="polite" (:2) | None — "21 days · kept" is the name | none |
Three extend, two gain a conditional name, eight need none — and zero surfaces double-announce. Assert that last clause with a test rather than an eyeball, per Orion's acceptance.
Surfaces 2 and 7 are the two Orion flagged as already carrying a label, and both are extend, not add — a second name alongside them is the failure mode §8.1 rests on.
18. The caller-side state derivation contract
StreakMotif does not derive state from day, and Codi must not assume it does.
Verified at d6049e3f3: resolvedState is props.day < 0 ? 'broken' : normalizedState, and groupsToDraw is state === 'broken' ? 1 : Math.max(1, displayGroups). So { day: 0, state: 'standard' } renders one filled group in the standard palette — a live one-week streak the user does not have. Every Phase 1 story passes state: 'broken' explicitly alongside day: 0 (StreakMotif.stories.ts:45, 145, 209), which is why the gap never surfaced.
Phase 2 is the first time real callers pass these props. One rule, identical at all thirteen sites:
const state = day <= 0 ? 'broken' : 'standard'
const tier = tierFor(day) // except where the caller knows betterTwo documented deviations, both already justified upstream:
StreakRepairCardpassesstate: 'broken'unconditionally withday: preBreakStreak > 0. The card exists because the streak broke; the pre-break count is what the copy quotes ("Your {n}-day {goal} streak broke."), andbrokendraws one hollow group regardless ofday(§3.3) — so the glyph and the copy agree. Showing three accreted groups next to "your streak broke" would contradict the sentence beside it.StreakRepairCard's success frame passestier: 'rescued',state: 'standard',day: restoredStreak.rescuedis--successwith the crown never gold — §4.2's "confirmed, not earned". This is the one place in the app a rescue actually happens, and it is the only place that tier is currently reachable. See §19.
state: 'atrisk' is unreachable from every Phase 2 surface. A repo-wide search at d6049e3f3 finds no streak-jeopardy signal anywhere — GoalCard.vue:130's isAtRisk is goal-deadline risk, a different thing, and its body is commented out. atrisk stays reachable only through Storybook until a surface gains the signal. Do not synthesise one. Recorded in §21.3.
19. StreakRepairCard's success frame — one addition to Orion's file list
Orion's task 4 scopes StreakRepairCard.vue:31 (the default frame's icon). The card's success frame (:9-23) still renders a generic check-circle (:11-16) beside "Streak restored. {n} days."
Ruling: the success frame renders the motif at tier: 'rescued', 24px, replacing the check icon.
- It is the same file, the same component, and the frame immediately after the one already in scope — break → repair → restored is one sequence, and specifying only its first frame leaves the transition undefined. That is a flow gap in my own artifact, which §21 of my remit says I fix here rather than file.
rescuedwas defined in Phase 1 (§4.2) for exactly this moment and is reachable nowhere else. A tier that ships with no call site is a tier nobody has verified.- A generic checkmark says "an operation succeeded". The rescued motif says your streak is back and it is yours — the difference between a receipt and a restoration.
This is an addition to Orion's file list, not a reinterpretation of it. Flagged explicitly in the handoff so Maggie can bounce it if she disagrees; it is one prop change in a file already open.
20. Flow sequence
Two multi-step journeys run through these surfaces. Both were traced against the code, not memory.
| From | Trigger | To | Transition + duration | Feedback | Reduced-motion | Back-nav |
|---|---|---|---|---|---|---|
Check-in chip (GoalEventForm, 16px, day N−1) | Mark today done → currentStreak increments | CelebrationToast (20px, day N) | streakCardLand 460ms cubic-bezier(0.34,1.56,0.64,1); crown entry animate: true, 460ms, same curve | impactLight (useStreakCelebration.ts:50); toast tier, no useToast call — the overlay owns its own dwell | global reset collapses both to end state; crown still identified by knockout + outline (§3.1) | tap-to-dismiss (StreakCelebration.vue:22-26); chip returns at day N |
CelebrationToast (day N) | day N is a milestone → intensityFor returns balanced | CelebrationBalanced (Hero + WeekRibbon + RankChip) | existing, unchanged | impactMedium on milestones (:47-48) | unchanged | unchanged |
StreakRepairCard default (broken, 20px) | Repair mutation confirms → success: true | StreakRepairCard success (rescued, 24px) | in-place frame swap, no route move | existing card copy; no new toast | no motion either frame | n/a — card dismisses |
The continuity defect this closes. intensityFor (streakRanks.ts:85-92) routes non-milestone ticks under day 30 to 'toast' and milestones to balanced/cinematic. So a Waves user checking in on day 5 gets CelebrationToast — a hardcoded FlameIcon (:4) — and on day 7 gets CelebrationBalanced, which does receive metaphor and renders waves. The motif flips between two consecutive days of the same journey, at the exact moment the app is rewarding them. Passing metaphor to Toast (§16.2) closes it. This is Orion's task-4b item; recorded here because it is a sequence defect, not only a static one, and the fix has to hold across both frames.
Timing. The toast's numeral counts day−1 → day over 600ms (CelebrationToast.vue:8) while the Mark's crown entry runs 460ms. The crown lands first and the number catches up — the glyph leads, which is the right order for a glyph that means "newest". No new curve or duration is introduced; both are §12's locked values.
No new motion vocabulary is minted anywhere in this addendum. Every keyframe, curve, duration and haptic tier cited is already in streakAnimations.ts, useStreakCelebration.ts or §12.
21. Findings recorded, not fixed here
WeekRibbon's six past pips fail the legibility floor.hsl(var(--streak-accent) / 0.67)measures 1.91:1 light / 3.67:1 dark against--card. Orion scoped the past pips out of Phase 2 and I have held that line — but the ribbon will now have a NOW pip at 5.43:1 beside six at 1.91:1. Route to Maggie.WeekRibbon's day labels are 9px, below the 11pxtext-2xsfloor declared intailwind-tokens.cjs. The alpha is fixed in §15.2b; the size is a layout change and is not. Route to Maggie.- No streak-jeopardy signal exists in the app, so
state: 'atrisk'— a fully specified state with its own palette, dash treatment, opacity floor and microcopy — is unreachable outside Storybook. Either a surface should gain the signal or the state should be acknowledged as Storybook-only. Route to Maggie. - P2-A4, corrected. Task 3 removes a
StoneBlockcall site, not aPALETTE_BY_TIERconsumer.StoneBlock.vueremains aPALETTE_BY_TIERconsumer and still ships on four other surfaces (StreakMetaphorSelector.vue,AchievementsHero.vue,StonesPyramid.vue,StonesWall.vue). Verified count atd6049e3f3: 7PALETTE_BY_TIERconsumers before Phase 2, 7 after —SproutIcon,SproutHero,WavesIcon,WavesHero,MountainIcon,MountainHero,StoneBlock. Phase 3 should start from 7, not 6. Put this number in the PR body. PALETTE_BY_TIERis untouched in this phase, per P2-A3 — its convergence withMOTIF_PALETTE_BY_TIERis Phase 3. Phase 1 §4.2 and §9.2 name Phase 2 for it; that is superseded. Stated here because a reviewer who read Phase 1 will look for it.- No guard catches raw Tailwind palette classes (
amber-400,orange-500). The five token guards cover alpha-stackedtext-foregroundinsrc/views/**, solid-fill foregrounds, plain surface ink, gradient primaries and the page-accent matrix — none scan for palette-class literals. The §15.4 swaps are review-enforced only. Route to Maggie. - The rank ladder's names are stone-flavoured — "First Stone", "Cornerstone", "Keystone", "Monument" — and a waves user now sees them beside a waves motif everywhere. A copy question, not a render one, and out of Phase 2's scope. Route to Maggie.
rareis still a no-op tier (Phase 1 §9.1), andMOTIF_PALETTE_BY_TIER.rarereproduces it. Days 3, 7 and 14 render exactly like day 2. Still open.
22. Storybook story brief
No new component files. Update the stories for every changed component; play() on RankChip and CelebrationToast at minimum, per Orion.
| Story file | Variants to add or update | argTypes |
|---|---|---|
streak/RankChip.stories.ts | Ladder — days 1/3/7/14/21/30/50/75/100/365 at tone: 'gold', so the ten rungs are visible as a ladder. Tones — all five tones at day 21, the two *Dark ones on a dark scrim decorator. EmptyRungContrast — 0.18 vs 0.32 side by side, the §15.1 evidence. | day (range 0–400), tone (select), size (select) |
streak/WeekRibbon.stories.ts | Motifs — 5 motifs × todayDone true/false. NotYetToday — the hollow NOW pip, the §15.2 ruling. | day (range 0–30), motif (select), dark (bool), accent (text) |
streak/CelebrationToast.stories.ts | Motifs — 5 motifs at day 21. Rescued — moment: 'rescued', tier: 'rescued'. | day (range 1–400), motif (select), moment (select), dark (bool) |
dashboard/StreakRepairCard.stories.ts | Broken and Restored — both frames, §19. | preBreakStreak, success (bool), restoredStreak |
goal/StreakVisualization.stories.ts | TierLadder — the current bar across standard/rare/epic/legendary. ZeroStreak — day 0, the broken header glyph. | currentStreak (range 0–400), longestStreak |
streak/StreakMetaphorIcon.stories.ts | Add day to every story; add SizeLadder at 16/18/20/24/44/56. | day, size, tier, state, isSupporter (override) |
dashboard/MomentumStat.stories.ts:124 | Pass a real day. | — |
dashboard/GoalGridCard.stories.ts, GoalFocusCard.stories.ts | Update the streak-chip variant to 16px Mark. | streak |
23. Acceptance criteria for Codi
Phase 1's §13 still governs StreakMotif itself. These are additive.
- [ ] Setting the motif to Waves and walking the app shows waves on all thirteen surfaces in §16, with the right count. No flame, no stone, anywhere a user's own live streak is rendered.
- [ ] No surface calls
effectiveMetaphor(false)and nomotifprop defaults to'stones'on the three M2 surfaces (§16.2). - [ ]
StreakMetaphorIconresolves the supporter flag itself;isSupporteris an optional override. Assert the cold-load rule (§16.1): before the flag settles, the stored preference renders — not'stones'. - [ ]
RankChiphas nomotifprop and imports no motif module. Empty rungs at0.32. All three raw hex literals replaced per §15.1. - [ ]
WeekRibbon's NOW pip rendersStreakMotifatday: 1/size: 18; the six past pips, the glow ring andstreakPipPulseare byte-unchanged. Day labels drop the/ 0.7alpha (§15.2b). - [ ]
StreakVisualization's current bar is a flat tier body fill; the Best bar is unchanged. - [ ]
GreetingCardrenders Mark at 56px; the-inset-3 blur-xlamber wash is removed; noamber-*ororange-*Tailwind palette class and no rawhsl(...)literal remains in the file. - [ ] Goal-card chips render Mark at 16px, not 12.
- [ ]
StreakRepairCardrendersbrokenin the default frame andtier: 'rescued'in the success frame (§19). - [ ] Every caller derives
stateper §18. No call site rendersday: 0withoutstate: 'broken'— assert this, it is the one failure that shows a user a streak they don't have. - [ ] Accessible names match §17 exactly. No surface announces the streak twice — assert with a test, not an eyeball. Surfaces 2, 5, 6, 8, 9, 10, 11, 13 gain no new name.
- [ ]
PathToNextBar.vueandPALETTE_BY_TIERare byte-unchanged (assert via diff). - [ ] The four out-of-scope
AppIcon name="streak"sites (PublicGoal.vue:131,CommunityBadges.vue:26,WidgetSetupSheet.vue:44,StreakHistoryChart.vue:5) each carry their one-line reason comment. - [ ] The PR body carries 7, not 6, as the
PALETTE_BY_TIERconsumer count (§21.4). - [ ] Renders at 375px without horizontal scroll on all thirteen surfaces.
- [ ]
npm run test:unit(full run),npm run test:storybook,eslint,vue-tsc --noEmitall green; the five token guards green.
24. Open questions
None blocking. Eight findings are recorded in §21 for Maggie to route. Two rulings go beyond Orion's literal file list and are flagged in the handoff rather than buried: §15.2b (the ribbon's day-label alpha — a live AA failure my own capture pass surfaced) and §19 (the repair card's success frame — the second half of a sequence whose first half was already in scope).
Throughline: run against the check-in → celebration and break → repair → restored journeys (§20). Two findings, both fixed in this spec: the motif discontinuity between CelebrationToast and CelebrationBalanced (fixed by §16.2's metaphor binding, transition specified in §20) and the undefined broken → rescued frame in StreakRepairCard (fixed by §19). One recorded for Maggie: state: 'atrisk' is a fully specified state with no reachable trigger (§21.3). No vocabulary gap — every curve, duration and haptic tier cited is already locked in §12, streakAnimations.ts and useStreakCelebration.ts.
Addendum last updated: 2026-09-09 · Version: v4.48.0 (OBJ-3567 correction to §15.4/§16/§16.1)
Addendum — v4.48 Phase 3: Bloom and Field renders
Scope. The four rulings Orion asked for in task 4a, plus the geometry contract Codi needs to build tasks 5 and 6: the streakPalette.ts convergence, the Bloom paint contract, per-motif Bloom geometry, Field and its ambient layer, render × size for the three real call sites, and the repaint blast radius. Written against 7127bd76c (master, Phase 2's 10b5f40f9 merged).
This is the milestone's first repaint of shipped UI. Phases 1 and 2 were additive or token-neutral; §25 changes colours users already see, on five surfaces, two of which no phase of this milestone names.
Mockups. .planning/phases/v4.48-phase-3-bloom-field-renders/mockups/ — bloom-accretion.html, field-and-call-sites.html, palette-convergence.html, and bloom.js, the executable reference geometry every builder in §27 is transcribed from. Codi ports bloom.js; it is not decorative.
25. The palette convergence
25.1 The shape — { body, crown } wins, and the third stop does not come back
Orion asked whether Bloom needs the three-stop { light, mid, dark } ramp at 72px+ or whether { body, crown } suffices. { body, crown } suffices — but not because Bloom has no depth. Because depth turns out to be tier-independent, so it does not belong in a per-tier record at all.
The reason a per-tier ramp cannot work is measurable, and it is the same reason #9A4A0F measures 2.86:1 in dark (§9.3): every -accessible token in this design system collides with its own family's -light rung in dark mode.
| Family | Light-mode rungs (L%) | Dark-mode rungs (L%) | Distinct rungs in dark |
|---|---|---|---|
| streak-accent | light 65 · anchor 55 · accessible 35 | light 65 · anchor 55 · accessible 55 | 2 |
| gold | light 70 · anchor 56 · accessible 27 | light 70 · anchor 56 · accessible 70 | 2 |
| coach | light 80 · anchor 65 · accessible 46 | light 80 · anchor 65 · accessible 80 | 2 |
| success | anchor 27 · accessible 20 | anchor 39 · accessible 39 | 1 |
| muted-foreground | 40 | 65 | 1 |
(Values from design_system/css/tokens.css :root and html.dark.)
No family ships three rungs that all flip with mode. A fixed three-stop ramp is therefore always anchored to one mode and always wrong in the other — which is exactly the bug shipped today. The convergence resolves it by making depth a relative overlay rather than a second and third hue.
The converged export:
// streakPalette.ts — replaces BOTH PALETTE_BY_TIER and MOTIF_PALETTE_BY_TIER (OBJ-3103).
// Values are HSL CHANNEL TRIPLETS, not finished colours: Bloom needs the same hue at
// five different alphas (recessive 0.45, at-risk 0.55, halo 0.45, ambient 0.18, full),
// and a finished `hsl(...)` string cannot be re-alpha'd without string surgery.
// Consume as `hsl(${p.body})` or `hsl(${p.body} / 0.45)`.
export interface MotifPalette {
/** The silhouette and every countable unit. Clears 3:1 on --card in BOTH modes. */
body: string
/** The newest unit's accent, or null where hue carries no signal (§5.3). */
crown: string | null
}
export const MOTIF_PALETTE_BY_TIER: Record<string, MotifPalette> = {
standard: { body: 'var(--streak-accent-accessible)', crown: 'var(--gold)' },
rare: { body: 'var(--streak-accent-accessible)', crown: 'var(--gold)' }, // §9.1: still a no-op tier
epic: { body: 'var(--gold-accessible)', crown: null }, // gold-on-gold: 1.17:1 dark
legendary: { body: 'var(--coach-accessible)', crown: 'var(--gold)' },
rescued: { body: 'var(--success)', crown: null }, // confirmed, not earned
dormant: { body: 'var(--muted-foreground)', crown: null }, // at-risk + broken
onPrimary: { body: 'var(--primary-foreground)', crown: null }, // §31.2 — a paint context, NOT a tier
}
/** Depth is tier-independent (§25.1). One mode-invariant deep ink, composited over
* `body` as a top-to-bottom gradient overlay. See §26 rule B2. */
export const MOTIF_SHADE = 'var(--motif-shade)'
export const MOTIF_SHADE_ALPHA = 0.28One new token, proposed here rather than minted silently (§9.3 deferred exactly this decision to Phase 3, "made with Bloom's geometry in hand"):
/* design_system/css/tokens.css — :root AND html.dark, byte-identical. */
/* The motif's shadowed face. Mode-invariant on purpose: the light source does not
flip with the theme, so the depth cue must not either. Only ever composited over
a `body` fill, never painted on --card directly (1.03:1 in dark). */
--motif-shade: 222 47% 8%;Codi adds the matching tailwind.config.js / design_system/tailwind-tokens.cjs key so tokenDrift.spec.ts's three-copy census stays green.
25.2 Every tier × every stop — the stated value
| Key | body | Light vs --card | Dark vs --card | crown | Notes |
|---|---|---|---|---|---|
standard | --streak-accent-accessible | 5.43:1 (was 2.62) | 6.83:1 | --gold | the light-mode correction |
rare | --streak-accent-accessible | 5.43:1 (was 2.62) | 6.83:1 | --gold | byte-identical to standard — §9.1 |
epic | --gold-accessible | 6.61:1 (was 1.69) | 12.44:1 | null | the light-mode correction |
legendary | --coach-accessible | 7.33:1 (was 3.96) | 8.40:1 | --gold | was passing but shallow |
rescued | --success | 5.03:1 | 6.91:1 | null | unchanged read layer |
dormant | --muted-foreground | 6.08:1 | 6.96:1 | null | absorbs atrisk + broken |
onPrimary | --primary-foreground | 4.56:1 vs --primary | 7.68:1 vs --primary | null | §31.2 |
Derived values, tier-independent:
| Layer | Value | Light | Dark | Floor |
|---|---|---|---|---|
| shaded end of the ramp | body + --motif-shade @ 0.28 | 7.57 – 10.18:1 | 3.98 – 6.77:1 | 3:1 ✅ |
| ramp visibility (shaded end vs body) | — | 1.39 – 1.50:1 | 1.70 – 1.84:1 | reads as volume, never as a second object |
| knockout — seam / cap / trail / crown ring | hsl(var(--card)) | 5.03 – 7.33:1 vs body | 6.83 – 12.44:1 vs body | 3:1 ✅ by construction |
| recessive — mountain's back peak | body @ 0.45 | 1.93 – 2.30:1 | 2.30 – 3.43:1 | 1.9:1 ✅ (tightest: rescued light) |
| ambient — Field only | body @ 0.18 | — | — | exempt (§29.2) |
(WCAG 2.x relative luminance against --card = 0 0% 100% light / 222 47% 11% dark. Reproduce with .planning/phases/v4.48-phase-3-bloom-field-renders/mockups/ + the token values in design_system/css/tokens.css.)
Correction to §5.2. That table lists
--successat 3.56:1 in dark. It measures 6.91:1 — 3.56 is the number you get from:root's160 84% 27%, buthtml.darkline 481 overrides--successto160 84% 39%(OBJ-2669). The verdict (✅) is unchanged; the number was wrong.
25.3 The nine raw hex literals — all nine are deleted, none is tokenized
Every literal lives in the light or dark stop. Both stops are removed by §25.1, so no tokenization decision is needed for any of them:
| # | Literal | Where | Fate |
|---|---|---|---|
| 1–2 | #9A4A0F | standard.dark, rare.dark | deleted with the stop. 2.86:1 in dark — the §9.3 finding. Its job (a per-unit edge) is now hsl(var(--card)), which is correct in both modes by construction. |
| 3 | #6B21A8 | legendary.dark | deleted with the stop. 2.05:1 in dark. |
| 4 | #5EEAD4 | rescued.light | deleted with the stop. 1.48:1 in light — invisible on a white card. |
| 5 | #0E7C5F | rescued.dark | deleted with the stop. 3.47:1 dark; no longer needed. |
| 6–7 | #94A3B8, #334155 | atrisk.light/.dark | deleted with the key. atrisk was a state laundered as a tier (§1.4); it folds into dormant. |
| 8–9 | #94A3B8, #334155 | locked.light/.dark | deleted with the key — §25.4. |
Result: streakPalette.ts contains zero raw hex. Codi extends tokenDrift.spec.ts with a census asserting that — the file is small, the assertion is one regex, and it is the only thing that keeps this true past this phase.
Adjacent, out of scope, recorded in §32.1: StoneBlock.vue holds four raw rgba() literals of its own (rgba(0,0,0,0.18), rgba(255,255,255,0.55), rgba(255,255,255,0), rgba(0,0,0,0.30)). Task 5 rewrites that component's gradient anyway; if the rewrite retires them for free, take it. Do not open a separate sweep.
25.4 locked — the stated fate
locked loses its palette entry and keeps its state. It is not a tier, and its entry is unreachable:
StoneBlock.vue:9-11short-circuits all three gradient stops totransparentwhenstate === 'locked'.pis never sampled.StoneBlock.vue:27-30swaps the stroke forhsl(var(--foreground) / 0.18)(light) /hsl(var(--card-foreground) / 0.22)(dark), a dashed 0.6 hairline at 0.55 opacity. Also notp.- The only path that could reach
PALETTE_BY_TIER.lockedispalette="locked"withstate !== 'locked'. No call site inionic_frontend/srcpasses it. TheStreakTier | 'locked'prop type is what kept the entry alive, not a consumer.
So: delete the key, keep the 'locked' member of StoneState, leave the rendering at StoneBlock.vue:9-11,27-30 byte-unchanged. Narrow palette's type from StreakTier | 'locked' to the converged key set. atrisk gets the same treatment for the same reason — a state, not a rung — and folds into dormant, which §1.4 already normalizes toward.
26. The paint contract — five rules, and why they are correct by construction
Bloom has more layers than Mark, and the failure mode is per-layer per-tier per-mode verification (6 tiers × 2 modes × ~6 layers = 72 cells). These five rules collapse it to the seven measured rows in §25.2.
B1 — the count is always painted in
body, andbodyalone. Stones' blocks, sprout's leaves, flame's coals, waves' crests: full-opacitybodyon--card, so every countable mark clears 3:1 in both modes by §25.2 row 1. The DC paints three of these inp.darkor a fixed off-white and each fails in one mode — flame's coals at 2.86:1 dark, mountain's ticks (#FFF6E8) at 1.07:1 light.bodyis the correction, not a preference.
B2 — depth is an overlay, never a hue. Volume is
--motif-shadeat α 0.28 in a top-to-bottomuserSpaceOnUselinear gradient, painted as a second copy of the silhouette path over the first. Because it composites over whateverbodyresolves to, it is correct in both modes for every tier with no per-cell check. Never paint--motif-shadeon--carddirectly — 1.03:1 in dark.
B3 — anything drawn on the silhouette that must be read is a
--cardknockout. Stones' per-block seams, mountain's trail ticks and summit cap, sprout's crown ring, waves' band separators. Contrast against the body is exactly the body's own ≥3:1 card ratio — this is Phase 1 §3.1's crown-knockout argument extended from the crown to the interior, and it is the only device that survives a mode flip. Measured alternative, rejected: an opaque white sheen lands at 1.44 – 2.62:1 againstbodyin dark. There is no mode-safe light ink here;--cardis it. A knockout is an interior device — it never outlines the silhouette against the page.
B4 — recessive forms are
bodyat α 0.45, floor 1.9:1. Only mountain's back peak. Same hue as the silhouette, so it reads as depth rather than as a second object. The floor is ours, not WCAG's, on the same footing as §5.1: below roughly 1.5:1 an edge stops being perceivable as a boundary at 72px (#5EEAD4on white, 1.48:1, is the worked example). 1.9:1 is the shallowest value every tier clears in both modes. The DC'sp.lightspur at α 0.30 is dropped — it measures 1.53:1 and B2's ramp already carries that flank.
B5 — where the crown sits on the silhouette, the crown signal is weight and halo, not hue. Mountain only. Gold on
bodycollapses to 1.17 – 1.55:1 in dark (§5.3), so mountain's newest step is a--cardtick atedge(size, 2.6)instead ofedge(size, 1.6), carryingstreakStoneHalo 2.4s ease-in-out infinite. Everywhere else the crown sits on the card and takes Mark's existing treatment:crown ?? bodyfill + a--cardknockout ring + the halo.
Stroke scaling. Every stroke in Bloom passes through Mark's existing edge(size, base) => Math.max(base, 100 / size) (StreakMotif.vue:105-107). At 72px one user unit is 0.72 device px, so the DC's 0.5-unit seam renders at 0.36px — a sub-pixel line that anti-aliases into nothing. Reuse the shipped function; do not write a second one.
viewBox. Bloom and Field use 0 0 100 115 with preserveAspectRatio="xMidYMax meet" — the DC's box and the heroes' box, not Mark's 0 0 100 106. Rendered height is size × 1.15. This is why the three call-site sizes fit their containers (§30).
27. Bloom geometry, per motif
Transcribed from the DC's bloom() builders (Streak Motif System.dc.html, class Component extends DCLogic, lines 559–767) with the B1–B5 corrections applied. The executable reference is mockups/bloom.js; the deltas from the DC are listed under each motif so a reader comparing the two does not read a correction as a transcription error.
Module shape. streakMotifGeometry.ts is group-level today (groupCountForDay, buildMotifShapes(motif, groups)). Bloom is day-level. Add a second builder; do not overload the first:
export type BloomRole = 'silhouette' | 'unit' | 'recessive' | 'knockout' | 'ambient'
export interface BloomShape {
tag: 'rect' | 'path' | 'ellipse' | 'line'
attrs: Record<string, string | number>
role: BloomRole // maps 1:1 onto the B1–B5 paint rules — the module decides
crown: boolean // the role, StreakMotif.vue decides the paint. Nothing else.
}
export function buildBloomShapes(
motif: StreakMetaphor,
day: number,
opts: { size: number; state: 'standard' | 'atrisk' | 'broken'; field?: boolean },
): { shapes: BloomShape[]; overflow: number }The role enum is the paint contract. StreakMotif.vue gains exactly five paint branches and no per-motif knowledge; streakMotifGeometry.ts gains no colour and stays pure (no Vue, no DOM).
MOTIF_CAPS (stones 63, sprout 42, flame 35, mountain 32, waves 49) is already a day cap and is reused as-is. groupCountForDay and buildMotifShapes are untouched — Mark still needs them.
27.1 Stones — block / row of 7 · cap 63
bw 12 · bh 6.4 · gap 1.7 · 7 cols · 9 rows, laid bottom-up, rows alternating ±3.5 offset. x = 3.2 + col·13.7 ± 3.5, y = 111 − (row+1)·8.1. Each block: rx 1.4, body fill, --card seam at edge(size, 0.9), then the shared ramp copy. The ramp spans userSpaceOnUse from the top row's y to 111, so the wall reads as one lit mass rather than 63 independently shaded bricks. Delta from DC: the p.dark stroke becomes the --card seam (B1/B3); the rgba(255,255,255,0.42) top highlight is dropped (B3 — no mode-safe light ink; the ramp carries it).
27.2 Sprout — leaf / node of 7 · cap 42
nodes = ceil(shown/7), H = 16 + nodes·14.5. Stem: M50 112 C 50 (112−0.35H), 52.5 (112−0.7H), 50 (112−H), stroke-width 3.2, stroke-linecap round, body — then the same path again with the ramp, so the stem darkens toward the soil. Leaf i: node = ⌊i/7⌋, j = i%7, side = j%2 ? 1 : −1, y = 112 − 13 − node·14.5 − ⌊j/2⌋·3.1, len = 10.5 − node·0.75, cx = 50 + side·len·0.68, rx = len·0.68, ry 3.3, transform rotate(side·−26, cx, y). Delta from DC: the DC's per-leaf opacity ramp (0.72 + 0.28·((j+1)/7)) is dropped — it bottoms out at 0.72 for the oldest leaf in every node, which is a count layer dimmed below B1. Every leaf is full-opacity body; the --card seam at edge(size, 0.7) separates overlapping leaves instead.
27.3 Flame — coal / hearth ring · cap 35
New work, not a refactor (F3). Coal i: ring = ⌊i/7⌋, j = i%7, r = 15 + ring·8.6, a = (j/7)·2π + ring·0.46 − π/2, cx = 50 + cos(a)·r, cy = 100 + sin(a)·r·0.40 (the 0.40 is the ground-plane foreshortening), rx 3.4 · ry 2.2, body fill, --card seam at edge(size, 0.7). Body: weeks = ceil(shown/7), s = 0.30 + min(weeks,5)·0.115, transform translate(50 98) scale(s) translate(-50 -110) on the DC's flame path — three layers, in order: solid body; a radial crown core (cx 50% cy 62% r 52%, α 0.55 → 0 at 60%, omitted when crown is null); the linear ramp. Delta from DC: coals move from p.dark (2.86:1 dark) to body (B1). The #FEF3C7 hot-core stop becomes the tier's crown, which is the same warm gold on standard/rare/legendary and correctly absent on epic/rescued/dormant. The rgba(242,133,41,0.45) drop-shadow becomes hsl(${body} / 0.40).
27.4 Mountain — step / leg of 7 · cap 32
steps = shown, H = max(34, steps·3.1 + 9), apex = 112 − H, W = min(H·0.60, 46). The max(34, …) floor is a correction, not a port. Un-floored, day 1 is a 12-unit form in a 115-unit box — a speck that reads as debris rather than a mountain. The trail still carries the exact count; the mass just stops claiming the count is the size below a week. Same discipline as §6.1. Layers, in order: contact ellipse (cy 112, rx W·1.02, ry 2.6, --motif-shade @ 0.16 — ambient, exempt); back peak (bx = 50 − W·0.58, bH = 0.62H, bW = 0.52W, recessive body @ 0.45); front silhouette (M50 apex L 50+W 112 L 50−W 112 Z, body); the ramp; the summit cap (ch = min(0.30H, 32), hw = W·0.36, the DC's cubic cap path, --card knockout). Trail tick i: leg = ⌊i/7⌋, j = i%7, dir = leg%2 ? −1 : 1, y = 112 − 3 − (i+1)·(H−12)/steps, half = W·(1 − (112−y)/H)·0.80, xc = 50 + dir·(−half + ((j+0.5)/7)·2·half), drawn xc±3.1 in --card at edge(size, 1.6), crown at edge(size, 2.6) + halo (B5). Delta from DC: the p.light spur is dropped (B4); the #FFF6E8 cap and ticks become --card (B3 — #FFF6E8 is 1.07:1 on a white card); tick y is spread across the floored H rather than a fixed 3.1 step, so the trail fills the mass at every day.
The dark-mode summit cap reads as the card showing through, not as snow. That is deliberate and measured: opaque white lands at 1.44 – 2.62:1 against
bodyin dark (§26 B3). The knockout is the only device that clears 3:1 in both modes, and it is the same device Phase 1 already shipped for the crown ring. Do not "fix" it with a lighter fill.
27.5 Waves — crest / line of 7 · cap 49
lines = ceil(shown/7), x0 3.5 · cw 13.3 · bandH 6.4 · step 14.2 · baseY 102. Line line: n = min(7, shown − line·7), y = baseY − line·14.2, amp = 5.2 − line·0.42. Top edge: M x0 y then per crest C a+4.4 y−amp, a+8.9 y+amp, a+cw y. Band = top edge closed down to y + bandH. Painted body, then the ramp, then the top edge again as a --card separator at edge(size, 1.1). Crown: the newest crest's single cubic, drawn twice — a --card knockout at edge(size, 4.2), then crown ?? body at edge(size, 2.9), plus the halo. Delta from DC: the per-line opacity ramp (0.78 − line·0.075, bottoming at 0.33 → 1.53:1) is dropped; every band is full-opacity body and the --card separator does the layering (B1). The p.light crest stroke becomes that separator.
28. States at Bloom
standard, atrisk and broken keep the Phase 1 semantics (§3) — this section states only what changes at Bloom's scale.
- Default / standard — as §27. Crown per B5.
- At-risk — every
body-painted layer drops toopacity 0.55; the crown loses its gold and its knockout ring (§3.2: "there is no colour to separate"); the halo does not run. Knockouts, the ramp and the recessive peak are unchanged — dimming a knockout would let the seam bleed. - Broken — one hollow scaffold per motif at
day 0:fill: none,stroke: hsl(${dormant.body} / 0.7)(3.15:1 light / 4.04:1 dark, the §5.2 alpha floor),stroke-width: edge(size, 1.4),stroke-dasharray: 3,2.4. Seven ghost units per motif, per the DC's own broken builders — at Bloom's scale the unit is the day, so unlike Mark (§3.3, one hollow group glyph) the scaffold shows a week. No crown, no ramp, no cap, no ambient layer. - Loading —
StreakMotifstill renders nothing without a resolvedday(§8.3). The three call sites own their own skeletons;StreakDetailsModal.vue:8-22already has one. - Focus / hover / active / disabled — not applicable. Bloom is
aria-hidden, non-interactive, and never a control. The container owns focus (§8.1). - Dark mode — no per-tier delta. Every difference is carried by
--card, the-accessibletoken flips, and B2's mode-invariant overlay. That is the whole point of §26. - Motion-reduced — §29.3.
29. Field, and the ambient layer
29.1 The DC does not define Field
The DC names Field once, in prose, in its own parity table: "Field render — Bloom at 140px plus the motif's own ambient layer." There is no field() builder, no per-motif ambient definition, and no geometry to port. Orion's assumption that "the DC's bloom() builders remain authoritative for Bloom/Field geometry" holds for Bloom and does not hold for Field. Field is designed here.
29.2 One primitive, five directions
Field = Bloom + role: 'ambient' marks. The marks are the same primitive for every motif; only direction, count and cadence differ, because the meaning differs.
| Motif | Reads as | n | Direction | Mark (units) | Cycle |
|---|---|---|---|---|---|
| stones | dust settling off a wall just built | 3 | down | 2.2 × 2.2 | 7.5s |
| sprout | pollen off a living plant | 4 | up | 1.8 × 1.8 | 9s |
| flame | embers off the hearth | 5 | up | 2.0 × 2.6 | 5.5s |
| mountain | haze at altitude | 3 | up | 2.4 × 1.4 | 11s |
| waves | swell carrying past | 4 | across-down | 3.0 × 1.6 | 8s |
Fill hsl(${body} / 0.18), rx = min(w,h)/2, staggered i × 1.3s, linear infinite. Travel is 46 units up / 42 down, fading in at 12% and out at 88%.
The ambient layer is exempt from every contrast floor in this spec, and that exemption is its definition. It carries no information: remove it and the motif is unchanged — same day, same tier, same state, same count. If a layer cannot be removed without losing meaning, it is not ambient, and it takes B1's floor instead. That test, not the alpha value, is the rule.
Anti-social check: no loop rewards attention, nothing accumulates, nothing waits to be watched. The marks are slower than the eye tracks and stop mattering in under a second.
29.3 Reduced motion — no media query, by design
The marks carry opacity: 0 as their resting style; the visible opacity exists only inside the keyframes, and the animation declares no fill-mode. Under App.vue:566-575's global reset (animation-duration: 0.01ms; animation-iteration-count: 1) the animation completes instantly and the element returns to its resting opacity: 0 — invisible, with nothing stranded. This is SparkLayer.vue:65-68's shipped pattern.
Do not add @media (prefers-reduced-motion: reduce) { .om-ambient { display: none } }. It would be dead CSS that reads as an accessibility feature — the §9.4 anti-pattern. Getting the resting state right is the fix; the media query only looks like one.
(This one bit was wrong in the first mockup iteration and is called out because the failure is silent: with no resting opacity: 0, the reduced-motion path leaves 3–5 fully opaque dots sitting frozen over the celebration. The stills cannot show it; only reasoning about the fill-mode can.)
29.4 SparkLayer — superseded on Cinematic, untouched on Balanced
SparkLayer.vue is tier-driven (tier/count/seed); Field's ambient layer is motif-driven. They are different things and they overlap on exactly one surface.
CelebrationBalanced.vue:4-9— unchanged. It renders Bloom (§30), not Field, and has no ambient layer of its own.SparkLayeris its ambient. Behaviour, props and tier gating stay byte-identical. This is Orion's task-6 acceptance criterion.CelebrationCinematic.vue:19-24— superseded. Remove the<SparkLayer>mount, with an inline note pointing here. Two particle systems on one 120px glyph is the engagement-loop shape the brand rules forbid, and the tier signal at Cinematic is already carried three other ways:StreakHalo(:18), the legendary ray fan (:3-15), andRankChip'slegendaryDarktone (:29). Nothing about the tier becomes unreadable.SparkLayer.vueitself is not deleted — Balanced still imports it.
30. Render × size, per call site
The kickoff's "72px / 140px" does not survive contact with the code. Verified at 7127bd76c:
| Call site | Line | Glyph | Container | Rendered box (size × 1.15) | Render | Why |
|---|---|---|---|---|---|---|
StreakDetailsModal.vue | :70-72 | 72px | min-h-[84px] grid | 72 × 82.8 ✅ | Bloom | Informational, not celebratory. An ambient layer on a surface the user opened to read is decoration asking for attention. |
CelebrationBalanced.vue | :16-18 | 92px | 110px metaphorBox | 92 × 105.8 ✅ | Bloom | Celebratory, but SparkLayer is already its ambient layer (§29.4). |
CelebrationCinematic.vue | :33-35 | 120px | 140px metaphorBox | 120 × 138 ✅ | Field | The one surface with room for atmosphere, and the only one where the day-14 flip was most visible. |
140 is a container, not a glyph. The DC's "Bloom at 140px" describes the box; :33-35 passes :size="120". All three glyphs fit their containers at × 1.15 — that is not a coincidence, it is the constraint that fixes the sizes.
Render is chosen by what the surface is, not by how many pixels it is. A fourth call site at 100px would still be Bloom unless it is a full-ceremony celebration.
Floors.
- Bloom ≥ 64px. The smallest countable mark is flame's coal at
6.8 × 4.4units →4.35 × 2.8device px at 64. At 44px it is3.0 × 1.9— Phase 1 §6.1's own threshold for where a mark stops claiming a count. Below 64, userender: 'mark'. - Field ≥ 112px. The ambient marks travel 46 units outside the silhouette; below 112 they land inside the glyph and read as noise on the form.
- Stones is the binding case for the modal. At day 63 the wall is 9 rows × 8.1 units = 72.9 of 115, so the 72px call site renders a 46px-tall wall. Legible, and unchanged from what
StonesHeroships today at:scale="0.75".
31. Repaint blast radius
PALETTE_BY_TIER has 7 consumers at 7127bd76c (Orion's count, re-verified): StoneBlock.vue, SproutHero.vue, MountainHero.vue, WavesHero.vue, SproutIcon.vue, MountainIcon.vue, WavesIcon.vue. Task 5 rebases the three heroes onto StreakMotif, leaving 4.
| # | Surface | Reached via | In this milestone? | Ruling |
|---|---|---|---|---|
| 1 | StreakDetailsModal | StonesHero → StoneBlock; heroes | yes | in |
| 2 | CelebrationBalanced | same | yes | in |
| 3 | CelebrationCinematic | same | yes | in |
| 4 | AchievementsHero.vue:26-29 | StoneBlock ×3, palette="epic" | no | in — §31.2 |
| 5 | StreakMetaphorSelector.vue:33-37, 68-72 | StoneBlock + the three *Icons | no | in — §31.1 |
| 6 | StreakVisualization.vue:63 | MOTIF_PALETTE_BY_TIER[…].body | yes (Phase 2) | in — key set unchanged for it; only the value shape moves (§25.1), a one-line hsl(...) wrap |
| 7 | Storybook stories for all of the above | — | — | in |
31.1 StreakMetaphorSelector — IN, and the repaint is strictly an improvement
After Phase 2 it is the sole production consumer of SproutIcon / MountainIcon / WavesIcon. Its five thumbnails render at 56px (desktop card grid) and 44px (mobile row grid), all at tier="standard" / palette="standard" — the exact entry §25.2 corrects.
Its tiles sit on bg-muted, not --card, so the numbers that matter are against --muted:
| Light | Dark | |
|---|---|---|
today — --streak-accent | 2.39:1 | 5.29:1 |
converged — --streak-accent-accessible | 4.94:1 | 5.29:1 |
Light mode improves by more than 2×; dark is byte-unchanged (the token is 27 89% 55% in dark either way). There is no cell where this surface gets worse. In, no caveat, no separate sign-off.
One consequence to state so Codi does not report it as a bug: the --card seam (B3) measures 1.10:1 / 1.29:1 against --muted, so where a stone's seam meets the tile background it disappears. That is correct — a knockout is an interior device (B3), and the silhouette's own 4.94:1 against the tile is what carries the shape.
31.2 AchievementsHero — IN the blast radius, but it must not take a tier
Three 10×7px StoneBlocks at palette="epic", and they sit on --primary, not --card (.hero-card { background: hsl(var(--primary)) }).
The tier palette is floored against --card and says nothing about --primary. Applying it here is a measured regression:
| Fill | vs --primary light | vs --primary dark |
|---|---|---|
--gold — today | 2.70:1 | 1.46:1 |
--gold-accessible — what the convergence would give it | 1.45:1 | 1.71:1 |
--gold-light | 3.17:1 | 1.71:1 |
--primary-foreground | 4.56:1 | 7.68:1 |
--primary's own lightness flips (41% → 61%), so no fixed hue clears 3:1 on it in both modes — which is why today's 2.70 / 1.46 was already below floor before this phase touched anything. The only value that clears is the primary card's own paired ink.
Ruling: these three blocks take palette="onPrimary" — the seventh key in §25.1, named as a paint context, not a tier, so the next surface that renders stone furniture on --primary reuses it instead of inventing another. Three attribute values change in AchievementsHero.vue; nothing else about the component moves, and no new prop is added.
Alternative considered and rejected: pinning epic to --gold to keep this surface still. That would leave epic at 1.69:1 on --card across every streak surface — trading a real fix on five surfaces for no change on one, and leaving this one below floor anyway.
31.3 What a reviewer will see in the diff
Colours that change on surfaces users already use: the streak details hero, both celebration heroes, the motif picker's five thumbnails, and the achievements rank pyramid. Everything else in the phase is new geometry behind a prop that rendered nothing.
32. Findings recorded, not fixed here
32.1 StoneBlock.vue holds four raw rgba() literals. rgba(0,0,0,0.18) (:21), rgba(255,255,255,0.55) / rgba(255,255,255,0) (:14-15), rgba(0,0,0,0.30) (:39). Outside streakPalette.ts and outside Orion's nine. Task 5 rewrites that gradient anyway. Route to Maggie if it does not fall out for free.
32.2 §5.2's --success dark figure was wrong. 3.56:1 published, 6.91:1 measured — the published number came from :root's value falling through, but html.dark:481 overrides it (OBJ-2669). Verdict unchanged. Corrected inline at §25.2 rather than left to be re-derived.
32.3 §4.1 called --streak-accent-accessible-foreground "mode-invariant white." It is not.html.dark overrides it to 27 89% 10% (OBJ-2927). §4.1 mapped the mountain snow cap and the stone top highlight — both marked "Bloom only" — onto that token on the strength of that claim, which would have given Codi a near-black cap in dark mode. §26 B3 replaces both mappings with --card and the reasoning is now the measured one. No action for Maggie — corrected here, in the phase that would have shipped it.
32.4 rare is still a no-op tier. §9.1's finding, unchanged and now reproduced in the converged export. Days 3, 7 and 14 render exactly like day 2. Route to Maggie — give rare a palette or drop the rung; it has never been a Phase 1/2/3 decision.
32.5 StreakDetailsModal uses a bare <ion-modal> with no useModalAnimations binding.:2 mounts <ion-modal class="streak-modal"> directly — no enterAnimation/leaveAnimation, not routed through <AppModal>. Throughline gate g10. Pre-existing, unrelated to this phase's change, and fixing it would repaint a transition nobody asked about. Route to Maggie.
32.6 CelebrationBalanced ends on "Tap anywhere to dismiss." Throughline gate g52 ("the done moment celebrates, then gets out of the way — auto-dismisses, no tap-to-dismiss wall"). Cinematic has explicit Share / Onward buttons and is arguably a terminal-exit surface; Balanced is not. Pre-existing. Route to Maggie.
32.7 +N means different things at Mark and at Bloom, on purpose. Mark's overflow is totalGroups − displayGroups (StreakMotif.vue:100); Bloom's is day − cap, matching the DC. They are different units because the renders count different things. Stated so nobody "harmonizes" them. StonesWall.vue:17-19's "+ N more stones" label retires with the rebase — it is motif-specific text on a path that now renders five motifs. The chip is the numeral alone: +12. No accessible name is needed — the motif is aria-hidden and the container's name already carries the true day count (§8.1).
Superseded at Mark (OBJ-3358): this ruling held that Mark's leaked-internal unit was "acceptable, stated so nobody harmonizes them." It didn't survive contact with a real screen — the number changed across five motifs for one identical 124-day streak, and its own centring box was being lost to the exact chip this section approved. Mark's
+Nchip is removed, not relabelled; see.planning/phases/v4.53-obj-3358-streak-mark-overflow-chip/UI-SPEC.md. This ruling stands unchanged for Bloom —day − capstill means something.
33. Microcopy
One new string in this phase. Everything else on these three surfaces is unchanged.
| Surface | String | Tone check |
|---|---|---|
| Bloom overflow chip | +12 | numeral only ✅ — the container's accessible name carries "75-day streak" (§8.1), so a word here would be the third place the same number appears |
(retired) StonesWall overflow | + 12 more stones | motif-specific text on a five-motif path (§32.7) |
34. Storybook story brief
| Path | Variants | argTypes |
|---|---|---|
StreakMotif.stories.ts (extend) | Bloom, BloomAccretion (day 1/7/15/30/70/300), BloomStates, BloomTiers, Field, FieldAmbient, BloomBelowFloor (44px, documents why Mark takes over) | add render (mark | bloom | field), widen size to range 16–140, keep day as range 0–320 |
StonesHero.stories.ts | keep every existing story; add PropSurface asserting all twelve props forward | — |
CelebrationBalanced.stories.ts / CelebrationCinematic.stories.ts | add Day15, Day30, Day300 per motif — the F4 comparison as a story, not just a test | — |
SproutHero / MountainHero / WavesHero .stories.ts | delete — the components are retired by the rebase | — |
The BloomAccretion story is the F4 regression made reviewable: one screen, five motifs, six days, nothing identical.
35. Acceptance criteria for Codi
- [ ]
streakPalette.tsexports one record with the seven keys in §25.1 and zero raw hex; atokenDrift.spec.tscensus asserts it - [ ]
--motif-shade: 222 47% 8%declared in:rootandhtml.dark, byte-identical, plus the matching Tailwind key in both token config copies - [ ]
lockedhas no palette entry;StoneBlock.vue:9-11,27-30renders byte-unchanged - [ ]
atriskfolds intodormant;StoneBlock'sstate === 'atrisk'branch maps to it - [ ]
MotifPalettevalues are HSL triplets consumed ashsl(${p.body})/hsl(${p.body} / α);StreakVisualization.vue:63updated to match - [ ]
buildBloomShapes()is a new day-level export;groupCountForDayandbuildMotifShapesare untouched;streakMotifGeometry.tsstays pure - [ ]
StreakMotif.vuehas exactly five paint branches, one per B1–B5 role; no per-motif colour logic - [ ] Bloom uses
viewBox="0 0 100 115"; every stroke passes throughedge(size, base) - [ ] Every motif's Bloom is pixel-distinct at day 15 vs day 30 vs day 300, asserted, not eyeballed
- [ ] Flame renders a day-accurate Bloom; no
FlameIconremains atStreakDetailsModal.vue:73,CelebrationBalanced.vue:19,CelebrationCinematic.vue:36 - [ ]
+N=day − cap, rendered as the numeral alone, past every motif's cap - [ ] No
day > 14switch survives in any motif render path, includingStonesHero.vue:48 - [ ]
StonesHero's twelve-prop surface is unchanged andStreakDetailsModal.vue:65-69still passes all six; regression test asserts the forward - [ ]
darkdeleted fromSproutHero/MountainHero/WavesHero/StonesPyramidonly —StonesWall.vue:99andStoneBlock.vue:27keep their live use - [ ] Render × size per §30: 72 Bloom / 92 Bloom / 120 Field
- [ ]
SparkLayerunchanged on Balanced; removed from Cinematic with an inline note citing §29.4 - [ ] Field's ambient marks rest at
opacity: 0and carry noprefers-reduced-motionrule (§29.3) - [ ]
AchievementsHero.vue:26-29usespalette="onPrimary" - [ ]
streakPalette.ts's stale "Phase 2 converges the two" comment is corrected - [ ] Renders at 375px without horizontal scroll; WCAG AA / the §25.2 floors verified in both modes
- [ ]
npm run test:unit(all five token guards),test:storybook,eslint,vue-tsc --noEmitgreen; ≥80% statement coverage, no drop
36. Flow sequence
| From | Trigger | To | Transition + duration | Feedback | Reduced-motion | Back-nav |
|---|---|---|---|---|---|---|
| Check-in | streak increments, non-milestone | CelebrationBalanced — Bloom 92 | Ionic modal, useModalAnimations 250ms cubic-bezier(0.32,0.72,0,1); crown enters 460ms cubic-bezier(0.34,1.56,0.64,1) (§12) | haptics.impactLight (useStreakCelebration.ts:50) | crown animation collapses to 0.01ms; knockout + outline remain the signal (§3.1) | dedup via sessionStorage.streakCelebrated — g31 satisfied by the shipped guard |
| Check-in | streak hits a milestone | CelebrationCinematic — Field 120 | same modal curve; ambient layer runs linear infinite from mount | haptics.impactMedium; toast achievement 5s where the caller fires one | ambient rests at opacity: 0 (§29.3); crown collapses as above | same dedup key |
| Dashboard | tap the streak tile | StreakDetailsModal — Bloom 72 | <ion-modal> default presentation — not useModalAnimations; see §32.5 | none — the modal is a read surface, g28 correctly silent | nothing to collapse; Bloom is static without animate | closeModal, state preserved |
| Any celebration | dismiss | back to the surface behind | modal leave, 250ms | none | — | celebration does not re-fire (g31) |
Nothing in this table is new vocabulary. Every curve, duration and haptic tier is already locked in §12, streakAnimations.ts, useModalAnimations.ts and useStreakCelebration.ts.
37. Open questions
None blocking. Six findings are recorded in §32; two of them (§32.5, §32.6) are pre-existing throughline gate failures on surfaces this phase touches but does not own, and both are for Maggie to route rather than for this phase to widen into.
Throughline: traced check-in → celebration and dashboard → streak details before specifying, and audited the sequence in §36 after. Two findings fixed in this spec — Field's undefined reduced-motion fallback (fixed in §29.3, and the mockup was wrong first, which is the argument for running the check) and the undefined ambient/SparkLayer overlap at Cinematic (fixed in §29.4). Two recorded for Maggie as implementation drift: g10 at §32.5 and g52 at §32.6. No vocabulary gap — --motif-shade is a token proposal under §25.1, not a motion-vocabulary entry, and every curve, duration and haptic tier in §36 is already locked.
Addendum last updated: 2026-09-01 · Version: v4.48.0
Addendum — v4.48 Phase 4: state-transition motion
Task: OBJ-3104 task 7a. Depends on: Phase 3 (shipped, PR #2776, merge d62a12c20). Blocks: task 7 (Codi). Mockups: .planning/phases/v4.48-phase-4-state-transition-motion/mockups/ — motion-grain.html, rank-up-restage.html, reduced-motion.html, with stills and motion clips under mockups/screenshots/.
Phases 1–3 built the grammar and put it on every surface at every size. It does not move yet: the only streak motion that ships is Phase 1's entry keyframes and Phase 3's ambient drift. Every state transition is a hard cut.
What this addendum decides, and what it does not. Every curve, duration and haptic tier in the contract's motion table is locked and restated here, never re-derived — that was Orion's explicit boundary. What the contract could not decide, because Bloom and Field did not exist when it was written, is which render grain each row runs at, and what its central rule — only the newest unit moves — refers to once the unit is a day instead of a group. That is §38. Two consequences of answering it are new rules the contract has no opinion on (§38.3), and both are stated rather than left to Codi.
38. The four rulings
38.1 (i) What "only the newest unit moves" means at Bloom
Ruling: the referent follows the render's own unit. At Mark it is the newest week-group; at Bloom and Field it is the newest day-mark. The rule does not change; its referent does.
This is not a new mechanism. buildBloomShapes already sets crown: true on the newest day-mark (streakMotifGeometry.ts, per §26 B5), exactly as buildMotifShapes sets it on the newest group. isCrown is grain-agnostic by construction, so extending the existing crown-scoped entry gate at StreakMotif.vue:263-270 to Bloom is a matter of reaching Bloom, not of teaching it a second rule.
What changes is the consequence, and it is large:
| Mark | Bloom / Field | |
|---|---|---|
| The unit | a week-group | a day |
| Units drawn | ≤ 3 (2 at size ≤ 20), plus +N | up to the motif's cap — 63 for stones |
| The crown | newest of ≤ 3 group glyphs | newest of up to 63 day-marks |
| A transition fires | one day in seven | every day |
| Re-animating earned units means | re-animating ≤ 2 things | re-animating up to 62 things |
The contract's failure mode — re-animating earned units — is a nuisance at Mark and a strobe at Bloom. The gate is not optional there; it is the whole reason Bloom is legible.
The entry gate does not currently reach Bloom at all.
bloomNodes(StreakMotif.vue:313-427) never readsprops.animate, and none of the three Bloom/Field call sites pass it. Phase 1's §12 keyframes have therefore never run at Bloom or Field. Extending the gate is new work, not a one-line widening — say so in the PR body so a reviewer does not read the diff as a tweak.
38.2 Two corollaries the grain question produces
Answering (i) surfaces two cases the contract cannot see, because both only exist once a render has a cap and an overflow chip. Both are ruled here rather than left to Codi.
C1 — Past the cap, the newest unit is the +N chip. At stones day 64 there is no 64th block; the day is absorbed into +N = day − cap (§32.7). At Mark the same thing happens far earlier — past 3 groups the drawn geometry is identical from one week to the next and only the chip changes. Rule: when the render's drawn geometry is unchanged by the increment, the row moves to the +N chip. It gets streakNumberPop — the existing keyframe at streakAnimations.ts:29-33 — at the row's own duration. No new keyframe, no new curve.
Superseded at Mark (OBJ-3358): with the
+Nchip removed at Mark (§32.7, §6.3, §7), C1 has no chip left to target there. Ruling: at Mark, C1 re-targets from the chip to the crown — samestreakNumberPopkeyframe, duration, curve, and reduced-motion neutralization, just applied to the newest group's own shape instead of a chip<span>. On a capped mark the crown is exactly where the day landed, so nothing is minted, only re-pointed. Bloom is unchanged — C1 still targets its chip there,day − capstill being a unit worth reading. See.planning/phases/v4.53-obj-3358-streak-mark-overflow-chip/UI-SPEC.md.
C2 — Crown handoff is the yield, not the exchange. At both grains the incoming crown is always a unit that did not exist a moment ago, so "the crown moves from unit N to unit N+1" decomposes into two animations that already have owners: the yield (unit N gives up its gold and its --card knockout ring) is Phase 4's 220ms row, and the take (unit N+1 arriving) is Phase 1's §12 entry keyframe at 460ms. They compose on different elements and must not be merged into one keyframe. Where C1 applies there is no incoming unit, so only the yield runs — on the chip at Bloom, on the crown itself at Mark (OBJ-3358).
38.3 (ii) The rank-up restage at stones' 63-unit Bloom cap
Orion's arithmetic is right and the conclusion it points at is wrong, for one reason: the word unit changed meaning underneath the sentence. Restore the meaning and the numbers hold.
Ruling: the restage stagger counts the week-group, at every grain. 45ms and 420ms are unchanged. Nothing is compressed, capped, or exempted.
n × 45 + 420, Orion's convention, where n is the number of week-groups drawn:
| Motif | Grain | Units drawn | Week-groups | Per day | Per week-group |
|---|---|---|---|---|---|
| stones | Mark · 44 | 3 groups | 3 | — | 555ms ✅ |
| stones | Bloom / Field | 63 days | 9 | 3,255ms ❌ | 825ms ✅ |
| sprout | Bloom / Field | 42 days | 6 | 2,310ms ❌ | 690ms ✅ |
| flame | Bloom / Field | 35 days | 5 | 1,995ms ❌ | 645ms ✅ |
| mountain | Bloom / Field | 32 days | 5 | 1,860ms ❌ | 645ms ✅ |
| waves | Bloom / Field | 49 days | 7 | 2,625ms ❌ | 735ms ✅ |
The worst case is 825ms, at stones' 63-day Bloom, with 775ms of headroom under the 1.6s cap. Every unit still restages, still oldest-first, still on the contract's own timings; the new tier colour climbs the wall one course at a time — which is what a course is. The week is already this grammar's beat: §2.1's entire table is "day unit / week group," and every Bloom builder in §27 lays its units out in groups of seven. Counting the beat the geometry already counts is not a workaround.
Why the ruling is this one rather than any of Orion's three:
- Compress the stagger — holding 63 units under the cap needs
s ≤ 18.7ms, about one frame at 60Hz. That is not a stagger, it is a blur, and it mints a seventh timing thatreferences/motion-vocabulary.mdhas no entry for. Same rule as tokens: don't invent it. - Cap the restaged units — "the first 26 wash, the rest snap" puts a visible seam in the middle of the accumulated work, which is the one thing the restage exists to repaint whole.
- Exempt Bloom — Bloom and Field are the celebration surfaces (§30). Exempting the render where a rank-up is actually watched is backwards.
Only the week-group rule mints nothing at all, and it leaves Mark byte-identical to what the contract described. A rule that has to special-case the grain it was written for is the wrong rule.
One correction to the task package, in its favour. "Mark ≤ 9 groups → 825ms" is the geometric group count; the drawn count is
min(totalGroups, maxGroups)= 3 (StreakMotif.vue:145-147). Mark's real figure is 555ms, and the 825ms worst case occurs at Bloom, not Mark. The question still needed answering — it was just never Mark that was near the cap.
38.4 (iii) Reduced motion — instant, and gated where a test can see it
Ruling: reduced motion means instant. The contract's 120ms fade is recorded as a vocabulary gap (§41.3), not minted here. What Phase 4 changes is not the outcome but the mechanism.
Three things argue for instant, in descending order of weight:
references/motion-vocabulary.mdhas no cross-fade entry. Its reduced-motion section documents the global reset and gate g13 ("the flow remains completable") and nothing else. The 120ms figure is the design contract's — a per-feature document, not the app-wide locked vocabulary. Minting a seventh timing on its say-so is precisely the move §9.3 refused for the Bloomdarkstep and §25.1 refused for the third palette stop.- Shipping the fade requires overriding both blanket resets with
!importantat higher specificity, makingStreakMotifthe first component in the app to opt out of the global reduced-motion policy. That is a platform decision, not a streak-motif one. - No information is lost. Every row resolves to a resting appearance that §3.1 / §3.2 / §3.3 / §28 already measured as legible on its own: the crown keeps its
--cardknockout and outline, at-risk keeps its 0.55 body and dashed crown, broken keeps its dashed scaffold. What reduced motion removes is the ceremony, which is what it is asking to have removed.
The part that is a real change. P4-A3 is right that the criterion passes trivially — but the deeper problem is that both existing mechanisms are CSS blanket resets that neutralise an animation the component still applied. That is inheritance, not a decision, and it is invisible to the test Orion asked for:
| Mechanism | Where | Assertable in a unit test? |
|---|---|---|
[data-streak-anim] * reset | streakAnimations.ts:104-110 | ❌ injected stylesheet, no cascade in jsdom |
* global reset | App.vue:569-578 | ❌ SFC style block, never mounted in a component test |
Line-number drift, so nobody hunts. The global reset is at
App.vue:569-578onmastertoday. §9.4 and §29.3 cite566-575andreferences/motion-vocabulary.mdcites566-575; both were correct when written and the file has since moved by three lines. The block itself is unchanged. |useReducedMotion()gate |useAccessibility.ts:113-144, shipped atDashboardHero.vue:52| ✅ mockmatchMedia, assert no animation string is applied |
Rule for Codi: gate the Phase 4 rows in JS, not in CSS. Under prefers-reduced-motion, StreakMotif applies no animation string at all for any row in §39 and renders the end state directly. The two CSS resets stay as a backstop. This is not a new pattern — DashboardHero.vue:52 already passes :animate="!reducedMotion" from the same composable.
Two consequences that follow, and both are acceptance items:
- Every Phase 4 animation must end at its resting paint with no
animation-fill-modedependency, so a collapsed animation cannot strand a half-state. This is §29.3's reasoning generalised, and it is what gate g13 actually requires. - Do not add a
@media (prefers-reduced-motion: reduce)block tostreakAnimations.tsfor any Phase 4 keyframe. It would be dead CSS that reads as an accessibility feature — the §9.4 anti-pattern, for the third time in this milestone.
38.5 (iv) Field's ambient layer
Ruling: the ambient layer never participates in a state transition. It renders at state === 'standard' only, and is unmounted — not dimmed, not animated out — at atrisk and broken.
Not participating is forced by the layer's own definition. §29.2 exempts it from every contrast floor in this spec on one test: remove it and the motif is unchanged — same day, same tier, same state, same count. A layer that reacted to a state transition would be carrying information about state, which forfeits the exemption that is the only reason it is allowed to exist at α 0.18. Two smaller reasons point the same way: it is the one continuous layer and every motion-table row is a discrete event, so a one-shot would visibly re-phase a linear infinite loop; and an ambient layer that reacts to events is the "motion designed to pull the eye" DESIGN.md's brand principles forbid.
"Does not participate" is not the same as "is unaffected," so the two state cases are stated:
broken— §28 already rules no ambient layer. Unchanged.atrisk— §28 dims everybodylayer to 0.55 and is silent on ambient. Rule: drop it entirely. Embers off a dying fire is the wrong metaphor, and dimming a layer already at α 0.18 is invisible anyway. The removal happens at the end of the drain, with no transition of its own — the drain's 600ms owns the frame; the marks are simply absent from the next render.
The layer has never actually run.
StreakMotif.vue:353composes an animation namedomDriftUp/omDriftDown, and neither keyframe exists anywhere inionic_frontend/src— they live only in Phase 3's mockup_shared.html. An undefinedanimation-nameapplies nothing, so the marks hold their restingopacity: 0forever and Field renders identically to Bloom in production today. Phase 4 ownsstreakAnimations.ts, so Phase 4 ports them — verbatim from.planning/phases/v4.48-phase-3-bloom-field-renders/mockups/_shared.html:11-12, which is the file §29.2's travel distances (46 units up / 42 down, fade in at 12%, out at 88%) were written from. This is a port, not an invention. See §41.1.
39. The motion table, at every grain
Every duration and curve below is the design contract's, restated. The grain columns are this addendum's ruling. Rendered and moving: mockups/motion-grain.html.
| # | Row | Contract timing | Mark · 44 | Bloom · 72 | Field · 120 |
|---|---|---|---|---|---|
| R1 | Crown handoff | 220ms cubic-bezier(0.4,0,0.2,1) | newest group yields; fires 1 day in 7; past 3 groups → +N chip (C1) | newest day yields; fires every day | = Bloom |
| R2 | Group close | 640ms gold wash, +260ms delay | the only signal — day 6 and 7 draw the same glyph | sweeps the row/node/ring/leg/line of 7 that just closed | = Bloom |
| R3 | Rank-up restage | 420ms/unit · 45ms apart · oldest first · 1.6s cap | 3 groups → 555ms | 9 groups at stones' cap → 825ms | = Bloom — 825ms |
| R4 | At-risk drain | 600ms gold → slate | whole glyph to 0.55; crown loses gold, knockout and halo | every body layer to 0.55; knockouts, ramp and recessive peak hold (§28) | = Bloom, and the ambient layer unmounts |
| R5 | Streak break | 400ms fade to dashed scaffold, then hold | → one hollow dashed group glyph (§3.3) | → seven ghost day-units (§28) | = Bloom, and the ambient layer unmounts |
| R6 | Rest-day sweep | 400ms emerald | sweep + palette → rescued | sweep + palette → rescued | = Bloom |
Field is never a separate behaviour. Its only delta from Bloom is the ambient layer, and §38.5 rules that layer out of every row. Stating "= Bloom" is the answer, not a gap in it.
Idle is unchanged. The crown halo only, streakStoneHalo 2.4s ease-in-out loop. Phase 4 adds no idle motion at any grain.
Phase 4 adds no haptic and no toast, at any grain. The celebration path already owns both (useStreakCelebration.ts:46-52), and firing a second acknowledgement for the same check-in is what gate g28 exists to catch. A Mark-grain transition on the dashboard is a transition, not a celebration — it gets no flourish beyond the row.
39.1 Trigger mode — Mark transitions, Bloom arrives
The six rows have different reachability at different grains, and not for the reason P4-A5 gives. P4-A5 is about states with no production trigger; this is about surfaces with no previous frame.
Mark's call sites are persistent — the dashboard tile is mounted when the check-in lands, so a prop change is a real before→after and all six rows transition in place. Bloom and Field live on modals that mount after the change has already happened (§30: CelebrationBalanced 92, CelebrationCinematic 120, StreakDetailsModal 72). A modal created at day 8 has no day-7 frame to leave.
Rule: every row has a trigger mode per grain.
| Mode | Meaning | Where |
|---|---|---|
transition | fires on a prop change while mounted | Mark, all six rows |
arrival | fires once on mount, replaying the change the surface was opened to show | Bloom / Field, R1–R3 |
static | the surface mounts already in that state; the row is Storybook-only there | Bloom / Field, R4–R6 |
arrival needs the previous value, which a component cannot know. Add one optional prop:
/** The state this motif is arriving *from*, for surfaces that mount after the
* change (§39.1). Undefined — the default — means no arrival animation. */
from?: { day?: number; tier?: StreakTier; state?: 'standard' | 'atrisk' | 'broken' }Additive, no default, and the plumbing is already three-quarters built: useStreakCelebration.ts:56,118 computes priorStreakDay, Dashboard.vue:454,935 forwards it as :prior-streak, and StreakCelebration.vue:74 declares it — but only spends it on analytics (:145-146). The one missing hop is StreakCelebration → CelebrationBalanced / CelebrationCinematic, neither of which declares the prop today. That hop plus :from is the whole of R1–R3 at Bloom, and it is what puts the milestone's single most valuable animation — the rank-up restage — on the surface a rank-up is actually watched on.
39.2 How rows compose when two fire on the same unit
Day 7 at Bloom is the case: the 7th mark lands and closes row 1, so R1's yield, Phase 1's entry keyframe, and R2's wash all run. They compose because they run on different elements, and Codi must keep them that way:
| Concern | Element | Timing |
|---|---|---|
| Entry (the take, §12) | the new mark | 460ms cubic-bezier(0.34,1.56,0.64,1), from 0ms |
| R1 yield | the previous crown | 220ms cubic-bezier(0.4,0,0.2,1), from 0ms |
| R2 wash | a separate overlay clipped to the closing group | 640ms, from 260ms — ends at 900ms |
Never merge them into one keyframe on one node. A single node carrying an entry transform and a paint change is how "only the newest unit moves" gets accidentally violated: the wash's element is not a unit, so it is not covered by the rule and must not be made into one.
R3 is the one exception to "only the newest unit moves", as the contract already says, and the exception is honest: a rank-up repaints the tier palette, so every unit genuinely changed. R3 never composes with R1/R2 — a rank-up and a group close are different days by construction.
40. State catalog delta
Phase 4 adds no state. §3 (Mark) and §28 (Bloom) are unchanged, and every row in §39 begins and ends at a state those sections already specify. The only additions are:
- Motion-reduced — §38.4. No animation string is applied; the end state renders directly. This supersedes §3.4's and §28's "handled by the global reset" for Phase 4's rows only; Phase 1's entry keyframes keep the §9.4 treatment, unchanged.
- Transitional — no row leaves the motif in a state not in the catalog. Every animation is a cross-fade or a wash between two catalog states, which is why §38.4's "no fill-mode dependency" rule is sufficient rather than merely prudent.
41. Findings recorded, not fixed here
41.1 omDriftUp / omDriftDown do not exist — Field's ambient layer has never run.StreakMotif.vue:353 references them by name; streakAnimations.ts defines neither, and no other file in ionic_frontend/src does. In production Field renders identically to Bloom. Fixed by this phase, because Phase 4 owns streakAnimations.ts and the port is verbatim from Phase 3's mockup (§38.5). Recorded here because the diff will otherwise look like Phase 4 adding ambient motion rather than repairing it, and because it is a live example of the gap class §29.3 warned about: the stills cannot show it. Phase 3's own screenshot evidence passed with the layer dead.
41.2 Waves' ambient drift is specced "across-down" and implemented "down". §29.2's table gives waves across-down; streakMotifGeometry.ts:198 emits only 'up' | 'down' and StreakMotif.vue:352 maps anything not 'up' to omDriftDown. The horizontal component was never built. Fixing it needs a third keyframe plus a geometry change, and the geometry module is not in Phase 4's file list. Route to Maggie — either §29.2's table entry is corrected to "down" or an omDriftAcross is added; both are one-line changes and neither belongs in this phase.
41.3 Three motion-table durations exceed DESIGN.md's stated band — vocabulary gap. DESIGN.md § Motion says "durations cluster around 200–400ms… anything slower feels laggy," and gate g12 requires small state changes to stay ≤300ms. A motif repaint on a 44–120px glyph is a small state change. Group close (900ms including its delay), rank-up restage (825ms) and at-risk drain (600ms) all sit outside that band. Orion locked these durations and put re-deriving them explicitly out of scope, so Phase 4 ships them as specified — but the conflict is between the design contract and the app-wide locked vocabulary, and the vocabulary is the senior document. Route to Maggie: either motion-vocabulary.md gains a "motif repaint" band that covers these, or the three durations come down in a later pass. This is the same class of decision as §41.4 and should be taken together with it.
41.4 The contract's 120ms reduced-motion fade has no home in the locked vocabulary.references/motion-vocabulary.md documents exactly one reduced-motion behaviour — the global reset — and no cross-fade tier. §38.4 rules instant on that basis. If the app wants a reduced-motion cross-fade, it is an app-wide policy that belongs in motion-vocabulary.md and applies to every component, not a per-component override bolted onto StreakMotif with !important. Route to Maggie as a vocabulary gap, named and scoped, not minted.
41.5 Phase 1's entry gate never reached Bloom. bloomNodes does not read props.animate and no Bloom/Field call site passes it, so §12's five keyframes have never run at Bloom or Field. Fixed by this phase (§38.1). Recorded so the PR body can say it plainly rather than let a reviewer read the diff as a widening of something that already worked.
41.6 §32.5 and §32.6 are unchanged and still open. StreakDetailsModal's unbound <ion-modal> (gate g10) and CelebrationBalanced's tap-to-dismiss wall (gate g52) were routed to Maggie in Phase 3. Phase 4 touches neither surface's modal behaviour and does not widen into them. Restated here only so a throughline reader does not record them a second time as new.
42. Microcopy
None. Phase 4 adds no user-facing string. The +N chip's numeral (§33) is unchanged, no toast or haptic is added (§39), and the motif remains aria-hidden with its accessible name on the container (§8.1) — a name that is already correct at every state a Phase 4 row lands in, because srLabel (StreakMotif.vue:453-460) derives from day/tier/state, all of which the rows only move between catalog values of.
43. Accessibility
- Nothing in Phase 4 is announced. The motif is
aria-hidden="true"; the container's accessible name changes with the underlying value and is what a screen reader gets. Adding anaria-liveregion for a decorative repaint would announce the same fact twice. - No new focusable element and no new interaction. Every row is a passive repaint.
- Reduced motion — §38.4, and it is a hard acceptance item rather than an inherited behaviour.
- Contrast is unchanged at every frame, because every frame is a state §3 / §28 already measured. The one element that is not a catalog state is R2/R6's wash overlay: it is transient decoration over an unchanged glyph, carries no information, and takes §29.2's ambient exemption on exactly the same test — remove it and the motif is unchanged.
- No flash hazard. The slowest row is 900ms end to end and no row repeats; WCAG 2.3.1's three-flashes-per-second threshold is not approached at any grain.
44. Storybook story brief
Path: ionic_frontend/src/components/gamification/streak/StreakMotif.stories.ts (extend)
| Story | What it must demonstrate |
|---|---|
MotionCrownHandoff | R1 at Mark and Bloom, side by side. Asserts the yield runs on the previous crown only. |
MotionGroupClose | R2 at Mark (day 6→7, geometry unchanged — the wash is the whole signal) and Bloom. |
MotionRankUpAtCap | R3 at stones day 63, Bloom — the §38.3 rule at the cap. play() asserts total duration ≈ 825ms and that the stagger step count is 9, not 63. |
MotionRankUpMark | R3 at Mark — asserts 3 steps / 555ms, i.e. that Mark is unchanged. |
MotionAtRiskDrain | R4 at both grains, plus Field asserting the ambient layer is absent at atrisk. |
MotionBreak | R5 at both grains — one dashed group glyph at Mark, seven ghosts at Bloom, then hold. |
MotionRestDaySweep | R6 at both grains. |
OneUnitAtBloom | Adding a day animates exactly one unit at Bloom. play() counts animated nodes at day 30 → expects 1, not 30. The Mark-level version of this assertion is Phase 1's; this is the one that makes the failure mode real. |
OneUnitPastCap | Day 63 → 64 at Bloom: C1 — the chip animates, no mark does. |
ReducedMotion | matchMedia mocked to reduce. play() asserts no animation string is applied to any node for any row — not that a duration is small. |
FieldAmbientUnperturbed | Field at day 21 running R1: the ambient marks' animation is untouched by the transition, and still carries no @media rule. |
argTypes: add from (object control), keep render, size, day, tier, state, animate.
45. Flow sequence
The three sequences Phase 4 touches. Every curve, duration and tier is already locked in §12, §36, streakAnimations.ts, useModalAnimations.ts and useStreakCelebration.ts; the Phase 4 delta column is the only new content.
| From | Trigger | To | Transition + duration | Phase 4 delta | Feedback | Reduced-motion | Back-nav |
|---|---|---|---|---|---|---|---|
| Dashboard (Mark mounted) | check-in, non-milestone | same surface, day+1 | in place | R1 yield 220ms + §12 entry 460ms on the tile; R2 wash 900ms on a week boundary | none — the modal owns the haptic (g28) | no animation string applied; end state renders directly | n/a — no navigation |
| Check-in | streak increments | CelebrationBalanced — Bloom 92 | Ionic modal, useModalAnimations 250ms cubic-bezier(0.32,0.72,0,1) | R1–R3 on arrival via the new from prop (§39.1); R3 is the rank-up | haptics.impactLight (useStreakCelebration.ts:50) — unchanged | modal + arrival rows both collapse; knockout and outline remain the signal | sessionStorage.streakCelebrated dedup (g31) — unchanged |
| Check-in | streak hits a milestone | CelebrationCinematic — Field 120 | same modal curve | as above, plus the ambient layer running for the first time (§41.1) | haptics.impactMedium — unchanged | ambient rests at opacity: 0 (§29.3); arrival rows apply no animation | same dedup key |
Two continuity notes, both stated so nobody has to rediscover them.
- The Mark tile's transition and the celebration modal's entrance fire on the same check-in. The longest Mark row is 825ms and the modal enter is 250ms, so Mark finishes underneath the modal and the user never sees two celebrations at once — gate g34 is satisfied by arithmetic, not by suppression. Do not add a suppression.
arrivalmode must fire once per mount, never on a re-render.fromis read on mount only. A remount with nofrom(back-nav, refresh) plays nothing, which is the correct behaviour and is what keeps gate g31 intact.
46. Acceptance criteria for Codi
- [ ] Every row in §39 has a keyframe or transition at each grain the table assigns it, and a story demonstrating it
- [ ] The crown-scoped entry gate at
StreakMotif.vue:263-270is extended to Bloom's day-level crown —bloomNodesreadsprops.animate; the existing Mark path is untouched - [ ] R3's stagger counts the week-group at every grain; asserted at stones day 63, Bloom, with 9 steps and ≈825ms total — not only at Mark
- [ ] A story asserts adding a day animates exactly one unit at Bloom as well as at Mark
- [ ] C1: past the cap (and past Mark's 3-group ceiling) the row runs on the
+Nchip via the existingstreakNumberPop; no new keyframe - [ ] C2: R1 animates the previous crown's yield only; the incoming unit's take stays §12's entry keyframe on a different element
- [ ] R2/R6's wash is a separate overlay clipped to the closing group, never a paint change on a unit node
- [ ] Reduced motion is gated in JS via
useReducedMotion()— underreduce, no animation string is applied to any node; asserted withmatchMediamocked, not inherited from either blanket reset - [ ] Every Phase 4 animation ends at its resting paint with no
animation-fill-modedependency - [ ] No
@media (prefers-reduced-motion: reduce)block is added tostreakAnimations.tsfor any Phase 4 keyframe - [ ]
omDriftUp/omDriftDownare ported intostreakAnimations.tsverbatim from.planning/phases/v4.48-phase-3-bloom-field-renders/mockups/_shared.html:11-12 - [ ] Field's ambient marks still rest at
opacity: 0and still carry no media query of their own (§29.3), and are absent atatriskandbroken - [ ] The optional
fromprop exists with no default;StreakCelebrationforwardspriorStreaktoCelebrationBalanced/CelebrationCinematic, which pass it as:from - [ ] Phase 4 adds no haptic, no toast, and no idle motion at any grain
- [ ] Keyframes live in
streakAnimations.ts's CSS block under[data-streak-anim], not inline;streakMotifGeometry.tsstays pure and unmodified by this phase - [ ] Bloom's
+N(days) is not harmonised with Mark's (groups) — §32.7 - [ ]
test:unit(all five token guards),test:storybook,eslint,vue-tsc --noEmitgreen; ≥80% statement coverage, no drop
47. Open questions
None blocking. Four findings are recorded in §41 for Maggie to route (§41.2 waves' drift direction, §41.3 the duration-band conflict, §41.4 the reduced-motion vocabulary gap, and §41.6's two carried Phase 3 items); two more (§41.1, §41.5) are fixed by this phase and recorded only so the diff reads correctly.
Throughline: traced check-in → Mark tile → celebration and dashboard → streak details before specifying, and audited the resulting sequence in §45 after. Four findings fixed in this spec — the undefined trigger mode at Bloom/Field, which is a flow gap the state catalog could not see (§39.1); the undefined composition of R1/R2/entry on day 7 (§39.2); the undefined ambient behaviour at atrisk (§38.5); and the Mark-vs-modal double-celebration question under gate g34 (§45). Two vocabulary gaps recorded, not minted — §41.3 (g12: three durations outside DESIGN.md's stated band) and §41.4 (no reduced-motion cross-fade tier). Where the design contract and motion-vocabulary.md disagree, the vocabulary is senior and this spec says so — which is why §38.4 rules instant rather than porting the contract's 120ms fade. Gates g13, g28, g31 and g34 are addressed inline and none fires.
Addendum last updated: 2026-09-01 · Version: v4.48.0
Addendum — v4.48 Phase 5: widget-scale motif treatment
Task: OBJ-3105 task 8. Depends on: Phase 1 (shipped, PR #2743). Blocks: task 9 (Codi — kindling snapshot matrix 15 → 36 cells, gate EXPECTED 248 → 374). Tier: spec-only. Mockups: none, deliberately — no pixel in the app changes. All five motifs already render at widget scale today; Phase 5 adds evidence, not behaviour, so a mockup would be a redrawing of shipped code rather than a proposal. Orion's task package states this explicitly (§3, "Desi does not produce mockups here"). Motion: n/a — a widget renders one non-animating snapshot per timeline entry (HomeWidgetViews.swift:246-249); there is nothing to record. Throughline: n/a — single-surface state catalog, no navigation, no sequence.
Phases 1–4 built the accretion grammar in the app. The iOS widget is the one surface the grammar never reached: it does not draw days, groups or a crown, and it is not going to. What it draws is the motif's identity mark — one glyph, at 14 or 16pt, that says which motif this person chose. This addendum is the contract for that mark, per motif, so the expanded snapshot matrix has something to be evidence of.
48. The five motifs at widget scale
48.1 The ruling
At widget scale the motif carries identity, not accretion. One mark, one motif, no day count.
This is not a compromise reached here; it is what shipped. StoneStack — the most elaborate of the three widget glyph views — takes small, reactive and kindling and no streak value (HomeWidgetViews.swift:382-388). It renders byte-identically at day 3 and at day 300. Its three stones are a silhouette, not a tally. Nothing at widget scale counts anything.
That is correct and should stay that way. A 14pt glyph inside a 155×155pt canvas cannot carry 63 day-marks; Mark's smallest in-app grain is 16px and already caps at 3 group glyphs plus a +N chip (§38.1). The widget's job is recognition — that is my tower / my fire / my leaf — and the streak number sitting beside it (kindlingHeader, :907-917) carries the count that the mark does not.
48.2 The treatment table
Three views serve five motifs. No motif needs its own SwiftUI view, and none should be added.
| Motif | Home Screen view | Home Screen glyph | Lock Screen glyph (metaphorGlyphName, :59-67) | Does StoneStack generalise? |
|---|---|---|---|---|
| Stones | StoneStack (:382) | three stacked rounded rects, capstone ~50% base width | square.stack.3d.up / .fill | it is StoneStack — bespoke geometry, stones-only |
| Flame | StreakFlame (:275) | flame.fill + accent glow | flame / flame.fill | no — predates the motif axis, keeps its own view (:499-501) |
| Sprout | MotifSymbolGlyph (:329) | leaf.fill | leaf / leaf.fill | no — and it must not. Uses the shared symbol view |
| Mountain | MotifSymbolGlyph | mountain.2.fill | mountain.2 / mountain.2.fill | no — shared symbol view |
| Waves | MotifSymbolGlyph | water.waves | water.waves (both states — see §50) | no — shared symbol view |
Why StoneStack does not generalise, stated once so nobody tries. Its whole substance is stones-specific: a base/mid/capstone hierarchy, a gold capstone confined to .extended by a brand rule that names stones (:457, RoadmapCapstone.vue:5), and a dashed capstone outline for .notYet. A leaf has no capstone. A wave has no base. Parameterising it would produce a stone tower wearing a different fill, which is the opposite of motif identity.
What generalises instead is MotifSymbolGlyph (:326-378) — the shape comes from the SF Symbol, everything else (accent glow at .extended, the gold→accent→accentDeep gradient, WidgetFlameMuted at .notYet, solid WidgetAccent at .baseline) is lifted verbatim from StreakFlame. That is deliberate and it is the right call: a new motif reads as a sibling of the shipped flame glyph, not as a new visual language dropped into the same card. StreakGlyph (:495-514) is the only dispatch point; three cases, five motifs, and it stays that way.
48.3 How the reactive cue is carried, per view
| View | .extended | .notYet | .baseline | Cue carried by |
|---|---|---|---|---|
StoneStack | gold capstone + glow (:469-475) | dashed capstone outline (:464-468) | solid warm capstone (:477) | shape |
StreakFlame | gradient + glow (:301-311) | WidgetFlameMuted @ 0.85 | solid WidgetAccent | hue (+ glow at .extended) |
MotifSymbolGlyph | gradient + glow (:356-366) | WidgetFlameMuted @ 0.85 | solid WidgetAccent | hue (+ glow at .extended) |
StoneStack is deliberately kept off .widgetAccentable so its cue survives .accented tint mode by shape (:459-461). The other two opt in (:291-295, :346-350), which means their .notYet / .baseline pair — identical filled: true symbol, differing only in hue — collapses under .accented. This is a property flame has shipped with since OBJ-1519; sprout, mountain and waves inherit it exactly, unchanged. Phase 5 neither introduces nor fixes it. The kindling matrix renders .light / .dark only, so the new PNGs cannot and do not evidence it — see §51.4.
49. The F6 ruling and its boundary
Ruling: extend the metaphor axis only. Restated here so the boundary lives with the spec rather than only in the task package.
In scope for Phase 5:
- The kindling snapshot matrix's metaphor axis goes from 2 (
stones | flame) to 5. .github/workflows/mobile-ios.yml'sKINDLING_STATESarithmetic and its comment block move with it.
Out of scope, explicitly:
| Not this phase | Why |
|---|---|
New WidgetState cases (at-risk, broken) | HomeWidgetState has six cases and Phase 5 adds none. Surfacing the tier axis's atrisk/broken states in the widget is a feature, and it gets its own issue. |
| New copy, any string | All five motifs already have captions (:78-85), accessibility suffixes (:87-114), CTAs (:921-928) and first-run subtitles (:933-940). Nothing is missing that this phase would write. Note §52 — some already-shipped strings are wrong, and that is a finding, not a licence to fix them here. |
Any change to resolveMetaphor | Its fail-open-to-stones posture (:38-49) is the old-install contract: widgets ship with the binary, not OTA, so an install that predates a motif resolves it to stones until the app updates. Deliberate, documented, and it stays. |
| Accessory-matrix expansion | Ruling P5-A1 (Orion). Two independent reasons follow in §51.2 and §50. |
50. The .waves reactive-cue gap — recorded, accepted
SF Symbols has no fill variant of water.waves, so Waves cannot carry the reactive fill-vs-outline cue the other four motifs do on the Lock Screen (HomeWidgetViews.swift:55-57; metaphorGlyphName returns water.waves for both filled: true and filled: false, :65).
This is recorded rather than worked around. The alternatives were considered and rejected when the motif axis shipped: drop.fill and wave.3.forward both mean something else on a Lock Screen (a measurement, a media control), and substituting a symbol that reads as a different concept costs more than the lost cue. Waves users see the correct motif in the correct state — they just see the same glyph in .extended and .notYet.
Consequence, stated plainly: on CircularAccessoryView's bottom glyph (:1804), RectangularAccessoryView's line-1 leading glyph (:1917) and inlineAccessorySymbolName (:2016), Waves is the one motif whose Lock Screen mark does not move between check-in states. The streak number and the state text still change; only the glyph is static.
This is a second, independent reason the accessory matrices are correctly excluded from Phase 5's expansion. A .waves accessory cell would be a degenerate snapshot — pinning that two states render identically is not coverage, it is a screenshot of a known gap.
On the Home Screen, Waves has no gap. MotifSymbolGlyph uses filled: true unconditionally (:334) and carries the cue by hue and glow like flame — so the kindling matrix's Waves cells are fully expressive and worth capturing.
51. What the expanded kindling matrix pins — and what it correctly excludes
51.1 What each new cell is actually evidence of
The +126 PNGs are not mostly glyph pixels. Per cell family:
| Kindling cell | Varies by motif? | What the new sprout / mountain / waves PNG pins |
|---|---|---|
first-run (1) | no — nil snapshot | (not expanded; metaphor-invariant) |
stale (→5) | glyph only | header mark, .baseline. Body copy is motif-neutral (:1007-1022) |
empty (→5) | glyph only at Small/Medium | header mark, .baseline. Large's motivator is motif-branched — see §52 |
all-done (→10) | glyph + caption | mark at .extended / .notYet, plus Medium's coach line (reactive.captionText, 5-way correct). Small + Medium titles are hardcoded stones copy — §52 |
default (→10) | glyph + CTA + subtitle | the strongest cells: kindlingCTATitle (:921-928) and kindlingSubtitle (:933-940) are genuinely 5-way and nothing currently pins them |
new-day (→5) | glyph only at Small/Medium | mark at .notYet (.extended is structurally unreachable here, :128-131) |
The default row is the phase's real payload: five distinct CTA strings ship today with zero snapshot evidence behind them.
51.2 Why the flag-off accessory matrix is excluded — by construction, not by omission
Every accessoryFlagOff* fixture is built with streakMetaphor: nil (WidgetSnapshotTests.swift:509-517). resolveReactiveTreatment fails closed on extendedToday alone and returns .flame / .baseline with the metaphor ignored entirely (:120-122) — so that matrix cannot express a motif at all. "Expanding its metaphor axis" is not a coherent ask: all five values would produce five byte-identical PNGs. It is correctly excluded, and the exclusion needs no further defence.
51.3 Why kindling-first-run stays at 1
kindling-first-run carries a nil snapshot (WidgetSnapshotTests.swift:259). FirstRunBody (:201-222) renders a gradient circle and two fixed strings; no glyph, no metaphor, no reactive treatment. Five copies of one image is not coverage. It stays at 1 — and the CI comment block must say so, since "5 states × 5 motifs" is the arithmetic a future reader will otherwise assume.
51.4 What the matrix does not pin, stated so nobody assumes it does
.accented/.vibrantmotif distinctness.HomeWidgetAccentedTestsrendersstreakMetaphor: "stones"only (:145-149) and asserts pairwise distinctness across states, never across motifs. Nothing pins that the five marks stay distinguishable once tint mode discards colour. Recorded in §52 as a finding; out of scope here.- Additive diff only. Existing slugs (
kindling-all-done,kindling-all-done-notyet-stones, and the rest of the current 15) are not renamed for consistency. New motifs take explicit suffixes —-sprout,-mountain,-waves— so the diff is new files, never modified baselines.
52. Findings recorded, not fixed here
Four findings for Maggie to route. None is fixed by this phase — all four are copy or behaviour, and F6 (§49) scopes Phase 5 to the metaphor axis. They are recorded here because task 9 is about to turn three of them into approved baseline PNGs, and a reviewer needs to know which images show a bug before they rubber-stamp 126 new files.
52.1 Small + Medium all-done hardcode stones copy for every motif
SmallWidgetView (:996, :999) and MediumWidgetView (:1263) render the literal strings "Today's stone laid" and "Tower's taller. Rest easy." with no metaphor branch at all. A Flame user sees stone copy today; so will a Sprout, Mountain and Waves user. The correct 5-way string already exists two hundred lines up — checkedInCaption(metaphor) (:77-85) — and is simply not called here.
This is a shipped bug, already pinned by the existing kindling-all-done-flame baseline and not caught. Severity is low (one card state) but it is user-visible and it names the wrong object.
52.2 Large's motivator is a two-motif binary
largeMotivatorText (:1606-1634) branches reactive.metaphor == .flame ? … : <stones copy> at four states — defaultProgress (three variants), allDone, empty, newDay. Everything that is not flame gets tower language. RectangularAccessoryView.line3 (:1945) has the same shape.
Blast radius across the expanded matrix, so the reviewer knows what they are approving:
| Grain | Cells showing copy that names a motif the user did not choose |
|---|---|
| Small | 8 of 10 all-done cells (all but the stones pair) — §52.1 |
| Medium | 8 of 10 all-done cells — §52.1 |
| Large | 18 of 30 all-done / default / empty / new-day cells (the sprout, mountain and waves thirds) — §52.2 |
stale, first-run | none — motif-neutral or nil-snapshot. Correct as-is. |
Ruling on what to do about it in this phase: capture the PNGs anyway, and label them. A snapshot baseline records what ships, not what is correct — regenerating around a bug would leave the bug unpinned and undocumented. Codi should note in the PR body that these cells encode §52.1/§52.2 and are expected to change when the follow-up lands. Do not fix the copy inside Phase 5's PR — a copy change under a coverage PR is exactly the kind of behaviour drift the F6 boundary exists to prevent, and it would make the +126-file diff unreviewable.
52.3 No motif-pairwise distinctness under .accented / .vibrant
Per §51.4. HomeWidgetAccentedTests guards state-vs-state distinctness with stones only. The five marks look plainly distinct by inspection (square.stack.3d.up.fill / flame.fill / leaf.fill / mountain.2.fill / water.waves), so this is low-risk — but it is unpinned, and it is the suite that would catch a future symbol swap collapsing two motifs into the same alpha mask. Belongs in HomeWidgetAccentedTests, which has its own count gate; expanding it here would cross Ruling P5-A1.
52.4 The legacy matrix's new-day metaphor pair stays at two
new-day-flame / new-day-stones in LEGACY_STATES keep two-motif coverage while the kindling matrix goes to five. Orion flagged this as its own coverage question and it is not actioned here. Recorded so the asymmetry between the two matrices reads as a decision, not an oversight.
53. Microcopy
No new strings. Every user-facing string this phase's snapshots capture already ships. Restated as the contract so Codi can diff a rendered PNG against a table rather than against the source:
| Surface | Stones | Flame | Sprout | Mountain | Waves |
|---|---|---|---|---|---|
CTA (:921-928) | Lay today's stone | Feed today's fire | Add today's leaf | Take today's step | Make today's wave |
First-run subtitle (:933-940) | Start today's tower | Start today's fire | Start today's sprout | Start today's climb | Start today's tide |
Checked-in caption (:77-85) | Today's stone laid | Locked in today | Today's leaf out | Today's step up | Today's wave in |
Not-yet caption (:97-105) | Add today's stone | Add today | Add today's leaf | Add today's step | Add today's wave |
All twenty are action-forward, one line, and name the motif's own object — brand voice ✅. The strings that are not in this table, because they never became 5-way, are §52.1 and §52.2.
54. Accessibility
No change. Recorded so the expansion is not mistaken for an accessibility delta:
composedAccessibilityLabel(:154-199) appendsreactive.accessibilitySuffix, which is 5-way complete (:87-95,:107-114). All five motifs already speak correctly.- Snapshot tests capture pixels, not VoiceOver output — the expanded matrix adds no accessibility evidence, and no acceptance criterion below claims it does.
- The
.wavesLock Screen gap (§50) is visual only.water.wavescarries no state, but the spoken label still distinguishes.extendedfrom.notYetvia the suffix, so a VoiceOver user loses nothing. - Contrast is unchanged: the three new motifs use the identical
WidgetAccent/WidgetFlameMuted/ gradient treatment flame already passes with.
55. Acceptance criteria for Codi
- [ ]
kindlingStatesgoes 15 → 36 per Orion's table: first-run 1, stale 5, empty 5, all-done 10, default 10, new-day 5 - [ ] New cells take explicit motif suffixes (
-sprout,-mountain,-waves); no existing slug is renamed and all 248 existing PNGs are present and unmodified (§51.4) - [ ]
KINDLING_STATES=36inmobile-ios.yml, with a comment block that re-derives 36 the way the current one re-derives 15 — including whyfirst-runstays at 1 (§51.3) - [ ]
LEGACY_STATES,ACCESSORY_STATES,ACCESSORY_FLAG_OFF_STATES,OVERFLOW_PNGSare unchanged; bump the matching per-matrix count only, never a bare total (mobile-ios.yml:147-151) - [ ] Measured
find … -name '*.png' | wc -land the gate's computedEXPECTEDboth return 374 - [ ] No new
WidgetStatecase, no new or changed user-facing string, no change toresolveMetaphor's fail-open-to-stones (§49) - [ ]
resolveMetaphor's fail-open-to-stones stays covered by a test - [ ] The blank/near-invisible pixel check following the count gate passes on all new PNGs
- [ ] PR body notes that the cells in §52's blast-radius table encode known copy bugs (§52.1, §52.2) and are expected to change when the follow-up lands — do not fix the copy in this PR
- [ ] PR carries
oversized-pr-ok(≈126 new files, overMAX_CHANGED_FILES=75) and flags the.github/workflows/touch as a possible v4.46 rebase conflict
56. Open questions
None blocking. Four findings are recorded in §52 for Maggie to route (§52.1 hardcoded stones copy at Small/Medium all-done, §52.2 Large's two-motif motivator binary, §52.3 unpinned motif distinctness under .accented, §52.4 the legacy matrix's two-motif new-day pair). §52.1 and §52.2 are the two worth routing promptly — they are user-visible today, independent of whether Phase 5 ships at all, and §52.1's fix is a one-line call to a function that already exists.
Throughline: n/a — this addendum specifies one surface's state catalog. No navigation, no sequence, no transition, and a widget renders a single non-animating snapshot per timeline entry, so there is no flow for objectuve-throughline to trace.
Addendum last updated: 2026-09-02 · Version: v4.48.0