UI-SPEC — OBJ-3244: five-metaphor copy tables for the three remaining widget surfaces
Issues: OBJ-3244 (Task 1 of OBJ-3243), reconciled under OBJ-3302 · Author: Desi · Status: ready for Codi Tier: 1 — spec only. No new visual surface, no layout change, no token change. Implements against: ionic_frontend/ios/App/HomeWidgetExtension/HomeWidgetViews.swift @ master 84e0baedb (= OBJ-3256's merge 9595373fb plus one Android-only commit; the Swift file is byte-identical at both). Voice source (locked): the four shipped 5-way tables at HomeWidgetViews.swift:75-113, plus ionic_frontend/src/composables/useWidgetSnapshot.ts:21-79 (COACH_LINE_TEMPLATES / COACH_LINE_SHORT_TEMPLATES) and .planning/phases/v4.28-obj-2037-streak-motifs/UI-SPEC.md:330-351. Governing ruling (locked, from OBJ-3256): the ALL_DONE cluster owns "you finished today's habits." The reactive caption and the reactive glyph own "your streak is confirmed-extended."No ALL_DONE string may borrow the caption's extension verb.
⚠️ Reconciliation notice — read before transcribing anything
This spec was written before OBJ-3256 merged (
9595373fb, 2026-09-02). OBJ-3256 shipped part of what this file specified, and shipped it differently — it split threeALL_DONEsurfaces into a confirmed branch and a pending branch, which this file's original 1-string-per-motif tables cannot express.Transcribing the original tables verbatim would revert OBJ-3256 on three shipped surfaces. Under OBJ-3302 this file has been reconciled against
master:
Table Disposition A — Small + Medium allDonesubtitleSuperseded. Do not transcribe. Already shipped, 5-way × 2-branch. §1 is now a pointer, not a source. B4 — Large largeMotivatorText,.allDoneRewritten 5 × 1 → 5 × 2. Stones/flame both branches are shipped and must be copied byte-for-byte; sprout/mountain/waves pending is new. C — accessory line3,.allDoneRewritten 5 × 1 → 5 × 2. This is OBJ-3302's actual fix. Five new pending strings. B1, B2, B3, B5, B6 Unchanged. Single-branch by nature — no extendedTodaydependency.Everything outside those three rows is exactly as OBJ-3244 shipped it.
Scope
Three iOS widget copy surfaces still speak only stones, or a flame-vs-stones binary, while the widget's metaphor axis has been five-wide since v4.28. A Sprout, Mountain or Waves user sees tower language today, in production, on every widget size.
| # | Surface | Code site @ 84e0baedb | Today | New strings |
|---|---|---|---|---|
| A | Small + Medium allDone subtitle | :127 (allDoneKindlingSubtitle), wired :1028 / :1294 | ✅ already 5-way × 2-branch — shipped by OBJ-3256 | 0 — superseded |
| B | Large largeMotivatorText — six branches | :1637; .allDone branch :1653-1662 | B1/B2/B3/B5/B6 flame-vs-stones ternary; B4 two-branch but stones/flame only | 21 (18 as originally specified, plus 3 pending cells for sprout/mountain/waves in B4) |
| C | Lock Screen RectangularAccessoryView.line3, allDone | :1983 | flame-vs-stones ternary, no extendedToday branch at all | 8 (3 confirmed + 5 pending) |
Total: 29 strings, of which 8 are new since OBJ-3244 — 3 pending B4 cells and 5 pending Table C cells. Every cell below is final copy — transcribe it, don't interpret it.
Line numbers. Every :NNN in this file is against master @ 84e0baedb. OBJ-3256 grew HomeWidgetViews.swift by 31 lines, so every line reference in the original OBJ-3244 text, in OBJ-3246's brief, and in OBJ-3302's issue body is stale by that amount. Re-grep before trusting any of them.
Out of scope: the allDone title, now allDoneKindlingTitle (:122) — already shipped two-branch, confirmed calling checkedInCaption(metaphor) (:75-83), pending reusing the shipped "All done today"; composedAccessibilityLabel (:183), which resolves its suffix through the already-5-way checkedInAccessibilitySuffix / notYetAccessibilitySuffix and is self-healing — the accessory's spoken label is already correct today, this is a rendered-only defect (see §4); Android, which is a missing-feature gap, not a copy gap (OBJ-3243 Task 4); the .waves glyph gap itself, which is recorded in §4 and deliberately not worked around here.
0. The motif vocabulary this spec writes against
Every string below is composed from nouns and verbs that already ship. This table is the whole justification for the 25 cells — nothing here is minted except where §6 says so explicitly.
| Motif | Unit noun | Accretion noun | Verb (add) | Continuation phrase | Participle (caption-owned) | Process noun (pending-owned) |
|---|---|---|---|---|---|---|
| stones | stone | tower | Lay / Set | keep the tower rising | laid | build |
| flame | log / spark | fire | Feed / Strike / Light | keep it going | fed / lit | burn |
| sprout | leaf | sprout | Add / Send out | keep it growing | out | growth |
| mountain | step | climb | Take | keep climbing | up | climb |
| waves | wave | tide | Make | keep the tide rolling | in | tide |
Sourced from checkedInCaption / notYetCaption / notYetAccessibilitySuffix (HomeWidgetViews.swift:75-113), the shipped Large motivator ternaries (:1637-1671), and the v4.28 native-strings table (CTA Take today's step / Make today's wave; first-run Start today's sprout / Start today's climb / Start today's tide).
The last two columns are the whole governing ruling, in vocabulary form. The participle column is what the reactive caption says when the widget has observed the streak extend. The process noun column is what an ALL_DONE surface may say when it has not. A pending ALL_DONE string that reaches into the participle column is the OBJ-3256 / OBJ-3302 bug, every time.
The process nouns are not minted here. build / burn ship in the .empty motivators (§2 B5) and in allDoneKindlingSubtitle's pending branch (:135-136); growth / climb / tide ship in the same function at :137-139, and were themselves derived from the shipped notYetAccessibilitySuffix continuations ("keep it growing" / "keep climbing" / "keep the tide rolling").
1. Table A — Small + Medium allDone subtitle · ❌ SUPERSEDED — DO NOT TRANSCRIBE
This table shipped, differently, in OBJ-3256 (9595373fb). The source of truth is the code, not this file. Read allDoneKindlingSubtitle at HomeWidgetViews.swift:127, wired at :1028 (Small) and :1294 (Medium). It is already five-way and two-branch. There is nothing here for Codi to implement, and transcribing the original OBJ-3244 table would be a straight revert of a fix that merged 2026-09-02.
Three of the five original cells conflict with what shipped. Naming them individually, because a "superseded" banner alone doesn't stop a careful transcriber from re-landing a string they can see sitting in a table:
| Metaphor | OBJ-3244 said (❌ do not use) | Shipped :130-134 (✅ authoritative) |
|---|---|---|
stones | Tower's taller. Rest easy. | Tower's taller. Rest easy. — identical, no conflict |
flame | Fire's brighter. Rest easy. | Fire's brighter. Rest easy. — identical, no conflict |
sprout | Sprout's taller. Rest easy. | Plant's fuller. Rest easy. |
mountain | Climb's higher. Rest easy. | Summit's closer. Rest easy. |
waves | Tide's fuller. Rest easy. | Tide's stronger. Rest easy. |
Orion's OBJ-3302 task package named two of these three conflicts (sprout and waves). Mountain is a third, and it conflicts the same way. Correcting that here rather than in a comment, because the do-not-transcribe marker is only as good as its list.
The pending branch this table never had — also shipped, at :135-139, and equally authoritative. The original table had no pending column at all, which is precisely why transcribing it would drop the branch and revert OBJ-3256 on Small and Medium:
| Metaphor | Shipped pending :135-139 (✅ authoritative) |
|---|---|
stones | Today's build. Rest easy. |
flame | Today's burn. Rest easy. |
sprout | Today's growth. Rest easy. |
mountain | Today's climb. Rest easy. |
waves | Today's tide. Rest easy. |
Quoted in full rather than by pattern so that "matches the code byte-for-byte" is a checkable claim on this table too, superseded or not.
What OBJ-3244's composition rule got right, and what OBJ-3256 changed. The original rule — "the subtitle names the accretion where the title named the unit" — still holds for the confirmed branch, and Tower's taller / Fire's brighter survived on it unchanged. What OBJ-3256 added is that the rule only applies once extension is observed: a comparative (taller, fuller, closer, stronger) is an assertion that the accretion grew, which is the caption's claim to make. So the pending branch drops the comparative entirely and names only the process. That is the same move Tables B4 and C make below.
2. Table B — largeMotivatorText, six branches × five metaphors
Code site: HomeWidgetViews.swift:1637, body :1638-1671. Six branches, not four states — .defaultProgress splits three ways, and B4 splits again on extendedToday, so the matrix is 35 cells, not 30. Shipped cells are included verbatim, so Codi can build the switch by reading one table rather than diffing against the old ternaries.
Rendering: KindlingMotivatorLine (:1330-1346) — .font(.system(size: 12, weight: .semibold)), WidgetStoneCaption, .lineLimit(2), .fixedSize(horizontal: false, vertical: true). Two lines of wrap is the budget. The longest shipped string is 49 chars ("Strike the first spark — today's fire starts now."); no new string exceeds 48, so the wrap behaviour is unchanged.
.stale and .firstRun return nil for every metaphor — unchanged, and not a cell in this table. Stale's sync footer takes the motivator's place; firstRun has no motivator at all.
B1 · .defaultProgress, doneCount == 0 (:1640)
Pattern: <verb> the first <unit> — <accretion> starts <today|now>.
| Metaphor | String | Chars |
|---|---|---|
stones | Set the first stone — the tower starts today. | 45 (shipped) |
flame | Strike the first spark — today's fire starts now. | 49 (shipped) |
sprout | Send out the first leaf — the sprout starts now. | 48 |
mountain | Take the first step — the climb starts today. | 45 |
waves | Make the first wave — the tide starts today. | 44 |
Why "Send out" and not "Put out": "put out the first leaf" is the horticulturally natural phrasing, but it reads as extinguish on a surface that also ships a flame metaphor. "Send out" carries the same meaning with no collision.
B2 · .defaultProgress, remaining == 1 (:1645)
Pattern: <verb> the last <unit> to keep <continuation>.
| Metaphor | String | Chars |
|---|---|---|
stones | Lay the last stone to keep the tower rising. | 44 (shipped) |
flame | Feed the last log to keep the fire going. | 41 (shipped) |
sprout | Add the last leaf to keep the sprout growing. | 45 |
mountain | Take the last step to keep climbing. | 36 |
waves | Make the last wave to keep the tide rolling. | 44 |
Mountain is the short one on purpose: keep climbing is the shipped continuation phrase (notYetAccessibilitySuffix, :110), and "keep the climb climbing" is not English. Brevity is in voice; padding it to match the others would not be.
B3 · .defaultProgress, otherwise (:1651)
Pattern: Each <unit> today <verb>s the <accretion> <comparative|adjective>.
| Metaphor | String | Chars |
|---|---|---|
stones | Each stone today makes the tower taller. | 40 (shipped) |
flame | Each log today keeps the fire bright. | 37 (shipped) |
sprout | Each leaf today makes the sprout greener. | 41 |
mountain | Each step today puts the summit closer. | 39 |
waves | Each wave today pulls the tide higher. | 38 |
B4 · .allDone (:1653-1662) — two branches, five motifs
Rewritten under OBJ-3302. The original table gave one string per motif. master already ships this branch two-way for stones and flame (:1658-1662, gated on let extended = reactive.flame != .notYet); the other three motifs fall through to the stones pair today, which is OBJ-3256's deferred F2. Filling them in here closes F2 as a by-product — intended, and already OBJ-3246 step 3's job, not new scope.
Branch predicate (transcribe exactly): let extended = reactive.flame != .notYet. Note the shape — != .notYet, not == .extended. The fail-closed path (flag off, extendedToday absent) resolves to .baseline, which must take the confirmed branch, because with no reactive caption on screen there is nothing for a confirmed string to contradict. This is the iOS analogue of OBJ-3256's extendedToday == Boolean.FALSE trap note on Android: only an observed false enters the pending branch.
| Metaphor | Confirmed (extended) | Chars | Pending (!extended) | Chars |
|---|---|---|---|---|
stones | Today's stone laid. Rest easy. | 30 (shipped :1662) | That's today's build. Rest easy. | 32 (shipped :1662) |
flame | Today's fire's lit. Rest easy. | 30 (shipped :1660) | That's today's burn. Rest easy. | 31 (shipped :1660) |
sprout | Today's leaf out. Rest easy. | 28 | That's today's growth. Rest easy. | 33 |
mountain | Today's step up. Rest easy. | 27 | That's today's climb. Rest easy. | 32 |
waves | Today's wave in. Rest easy. | 27 | That's today's tide. Rest easy. | 31 |
The four shipped cells are quoted byte-for-byte from master @ 84e0baedb and must be transcribed as-is; moving one of them moves a golden that this change has no business moving.
Confirmed column — composition. checkedInCaption(metaphor) + ". Rest easy." for four of the five. Flame is the exception and always has been: checkedInCaption(.flame) is the motif-neutral "Locked in today", so the Large motivator carries its own flame-specific line (Today's fire's lit.) rather than a caption echo. Do not "simplify" this branch into a single checkedInCaption(m) + ". Rest easy." expression — it would silently rewrite the shipped flame string.
Pending column — composition. "That's today's " + <process noun> + ". Rest easy.", uniformly, all five. Zero participles, so the governing ruling holds by construction. The pending line names what the day was (a build, a burn, a growth) without claiming what the streak became. The growing→growth, climbing→climb echo against the not-yet caption's continuation phrase is deliberate metaphor cohesion, ratified in OBJ-3256's spec — don't "fix" it back toward the unit noun, which is what reintroduces the ambiguity.
B5 · .empty (:1663)
Pattern: Rest is part of the <accretion-process>. Back tomorrow. The anti-guilt bookend — a rest day is part of the thing, never a hole in it.
| Metaphor | String | Chars |
|---|---|---|
stones | Rest is part of the build. Back tomorrow. | 41 (shipped) |
flame | Rest is part of the burn. Back tomorrow. | 40 (shipped) |
sprout | Rest is part of the growth. Back tomorrow. | 42 |
mountain | Rest is part of the climb. Back tomorrow. | 41 |
waves | Rest is part of the tide. Back tomorrow. | 40 |
B6 · .newDay (:1667)
Pattern: <verb> today's <unit> when you're ready. The three new cells reuse the shipped v4.28 CTA verbs exactly (Add today's leaf / Take today's step / Make today's wave), so this branch is also invention-free.
| Metaphor | String | Chars |
|---|---|---|
stones | Lay today's first stone when you're ready. | 42 (shipped) |
flame | Light today's fire when you're ready. | 37 (shipped) |
sprout | Add today's leaf when you're ready. | 35 |
mountain | Take today's step when you're ready. | 36 |
waves | Make today's wave when you're ready. | 36 |
3. Table C — RectangularAccessoryView.line3, .allDone — two branches, five motifs
This is OBJ-3302's fix. Everything else in this file is reconciliation; this table is the new work.
Code site: HomeWidgetViews.swift:1983.
The defect. line3 under .allDone returns "Every stone laid" / "Every log fed" unconditionally — it has no extendedToday branch at all. Six lines above it, line1's leading glyph does react (metaphorGlyphName(reactive.metaphor, filled: reactive.flame == .extended), :1954). So in the pending window the accessory renders an outline glyph — the not-yet signal — directly above a line asserting the stone is already laid. Two contradictory claims, one glance apart, in a 172 × 76pt box. Same bug class as OBJ-3256, on the one surface neither prior audit scoped.
Rendering: .font(.system(size: 11)), .foregroundStyle(.tertiary), .lineLimit(1), .truncationMode(.tail), inside a ViewThatFits(in: .vertical) text stack (:1899) that drops line3 entirely if the stack doesn't fit. One line, tail-truncated — length is a hard constraint here, unlike Tables A and B.
Ceiling: 16 characters, set by the shipped "Every stone laid" and re-verified for OBJ-3302 against the live baseline __Snapshots__/WidgetSnapshotTests/testAccessoryStates.accessory-all-done-notyet-stones-light-rectangular.png (344 × 152px = 172 × 76pt @2x). Measured ink extents in that render:
| Line | Ink span (px @2x) | Right headroom to line1's extent |
|---|---|---|
line1 — glyph + "12-day streak" | x 100–325 | — (widest line; sets the usable right edge) |
line2 — "All done today" | x 100–277 | 48px ≈ 24pt |
line3 — "Every stone laid" (16 ch) | x 100–276 | 49px ≈ 24.5pt |
So the 16-character shipped string renders complete with roughly 4½ characters of slack before it would even reach line1's right edge, and 68px (34pt) before the container edge. ≤16 is a safe ceiling with real headroom, not a measured-to-the-pixel one. Every string below is at or under it; the longest new cell is 14.
Branch predicate: identical to B4 — let extended = reactive.flame != .notYet. Fail-closed (flag off) takes the confirmed branch, which keeps testAccessoryStates_flagOff.accessory-flag-off-all-done-rectangular.png unmoved. That golden not moving is the assertion that the fail-closed path is right; if it moves, the predicate is wrong.
| Metaphor | Confirmed (extended) | Chars | Pending (!extended) | Chars |
|---|---|---|---|---|
stones | Every stone laid | 16 (shipped — the ceiling itself) | Today's build | 13 ✅ −3 |
flame | Every log fed | 13 (shipped) | Today's burn | 12 ✅ −4 |
sprout | Every leaf out | 14 ✅ −2 | Today's growth | 14 ✅ −2 |
mountain | Every step up | 13 ✅ −3 | Today's climb | 13 ✅ −3 |
waves | Every wave in | 13 ✅ −3 | Today's tide | 12 ✅ −4 |
Confirmed column — unchanged. Pattern Every <unit> <participle>, the same participle the metaphor's checkedInCaption uses. Stones and flame are shipped and must stay byte-identical; the other three are OBJ-3244's original cells, carried forward verbatim.
Pending column — composition. "Today's " + <process noun>, uniformly, all five. This is allDoneKindlingSubtitle's shipped pending branch (:135-139) with its " Rest easy." tail dropped — the tail is a release the Home Screen has room for and a Lock Screen glance does not, and at 24–26 characters it would blow the ceiling outright. Zero new lexical items: every process noun already ships, in this exact grammatical frame, on Small and Medium.
Three constraint checks, stated explicitly because each one is a way this fix could go wrong:
- No caption participle.
build/burn/growth/climb/tide— none of them islaid/fed/out/up/in. This is the bug, and the pending column is free of it. - No echo of
line2.line2under.allDoneis the literal"All done today"(:1969). No pending cell repeats it, so the stack never says the same thing twice. - Distinct across motifs, and across branches. All ten cells are unique. The confirmed/pending pair for a single motif differs too, which is the half of the guard test that actually catches this bug class — see §8.
On the
todayecho betweenline2andline3. The pending stack reads "All done today" / "Today's build". That repetition is deliberate and already ratified: it is byte-for-byte the Small and Medium pending pairing shipped by OBJ-3256 (allDoneKindlingTitle"All done today"at:123aboveallDoneKindlingSubtitle"Today's build. Rest easy."at:135). Adopting it here makes all threeALL_DONEsurfaces speak with one voice in the pending window. Diverging on the accessory to avoid an echo would buy a small prose win and cost the cross-surface consistency, which on a three-line glance surface is the more valuable of the two.
4. Accessibility — the accessory's reactive cue
The spoken label is already correct and must not be touched. composedAccessibilityLabel (:183) resolves the accessory's streak phrase through the already-five-way checkedInAccessibilitySuffix / notYetAccessibilitySuffix pair, so a pending accessory today already speaks "…12 day streak — add today's stone to keep building. All done today, 3 of 3." — honest, and free of the contradiction. This is a rendered-only defect. Say so in the PR, so nobody "fixes" the label alongside it.
The .waves gap — recorded, deliberately not fixed here
metaphorGlyphName(.waves, filled:) (:58-66) returns "water.waves" for both states. SF Symbols has no fill variant of water.waves, and the shipped comment at :54-57 records the decision not to substitute a symbol (drop.fill, wave.3.forward) that means something else on a Lock Screen. So on this surface, for a Waves user:
line1's glyph — the only reactive cue that survives.vibrantmonochrome — carries no confirmed-vs-pending information.line3is therefore the sole rendered not-yet signal, andViewThatFits(in: .vertical)(:1899) can dropline3at tight heights.- In that combination — Waves motif, pending,
line3dropped — the rendered accessory carries no not-yet cue at all. The spoken label still does.
This is pre-existing, predates OBJ-3302, and is out of scope to fix in a copy spec — closing it means either a new glyph decision or a layout change, both of which need their own scoping. Recording it because it is exactly the "single-appearance / vibrant-rendering consideration" Roy suspected this surface had, and because Table C's fix narrows the gap rather than closing it: it makes line3 carry a real not-yet signal for all five motifs, so Waves goes from "no cue in any pending render" to "no cue only when line3 is dropped." Worth a follow-up issue; not worth widening this one.
Everything else
- Contrast. No color changes.
line3stays.foregroundStyle(.tertiary); inWidgetRenderingMode.vibrantiOS derives the whole stack from one desaturated wallpaper-aware tint, so relative hierarchy is preserved and no AA calculation changes. - Tap targets. n/a — no accessory family is interactive.
- Motion-reduced. n/a — nothing on these surfaces animates.
5. State catalog
This spec changes strings only. Every non-copy state property of these three surfaces is unchanged and is not restated here. What follows is the delta.
| State | Delta |
|---|---|
Default / .defaultProgress | Large motivator: B1 / B2 / B3 by branch. Small, Medium, accessory: unchanged. |
.allDone — confirmed (extendedToday observed true, or fail-closed) | Small + Medium subtitle: shipped, §1, do not touch. Large motivator: B4 confirmed column. Accessory line3: Table C confirmed column. Rendered glyph: filled. |
.allDone — pending (extendedToday observed false) | Small + Medium subtitle: shipped, §1, do not touch. Large motivator: B4 pending column. Accessory line3: Table C pending column. Rendered glyph: outline (except .waves — see §4). |
.empty | Large motivator: B5. |
.newDay | Large motivator: B6. |
.stale, .firstRun | Unchanged. Motivator stays nil; no copy in scope. |
| Loading | n/a — WidgetKit has no loading state; a timeline entry is either present or the placeholder renders. |
| Error | n/a — decode failure falls through to .firstRun / legacyBody, neither of which carries copy in scope. |
| Disabled / focus / hover / active | n/a — none of the three surfaces is an interactive control. Large's motivator is a static closing line; the accessory is non-interactive. |
| Dark mode | Parity by construction. All three surfaces read their color from Color(widget:) asset-catalog tokens (WidgetStoneCaption, WidgetStoneMuted) or .foregroundStyle(.tertiary). Copy is theme-independent; no string differs between modes, and no new token is introduced. |
| Tinted / vibrant host modes | Unchanged — monochrome legibility was already satisfied; no color-alone cue is added or removed. The accessory families render only in WidgetRenderingMode.vibrant and have no light/dark appearance at all (WidgetSnapshotTests.swift:640-647), so "dark mode" is not a real second state there — see the evidence note in §11. |
| Motion-reduced | n/a — no animation is specified or changed. |
| Mobile / tablet / desktop | n/a — WidgetKit families (systemSmall / systemMedium / systemLarge / accessoryRectangular) are the axis, and each is covered above. |
6. Reused vs. invented
Reused (27 of 29 strings are recombinations of shipped vocabulary):
- Every unit noun, accretion noun, verb, continuation phrase, participle, and process noun in §0 already ships.
- B4 and B6 invent nothing at all. B4 confirmed is
checkedInCaption(metaphor) + ". Rest easy."(flame excepted — it carries its own shipped line, see §2 B4); B4 pending is"That's today's " + <process noun> + ". Rest easy.", extending a two-cell shipped pattern to five; B6 is the v4.28 CTA verb phrase +" when you're ready.". - Table C confirmed is
"Every " + <unit> + " " + <participle>, all three parts shipped. - Table C pending — the eight new OBJ-3302 cells — invents nothing either. It is
"Today's " + <process noun>, which isallDoneKindlingSubtitle's shipped pending branch (:135-139) minus its" Rest easy."tail. Same words, same grammar, same surface family, already on screen today on Small and Medium. - Table A is superseded, so its two originally-invented comparatives (
Sprout's taller,Tide's fuller) are withdrawn along with the rest of it; the shippedPlant's fuller/Summit's closer/Tide's strongerreplace them and are OBJ-3256's, not this file's, to justify.
Invented — two nouns, both justified:
| New word | Where | Justification |
|---|---|---|
summit | B3 mountain — "Each step today puts the summit closer." | The mountain motif's own geometry already names it: docs/ui-specs/v4.48-streak-motif-accretion-grammar.md:1379 specifies "mountain's trail ticks and summit cap". B3 needs a target noun rather than a process noun (climb is used in B1, B2 and B5), and reusing climb a fourth time flattens the branch distinctions the guard test in §8 exists to protect. |
growth | B5 sprout — "Rest is part of the growth." | B5's shipped pattern takes a noun for the accretion process (the build / the burn). Sprout's shipped continuation is the gerund growing; "part of the growing" is not idiomatic, so the nominalisation is required by the pattern, not chosen over an existing option. |
Neither is a new token or a new pattern — both are single lexical items inside an existing sentence frame. No new function, class, or design token is proposed by this spec. The OBJ-3302 reconciliation adds no third invention: every new cell in B4 pending and Table C pending is built from the §0 process-noun column, which shipped in 9595373fb.
7. Brand-voice check
| Rule | How these 25 strings hold it |
|---|---|
Gain-framed, zero loss-aversion (v4.2 contract, docs/milestones/v4.2-reactive-streak-widget.md:39; PBC floor) | Every string names what the user built, never what they could lose. No "don't break", no "you'll lose", no countdown, no streak-at-risk framing. B2's keep … phrasings are continuation, not threat — identical in shape to the two shipped cells that already passed this bar. |
| No guilt on rest | B5 makes the rest day part of the metaphor's own process for all five motifs (the build / the burn / the growth / the climb / the tide). Waves gets the best of it: a tide that ebbs is still a tide. |
| Action-forward | Every non-allDone branch leads with a verb: Set / Strike / Send / Take / Make / Lay / Feed / Add / Light. |
| Brief | Longest new Large string is 48 chars, under the shipped 49-char ceiling; longest B4 pending cell is 33, well inside the 2-line wrap budget. Table C's longest is 14, under the shipped 16. |
| Celebratory without empty praise | allDone copy states the fact ("Today's leaf out") and releases the user ("Rest easy."). No "Amazing job!", no exclamation marks anywhere. |
| Honest about what was observed (the OBJ-3256 ruling) | Every pending cell names the day's work (build / burn / growth / climb / tide) and stops there. None claims the streak advanced — that claim belongs to the reactive caption and glyph, and only once the app has seen it happen. A pending ALL_DONE string is still a celebration; it just celebrates the thing that actually happened. |
| No generic-motivational filler | Zero instances of "You've got this", "Keep it up", or equivalents. Every line is motif-specific and states something concrete. |
| No guilt in the pending window | The pending copy never frames the unconfirmed streak as at risk, and never nudges the user to go check. It reads as "today counted", because it did — the habits were completed; only the sync hasn't been observed. |
| No "AI" in consumer copy | n/a — no coach-attributed copy on these surfaces. |
| No engagement bait | Nothing in these strings drives a return visit, a notification, or a session extension. Back tomorrow. is explicitly a dismissal. |
8. Acceptance criteria for Codi
Do-not-do, first — these are reverts, not omissions:
- [ ] Table A (§1) is NOT implemented.
allDoneKindlingSubtitle(:127) andallDoneKindlingTitle(:122) already ship, already five-way, already two-branch, already wired at:1025/:1028(Small) and:1293/:1294(Medium). Steps 1 and 2 of OBJ-3246's original brief are already complete — do not redo them, and do not re-landSprout's taller/Climb's higher/Tide's fullerover the shipped strings. - [ ] No
ALL_DONEbranch is collapsed back to one string per motif. B4 and Table C are each 5 × 2. A plain five-wayswitchon either is a revert of OBJ-3256 / OBJ-3302, even though it passes the flame-grep below. - [ ]
.planning/phases/v4.7-kindling-widgets-phase-4-lock-screen/UI-SPEC.mdis not used as a source. Its:144row gradesEvery stone laidas "celebrate, no streak-guilt ✅" — that grading is the origin of this bug. Historical record only; this file supersedes it.
Transcription:
- [ ] Every string transcribed verbatim, including the em dash (
—, U+2014) in B1. Apostrophes are straight ASCII'(U+0027), not curly — verified:HomeWidgetViews.swiftcontains zero U+2019 characters today. A curly apostrophe here is a silent copy divergence, not a typo. - [ ] Every string this file quotes as (shipped) matches
HomeWidgetViews.swift@84e0baedbbyte-for-byte after the change. Specifically: the four B4 cells at:1658-1662and the two Table C confirmed cells at:1983.
Wiring:
- [ ] Table B wired as a 5-way
switchinside the extracted free functionlargeMotivatorText(state:metaphor:doneCount:dueCount:) -> String?— a free function, not a private computed var, per this file's own stated convention. B4 is 5-way × 2-branch. - [ ] Table C wired at
:1983as a 5-wayswitch× 2-branch. - [ ] Both branch predicates are
reactive.flame != .notYet— not== .extended. Verified by the flag-off golden not moving (below). - [ ]
grep -n "metaphor == \.flame" HomeWidgetViews.swiftreturns zero matches.:1983is currently the last one. (Necessary, not sufficient — see the do-not-do list.) - [ ]
grep -ni "stone\|tower" HomeWidgetViews.swiftreturns only.stonesswitchcases — no stone/tower literal survives outside a 5-way table.
Assertions in code, not at review:
- [ ] Table C length assertion: every
line3.allDonereturn, both branches, is ≤ 16 characters. This is the only length constraint in the spec that can actually break the render. - [ ] Guard test, both halves: no copy function returns (a) the same string for two different metaphors at the same state/branch, or (b) the same string for both branches of one metaphor. Half (b) is the half that catches this bug class in future, and is the reason OBJ-3302 needed to exist at all.
- [ ] No
ALL_DONEpending string on any iOS surface contains a caption participle (laid/fed/out/up/in). Assertable as a test over the pending branch's returns. - [ ] Large motivator still wraps to at most 2 lines at every metaphor × branch (48-char worst case is inside the shipped 49-char precedent; longest B4 pending cell is 33).
Snapshots:
- [ ] Exactly four goldens move, all
rectangular:testAccessoryStates.accessory-all-done-notyet-{stones,flame}-{light,dark}-rectangular.png. - [ ] Goldens that must NOT move: every
accessory-all-done{,-flame}-*(confirmed strings unchanged); everycircularandinline(noline3); andtestAccessoryStates_flagOff.accessory-flag-off-all-done-rectangular.png— that last one is the fail-closed assertion, not a formality. - [ ] PNG count identical to pre-change. No new fixtures. The accessory fixtures are stones and flame only (
WidgetSnapshotTests.swift:570-573); sprout / mountain / waves copy correctness is proven by unit assertion, not by new goldens. - [ ]
.github/workflows/mobile-ios.ymluntouched — PNG content moves, PNG count does not.
Do not touch:
- [ ]
composedAccessibilityLabel(:183) untouched. The spoken accessory label is already correct today (§4) — this is a rendered-only defect. Themotivator:argument at:1399survives thelargeMotivatorTextextraction, so the spoken Large label picks up the new motivator for free. - [ ]
metaphorGlyphName(:58) untouched. The.wavesgap in §4 is recorded, not fixed here.
9. Throughline
Throughline: fires — and the original "n/a" verdict is withdrawn.
OBJ-3244 recorded this as single-surface state copy with no journey. That was defensible for a spec whose ALL_DONE cells had one branch each. It stopped being true the moment OBJ-3302 split Table C on extendedToday: a two-branch ALL_DONE is a sequence — check-in tap → optimistic pending render → confirmed render — and the pending cell is the wait state in the middle of it. That is exactly the journey OBJ-3256 ran the 52 gates against.
Journey: in-widget check-in tap → optimistic pending render → confirmed render, on accessoryRectangular. Method, stated honestly: trace / walk don't apply — both are built for Vue routes, Apollo cache writes, and .planning/flow-evidence/ journeys, and this is a native SwiftUI widget extension with no route, no Apollo, and no registered journey. I ran the gates against the sequence and the verified code paths, the same way OBJ-3256 did, rather than claiming tooling evidence I don't have.
| Gate | Disposition |
|---|---|
| g32 — celebration follows the confirmed state change, never optimistically before it | Fires today on this surface. Fixed in this spec (§3). line3 celebrated streak extension unconditionally. Table C's pending column resolves it. |
| g28 — exactly one acknowledgment per mutation | Fires today. Fixed in this spec (§3). The outline glyph and "Every stone laid" are two contradicting acknowledgments in one frame. Now one coherent one. |
| g14 — every async step boundary has a designed wait state | Fires today. Fixed in this spec (§3, §5). The pending window is the wait state; line3 had copy written for the resolved state. The §5 state catalog now designs both. |
| g24 (blocking) — offline at a mutation fails visibly or queues honestly | Fires today. Fixed in this spec (§3). An unconfirmed offline write read as success indefinitely on this surface. Copy-level fix; same disposition as OBJ-3256. |
| g17 — a wait exceeding ~3s says what is happening | Considered, deliberately not fixed. The pending window is unbounded offline, but a "Saving…" state was rejected as wrong voice for a deliberately offline-tolerant surface (OBJ-3256), and the not-yet caption/glyph is the honest status signal. Carried forward unchanged. |
g13 / any non-visual-cue gate on .waves | Fires, and is NOT fixed here. With metaphorGlyphName(.waves, filled:) returning one glyph for both states and ViewThatFits able to drop line3, a pending Waves accessory can render with no not-yet cue. Recorded for Maggie as implementation drift, not fixed — closing it needs a glyph or layout decision, which is outside a copy spec. See §4. |
| g50, g49 | Do not fire — checked. No manufactured urgency about the unconfirmed streak; the pending copy is a release, not a hook. |
| g27 (blocking) — double-submit impossible | Not assessed. The widget's check re-entry guard is existing behavior outside a copy spec's reach. Flagged as not-assessed rather than passed. |
| g7, g10, g12, motion category | N/A — no motion. Neither platform animates widget content; the optimistic→confirmed handoff is a WidgetKit timeline reload. No routeAnimated, no useModalAnimations, no toast, no haptic has a widget call site. No GIF owed. |
| Categories A, B, C (rest), D (rest), F, G | N/A — no routes, no guards, no modals, no Apollo cache, no deep links on a Lock Screen accessory. |
Vocabulary gaps: none. Every string in the reconciliation comes from the §0 columns, all of which ship. Nothing minted. Implementation drift recorded for Maggie: one — the .waves glyph gap above.
The Small/Medium/Large legs of the same journey were audited under OBJ-3256 and are unchanged here.
10. Open questions
None blocking. Every cell is filled.
One follow-up, recorded not fixed: the .waves reactive-cue gap (§4). A pending Waves accessory with line3 dropped renders no not-yet signal at all. Pre-existing, needs a glyph or layout decision, and belongs in its own issue rather than widening this one. Table C narrows it but does not close it.
11. Evidence
No HTML mockups, and no light/dark pairs. Two ratified precedents, both already load-bearing in this repo:
- This file's own original evidence note (OBJ-3244). Tier 1, copy-only, against a native SwiftUI widget extension. An HTML approximation of a WidgetKit render is a fabricated visual — it would look like evidence and prove nothing. Same reasoning OBJ-3256 gave for calling its board a "copy-adjacency board, not a pixel-accurate widget render."
- Josh's OBJ-1595 round-3 finding, recorded in
WidgetSnapshotTests.swift:640-647: Lock Screen accessories have no light/dark appearance. iOS always renders.accessoryCircular/.accessoryRectangular/.accessoryInlineinWidgetRenderingMode.vibrant. A "dark"colorSchemerender is white-on-transparent and reads as blank outside a black backdrop, so a light/dark capture pair on this surface is a meaningless second pass.
The evidence is the existing accessory baselines, which are real WidgetKit renders — used in §3 to re-verify the 16-character ceiling by measuring ink extents in testAccessoryStates.accessory-all-done-notyet-stones-light-rectangular.png directly.
Recorded, not fixed:
testAccessoryStatesstill loopslight/darkand so carries two goldens per accessory state, contradicting the rulingtestAccessoryStates_flagOffdocuments at:640-647and doubling the accessory golden count. Pre-existing and out of scope — but it is why §8 says four goldens move rather than two.
12. Sequencing note (not a Desi deliverable — recorded for Maggie)
Both of OBJ-3246's gates have cleared — recording it here because nobody had noticed, and it is the reason OBJ-3302 became urgent rather than routine:
- Gate 1 — OBJ-3244 (this spec) is
done. - Gate 2 — v4.48 Phase 5 merged 2026-09-02 as PR #2835 /
2f3f5dda8(ROADMAP line 158). The snapshot-baseline regeneration this spec was waiting on has already happened.
OBJ-3246 is therefore promotable now — and promoting it against the original text of this file would have shipped a revert of OBJ-3256 on Small, Medium, and Large. That hazard is what this reconciliation closes. Codi's brief must point at the reconciled file, not at commit a80b31685.
Sequencing that still holds: this spec and Codi's implementation ride one PR on fix/widget-metaphor-copy-gaps (rebased onto master). Splitting them re-creates the exact revert hazard — two PRs touching the same four lines and regenerating the same four goldens, with the second silently undoing the first.