Skip to content

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

MotifDay unitWeek groupCap before +NGroups at cap
Stonesa stonea course of 7639
Sprouta leafa node of 7426
Flamea coala hearth ring of 7355
Mountaina stepa switchback of 7325 (last is partial)
Wavesa cresta line of 7497

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:

ts
export const FREE_METAPHORS = ['stones', 'sprout'] as const
export const SUPPORTER_METAPHORS = ['flame', 'mountain', 'waves'] as const

stones 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:

GrainSizeUnit drawnWhere it's used
Mark16 / 18 / 20 / 24 / 56pxthe week-groupStreakMetaphorIcon.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)
Bloom72 / 92pxthe dayStreakDetailsModal.vue (informational — no ambient layer), CelebrationBalanced.vue (celebratory — SparkLayer is its ambient layer instead)
Field120pxthe day, plus an ambient layerCelebrationCinematic.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, from tierFor(day) in streakRanks.ts) drives the paletterare at days 3/7/14, epic at 30/50, legendary at 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. (rare currently renders identically to standard — a tracked no-op, not a bug in this component.)
  • state (standard | atrisk | broken) drives the treatment. state always wins over tier when 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:

StateLabel
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 renderer
  • ionic_frontend/src/components/gamification/streak/streakMotifGeometry.ts — pure Mark/Bloom/Field geometry, no Vue, no color
  • ionic_frontend/src/components/gamification/streak/streakPalette.tsMOTIF_PALETTE_BY_TIER
  • ionic_frontend/src/components/gamification/streak/streakAnimations.ts — entry + state-transition keyframes
  • ionic_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

Loading…