Skip to content

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

ArtifactStatus
ionic_frontend/src/components/gamification/streak/StreakMotif.vuenew
ionic_frontend/src/components/gamification/streak/streakMotifGeometry.tsnew (pure)
ionic_frontend/src/components/gamification/streak/StreakMotif.stories.tsnew
ionic_frontend/tests/unit/streakMotifGeometry.spec.tsnew
ionic_frontend/src/components/gamification/streak/streakPalette.tsmodified — add MOTIF_PALETTE_BY_TIER alongside PALETTE_BY_TIER; do not touch the existing export
ionic_frontend/src/components/gamification/streak/streakAnimations.tsmodified — append five entry keyframes to the CSS block
design_system/css/tokens.cssuntouched — zero new tokens (see §4)
StreakMetaphorIcon.vue, StoneBlock.vue, all call sitesuntouched

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:

  • TierStreakTier (ionic_frontend/src/composables/streakRanks.ts:38): standard | rare | epic | legendary | rescued | atrisk. Produced by tierFor(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:

ts
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?: boolean

1.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:

  1. Tier promotion is momentary, not persistent. tierFor returns rare/epic/legendary only on the exact milestone days (3/7/14 → rare; 30/50 → epic; 100 and ≥365 → legendary). Day 101 is back to standard. 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.
  2. tierFor never returns rescued or atrisk. Those two values only ever arrive from a call site (they are StoneState, 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 →standardatriskbroken
standardBody --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).
rareIdentical to standard today — see the finding in §9.1. Crown --gold.Tier ignored — dormant render.Unreachable. state: 'broken' means day === 0; tierFor(0) === 'standard'.
epicBody --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).
legendaryBody --coach-accessible. Crown: --gold fill + --card knockout + halo.Tier ignored — dormant render.Unreachable (same reason as rare).
rescuedBody --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.
atriskNormalized, 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 in StreakTier because PALETTE_BY_TIER was extracted from StoneBlock's StoneState, not because the tier ladder has an at-risk rung. StreakMotif coerces tier === '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)

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

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-accent fill is wanted. Bloom will need a dark step token that flips with mode (#9A4A0F is 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

LayerTreatment
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.
+Ntext-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:

  1. Gold does not always separate. --gold against the tier body is 3.21:1 (standard, light) but 1.55:1 in dark, 1.17:1 on epic, 1.26:1 on legendary dark (§5.3). A signal that disappears in half the matrix cannot be the primary one.
  2. The halo does not survive reduced motion. App.vue:566-575 forces animation-iteration-count: 1 and animation-duration: 0.01ms on everything. A looping drop-shadow halo 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.
  3. The --card layers are correct by construction. Every body token clears 3:1 against --card (§5.2), so a --card ring 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 epic and rescued, 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.0max(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 no broken branch at all — it falls through to PAL[st] with n = max(1, min(0, 3)) = 1 and 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

StateTreatment
LoadingNone. 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.
Emptystate: 'broken' is the empty state. There is no other.
ErrorNot 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 / ActiveNone. 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 modeNo delta. Every colour is a mode-aware token; nothing in the component branches on theme.
Motion-reducedEntry 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 valueDC roleTokenNote
#F28529PAL.standard.midhsl(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.
#F9A857PAL.standard.lighthsl(var(--streak-accent-light))Bloom only — Mark has no gradient.
#9A4A0FPAL.standard.dark(unused at Mark — §2.2a)Recorded gap for Phase 3, §9.3.
#FCC419GOLDhsl(var(--gold))45.2° 97% 54% vs the token's 43° 96% 56% — visually identical.
#FEF3C7flame gradient 0% stophsl(var(--gold-light))Bloom only. Preserves the hot-core → gold ramp one step less blown-out.
#94A3B8PAL.atrisk.mid and PAL.broken.darkhsl(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.
#CBD5E1PAL.atrisk.light(unused — §3.2 raises the at-risk body to a single opaque tone)
#64748BPAL.atrisk.dark(unused — the at-risk crown is a --muted-foreground dash, §3.2)
#E2E8F0, #CBD5E1PAL.broken.light/mid(unused — broken is hollow; only the stroke is painted)
#FFF6E8mountain snow cap / step tickshsl(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 highlighthsl(var(--streak-accent-accessible-foreground) / 0.42)Bloom only. Same token, same reasoning.
rgba(252,196,25,0.78)crown halo shadowhsl(var(--gold) / 0.55)Alpha lowered from 0.78; at 0.78 the glow bleeds past the 44px box into adjacent text.
(none)crown separatorhsl(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.

ts
// 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

TokenLightDarkVerdict
--streak-accent2.62:16.83:1✗ light — not used
--streak-accent-accessible5.43:16.83:1
--gold1.69:110.59:1✗ light — crown fill only, never a body (§5.3)
--gold-accessible6.61:112.44:1✅ (epic body)
--coach3.96:14.52:1✅ but shallow — --coach-accessible used instead
--coach-accessible7.33:18.40:1✅ (legendary body)
--success5.03:13.56:1✅ (rescued body)
--muted-foreground @1.06.08:16.96:1✅ (dormant body)
--muted-foreground @0.73.15:14.04:1✅ (dormant crown / broken scaffold — this is the floor for the alpha, §3.2)
--muted-foreground @0.52.16:12.68:1✗ both — the contract's value, rejected

5.3 Crown fill against its own tier body — why gold cannot be the primary signal

PairLightDark
--gold vs --streak-accent-accessible (standard/rare)3.21:11.55:1
--gold vs --coach-accessible (legendary)4.34:11.26:1
--gold vs --gold-accessible (epic)3.91:11.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:

MotifForm at 16pxCount readable?State readable?
Stonestwo stacked bars, 9.3 × 3.4 and 12.2 × 3.4 px, 0.8px gapyesyes
Mountaintwo overlapping triangles, 5.4 × 7 and 4.8 × 10.6 pxyes — the knockout ring separates themyes
Wavestwo 2.1px wavy strokes, 4.2px apartyesyes
Sprout1.3px stem + two 2.9 × 1.4px tilted leavesnoyes, with §6.2
Flame5 × 11px body dominating two 2.9 × 1.9px coalsnoyes, 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 +N label 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 counted totalGroups − 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 <= 20 threshold never covered. The remaining rows are unaffected.

FeatureAt ≤20pxReason
Third groupnot rendered (cap 2)contract
+N labelomitted — omitted at every Mark size (OBJ-3358)no room; adjacent numeral carries it
Crown halosuppresseda 5px glow on a 16px glyph is a smudge, not a signal
Crown gold fillkeptit is a whole-shape hue difference and survives
Knockout + --card outlinekept, scaled by §6.2together they are the load-bearing crown signal
Dash on broken / atriskdropped — 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.

ElementFontClassToken
+N overflowIntertext-2xs font-semiboldtier ink: --streak-accent-accessible / --gold-accessible / --coach-accessible / --success / --muted-foreground
(retired at Mark) +N overflowIntertext-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:

  1. 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.
  2. 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.
  3. 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 StreakMotif must carry an accessible name that includes the streak state whenever state !== 'standard'. StreakMotif exposes a srLabel computed value for the caller to bind; it never renders it itself.

8.2 Accessible-name templates (brand voice — see §10)

StateTemplate
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. StreakMotif is not focusable and adds no tab stop. Codi must not add tabindex, role, or a title element.
  • Keyboard: no shortcuts. Nothing to operate.
  • Tap targets: StreakMotif is 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 / broken differ 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: StreakMotif must never be rendered with a placeholder day. day is required, has no default, and a caller with an unresolved streak renders its own skeleton instead. Rendering day: 0 while 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.tsand 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.

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

ArgControlOptions
motifselectstones sprout flame mountain waves
dayrangemin: 0, max: 70, step: 1 — the reviewer must be able to drag it
tierselectstandard rare epic legendary rescued atrisk
stateselectstandard atrisk broken
sizeselect16 20 24 44
animateboolean

Named stories (the acceptance evidence — a reviewer opens Storybook, picks any motif, drags day 0 → 70, and watches it accrete correctly):

  1. Playground — all controls live. This is the story the acceptance criterion is read against.
  2. Grammar — 5 motifs × {standard, atrisk, broken} at 44px, day 21 / 21 / 0.
  3. Sizes — 5 motifs × {16, 20, 24, 44} at day 21, standard.
  4. Accretion — 5 motifs × days {0, 1, 6, 7, 8, 21, cap−1, cap, cap+1, cap+5}. The +N boundary is visible here.
  5. 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.
  6. SixteenPixel — all five motifs × three states at 16px only, on both a bg-card and a bg-muted surface. This is the story that catches a §6.2 regression.
  7. ReducedMotionGrammar with animate: true under a prefers-reduced-motion decorator. 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.

MotifKeyframeTransform originVector
StonesomDrop50% 100%drops in from −30px with a settle bounce
SproutomUnfurl50% 50%scales 0.12 → 1.14 → 1
FlameomSettle50% 50%scales down 2.2 → 0.9 → 1
MountainomStep50% 50%rises 9px with a 1.12 overshoot
WavesomSweep50% 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 in DESIGN.md § Motion. No new curve is introduced.
  • transform-box: fill-box is 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 streakStoneHalo keyframe in streakAnimations.ts — do not port the DC's omHalo. The two are functionally identical (0 0 5px → 14px at 0.40 → 0.85 alpha) and streakStoneHalo is 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 any day >= 0; bloom/field render nothing and are documented as Phase 3.
  • [ ] day: 0 renders 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.
  • [ ] +N renders past each motif's cap, and is omitted at size <= 20.
  • [ ] The crown is the only unit carrying the --card knockout + --card outline, in every tier. Verified on epic and rescued specifically, where there is no gold fill to fall back on.
  • [ ] Gold fills the crown on standard/rare/legendary only; never on epic, rescued, atrisk, or broken.
  • [ ] 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 SixteenPixel story.
  • [ ] Renders at 375px width without horizontal scroll at every size.
  • [ ] Zero raw hex in StreakMotif.vue, streakMotifGeometry.ts, and the new MOTIF_PALETTE_BY_TIER. All five token guards under tests/unit/ green.
  • [ ] PALETTE_BY_TIER is not modified. StreakMetaphorIcon.vue, StoneBlock.vue and every existing surface are untouched (A2).
  • [ ] StreakMotif's root carries data-streak-animverified, not assumed (§9.4).
  • [ ] Root carries aria-hidden="true"; no tabindex, no role, no <title>. srLabel is exposed for callers but never rendered by the component.
  • [ ] day has 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:unit and npm run lint green.

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:

  1. 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.
  2. Semantics — the load-bearing reason. The rungs count rank-ladder rungs, not days and not week-groups: rank.stones is 1–10 across the ten RANKS entries (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.
  3. It is already carried by adjacency. All three RankChip call 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.

ChangeFromTo
Empty-rung opacity0.180.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 × modefilled vs empty @0.32empty vs chip fill @0.18 → @0.32
gold · light3.79:11.30 → 1.62
gold · dark4.12:11.59 → 2.29
coral · light3.07:1 (binding constraint)1.27 → 1.55
coral · dark3.21:11.35 → 1.75
coach · light3.54:11.33 → 1.70
coach · dark3.61:11.44 → 1.95
onDark3.83:11.57 → 2.22
legendaryDark3.94:11.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:

LineRawTokenLightDark
:36 coral.fg#9A4A0Fhsl(var(--streak-accent-accessible))5.49 → 4.762.36 → 5.62
:38 onDark.fg#FDE68Ahsl(var(--gold-light))9.82 → 8.50
:42 legendaryDark.fg#FFFBEBhsl(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 stateStreakMotif propsReads 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.

ElementFromTo
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-warningflat MOTIF_PALETTE_BY_TIER[tierFor(currentStreak)].body
Best bar (:33)bg-gradient-to-r from-coach to-primaryunchanged — 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:

FillLightDark
--accent (today, from-)2.39:15.29:1
--warning (today, to-)1.95:17.52:1
--streak-accent-accessible (standard/rare)4.94:15.29:1
--gold-accessible (epic)6.02:19.64:1
--coach-accessible (legendary)6.67:16.51:1
--success (rescued)4.58:15.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 the epic Mark 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 96RepairConfirmationModal.vue and WelcomeBackInterstitial.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:

TermAt 44pxAt 56px
maxGroups (§2.1)33 — the cap is a function of size <= 20, not of size
edge(size, 3) — knockout ring33
edge(size, 2.5) — crown outline2.52.5
edge(size, 4 / 6) — broken scaffold4 / 64 / 6
dash(size, …) factor1.621.28
Crown gold + haloonon
+Nshownshown

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.vueFromToWhy
: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">removedTwo 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).
:18ring-2 ring-amber-400/55ring-2 ring-gold/55Raw Tailwind palette class
:94 streakHeroStylehsl(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. tokenAlphaGuard scans src/views/** for text-foreground/NN only; none of the five guards look for Tailwind palette classes like amber-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 d6049e3f3 inventory below was incomplete, not wrong about what it checked — three signed-in-user's-own-streak surfaces on Dashboard.vue (RepairConfirmationModal.vue, WelcomeBackInterstitial.vue, WelcomeBackBanner.vue) carried neither the StreakMetaphorIcon conversion nor the P2-A1: out of scope annotation 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 in docs/development/gotchas.md. ionic_frontend/tests/unit/streakMotifSurfaceGuard.spec.ts is 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_QUERY is cached and already in flight on every authenticated screen, but before it settles isSupporter reads false. Resolving through effectiveMetaphor(false) at that moment would flash stones at a waves user — the precise defect this phase removes, briefly. So:

resolved = supporterKnown ? effective(isSupporter) : metaphor.value

The 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.

#SurfaceSizeday source (verified d6049e3f3)
1dashboard/DashboardHero.vue:4624props.streakCount (:118)
2GoalEventForm.vue:2016streak computed (:352)
3dashboard/streamlined/HabitCard.vue:718props.habit.habitStreak (:203) — per-habit, correct
4dashboard/streamlined/GreetingCard.vue:2356props.streak (:78)
5dashboard/streamlined/AmbientSidebar.vue:1418props.streak (:128)
6views/Dashboard.vue:11018userStreak (:340)
7dashboard/StreakRepairCard.vue:3120props.preBreakStreak (:111)
8goal/StreakVisualization.vue:516props.currentStreak
9dashboard/GoalGridCard.vue:10516props.streak (:280)
10dashboard/GoalFocusCard.vue:4316props.streak (:178)
11dashboard/RepairConfirmationModal.vue:2796props.preBreakStreak default / displayStreak success (:31)
12dashboard/WelcomeBackInterstitial.vue:3396props.streakDays default / displayStreak success (:37)
13dashboard/WelcomeBackBanner.vue:1024props.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.

#SurfaceParent(s) that already hold metaphor
11streak/RankChip.vuen/a — §15.1, no motif
12streak/WeekRibbon.vueCelebrationBalanced.vue:29, CelebrationCinematic.vue:46, StreakDetailsModal.vue:84 (prop at :207)
13streak/CelebrationToast.vueStreakCelebration.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.

#SurfaceName hostActionReachable non-standard state
1DashboardHerostreakTileAriaLabel (:143), on the tile buttonExtend — add a broken branch beside the existing loading / unavailable onesbroken (streakCount 0)
2GoalEventForm:aria-label on the chip span (:18)None — chip is v-if="streak && streak > 0"none
3HabitCardthe flex items-center gap-2 wrapper (:6)Conditionalaria-label only when habitStreak === 0; otherwise the visible "N day streak" is the namebroken
4GreetingCardthe streak-hero div (:17)Conditional — same rule; visible {{ streak }} + "day streak" is the name otherwisebroken
5AmbientSidebarthe streak block (:9)None"Streak" eyebrow + tweenedStreak is the name; at 0 the text is truthfulbroken, text-covered
6Dashboard.vue:104the <button>None — its text spans are the namebroken, text-covered
7StreakRepairCardrole="region" :aria-label (:6)Extend"Streak repair — your {n}-day {goal} streak broke."broken (always)
8StreakVisualizationthe <h3> (:4) + the bars' own textNone"Streak", "Current N days", "Best N days" is completebroken, text-covered
9GoalGridCardthe .gc-streak-chip spanNonev-if="streak > 0"none
10GoalFocusCardthe .gc-streak-chip spanNonev-if="streak > 0"none
11RankChipthe chip span; SVG already aria-hidden (:3)None — no motif, rank name is textn/a
12WeekRibbonthe NOW cell (the flex-col div, :3)Newaria-label="Today — marked" / "Today — not marked yet". The hollow render says something "NOW" does not. Six past cells unchanged.hollow / "not yet"
13CelebrationToastroot role="status" aria-live="polite" (:2)None"21 days · kept" is the namenone

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:

ts
const state = day <= 0 ? 'broken' : 'standard'
const tier  = tierFor(day)                       // except where the caller knows better

Two documented deviations, both already justified upstream:

  • StreakRepairCard passes state: 'broken' unconditionally with day: preBreakStreak > 0. The card exists because the streak broke; the pre-break count is what the copy quotes ("Your {n}-day {goal} streak broke."), and broken draws one hollow group regardless of day (§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 passes tier: 'rescued', state: 'standard', day: restoredStreak. rescued is --success with 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.
  • rescued was 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.

FromTriggerToTransition + durationFeedbackReduced-motionBack-nav
Check-in chip (GoalEventForm, 16px, day N−1)Mark today done → currentStreak incrementsCelebrationToast (20px, day N)streakCardLand 460ms cubic-bezier(0.34,1.56,0.64,1); crown entry animate: true, 460ms, same curveimpactLight (useStreakCelebration.ts:50); toast tier, no useToast call — the overlay owns its own dwellglobal 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 balancedCelebrationBalanced (Hero + WeekRibbon + RankChip)existing, unchangedimpactMedium on milestones (:47-48)unchangedunchanged
StreakRepairCard default (broken, 20px)Repair mutation confirms → success: trueStreakRepairCard success (rescued, 24px)in-place frame swap, no route moveexisting card copy; no new toastno motion either framen/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

  1. 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.
  2. WeekRibbon's day labels are 9px, below the 11px text-2xs floor declared in tailwind-tokens.cjs. The alpha is fixed in §15.2b; the size is a layout change and is not. Route to Maggie.
  3. 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.
  4. P2-A4, corrected. Task 3 removes a StoneBlock call site, not a PALETTE_BY_TIER consumer. StoneBlock.vue remains a PALETTE_BY_TIER consumer and still ships on four other surfaces (StreakMetaphorSelector.vue, AchievementsHero.vue, StonesPyramid.vue, StonesWall.vue). Verified count at d6049e3f3: 7 PALETTE_BY_TIER consumers before Phase 2, 7 afterSproutIcon, SproutHero, WavesIcon, WavesHero, MountainIcon, MountainHero, StoneBlock. Phase 3 should start from 7, not 6. Put this number in the PR body.
  5. PALETTE_BY_TIER is untouched in this phase, per P2-A3 — its convergence with MOTIF_PALETTE_BY_TIER is 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.
  6. No guard catches raw Tailwind palette classes (amber-400, orange-500). The five token guards cover alpha-stacked text-foreground in src/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.
  7. 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.
  8. rare is still a no-op tier (Phase 1 §9.1), and MOTIF_PALETTE_BY_TIER.rare reproduces 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 fileVariants to add or updateargTypes
streak/RankChip.stories.tsLadder — 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.tsMotifs — 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.tsMotifs — 5 motifs at day 21. Rescuedmoment: 'rescued', tier: 'rescued'.day (range 1–400), motif (select), moment (select), dark (bool)
dashboard/StreakRepairCard.stories.tsBroken and Restored — both frames, §19.preBreakStreak, success (bool), restoredStreak
goal/StreakVisualization.stories.tsTierLadder — the current bar across standard/rare/epic/legendary. ZeroStreak — day 0, the broken header glyph.currentStreak (range 0–400), longestStreak
streak/StreakMetaphorIcon.stories.tsAdd day to every story; add SizeLadder at 16/18/20/24/44/56.day, size, tier, state, isSupporter (override)
dashboard/MomentumStat.stories.ts:124Pass a real day.
dashboard/GoalGridCard.stories.ts, GoalFocusCard.stories.tsUpdate 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 no motif prop defaults to 'stones' on the three M2 surfaces (§16.2).
  • [ ] StreakMetaphorIcon resolves the supporter flag itself; isSupporter is an optional override. Assert the cold-load rule (§16.1): before the flag settles, the stored preference renders — not 'stones'.
  • [ ] RankChip has no motif prop and imports no motif module. Empty rungs at 0.32. All three raw hex literals replaced per §15.1.
  • [ ] WeekRibbon's NOW pip renders StreakMotif at day: 1 / size: 18; the six past pips, the glow ring and streakPipPulse are byte-unchanged. Day labels drop the / 0.7 alpha (§15.2b).
  • [ ] StreakVisualization's current bar is a flat tier body fill; the Best bar is unchanged.
  • [ ] GreetingCard renders Mark at 56px; the -inset-3 blur-xl amber wash is removed; no amber-* or orange-* Tailwind palette class and no raw hsl(...) literal remains in the file.
  • [ ] Goal-card chips render Mark at 16px, not 12.
  • [ ] StreakRepairCard renders broken in the default frame and tier: 'rescued' in the success frame (§19).
  • [ ] Every caller derives state per §18. No call site renders day: 0 without state: '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.vue and PALETTE_BY_TIER are 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_TIER consumer 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 --noEmit all 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.

FamilyLight-mode rungs (L%)Dark-mode rungs (L%)Distinct rungs in dark
streak-accentlight 65 · anchor 55 · accessible 35light 65 · anchor 55 · accessible 552
goldlight 70 · anchor 56 · accessible 27light 70 · anchor 56 · accessible 702
coachlight 80 · anchor 65 · accessible 46light 80 · anchor 65 · accessible 802
successanchor 27 · accessible 20anchor 39 · accessible 391
muted-foreground40651

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

ts
// 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.28

One new token, proposed here rather than minted silently (§9.3 deferred exactly this decision to Phase 3, "made with Bloom's geometry in hand"):

css
/* 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

KeybodyLight vs --cardDark vs --cardcrownNotes
standard--streak-accent-accessible5.43:1 (was 2.62)6.83:1--goldthe light-mode correction
rare--streak-accent-accessible5.43:1 (was 2.62)6.83:1--goldbyte-identical to standard — §9.1
epic--gold-accessible6.61:1 (was 1.69)12.44:1nullthe light-mode correction
legendary--coach-accessible7.33:1 (was 3.96)8.40:1--goldwas passing but shallow
rescued--success5.03:16.91:1nullunchanged read layer
dormant--muted-foreground6.08:16.96:1nullabsorbs atrisk + broken
onPrimary--primary-foreground4.56:1 vs --primary7.68:1 vs --primarynull§31.2

Derived values, tier-independent:

LayerValueLightDarkFloor
shaded end of the rampbody + --motif-shade @ 0.287.57 – 10.18:13.98 – 6.77:13:1 ✅
ramp visibility (shaded end vs body)1.39 – 1.50:11.70 – 1.84:1reads as volume, never as a second object
knockout — seam / cap / trail / crown ringhsl(var(--card))5.03 – 7.33:1 vs body6.83 – 12.44:1 vs body3:1 ✅ by construction
recessive — mountain's back peakbody @ 0.451.93 – 2.30:12.30 – 3.43:11.9:1 ✅ (tightest: rescued light)
ambient — Field onlybody @ 0.18exempt (§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 --success at 3.56:1 in dark. It measures 6.91:1 — 3.56 is the number you get from :root's 160 84% 27%, but html.dark line 481 overrides --success to 160 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:

#LiteralWhereFate
1–2#9A4A0Fstandard.dark, rare.darkdeleted 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#6B21A8legendary.darkdeleted with the stop. 2.05:1 in dark.
4#5EEAD4rescued.lightdeleted with the stop. 1.48:1 in light — invisible on a white card.
5#0E7C5Frescued.darkdeleted with the stop. 3.47:1 dark; no longer needed.
6–7#94A3B8, #334155atrisk.light/.darkdeleted with the key. atrisk was a state laundered as a tier (§1.4); it folds into dormant.
8–9#94A3B8, #334155locked.light/.darkdeleted 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-11 short-circuits all three gradient stops to transparent when state === 'locked'. p is never sampled.
  • StoneBlock.vue:27-30 swaps the stroke for hsl(var(--foreground) / 0.18) (light) / hsl(var(--card-foreground) / 0.22) (dark), a dashed 0.6 hairline at 0.55 opacity. Also not p.
  • The only path that could reach PALETTE_BY_TIER.locked is palette="locked" with state !== 'locked'. No call site in ionic_frontend/src passes it. The StreakTier | '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, and body alone. Stones' blocks, sprout's leaves, flame's coals, waves' crests: full-opacity body on --card, so every countable mark clears 3:1 in both modes by §25.2 row 1. The DC paints three of these in p.dark or 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. body is the correction, not a preference.

B2 — depth is an overlay, never a hue. Volume is --motif-shade at α 0.28 in a top-to-bottom userSpaceOnUse linear gradient, painted as a second copy of the silhouette path over the first. Because it composites over whatever body resolves to, it is correct in both modes for every tier with no per-cell check. Never paint --motif-shade on --card directly — 1.03:1 in dark.

B3 — anything drawn on the silhouette that must be read is a --card knockout. 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 against body in dark. There is no mode-safe light ink here; --card is it. A knockout is an interior device — it never outlines the silhouette against the page.

B4 — recessive forms are body at α 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 (#5EEAD4 on white, 1.48:1, is the worked example). 1.9:1 is the shallowest value every tier clears in both modes. The DC's p.light spur 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 body collapses to 1.17 – 1.55:1 in dark (§5.3), so mountain's newest step is a --card tick at edge(size, 2.6) instead of edge(size, 1.6), carrying streakStoneHalo 2.4s ease-in-out infinite. Everywhere else the crown sits on the card and takes Mark's existing treatment: crown ?? body fill + a --card knockout 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:

ts
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 body in 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 to opacity 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.
  • LoadingStreakMotif still renders nothing without a resolved day (§8.3). The three call sites own their own skeletons; StreakDetailsModal.vue:8-22 already 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 -accessible token 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.

MotifReads asnDirectionMark (units)Cycle
stonesdust settling off a wall just built3down2.2 × 2.27.5s
sproutpollen off a living plant4up1.8 × 1.89s
flameembers off the hearth5up2.0 × 2.65.5s
mountainhaze at altitude3up2.4 × 1.411s
wavesswell carrying past4across-down3.0 × 1.68s

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. SparkLayer is 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), and RankChip's legendaryDark tone (:29). Nothing about the tier becomes unreadable.
  • SparkLayer.vue itself 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 siteLineGlyphContainerRendered box (size × 1.15)RenderWhy
StreakDetailsModal.vue:70-7272pxmin-h-[84px] grid72 × 82.8 ✅BloomInformational, not celebratory. An ambient layer on a surface the user opened to read is decoration asking for attention.
CelebrationBalanced.vue:16-1892px110px metaphorBox92 × 105.8 ✅BloomCelebratory, but SparkLayer is already its ambient layer (§29.4).
CelebrationCinematic.vue:33-35120px140px metaphorBox120 × 138 ✅FieldThe 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.4 units → 4.35 × 2.8 device px at 64. At 44px it is 3.0 × 1.9 — Phase 1 §6.1's own threshold for where a mark stops claiming a count. Below 64, use render: '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 StonesHero ships 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.

#SurfaceReached viaIn this milestone?Ruling
1StreakDetailsModalStonesHeroStoneBlock; heroesyesin
2CelebrationBalancedsameyesin
3CelebrationCinematicsameyesin
4AchievementsHero.vue:26-29StoneBlock ×3, palette="epic"noin — §31.2
5StreakMetaphorSelector.vue:33-37, 68-72StoneBlock + the three *Iconsnoin — §31.1
6StreakVisualization.vue:63MOTIF_PALETTE_BY_TIER[…].bodyyes (Phase 2)in — key set unchanged for it; only the value shape moves (§25.1), a one-line hsl(...) wrap
7Storybook stories for all of the abovein

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:

LightDark
today — --streak-accent2.39:15.29:1
converged — --streak-accent-accessible4.94:15.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:

Fillvs --primary lightvs --primary dark
--gold — today2.70:11.46:1
--gold-accessible — what the convergence would give it1.45:11.71:1
--gold-light3.17:11.71:1
--primary-foreground4.56:17.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 +N chip is removed, not relabelled; see .planning/phases/v4.53-obj-3358-streak-mark-overflow-chip/UI-SPEC.md. This ruling stands unchanged for Bloomday − cap still means something.


33. Microcopy

One new string in this phase. Everything else on these three surfaces is unchanged.

SurfaceStringTone check
Bloom overflow chip+12numeral 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 stonesmotif-specific text on a five-motif path (§32.7)

34. Storybook story brief

PathVariantsargTypes
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.tskeep every existing story; add PropSurface asserting all twelve props forward
CelebrationBalanced.stories.ts / CelebrationCinematic.stories.tsadd Day15, Day30, Day300 per motif — the F4 comparison as a story, not just a test
SproutHero / MountainHero / WavesHero .stories.tsdelete — 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.ts exports one record with the seven keys in §25.1 and zero raw hex; a tokenDrift.spec.ts census asserts it
  • [ ] --motif-shade: 222 47% 8% declared in :root and html.dark, byte-identical, plus the matching Tailwind key in both token config copies
  • [ ] locked has no palette entry; StoneBlock.vue:9-11,27-30 renders byte-unchanged
  • [ ] atrisk folds into dormant; StoneBlock's state === 'atrisk' branch maps to it
  • [ ] MotifPalette values are HSL triplets consumed as hsl(${p.body}) / hsl(${p.body} / α); StreakVisualization.vue:63 updated to match
  • [ ] buildBloomShapes() is a new day-level export; groupCountForDay and buildMotifShapes are untouched; streakMotifGeometry.ts stays pure
  • [ ] StreakMotif.vue has exactly five paint branches, one per B1–B5 role; no per-motif colour logic
  • [ ] Bloom uses viewBox="0 0 100 115"; every stroke passes through edge(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 FlameIcon remains at StreakDetailsModal.vue:73, CelebrationBalanced.vue:19, CelebrationCinematic.vue:36
  • [ ] +N = day − cap, rendered as the numeral alone, past every motif's cap
  • [ ] No day > 14 switch survives in any motif render path, including StonesHero.vue:48
  • [ ] StonesHero's twelve-prop surface is unchanged and StreakDetailsModal.vue:65-69 still passes all six; regression test asserts the forward
  • [ ] dark deleted from SproutHero/MountainHero/WavesHero/StonesPyramid only — StonesWall.vue:99 and StoneBlock.vue:27 keep their live use
  • [ ] Render × size per §30: 72 Bloom / 92 Bloom / 120 Field
  • [ ] SparkLayer unchanged on Balanced; removed from Cinematic with an inline note citing §29.4
  • [ ] Field's ambient marks rest at opacity: 0 and carry no prefers-reduced-motion rule (§29.3)
  • [ ] AchievementsHero.vue:26-29 uses palette="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 --noEmit green; ≥80% statement coverage, no drop

36. Flow sequence

FromTriggerToTransition + durationFeedbackReduced-motionBack-nav
Check-instreak increments, non-milestoneCelebrationBalancedBloom 92Ionic 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-instreak hits a milestoneCelebrationCinematicField 120same modal curve; ambient layer runs linear infinite from mounthaptics.impactMedium; toast achievement 5s where the caller fires oneambient rests at opacity: 0 (§29.3); crown collapses as abovesame dedup key
Dashboardtap the streak tileStreakDetailsModalBloom 72<ion-modal> default presentation — not useModalAnimations; see §32.5none — the modal is a read surface, g28 correctly silentnothing to collapse; Bloom is static without animatecloseModal, state preserved
Any celebrationdismissback to the surface behindmodal leave, 250msnonecelebration 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:

MarkBloom / Field
The unita week-groupa day
Units drawn3 (2 at size ≤ 20), plus +Nup to the motif's cap — 63 for stones
The crownnewest of ≤ 3 group glyphsnewest of up to 63 day-marks
A transition firesone day in sevenevery day
Re-animating earned units meansre-animating ≤ 2 thingsre-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 reads props.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 +N chip 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 — same streakNumberPop keyframe, 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 − cap still 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:

MotifGrainUnits drawnWeek-groupsPer dayPer week-group
stonesMark · 443 groups3555ms
stonesBloom / Field63 days93,255ms ❌825ms
sproutBloom / Field42 days62,310ms ❌690ms
flameBloom / Field35 days51,995ms ❌645ms
mountainBloom / Field32 days51,860ms ❌645ms
wavesBloom / Field49 days72,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 that references/motion-vocabulary.md has 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:

  1. references/motion-vocabulary.md has 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 Bloom dark step and §25.1 refused for the third palette stop.
  2. Shipping the fade requires overriding both blanket resets with !important at higher specificity, making StreakMotif the first component in the app to opt out of the global reduced-motion policy. That is a platform decision, not a streak-motif one.
  3. 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 --card knockout 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:

MechanismWhereAssertable in a unit test?
[data-streak-anim] * resetstreakAnimations.ts:104-110❌ injected stylesheet, no cascade in jsdom
* global resetApp.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-578 on master today. §9.4 and §29.3 cite 566-575 and references/motion-vocabulary.md cites 566-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 at DashboardHero.vue:52 | ✅ mock matchMedia, 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-mode dependency, 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 to streakAnimations.ts for 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 every body layer 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:353 composes an animation named omDriftUp / omDriftDown, and neither keyframe exists anywhere in ionic_frontend/src — they live only in Phase 3's mockup _shared.html. An undefined animation-name applies nothing, so the marks hold their resting opacity: 0 forever and Field renders identically to Bloom in production today. Phase 4 owns streakAnimations.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.

#RowContract timingMark · 44Bloom · 72Field · 120
R1Crown handoff220ms 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
R2Group close640ms gold wash, +260ms delaythe only signal — day 6 and 7 draw the same glyphsweeps the row/node/ring/leg/line of 7 that just closed= Bloom
R3Rank-up restage420ms/unit · 45ms apart · oldest first · 1.6s cap3 groups → 555ms9 groups at stones' cap → 825ms= Bloom — 825ms
R4At-risk drain600ms gold → slatewhole glyph to 0.55; crown loses gold, knockout and haloevery body layer to 0.55; knockouts, ramp and recessive peak hold (§28)= Bloom, and the ambient layer unmounts
R5Streak break400ms fade to dashed scaffold, then holdone hollow dashed group glyph (§3.3)seven ghost day-units (§28)= Bloom, and the ambient layer unmounts
R6Rest-day sweep400ms emeraldsweep + palette → rescuedsweep + 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.

ModeMeaningWhere
transitionfires on a prop change while mountedMark, all six rows
arrivalfires once on mount, replaying the change the surface was opened to showBloom / Field, R1–R3
staticthe surface mounts already in that state; the row is Storybook-only thereBloom / Field, R4–R6

arrival needs the previous value, which a component cannot know. Add one optional prop:

ts
/** 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 StreakCelebrationCelebrationBalanced / 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:

ConcernElementTiming
Entry (the take, §12)the new mark460ms cubic-bezier(0.34,1.56,0.64,1), from 0ms
R1 yieldthe previous crown220ms cubic-bezier(0.4,0,0.2,1), from 0ms
R2 washa separate overlay clipped to the closing group640ms, 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 an aria-live region 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)

StoryWhat it must demonstrate
MotionCrownHandoffR1 at Mark and Bloom, side by side. Asserts the yield runs on the previous crown only.
MotionGroupCloseR2 at Mark (day 6→7, geometry unchanged — the wash is the whole signal) and Bloom.
MotionRankUpAtCapR3 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.
MotionRankUpMarkR3 at Mark — asserts 3 steps / 555ms, i.e. that Mark is unchanged.
MotionAtRiskDrainR4 at both grains, plus Field asserting the ambient layer is absent at atrisk.
MotionBreakR5 at both grains — one dashed group glyph at Mark, seven ghosts at Bloom, then hold.
MotionRestDaySweepR6 at both grains.
OneUnitAtBloomAdding 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.
OneUnitPastCapDay 63 → 64 at Bloom: C1 — the chip animates, no mark does.
ReducedMotionmatchMedia mocked to reduce. play() asserts no animation string is applied to any node for any row — not that a duration is small.
FieldAmbientUnperturbedField 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.

FromTriggerToTransition + durationPhase 4 deltaFeedbackReduced-motionBack-nav
Dashboard (Mark mounted)check-in, non-milestonesame surface, day+1in placeR1 yield 220ms + §12 entry 460ms on the tile; R2 wash 900ms on a week boundarynone — the modal owns the haptic (g28)no animation string applied; end state renders directlyn/a — no navigation
Check-instreak incrementsCelebrationBalanced — Bloom 92Ionic 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-uphaptics.impactLight (useStreakCelebration.ts:50) — unchangedmodal + arrival rows both collapse; knockout and outline remain the signalsessionStorage.streakCelebrated dedup (g31) — unchanged
Check-instreak hits a milestoneCelebrationCinematic — Field 120same modal curveas above, plus the ambient layer running for the first time (§41.1)haptics.impactMedium — unchangedambient rests at opacity: 0 (§29.3); arrival rows apply no animationsame 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.
  • arrival mode must fire once per mount, never on a re-render. from is read on mount only. A remount with no from (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-270 is extended to Bloom's day-level crown — bloomNodes reads props.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 +N chip via the existing streakNumberPop; 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() — under reduce, no animation string is applied to any node; asserted with matchMedia mocked, not inherited from either blanket reset
  • [ ] Every Phase 4 animation ends at its resting paint with no animation-fill-mode dependency
  • [ ] No @media (prefers-reduced-motion: reduce) block is added to streakAnimations.ts for any Phase 4 keyframe
  • [ ] omDriftUp / omDriftDown are ported into streakAnimations.ts verbatim from .planning/phases/v4.48-phase-3-bloom-field-renders/mockups/_shared.html:11-12
  • [ ] Field's ambient marks still rest at opacity: 0 and still carry no media query of their own (§29.3), and are absent at atrisk and broken
  • [ ] The optional from prop exists with no default; StreakCelebration forwards priorStreak to CelebrationBalanced / 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.ts stays 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 --noEmit green; ≥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.

MotifHome Screen viewHome Screen glyphLock Screen glyph (metaphorGlyphName, :59-67)Does StoneStack generalise?
StonesStoneStack (:382)three stacked rounded rects, capstone ~50% base widthsquare.stack.3d.up / .fillit is StoneStack — bespoke geometry, stones-only
FlameStreakFlame (:275)flame.fill + accent glowflame / flame.fillno — predates the motif axis, keeps its own view (:499-501)
SproutMotifSymbolGlyph (:329)leaf.fillleaf / leaf.fillno — and it must not. Uses the shared symbol view
MountainMotifSymbolGlyphmountain.2.fillmountain.2 / mountain.2.fillno — shared symbol view
WavesMotifSymbolGlyphwater.waveswater.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.baselineCue carried by
StoneStackgold capstone + glow (:469-475)dashed capstone outline (:464-468)solid warm capstone (:477)shape
StreakFlamegradient + glow (:301-311)WidgetFlameMuted @ 0.85solid WidgetAccenthue (+ glow at .extended)
MotifSymbolGlyphgradient + glow (:356-366)WidgetFlameMuted @ 0.85solid WidgetAccenthue (+ 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's KINDLING_STATES arithmetic and its comment block move with it.

Out of scope, explicitly:

Not this phaseWhy
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 stringAll 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 resolveMetaphorIts 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 expansionRuling 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 cellVaries by motif?What the new sprout / mountain / waves PNG pins
first-run (1)no — nil snapshot(not expanded; metaphor-invariant)
stale (→5)glyph onlyheader mark, .baseline. Body copy is motif-neutral (:1007-1022)
empty (→5)glyph only at Small/Mediumheader mark, .baseline. Large's motivator is motif-branched — see §52
all-done (→10)glyph + captionmark 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 + subtitlethe 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/Mediummark 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 / .vibrant motif distinctness. HomeWidgetAccentedTests renders streakMetaphor: "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:

GrainCells showing copy that names a motif the user did not choose
Small8 of 10 all-done cells (all but the stones pair) — §52.1
Medium8 of 10 all-done cells — §52.1
Large18 of 30 all-done / default / empty / new-day cells (the sprout, mountain and waves thirds) — §52.2
stale, first-runnone — 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:

SurfaceStonesFlameSproutMountainWaves
CTA (:921-928)Lay today's stoneFeed today's fireAdd today's leafTake today's stepMake today's wave
First-run subtitle (:933-940)Start today's towerStart today's fireStart today's sproutStart today's climbStart today's tide
Checked-in caption (:77-85)Today's stone laidLocked in todayToday's leaf outToday's step upToday's wave in
Not-yet caption (:97-105)Add today's stoneAdd todayAdd today's leafAdd today's stepAdd 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) appends reactive.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 .waves Lock Screen gap (§50) is visual only. water.waves carries no state, but the spoken label still distinguishes .extended from .notYet via 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

  • [ ] kindlingStates goes 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=36 in mobile-ios.yml, with a comment block that re-derives 36 the way the current one re-derives 15 — including why first-run stays at 1 (§51.3)
  • [ ] LEGACY_STATES, ACCESSORY_STATES, ACCESSORY_FLAG_OFF_STATES, OVERFLOW_PNGS are unchanged; bump the matching per-matrix count only, never a bare total (mobile-ios.yml:147-151)
  • [ ] Measured find … -name '*.png' | wc -l and the gate's computed EXPECTED both return 374
  • [ ] No new WidgetState case, no new or changed user-facing string, no change to resolveMetaphor'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, over MAX_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

Loading…