v4.48 — Streak Motif Accretion Grammar
Every streak motif accretes. A day is a unit, seven units close a group, and the newest unit wears gold — the same grammar for all five motifs, rendered by one component, at every size, in every state, on every surface that shows a streak.
Summary
Before this milestone, only Stones actually accreted. SproutHero.vue, MountainHero.vue, and WavesHero.vue each computed a single grown = day > 14 boolean and switched between exactly two drawings for the rest of the streak's life — day 15 and day 300 rendered pixel-identical. Flame had no large-scale render at all; every surface that would have shown a Flame Bloom fell through to a static <FlameIcon>. And several surfaces that show a user's streak — RankChip, CelebrationToast, StreakRepairCard, StreakVisualization — hardcoded stones-or-flame regardless of which of the five motifs the user had actually picked, so a Waves user watched a stone stack or a flame everywhere except the one place their choice was honored.
Josh dropped a design contract — written spec, screen map, and a browsable .dc.html carrying the authoritative geometry and motion — proposing one shared grammar for all five motifs. Kickoff investigation found a reconciliation the contract itself didn't address: its three-state model (standard/atrisk/broken) and the app's existing StreakTier ladder (rare/epic/legendary, which is what makes day 100 look different from day 99) are independent axes. Building to the contract as written would have silently deleted the visual rank ladder. Six phases, three days, all direct to master: Phase 1 built the shared renderer and resolved that reconciliation; Phase 2 rewired every surface that shows a Mark; Phase 3 shipped day-accurate Bloom and Field renders, including Flame's first-ever; Phase 4 shipped the state-transition motion table under prefers-reduced-motion; Phase 5 extended iOS widget snapshot coverage from two motifs to five; Phase 6 corrected the docs, which still described a two-motif world four weeks after the fifth motif shipped.
The milestone's own close was the one part that didn't go cleanly: the final ROADMAP-tick PR (#2829) missed its merge hop three times, the last time because an agent started a background CI watch and yielded its turn — a gh pr checks --watch that cannot survive turn exit in this runtime. A synchronous re-dispatch closed it. No shipped code was affected; it's a coordination-mechanics footnote, not a product one.
Goal
Every streak motif accretes. A day is a unit, seven units close a group, and the newest unit wears gold — the same grammar for all five motifs, rendered by one component, at every size, in every state, on every surface that shows a streak.
Scope — What Shipped
- New component:
StreakMotif.vue(ionic_frontend/src/components/gamification/streak/) — one renderer for all five motifs, takingmotif,day,tier,state,render(mark|bloom|field),size,animate. - New module:
streakMotifGeometry.ts— pure builders porting the design contract'sbloom()geometry, no Vue, no DOM; day-level and group-level unit builders for all five motifs with their caps (stones 63, sprout 42, flame 35, mountain 32, waves 49). - Thirteen surfaces rewired onto the shared renderer (two more than the eleven-surface kickoff walk named, after a wider grep found the actual reach of the
AppIcon name="streak"sweep):StreakMetaphorIcon,DashboardHero,GoalEventForm,HabitCard,GreetingCard,AmbientSidebar,views/Dashboard.vue,RankChip,CelebrationToast,StreakRepairCard,StreakVisualization,WeekRibbon,GoalGridCard/GoalFocusCard. - Day-accurate Bloom and Field for all five motifs, Flame's first-ever large-scale render (hearth-ring geometry, previously a static
<FlameIcon>fallback).SproutHero,MountainHero,WavesHero,StonesHero/StonesWall/StonesPyramidall rebased ontoStreakMotifand the legacy Stones components retired. - Retired: every
grown = day > 14boolean, including a fourth instance (StonesHero.vue:48) the original plan hadn't named. Retired:StonesHero,StonesWall,StonesPyramid,InfoCard-adjacent Hero dead-prop declarations (darkon three Heroes that never read it). - New motion: crown handoff, group close, rank-up restage (bounded to a 1.6s cap even at stones' 63-unit Bloom grain), at-risk drain, streak break, rest-day sweep — all under the existing
prefers-reduced-motionblock, with reduced motion explicitly asserted as "instant" via a mockedmatchMediatest rather than left to CSS-block inheritance. - New tokens:
--motif-shade(Phase 3, mode-invariant depth cue for Bloom). Zero raw hex remains instreakPalette.ts— nine literals found during the Phase 3 palette convergence were deleted, not tokenized, once a fixed three-stop ramp was found structurally incapable of flipping correctly with mode in both directions. - iOS widget:
WidgetSnapshotTests.swift'sstreakMetaphoraxis expanded fromstones/flameto all five;mobile-ios.yml's PNG-count arithmetic updated to match (kindlingStates15→36, gate total 248→374). Coverage only — no newWidgetState, noHomeWidgetViews.swiftchange. - New docs:
docs/features/streak-motifs.md— the five motifs, the accretion grammar, the three render grains, the three states, the free/Supporter split. - Doc corrections:
docs/features/home-widgets.mdanddocs/product/user-guide.mdno longer describe a stones-or-flame world; both now statestones/sproutfree,flame/mountain/wavesSupporter, matchinguseStreakMetaphor.ts'sFREE_METAPHORS/SUPPORTER_METAPHORSexactly. - Filed, not built here: OBJ-3107 (the rank-ladder cadence-rename question, cut from this milestone at kickoff), OBJ-3249 (Android/iOS widget state-parity gap, surfaced during Phase 6), a Storybook-only
state: 'atrisk'gap (no production trigger anywhere in the app — fully specified and built, just unreachable).
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | The grammar — shared renderer, Mark render, five motifs, three states | Shipped | 1 | StreakMotif.vue + streakMotifGeometry.ts. Resolved the tier × state conflation without deleting the rank ladder. Purely additive — no existing surface touched. |
| 2 | Mark placement parity | Shipped | 1 | Thirteen surfaces converted, two more than planned. Inverted the resolution strategy mid-phase after finding the planned isSupporter-threading approach would have silently forced stones on real Supporters. |
| 3 | Bloom and Field renders | Shipped | 1 | Day-accurate Bloom for all five, Flame's first-ever. Retired every grown = day > 14 boolean. First repaint of shipped UI in the milestone — converged two palette exports, lifted two light-mode legibility failures over the 3:1 floor. |
| 4 | State-transition motion | Shipped | 1 | Full motion table under prefers-reduced-motion. Kept rather than dropped — the milestone ran fast enough the drop condition never triggered. One fix round for two wiring gaps that made the two most player-visible rows correct-but-unreachable. |
| 5 | iOS widget metaphor coverage | Shipped | 1 | Widget snapshot axis 2→5 motifs. Scope held exactly to coverage-only — HomeWidgetViews.swift never touched. |
| 6 | Docs | Shipped | 1 | New docs/features/streak-motifs.md; corrected stale two-motif claims. Caught and fixed a doc claiming an unused component default (44px) as a real render size. |
Key Decisions
- The design contract's three-state model and the app's tier ladder stay two independent axes, not one collapsed model — the contract's
standard/atrisk/brokendrives outline/dash/gold treatment; the existingStreakTier(rare/epic/legendary) keeps driving the palette. Building to the contract as written would have silently deleted the visual rank ladder nobody asked to remove. - All five motifs stay, and the free/Supporter split is unchanged — dropping a motif is a breaking change with a persistence tail (
localStorage) the widget can't even follow, since widgets ship with the binary and fail open tostonesfor motifs an old install doesn't recognize. The marginal cost of a fifth motif collapses once the renderer is shared, which is exactly what Phase 1 builds. - The rank-ladder rename question is explicitly cut from this milestone (OBJ-3107) — both proposed rank-name drafts touch only
RANKS[].name, orthogonal to the accretion grammar, and one draft has a known chip-overflow defect the contract itself flags. A renaming pass deserves its own scoped issue with its own docs pass, not a rider on a rendering milestone. - The palette convergence (
PALETTE_BY_TIER→MOTIF_PALETTE_BY_TIER) moved from Phase 2 to Phase 3 on the evidence that every consumer at the time was a Hero, and Phase 3 is the phase that rebases Heroes — converging earlier would have meant repainting components Phase 3 was about to touch again days later. - A fixed three-stop palette ramp was deleted rather than tokenized — every
-accessibletoken variant in this system collides with its own family's-lightrung in dark mode, so no three-stop ramp can flip correctly with mode in both directions. Depth turned out to be tier-independent; one new mode-invariant token (--motif-shade) replaced it. - Reduced motion ships as "instant," not the contract's asked-for 120ms fade — what already shipped, ruled deliberately rather than left to the two pre-existing blanket CSS blocks, and asserted directly by a test rather than assumed from inheritance.
- The iOS widget phase shipped coverage only, not new behavior — the widget already rendered all five motifs correctly via
resolveMetaphor's fail-open logic; this phase closed a test-coverage gap, and the harder "should the widget show at-risk/broken" question was deliberately left as a follow-up rather than smuggled in as a "coverage" row.
Requirements Coverage
8 / 8 milestone success criteria satisfied, all six phases' acceptance criteria satisfied (see .planning/milestones/v4.48-streak-motif-accretion-grammar-MILESTONE-AUDIT.md).
| Success criterion | Status |
|---|---|
| One component renders every motif, at every size, in every state — no surface hardcodes a motif | ✅ |
Every motif accretes daily — no grown = day > 14 boolean remains | ✅ |
| Gold marks exactly one unit, never in the at-risk state | ✅ |
| Day 0 renders the broken scaffold, never a zero or an empty frame | ✅ |
| The tier ladder survives — day 100 still renders legendary | ✅ |
| Reduced motion is honoured on every new animation | ✅ |
| No raw hex enters the codebase; the five token guards stay green | ✅ |
| The docs describe the five-motif world, not the two-motif one | ✅ |
Outcomes
A user on any of the five motifs now sees their own motif — never a hardcoded stone or flame — at every surface that shows a streak, accreting correctly at whatever render grain that surface needs: a small Mark on a dashboard tile, a day-accurate Bloom in the streak details modal, or the full ambient Field render on a celebration screen. The newest unit, and only the newest unit, carries gold; the tier ladder that makes day 100 look different from day 99 is untouched. Motion respects prefers-reduced-motion end to end, asserted by test rather than assumed. The iOS widget's snapshot suite now actually covers what resolveMetaphor has rendered correctly all along. And the docs finally describe the app that's been shipping since OBJ-2037 — five motifs, two of them free — instead of the stones-or-flame world that stopped being true weeks earlier.
Tech Debt
- (Milestone-wide) The five token guards were not re-run locally during milestone close (
node_modulesnot installed in the closing workdir) — relying on each phase's own CI-green merge state, which is this repo's actual gate of record. - (Phase 2 / 4, carried forward)
state: 'atrisk'has no reachable production trigger anywhere in the app — fully specified, built, and tested, Storybook-only by construction. Same is true oftier: 'rescued''s motion row. - (Phases 2–4) Live-app UI click-through evidence was unavailable for four consecutive phases (shared-host disk exhaustion during Phase 3, no browser tooling in the Phase 4 environment). Each phase individually justified the substitution (Storybook, mockup captures, reviewer/verifier sign-off); the pattern itself is a standing tooling gap worth its own issue.
- (Phase 5) The CI-rendered iOS snapshot-baseline procedure (delete cells, let CI auto-record, download and commit the artifact) has no home in the docs — it currently survives only in the ROADMAP and the PR #2835 thread.
Related Artifacts
- Roadmap: v4.48-streak-motif-accretion-grammar-ROADMAP.md
- Milestone Audit: v4.48-streak-motif-accretion-grammar-MILESTONE-AUDIT.md
- Source issue: OBJ-3095 — "Apply refinements/enhancements to streak motif," filed by Maggie from Josh's dropped design-contract zip
- Coordination anchor: OBJ-3100 (parent milestone issue)
- Feature docs: docs/features/streak-motifs.md
- Follow-ups filed: OBJ-3107 (rank-ladder cadence rename), OBJ-3249 (Android/iOS widget state parity), OBJ-3243 (pre-existing hardcoded-stones-copy bug, decomposed into OBJ-3244–OBJ-3249, in progress separately)
Related Commits
d6049e3f3— Phase 1: The grammar (PR #2743)10b5f40f9— Phase 2: Mark placement parity (PR #2758)d62a12c20— Phase 3: Bloom and Field renders (PR #2776)54882003b— Phase 4: State-transition motion (PR #2794)2f3f5dda8— Phase 5: iOS widget metaphor coverage (PR #2835)169196b72— Phase 6: Docs (PR #2833)43a9fbf2c— Milestone-close ROADMAP tick, Phases 4–6 (PR #2829)
Last updated: 2026-09-03