Skip to content

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, taking motif, day, tier, state, render (mark | bloom | field), size, animate.
  • New module: streakMotifGeometry.ts — pure builders porting the design contract's bloom() 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/StonesPyramid all rebased onto StreakMotif and the legacy Stones components retired.
  • Retired: every grown = day > 14 boolean, including a fourth instance (StonesHero.vue:48) the original plan hadn't named. Retired: StonesHero, StonesWall, StonesPyramid, InfoCard-adjacent Hero dead-prop declarations (dark on 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-motion block, with reduced motion explicitly asserted as "instant" via a mocked matchMedia test rather than left to CSS-block inheritance.
  • New tokens: --motif-shade (Phase 3, mode-invariant depth cue for Bloom). Zero raw hex remains in streakPalette.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's streakMetaphor axis expanded from stones/flame to all five; mobile-ios.yml's PNG-count arithmetic updated to match (kindlingStates 15→36, gate total 248→374). Coverage only — no new WidgetState, no HomeWidgetViews.swift change.
  • 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.md and docs/product/user-guide.md no longer describe a stones-or-flame world; both now state stones/sprout free, flame/mountain/waves Supporter, matching useStreakMetaphor.ts's FREE_METAPHORS/SUPPORTER_METAPHORS exactly.
  • 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

PhaseNameStatusPlansHighlights
1The grammar — shared renderer, Mark render, five motifs, three statesShipped1StreakMotif.vue + streakMotifGeometry.ts. Resolved the tier × state conflation without deleting the rank ladder. Purely additive — no existing surface touched.
2Mark placement parityShipped1Thirteen 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.
3Bloom and Field rendersShipped1Day-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.
4State-transition motionShipped1Full 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.
5iOS widget metaphor coverageShipped1Widget snapshot axis 2→5 motifs. Scope held exactly to coverage-only — HomeWidgetViews.swift never touched.
6DocsShipped1New 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/broken drives outline/dash/gold treatment; the existing StreakTier (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 to stones for 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_TIERMOTIF_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 -accessible token variant in this system collides with its own family's -light rung 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 criterionStatus
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_modules not 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 of tier: '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.
  • 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

Loading…