Skip to content

Home Screen Widgets

Roadmap item: Phase 4 (v4.1 Engagement & Retention), Item 25 — docs/product/roadmap.md; reactive same-day layer added by v4.2 — Reactive Streak Widget (.planning/milestones/v4.2-reactive-streak-widget-ROADMAP.md); visual refinement + interactive check-off added by v4.7 — Kindling Widgets, iOS (.planning/milestones/v4.7-kindling-widgets-ROADMAP.md); ported to Android by v4.11 — Kindling Widgets: Android Parity (.planning/milestones/v4.11-kindling-widgets-android-parity-ROADMAP.md) Flags: home_widgets_enabled (base widget, fails closed), reactive_streak_widget_enabled (same-day reactive layer + Coach line, fails closed independently — see Same-day reactive layer below), kindling_widget_enabled (v4.7 refined visuals + interactive check-off, iOS Small/Medium/Large, ported to Android by v4.11 — see Kindling refinement and Android parity below; fails closed to a byte-identical pre-kindling render on both platforms; does not gate the three Lock Screen accessories (iOS only — no Android equivalent), which instead ride reactive_streak_widget_enabled — see Feature flag below) PRs: #1306 (shared-storage bridge), #1321 (iOS + Android widget UI), #1325 (in-app "Add widget" affordance), #1329/#1330/#1334 (CI snapshot-evidence harness, OBJ-1200), #1387 (iOS reactive states), #1388 (Android reactive states), #1457 (Enkidu→Objectuve copy fix, OBJ-1348), #1467 (iOS host-chrome fidelity, OBJ-1351), #1471 (Android left-column height budget, OBJ-1372), #1475 (widget color shim bundle-resolution fix, OBJ-1363), #1477 (iOS NEW_DAY day-aware midnight rollover + tests/evidence, OBJ-1350/OBJ-1360/OBJ-1367), #1465 (Android NEW_DAY day-aware midnight rollover, OBJ-1350), #1480 (reconciled the Android v4.2 reactive-widget stack + NEW_DAY onto master, plus the NEW_DAY medium-widget clipping fix, OBJ-1350/OBJ-1379), #1486 (Android small-widget width budget + NEW_DAY vertical-budget fix, OBJ-1374/OBJ-1382), #1561 (empty/all-done centering fill + rest glyph, OBJ-1477), #1608 (v4.7 snapshot foundation — stable per-habit id + Coach-line templates), #1613 (v4.7 Small + Medium kindling visual refinement, kindling_widget_enabled registered at 0%), #1637 (v4.7 Large family), #1642 (v4.7 interactive check-off spike + App Group reconciliation, OBJ-1522, Phase 5), #1645 (v4.7 Lock Screen accessories), #1658 (Android Coach line, OBJ-1589, v4.11), #1671 (Android interactive check-off, OBJ-1592, v4.11), #1673 (Android kindling visual refinement, Small + Medium, OBJ-1590, v4.11), #1684 (Android Large-equivalent breakpoint, OBJ-1591, v4.11), #1700 (Android streak-glyph 3:1 graphical contrast floor, OBJ-1625, v4.11), #1702 (Android caption/Coach spark glyph 4.5:1 text-contrast floor, OBJ-1634, v4.11), #1709 (Android kindling ember contrast + capstone dash retune, OBJ-1636, v4.11), #1851 (widget check-in date preservation + visible sync-rejection failure, OBJ-1858), #1855/#1857/#1859/#1863/#1864 (v4.22 background check-in delivery — CheckinToken foundation, WorkManager, BGTaskScheduler parity, and on-device verification, OBJ-1860), #2268 (fix stale coachLine after widget-native optimistic check-off, OBJ-2436), #2271 (Android narrow-Large/Medium width floor + readability, OBJ-2428/OBJ-2435/OBJ-2437), #2838 (v4.52 Phase 1 Android widget motif UI-SPEC, OBJ-3251), #2840 (v4.52 Phase 2 five-way StreakMetaphor + motif copy layer, Android, OBJ-3252), #2857 (v4.52 Phase 3 third glyph slot + nine motif drawables, Android, OBJ-3253), #2858 (v4.52 Phase 4 captionWidthDp exhaustiveness fix, Android, OBJ-3254), #2852 (pending-ALL_DONE copy fix, iOS, OBJ-3256), #2867 (pending-ALL_DONE copy fix, Android, OBJ-3256), #2868 (extended the pending-ALL_DONE copy fix to Large + the Lock Screen accessory, iOS, OBJ-3246/OBJ-3302), #2877 (Android Small all_done_sub fail-closed motif-source fix, OBJ-3315) Last updated: 2026-09-17 (v4.58 Phase 6, OBJ-3745) — corrected the completedDate server-side bound from "today or yesterday" to 7 days (widened in v4.58 Phase 1, PR #3163, OBJ-3740), in the "Credited to the day tapped" paragraph and the background-delivery error-handling list; linked docs/architecture/offline-contract.md. Previously (2026-09-03) — folded in Android Small's motif-neutral fail-closed fix (OBJ-3315, PR #2877, not yet merged to master at time of writing): the Pending-ALL_DONE copy fix "Fix" paragraph's Android citation no longer lumps allDoneSubStringRes (Small) in with the still-open motivatorStringRes/motivatorSentence (Large, OBJ-3316) — Small's call site already branches on ReactiveFlame.BASELINE and renders its own motif-neutral string, documented in Fail-closed behavior below; corrected the matching Known open items entry to distinguish the two. Previously (same day): corrected the Pending-ALL_DONE copy fix section: the extendedToday == nil/absent "reads as extended" claim was superseded by OBJ-3317's three-way ruling and had gone stale in three places (the Fix:/Extended to Large... paragraphs, the Large family motivator description, and the Lock Screen accessories line3 description); new Baseline is its own branch (OBJ-3317, iOS only) section documents all six iOS ruled line locations (five copy decisions) plus the Large VoiceOver render/speech gating fix (Q4); corrected the matching Known open items entry. OBJ-3317. Previously (same day): largeMotivatorText's Large .allDone branch went five-way with a confirmed/pending split (previously flame/stones-only; the other four states were already five-way), and the Lock Screen accessory's RectangularAccessoryView.line3 .allDone line gained the same confirmed/pending split (previously an unconditional confirmed claim) — extending the pending-ALL_DONE copy fix below to Large and the Lock Screen accessory; see Large family and Lock Screen accessories below. OBJ-3246/OBJ-3302. Previously (same day): replaced the stale "known interaction, not a bug" paragraph and the allDone state-catalog row with the shipped fix: new Pending-ALL_DONE copy fix section documents the ruling, both reachable paths, and the fail-closed contract; corrected the matching "Known open items" entry; PRs #2852 (iOS)/#2867 (Android), OBJ-3256. Previously (same day): corrected the stones/flame-only claims left over from before v4.52 — the Reactive states table and the reactive layer's Accessibility paragraph now cover all five streak motifs, and the contrast-floor paragraph now covers all nine new motif drawables; new Android motif parity (v4.52) section, OBJ-3293. Previously (2026-08-10): documented the OBJ-2428 widget-truncation report's two fixes: Large's now-explicit LARGE_WIDTH_THRESHOLD_DP width floor and the narrow-Large header degradation ladder (INLINE/COMPACT/STACKED), PR #2271 — see Narrow-Large readability (OBJ-2428)

Overview

A native home-screen widget (iOS WidgetKit + Android App Widget) that shows today's due habits and the user's current streak — a zero-friction reminder that never requires opening the app. Whole-widget tap deep-links into the dashboard; tapping an individual due habit row checks it off directly from the widget, on both platforms — see Interactive check-off below.

Read-only glance surface, plus one narrow write path. The widget is a glance surface, not a new engagement channel — it renders the last-synced snapshot and gets out of the way. No FOMO copy, no "come back" nudges, no badges-to-chase. See Anti-social discipline below. V1 (v4.1) shipped fully read-only by design; per-habit check-off was an explicitly documented fast-follow — see UI-SPEC.md Scope — and has since shipped on iOS (v4.7 Phase 5, OBJ-1522) and Android (v4.11, OBJ-1592). It's a single tap-to-check-off action, not a general interactive surface: no forms, no habit creation, no editing. This is a deliberate, gated exception to the "no engagement channel" posture just described — it logs an action the user already intended to take (a due habit), not a passive re-engagement hook.

Both home screens go further under kindling_widget_enabled. As of v4.7 ("Kindling", iOS) and v4.11 (Android parity, same flag), Small/Medium/Large add a refined stone-tower visual language and one earned Coach line — see Kindling refinement (iOS) and Android parity (Android) below. The three Lock Screen accessory families (iOS 16+/v4.7 only) stay glance-only, with no Android equivalent — see No Lock Screen analog on Android below — no per-habit tap target there, whole-widget tap opens the app.

No new backend. The widget snapshot is a client-side projection of data the app already queries — GOALS_QUERY (dueToday, checkedInToday) and USER_QUERY (streak, longestStreak). No new GraphQL query, mutation, or type shipped for this feature; the interactive check-off (iOS v4.7, Android v4.11) reuses the existing CHECK_IN_HABIT_MUTATION (goals.js:624) via each platform's existing offline sync queue — see Interactive check-off below.

Architecture

Check-in success / app foreground


useWidgetSnapshot(goals, user)          ionic_frontend/src/composables/useWidgetSnapshot.ts
        │  watches [goals, user], builds { syncedAt, streak, longestStreak, habits[] }

WidgetBridge.updateSnapshot({snapshot}) ionic_frontend/src/plugins/widgetBridge.ts
        │  custom Capacitor plugin — best-effort, swallows rejections (.catch(() => {}))
        │  no-ops on web (Capacitor.isNativePlatform() check)
        ├── iOS: WidgetBridgePlugin.swift
        │     writes UserDefaults(suiteName: "group.com.objectuve.ionic")["widget_snapshot"]
        │     calls WidgetCenter.shared.reloadAllTimelines()
        └── Android: WidgetBridgePlugin.java
              writes SharedPreferences("objectuve_widget", MODE_PRIVATE)["widget_snapshot"]
              triggers AppWidgetManager.updateAppWidget(...) directly (no App Group
              equivalent needed — the widget provider runs in the same app process)

Why a custom plugin, not @capacitor/preferences

@capacitor/preferences can persist the JSON snapshot but has no way to poke WidgetCenter or AppWidgetManager to reload — the widget would only refresh on its passive timeline budget (≥30 min on Android, opportunistic on iOS), which reads as "broken" right after a check-in. WidgetBridge owns both the write and the reload trigger behind one call, so there's a single source of truth for the storage location instead of two mechanisms that could drift apart. Full rationale: PLAN.md §A.

Update cadence

TriggerPathNotes
Habit check-inuseWidgetSnapshot watcher fires on the next goals/user changePrimary freshness path
App foreground (native)Dashboard.vue's resume listener calls refetchUser()/refetchGoals(), which re-triggers the watcherCapacitor App plugin resume event, listener captured and removed in onUnmounted
Passive system floorAndroid updatePeriodMillis="1800000" (30 min) in home_widget_info.xmlAndroid clamps this to a 30-minute minimum regardless of a smaller value — true floor
iOS timeline policyTimelineProvider reloads .after(next local midnight)Covers the daily habit rollover for a user who never foregrounds the app

No WorkManager periodic worker in V1 — the event-driven push (check-in + foreground) already covers real user activity; a worker is deferred to a fast-follow only if telemetry shows stale widgets for inactive users.

Stale-data threshold

If the widget has never synced (no snapshot / signed out), it renders the first-run state. If a snapshot exists but syncedAt is older than 18 hours, it renders the stale state (last-synced data + a "last synced Xh ago" caption) instead of a spinner or fake data. Threshold: WidgetSnapshot.swift's widgetStaleThreshold (18 * 60 * 60 seconds), mirrored on Android.

State catalog

Five states, each rendered in both themes, plus a sixth state added in v4.2 (NEW_DAY, iOS + Android). Sizes: small (2×2) and medium (4×2) on both platforms; Android also resolves a third, Large-equivalent size (see Android parity (v4.11) below) as a resize target on the same widget, not a separate picker entry.

StateTriggerBehavior
DefaultSnapshot fresh, habits dueStreak (flame + number) + today's progress / habit rows
All-doneEvery due habit checked inSuccess check + "All done today", vertically centered (medium) — calm and terminal, no "come back" nudge
EmptyNothing due today"Nothing due today" / "Rest counts too." vertically centered, plus a decorative rest glyph — streak block still shows. See Empty/all-done centering fill below.
StaleSnapshot older than 18hDefault content + "Today · last synced Xh ago" caption
First-runNo snapshot yet / signed outApp glyph + "Open Objectuve to sync today" — neutral bootstrap, no error styling
New day (NEW_DAY)Reactive layer on, snapshot has crossed local midnight since last sync (and isn't yet stale)Habit rows/pips suppressed, streak digit unchanged — "New day — tap to see today's habits" caption. See NEW_DAY state below.

Full layout ASCII, typography ramp, and per-state copy: UI-SPEC.md State catalog (predates NEW_DAY, added by OBJ-1360).

This is the habit-progress state machine, unchanged in shape since v4.1/v4.2 — v4.7 (iOS only) doesn't add or remove states, it adds two new render surfaces these six states render onto (Large and the three Lock Screen accessories) plus a refined visual treatment of the existing Small/Medium surfaces. See Kindling refinement below.

This five-state catalog is the habit-progress state machine at the base-widget layer (v4.1) and is unchanged in shape by v4.2. The reactive same-day layer described below is a second, independent axis that re-skins only the streak block on top of it — see Same-day reactive layer — except on both platforms, where that same layer can also produce the standalone NEW_DAY state above, superseding the habit-progress determination entirely rather than just re-skinning the streak block.

Empty/all-done centering fill (v4.3, OBJ-1477)

The EMPTY ("rest day") state used to be top-anchored (wrap_content, no gravity="center"), leaving a large dead zone once the day's habit rows disappeared — the "widget looks sparse after all actions are done" report. Both EMPTY and ALL-DONE now vertically center their body, both platforms, both sizes, both themes (ALL-DONE parity fix is Android-medium only; iOS medium already centered pre-fix).

Also added: a decorative rest glyph — a crescent moon tinted WidgetMuted, the evening bookend to the existing "New day" sun icon (widget_ic_new_day / sun.max). It renders on Android medium and both iOS sizes; it does not render on Android small — that layout has no height-budget slack for glyph + title + subtitle together at the 110dp floor (see Left-column height budget), so the glyph was omitted there rather than reintroducing clipping. Android small still gets the centering fix, just without the glyph. The glyph is decorative (contentDescription null on Android / not an accessibility element on iOS) and stays out of the accent group — accent remains flame-only, per Accessibility below.

Files: home_widget_medium.xml (state_all_done_medium, state_empty_medium) and home_widget_small.xml (state_empty), new widget_ic_rest.xml drawable (Android); HomeWidgetViews.swift's new RestGlyph view plus the MediumWidgetView/SmallWidgetView .empty bodies (iOS). Verified via the Roborazzi (Android) and testAllWidgetStates (iOS) snapshot harnesses — see Testing. PR #1561.

Accessibility — the one rule not to regress

Streak number renders in the foreground/WidgetInk token; the flame icon carries accent/WidgetAccent — never the reverse. Accent orange on the light card measures ~2.62:1, which fails WCAG AA even at large text; the flame icon carries the accent color, the streak number carries the data in foreground. This was the single explicit "do not regress" constraint in Desi's UI-SPEC and was verified against source (not the self-certified checklist) on every state/platform during PR #1321's review.

Composed VoiceOver/TalkBack labels read the full state in one label (e.g. "Objectuve. 12 day streak, longest 21. Today: Morning run done, Read 20 min done, Meditate not yet, No sugar not yet.") rather than a pile of unlabeled glyphs.

Same-day reactive layer (v4.2)

UI-SPECs: flame (Phase 1), stones (Phase 1b)

A second, independent axis layered onto the streak block only — for defaultProgress/allDone it re-skins the streak block without changing the habit-progress state catalog above. It answers one question, has today already been added to the streak?, and renders one of two same-day treatments in whichever streak metaphor the user has selected in-app (see Streak motifs for the full five-motif set and its free/Supporter split). On both platforms, if that answer has gone stale across a local-midnight boundary, it instead produces the standalone NEW_DAY state from the State catalog above — see NEW_DAY state — midnight rollover (iOS + Android) below.

Two new snapshot fields

useWidgetSnapshot.ts writes both fields only when reactive_streak_widget_enabled is on; when the flag is off, neither key is present in the snapshot JSON, and native code treats their absence as the fail-closed signal — not a third value to branch on.

FieldTypeDerivation
extendedTodayboolean(user.signInDates ?? []).includes(format(new Date(), 'yyyy-MM-dd')) — device-local calendar day, computed in the same watch([goals, user], ...) that builds the rest of the snapshot
streakMetaphor'stones' | 'sprout' | 'flame' | 'mountain' | 'waves'useStreakMetaphor().effectiveMetaphor(user.isSupporter)stones is the app default; stones/sprout are free, flame/mountain/waves require isSupporter: true (a non-Supporter's saved Supporter-tier preference resolves to stones)

Reactive states

All five streak motifs, not just flame/stones, get their own glyph treatment and caption. Android and iOS ship different caption strings for the same motif and state — this isn't drift, it's two independently maintained platform string tables: Android's captions are the shortened v4.2 narrow-width forms (Microcopy shortened alongside the width fix below applied the same treatment to all five motifs, not just the original two), iOS's are the original, longer forms. Sources: Android — extendedCaptionTextRes/notYetCaptionTextRes (HomeWidgetProvider.java:261-293); iOS — checkedInCaption/notYetCaption (HomeWidgetViews.swift:77-105).

StateTriggerGlyph treatmentAndroid captioniOS caption
Extended (celebratory)extendedToday: trueLit/glowing glyph — flame: gradient flame + glow + gold core spark; stones: capped stack, capstone placed/lit/glowing; sprout/mountain/waves: lit motif glyph + glow (MOTIF-SPEC-1)Flame "Locked in" · Stones "Stone laid" · Sprout "Leaf out" · Mountain "Step up" · Waves "Wave in"Flame "Locked in today" · Stones "Today's stone laid" · Sprout "Today's leaf out" · Mountain "Today's step up" · Waves "Today's wave in"
Not-yet (encouraging)extendedToday: falseMuted glyph — flame: muted flame, no glow; stones: dashed open slot at the top of the stack; sprout/mountain/waves: muted motif glyph, same dashed-slot caption-glyph icon as stones (MOTIF-SPEC-5)Flame "Add today" · Stones "Add a stone" · Sprout "Add a leaf" · Mountain "Add a step" · Waves "Add a wave"Flame "Add today" · Stones "Add today's stone" · Sprout "Add today's leaf" · Mountain "Add today's step" · Waves "Add today's wave"

An absent/unrecognized streakMetaphor resolves to stones (mirrors the JS-side fail-open-to-stones default in useStreakMetaphor.ts); in practice this only happens if the flag is on but the field is somehow missing, since streakMetaphor is only ever set alongside extendedToday.

Composition with the habit-progress state catalog

The reactive layer only applies to two of the five habit-progress states; it's suppressed everywhere else so the widget never asserts a same-day claim it can't back up:

Habit-progress stateReactive layer applies?Why
defaultProgressYesThe everyday case — extended or not-yet
allDoneYesThe everyday case, same shape as defaultProgress — confirmed (extended) or pending. The ALL_DONE motivator/kindling copy and the reactive caption/glyph read the same extended value, so they can't disagree (see Pending-ALL_DONE copy fix below)
emptyNo — suppressedNothing due today; no same-day claim to make
staleNo — suppressedData's too old to assert a same-day claim
firstRunNo — suppressedNo snapshot yet

Pending-ALL_DONE copy fix (OBJ-3256)

allDone (habit-progress) and extendedToday (reactive) are independent flags — before this fix, the widget could show ALL_DONE's celebratory copy ("Today's stone laid. Rest easy." / "Tower's taller. Rest easy.") right next to the not-yet caption/glyph ("Add today's stone"), asserting a confirmed streak extension the widget hadn't actually observed. On Large, the spoken a11y label combined both into one self-contradicting VoiceOver/TalkBack utterance.

Two reachable paths produced it, both still true today — this fix changes what the ALL_DONE copy says, not how the mismatch window itself arises:

  • Widget tap. Android's WidgetSnapshot.withOptimisticCheckin (WidgetSnapshot.java:133-149) and iOS's WidgetSnapshot.checkingIn (WidgetSnapshot.swift:58-70) flip a habit's checkedInToday locally so the tap redraws immediately, without waiting for the sync round trip — but deliberately leave extendedToday untouched.
  • In-app, before signInDates syncs. useWidgetSnapshot.ts's watch([goals, user, metaphor], ...) (line 107) recomputes the snapshot the moment goals changes — which a check-in mutation can update before the server round trip lands — while extendedToday is derived separately from user.signInDates (line 127), which hasn't caught up yet. This path needs no widget tap at all; a genuine synced snapshot can land here too.

Both paths can leave checkedInToday: true for every due habit (→ ALL_DONE) alongside extendedToday: false (→ the not-yet reactive branch) in the same snapshot.

The ruling: the ALL_DONE cluster (motivator line + kindling title/subtitle) owns "you finished today's due habits." The reactive caption/glyph owns "your streak is confirmed-extended." No ALL_DONE string may borrow the caption's extension verb (laid / lit / out / up / in) unless the extension is actually confirmed. This is motif-agnostic by construction — it holds across all five motifs without a separate judgment call per motif — and resolves the mismatch by softening the celebration's specificity, never by fabricating an unconfirmed extension.

Fix: the ALL_DONE copy functions on both platforms gained an extended parameter, computed from the same fail-closed trap the caption/glyph already used — treatment.flame != NOT_YET (Android, resolveReactiveTreatment) / reactive.flame != .notYet (iOS, superseded — see below) — so extendedToday == nil/absent (flag-off) and extendedToday == true (confirmed) both still read as extended, and only a confirmed extendedToday == false enters the pending branch. When pending, the copy drops the extension claim entirely — e.g. stones: "Today's build. Rest easy." instead of "Tower's taller. Rest easy." — while still celebrating the completed habits. Android: motivatorStringRes/motivatorSentence (Large motivator, HomeWidgetProvider.java) — still the live two-way predicate as of this writing; a parity fix is tracked separately as OBJ-3316, in progress. Android Small's allDoneSubStringRes is a different case: its call site (HomeWidgetProvider.java, case ALL_DONE) now checks ReactiveFlame.BASELINE first and never reaches this two-way predicate in the fail-closed case at all — see Fail-closed behavior below (OBJ-3315). iOS: largeMotivatorText, allDoneKindlingTitle, allDoneKindlingSubtitle (HomeWidgetViews.swift) — this != .notYet predicate no longer describes iOS's shipped behavior; see Baseline is its own branch (OBJ-3317, iOS only) below. extendedToday itself is still never flipped speculatively on either reachable path above — doing so would fabricate an unconfirmed streak extension and break its fail-closed contract (see Fail-closed behavior above); the fix only makes the ALL_DONE copy read the flag honestly.

Correction (OBJ-3315): the "both still read as extended" claim above no longer holds for Android Small either. Its all_done_sub call site now takes a third branch instead of reading extendedToday == nil as extended — see Fail-closed behavior below for the full ruling.

Extended to Large and the Lock Screen accessory (OBJ-3246/OBJ-3302): the gap noted above — iOS's largeMotivatorText .allDone branch was still flame/stones-only, and the Lock Screen accessory's RectangularAccessoryView.line3 .allDone line unconditionally asserted the confirmed string regardless of extendedToday — is now closed. Both gained the same extended/fail-closed treatment as the rest of the ALL_DONE cluster, five motifs wide. See Large family and Lock Screen accessories below for the full copy tables. This two-way extended/fail-closed treatment was itself superseded shortly after by OBJ-3317's three-way ruling — see below.

Shipped: iOS (PR #2852), Android (PR #2867); Large + Lock Screen accessory extension, iOS only (PR #2868); Android Small fail-closed baseline (PR #2877).

Baseline is its own branch (OBJ-3317, iOS only)

The two-way flame != .notYet predicate above still had a gap: it read flag-off/absent (extendedToday never populated, reactive.flame == .baseline) as indistinguishable from a genuinely confirmed extension, so both took the identical "confirmed" copy — a fail-closed render could still say "Today's stone laid" (or, worst case, the Lock Screen accessory's "Locked in"-style confirmed string) with zero data behind it. Desi's ruling (OBJ-3317, comment c708f033-0355-4564-bfa4-2f52630cd781) replaces the boolean with an explicit three-way switch on StreakReactiveState (.extended / .notYet / .baseline) at every iOS ALL_DONE copy site, so baseline gets its own motif-neutral string that asserts nothing about the streak — only that today's due habits are done. Six line locations, five copy decisions (Small and Medium share the same two functions):

#SiteFunctionBaseline ruled value
1–2Small kindling title + subtitleallDoneKindlingTitle / allDoneKindlingSubtitleAll done today / That's the day. Rest easy.
3–4Medium title + subtitle (same functions, different call site)allDoneKindlingTitle / allDoneKindlingSubtitlesame pair
5Large's closing motivator line, rendered and spokenlargeMotivatorTextThat's the day. Rest easy. — adopted verbatim from Desi's OBJ-3316 ruling for Android's equivalent (see note below), not minted separately for iOS
6Lock Screen RectangularAccessoryView.line3accessoryAllDoneLine3That's the day — no "Rest easy" clause, to hold the accessory's 16-character/1-line budget

allDoneKindlingTitle stays a two-outcome function — its extended: Bool parameter now derives from flame == .extended instead of flame != .notYet, so baseline converges with pending on "All done today" rather than with confirmed; nothing at headline weight should vary on a distinction (confirmed vs. pending) the user can't see. The other four sites take the full three-way StreakReactiveState directly.

Q4 — the Large VoiceOver utterance is gated on the same flag as the render it describes. LargeWidgetView.body passes motivator: isKindlingEnabled ? motivatorText : nil into composedAccessibilityLabel — only kindlingBody draws the closing motivator line, so a flag-off VoiceOver user must not hear a sentence that isn't on screen. This was a pre-existing gap (Large's accessibility label passed motivator: unconditionally since the Large family shipped), closed alongside the copy fix because the same motivatorText value feeds both the visual line and the spoken label — fixing the string once fixes both paths, but only because render and speech now share one condition.

Android has not adopted this three-way split yet. Android's Large-motivator equivalent (motivatorStringRes) is tracked separately as OBJ-3316 — in progress as of this writing — and iOS's baseline string above only borrows the value Desi ruled there; that doesn't imply the Android code has shipped it. Don't assume cross-platform parity on this specific branch until OBJ-3316 closes.

Tests: WidgetCopyTests.swift asserts the baseline branch is distinct from both confirmed and pending at all five copy decisions; WidgetSnapshotTests.swift pins the .allDone + extendedToday == nil render for both a nil and a set streakMetaphor.

Shipped: iOS only, PR #2880 (OBJ-3317).

NEW_DAY pre-empts this table entirely, on both platforms. iOS's resolveWidgetState(_:asOf:) and Android's resolveState(snapshot, asOf) both check for a midnight-rollover crossing before ever deriving defaultProgress/allDone/empty; when that check fires, the widget renders the standalone NEW_DAY state from the State catalog instead of any row above. See NEW_DAY state — midnight rollover (iOS + Android) below.

Fail-closed behavior

reactive_streak_widget_enabled off, or extendedToday absent from the snapshot, forces the classic v4.1 flame at baseline (no glow, no caption) on both platforms — streakMetaphor is ignored entirely in this path, so a stones-preferring user with the flag off still sees the pre-v4.2 flame, not a neutral stones glyph. An early implementation pass rendered the stones neutral glyph in the fail-closed path instead; caught and fixed before either PR opened. See the resolveReactiveTreatment guard in HomeWidgetViews.swift (iOS) / HomeWidgetProvider.java (Android).

Android Small all_done_sub string swap (OBJ-3315). Inside this same fail-closed branch, ALL_DONE + kindling_widget_enabled on used to feed treatment.metaphor — the fail-closed FLAME glyph sentinel, not the user's actual motif — into the Small kindling subtitle's copy selector, silently asserting a comparative claim ("Fire's brighter. Rest easy.") that the classic non-reactive flame render on screen can't back up. The call site (HomeWidgetProvider.java, case ALL_DONE) now checks treatment.flame == ReactiveFlame.BASELINE first and renders one new motif-neutral string instead: widget_all_done_sub_baseline = "That's the day. Rest easy." — one string, all five motifs, both themes. allDoneSubStringRes itself is unchanged; it's simply never called in this one state. Full ruling: .planning/phases/obj-3256-all-done-pending-copy/UI-SPEC.md §"Table 2a — the fail-closed branch (extendedToday == null)".

Known, routed gap this fix doesn't cover: Android's Large motivator (motivatorStringRes/motivatorSentence) still renders "Today's stone laid. Rest easy." in this same no-data state — the identical defect, routed as OBJ-3316. iOS closed its equivalent gap across all six of its .allDone copy sites — see Baseline is its own branch (OBJ-3317, iOS only) above. Android Large is now the only surface still reading the fail-closed sentinel as a confirmed extension.

The flame is not a paywall

Streaks — the number and the flame icon — are a free feature (pricing-philosophy.md L24: "Full-featured goal tracking, habit streaks, XP…" — no gate). The classic/fail-closed path above renders the flame for every user regardless of Supporter status; only the reactive treatment (glow/celebration, Reactive states above) and the in-app choice among the five streak metaphors are Supporter cosmetics. useStreakMetaphor.ts splits the five as FREE_METAPHORS = ['stones', 'sprout'] and SUPPORTER_METAPHORS = ['flame', 'mountain', 'waves']stones and sprout are both free; a non-Supporter holding a Supporter motif (flame/mountain/waves) renders as stones (effective()). See Streak motifs for the full split. A non-subscriber seeing a lit flame + streak number on their widget is seeing intentional, documented behavior — don't read the flame itself as a premium signal or paywall indicator. (OBJ-1477)

Static render — no live update, no animation

Same constraint as the rest of the widget, not new for this phase: widgets are static timeline snapshots on both platforms. A same-day extension doesn't animate into view — the widget shows whichever state was current at its last refresh, per the existing update cadence (check-in / app-foreground push, Android's 30-minute floor, iOS's next-midnight timeline reload). There is deliberately no motion; both UI-SPECs call this out explicitly as honoring the anti-social-app discipline (no "keep checking back for the reveal" pull), and it trivially satisfies prefers-reduced-motion.

Anti-dark-pattern copy rationale

Both specs require gain-framed, zero-loss-aversion copy for the not-yet state — "Add today" / "Add today's stone" lead with the action verb and what the user gains, never what they stand to lose. Rejected alternatives are documented in both UI-SPECs specifically so this line stays bright for future edits: "Don't lose your streak", "Keep your streak alive", "1 day left to…", "Your tower will fall", "Don't let the stack crumble" (all loss-aversion) and "Amazing job!", "Great stacking!" (empty praise) — all out, consistent with the anti-social discipline already governing this feature.

Accessibility

Same never color-alone rule as the base widget, extended to three independent signals per reactive state: glyph shape (lit vs. muted flame or motif glyph, or solid vs. dashed stone), a caption glyph, and the caption text. The caption glyph itself is metaphor-dependent, on Android: flame uses a spark (extended) / open-ring (not-yet) text glyph (widget_caption_glyph_spark/_ring); stones and the three v4.52 motifs (sprout, mountain, waves) share the same ✦ spark text glyph at extended and the same dashed-slot icon (widget_caption_dashed_slot) at not-yet — HomeWidgetProvider.java's useGlyphIcon/resolveReactiveTreatment (MOTIF-SPEC-5). Plus the composed VoiceOver/TalkBack label, which appends a reactive suffix onto the streak phrase for all five motifs (Android: reactiveAccessibilitySuffix, HomeWidgetProvider.java:297-333; iOS: checkedInAccessibilitySuffix/notYetAccessibilitySuffix, HomeWidgetViews.swift:87-115), e.g. "...12 day streak, locked in for today..." / "...12 day streak, today's stone laid..." / "...12 day streak, today's leaf out...".

NEW_DAY state — midnight rollover (iOS + Android), OBJ-1350/OBJ-1360

extendedToday is only recomputed client-side when the app is foregrounded (the Vue watch in useWidgetSnapshot.ts); nothing re-derives it natively. Without a day-aware check, a late-night check-in could leave the widget showing "Locked in today" well into the next day, inside the 18-hour stale threshold's grace window (OBJ-1350).

Fix (iOS): resolveWidgetState(_ snapshot:, asOf:) (WidgetSnapshot.swift) takes an explicit asOf date instead of reading the clock directly — always the WidgetKit timeline entry's own date, never Date() (WidgetKit pre-renders future entries at generation time, so Date() would evaluate to build time, not display time). HomeWidget.swift's getTimeline emits a two-entry timeline ([now, nextMidnight], policy: .after(nextMidnight)) so the midnight boundary is crossed by a real timeline swap. When the reactive layer is on (extendedToday present) and asOf has crossed into a new calendar day since syncedAt, the resolver returns a new .newDay state — checked after STALE and before the habit-progress states (EMPTY/ALL_DONE/DEFAULT), so a widget that's both stale and into a new day still shows STALE, not NEW_DAY.

Fix (Android): HomeWidgetProvider.isSameLocalDay(Date, long) (HomeWidgetProvider.java) does a Calendar-based year + day-of-year comparison between syncedAt and the render-time clock, read at call time rather than cached. resolveState(snapshot, asOfMs) inserts the resulting NEW_DAY state into the same precedence slot as iOS — after STALE, before EMPTY/ALL_DONE/DEFAULT — so the stale-outranks-new-day rule holds identically on both platforms. Landed via PR #1465, reconciled onto master via PR #1480.

NEW_DAY suppresses habit rows/pips (not greyed — absent) on both sizes, keeps the streak digit at full strength, and shows its own copy: visual "New day — tap to see today's habits" (em dash), spoken "New day. Tap to see today's habits." (period — VoiceOver renders an em dash as an awkward pause or skips it, so the divergence from the visual copy is intentional).

Tests (iOS): WidgetSnapshotTests.swift asserts directly on resolveWidgetState(_:asOf:) with fixed Date instances (no simulator time-travel) — same-day, rollover inside the 18h STALE window, rollover across a timezone change, rollover across a US DST spring-forward, STALE-outranks-NEW_DAY beyond 18h, and fail-closed when extendedToday is nil. testAllWidgetStates (ImageRenderer) renders NEW_DAY for both sizes × both metaphors, in light and dark.

Tests (Android): HomeWidgetProviderTest asserts directly on isSameLocalDay/resolveState — rollover inside the 18h STALE window, rollover across a US DST spring-forward, and a traveler-changes-device-timezone-between-check-in-and-render case, alongside the existing Roborazzi snapshot harness (verifyRoborazziDebug) that renders NEW_DAY for both widget sizes, both themes.

Known follow-ups (not blocking this phase)

  • iOS is missing .containerBackground(for: .widget) on HomeWidgetEntryView (uses a plain .background() instead) — pre-existing, not a regression from this phase, but worth a follow-up for iOS 17+ host-chrome fidelity. Tracked as OBJ-1351.

Kindling refinement (v4.7)

UI-SPECs: Phase 2 — Small + Medium, Phase 3 — Large, Phase 4 — Lock Screen · ROADMAP: .planning/milestones/v4.7-kindling-widgets-ROADMAP.md · Source issue: OBJ-1516 (design direction 1d, "Kindling: feel it and do it") · This section covers the iOS (SwiftUI) build. Android parity — refined visuals, Coach line, and interactive check-off, minus the Lock Screen accessories — shipped in v4.11; see Android parity (v4.11) below.

v4.7 refines the widget's visual language (a stone-tower streak metaphor instead of the flat v4.2 flame/stones treatment), adds a full Large (.systemLarge) family and three Lock Screen accessory families, and — for the first time — makes the widget directly actionable: a tap can check off a habit without opening the app. Small/Medium/Large's refined treatment is gated behind kindling_widget_enabled (see Fail-closed / byte-identical off-state below, including a known exception for the Lock Screen accessories); the base v4.1 render and the v4.2 reactive layer above are unaffected when it's off.

Families

FamilySwiftUI typeAdds
Small (.systemSmall)SmallWidgetView.kindlingBodyStreak header + next-due habit + a full-width KindlingCheckButton CTA ("Feed today's fire" / "Lay today's stone")
Medium (.systemMedium)MediumWidgetView.kindlingBodyStreak header + Coach line + a short FrostedHabitRow checklist
Large (.systemLarge), new in v4.7LargeWidgetView.kindlingBodyTower/streak header + Coach line + full checklist (capped, see Large family below) + a closing motivator line
Lock Screen accessories, new in v4.7 (iOS 16+)CircularAccessoryView / RectangularAccessoryView / InlineAccessoryViewMonochrome-legible glance surfaces — see Lock Screen accessories below

HomeWidget.swift's supportedFamilies always declares [.systemSmall, .systemMedium, .systemLarge] and appends the three accessory families under if #available(iOS 16.0, *) — the families themselves are registered regardless of the flag; the flag only gates which treatment (kindling vs. legacy) each family's body renders (HomeWidget.swift:135-143).

Coach line

A single deterministic, state-keyed encouragement line, rendered on Medium and Large. Gated by reactive_streak_widget_enabled, not kindling_widget_enabled — it's computed and written alongside the rest of the same-day reactive layer fields, independently of whether the kindling visual treatment is on. Templates live client-side in useWidgetSnapshot.ts, not in Swift:

CoachLineStateTriggerLine
not-yet-new-dayNo due habit checked in yet"Add today's stone to keep the tower rising."
in-progressSome but not all due habits checked in, stones metaphor"One more and today's stone is set."
flame-supporterSame as above, flame metaphor (Supporter-only)"One more and today's fire is fed."
all-doneEvery due habit checked in"You showed up again. That's the whole game."

resolveCoachLineState(dueHabits, metaphor) (useWidgetSnapshot.ts) returns null (and the coachLine field is omitted from the snapshot entirely) when there are no due habits — the templates never fire for empty. On the native side, WidgetSnapshot.coachLine is an optional string (var coachLine: String? = nil, WidgetSnapshot.swift:37-40), never a fallback string, and both MediumWidgetView.kindlingCoachLine(reactive:) and LargeWidgetView.largeCoachLine() read snapshot?.coachLine ?? reactive.captionText — the deterministic Coach line wins when present, falling back to the existing reactive-layer caption otherwise. Both are suppressed entirely on .stale (last-known due-habit progress can't be asserted as current). Medium clamps to 2 lines, Large to 1.

Large family (OBJ-1521)

LargeWidgetView.kindlingBody composes, top to bottom (HomeWidgetViews.swift:1360-1500):

  1. largeHeader — the tower/streak glyph, streak number, and "DAY STREAK · LONGEST N".
  2. largeCoachLine() — see above, 1-line clamp.
  3. A habit checklistlargeChecklist (default/in-progress) or largeAllDoneChecklist (all-done), each capped at 4 visible rows. largeVisibleWindow keeps the active (next-due) row visible even when it would otherwise fall past the 4th slot, sliding the window forward so it lands last rather than being dropped; any remainder renders as "+N more in the app" (or "+N more done" on all-done). This cap was a Phase 3 scope decision (Josh, 2026-07-20) so the closing motivator line is never pushed out of the card by a long habit list.
  4. A closing motivator line — a file-scope free function, largeMotivatorText(state:metaphor:flame:doneCount:dueCount:) (HomeWidgetViews.swift), state- and metaphor-aware across all five streak motifs, e.g. "Set the first stone — the tower starts today." / "Lay the last stone to keep the tower rising." / "Today's stone laid. Rest easy.". nil on .stale/.firstRun — those states have no motivator, .stale's own sync-footer line takes its place. LargeWidgetView.motivatorText is a thin forwarder onto it, matching the file's resolveReactiveTreatment/inlineAccessoryText convention of keeping cross-cutting copy logic in a directly-unit-testable free function. The .allDone case is a three-branch table — confirmed, pending, and baseline (fail-closed/flag-absent) — switched directly on StreakReactiveState; see Baseline is its own branch (OBJ-3317, iOS only) above for the ruling and why baseline no longer folds into confirmed. This closed the one place iOS previously trailed Android on motif coverage: before OBJ-3246/OBJ-3302, the .allDone branch was flame/stones-only (sprout/mountain/waves fell through to the stone copy) while Android's equivalent (motivatorStringRes/motivatorSentence, HomeWidgetProvider.java:1662-1786) had already gone five-way in this milestone's Phase 2 (OBJ-3252); the other four states were already five-way. Tests: WidgetCopyTests.swift.

VoiceOver: composedAccessibilityLabel was extended (Roy MAJOR finding, Phase 3 review) to take visibleHabits and cap the spoken habit list to the same visible window as the visual checklist, appending "N more habits in the app." rather than reading the full uncapped list. The motivator sentence in that same label is gated on isKindlingEnabled — see Baseline is its own branch (OBJ-3317, iOS only) above (Q4) for why render and speech must share one condition.

Lock Screen accessories (OBJ-1521)

Three iOS 16+ WidgetFamily accessory views, all monochrome-legible (no color-alone cues — Lock Screen accessories render in a single system tint, same constraint as tinted Home Screen mode):

AccessoryViewRenders
.accessoryCircularCircularAccessoryViewAccessoryWidgetBackground() + a ring: Gauge(...).gaugeStyle(.accessoryCircularCapacity) with the streak number as the center label and done/total as the gauge value on .defaultProgress/.allDone; a state-specific bottom glyph (moon / sunrise / clockwise-arrow / checkmark / streak-metaphor glyph)
.accessoryRectangularRectangularAccessoryViewA leading mini-ring or glyph (leadingMark) + up to 3 text lines via ViewThatFits — streak-day line, done/total or state line, and a next-habit or motivator line
.accessoryInlineInlineAccessoryViewA single Label(text, systemImage:) — e.g. "12 · 2 of 4 · Meditate left" — built by the free functions inlineAccessoryText(state:snapshot:asOf:) and inlineAccessorySymbolName(state:snapshot:) (kept as free functions specifically so the exact grammar is unit-testable)

RectangularAccessoryView.line3's .allDone copy contract (OBJ-3302)

line3 (RectangularAccessoryView's third ViewThatFits text line, dropped entirely at tight heights) is state-driven; on .allDone it defers to the free function accessoryAllDoneLine3(metaphor:flame:), a 5-motif × 3-branch table (confirmed / pending / baseline) switched directly on StreakReactiveState — see Baseline is its own branch (OBJ-3317, iOS only) above. This site was the worst of the six OBJ-3317 line locations: before that ruling it never read extendedToday at all, so it asserted the confirmed string unconditionally in the confirmed, pending, and baseline cases alike, while line1's leading glyph one line above it (metaphorGlyphName(_:filled:)) reacted to the real confirmation state independently — the two could visibly contradict each other. Baseline now returns its own motif-neutral That's the day (no "Rest easy" clause — the accessory's tighter budget), never the confirmed string.

MotifConfirmedPendingPending length
Stones"Every stone laid""Today's build"13
Flame"Every log fed""Today's burn"12
Sprout"Every leaf out""Today's growth"14
Mountain"Every step up""Today's climb"13
Waves"Every wave in""Today's tide"12

Baseline (fail-closed/flag-absent, reactive.flame == .baseline) is a single motif-neutral string across all five motifs — That's the day — not shown per-motif in the table above since it doesn't vary; see Baseline is its own branch (OBJ-3317, iOS only) above. Confirmed strings are byte-identical to what shipped before this fix. Pending strings are new, hold to a 16-character ceiling (.lineLimit(1), .truncationMode(.tail)), and deliberately avoid the caption's extension participle (laid/fed/out/up/in) — see the governing ruling above. Known gap, not fixed here: metaphorGlyphName(.waves, filled:) returns the same glyph (water.waves) for both confirmed and pending — SF Symbols has no fill variant — so a Waves user gets no glyph-level not-yet cue on this surface, and line3 itself can be dropped by ViewThatFits at tight heights; Table C narrows this gap (a pending-copy cue now exists when line3 renders) but doesn't close it. Full spec: docs/ui-specs/obj-3244-widget-metaphor-copy-tables.md. Tests: WidgetCopyTests.swift.

Fail-closed / byte-identical off-state

kindling_widget_enabled off means Small/Medium/Large each render their pre-existing legacyBody — byte-identical to the pre-v4.7 render, verified by CI snapshot coverage rather than a visual eyeball (this was Roy's round-1 MAJOR finding on Phase 2, PR #1613, before it shipped). The flag is read per-widget-render, not cached: snapshot.kindlingEnabled on the decoded snapshot is authoritative when a snapshot exists; only for .firstRun (no snapshot yet) does the code fall back to a standalone App Group key (widget_kindling_enabled, written by useWidgetSnapshot.ts on every sync independently of the JSON blob, so a first-run device still knows the flag state — WidgetSnapshot.swift:5-10, loadWidgetKindlingEnabled()). Both reads default to false on any failure (no App Group, key never written), so an unreadable state renders the legacy body, never a kindling one.

Known exception — Lock Screen accessories don't check kindling_widget_enabled at all. See Lock Screen accessories don't read kindling_widget_enabled under Feature flag below for the full explanation; tracked as OBJ-1580 (non-blocking, milestone-level follow-up) — also see Known open items below.

Interactive check-off (v4.7)

See Interactive check-off below for the cross-platform summary and history.

The Small CTA button and each active habit row in Medium/Large now check off a habit directly from the widget — no app launch — when kindling_widget_enabled is on, the habit has a stable id, and the device runs iOS 17+. Below iOS 17, or when the habit id is missing (older cached snapshot, or a goal without a publicId), the same tap surface falls back to the pre-v4.7 Link deep-link into the dashboard — fail-closed to read-only, never a crash.

The write path, widget process → server:

  1. CheckInHabitIntent (CheckInHabitIntent.swift) — an AppIntent (openAppWhenRun = false, so the tap stays on the Home/Lock Screen) taking one parameter, habitId: String. It lives inside the existing HomeWidgetExtension target/bundle ID — no new App ID, no new App Group, no signing change (confirmed by a Phase 5 spike before the full build started). Button(intent: CheckInHabitIntent(habitId:)) wires it into FrostedHabitRow.activeRowTapSurface (Medium/Large habit rows) and KindlingCheckButton.tapSurface (Small's CTA).
  2. perform() (CheckInHabitIntent.swift:27-39) optimistically marks the habit checked-in in the locally cached WidgetSnapshot (checkingIn(habitId:), a no-op if the habit is already checked in or unknown) and, only if that write succeeds, appends a PendingCheckin { goalId, localDate } to a separate App Group key and calls WidgetCenter.shared.reloadTimelines(ofKind: "HomeWidget") to redraw immediately. Both writes are all-or-nothing off one successful checkingIn transform, so the pending queue and the rendered snapshot can never disagree.
  3. App Group storageUserDefaults(suiteName: "group.com.objectuve.ionic"), the same suite the base widget already uses for the snapshot blob (widget_snapshot), plus one new write-only key: widget_pending_checkins (homeWidgetPendingCheckinsKey). The app never writes this key — it's the AppIntent's outbox — only reads and clears it, via the native plugin's drain method below.
  4. WidgetBridgePlugin.drainPendingCheckins (ionic_frontend/ios/App/App/WidgetBridgePlugin.swift, the plugin's second exposed method alongside the pre-existing updateSnapshot) reads widget_pending_checkins, decodes it, then immediately clears the key, resolving the JS call with the drained list — a destructive read, so a given pending check-in is returned exactly once to JS. iOS and Android — the Android plugin implements the same method against a SharedPreferences-backed queue instead of an App Group; see Android parity (v4.11) § The check-off write path below for the platform-specific storage and locking.
  5. useWidgetCheckinDrain.ts (mounted once in App.vue, distinct from useWidgetSnapshot's one-way write composable) calls drainPendingCheckins() on app mount and on every Capacitor resume event, guarded to platform !== 'ios' && platform !== 'android' returning early — i.e. it runs on both platforms (ANDROID-INTERACT-4, v4.11 Phase 4). Each drained {goalId, localDate} is enqueued into the existing offline sync store (syncStore.ts) under a deterministic id (`widget-checkin-${goalId}-${localDate}`) using the checkInHabit mutation name — the same queue and the same CHECK_IN_HABIT_MUTATION (goals.js:624) that any other offline-queued in-app check-in uses. No new mutation, no new call site distinct from the app's existing offline-sync mechanism.
  6. Reconciliation replay happens generically through syncStore.processQueue(), the same replay path as any other queued mutation — no widget-specific server code.

Exactly-once, end to end. Because the App Group's destructive clear (step 4) happens before the item is durably queued in JS, a naive implementation has a data-loss window: a process kill between the clear and the durable write would drop the check-in permanently. This is closed with an explicitly awaited flush, not the store's normal fire-and-forget persistence: useWidgetCheckinDrain's drain() calls await syncStore.flush() after enqueueing, and flush() only persists once syncStore's internal hydrated flag is true (set once the queue has loaded from disk on store init) — this is the hydrated-guarded step in the reconciliation path. Layered with it: (a) native write-time dedup by goalId+local date (appendPendingCheckin, refuses a second entry for the same habit/day), (b) the deterministic enqueue id, deduped against the existing queue in syncStore.enqueue, and (c) the server's own per-day idempotency (GoalTracking::CheckInHabit returns the existing completion rather than creating a second one for the same goal/date). A queue-twice/replay-twice sequence reduces to a single persisted check-in even before the server backstop.

Credited to the day tapped, not the day it syncs (OBJ-1858). Step 5's enqueue payload now includes completedDate: localDate — the day the widget tap actually happened, captured by the native queue — threaded straight through to checkInHabit's completedDate argument (rails_api/app/graphql/mutations/check_in_habit.rb). The server bounds completedDate to the last 7 days in the user's timezone (widened from today-or-yesterday in v4.58 Phase 1, PR #3163, OBJ-3740 — see Offline Contract § Idempotency); anything outside that window is rejected rather than silently credited to the wrong day. Before this fix, the enqueued payload carried only goalId, so a tap synced after midnight — or after a multi-day gap before the next app open — was credited to whichever day the sync happened to land on, risking a lost streak day for the exact scenario this widget exists to prevent. A second, previously-silent failure mode is closed alongside it: syncStore.tryReplay (ionic_frontend/src/stores/syncStore.ts:91-107) now inspects the mutation response's own errors array, since a rejected completedDate comes back as a normal GraphQL 200 with errors populated, not a thrown exception. A populated errors array is now treated as a permanent_error and reported to Sentry, instead of the prior behavior of reading the response as 'success' and dropping the check-in from the queue with no record and no signal. A tap stale enough to fall outside the 7-day window still fails to sync — but visibly now, not invisibly.

Known coverage gap (carried forward, not resolved by Phase 6): the live tap → cold-kill → foreground device repro has not been run on a physical device (no iOS device/simulator available in the phases' run environments) — verified instead by code-path re-derivation and CI. Treat this as an open item before kindling_widget_enabled rolls above 0% — see Known open items below.

Background check-in delivery (v4.22)

ROADMAP: .planning/milestones/v4.22-widget-background-checkin-sync-ROADMAP.md · Parent: OBJ-1860 · PRs: #1855 (OBJ-1862, CheckinToken model + device-scoped auth resolution), #1857 (OBJ-1863, app-side mint/rotate/revoke bridge, both platforms), #1859 (OBJ-1864, Android WorkManager delivery), #1863 (OBJ-1865, iOS BGTaskScheduler delivery parity), #1864 (OBJ-1865, on-device verification hardening) Flag: widget_background_sync_enabled (PostHog, server-side only — gates whether a device is ever issued a check-in token; see Feature flag below)

Extends Interactive check-off above: that section's offline sync queue only replays once the user reopens the app. v4.22 closes the "user never reopens the app" gap — each platform now attempts real delivery to the GraphQL API without the app ever being foregrounded, using a dedicated per-device credential instead of the user's Clerk session. Full credential lifecycle (mint/rotate/revoke): Check-in token authentication.

Two delivery paths, one queue

Both platforms attempt delivery through the same pending-checkin queue the tap-time write already populates (App Group on iOS, SharedPreferences on Android — see Interactive check-off), via two independent triggers:

PathiOSAndroidRole
Tap-time-primaryCheckInHabitIntent.perform() (CheckInHabitIntent.swift) posts inside the AppIntent's own live async execution window, immediately after the local optimistic writeHomeWidgetProvider.onReceive enqueues an expedited one-time CheckinDeliveryWorker (enqueueExpeditedIfEligible, OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)The fast path — fires the moment the user taps, whether or not the app is open
Scheduler-retryBGTaskScheduler-registered CheckinDeliveryTask (identifier com.objectuve.ionic.checkin-delivery-retry, a BGProcessingTaskRequest requiring network connectivity, re-armed on every app-background transition and every successful token push, earliestBeginDate 15 min out)WorkManager periodic CheckinDeliveryWorker (checkin_delivery_retry, ExistingPeriodicWorkPolicy.KEEP, 15-minute floor — PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS), re-enqueued on every widget onUpdate tickBackstop only — retries whatever the tap-time path didn't clear (offline at tap, transient error)

Both paths call the same drain logic against the same queue, so a queued entry is delivered exactly once regardless of which path wins the race — see Idempotency below. iOS's BGTaskScheduler is deliberately retry-only, never primary: the OS is free to defer or skip a background task for an app the user doesn't open, so the tap-time path — which runs inside a guaranteed live process window (the widget-extension process, by construction of iOS's App Extension architecture) — carries the real reliability. This was Orion's spike-gate requirement going into Phase 5.

The write path, both platforms

  1. Widget tap → optimistic local check-off in the cached snapshot, then a durable append to the pending-checkin queue ({goalId, localDate}), keyed by goalId+local date so a duplicate tap on the same day is a no-op at write time.
  2. The tap-time path attempts immediate delivery; the scheduler-retry path attempts delivery on its own cadence — both read the queue, POST each entry, and on success remove only the entries actually sent (never a blanket clear), so an entry appended mid-drain survives.
  3. Each POST targets GRAPHQL_BASE_URL (already includes the /graphql path — see GraphQL endpoint resolution below) with header SessionToken: CheckinToken <deviceId>:<token> and the same checkInHabit mutation (goalId, completedDate) any other offline-queued check-in uses — no separate background-only mutation.
  4. Response handling is an identical, test-for-test-ported contract on both platforms (CheckinApiResponseTests.swift/CheckinApiClientTest.java, CheckinDeliveryTaskTests.swift/CheckinDeliveryWorkerTest.java):
    • Network/transient failure → leave queued, retried on the next drain.
    • Response's top-level errors[].extensions.code == "UNAUTHORIZED" (the server's signal for an invalid/expired/revoked token) → hard stop-and-clear: wipe the locally cached token, stop attempting further entries in this drain, leave everything queued for the next mint. Never retried with a known-dead credential (carried forward from the Phase 3 offline-revoke lesson).
    • Mutation-payload business failure (e.g. completedDate outside the 7-day window) → leave that entry queued, don't touch the token, continue to the next entry.
    • Success → remove the entry from the queue; if the response carries a rotated token, write it back to local secure storage and use it for the rest of the drain (rotate-on-use — see Check-in token authentication).

GraphQL endpoint resolution (native has no import.meta.env)

Neither native platform can read the frontend's Vite env vars, so each resolves the endpoint independently at build time: iOS via ionic_frontend/ios/graphql.xcconfig (GRAPHQL_BASE_URL, overridable per build via GRAPHQL_BASE_URL_OVERRIDE, defaulting to https://api.objectuve.com/graphql), Android via ionic_frontend/android/app/build.gradle's GRAPHQL_BASE_URL buildConfigField, sourced from System.getenv("ANDROID_GRAPHQL_BASE_URL") with the same production default. Neither override is wired into a CI workflow yet — every CI-built mobile artifact points at production regardless of build environment unless someone sets the var by hand. Build-side detail: Mobile Builds → Background check-in delivery build setup.

Idempotency (background delivery)

Three independent layers, the same shape as the app's own offline check-off path: (1) native write-time dedup by goalId+local date at queue-append; (2) remove-only-sent (never index/overwrite) on drain, so a concurrent append mid-drain survives; (3) the server's own per-day idempotency in GoalTracking::CheckInHabit — a repeat POST for an already-recorded (goal, completed_date) returns the existing completion rather than creating a second one. No new dedup authority was added for background delivery — it reuses the same server backstop the tap-time-primary write path already relies on.

Feature flag (background delivery)

widget_background_sync_enabled (PostHog, evaluated server-side only, inside the token-mint interaction) gates whether a device is ever issued a check-in token in the first place. Neither platform's native code reads the flag directly; both treat token absence as the fail-closed signal (WidgetBridgePlugin.hasCheckinToken on Android, CheckinDeliveryTask.shouldAttemptDelivery(token:deviceId:) on iOS — both require token and device id present before enqueueing any work). Flag-off is therefore byte-identical to pre-v4.22 behavior on both platforms: no token minted, no WorkManager/BGTaskScheduler registration, tap-time delivery silently no-ops and the entry just waits for the existing foreground sync-queue drain covered under Interactive check-off.

Known open item — iOS scheduler-retry live-fire proof (accepted risk, shipped)

Not verified pre-launch: whether BGTaskScheduler actually invokes the registered retry handler while the app is fully closed. Phase 5's on-device verification pass confirmed this conclusively on Android — a real Pixel_9_Pro_XL emulator, am force-stop full app kill, then dumpsys jobscheduler + logcat showing CheckinDeliveryWorker genuinely executing via the real OS JobScheduler after the kill — but could not complete the iOS equivalent, across two separate verification sessions, and no physical iOS device was available to the crew in either.

The first session's BGTaskScheduler._simulateLaunchForTaskWithIdentifier: probe (via lldb process attach, Apple's own documented technique) repeatedly hung the Simulator/lldb tooling. A second session, on a fresh Simulator instance, root-caused that hang (process continue blocks lldb's batch mode indefinitely — detach fixed it) and made further progress: CheckinDeliveryTask.register() was confirmed firing at launch, and a real background transition was confirmed to genuinely call BGTaskScheduler.shared.submit(_:) successfully (correct identifier, requiresNetworkConnectivity, and ~15-minute earliestBeginDate, no error) — i.e. our scheduling code is verified correct through successful submission. What remained unprovable: _simulateLaunchForTaskWithIdentifier:, called immediately after that confirmed-successful submission, still reported "No task request... has been scheduled," reproduced 3× across separate process instances — a specific, well-evidenced disconnect between Apple's private debug-simulate API and the real submission queue on that Xcode/Simulator build, not a defect in this repo's code.

The two delivery paths carry different risk here: tap-time-primary's "runs without the host app" property is a structural guarantee of iOS's own App Extension process sandboxing (independently corroborated — the extension target builds and its tests pass independent of the App target), so it doesn't carry the same open question. The scheduler-retry path's actual OS-invocation behavior does, since BGTaskScheduler firing is opportunistic and OS-discretion-driven by design — precisely why it needs a live-fire proof tap-time-primary doesn't.

Accepted as a known, monitored risk — Josh, 2026-07-29. Given this environment's tooling ceiling (no physical device, a Simulator/Xcode debug-API disconnect that further diagnosis couldn't close) and that the actual delivery code is verified correct through submission, the milestone shipped without a live-fire proof rather than blocking indefinitely on unavailable hardware. Real-world scheduler-retry reliability is to be monitored via production telemetry post-launch instead of pre-verified; a delivery-success/failure telemetry signal (PostHog/Sentry breadcrumb in CheckinDeliveryTaskScheduling.performDelivery() / Android's CheckinDeliveryWorker.doWork()) is a recommended, not-yet-implemented fast-follow to make that monitoring concrete.

Left-column height budget (Android)

UI-SPEC: Amendment A (OBJ-1372)

The Android medium and small widgets' left column (flame, streak number, DAY STREAK, the reactive caption row, Longest N) has no headroom to spare against the declared resize bounds. Before this amendment nobody had written the budget down, so the column silently outgrew the widget — the reactive caption row (above) made the overflow visible, but it wasn't the cause. Read this before adding a row to that column.

The real content box is smaller than the declared bounds

targetSdk 36 means the host applies an 8dp system widget margin on every side (16dp total) on top of the root LinearLayout's own 24dp of padding — the declared maxResizeHeight/minResizeHeight in home_widget_info.xml is not the space the layout actually gets:

contentBox(h)    = h − 16 (system widget margin) − 24 (root padding) − 6 (safety margin)
mainRowBudget(h) = contentBox(h) − staleFooter   // staleFooter is 0 unless the STALE state is showing

The 6dp safety margin absorbs OEM font-metric drift and covers the one real unknown — whether a given launcher reports the granted-height option (OPTION_APPWIDGET_MAX_HEIGHT in portrait, OPTION_APPWIDGET_MIN_HEIGHT in landscape — see OBJ-1631) inclusive or exclusive of the system margin. It is subtracted unconditionally: the formula only ever errs toward hiding a row, never toward clipping one.

Text height also isn't sp × 1.2 — Android's default TextView metrics (includeFontPadding on) give a Roboto line box of sp × 1.33. Both constants are load-bearing: budgeting against the declared bounds at 1.2× line height is exactly what produced the original bug.

Constants live in HomeWidgetProvider.java (SYSTEM_WIDGET_MARGIN_DP, ROOT_PADDING_DP, SAFETY_MARGIN_DP, ROBOTO_LINE_BOX), originally calibrated against a real on-device capture (OBJ-1359) whose BEFORE clip edge lands at 135.0dp. The one-off calibration bench used during that phase has since been pruned along with the rest of the closed phase's scratch tooling; the live regression gate that now enforces these constants on every PR is the pixel-diff harness described in Widget Snapshot Evidence (CI).

The always-visible core

Core = head row (flame + numeral) + reactive caption row. Nothing else is guaranteed to render.

fontScale 1.0fontScale 1.3
Core63dp71dp
Core, STALE (caption suppressed, footer reserved instead)72dp80dp

Worst case (80dp) fits inside the 130dp floor's 84dp content box, with 4dp to spare. The streak numeral itself is never hidden and never shrinks below today's size — it scales sub-linearly (34sp at fontScale ≤ 1.15, 28sp above, which still renders at an effective ≥34sp).

Shedding order as the budget shrinks

Everything past the core is negotiable, in this priority order:

  1. Longest N — historical, not actionable. Goes first.
  2. DAY STREAK — redundant once the reactive caption is showing ("Add today" / "Locked in today" already frames the number as a streak).
  3. Habit rows, dropped from the bottom, most-recent-first.

The threshold

Longest N shows at a granted height of ≥147dp (fontScale 1.0, caption visible, non-STALE) — but that's a derived number, not the rule. The rule is the budget comparison in HomeWidgetProvider.planWidget(...), because the answer moves with font scale and state:

DAY STREAK shows atLongest N shows at
fontScale 1.0 · caption on≥124dp≥147dp
fontScale 1.15 · caption on≥135dp≥160dp
fontScale 1.3 · caption on≥137dp≥165dp
fontScale 1.0 · STALE≥133dp≥156dp
fontScale 1.3 · STALE≥146dp≥174dp

Code the comparison, never hardcode a constant against a specific height.

home_widget_info.xml bounds

AttributeValueWhy
minWidth / minResizeWidth110dpUnchanged — width was never the problem.
minHeight / minResizeHeight130dp (was 110dp)110dp yields a 70dp content box; the STALE core needs 80dp at fontScale 1.3. Not survivable for a layout that keeps a flame, a number, and a status line.
maxResizeWidth250dpUnchanged.
maxResizeHeight180dp (was 155dp)At 155dp, a fontScale 1.3 user can never see Longest N (needs 165dp). 180dp is a permission, not a default.

Raising minHeight to 130dp changes the widget's default home-screen footprint: on pre-Android-12 launchers the legacy cell formula (ceil((size + 30) / 70)) takes it from 2 rows to 3; Android 12+ launchers grid against real dp and typically stay at 2 rows. Existing placed instances may be re-sized by the launcher on next redraw.

Small widget

The small widget shares the same provider and declared bounds, so it isn't exempt even though it has no Longest N line — its DEFAULT stack alone needs 93dp at fontScale 1.3 against an 84dp budget at the 130dp floor. Same mechanism, no new layout structure:

  • Numeral steps 22sp → 18sp above fontScale 1.15 (same sub-linear rule as the medium widget).
  • The 4-pip progress row (@+id/pip_row in home_widget_small.xml) drops when the budget is tight — it's redundant with the "N of M done" text directly above it.

Adding an element to this column? Check the budget first

The left column (and the small widget's stack) has no slack at the floor of the declared range. Before adding a new row, label, or icon to home_widget_medium.xml or home_widget_small.xml:

  1. Add its dp cost to HomeWidgetProvider.planWidget(...) / planSmallWidget(...) — do not assume it fits because it "looks small."
  2. Re-run the band table across {130, 155, 180}dp × {caption on/off} × {fontScale 1.0, 1.3} × all 5 states, and place the new element in the shedding-priority list above (or extend it) — nothing renders unconditionally in this column anymore.
  3. Run the committed regression gate before opening a PR — cd ionic_frontend/android && ./gradlew :app:verifyRoborazziDebug — which diffs every widget render against the committed goldens and fails on any clipped cell. See Widget Snapshot Evidence (CI) for the full harness (this superseded the OBJ-1373 design bench once the persistent render/snapshot regression gate landed) and how to re-record goldens after an intentional change.
  4. contentDescription must stay identical across every height band regardless of what's visually hidden — the budget governs pixels, not the accessibility label.

A pre-existing, unrelated bug surfaced by this same audit: main_row used to be wrap_content, so LinearLayout measured it first and handed the STALE footer caption ("Today · last synced Xh ago") zero remaining height — it silently rendered at 0dp in every state. Fixed alongside the height budget by giving main_row layout_height="0dp" + layout_weight="1", so the footer reserves its height first.

Width budget — small widget header row (Android)

UI-SPEC: Amendment B (OBJ-1374) + its NEW_DAY addendum (OBJ-1382)

The small widget's header_row (flame_containerstreak_number → the days label) is a wrap_content LinearLayout whose last child had no maxLines — at the declared 110dp minWidth, a 2-digit streak overflowed the row and the days label absorbed the deficit by breaking one character per line (d/a/y/s). The height budget above assumes header_row renders as exactly one line; a wrapped label silently invalidated that budget too — the two axes are coupled, which is why this had been sitting as an open item until now.

The box model

Same host constraints as the height axis, applied to width instead — targetSdk 36's 8dp/side system widget margin plus the root LinearLayout's 12dp/side padding:

contentWidth(w) = w − 16 (system widget margin) − 24 (root padding) = w − 40

110dp → 70dp   130dp → 90dp   180dp → 140dp   250dp → 210dp

No safety constant on this axis — width demand is a measured Roboto advance (real Roboto, canvas advance; at fontScale fs a string at N sp renders at exactly N × fs px, so a browser advance in px is the dp demand), not an estimate. The safety instead lives in the shed order: every gate errs toward hiding an element, never toward clipping the numeral.

ElementfontScale 1.01.32.0
flame_container282828 (fixed)
days label (11sp)233046
numeral, 1 digit131421
numeral, 2 digits262742
numeral, 3 digits384162

Shed order, widest-first

days label → flame container. The streak numeral never sheds and never wraps.

coreW          = 28 (flame) + 4 + numeralWidthDp(digits, fs)
showsFlame     = coreW ≤ contentWidth
showsDaysLabel = showsFlame ∧ coreW + 4 + daysLabelWidthDp(fs) + 6 (headroom) ≤ contentWidth

The days label sheds first because it's the only genuinely redundant element in the row — contentDescription still announces "12 days" verbatim regardless, and the flame + numeral already read as a streak. The flame sheds last, not never: a 3-digit streak at fontScale ≥ 1.3 leaves 28 + 4 + 41 = 73dp against a 70dp box at the 110dp floor — between "no flame" and "a clipped number," the number wins. This fires only in the narrow-and-large-font-and-3-digit corner of the matrix.

The 6dp headroom on showsDaysLabel exists so its visibility isn't decided by a 1dp coin-flip against font-metric drift — without it, 130dp/fontScale 1.0/2-digit resolves 85-vs-90dp and could flip either way on OEM rendering variance.

Constants live in HomeWidgetProvider.java (DAYS_LABEL_HEADROOM_DP, DAYS_LABEL_WIDTH_DP, NUMERAL_WIDTH_DP, FONT_SCALE_BAND_MEDIUM/_LARGE), originally calibrated against 3 known renders (an on-device capture and two Paparazzi goldens). The one-off calibration bench used during that phase has since been pruned along with the rest of the closed phase's scratch tooling; see Widget Snapshot Evidence (CI) for the live regression gate that now enforces these constants on every PR.

The threshold — band table

showsFlame/showsDaysLabel are a budget comparison in HomeWidgetProvider.planSmallWidget(...), not a fixed dp constant — the answer moves with granted width, font scale, and streak digit count:

wboxfontScaledigitsshowsFlameshowsDaysLabel
110dp70dp1.0–1.31–2shownhidden
110dp70dp1.33shedhidden
110dp70dp2.02–3shedhidden
130dp90dp1.0–1.31shownshown
130dp90dp2.03shedhidden
180dp140dp1.0–1.31–3shownshown
180dp140dp2.01–2shownshown
250dp210dp1.0–2.01–3shownshown

No cell wraps, in any band. The days label is a progressive enhancement — absent at the 110dp resize floor, present at every realistically-placed widget (a 2-column home screen slot is granted ~160–200dp, not 110dp). Full band table (every width × fontScale × digit-count combination): Amendment B § Band table.

Structural floor — an ellipsis, never a character stack

Every single-line TextView in home_widget_small.xml (days_label, reactive_caption_text, progress_default, progress_stale, stale_caption_small) gets android:maxLines="1" + android:ellipsize="end". This is the safety net, not the mechanism — the budget comparisons above are the fix; the floor guarantees a wrong gate degrades to an ellipsis rather than to a vertical stack of letters.

0 of 4 done (the progress text) doesn't fit the 70dp box at fontScale 2.0 even as a single line, and 0 of 4 … reads as worse than useless. HomeWidgetProvider.usesCompactProgress(...) swaps it for 0/4 when the measured advance exceeds contentWidth — a fallback, not a truncation, and it costs no new string resource (built by string concatenation in buildSmallViews). contentDescription keeps the full 0 of 4 done regardless of which form renders.

The NEW_DAY state's own overflow — a height bug in width-bug clothing (OBJ-1382)

state_new_day isn't in Amendment B's maxLines="1" structural floor — it's a sentence ("New day"), not a label, so New d… would be a broken render, not a graceful truncation. It gets android:maxLines="2" instead. It was a genuine miss from Amendment B's original enumeration despite already being in the layout, and once measured it turned out to be a height-axis failure: state_new_day's TextView is already width-bounded (layout_width="0dp" + layout_weight="1"), so it word-wraps rather than character-wraps, then clips off the card bottom because the reactive caption row (Add a stone / Add today) renders alongside it and leaves zero remaining vertical budget at fontScale 2.0.

The ruling: in NEW_DAY, state_new_day is the status line, so the reactive caption row sheds unconditionally — not budget-gated, so the widget can't gain or lose a row as the user drags it. NEW_DAY forces extendedToday → false because dueToday is unknowable after a rollover, so the caption line is an assertion built from a forced default; state_new_day is the only line that states what's actually known, and shedding it frees the budget state_new_day needs. state_new_day's gravity flipped bottomtop to match — the caption row it displaces used to anchor to the bottom of its parent, and a bottom-anchored line would otherwise strand itself under a lot of empty space once the caption is gone.

Fix — microcopy, cheaper than layout. widget_new_day_habits shortened from "New day — tap to see today's habits" (up to 349dp of advance) to "New day" (42–84dp) — the whole widget is one tap target, so the sentence survives verbatim in contentDescription, and "today" was redundant in a state definitionally about today. Shed order mirrors the header row — icon, then text, then (as the fallback) the reactive caption row:

maxNewDayLines  = min(2, floor((budget(h) − head(fs) − 6) / lineBox(11, fs)))
textBox(w, icon) = contentWidth(w) − (icon ? 17 : 0)     // 13dp icon + 4dp gap

showsNewDayIcon = wraps("New day", textBox(w, true),  fs) ≤ maxNewDayLines
showsNewDayLine = showsNewDayIcon ∨ wraps("New day", textBox(w, false), fs) ≤ maxNewDayLines
showsCaptionRow = ¬showsNewDayLine

Constants: NEW_DAY_SMALL_TOP_MARGIN_DP (6), NEW_DAY_SMALL_ICON_AND_GAP_DP (17), NEW_DAY_LINE_WIDTH_DP. composedAccessibilityLabel's NEW_DAY phrase is a hardcoded literal ("New day. Tap to see today's habits.") that never reads @string/widget_new_day_habits, so parity between the spoken and rendered strings held by construction — locked with a dedicated JVM test rather than left to discipline.

Known limitation (inherited from Amendment B, not new): at 110dp × fontScale 2.0, below 160dp of granted height, New day needs 2 lines (84dp of advance against a 70dp box) but the budget only grants 1 — the reactive caption row's already-accepted Add to… ellipsis renders instead. Reachable only by manually resizing the widget to its floor and running double-size system font.

home_widget_info.xml — unchanged

Amendment B explicitly keeps minWidth/minResizeWidth at 110dp and maxResizeWidth at 250dp. Android's legacy cell formula (ceil((size + 30) / 70)) puts 110dp at the exact top of the 2-column band — any width increase pushes the small widget to 3 columns and re-flows every already-placed instance, a worse trade than shedding a 28dp flame in one narrow-and-large-font-and-3-digit corner cell. Rollback for this fix is code-only.

Microcopy shortened alongside the width fix

The reactive-layer captions also overflowed the same box at the narrow widths (a caption sits directly under the numeral it now shares a row budget with) — shortened rather than reflowed, since the reactive layer is same-day by definition and "today" was redundant:

StringWasNow
widget_caption_locked_in"Locked in today""Locked in"
widget_caption_stone_laid"Today's stone laid""Stone laid"
widget_caption_add_stone"Add today's stone""Add a stone"
widget_new_day_habits"New day — tap to see today's habits""New day"

widget_caption_add_today ("Add today") is unchanged — already short enough. widget_new_day_habits is shared with the medium widget's layout, so the shortening fixes a latent (unreported) clip there too at fontScale 2.0 — one state, one string, not forked per size.

Android parity (v4.11)

Milestone: v4.11 Kindling Widgets: Android Parity — narrative · .planning/milestones/v4.11-kindling-widgets-android-parity-ROADMAP.md. Merged to master via #1687 (OBJ-1622) — staging-live, kindling_widget_enabled still 0% rollout on both platforms. PRs: #1658 (Coach line, OBJ-1589), #1671 (interactive check-off, OBJ-1592), #1673 (kindling visual refinement, Small + Medium, OBJ-1590), #1684 (Large-equivalent breakpoint, OBJ-1591).

v4.11 ports the v4.7 kindling refinement (stone-tower streak block, earned Coach line, tinted habit rows) to Android, plus the click-check-off write path — bringing the two platforms to parity on the refined widget. All of it lands inside the existing HomeWidgetProvider/WidgetBridgePlugin classes; no new widget target, no new App Widget provider, no new layout family.

Why RemoteViews, still

The kindling port is a refinement of the incumbent widget, not a rewrite onto Jetpack Compose/Glance. HomeWidgetProvider was already RemoteViews-based since v4.1 — chosen over Glance specifically to avoid pulling the Compose runtime into the app for a widget (see Mobile Builds § Android native widget/plugin code is Java, not Kotlin) — and v4.11 stays inside that same architecture: new KINDLING_* layout constants, new drawables, and the new WidgetSize.LARGE branch are all additive to the existing RemoteViews/AppWidgetProvider model. Nothing about kindling or the Large breakpoint required a different rendering technology.

Coach line and the streak metaphor — reused, not reimplemented

Both the earned Coach line and the flame/stones streak-metaphor choice come from the same platform-neutral snapshot fields iOS already reads. useWidgetSnapshot.ts computes coachLine (via resolveCoachLineState) and streakMetaphor once, in the one place both platforms' snapshots are built — Android's WidgetSnapshot.java just adds coachLine/streakMetaphor/kindlingEnabled fields to its JSON parse (mirroring the iOS WidgetSnapshot.swift shape) and reads them natively:

  • HomeWidgetProvider.showsCoachLine(WidgetState, WidgetSnapshot) fails closed on STALE (a stale snapshot can't vouch for current progress, mirroring iOS's same call in HomeWidgetViews.swift), on kindlingEnabled being anything but true, and on a null coachLine — no new flag, reuses kindling_widget_enabled.
  • resolveMetaphor(String) mirrors the JS-side fail-open-to-stones default: an absent or unrecognized streakMetaphor renders stones, matching Fail-closed behavior above.

No new GraphQL field or query feeds either — both were already flowing through the shared snapshot before this milestone; Android just started reading them.

Coach-line ownership & widget-native mutation contract (OBJ-2428/OBJ-2436)

JS owns coachLine and coachLineShort; native code never derives either. Both fields are computed once, in useWidgetSnapshot.ts, by resolveCoachLineState(dueHabits, metaphor) — a pure function of the due-habit list's checked-in count (all-done / not-yet-new-day / in-progress / flame-supporter, null when there are no due habits) — then looked up in COACH_LINE_TEMPLATES/COACH_LINE_SHORT_TEMPLATES. Both are written to the snapshot only when reactive_streak_widget_enabled is on, same fail-closed posture as extendedToday/streakMetaphor. Neither Android nor iOS re-derives the string; they only render whatever JS last wrote.

The bug this contract exists to prevent (OBJ-2436). Both platforms' interactive check-off has an optimistic, widget-native write: Android's WidgetSnapshot.withOptimisticCheckin and iOS's WidgetSnapshot.checkingIn flip a habit's checkedInToday locally, ahead of the next real JS sync, so the tap redraws immediately (see The check-off write path above / Interactive check-off (v4.7)). Because coachLine is derived from the checked-in count, carrying it through unmodified goes stale the instant that local flip happens — the reported screenshot showed exactly this: the not-yet-new-day line ("Add today's stone…") rendered next to a 5-of-5 ALL_DONE card, because the optimistic transform had updated every habit's checkedInToday but left coachLine at whatever JS had last synced.

The fix clears, it never recomputes. Both withOptimisticCheckin (Android) and checkingIn (iOS) now clear coachLine in the same transform that flips checkedInToday — Android removes the JSON key (obj.remove("coachLine")), iOS sets the field nil. The alternative — porting resolveCoachLineState's four-branch selection into two native codebases — was explicitly rejected: it would duplicate a JS-authored string table natively, and the phase-3 UI-SPEC already warns that coachLine's templates can change in a web release without a matching native release. Clearing respects the "JS owns this string" boundary: the row goes quiet until the next real sync repopulates it, rather than ever asserting a state-derived claim natively.

Render effect differs by platform, but never re-introduces a stale string:

  • Android: showsCoachLine(state, snapshot) gates the entire Coach row on snapshot.coachLine != null (among its other checks) — clearing coachLine hides the row outright. coachLineShort is not explicitly cleared alongside it, but that's inert: the row's visibility gate is coachLine-only, so a stale coachLineShort never has a code path that renders it while coachLine is absent.
  • iOS: kindlingCoachLine(reactive:)/largeCoachLine() read snapshot?.coachLine ?? reactive.captionText — with coachLine nil, both fall back to the reactive-layer caption, which is itself computed at render time from extendedToday/state, not the stale JS string. iOS has no coachLineShort field (narrow-Large is Android-only, see Narrow-Large readability (OBJ-2428) above).

The general rule, not just this one field: any snapshot field a widget-native transform mutates locally must clear or recompute every other field derived from what it just changed — see the new gotcha in docs/development/gotchas.md for the general write-up.

The check-off write path

End to end, a tap on a due habit row on the Android widget:

  1. Tap → broadcast. populateHabitRows attaches a click PendingIntent (HomeWidgetProvider.checkinPendingIntent) to any row that's due, not yet checked in, kindling-enabled, and not in STALE (a stale surface never invites a check-off — a carried defect from Medium's original PR #1671 review, fixed so the gate now applies uniformly to Small/Medium/Large). The intent targets HomeWidgetProvider directly via an explicit component Intent (ACTION_CHECK_IN_HABIT) rather than an intent-filter action — so it's deliberately absent from AndroidManifest.xml; delivery doesn't depend on a manifest entry.
  2. onReceive → pending-checkin queue. HomeWidgetProvider.onReceive reads the goalId extra and calls enqueuePendingCheckin, which re-validates kindlingEnabled from the cached snapshot (a write-time backstop for a widget instance that hasn't redrawn since a flag flip-off), then durably appends {goalId, localDate} to a SharedPreferences queue (PENDING_CHECKINS_KEY) — the same objectuve_widget prefs file the rendered snapshot lives in. appendPendingCheckin dedups on goalId + local date (yyyy-MM-dd, device-local timezone) before writing, so a second tap or a queue-twice race never produces a second entry. Every read-modify-write of that queue is guarded by PENDING_CHECKINS_LOCKonReceive runs on the main thread, WidgetBridgePlugin.drainPendingCheckins runs off it (Capacitor plugin calls), and without the shared lock a tap's append landing between a drain's read and its clear would be silently wiped (Roy's review, PR #1671). The snapshot is then optimistically flipped to show the habit checked so the redraw looks correct before the app is ever foregrounded, and the widget redraws immediately.
  3. WidgetBridgePlugin.drainPendingCheckins → JS. On the next app foreground, useWidgetCheckinDrain.ts's resume listener (and its own onMounted call) invokes the Capacitor plugin's drainPendingCheckins, which reads and clears the SharedPreferences queue in one locked call — destructive by design, since the caller immediately re-homes each entry somewhere durable.
  4. useWidgetCheckinDrain.ts → the existing offline sync queue. Each drained {goalId, localDate} pair is enqueued into syncStore under a deterministic id (widget-checkin-${goalId}-${localDate}) with mutation name checkInHabit — the same dedup key shape the pending-checkin queue itself uses, so a second drain of the same pair before the first sync finishes is a no-op at syncStore.enqueue's own dedup. syncStore.flush() is awaited before the drain returns, closing the window where a process kill between the native clear and the durable write would lose the check-in.
  5. Sync queue → CHECK_IN_HABIT_MUTATION. syncStore.ts maps the checkInHabit mutation name to the existing CHECK_IN_HABIT_MUTATION (ionic_frontend/src/constants/graphql/goals.js) — the same mutation any other check-in (in-app or offline-queued) already uses. No new GraphQL surface shipped for this path — no new query, mutation, or type.
  6. Server-side per-day idempotency. GoalTracking::CheckInHabit (rails_api/app/interactions/goal_tracking/check_in_habit.rb) looks up an existing habit_completions row for (goal, completed_date) before creating one; if found, it returns the existing completion instead of creating a duplicate. This is the backstop layer beneath the native write-time dedup (step 2) and the sync-queue dedup (step 4) — three independent, cheaper-to-more-authoritative layers all converging on "exactly one completion per habit per day," regardless of how many times a tap, a queue replay, or a retried mutation reaches the server.

Read-only remains the fallback for anything the write path can't vouch for: a STALE snapshot, a due habit whose cached WidgetHabit.id predates check-off support (an older app version's snapshot), or an already-checked-in habit render with no click target attached at all — mirroring iOS's CheckInHabitIntent gating (a nil/absent habit ID renders read-only).

Three-breakpoint model, one widget

Android resolves one resizable App Widget across three sizes — enum WidgetSize { SMALL, MEDIUM, LARGE } (HomeWidgetProvider.java) — not a second launcher picker entry. There's a single <appwidget-provider> declaration (home_widget_info.xml, minWidth/minHeight 110dp/130dp, maxResizeWidth/maxResizeHeight 500dp/400dp, resizeMode="horizontal|vertical") and a single manifest <receiver android:name=".HomeWidgetProvider"> — Large was added as a wider resize band on the existing provider, not a new one.

resolveSize(minWidthDp, minHeightDp, kindling) re-resolves per widget instance on every onUpdate/onAppWidgetOptionsChanged call, since the user drags to resize rather than picking a fixed size upfront (hysteresis-free — there's no "sticky" size once granted):

  • minWidthDp < 200Small.
  • Otherwise, kindling on and minHeightDp ≥ 270 and minWidthDp ≥ 200Large, gated further by a viability check (computeLargeWidgetPlan): a granted height that clears the threshold but, at the current font scale, can't seat at least 2 habit rows falls back to Medium rather than rendering a cramped Large.
  • Otherwise → Medium.

Large reuses Medium's flame/streak-metaphor treatment, Coach line, and CTA styling — it adds a full-width header/Coach/section/checklist/motivator stack (up to LARGE_MAX_HABIT_ROWS = 6 habit rows, capped so the click-PendingIntent request-code scheme — appWidgetId * 10 + rowIndex — stays collision-free) rather than a materially new visual language.

Large's width floor, OBJ-2428. LARGE_HEIGHT_THRESHOLD_DP (270dp) was the only size-resolution gate Large had until a report showed the widget rendering Large's full-width header on a card too narrow to seat it. resolveSize now also names a LARGE_WIDTH_THRESHOLD_DP (200dp) — deliberately equal to MEDIUM_WIDTH_THRESHOLD_DP, so this doesn't change which cards resolve to Large (any card wide enough to avoid Small already clears it). The point of stating it explicitly, rather than leaving Large's width floor as an accident of resolveSize's branch order, is the ruling behind it: a narrow granted width never demotes Large to Medium. Medium has the exact same header-caption defect and shows fewer habit rows at the reported geometry, so demoting would make the report worse, not better. Instead, narrow-but-tall Large degrades its own header — see Narrow-Large readability (OBJ-2428) below. JVM-pinned at the boundary (HomeWidgetProviderTest.resolveSize_widthBoundary_199_200_201AtKindlingOnAndHeightFloor): 199dp → Small, 200dp/201dp → Large (height/flag held constant).

Narrow-Large readability (OBJ-2428)

UI-SPEC: .planning/phases/obj-2428-widget-narrow-large-readability/UI-SPEC.md · PR: #2271

A 2×~3 launcher-grid placement (≈200–290dp granted width, tall enough for Large) used to hard-clip the header caption mid-word (no maxLines/ellipsize), one-line-clamp the Coach line past readability, and truncate habit labels mid-word — the header was specced against a wide card and had no narrower fallback. The fix is a width-axis degradation ladder inside Large itself, not a width gate on size selection (see Large's width floor above).

The header ladder — shed before you stack. planLargeHeader (pure, JVM-tested at every rung boundary) resolves one of three rungs from the granted width, font scale, streak/longest digit counts, and the active caption string's measured advance:

RungFires at (fontScale 1.0)What happens
INLINE≥ 290dp (LARGE_HEADER_INLINE_WIDTH_DP)Everything on one row: streak block, DAY STREAK/Longest N, and the reactive caption — today's layout, unchanged.
COMPACT219dp–289dp (LARGE_HEADER_COMPACT_WIDTH_DP)The DAY STREAK/Longest N wrapper (streak_labels_large) sheds — set GONE as a whole, not its two children individually, so its marginStart doesn't survive as a phantom indent. Zero height cost; composedAccessibilityLabel still speaks the streak/longest numbers in full, so nothing is lost, only hidden visually.
STACKED< 219dpThe caption also drops to its own row below the header (+1 eleven-sp line, +6dp margin — the only rung with a height cost).

At the reported geometry (~230×330dp, fontScale 1.0), the rung resolves to COMPACT — pinned by computeLargeWidgetPlan_reportedGeometry_compactRungFiveRowsNoOverflowNoLevelB (HomeWidgetProviderTest.java), which also confirms all 5 habit rows still render with no overflow line. The fix costs the reporter no visible rows. Both rung tables scale with font scale and streak/longest digit count (fontScale 1.3: INLINE ≥334dp/COMPACT ≥244dp; fontScale 2.0: INLINE ≥460dp/COMPACT ≥325dp).

Caption floor, both Large and Medium. reactive_caption_text_large and reactive_caption_text_medium now carry android:maxLines="1" + android:ellipsize="end" — the same non-negotiable pattern home_widget_small.xml already had. This is the safety net if a rung/budget miscalculates; the ladder above is the actual fix.

Habit-label line policy — split, not uniform. Two independent rules, both JVM-tested:

  • Level A (unconditional): the next-due row always renders its label on 2 lines — its 44dp row height (KINDLING_NEXT_DUE_ROW_HEIGHT_DP) already seats a second line for ~2dp of extra cost.
  • Level B (budget-gated, all-or-none): every other visible row also gets 2 lines, but only when there's no overflow line (rows == dueHabits) and the remaining budget covers an extra line per row. It does not fire at the reported geometry — done-row labels there stay 1 line with an ellipsis, recoverable via TalkBack (full title) or the app.

Coach line — a second field, not a truncation. coachLineShort (useWidgetSnapshot.ts, ≤24 chars, Vitest-capped) is a new, independently-derived nullable snapshot field alongside coachLine — see Coach-line ownership & widget-native mutation contract below for how both are derived. Android renders it on the COMPACT/STACKED rungs and on Medium; INLINE keeps the long form, which fits with room to spare. An older web build that hasn't shipped coachLineShort yet falls back to the unabridged coachLine unchanged — today's exact (truncated) behavior, not a regression.

TalkBack now speaks the Coach line. Before this fix it was the one string in the report with no fallback route at all — not readable on the card, not spoken. composedAccessibilityLabel now appends the long-form coachLine (never coachLineShort — TalkBack has no width budget) whenever showsCoachLine is true, so spoken and rendered content can never disagree about whether a Coach line exists.

No Lock Screen analog on Android — deliberate, not an omission

iOS's kindling milestone (v4.7 Phase 4) shipped three Lock Screen accessory views (CircularAccessoryView, RectangularAccessoryView, InlineAccessoryView) — see Lock Screen accessories above. Android has no equivalent host surface: Android's lock screen does not support third-party glanceable widgets the way iOS 16+'s Lock Screen does (that surface is iOS-specific, introduced alongside WidgetKit's accessoryCircular/accessoryRectangular/accessoryInline families). This milestone's scope is the three home-screen sizes only — there is no Android surface to port the Lock Screen accessories to, not a feature that was cut for time.

Kindling × FLAME — correct as shipped

applyReactiveTreatment (HomeWidgetProvider.java:1795-1849) branches on treatment.metaphor == StreakMetaphor.STONES. The stones branch consumes the kindling parameter in all three of its arms (gold capstone, kindling open-slot, kindling solid drawables); the flame branch (:1825 onward) ignores it entirely — a flame user with kindling_widget_enabled on gets the byte-identical pre-kindling flame treatment (widget_flame_lit + halo on EXTENDED, widget_flame_muted on NOT_YET, widget_flame on BASELINE). There are no widget_flame_kindling_* drawables.

This is Desi's ruling, not an open gap — see the dated "Kindling × FLAME — the streak-block ruling (Desi, 2026-07-21)" subsection of UI-SPEC.md (Phase 2): the stone-tower refinement (gold capstone, open slot, warm gradient) is a tower-shaped metaphor with nothing to port onto a flame, which already carries its own three-state signal (lit+halo / muted / plain) in its own vocabulary. A flame user is not on a degraded surface — every metaphor-neutral part of the refinement still reaches them: the kindling card and ember (applyKindlingCard), tinted habit rows and the 44dp next-due row, the Coach line, the CTA pill, and the whole Large layout. Only the 18dp streak-block glyph stays as it was pre-kindling.

Caption/Coach glyph text-contrast floor (OBJ-1634)

Text and graphical contrast are two different WCAG floors on this widget, and it's easy to apply the wrong one. The reactive caption row's small glyph (reactive_caption_glyph*, 11sp bold TextView) and Large's coach_spark_large are text — they owe WCAG SC 1.4.3 at 4.5:1, the same floor as any other body copy, because 11sp bold is well under the 18.66sp-bold large-text exemption threshold. This is distinct from the streak-block silhouette edge (widget_glyph_edge/widget_glyph_edge_muted), which is a graphical object and only owes 3:1. All nine v4.52 motif drawables (sprout/mountain/waves × lit/muted/baseline) carry this same 1.33dp edge at the same 3:1 floor, not just the original stones/flame glyph. A filled shape (e.g. sprout's two leaves, the mountain triangle) carries both fillColor and the 1.33dp strokeColor edge on one path; a stroked form with no fill to carry a border (the sprout stem, both wave crests) can't — a single vector <path> declares only one strokeColor — so those get the edge as an under-stroke pair instead: a wider widget_glyph_edge-colored path stacked under a narrower accent-colored path, the two strokes' width difference equal to the same 1.33dp edge (widget_sprout.xml's stem: 3.93dp edge path under a 2.6dp accent path; widget_waves.xml's two crests: 4.96dp/4.56dp edge paths under 3.63dp/3.23dp accent paths). The stones NOT_YET state's dashed-slot glyph is rendered as an ImageView, not a TextView — same row, same visual role, but it genuinely is graphical and correctly stays at the 3:1 floor (3.11:1 light / 3.12:1 dark).

HomeWidgetProvider.glyphColorRes (:1902-1917) selects the glyph's text color per branch:

BranchTokenLightDarkNotes
Stones spark (any lit state)widget_glyph_gold#7A5300#FCC419New token — retired widget_gold (#FCC419 light, single consumer, no values-night pair, measured 1.06:1 against the kindling card in light mode)
Flame spark, EXTENDEDwidget_glyph_edge#9C4508#FFE4C6Reused from the streak glyph's 3:1 edge token — it independently clears 4.5:1 on these composites too
Flame spark, NOT_YET/BASELINEwidget_glyph_edge_muted#546174#BAC5D3Reused, same reasoning
Large Coach spark (coach_spark_large)widget_coach#6D28D9#D6B0FERetuned in place from #A855F7/#C084FC — single consumer, no non-text role to protect

widget_gold's replacement is a rename, not just a retune: a token named widget_gold holding #7A5300 (a colour that reads bronze, not gold) would be a standing invitation to misuse it elsewhere. The gold capstone directly above the caption — a graphical object at the existing 3:1 floor (widget_gold_top/_mid/_bot) — is untouched and still carries the bright-gold brand hue; the 11sp spark carries the state meaning, not the hue, so the trade-off doesn't cost the widget its gold read anywhere a user actually sees it.

Both flame branches and the Coach spark clear 4.5:1 only because the fix was measured against the kindling ember composite, not just the flat card — three of the four pre-fix tokens passed on a bare white/black card but failed once the kindling ember renders behind them (widget_flame_muted measured 1.76:1 on the dark kindling composite, worse than its light-mode reading). WidgetTokenContrastTest (app/src/test/java/com/objectuve/ionic/WidgetTokenContrastTest.java) is the regression gate for this: a pure-JVM test with no Robolectric dependency that parses values/colors_widget.xml and values-night/colors_widget.xml directly and asserts ≥4.5:1 for the four glyph/Coach tokens and ≥3:1 for the streak-glyph edge tokens, against the flat card, the kindling card, and kindling + ember, in both themes — proven to fail against the pre-fix hexes. There was no contrast test anywhere in this module before this.

Kindling ember contrast floor (OBJ-1636)

The four foreground tokens Desi's OBJ-1634 audit flagged as failing behind the kindling ember (widget_card_ember.xml / drawable-night/widget_card_ember.xml, shipped in OBJ-1590) split into two unrelated bugs, not one: widget_stone_muted and widget_stone_caption failed only once composited behind the ember, an ember-caused defect — but widget_muted and widget_capstone_dash already failed on the bare kindling card, ember at alpha 0. No ember change could have closed the second pair; they needed a token retune and a provider fix respectively, both below.

Ruling — widget_capstone_dash owes WCAG SC 1.4.11 at 3:1, not SC 1.4.3's 4.5:1. Its only two consumers are a 1.4dp <stroke> inside a layer-list drawable rendered into an ImageView on the open capstone slot (widget_stones_kindling_open_slot_small.xml:43, ..._medium.xml:43) — never a TextView, never announced, so the text floor doesn't reach it. It isn't decoration-exempt either: the dashed outline versus a filled stone is the only visual difference between an open and an earned capstone slot, so it carries state and owes the Non-text Contrast floor instead. Retuned #B7A98F#86765A (light, values/colors_widget.xml:18) and #97A3B4#B5AA93 (dark, values-night/colors_widget.xml:16) — dark already cleared 3:1 pre-fix (3.86), but shipped anyway because #97A3B4 was byte-identical to widget_stone_muted's dark value, making the token's own drawable comment ("the warm widget_capstone_dash token") false. Post-fix worst case: 3.48 light / 4.97 dark against the 3:1 floor. Don't reopen this token to chase a 4.5:1 target — it was never text, and driving it darker to hit the wrong floor was the exact risk this ruling closes.

The fix lever was position, not alpha — the ember's peak alpha is unchanged. #4DF28226#00F28226 (light) and #61F4903E#00F4903E (dark) are byte-identical to shipped. Solving for compliance via alpha alone would have taken the light gradient stop to roughly 8% alpha — not a retune, a silent reversal of OBJ-1590's visual. Shrinking gradientRadius alone barely moved the worst case either (64dp→16dp still left 4.13:1), because the binding text runs sit 6–12dp from the ember's center, where radial attenuation is negligible at any reasonable radius. What actually worked: centering the ember on the flame-and-stone glyph it's an ember of, and shrinking its box to 52dp (was 84dp Small / 64dp Medium+Large) — gradientRadius 64dp→26dp in both drawable/widget_card_ember.xml and drawable-night/widget_card_ember.xml, box margins 0dp on Small (unchanged flame-container size, don't "tidy" it to match) and 4dp on Medium/Large. If a future change widens the ember box back out to "restore the glow," it reopens this contrast floor without touching a single alpha value — that's the trap this fix closes, not the alpha itself.

Provider change — three missing kindling recolour branches, an approved scope exception. The task was originally scoped to stay inside the drawable and layout, no HomeWidgetProvider.java changes — that boundary assumed the ember alone could close all four tokens, which the bare-card measurements above disproved. Three views were rendering unconditionally as widget_muted with no kindling branch at all, so the ember could never have been "at fault" for them:

ViewFileCase
new_day_texthome_widget_small.xml:349buildSmallViews, NEW_DAY
empty_subtext_small (new id — the view had none)home_widget_small.xml:277buildSmallViews, EMPTY
new_day_habits_texthome_widget_medium.xml:339buildMediumViews, NEW_DAY

Each now branches through a new package-private mutedTextColorRes(kindling) helper (HomeWidgetProvider.java, same testability idiom as showsGoldCapstone), returning widget_stone_muted when kindling is on and widget_muted when it's off — an explicit both-arm branch, never a one-armed if. This extends the fail-closed contract rather than breaking it: these three views previously had no arm to reset on RemoteViews recycling; adding one is strictly more fail-closed than the status quo. applyKindlingCard and all ember visibility logic are untouched — the flag-off render is byte-identical to before, in every size and state.

Why not a fourth branch — the FIRST_RUN exclusion. home_widget_medium.xml:402 (the first-run subtitle, also id-less) renders widget_muted with no kindling branch and reads like a missed fourth candidate. It isn't: isKindling(WidgetSnapshot) (HomeWidgetProvider.java:631-633) returns false whenever snapshot is null, and FIRST_RUN has no decodable snapshot to mirror the flag from — it fails closed to the flat, non-kindling card by construction, so it never composites over the ember and has no contrast defect to fix. Recorded here so a future reviewer who spots the same-looking pattern doesn't "fix" it again.

Regression coverage: HomeWidgetProviderTest gained a fail-closed assertion for mutedTextColorRes (flag off ⇒ widget_muted, flag on ⇒ widget_stone_muted). WidgetTokenContrastTest (created by OBJ-1634) was extended — never recreated — with an ember-composite case per theme for every affected row, each proven to fail against the pre-fix drawable/colour values before the fix landed. Worst case post-fix across every row: 4.58:1 text (Small new_day_text, light, against a 4.5 floor) and 3.48:1 graphical (Small capstone dash, light, against a 3.0 floor) — thin margins by design, guarded by the test rather than discipline.

Android motif parity (v4.52)

Milestone: v4.52 — Android Widget Motif Parity · Source issue: OBJ-3250 · PRs: #2838 (Phase 1 UI-SPEC, OBJ-3251), #2840 (Phase 2 five-way StreakMetaphor + motif copy layer, OBJ-3252), #2857 (Phase 3 third glyph slot + nine motif drawables, OBJ-3253), #2858 (Phase 4 captionWidthDp exhaustiveness fix, OBJ-3254)

Before this milestone, choosing Sprout, Mountain, or Waves — three of the five streak motifs — in-app rendered as Stones on the Android widget: streakMetaphor decoded correctly, but Android's glyph-selection switch only had FLAME/STONES arms, so any other value fell through to Stones. iOS already rendered all five distinctly. v4.52 closes that gap: Android now renders its own glyph, caption, motivator line, and accessibility suffix for all five motifs — see largeMotivatorText above for the one string this milestone did not touch — largeMotivatorText was flame/stones-only at the time, a platform gap running the other way; it went five-way in the later OBJ-3246/OBJ-3302 fix (see Large family above), so the gap is now closed.

Third glyph slot. flame_container/flame_container_medium/flame_container_large each gain a third ImageView (motif_icon/motif_icon_medium/motif_icon_large) alongside the pre-existing flame_icon/stone_icon pair; applyReactiveTreatment (HomeWidgetProvider.java:1795-1849) became a three-way exhaustive switch on StreakMetaphor, throwing on an unhandled case rather than falling through. Nine new <vector> drawables (widget_sprout/_lit/_muted, widget_mountain/_lit/_muted, widget_waves/_lit/_muted) render on widget_flame.xml's existing 24×24/18dp envelope, path data fitted from the shipped in-app motif art (MOTIF-SPEC-1).

Copy layer. StreakMetaphor (HomeWidgetProvider.java:171) expanded to five cases (STONES, FLAME, SPROUT, MOUNTAIN, WAVES); every copy-selection path — resolveMetaphor, extendedCaptionTextRes/notYetCaptionTextRes, motivatorStringRes/motivatorSentence, reactiveAccessibilitySuffix — is now a five-way exhaustive switch with a throwing default. Six new caption strings and eighteen new motivator strings (strings.xml:21-26, :64-81) round out the Android-register copy — see Reactive states above for the caption table.

captionWidthDp exhaustiveness fix (OBJ-3254). The narrow-Large header ladder's caption-width lookup (Narrow-Large readability above) fell through a bare default to CAPTION_ADD_STONE_WIDTH_DP for all six new caption keys, mis-measuring the COMPACT/STACKED rung boundary for Sprout/Mountain/Waves. captionWidthDp (HomeWidgetProvider.java:849) gained the six missing width tables (measured via measure-motif-captions.mjs) and became exhaustive with a throwing default.

Contrast. All nine new drawables carry the same 3:1 silhouette-edge floor the original stones/flame glyph already had — see Caption/Coach glyph text-contrast floor above.

No kindling treatment (MOTIF-SPEC-6). Same ruling as the Kindling × FLAME streak-block ruling above: Sprout/Mountain/Waves get no _kindling_* drawable variant. The kindling card, ember, and Coach line still reach every metaphor — only the 18dp streak-block glyph itself stays as it is.

Native color set

A native widget target can't consume the app's live tokens.css layer, so the widget declares its own Widget color set mirroring six of the canonical design tokens — a token change is a one-file update, not a hunt through Swift/XML:

Native nameCanonical tokenUsed for
WidgetCard--cardOpaque widget background in app-owned modes (no blur — WidgetKit/Glance can't render backdrop-blur, and a translucent card wouldn't read on an arbitrary wallpaper). On iOS 17+, the tinted Home Screen and StandBy are host-owned and don't render this fill at all — see Host rendering modes (iOS) below.
WidgetInk--foregroundStreak number, habit labels
WidgetAccent--accentFlame icon only
WidgetSuccess--success-accessibleDone-habit check icon (the AA-safe variant, not --success)
WidgetMuted--muted-foregroundTodo habit rows, sync captions
WidgetBorder--borderTodo pips, dividers
  • iOS: ionic_frontend/ios/App/HomeWidgetExtension/Assets.xcassets/ — six color sets, each with Any + Dark appearances.
  • Android: ionic_frontend/android/app/src/main/res/values/colors_widget.xml + res/values-night/colors_widget.xml — same six names. See Mobile Builds — Android widget colors landmine for why this is a dedicated file, not colors.xml.

v4.7 kindling palette, iOS-only. The kindling treatment adds thirteen more widget-only color sets under the same Assets.xcassets (no Android equivalent — the Android render is unaffected by v4.7): WidgetAccentDeep, WidgetAccentText, WidgetCapstoneDash, WidgetCoach, WidgetFlameMuted, WidgetGoldTop/WidgetGoldMid/WidgetGoldBot (the gold capstone gradient — gold stays confined to the capstone per brand rule, RoadmapCapstone.vue:5), WidgetStoneTop/WidgetStoneMid/WidgetStoneBot (the stone-tower gradient), and WidgetStoneMuted/WidgetStoneCaption. Same Color(widget:) resolution rule as the base six above.

iOS: always Color(widget:), never bare Color(…)

Every named-color lookup inside HomeWidgetExtension/ (extension code, not just test code) must go through Color(widget: "…") — the shim declared in WidgetColors.swift — never SwiftUI's bare Color("…") initializer.

Bare Color(_:) resolves against Bundle.main. Inside the real widget extension process, Bundle.main is the extension's bundle, so a bare lookup happens to resolve correctly on-device — there is no production bug. But the HomeWidgetExtensionTests snapshot-testing target (Testing below) is host-less: for that test bundle, Bundle.main resolves to the generic XCTest runner, not the widget's asset catalog, so the named color silently fails to resolve and the rendered UIImage comes out blank except for non-color content (e.g. a divider line). Color(widget:) resolves via Bundle(for: WidgetResourcesBundleToken.self) instead — the bundle that actually contains the calling code, regardless of which bundle the process considers "main" — so it resolves correctly in both contexts.

This is exactly why it's easy to reintroduce: it compiles either way, and the only symptom is a blank test render, not a build error or an on-device bug. Two call sites in HomeWidget.swift's WidgetContainer modifier shipped with bare Color("WidgetCard") for a time after PR #1467 introduced them, undetected until OBJ-1363 (PR #1475) traced it back to widgetResourcesBundle/Color.init(widget:) having been declared file-scope private in HomeWidgetViews.swift — invisible to any other file, including HomeWidget.swift. The fix lifted the shim into its own file (WidgetColors.swift) at internal scope and added two deterministic XCTest guards (per-color asset-resolution + non-blank-pixel-count) so a regression fails CI instead of requiring someone to eyeball 20 PNGs.

Before touching any Color call in this directory, verify: git grep -n 'Color("' ionic_frontend/ios/App/HomeWidgetExtension/ returns nothing.

Host rendering modes (iOS)

iOS 17+ widgets adopt .containerBackground(for: .widget) (the WidgetContainer modifier in HomeWidget.swift), which lets the host substitute its own background for some rendering modes instead of the app drawing one. Below iOS 17, WidgetContainer falls back to the old .background() and applies 16pt padding itself; on iOS 17+ the system supplies that 16pt as content margins instead, which is why the view-level .padding() was removed from SmallWidgetView, MediumWidgetView, and FirstRunBody — leaving both in place would have stacked to 32pt.

ModeBackgroundColourWhat the app controls
Home Screen, lightApp — opaque WidgetCardFullEverything. Unchanged.
Home Screen, darkApp — opaque WidgetCard (dark)FullEverything. Unchanged.
Home Screen, tintedHost — its own dark translucent materialCollapsed to one tintLayout, shape, type. Not colour.
StandByHost — removed entirely, full-bleed on blackFull (Night Mode: one red tint)Layout, shape, type. Not the card.

Nothing may encode meaning in colour alone in the two host-owned modes. WidgetKit's accented rendering doesn't desaturate colours, it discards their RGB and repaints every opaque view with a single tint — two fills that differ only in hue become byte-identical there. Two things in this widget exist because of that rule:

  • HabitPip (HomeWidgetViews.swift) is shape-coded, not just colour-coded: a solid 20×8pt capsule (WidgetSuccess) when done, a 1.5pt WidgetMuted-stroke outline (no fill) when not — distinguishable with hue removed.
  • StreakFlame carries .widgetAccentable(true) (iOS 16+) so it stays in the tinted-mode accent group, preserving the "flame carries accent, streak number carries data" rule from Accessibility above — the streak number itself stays in the default group.

Accepted, non-regressed limitations in tinted mode: secondary text (WidgetMuted/WidgetInk captions) flattens to one tint — hierarchy still reads via size and weight, not colour. FirstRunBody's gradient circle flattens to a solid tinted disc — decorative, seen once before the first sync.

Full design rationale: UI-SPEC.md addendum "Host chrome & rendering modes," .planning/phases/v4.1-phase-4-home-widgets/UI-SPEC.md (L193–324).

Whole-widget tap opens https://app.objectuve.com/dashboard?src=widget. This reuses the existing appUrlOpen listener (ionic_frontend/src/App.vue) — no new deep-link handler, no new route:

  • iOS: .widgetURL(URL(string: "https://app.objectuve.com/dashboard?src=widget"))
  • Android: a PendingIntent on Intent.ACTION_VIEW with the identical URL

The existing applinks:app.objectuve.com entitlement (iOS) and autoVerify App Link intent-filter (Android) already cover this — no manifest or entitlement change was needed for the link itself, only for the App Group (see below). ?src=widget flows into persistSourceAttribution() for attribution.

Interactive check-off

V1 (v4.1) shipped fully read-only: the widget had exactly one tap target (the whole widget, opening the app), no per-habit checkboxes, no buttons, no Toggles in the SwiftUI views, and Android's RemoteViews set exactly one setOnClickPendingIntent on the root view. Interactive check-in — tapping a single due habit row to check it off without opening the app — was called out at the time as a documented, out-of-scope fast-follow (UI-SPEC.md Scope), since it needs a write path from the widget process back into the app's GraphQL layer — a materially different architecture from the read-only bridge above, not a small addition to it.

That fast-follow has since shipped on both platforms:

  • iOS (v4.7 Phase 5, OBJ-1522): CheckInHabitIntent (ionic_frontend/ios/App/HomeWidgetExtension/CheckInHabitIntent.swift), an AppIntent (openAppWhenRun = false, iOS 17+ only — the read-only render still deploys to iOS 15) wired to each due habit row as an interactive Button. Lives inside the existing HomeWidgetExtension target/bundle ID — no new App ID, no new App Group. See Interactive check-off (v4.7) above for the full path.
  • Android (v4.11, OBJ-1592): a click-PendingIntent per due habit row on HomeWidgetProvider. See Android parity (v4.11) § The check-off write path below for the full path.

Both implementations share the same shape: a durable, locally-deduped pending-checkin queue (keyed on habit/goal ID + local date) that an optimistic snapshot update reads immediately, and that the app drains into its existing offline sync queue on next foreground — landing on the same CHECK_IN_HABIT_MUTATION and the same server-side per-day idempotency as any other check-in. Neither platform's per-habit tap opens a form, creates or edits a habit, or does anything beyond the single check-off action.

v4.22 adds a second drain path that doesn't need that next foreground. See Background check-in delivery (v4.22) below — the same pending-checkin queue is now also drained by native background code (WorkManager on Android, tap-time AppIntent + BGTaskScheduler on iOS) using a dedicated device-bound credential, so a check-in can reach the server even if the user never reopens the app.

In-app "Add widget" affordance

Two new components teach the user how to place the widget, gated end-to-end behind home_widgets_enabled:

  • WidgetSettingsRow.vue (ionic_frontend/src/components/) — a Settings row (Profile tab, alongside the Haptics toggle) that opens the setup sheet. ≥56px tap target.
  • WidgetSetupSheet.vue (ionic_frontend/src/components/) — an IonModal bottom sheet with a live medium-widget preview (reads the same USER_QUERY/GOALS_QUERY data as the bridge, not a static mock), an iPhone/Android segmented tablist, and 3 numbered setup steps that swap copy per platform.

Fail-closed gating. Both the settings row and the setup sheet are inside a single v-if="homeWidgetsEnabled" wrapper in WidgetSettingsRow.vue — when the flag is off, WidgetSetupSheet is never instantiated, so its USER_QUERY/GOALS_QUERY calls never fire. This was originally broken (the v-if only gated the visible button, not the sheet sitting as a sibling, so the sheet's queries fired for every signed-in user regardless of the flag) — caught in code review on PR #1325 and fixed by wrapping both under one v-if root. The regression test (WidgetSettingsRow.spec.ts) mounts the real WidgetSetupSheet component (not a mock) and asserts it doesn't exist when the flag is off, so this class of bug can't slip through silently again.

Android step 2 copy is an inferred variant, not verbatim spec text. The UI-SPEC's microcopy table only spells out iPhone wording ("Tap + in the top corner, then search Enkidu."); Codi substituted "Tap Widgets, then find Enkidu." for Android based on the real long-press → "Widgets" flow exercised hands-on against an Android emulator, not a guess — but it has not had Desi's sign-off as verbatim spec copy. If you're touching this copy, confirm with Desi first.

Feature flag

home_widgets_enabled, registered in both ionic_frontend/src/lib/featureFlags.ts (FEATURE_FLAGS + DEV_OVERRIDE_KEY_MAP) and PostHog project 368400, per the dual-registration lifecycle. The flag gates only the in-app enable affordance (Settings row + setup sheet) — the native widget itself has no runtime flag check; it renders from whatever snapshot exists in shared storage and falls back to the first-run state when none does. This means a widget already placed on a home screen keeps rendering even if the flag is later turned off; only the in-app path for discovering/adding it disappears.

reactive_streak_widget_enabled gates the same-day reactive layer independently of home_widgets_enabled — a user can have the base widget on and the reactive layer off (classic v4.1 flame render) or both on. Same dual-registration lifecycle, same fail-closed posture: off means useWidgetSnapshot.ts omits extendedToday/streakMetaphor from the snapshot entirely, and native code reads their absence as the signal to fall back, not a flag value it checks directly. It also gates the Coach line independently of kindling_widget_enabled — the Coach line can appear on a legacy-visual widget with the reactive layer on, and is absent from a kindling-visual widget if the reactive layer is off.

widget_background_sync_enabled (v4.22) is a different shape from the three flags above — evaluated server-side only (PostHog, inside the token-mint interaction), not dual-registered client-side, and it doesn't gate rendering at all. See Feature flag (background delivery) above.

kindling_widget_enabled gates the refined visual treatment and interactive check-off on both platforms — iOS (refined visual treatment, Large family, and interactive check-off, v4.7) and Android (the same refinement + check-off, v4.11) — on the three home-screen families on each platform. On iOS, SmallWidgetView/MediumWidgetView/LargeWidgetView each branch kindlingBody vs. legacyBody on isKindlingEnabled (HomeWidgetViews.swift:653), independently of the other two flags, registered in featureFlags.ts at 0% rollout as of this writing. Same fail-closed posture on both platforms: off (or unreadable) renders the pre-kindling legacy render for every family, byte-identical to the pre-v4.7/pre-v4.11 widget. It does not gate reactive_streak_widget_enabled's reactive cue, which those same views also render independently of the kindling flag — nor, as detailed below, does it gate the three Lock Screen accessories (iOS only — Android has no Lock Screen surface, see No Lock Screen analog on Android).

Lock Screen accessories don't read kindling_widget_enabled (v4.7 Phase 4, OBJ-1521/OBJ-1580)

The three iOS Lock Screen accessory views — CircularAccessoryView, RectangularAccessoryView, InlineAccessoryView (HomeWidgetViews.swift:1623/:1698/:1882) — are net-new in v4.7 and have no legacy accessory render to fall back to, unlike Small/Medium/Large. Their kindling-styled geometry is the only render that exists, so it shows for anyone who adds the accessory to their Lock Screen regardless of kindling_widget_enabled's rollout % (HomeWidgetViews.swift:1617-1620).

The accessories' only variable cue — the reactive filled/outline tower + caption — is driven entirely by resolveReactiveTreatment/snapshot.extendedToday, i.e. by reactive_streak_widget_enabled, not by kindling. WidgetSnapshot.swift:34 populates extendedToday from reactive_streak_widget_enabled; :41-43 populates the separate kindlingEnabled field from kindling_widget_enabled, which accessories never read. When extendedToday is absent, resolveReactiveTreatment returns .baseline (plain/outline ring, no reactive caption) — that reactive-absent render is the accessories' flag-off baseline. This was ratified as intentional (Option A) rather than re-specced to wire in the kindling flag — see resolved Open question #1 in the Phase 4 UI-SPEC.

Ops takeaway: ramping kindling_widget_enabled to 100% does not enable the accessory reactive cue and carries no incremental Lock Screen exposure risk (the accessory geometry already renders the same for everyone who adds it). To make accessory users see the reactive stone/flame cue, ramp reactive_streak_widget_enabled — that's the flag that actually governs this surface.

Anti-social discipline

No state introduces FOMO, guilt, "don't break your streak" language, badges-to-chase, or a "come back" nudge. The all-done and empty states are deliberately calm and terminal: they inform and get out of the way, consistent with the brand philosophy's anti-social-app principle. This was a hard requirement in the UI-SPEC, not a nice-to-have — see the setup sheet's own subtitle copy: "A quiet glance at today's habits and your streak, right on your home screen. It never opens on its own."

Testing

  • Frontend unit (Vitest): useWidgetSnapshot.spec.ts (web no-op, write/re-write on data change, rejection-path swallowing), WidgetSettingsRow.spec.ts / WidgetSetupSheet.spec.ts (flag on/off, fail-closed mount gating, live preview data, platform-copy swap).
  • iOS: swiftc -typecheck against the iOS Simulator SDK for the extension target; plutil -lint on project.pbxproj. Plus, as of OBJ-1200, a PR-triggered xcodebuild test snapshot harness (HomeWidgetExtensionTests, swift-snapshot-testing) renders SmallWidgetView/MediumWidgetView/LargeWidgetView and all three Lock Screen accessory views deterministically on macos-26 — see docs/operations/mobile-builds.md § Widget Snapshot Evidence (CI) for the current baseline count and state matrix. WIDGET-COVERAGE-1 (Phase 6) extended the harness from SmallWidgetView/MediumWidgetView-only to the full Large + accessory matrix across HomeWidgetState × StreakReactiveState × StreakMetaphor; check that section for the current baseline count rather than assuming a fixed figure here, since it moves as new states/metaphors land.
  • Android: exhaustive R.id/R.layout/R.drawable/@string/@color cross-reference between Java and XML; a real ./gradlew :app:assembleDebug build was run once during development (JDK 21 via -Dorg.gradle.java.home) and succeeded. Plus, as of OBJ-1200, a PR-triggered Roborazzi snapshot harness renders home_widget_small.xml/home_widget_medium.xml via Robolectric on the JVM — same doc section as above. No automated assembleDebug CI build gate exists yet, independent of the snapshot harness.
  • Manual verification not yet done (v4.1 Phase 4): no sandbox used across that phase could render either native widget end-to-end (a documented Android emulator "System UI isn't responding" ANR opening the widget picker; two independently broken iOS Simulator tooling paths). Roy's code review verified UI-SPEC compliance from source (color tokens, state logic, layout structure) as a substitute.
  • Manual device QA now done (v4.2 reactive layer, OBJ-1256): a full manual pass on both platforms closed out the outstanding spot-check above. Android: an instrumented-test harness (AppWidgetHost/AppWidgetManager, real inflation path) rendered all 11 state/metaphor/extendedToday combinations at both sizes and themes, on PR #1388. iOS: a standalone ImageRenderer harness rendered the real resolveWidgetState/resolveReactiveTreatment/resolveMetaphor code across 44 fixtures (11 states/metaphors × 2 sizes × 2 themes) on PR #1387. Both passes confirmed no color-alone accessibility regressions. Real home-screen widget-picker placement remains untested on iOS (WidgetKit preview rendering needs an Xcode-IDE-attached session, not shell-scriptable) — see Native UI evidence.
  • Real-device crash found and fixed: the same QA pass on Android found the widget crashed on placement 100% of the time on a fresh install — a pre-existing bare <View> element in the Android layout XML isn't on RemoteViews' inflation allow-list. See Mobile Builds § A bare <View> in a RemoteViews layout crashes on real-device placement for the full root cause and fix.
  • Manual verification: the OBJ-1148-era sandbox limitation stands (no sandbox here can drive an Android emulator's widget picker or a matched iOS Simulator without ANRing/hanging — see docs/operations/mobile-builds.md § Shared-runner gotchas), but OBJ-1200's CI snapshot harness now produces deterministic, reviewable PNG evidence for every state × size × theme without needing to drive that UI — pull artifacts via gh run download <run-id> per the same doc section. This closes the original UI-evidence waiver, but it is not the same as Desi's mockup-comparison spot-check: the CI renders prove the widget compiles and inflates cleanly per state, not that it visually matches the 12 mockup PNGs pixel-for-pixel. A human visual spot-check against Desi's 12 mockup PNGs is still outstanding — see the follow-ups on OBJ-1148.
  • On-device host-chrome captures are a standing requirement for iOS widget changes, not a one-off. Any future change to HomeWidgetEntryView, WidgetContainer, or values feeding widget colour must be checked against Home Screen light, Home Screen dark, Home Screen tinted, and StandBy on iOS 17+, plus an iOS 15/16 fallback check — see Host rendering modes (iOS) above. ImageRenderer and Xcode's static SwiftUI previews render SmallWidgetView/MediumWidgetView directly and never go through the real host-chrome substitution, so they cannot catch a host-chrome regression — this is exactly how OBJ-1351 shipped undetected through PR #1321's review, and it's a gap the existing OBJ-1200 harness above does not close (it renders via ImageRenderer, the same non-host-chrome path). OBJ-1351's own on-device captures were waived under the native-surfaces evidence escape hatch (no iOS 16/17 simulator runtime or launcher-automation tooling available in-session; compile proof stood as evidence of record) — the durable fix, a host-chrome-mode-aware extension to the harness, is tracked separately as OBJ-1371.

Known open items

  • iOS BGTaskScheduler scheduler-retry live-fire proof is unverified — accepted risk, shipped (v4.22 Phase 5). Whether the OS actually invokes the registered background-delivery retry handler while the app is fully closed was never confirmed — no physical iOS device was available to the crew, and a Simulator/Xcode debug-API disconnect blocked the private _simulateLaunchForTaskWithIdentifier: probe even after the underlying scheduling code was verified correct through successful submitTaskRequest. Josh explicitly accepted shipping on this evidence (2026-07-29), with real-world reliability to be monitored via production telemetry post-launch rather than gated pre-ship. See Known open item — iOS scheduler-retry live-fire proof (accepted risk, shipped) above for full detail and risk framing.
  • Live device repro for the interactive check-off is still outstanding — the tap → cold-kill → foreground reconciliation path (see Interactive check-off) has only been verified by code-path re-derivation and CI, never a physical-device walk (no iOS device/simulator in the phases' run environments). Worth a manual device pass before kindling_widget_enabled rolls above 0% rollout; any post-ship widget check-off reliability report should be checked against this gap first. (v4.7 Phase 5)
  • Lock Screen accessories don't check kindling_widget_enabled at all — their reactive cue is driven solely by the older reactive_streak_widget_enabled/extendedToday, so a user with the reactive layer on sees it on their Lock Screen accessory regardless of the kindling flag's rollout — see Fail-closed / byte-identical off-state above. Spun out as a non-blocking follow-up, OBJ-1580, rather than expanding Phase 4's scope. (v4.7 Phase 4 review)
  • Android small empty-state body doesn't fit the rest glyph — the UI-SPEC's glyph+title+subtitle grouping doesn't fit inside Android small's 110dp height budget even with correct centering, so the rest glyph is omitted there (Empty/all-done centering fill above). Flagged for Desi's eventual attention, not blocking (OBJ-1477).
  • Android step 2 setup copy needs Desi's sign-off (see above).
  • allDone + not-yet-extended dual render — resolved by copy, not cross-suppression (OBJ-3256/OBJ-3302/OBJ-3317). The two blocks can still render simultaneously, but every ALL_DONE string (Small/Medium kindling title + subtitle, Large's motivator line — rendered and spoken — and the Lock Screen accessory's line3) now branches confirmed-vs-pending-vs-baseline on iOS and on Android Small (OBJ-3315); Android Large's motivator equivalent is still confirmed-vs-pending only, tracked separately as OBJ-3316, in progress — instead of asserting an extension it can't back up — see Pending-ALL_DONE copy fix and Baseline is its own branch (OBJ-3317, iOS only) above.
  • iOS missing .containerBackground(for: .widget) (OBJ-1351) — see Known follow-ups above. (extendedToday staleness past local midnight, OBJ-1350, is fixed on both platforms — see NEW_DAY state — midnight rollover (iOS + Android) above.)
  • 4+-digit streak numerals have no width-budget coverage — Amendment B and the width budget fix above both bound their matrix to {1,2,3}-digit streaks (the accepted scope); flagged non-blocking on PR #1486's review as OBJ-1385, routed to Desi as a fast-follow. (The small widget's header_row "days" label character-wrapping at 110dp minWidth, OBJ-1374, is fixed — see Width budget — small widget header row (Android) above.)

See Also

  • Dashboard — the /dashboard route the widget deep-links into (and the fallback destination when the interactive check-off isn't available)
  • Mobile Builds — iOS/Android CI, signing, and the widget-specific build-config notes, including the RemoteViews bare-<View> crash gotcha and Widget Snapshot Evidence (CI)
  • Feature Flags — dual-registration lifecycle for home_widgets_enabled, reactive_streak_widget_enabled, and kindling_widget_enabled
  • UI-SPEC.md and PLAN.md at .planning/phases/v4.1-phase-4-home-widgets/ — the full design contract and native-architecture decisions for the base widget
  • UI-SPEC.md at .planning/phases/v4.2-reactive-streak-widget-phase-1/ (flame) and .planning/phases/v4.2-reactive-streak-widget-phase-1b-stones/ (stones) — the reactive-layer design contracts
  • UI-SPEC.md at .planning/phases/v4.7-kindling-widgets-phase-2-visual-refinement/ (Small + Medium), .planning/phases/v4.7-kindling-widgets-phase-3-large-family/ (Large), and .planning/phases/v4.7-kindling-widgets-phase-4-lock-screen/ (Lock Screen accessories, including resolved Open question #1 on accessory flag governance) — the v4.7 Kindling design contracts; .planning/milestones/v4.7-kindling-widgets-ROADMAP.md for the full delivery record
  • UI-SPEC.md at .planning/phases/v4.11-android-parity-phase-2-visual-refinement/ (Small + Medium refinement, plus the Kindling × FLAME ruling) and .planning/phases/v4.11-android-parity-phase-3-large/ (Large-equivalent breakpoint) — the v4.11 Android Parity design contracts; .planning/milestones/v4.11-kindling-widgets-android-parity-ROADMAP.md for the full delivery record

Loading…