Maddy: Caregiver Surface, Consolidated Build Spec
Status: consolidation draft, in final verification. Single build-ready source for the caregiver-surface rebuild ("Between You" home, reskinned into the Things room, with per-person theming). Visual source of truth:
caregiver-home-redesign.html(plan + states + decisions) andcaregiver-reskin.html(tokens + components + theming). This doc is the engineer-facing buildable spec; the two HTML files are the rendered reference. Trackers: GitHub #231, Linear JER-633 (under JER-115).
1. Overview & scope
What this is
A single consolidated, build-ready spec for the Maddy caregiver surface, reskinned into the canon-assigned Things room (warm-neutral, hairlines, authority-from-Lexend-weight, line icons, the system's own voice, a small static companion Pip on the subject's card only). It folds the "Between You" daily-channel design (PLAN doc) and the Things-room reskin (RESKIN doc) into one spec, reconciles every stale decision against the latest resolved state, and maps every shipped caregiver capability into the new design.
The thesis
The caregiver home is a calm relationship-channel, not an operator console. Most opens are caught-up and send you away. Warmth is push-primary, consumed in place; the home never advertises waiting warmth. Receiving warmth is felt on the home (arbitrated, vanishing, exactly one warm thing per open). Sending and browsing is deliberate work behind one Manage door. "Direction is the axis" is canon.
Latest-decision reconciliation (stale dropped)
| Topic | LATEST (this spec) | Dropped as stale |
|---|---|---|
| Caregiver tone | Things tone (warm-neutral) | Subject's Calm/pastel room |
| Theming | per-PERSON theme palette x fixed Things tone via thingsTone(T) | one fixed warm-neutral palette; ":root never themed" |
| Hard-day reach-out (F5) | OWNS the whole screen; tasks wait | F5 sits above a pending Recognize |
| Manage entry | Option 3 tamed pull-up handle, opens by TAP | Option 1 gear pill; Option 2 circle-pill-is-the-door lean |
| Warmth surfacing | push-primary, never advertised on home | any badge/count/cue on home |
| Levity (F4) | decoupled fallback, empty-open only; one-liner deferred | levity in main marquee |
| Softening | cadence/optionality only, reversible, auto-apply+undo | any path that touches earned/future money |
| Push window | 6h shared coalescing/caregiver; F2 never pushes | 4-6h range |
| In-her-corner milestone | folded into the one arbiter at rank 4 | standalone card alongside Group F |
| Home model | single warm channel + Self-Care door + Manage | two co-equal MADDY/YOU cards (shipped HomeTab) |
Hard prerequisites (blocking)
- Phase 0 pronoun/name engine.
src/utils/pronouns.jsis the canonical engine (8.8KB);src/data/pronouns.jsis a 1.8KB duplicate to be consolidated/deleted in Phase 0 so all caregiver copy templates through ONE source (PRONOUNS.md / GH #141). Every literal "Maddy"/"Jeremy"/pronoun in copy below is a TEMPLATE placeholder. The companion voice cannot ship correctly without it. - GH #207 (bucket ACLs): VERIFICATION step, not a hard prerequisite.
_upload.jsPARSES legacy public URLs (line 131:/object/public/{bucket}/) and resolves all reads viacreateSignedUrlon private buckets (line 136); it does NOT write public URLs. Re-verify #207 against live bucket ACLs; if the buckets are already private at the storage layer, #207 is a Phase-2.5 verification step (confirm the levity bucket's ACL before the EXIF + short-TTL pipeline ships), not a blocking prerequisite.
In scope for v1.0 build
Groups A-F daily channel + two-way warmth, Self-Care, running-low hatch, the one Manage door (Things/Rewards/Circle/Settings), Rewards no-zero + soft-void undo, the warm-slot arbiter, levity lane (photo+sticker+Pip-joke), per-person Things theming, all shipped caregiver capabilities preserved.
Out of scope (design-forward the firewall only)
Circle comms (Pass the baton / Ask the circle), browsable Learning shelf, Community/Together graduation destination, co-caregiver YOU-zone arbiter, the matured relationship-distance launcher. Build the extensible launcher (labels-only), the reusable one-warm-thing primitive, the YOU-zone, and keep Pip bound to MADDY so these are additive later, never a rebuild.
2. Architecture & IA
The two-axis model (canon)
- Receiving warmth = felt on the home, arbitrated to exactly one item, vanishing (consumed in place, never queued/badged).
- Sending / browsing / admin = deliberate work behind the one Manage door.
Home states (the channel)
The home renders exactly ONE of these per focus, chosen by precedence:
- F5 hard-day reach-out (if unacked) - OWNS the whole screen.
- Pending Recognize (if pending > 0) - the chip cluster / Recognize. The queue is the job; Group F does not compete with it.
- One warm thing from
warmInbound.js(only when queue is empty) - F1 > F2 > milestone > F3 > F4. - Caught-up (the common open) - send-away.
Surface tree
CaregiverView (wrapper; subject-long-press lock fallback)
CaregiverDashboard (orchestrator: state + handlers + routing + theming)
HOME (the channel) -- single warm channel, replaces shipped two-card HomeTab
A1 caught-up (send-away)
A2 recognize (capped still chip cluster) + load off-ramp link
A3 look closer (one moment)
A4 the one composer (optional add-on)
B1 The After card (own card, inner-ring only)
B2 in-her-corner milestone (arbiter rank 4)
F1..F5 Group F (arbiter)
Self-Care (C1) -- top-right entry on home
Running-low hatch (C2)
Manage sheet (E1) -- bottom pull-up handle, TAP to open
Things (Tasks & budget)
Rewards (Earnings & payout, E2)
Circle (People & roles)
Settings (Theme & account)
Fade two-zone (D1) -- display layer over the same home
Soft step-down (D2) -- admin-only
Navigation rules
- Manage opens by TAP (primary). Drag is secondary and exists ONLY after the sheet is open (between detents). The resting handle sits above
env(safe-area-inset-bottom). Never usepreferredScreenEdgesDeferringSystemGestures(.bottom). Built on OS sheet primitive (UISheetPresentationController/@gorhom/bottom-sheet). - Two detents: medium = the 2x2 launcher ("choose where to go"); large = the working place (tapping a tile pushes it in and the sheet rises in one motion).
- Sub-sections use a segmented control, never bottom tabs.
- Dismiss / Done always returns to the calm home, never to the launcher (you cannot get stranded).
- Self-Care moves to top-right so the bottom edge is the handle's alone (reconciles the A1-bottom-pill vs Option-3 displacement).
- AmbientBackground is removed from the caregiver surface (it is SUBJECT-ONLY; currently mis-applied behind the whole caregiver area in CaregiverDashboard - a BRAND violation to fix).
- iPad/tablet-landscape keeps the persistent 200px sidebar (Home/Today merged into channel/manage; Things/Rewards/Circle/Settings/Care) - additive only, never regress iPhone (ADR 0006, JER-515).
Realtime
Keep all shipped channels: maddy_messages, daily_state (picks up reached_out_at), task_feedback, task_submissions, task_change_requests. Re-key on AppState -> active (iOS suspends the websocket). Add: the per-(caregiver,signal) ack ledger and levity_sends to the focus-time arbiter read (one batched debounced read, silent on failure, never from stale cache).
3. Design system (tokens, tones, theming, type, icons, motion, elevation)
Canonical caregiver default = the Dream token set (per-person theming is canon)
There is exactly ONE caregiver default palette: the Dream token set (see the tokenized table below; surface #EFEDF4 / card #F8F6FB / well #E9E6F1 / ink #2B2733 / accent #6B5FA8). App.js renders the Dream token set when profiles.theme is unset (resolved through thingsTone(THEMES.dream)). The old ":root never themed" / fixed warm-neutral model is DROPPED (see §1 reconciliation). All caregiver color is per-person palette x fixed Things shape; there is no separate untintable fallback palette.
Pre-theme-load bootstrap splash (never shown to a user; not a default)
These tokens are ONLY the brief static splash painted before profiles.theme resolves on cold launch. They are not a theme, not a fallback default, and no user ever sees a rendered surface in them. The moment the profile resolves, the Dream token set (or the user's chosen theme) takes over.
| Token | Hex | Use |
|---|---|---|
| --cg-surface | #F4EFE6 | bootstrap-only page cream |
| --cg-card | #FBF8F2 | bootstrap-only inset off-white (NOT pure white) |
| --cg-well | #F0EBE2 | bootstrap-only Pip well / soft fill |
| --cg-line | #E7DECE | bootstrap-only warm hairline |
| --cg-ink | #2B2620 | bootstrap-only warm cocoa-black (NOT cool slate) |
| --cg-dim | #6E655B | bootstrap-only body-secondary taupe |
| --cg-meta | #8B8175 | bootstrap-only eyebrow / meta warm grey |
| --cg-accent | #6B5FA8 | bootstrap-only plum |
| --cg-soft | #F0E9DC | bootstrap-only soft secondary-action fill |
| --cg-clay | #B4503A | bootstrap-only destructive |
thingsTone(T) / presenceTone(T) derivers (NET-NEW; do not exist in src/)
- thingsTone(T): takes any of the 8 themes' tokens and produces the restrained Things SHAPE: flat surface, hairlines, ONE accent, line icons, NO ambient gradient. Shape is stable across all themes (shape-stability IS the load-reduction); only HUE changes.
- presenceTone(T): relaxed outer-ring variant, warmer than Things, no gradient, no narration. (Outer ring only; not on caregiver surface.)
- Subject keeps reading T directly (Calm tone).
- Map onto existing
T.sensory.*(surface, surface2, line, text, textDim, tint) andT.primary; pull type fromsrc/styles/typography.js, never hardcode.
thingsTone(T) input/output contract (LOCKED)
Input: a full theme object T from src/styles/theme.js (one of the 8), exposing T.sensory.{surface, surface2, line, text, textDim, tint} and T.primary, plus T.isCandy (true for Bubblegum / Cotton Candy Sky / Mint Fresh / Golden Hour) and T.isDark (true for Night Owl).
Output: a flat token object
{ surface, card, well, line, ink, dim, meta, accent, accentInk, soft }
shape is identical for every theme; only the hex values differ.
Derivation map (token <- source):
| Out token | Source | Operation |
|---|---|---|
| surface | T.sensory.surface | candy: pass through chroma-cut first (see below); else as-is. Drop any ambient gradient. |
| card | T.sensory.surface2 | inset off-white-equivalent in-family; NOT pure white. Candy: chroma-cut. |
| well | T.sensory.tint | the soft Pip-well fill, lightened toward card (mix ~50% with card). |
| line | T.sensory.line | warm/in-family hairline; the ONLY structural divider. |
| ink | T.sensory.text | primary text; Night Owl keeps its light ink. |
| dim | T.sensory.textDim | secondary text. |
| meta | derived from ink |
mix(ink, surface, 0.45), the eyebrow/meta grey, in-family, NOT a fixed grey. |
| accent | T.primary | the ONE accent; used at full strength only for large/solid fills (cgprimary). |
| accentInk | darken(accent) |
the in-family accent darkened until it clears WCAG AA (>=4.5:1 on card) at small text/icon sizes. Method OPEN (§15 #1): hand-tuned per-theme lookup vs programmatic darkening. Programmatic reference: rotate accent into OKLCH, lower L in steps of 0.04 until contrast(accentInk, card) >= 4.5, keep H/C. Use accentInk for ALL small accent text + line icons; never the raw accent. |
| soft | well |
tint of well (mix ~60% well with surface) for secondary-action fills (cgsoft). |
Candy chroma-cut branch (before deriving surface/card/well): for T.isCandy, first reduce the chroma of surface, surface2, and tint toward neutral (drop saturation; reference target ~35-50% of source chroma, exact amount per theme is OPEN, §15 #2) and DROP the ambient gradient entirely, THEN run the map above. Cotton + Mint fail AA raw and need the deepest cut; Bubblegum/Golden are borderline. Night Owl (T.isDark) skips the chroma-cut, keeps a real dark track (card #1A1A24, never pure black), and lightens accent to #9D8CFF for contrast.
Reference token output, Dream (default):
thingsTone(THEMES.dream) -> {
surface: '#EFEDF4',
card: '#F8F6FB',
well: '#E9E6F1',
line: '#E2DEEC',
ink: '#2B2733',
dim: '#6B6678',
meta: '#9893A4',
accent: '#6B5FA8',
accentInk:'#574B91', // darkened plum, clears AA on #F8F6FB at 13-15px
soft: '#E9E6F1',
}
Reference token output, Bubblegum (candy branch, chroma-cut + accentInk):
thingsTone(THEMES.bubblegum) -> {
surface: '#F6EEF0', // chroma-cut from raw bubblegum pink
card: '#FDF7F9',
well: '#F1E6EA',
line: '#ECDDE3',
ink: '#2C2327',
dim: '#6E5C63',
meta: '#9C8A91',
accent: '#E0567E', // raw display accent (large/solid only)
accentInk:'#C23B5E', // darkened, clears AA on #FDF7F9 at small sizes
soft: '#F1E6EA',
}
presenceTone(T): same output shape, warmer (skips part of the chroma-cut, keeps slightly higher tint warmth), still no gradient/narration. Outer ring only; never invoked on the caregiver surface.
accentInk + candy/dark handling (per-role theming)
- accentInk: darkened in-family accent for small text/icons to clear WCAG. OPEN: hand-tuned per-theme lookup vs programmatic darkening - lock before all-theme ship.
- Candy themes (Bubblegum, Cotton Candy Sky, Mint Fresh, Golden Hour): hard chroma-cut surface + gradient dropped + accentInk. Cotton + Mint FAIL WCAG raw; Bubblegum/Golden borderline. OPEN: exact chroma-cut amount per theme needs a visual + WCAG pass.
- Night Owl: real dark Things track - off-black card #1A1A24 (NEVER pure black), hairlines, lavender accent #9D8CFF lightened for contrast; distinct from OS dark mode.
- Caregiver default when unset = Dream Space (never candy by accident). LOCKED: App.js renders
thingsTone(THEMES.dream)whenprofiles.themeis unset (see the canonical-default section above).
Tokenized theme sets (4 of 8 done; 4 outstanding)
| Theme | surface | card | well | line | ink | dim | meta | accent | soft |
|---|---|---|---|---|---|---|---|---|---|
| Dream (default) | #EFEDF4 | #F8F6FB | #E9E6F1 | #E2DEEC | #2B2733 | #6B6678 | #9893A4 | #6B5FA8 | #E9E6F1 |
| Bubblegum | #F6EEF0 | #FDF7F9 | #F1E6EA | #ECDDE3 | #2C2327 | #6E5C63 | #9C8A91 | #C23B5E (darkened) | #F1E6EA |
| Ocean | #EDF1F5 | #F7FAFC | #E6ECF2 | #DDE5EC | #22272E | #5C6670 | #93A0AC | #2B63C7 | #E6ECF2 |
| Night | #0E0E14 | #1A1A24 | #232333 | #2E2E3C | #F2F2F7 | #A6A6B2 | #76768A | #9D8CFF | #232333 |
| Mint / Cotton / Golden / Pillow Fort | OUTSTANDING - derive via thingsTone + chroma-cut + accentInk before all-theme ship |
Theme-bleed guardrail (LOCKED)
Subject-owned fragments inside the caregiver view keep HER hue, never the caregiver's accent: the bridge Pip, "in her words" (The After + gratitude), her avatar / hue dot. The two identities never collapse. (The recognize-chip dot is shown as the subject hue marker; confirm it themes to the subject's actual chosen theme vs a fixed flower glyph.)
Type ramp (Lexend; src/styles/typography.js)
Authority comes from WEIGHT, not chrome. LargeTitle 34/700, Title 28/700 (hero money), Title2 22/600, Title3 20/600, Headline 17/600, Body 17/400, Callout 16/400, Subhead 15/400, Footnote 13/400, Caption 12/500, SectionHeader 13/600 uppercase tracked. Surface-specific: cgstate/cgtitle 21/700, moneyhero 27/700 plum, eyebrow cglabel 10/700 tracked uppercase --cg-meta, quote 16/500 warm cocoa (the ONE subjectivity-leads moment), tabular-nums (.tnum) on ALL money/figures. Never hardcode fontSize/weight; pass color from theme, never hex.
Icons
Line/stroke icons (Ionicons / SVG stroke 1.6-2.4, round caps, currentColor) for ALL affordances: bell, card/camera, list, people, gear, chevron, back-chevron, heart, leaf, target, headphones, chat-bubble, up-arrow. Accent icons plum; inert/meta icons --cg-meta. Emoji reserved ONLY for: subject companion Pip (in well), subject hue dot, real task emoji in recognize chips + reward ledger rows, identity avatars, theme swatches, celebration. NO emoji-as-status-icon; NO emoji-as-care-tile.
Motion
At-rest motion REMOVED (caregiver = restraint): no breathing, no float on the chip cluster. Pip is small + STATIC. The ONLY animation is one gentle fade-in on the in-her-corner medallion (no at-rest glow). Reach motion via T.motion.* (MOTION slow/normal/lively). GLOBAL Reduce Motion fallback freezes every breathe/float/reveal (useReducedMotion() already honored). Surprise lives in content, never in sensory delivery.
Elevation
Hairlines do the structural work. NO drop-shadow cards (dadCard anti-pattern). The ONLY allowed soft shadow is the Manage sheet: box-shadow: 0 -12px 34px rgba(43,38,32,.18); scrim rgba(43,38,32,.32), background dimmed to ~.4.
4. Component library
Reused shipped primitives (BRAND section 8)
| Component | File | Notes |
|---|---|---|
| Section | src/components/ui/Section.js | inset grouped list on surface2 |
| Row | src/components/ui/Row.js | 44pt min, hairline, tone='destructive', iconName, chevron, subtitle, value |
| AmbientBackground | src/components/ui/AmbientBackground.js | SUBJECT-ONLY; REMOVE from caregiver surface |
| CirclePill, CaregiverMilestoneCard, BetaWelcomeCard, FeedbackNudgeCard, VoiceNotePlayer, PayoutHistory, CustomRewardModal, SortableTaskList, RecognizeBatchModal, ApprovalModal, TaskEditModal, AddTaskModal | src/components/* | preserved, reskinned to Things tone |
New / reskinned Things-room components
| Class | Role |
|---|---|
| .cgsec | card: --cg-card bg, --cg-line border, 16px radius, 15px pad |
| .cgpill | chrome pill: 999px, --cg-card, hairline, 12/600, plum icon |
| .cgrow | inset list row: 12px vert pad, top hairline, line icon + lbl + chevron/val |
| .cgprimary | plum solid button, #fff text, 14px radius, 14.5/600 |
| .cgsoft | secondary: --cg-soft bg, hairline, plum icon |
| .cgquiet | centered quiet text link, 12.5px, --cg-dim |
| .cgwell | 30px circle well (holds Pip / subject emoji) |
| .cgcontract | reliability-contract row, bell icon, top hairline |
| .moneyhero | 27/700 plum tabular |
| .cluster / .chip | recognize chips 62px, dot badge 21px, capped, countless, NO float |
| .proof | hairline image frame #EFE7DA, NO candy gradient |
| .quote / .sig / .reasons | The After + gratitude (warm cocoa subjectivity) |
| .medallion | 96px cream, hairline, heart icon, one fade-in (in-her-corner) |
| .cghandle | grab pill 40x5 + 'Manage' label (teach-once-then-recede) |
| .scrim / .sheet / .seg | Manage sheet + segmented control |
| .fz / .fzher / .fzyou | fade two-zone (her/you carried by LABEL + SIZE, never tint) |
| .innote / .reactstrip / .reactchip | Group F gratitude / reaction |
| .circlerow / .cav | F3 circle presence (non-numeric avatars) |
| .levphoto / .jokecard / .modgrid / .modt | levity surfaces |
| .stepcard | D2 admin soft step-down |
Buttons
- cgprimary (plum, one per screen max), cgsoft (secondary), cgquiet (text link), the After cgsoft-fix ('Soften this task').
Component invariants
- Chip cluster: capped (3), countless, NEVER scrollable, NEVER accretes (not a feed of her activity).
- Manage handle: ONE VoiceOver element, color-independent by shape, NO badge/count/dot ever, identical no matter how much is behind it.
- The After is its OWN card, never folded into the composer.
- Composer: plain field + emoji quick-picks (A4) or plain field only (reskin); NO turtle messenger, NO read-receipt, NO sent log.
5. Per-surface specs
Each surface lists layout / copy / components / data / interactions / edge cases / acceptance. All copy is a TEMPLATE (names/pronouns via the engine).
A1 Caught-up (the common open)
- Layout: top-left CirclePill ('[subject]'s circle'), top-right Self-Care (heart). MADDY section: eyebrow 'MADDY', 30px well holding subject emoji, state 21/700 'Good today'. Contract row (bell): 'Nothing needs you right now. You'll get a notification the moment anything does.' Earned row (card icon): '[subject] has earned $14' + 'this week, 3 things recognized' + chevron. Bottom: Manage handle.
- Components: cgsec, cgwell, cgcontract, cgrow, cghandle.
- Data: loadTodayState (read-only), loadWeekEarnings, count of recognized this week.
- Interactions: the ONE offer is reach her first (no prompt/chore); tapping earned row -> Manage > Rewards.
- Edge: no count, no watching-eye, no refreshable state.
- Acceptance: opens silent; no badge/count/cue advertising waiting warmth; AmbientBackground absent.
A2 Recognize (something to recognize)
- Layout: eyebrow 'MADDY', title 'A few things to recognize'. Still chip cluster (max 3, 62px, subject-hue dot badges, NO motion). Primary 'Recognize · $3' (tabular). Quiet 'Tap any one to look closer'. Quiet load off-ramp 'Running low? Quiet things down'. Manage handle.
- Data: loadPendingSubmissions; batch credits each to its own for_date via atomic idempotent RPC (approveSubmissions) - no double-pay.
- Interactions: 'Recognize' blesses all (a wave of warmth) -> onRecognizeBatch / RecognizeBatchModal; load off-ramp -> C2.
- Edge: cluster never scrolls/accretes/shows a count; > 3 pending still shows 3.
- Acceptance: batch recognize works; cluster capped & countless; Group F suppressed while pending > 0.
A3 Look closer (one moment)
- Layout: Back chevron + subject pill. Section 'A MOMENT FROM [subject]', proof image in hairline frame (#EFE7DA). Title 'Took a shower' + inline $2 (moneyhero 18px). Primary 'Recognize this'. Quiet 'Ask for a change'.
- Data: single submission -> ApprovalModal; proof via signed short-TTL read (honor #139/#207).
- Interactions: 'Recognize this' = per-item approve; 'Ask for a change' = warm redo (resolveSubmission needs_redo with required warm note) or needs_proof.
- Edge: redo never red, never balanced approve/deny, never withholds her win.
- Acceptance: per-item recognize + warm redo + photo-request all work; force-complete supersedes only TODAY's open submission.
A4 The one composer (optional add-on)
- Layout: Back + subject pill. Title 'Send [subject] a note'. Sub 'She'll see it when she's ready. No pressure to reply.' Placeholder quote field 'So proud of how you showed up today.' (emoji row 💛 ✨ 🌸 🫶 ☀️ optional). Primary 'Send'. Micro 'It lands on her home as a note from you.'
- Data: writes encouragement_notes (or caregiver_notes for bidirectional inner-ring). Carries reply-to-a-win AND out-of-the-blue.
- Edge: OPTIONAL; recognizing is a complete standalone tap. NO receipts, NO sent log either direction.
- Acceptance: composer optional; no read-receipt anywhere.
B1 The After ('what [subject] told you')
- Layout: eyebrow 'IN HER WORDS' (chat icon). Her words warm cocoa quote 16/500 'The shower felt like a lot today.' Reason pills ('too much', 'sensory'). Voice note '🎧 She left a voice note · 0:08' (VoiceNotePlayer). Primary 'Soften this task'. Fixhint 'Suggested: make it optional on tough days'. Quiet 'Or just send her a note'.
- Data: reads task_feedback (valence/reasons/note/audio_url); INNER-RING ONLY RLS; dedupe by task|valence with ·N, durable dismiss, auto-mark-seen after 2.5s, 'Clear all'. Feeds feedbackCadence.js.
- Interactions: 'Soften this task' writes task CONFIG only (partial_credit / requires_approval / schedule), reversible; lean AUTO-APPLY 'optional on tough days' with undo (carry, don't present). Admin-only 'Adjust this task' -> TaskEditModal.
- Edge: NEVER folded into the composer; NEVER touches earned/future money; outer ring CANNOT read.
- Acceptance: own card; softening never changes any money; inner-ring-only confirmed; surfaced on home (rough line) + Today (full card).
B2 In-her-corner (JER-569 milestone; arbiter rank 4)
- Layout: centered 96px cream medallion (hairline, heart icon, ONE fade-in, no glow). State 'Maddy passed $100 she earned herself.' Sub 'You made the room for that. Quietly, every day.' Dismiss 'Close'.
- Data: caregiverMilestones.js (7/30/100/365), monotonic, dismissable, shown once.
- Edge: descriptive figure, NEVER a tier, NEVER previews next threshold, NO running day-count/streak. Folded into arbiter at rank 4 (milestone OR a Group F moment, never both).
- Acceptance: monotonic, never earnable, never a count/last-seen.
C1 Self-Care (your corner)
- Layout: Back. Title 'For you, [caregiver].' Sub 'Supporting is heavy work. This part is yours.' Inset list (line icons + chevrons): Breathe 'A one-minute reset', Ground 'Five senses, slow', Sounds 'Plays while you work'. 'Good to know' section: card 1 masking guardrail ('Reward that a need got met, not how "normal" it looked. It keeps tasks honest to who she is.'), card 2 the fade ('You needing the app less, over time, is the win. Not a sign you are doing it wrong.').
- Data: reuses subject Relax/Ground/Sounds screens; education accordion (3 micro-cards, pronoun-aware).
- Edge: a DOOR, never a prompt: NO badge, NO count, NO 'did you self-care?'.
- Acceptance: always available; zero nag; Sounds runs while you work.
C2 Running low / quiet things down (the hatch)
- Layout: Pip well. Title 'Let's quiet things down.' Sub 'I'll carry today. Nothing here will pull at you.' Body 'Maddy won't be left waiting. She'll see a warm note: "Seen. I'll get to these when there's room."' Soft button 'Take a breath' (target icon).
- Data: NEW running-low hold + holding-note-to-subject; on hold, send subject the warm note then route caregiver to a breath. (handleHold pattern, never strands the subject.)
- Edge: ZERO self-report; defaults carry; subject never stranded.
- Acceptance: no 'how are you feeling?'; subject gets the holding note.
D1 The fade (months later)
- Layout: two-zone. HER zone (fzher, smaller): well + 'MADDY' + 'Maddy's mostly got this now.' YOU zone (fzyou, larger): 'FOR YOU' + 'More and more, this part is just yours.' + 'She's doing more on her own, because of the foundation you built.' + condensed Self-Care rows.
- Data: display layer over the regulation arc (ship dormant in P1; cannot compute a level at v1.0 - do not show a level we can't compute).
- Edge: her/you carried by LABEL + SIZE, never tint; framed as HER growth + YOUR success, never emptiness/grief; no metric/downward trend.
- Acceptance: same layout, no rebuild; honest (no fabricated level).
D2 Soft step-down (admin only)
- Layout: Pip + 'Maddy's been steady for a while.' Stepcard label 'A gentle idea, just for you', title 'Want to offer her a little more room?', body 'You could let some tasks run without a check from you. She can take it or leave it.', button 'Offer it to Maddy', note 'She decides. Nothing changes unless she says yes.'
- Edge: admin-caregiver ONLY (can_access_settings); suggest never decide; subject accepts/declines; never guilt.
- Acceptance: asymmetric authority; caregiver never moves her level directly.
E1 Manage sheet
- Layout: scrim over dimmed home; sheet (warm-neutral, rounded 26px top, the one allowed shadow), grab pill + 'Manage' + plum 'Done'. Segmented Things | Rewards | Circle | Settings. Medium detent = 2x2 launcher with one-line subtitles; large detent = the working place.
- Interactions: opens by TAP; drag only between detents after open; tile tap pushes place in + rises to large; sub-sections use segmented control; Done/back -> calm home (never launcher).
- Edge: The resting HANDLE carries zero state (canon: no count/badge/dot/preview ever). The medium-detent 2x2 tiles carry a one-line NON-NUMERIC subtitle only (e.g. 'Tasks & budget', 'Earnings & payout'). The working rows INSIDE each section show real counts/values/figures (e.g. 'Tasks · 12 active, 1 weekly', 'Weekly budget · Up to $25', 'earned $14'). The labels-only-forever rule applies to the FUTURE relationship-distance section launcher ('This circle / For you / Together'), NOT to these working rows.
- Acceptance: TAP-to-open; no system-swipe conflict; returns to home.
E2 Rewards (money done right)
- Layout: Back + subject pill. Eyebrow 'REWARDS'. Label 'Maddy has earned this week' + moneyhero '$14' (plum tabular). Primary 'Pay Maddy $14'. Ledger rows (🚿 'Took a shower' $2 + Undo; 🛏️ 'Made her bed' $2 + Undo; 🌙 'Yesterday / A rest day' = the no-zero placeholder). Footnote 'Undo just voids it gently. Nothing is taken from her.' Reward-style picker (admin: Money/Screen Time self-back + Custom gated backing).
- No-zero placeholder glyph (LOCKED): a no-earnings row renders as an en dash '–', a small centered dot '·', or a '(rest day)' label. Never '$0' and never an em dash. Pick ONE per surface and use it consistently (recommend '(rest day)' on a known rest day, the centered dot '·' otherwise).
- Data: earnings + payouts; recordPayout (Crypto idempotency token) full + partial; voidPayout (soft void, auto-fade 8s); PayoutHistory modal; updateSubject reward config.
- Edge: NO-ZERO everywhere (the no-zero placeholder is an en dash '–', a centered dot '·', or '(rest day)', never '$0' and never an em dash; $0 week = 'Nothing to settle up', no Pay-$0). Line items name the moment loosely (never logs WHEN she showered). Undo = soft void.
- Acceptance: full + partial payout, undo, history, reward-style all work; no-zero incl. aggregate; subject-glance reads as a tally.
6. The warm-slot arbiter (warmInbound.js)
Contract
One named module src/utils/warmInbound.js (NET-NEW). Runs once per home focus, queries every warm pipe in ONE batched debounced read, returns exactly one item (or null). Silent on read-failure (never an error state on the calm home). Never reads from stale cache. Daily reset is the backstop (nothing carries forward as backlog).
Dedup key (the arbiter "unseen" definition)
"Unseen" for the arbiter = the ABSENCE of a matching warm_acks row for (caregiver_user_id = auth.uid(), signal_type, source_id). The arbiter dedups ONLY against the per-(caregiver,signal) warm_acks ledger. The subject-facing seen_at columns on maddy_messages / caregiver_notes / daily_state are SUBJECT state and are NOT the arbiter dedup key (a caregiver must get their own moment regardless of whether the subject has seen anything; multiple caregivers each ack independently). Likewise daily_state is never the F5 ack store (§7).
Batched query shape
One batched, debounced read per focus: read each warm pipe (F5 daily_state.reached_out_at, F1 maddy_messages kind='gratitude' / encouragement_notes.to_user_id, F2 caregiver_notes.reactions, milestone caregiverMilestones, F3 circle-presence eval, F4 levity_sends) and LEFT JOIN each candidate to warm_acks on source_id + caregiver_user_id = auth.uid() + matching signal_type, filtering to rows where the joined ack is NULL (i.e. unacked by THIS caregiver). Collect the unacked candidates, then apply the locked precedence + most-recent-unseen tiebreak to pick exactly one. On any read error return null (plain caught-up home).
Precedence (LOCKED)
F5 (hard-day reach-out) > F1 (gratitude) > F2 (reaction) >
in-her-corner milestone > F3 (circle presence) > F4 (levity)
Ties within a tier break by most-recent-unseen.
Suppression rules
- Group F never competes with a pending Recognize. If pending > 0, the arbiter returns null and the queue (A2/A3) owns the home. The queue is the job; Group F is what the EMPTY queue gets to feel like.
- The ONLY exception is F5: the hard-day reach-out OWNS the whole screen above everything, including a pending Recognize. One quiet line notes the wins are there ('Her wins are here when you're ready.'). No cluster, no competing CTA.
- The in-her-corner milestone is rank 4 INSIDE this arbiter - a caught-up open shows the milestone OR a Group F moment, never both.
- Levity (F4) is the decoupled fallback - returned only on a truly-empty open so higher items never starve it.
Output shape (suggested)
{ type: 'F5'|'F1'|'F2'|'milestone'|'F3'|'F4', payload, signalKey, sourceId }
// signalKey keys the per-(caregiver,signal) ack ledger
Acceptance criteria
- Returns at most one item per focus; never two warm things stacked.
- Order matches the locked precedence; ties resolve most-recent-unseen.
- Returns null while pending > 0 (except F5).
- Read failure renders the plain caught-up home, never an error.
- After display, the item is acked in the ledger and does not resurface (per caregiver).
- Everything not surfaced is held silently in its own durable home (never queued/badged/+N).
7. Group F signal specs
All Group F is she-initiated, pushed, forward-only. Never a read-receipt, never a feed, never 'who didn't show'. The Circle reads as warmth toward HER, never her activity log. Pushed on the non-urgent channel with content-free copy.
| Signal | Copy (template) | Components | Pipe | Push | Guardrails | Acceptance |
|---|---|---|---|---|---|---|
| F5 hard-day reach-out | 'Maddy reached out today.' / 'Today felt heavy. She wanted you to know.' / btn 'Be in her corner' / quiet 'Her wins are here when you're ready.' | Pip well 48px, cgprimary (max 210px) | TWO distinct artifacts: (a) daily_state.reached_out_at = SUBJECT-WRITTEN (RLS subject-updates-own, subject TZ, SHIPPED, subject-readable), the fact she reached out; (b) F5 caregiver ack = a caregiver-written warm_acks row (signal_type='F5'), SUBJECT-UNREADABLE at RLS. Never store the caregiver F5 ack on daily_state. |
yes, content-free | consent-gated; soft tint + heart; NEVER red/banner/modal; guided to warmth, never 'are you okay?'; silence still means okay; OWNS the screen | reach-out owns screen; ack subject-unreadable; no alarm |
| F1 gratitude | eyebrow 'MADDY LEFT YOU SOMETHING'; quote 'Thanks for not giving up on me. Today was a better one.'; sig 'From Maddy'; quiet 'Say something back' | small static Pip well 22px (the bridge), quote | maddy_messages + NEW kind='gratitude' column (+ backfill); OR encouragement_notes.to_user_id (SHIPPED) | yes, content-free | the #1 evidence-backed lever; arrives quiet, never a stat; no confetti | renders as a quiet hero moment; 'say something back' -> A4 |
| F2 reaction | 'She sent a little something back.'; reactstrip = your outbubble + her reactchip | reactstrip, outbubble, reactchip | caregiver_notes.reactions (SHIPPED) + NEW from_user_id for routing (forward-only; old notes have no author) | NEVER pushes | her CHOSEN reaction; never 'seen at 3:14'; never a reaction count; removed reaction is silent | reaction shown; F2 never pushes; no receipt semantics |
| milestone (rank 4) | see B2 | medallion | caregiverMilestones.js (SHIPPED) | (no push; surfaces in-app) | monotonic; no count/next-threshold | folded into arbiter; never alongside Group F |
| F3 circle showed up | copy by supporter count (see F3-crossing trigger below) | circlerow, cav | milestone_shares / encouragement_notes presence (SHIPPED build 67) + NEW consent column on subjects (share_circle_presence_with_caregiver, DEFAULT OFF) + RLS honoring outer_ring_sharing.paused_until + NEW F3-crossing evaluator (migration #7) | yes, content-free | non-numeric; forward-only; never 'who didn't show'; NEVER reveal a supporters-only hard-day by inference; consent toggle on Maddy's side only | shows Circle warmth toward HER; consent default OFF; pause honored |
| F4 levity (decoupled fallback) | 'Maddy sent Pip over with a joke.'; jokecard setup + 'Tap for the rest 👀' / OR levphoto + caption | jokecard, levphoto, reactchip | NEW levity_sends table + private bucket | yes, content-free | content-surprise only; still photo v1, no autoplay; punchline out of a11y tree until revealed; never a system notification | empty-open only; never starves marquee |
F3-crossing trigger (migration #7 evaluator)
- Crossing rule: fire F3 when >= 3 distinct supporters sent warmth toward the subject within ONE subject-local day (matches the 'Nana and two others' copy: 3 = Nana + two others). Below 3 the signal does not fire (1 or 2 supporters do not cross).
- Fire-once-per-day: at most one F3 per subject-local day; the day window is computed in the SUBJECT's timezone (
subjects.timezone), midnight-to-midnight local. Subsequent supporters that day do NOT re-fire. - Distinct = distinct supporter
from_user_idacross milestone_shares / encouragement_notes presence in that local-day window; de-dupe per supporter (one supporter sending twice counts once). - Copy variants (non-numeric framing; threshold is 3+, but spell the warmth, never the raw count):
- 3+ supporters: 'Maddy's circle was in her corner today.' / 'Nana and two others sent her warmth.' / 'You are not carrying this alone.'
- (No 1-supporter or 2-supporter F3: the signal only exists at 3+. If product later wants a 1/2 variant it is a separate decision; v1.0 fires at 3+ only.)
- Honors consent (default OFF) and
outer_ring_sharing.paused_until; never reveals a supporters-only hard-day by inference.
Empty default (F+)
Most opens: Pip lg, 'All quiet with Maddy.', 'I'll find you if anything changes.', Self-Care. Absence is NEVER a deficit - no 'nothing today' sad-state, no empty inbox. Group F is the exception, not the feed.
Levity send (Maddy's side, NEW build)
From her Laughs screen: title 'Send Jeremy a laugh', sub 'Pip will carry it over.', modgrid of 4 modalities (📷 Photo, ✨ Sticker, ✍️ One-liner [DEFERRED to fast-follow], 🐢 Pip joke), micro 'Whenever. No pressure, nothing owed.' Large tap targets. Send-cap 2/day, 5/week LAND for the caregiver; excess silently coalesced (her tap never gets a 'no'). Cap is invisible to Maddy - her Laughs screen looks identical at send 1 and send 6 (no count/quota/cooldown/disabled tile). Photo: private bucket, EXIF stripped on device + server, short-TTL signed reads, never a public URL; on strip-failure the photo is dropped and a sticker/joke lands instead.
Definition of "land": a send "lands" = a levity_sends row is INSERTED and is ELIGIBLE for the arbiter (i.e. it can be surfaced as F4 on a future empty open). The send-cap counts INSERTS that land, not surfacings. Maddy's tap ALWAYS succeeds (a row is always recorded for HER side / her sense of having sent), but only up to 2/day and 5/week are marked eligible-to-land for the caregiver; the rest are coalesced. Over-cap rule (CHOSEN): drop the over-cap send silently from the caregiver-eligible set. The excess insert is recorded (so her Laughs screen and any future "things I sent" view are honest) but flagged not-eligible, so it never surfaces to the caregiver and never replaces an already-landed one. (The alternative, replace-newest-unsurfaced, is rejected: it would let a 6th send bump an unseen earlier laugh the caregiver hasn't gotten to.)
8. Data model (tables, columns, migrations, RLS)
Shipped pipes the rebuild rides (verified in src/)
| Table | Verified columns / behavior |
|---|---|
| profiles | theme is per-user (App.js resolves THEMES[profile.theme]) -> per-PERSON theming needs NO migration; push_token via claim_push_token |
| subjects | reward config, timezone, week_start_day, outer_ring_sharing |
| groups/group_members/group_invites | role subject/caregiver/supporter/peer; can_access_settings=admin; assigned_subjects; last_seen_at |
| tasks | full CRUD + schedule + partial_credit |
| earnings / payouts | payouts.voided_at/voided_by (soft void) |
| daily_state | entry_state, updated_at, reached_out_at SHIPPED (dailyFlow.js:274,325); writes refuse null subject_id |
| caregiver_notes | message, audio_url, from_name, from_emoji, reactions SHIPPED; from_user_id is NOT referenced by any shipped read (the caregiver_notes SELECTs at caregiverActivity.js:40 and :159 omit it; the from_user_id at :64 is on encouragement_notes). Treat as a REQUIRED ADD migration (see migration #2). |
| maddy_messages | message, reactions, seen_at (NO kind column yet) |
| task_completions | shared_with_outer_ring |
| task_feedback | valence/reasons/note/audio_url/seen_at; RLS INNER-RING ONLY |
| task_change_requests | JER-571, kind change/remove, status open |
| task_submissions | dated approval ledger, for_date |
| encouragement_notes | outer->subject + to_user_id subject->supporter gratitude (outerRing.js:496) |
| milestone_shares | reactions jsonb |
Note: caregiver_notes.from_user_id is NOT referenced by any shipped read (the caregiver_notes SELECTs at caregiverActivity.js:40 and :159 omit it; the from_user_id at :64 is on encouragement_notes, which genuinely carries it). Treat caregiver_notes.from_user_id as a required ADD migration (migration #2) with a real prod-schema check (information_schema.columns) before assuming it exists; F2 routing is forward-only regardless (old notes carry no author key, so they never become F2 candidates).
Required migrations (small; ~70% rides existing pipes)
- maddy_messages.kind text + BACKFILL (F1 gratitude vs ordinary; backfill so history doesn't replay as unseen warm items on first post-migration open).
- caregiver_notes.from_user_id REQUIRED ADD (not present in any shipped read; run a prod-schema check via
information_schema.columnsfirst, thenADD COLUMN from_user_id uuid REFERENCES auth.users(id)if absent). F2 routing is forward-only: existing rows have no author key and never become F2 candidates. - subjects.share_circle_presence_with_caregiver boolean DEFAULT false + RLS honoring outer_ring_sharing.paused_until; must never reveal a supporters-only hard-day by inference.
- The ack ledger (see below).
- levity_sends table + verified-PRIVATE media bucket + server + on-device EXIF strip/re-encode + send-cap trigger (2/day, 5/week land, excess silently coalesced); RLS inner-ring, subject->caregiver only (mirrors task_feedback insert-own); short-TTL signed reads regenerated per view. GH #207 is a VERIFICATION step, not a blocker here:
_upload.jsdoes NOT write public URLs (it PARSES legacy public URLs at line 131 and resolves all reads viacreateSignedUrlon private buckets at line 136). Before this pipeline ships, re-verify the levity bucket's ACL is private at the storage layer; if already private, #207 is a Phase-2.5 confirmation only. - Durable warm-push coalescing store + scheduled flush (one shared 6h window per caregiver, newest replaces queued, F2 never enqueues); device-tokens table if caregivers run multiple devices. Schema + mechanism:
warm_push_queue (
id uuid pk default gen_random_uuid(),
caregiver_user_id uuid not null, -- recipient
group_id uuid not null,
window_start timestamptz not null, -- start of this caregiver's rolling-6h bucket
latest_signal_type text not null, -- F5|F1|milestone|F3|F4 (NEVER F2)
latest_source_id text not null, -- the row the newest event points at
scheduled_for timestamptz not null, -- when the flusher should send (= window_start + interval '6h', clamped: F5 may flush near-immediately)
sent_at timestamptz -- null until flushed
)
-- one open (unsent) row per caregiver per window bucket:
create unique index warm_push_queue_open
on warm_push_queue (caregiver_user_id, window_start)
where sent_at is null;
- Per-caregiver rolling-6h window key:
window_start = date_trunc('hour', now()) - ((extract(hour from now())::int % 6) * interval '1 hour')per caregiver (a 6h bucket), OR simpler: the window_start of the caregiver's currently-open (sent_at IS NULL) row; if none is open, a new bucket opens atnow()andscheduled_for = now() + interval '6h'. Either way there is at most ONE open bucket per caregiver at a time. - "Newest replaces queued" = UPSERT on the open bucket:
INSERT ... ON CONFLICT (caregiver_user_id, window_start) WHERE sent_at IS NULL DO UPDATE SET latest_signal_type = EXCLUDED.latest_signal_type, latest_source_id = EXCLUDED.latest_source_id. The body always carries the NEWEST event; onlylatest_*fields are replaced (window_start and scheduled_for are NOT bumped, so the 6h window does not slide forward on each new event). Concurrent inserts resolve via the unique partial index (one winner per bucket; the loser's UPDATE replaces latest_*). - F2 never enqueues (no row is ever inserted with
latest_signal_type='F2'). - Flush mechanism (NAMED): a pg_cron-triggered edge function runs every few minutes, selects open rows where
scheduled_for <= now(), sends ONE content-free push per row (newest content), and setssent_at = now()(which releases the partial unique index so the next event opens a fresh bucket). F5 may setscheduled_for = now()to flush near-immediately while still coalescing any other warm event in the same window. - Acceptance tie-in: two warm events within 6h share one open bucket -> ONE push (newest content) when the flusher fires. 7. F3-crossing evaluator (computes the circle-presence signal) + F5/F3 push fan-out.
The ack ledger (the keystone new table)
ONE per-(caregiver, signal) seen/ack table that resolves: dedupe, multi-caregiver per-caregiver-seen, and keeps the F5 reached-out ack SUBJECT-UNREADABLE at the RLS layer.
warm_acks (
id uuid pk,
caregiver_user_id uuid, -- the acking caregiver
group_id uuid,
subject_id uuid,
signal_type text, -- F5|F1|F2|milestone|F3|F4
source_id text, -- the underlying row
acked_at timestamptz
)
RLS: a caregiver SELECTs/INSERTs only their OWN acks (caregiver_user_id = auth.uid()); the subject can NEVER read this table (so the F5 ack is invisible to her); no caregiver reads another caregiver's ack (each gets their own moment, no cross-caregiver receipt).
RLS posture to preserve (do not regress)
- Scoped on group_id IN my_group_ids(), not user_id.
- task_feedback INNER-RING ONLY (outer ring can NEVER read how a task felt).
- daily_state / mood writes refuse null subject_id.
- payout/state/note writes derive group_id from the SUBJECT row (not _activeGroupId) - no cross-circle leak.
- subject-identity fields (name/emoji/pronouns) never cross-writable.
- admin = group_members.can_access_settings = true.
Graduation schema (deferred, build dormant)
profiles.scaffolding_level / previous / _set_at / _set_by / _offer_dismissed_at + tasks.is_anchor - NOT yet in schema. Build the D1 display layer dormant in P1 (so P3 is a dimmer, not a rebuild); do NOT compute or show a level at v1.0 (honesty boundary).
9. Notifications / push
The hard requirement (this is a FIX, not current behavior)
SHIPPED src/utils/notifications.js LEAKS subject content into push title/body today (verified by line citation): line 92 task name on approval, line 107 task name, line 123 task name in a photo/video request body, line 162 task name 'Ready for you', line 203 ${personName} says: + message text, line 224 task name on a request, line 297 + line 433 task names on reminders. (sendCaregiverReachOut at line 463 is already content-free: title '💛 A quiet heads-up', body carries no task/state/words, so it is NOT a leak; do not touch it.) The rebuild must make every Group F and subject-state push content-free on the lock screen.
Spec
- Lock-screen copy is content-free for ALL Group F + subject-state pushes: e.g. 'Something warm from Maddy'. Her words / state / task names are revealed ONLY after unlock, in-app. The caregiver is never made an unwitting broadcaster.
- Coalescing window: 6h shared per caregiver (newest replaces queued), backed by the
warm_push_queuestore (§8 migration #6: one open bucket per caregiver, UPSERT replaceslatest_*, pg_cron-triggered edge function flushes whenscheduled_for <= now()). Non-urgent channel, no red badge. - F2 never pushes (never enqueued into
warm_push_queue). - F3 consent default OFF (toggle on Maddy's side only).
- Device-tokens table if caregivers run multiple devices (fan-out F5/F3).
- Honor #139 audio privacy: signed/expiring URLs for any voice in composer/After.
Acceptance
- No push title/body contains a task name, the subject's words, her state, or a reward amount.
- Unlocking the notification deep-links into the in-app surface where content is shown.
- Two warm events within 6h produce ONE push (newest content), not two.
- F2 produces zero pushes.
10. Accessibility / sensory / WCAG-per-theme
Enforced, not assumed
- Global Reduce Motion fallback freezes every breathe/float/reveal (
useReducedMotion()already honored in HomeTab; extend to all new surfaces). The only animation is the medallion fade-in, which also freezes. - Meaning never rides color alone: the fade's her/you zones carry a LABEL + SIZE, not just tint; the Manage handle is color-independent by shape; reason chips carry text not just hue.
- Dynamic Type: the chip cluster + dense cards reflow to a single column at large Dynamic Type.
- VoiceOver: explicit labels on all affordances; the Manage handle is ONE element; the levity joke punchline stays OUT of the a11y tree until revealed.
- tabular-nums on all money/figures (prevents jitter, aids scanning).
WCAG per theme
- accentInk darkens small text/icons to clear contrast (method OPEN: hand-tuned lookup vs programmatic).
- Candy themes chroma-cut + gradient dropped (Cotton + Mint fail raw; Bubblegum/Golden borderline) - exact reduction per theme needs a visual + WCAG pass (OPEN).
- Night Owl card never pure black (#1A1A24); lavender accent #9D8CFF lightened for contrast.
- All 8 themes must pass AA at small sizes in Things tone before all-theme ship; 4 themes (Mint, Cotton Candy Sky, Golden Hour, Pillow Fort) still need token sets derived.
Acceptance
- Reduce Motion ON -> zero motion anywhere on the caregiver surface.
- Every theme passes AA contrast at small sizes (ramp + accentInk).
- VoiceOver never announces a levity punchline before reveal.
- Large Dynamic Type reflows clusters/cards without clipping.
11. Gesture safety
Rules (Option 3, tamed)
- Manage opens by TAP (primary, 44pt+ button) so it never fights the system home-swipe.
- The resting handle is chrome-only peek forever (a bare grabber + 'Manage' label that teaches in first sessions then recedes like the iOS home indicator). NO peeking sheet edge, NO badge/count/dot ever, NO state on the handle.
- Drag is secondary and exists ONLY after the sheet is open (between detents). The drag hit-region starts ABOVE the home-indicator inset (
env(safe-area-inset-bottom)). - Do NOT use
preferredScreenEdgesDeferringSystemGestures(.bottom)- never capture the system swipe-up. - Built on the OS sheet primitive (
UISheetPresentationController/@gorhom/bottom-sheet) so it coexists with Home / App-Switcher swipes. - The hard-day Care veil dims the handle inert so the only invitations are rest and Care.
- Self-Care relocated to top-right so the bottom edge belongs to the handle alone.
Acceptance
- A bottom-edge swipe-up triggers the OS home gesture, NOT Manage.
- Tapping the handle opens the sheet to the medium detent.
- The 'Manage' label fades after the first sessions; the handle never shows a badge.
- On an F5 hard-day, the handle is inert.
12. Graduation + future hooks
Graduation (the fade) in this build
- D1 display layer dormant in P1: build the regulation-arc -> layout display layer so Phase 3 is a dimmer, not a rebuild. v1.0 cannot compute a real scaffolding_level (schema deferred per GRADUATION.md / GH #220) - do not show a level we can't compute (honesty boundary).
- D2 admin-only soft step-down: asymmetric authority; suggest never decide; subject accepts/declines; never guilt.
- Graduation voice softens but never drops: Pip's hand-off shortens as the card thins ('A note from Maddy'); relationship channels (F1/F4/F5) fade LAST and never auto-graduate away.
- The fade reads as the WIN (her growth + your success), never abandonment/grief; the outer ring never sees the level.
Future hooks (build the firewall, not the feature)
- YOU-zone arbiter (future second relationship): co-caregiver warmth ('Sarah's got tonight. You rest.') lands in the YOU zone via its OWN one-warm-thing arbiter, forward-only, no feed. Self-Care is the floor that never yields; the warm line is what yields. No new card. Build the one-warm-thing primitive REUSABLE so this is additive. (OPEN: YOU-zone arbiter vs routing through MADDY Group F - YOU-zone recommended.)
- Matured launcher -> relationship-distance sections 'This circle' / 'For you' / 'Together', LABELS ONLY forever, no counts/previews/badges; every place two taps away; exit always returns to the calm home. Build the launcher extensible.
- Pip stays structurally bound to the MADDY relationship (a guard, not a convention) - never narrates the YOU zone or anything behind the door.
- Circle comms (future): two fire-and-forget primitives only - 'Pass the baton', 'Ask the circle'. Coordinates HELPERS, never a subject monitor.
- Community / 'Together' (future): caregiver->mentor arc; offered once, on a calm day, one tap deep, declinable, never a home CTA, never a re-engagement streak. (OPEN: ships at all vs defer indefinitely - most defensible to cut; 'if it cannot ship without a feed or a reply obligation, ship it not at all'.)
13. Functionality-preservation matrix
Every shipped caregiver capability mapped to where it lives in the new design. "Keep all functionality" = nothing dropped.
| Shipped capability | Source | Lives now in |
|---|---|---|
| Per-submission approve (approve/reject-with-note/photo-request) | ApprovalModal | A3 Look closer ('Recognize this' + 'Ask for a change' + needs_proof) |
| Batch recognize across tasks/days | RecognizeBatchModal, Home 'Recognize what's ready', Today select-mode | A2 Recognize ('Recognize · $X' wave) |
| Redo with required warm note | rejectTask -> resolveSubmission needs_redo | A3 'Ask for a change' (per-item, warm, never red, never batched) |
| Dated submission ledger, atomic idempotent credit, no double-pay; force-complete supersedes today's open submission | tasks.js approveSubmissions | preserved under A2/A3 (same RPC) |
| Weekly earned-vs-possible hero (no-zero) | RewardsTab | E2 Rewards moneyhero + no-zero |
| balanceOwed + full payout (idempotency token) | recordPayout | E2 'Pay Maddy $X' |
| Partial payout composer | RewardsTab partial | E2 (clamped partial) |
| Inline Undo + after-the-fact void (soft void) | voidPayout, auto-fade 8s | E2 Undo + PayoutHistory void |
| Payout history | PayoutHistory modal | E2 drill-in |
| Reward style (Money/Screen Time self-back + Custom gated backing per ADR 0013) + goal name/description | REWARD_PICKER, CustomRewardModal, updateSubject | E2 / Manage > Rewards reward-style picker |
| Reward-granted warm push (non-solo) | notifySubject | preserved (now content-free) |
| Task add (guarded until subject exists) | AddTaskModal | Manage > Things 'Add a thing' |
| Task edit (timer/due-time/notify-if-late/partial-credit/schedule), duplicate, delete | TaskEditModal | Manage > Things + The After 'Adjust this task' |
| Drag-reorder within group, move-to-group | SortableTaskList | Manage > Things |
| Restore starter tasks, reset today's tasks | Tools | Manage > Settings > Tools (admin) |
| Non-admin read-only list w/ tap-to-complete | TasksTab | Manage > Things (non-admin) |
| Live weekly budget | TasksTab hero | Manage > Things ('Weekly budget / Up to $25') |
| The After: valence/reason chips/note/voice/suggested fix/one-tap adjust, dedupe ·N, durable dismiss, auto-seen, Clear all | TodayDetailTab + HomeTab | B1 The After card (own card; home rough-line + full card) |
| 'asked you' task-change requests (JER-571), one-tap Edit/Remove/Keep, auto-resolve on act | TodayDetailTab | Manage > Things request affordance + home line |
| Messages from subject (companion-framed, per-message + mark-all-seen) | TodayDetailTab | F1 + composer thread (maddy_messages) |
| Write-to-subject composer (presets + free text + voice 30s + upload + error) | TodayDetailTab | A4 the composer (encouragement_notes / caregiver_notes) |
| Subject daily state (read-only, subject-TZ, 'hasn't checked in'/shutdown) | TodayDetailTab/HomeTab | A1 state line (read-only, no surveillance) |
| reached_out_at heads-up (JER-594) | HomeTab strip | F5 (now OWNS the screen; content-free push) |
| Progress by category, Still outstanding, Recent completions (dated, partial-credit) | TodayDetailTab | Manage > Rewards ledger + 'whole day' drill (no-zero) |
| Circle switcher + member list + remove member | CirclePill, SettingsTab | Manage > Circle (Members segment) |
| Invite generate/share/revoke/regenerate; join via code | SettingsTab | Manage > Circle (Invites segment) |
| Per-circle scoping (no cross-circle leak), last-seen | circle.js | preserved (group_id from subject row) |
| Admin gate (can_access_settings) | dashboard | preserved (admin-only D2, Tools, edit) |
| Care suite: Laughs/Relax/Learn/Ground/Sounds + education micro-cards (reward/graduation/masking) | CareTab | C1 Self-Care (line-icon inset rows, reskinned) |
| 'I'll hold it' overwhelm off-ramp (warm holding note, never strands subject) | handleHold | C2 Running-low hatch |
| Caregiver name/emoji/pronouns; subject name/emoji/pronouns + pre-fill | SettingsTab | Manage > Settings |
| Theme picker (per-user, persisted) | SettingsTab setTheme/saveProfile | Manage > Settings (per-PERSON theme, Things tone) |
| Companion reset (admin) | resetSubjectCompanionRpc | Manage > Settings (admin) |
| Sound toggle; help/guide; password reset; delete account (edge fn); biometric Lock; Sign out; Preview subject | SettingsTab | Manage > Settings |
| Persistent 'Send Jeremy a note' | SettingsTab (shipped) | Manage > Settings (preserved) |
| JER-569 in-her-corner milestone (7/30/100/365, monotonic, once) | CaregiverMilestoneCard | B2 / arbiter rank 4 |
| iPad V1: sidebar + content pane, portrait back-chevron, wide layout, pull-to-refresh, realtime resubscribe, pronoun engine | ADR 0006 / JER-515 | preserved (additive-only) |
| #204 which-caregiver-approved (multi-caregiver) | tracker | A3 Look closer: show the approving caregiver's name/emoji on an already-recognized submission ('Recognized by [caregiver]'); Manage > Rewards > PayoutHistory: each ledger/payout row names the caregiver who recognized/paid it. Reads from the approving caregiver's user id (no surveillance of the subject; this is caregiver-to-caregiver attribution). |
| #205 nudge-a-Thing (pre-written gentle nudge) | tracker | optional affordance in Manage > Things (cross-ref) |
| #166 66-days patience surface | tracker | C1 'Good to know' education (cross-ref) |
| Morning / check-in reminder time | profiles.morning_reminder_time -> scheduleMorningReminder | SUBJECT-OWNED, out of the caregiver surface. morning_reminder_time lives on the SUBJECT's profile and the reminder is scheduled on her device; it is NOT a caregiver-editable control and does NOT appear in Manage. (If a caregiver-set version is ever wanted it is a separate decision, not part of this rebuild.) |
| A4 composer rich media (30s voice memo + photo upload) | TodayDetailTab composer (presets + free text + voice 30s + upload) | A4 the composer: the 30s voice memo + photo upload CARRY OVER, written to caregiver_notes/encouragement_notes via uploadCaregiverReply (caregiver-replies bucket), resolved at read time through resolveStorageUrl (short-TTL signed URLs, honoring #139 audio privacy). NO read-receipt / sent log either direction. |
14. Resolved decisions
The full locked set (latest wins; stale dropped).
- Caregiver tone = Things (warm-neutral, hairlines, Lexend-weight authority, line icons, system's own voice, small static Pip on her card only). Removes ambient gradient, narrating Pip, emoji-as-status-icons, shadowed oversized cards, at-rest motion, vague ledger labels.
- Per-PERSON theming: theme = palette (profiles.theme, per-user, NO migration); tone = deployment fixed by role (Subject=Calm, Caregiver=Things, outer=presence). thingsTone(T) renders any of 8 themes in the stable Things shape; only hue changes.
- Theme-bleed guardrail: subject-owned fragments keep HER hue.
- The verb is Recognize (not Approve/Celebrate); generates the reinforcer, non-contingent. Redo = per-item 'Ask for a change'.
- F5 hard-day reach-out OWNS the whole screen; tasks wait; one quiet 'wins are here' line; no competing CTA. (Replaces 'F5 above Recognize'.)
- Warmth is push-primary, consumed in place; home never advertises waiting warmth; at most one waiting item on a cold open; open-frequency is the guardrail metric (opens climb -> push-only). Opening is never made rewarding.
- warmInbound.js arbiter: one item per focus; order F5 > F1 > F2 > milestone > F3 > F4; ties most-recent-unseen; milestone folded at rank 4; Group F never competes with a pending Recognize except F5.
- Levity (F4) decoupled fallback; empty-open only; one-liner DEFERRED to fast-follow (ship photo + sticker + Pip-joke first).
- Softening never touches money; future cadence/optionality only, reversible; lean AUTO-APPLY 'optional on tough days' + undo. The After is its own card, inner-ring only.
- One per-(caregiver,signal) ack ledger, RLS-scoped; each caregiver gets their own moment; F5 ack subject-unreadable.
- Manage opens by TAP (Option 3 tamed handle); drag only after open; handle above the home-indicator inset; never preferredScreenEdgesDeferringSystemGestures(.bottom); OS sheet primitive. Navigable hub (segmented Things/Rewards/Circle/Settings) + two detents ship regardless. Self-Care to top-right. (Option 1 gear + Option 2 circle-pill REJECTED.)
- Money NO-ZERO everywhere (the no-zero placeholder is an en dash '–', a centered dot '·', or '(rest day)', never '$0' and never an em dash; $0 week = 'Nothing to settle up'); no completion-rate/score/gap/compliance streak; Undo = soft void; line items name the moment loosely.
- Self-Care is a door, never a prompt (no badge/count/'did you self-care?').
- Push payloads never leak the subject (content-free lock screen). Window 6h shared/caregiver; F2 never pushes; F3 consent default OFF (toggle on her side only); Group F adds ZERO caregiver-facing toggles.
- Graduation: voice softens never drops; D1 display layer dormant in P1; D2 admin-only soft step-down; relationship channels fade last.
- Future IA: 'Sacred Home + one growing hub' + 'Two living relationships'; place-switcher REJECTED; direction is the axis; launcher matures into 'This circle / For you / Together', labels-only forever; Pip bound to MADDY.
- Chosen design 'Between You' (88/100); #140 'Them & You' two-card home SUPERSEDED.
15. Open decisions
Carry these to Jeremy; do not block P1 on them where noted.
- accentInk method: hand-tuned per-theme lookup vs programmatic darkening. (Blocks all-theme ship, not P1 default.)
- Chroma-cut amount per candy theme (Cotton/Mint fail, Bubblegum/Golden borderline): needs a visual + WCAG pass. (Blocks all-theme ship.)
- Mint / Cotton Candy Sky / Golden Hour / Pillow Fort token sets must be derived/locked (only 4 of 8 done). (Blocks all-theme ship.)
- Caregiver default theme when unset: recommend Dream Space - confirm and lock.
- The After auto-apply boundary: confirm 'auto-apply optional-on-tough-days + undo' is the shipping behavior vs presenting it as a caregiver decision (lean = auto+undo).
- Community: ships ever vs defer indefinitely (most defensible to cut).
- Co-caregiver warmth: YOU-zone arbiter (recommended) vs route through MADDY Group F.
- 'Ask the circle': auto-route to ALL supporters (lowest decision-count, canon-preferred) vs caregiver picks.
- Browsable Learning shelf: ships vs in-context only (safest).
- Confirm future section names exactly 'This circle / For you / Together'.
- Tracker reconciliation: confirm #231/JER-633 updated to Things tone + per-person theme + TAP-to-open; confirm #140 closed-as-superseded; #231 has no milestone - assign a release target.
- RESOLVED (not open): caregiver_notes.from_user_id is NOT referenced by any shipped read and is a REQUIRED ADD migration (§8 migration #2; run a prod
information_schema.columnscheck, then ADD if absent). F2 routing is forward-only. No engineer should skip this migration on the false assumption it is already shipped. - Device-tokens table in scope for multi-device caregivers - confirm.
Revisit triggers (re-open conditions, not decisions)
- If caregiver open-frequency rises -> move to push-only.
- If real AuDHD young-adult caregivers read any marker as a target -> strip to pure milestone moments.
16. Phased build plan with acceptance criteria
Mirrors FLOW. ~70% rides shipped pipes.
Phase 0 - Pronoun/name engine (BLOCKING)
- Consolidate to ONE source:
src/utils/pronouns.jsis canonical (8.8KB engine);src/data/pronouns.js(1.8KB) is a duplicate to delete in Phase 0 so all caregiver copy templates through one source. - Template every literal name/pronoun across all copy via src/utils/pronouns.js / PRONOUNS.md.
- Acceptance: the duplicate
src/data/pronouns.jsis gone; no hardcoded 'Maddy'/'Jeremy'/gendered pronoun in any caregiver string; companion voice renders correct for any configured subject/caregiver.
Phase 1 - The channel on existing pipes
- Build thingsTone(T)/presenceTone(T)/accentInk derivers; remove AmbientBackground from caregiver surface; reskin all caregiver surfaces to Things tokens + line icons (replace CareTab gradient/shadow tiles).
- A1 caught-up send-away; A2 Recognize capped still cluster + batch; A3 look-closer; A4 composer -> encouragement_notes; C1 Self-Care door (top-right) + education; E1 Manage sheet (TAP handle, 2 detents, segmented hub) wiring Things/Rewards/Circle/Settings; E2 Rewards no-zero + soft-void undo.
- Build D1 fade display layer DORMANT.
- Acceptance: caught-up opens silent + sends away; no badge/count on home; AmbientBackground gone; Manage opens by TAP and never fights the system swipe; all 4 Manage tabs reach every shipped admin capability; per-person theme renders in stable Things shape (Dream default); Rewards no-zero incl. aggregate; payout full/partial/undo/history work; iPhone not regressed; iPad sidebar additive.
Phase 2 - Two-way warmth + load + arbiter + push privacy
- B1 The After card + one-tap reversible Soften (config-only, never money); C2 running-low hold + warm holding-note; warmInbound.js arbiter + ack ledger; Group F F1/F2/F3 + milestone fold; content-free push rebuild (fix notifications.js leak) + 6h coalescing store; F3 consent column + RLS; maddy_messages.kind + backfill; caregiver_notes.from_user_id (confirm/add).
- Acceptance: arbiter returns at most one item/focus in locked order; Group F suppressed while pending > 0 (except F5); F5 owns the screen; The After inner-ring only and never touches money; no push leaks subject content; F2 never pushes; two warm events in 6h -> one push; each caregiver acks independently; F5 ack subject-unreadable; F3 default OFF + honors pause; no kind-backfill replay on first open.
Phase 2.5 - Levity lane (after #207 buckets fixed)
- levity_sends + verified-private bucket + EXIF strip (device+server) + short-TTL signed reads + send-cap trigger (2/day, 5/week land, coalesced); F4 decoupled fallback; Maddy-side send flow (photo + sticker + Pip-joke; one-liner deferred).
- Acceptance: F4 only on truly-empty open; cap invisible to Maddy (identical Laughs screen at send 1 vs 6); strip-failure drops photo and lands a sticker/joke (her tap never gets a 'no'); no public URL; punchline out of a11y tree until revealed.
Phase 3 - The fade (dimmer, not rebuild)
- Activate D1 display layer over the regulation arc; D2 admin-only soft step-down. (Real scaffolding_level + is_anchor deferred per GRADUATION.md; do not show a level we can't compute.)
- Acceptance: fade reads as her growth + your success, never grief; her/you by label+size not tint; D2 admin-only, suggest-not-decide; relationship channels fade last; no fabricated level shown.
Cross-phase
- Global Reduce Motion freeze; Dynamic Type reflow; VoiceOver labels; all-theme WCAG (after token sets + accentInk locked); no em dashes; warm-not-clinical voice throughout.
17. Canon-compliance checklist
Pre-merge gate against CAREGIVER.md, BRAND.md, RESEARCH.md section 8, GRADUATION.md, CLAUDE.md reward canon.
- Support not surveillance: no count badge, last-active, online dot, 'check now', map, or refreshable live state anywhere. Warmth push-primary; home never advertises waiting warmth; open-frequency is the guardrail.
- Subject-glance test: Rewards ledger reads as a tally, not a log of when she did things; line items name the moment loosely; no-zero incl. aggregate.
- Sensory envelope (decision 4): surprise in content, never delivery; no startling sound/animation/red/aggressive haptics; F5 = soft tint + heart, never red/banner/modal; levity still-photo only v1, no autoplay; caregiver = restraint, at-rest motion removed; one plum accent per screen max.
- Reduce Motion: global freeze of every breathe/float/reveal.
- RSD-safe: Recognize is the warm verb; redo = 'Ask for a change' (never reject/red/batched/withholding); subject's pending submission never stranded (C2 + reward-grant holding notes).
- RLS: task_feedback inner-ring only; daily_state refuses null subject_id; group_id derived from subject row (no cross-circle leak); F5 ack subject-unreadable; F3 behind consent + pause-honoring RLS; levity media in verified-private bucket (#207 fixed first).
- Push: content-free lock-screen copy always; caregiver never an unwitting broadcaster (FIXES current notifications.js leak).
- Reward canon: no terminal abstract reward (Custom requires a backing before save); badges non-contingent positive-only; real concrete reward (Money/Premack/save-toward co-equal); structured flexibility universal (no neurotype sorting/functioning labels); caregiver load first-class (carry-don't-present, running-low needs zero self-report, Group F adds zero caregiver toggles).
- Masking guardrail (decision 7): Care education + task/reward creation frame reward-the-need-not-the-look; Learning never coaches appearing neurotypical.
- No-zero / no-judgment: the no-zero placeholder is an en dash '–', a centered dot '·', or '(rest day)', never '$0' and never an em dash; no completion-rate/score/compliance streak; in-her-corner monotonic, never a tier/threshold-preview/day-count.
- Self-Care is a door, never a prompt.
- Theme-bleed: subject-owned fragments keep HER hue; per-person palette x fixed Things tone; shape stability = load-reduction.
- BRAND primitives: Section/Row/typography ramp; Lexend; line icons; emoji only for identity/companion/task-content/swatch/celebration; no dadCard shadows (only the Manage sheet shadow allowed); AmbientBackground subject-only.
- Gesture safety: Manage TAP-primary; handle above home-indicator inset; never capture system bottom swipe; OS sheet primitive.
- Accessibility: color independence (label + size, not tint); Dynamic Type reflow; VoiceOver labels; levity punchline out of a11y tree until revealed; all-theme AA at small sizes.
- Graduation: asymmetric authority (admin suggests, subject decides); fade = the win, never grief; outer ring never sees the level; channels fade last; no fabricated level at v1.0.
- iPad V1 (ADR 0006): additive-only, iPhone not regressed, both screenshotted before merge.
- House style: no em dashes in user-facing copy; warm-not-clinical; no lectures; pronoun/name engine throughout (no hardcoded 'Maddy').
Coverage note (from authoring pass): All four inventories are represented. PLAN doc: every surface (A1-A4, B1-B2, C1-C2, D1-D2, E1-E2, F1-F5, F+, levity send/receive, priority frame, Manage Options 1/2/3, future-state two-arbiter home + matured launcher + Circle comms + Together, before/after) maps into Per-surface specs, Group F specs, the arbiter section, Architecture/IA, and Future hooks; all RESOLVED decisions are in Resolved decisions; all OPEN decisions + revisit triggers in Open decisions; every functionality_must_keep item is in the Functionality-preservation matrix; all guardrails are in the Canon-compliance checklist + Notifications + Accessibility + Gesture-safety + Data-model RLS. RESKIN doc: locked tokens, 4 theme sets, thingsTone/presenceTone/accentInk, candy/dark, theme-bleed, component library, every reskinned surface, and its open decisions are in the Design system, Component library, and Open decisions. Shipped-code inventory: all shipped surfaces/modules/pipes/RLS are in the Functionality-preservation matrix + Data model; the verified notifications.js leak and the public-bucket/_upload.js path are flagged as real fixes (confirmed by grep). Trackers: #231/JER-633 keystone, #140 supersede, #207/#220 sequencing deps, #139 audio privacy, #204/#205/#166 cross-refs, and the stale-visual-layer note are in Open decisions + the matrix. Deliberately deferred (firewall-only, not built in v1.0): one-liner levity modality (fast-follow), Circle comms, browsable Learning shelf, Community/Together, co-caregiver YOU-zone arbiter, matured relationship-distance launcher, and real graduation scaffolding_level/is_anchor computation (D1 display layer ships dormant; no level shown until computable). Build-time numbers still to lock and called out as OPEN/blocking-all-theme-ship-only: accentInk method, per-candy chroma-cut amounts, and the 4 missing theme token sets (Mint, Cotton Candy Sky, Golden Hour, Pillow Fort).