Streak motifs
One grammar, five looks: a day is a unit, seven units close a group, the newest unit wears gold.StreakMotif.vue (ionic_frontend/src/components/gamification/streak/StreakMotif.vue) is the single component that renders a user's streak as an accreting shape — stones stacking, a sprout growing leaves, coals filling a hearth ring, a mountain gaining switchbacks, or waves rolling in — at any day count, in any of three states, at three levels of detail.
This page covers the system shipped across v4.48 phases 1–4 (OBJ-3101, OBJ-3103, OBJ-3104). Full design rationale, contrast measurements, and every ruling live in the UI-SPEC — this page is the durable reference; that one is the historical record of how each decision was reached.
The five motifs
| 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 |
Every seventh unit closes a group, and the newest unit in the newest group always carries the gold "crown" treatment — a filled or ringed highlight plus a soft halo pulse. Past a motif's day cap, the shape stops growing and a +N chip reports the overflow instead (streakMotifGeometry.ts, groupCountForDay/buildMotifShapes for the group-level Mark render; buildBloomShapes for the day-level Bloom/Field render).
Free vs. Supporter
useStreakMetaphor.ts splits the five motifs into two tiers:
export const FREE_METAPHORS = ['stones', 'sprout'] as const
export const SUPPORTER_METAPHORS = ['flame', 'mountain', 'waves'] as conststones is the app-wide default. A non-Supporter holding a saved Supporter-tier preference (e.g. they picked flame while subscribed, then lapsed) renders as stones — the effective() resolution never silently upgrades or blocks, it just falls back. The picker lives in Settings → Supporter (StreakMetaphorSelector.vue); switching is a purely cosmetic choice with no functional effect on the streak itself.
Render grains
One shared renderer, StreakMotif.vue, with three render modes (render: 'mark' | 'bloom' | 'field') selected by the calling surface, not by pixel count:
| Grain | Size | Unit drawn | Where it's used |
|---|---|---|---|
| Mark | 16 / 18 / 20 / 24 / 56px | the week-group | StreakMetaphorIcon.vue call sites (GoalGridCard.vue, GoalFocusCard.vue, StreakVisualization.vue, GoalEventForm.vue at 16; WeekRibbon.vue, AmbientSidebar.vue, Dashboard.vue, HabitCard.vue at 18; StreakRepairCard.vue at both 20 and 24 for its two glyphs; GreetingCard.vue at 56) and CelebrationToast.vue (StreakMotif directly, render="mark", 20px) |
| Bloom | 72 / 92px | the day | StreakDetailsModal.vue (informational — no ambient layer), CelebrationBalanced.vue (celebratory — SparkLayer is its ambient layer instead) |
| Field | 120px | the day, plus an ambient layer | CelebrationCinematic.vue — the one surface with room for atmosphere |
Mark renders groups (groups = ceil(min(day, cap) / 7), capped at 3 visible groups, 2 at size <= 20, with +N for the rest). Bloom and Field render days directly, up to the motif's cap. Both grains are driven by the same day count and the same crown rule — only the unit changes.
Tiers and states
StreakMotif takes two independent inputs beyond motif and day:
tier(standard | rare | epic | legendary | rescued | atrisk, fromtierFor(day)instreakRanks.ts) drives the palette —rareat days 3/7/14,epicat 30/50,legendaryat 100 and every year after. Tier promotion is a one-day flash on the milestone, not a persistent state: day 100 differs from both day 99 and day 101. (rarecurrently renders identically tostandard— a tracked no-op, not a bug in this component.)state(standard | atrisk | broken) drives the treatment.statealways wins overtierwhen they conflict: at-risk and broken both discard the tier palette entirely and render a dormant ramp regardless of milestone. At-risk dims the body and drops the crown's gold; broken (day: 0) draws one hollow dashed scaffold — never a zero, never an empty frame.
Color is never the only signal: standard/at-risk/broken differ by fill-vs-hollow and solid-vs-dashed too, so the three states stay distinguishable in greyscale.
Motion
Entry. The newest unit animates in on arrival — a per-motif keyframe (stones drop, sprout unfurls, flame settles, mountain steps up, waves sweep in), 460ms, the same celebratory overshoot curve used elsewhere in the app. Only the newest unit ever re-animates; earned units never replay.
State transitions (crown handoff, a group closing, a rank-up restage, the at-risk drain, a break, a rest-day sweep) cross-fade the affected units between frames. Every row ends at its real resting paint with no dependency on an animation being allowed to finish.
Field's ambient layer (Field only) is a slow, motif-specific drift — dust settling for stones, pollen for sprout, embers for flame, haze for mountain, swell for waves — that carries no information: removing it changes nothing about the day, tier, or state being shown. It never reacts to a state transition; it simply stops being drawn at at-risk and broken.
Reduced motion. Every state-transition row above ships as instant, not a fade — this was a deliberate ruling (UI-SPEC §38.4), not an oversight. Under prefers-reduced-motion, StreakMotif applies no animation at all and renders straight to the end state; the CSS-level blanket resets in streakAnimations.ts and App.vue are a backstop, not the primary mechanism. The ambient layer needs no special-casing either — its resting opacity is already 0, so a collapsed animation just leaves it invisible.
Accessibility
StreakMotif is decorative and renders aria-hidden="true" on its root — the accessible name lives on the caller's wrapping element, not the glyph. The component exposes a srLabel computed value for the caller to bind:
| State | Label |
|---|---|
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." |
StreakMotif never renders this label itself, adds no tab stop, and is never itself a tap target — where a caller makes the glyph tappable, the ≥44px target is the caller's wrapper.
Source
ionic_frontend/src/components/gamification/streak/StreakMotif.vue— the rendererionic_frontend/src/components/gamification/streak/streakMotifGeometry.ts— pure Mark/Bloom/Field geometry, no Vue, no colorionic_frontend/src/components/gamification/streak/streakPalette.ts—MOTIF_PALETTE_BY_TIERionic_frontend/src/components/gamification/streak/streakAnimations.ts— entry + state-transition keyframesionic_frontend/src/composables/useStreakMetaphor.ts— the free/Supporter selection and persistence- UI-SPEC — v4.48 streak motif accretion grammar — full grammar, contrast measurements, and every ruling