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) and caregiver-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)

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)

Home states (the channel)

The home renders exactly ONE of these per focus, chosen by precedence:

  1. F5 hard-day reach-out (if unacked) - OWNS the whole screen.
  2. Pending Recognize (if pending > 0) - the chip cluster / Recognize. The queue is the job; Group F does not compete with it.
  3. One warm thing from warmInbound.js (only when queue is empty) - F1 > F2 > milestone > F3 > F4.
  4. 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

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) 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)

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

Component invariants


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)

A2 Recognize (something to recognize)

A3 Look closer (one moment)

A4 The one composer (optional add-on)

B1 The After ('what [subject] told you')

B2 In-her-corner (JER-569 milestone; arbiter rank 4)

C1 Self-Care (your corner)

C2 Running low / quiet things down (the hatch)

D1 The fade (months later)

D2 Soft step-down (admin only)

E1 Manage sheet

E2 Rewards (money done right)


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

Output shape (suggested)

{ type: 'F5'|'F1'|'F2'|'milestone'|'F3'|'F4', payload, signalKey, sourceId }
// signalKey keys the per-(caregiver,signal) ack ledger

Acceptance criteria


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)

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)

  1. maddy_messages.kind text + BACKFILL (F1 gratitude vs ordinary; backfill so history doesn't replay as unseen warm items on first post-migration open).
  2. caregiver_notes.from_user_id REQUIRED ADD (not present in any shipped read; run a prod-schema check via information_schema.columns first, then ADD 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.
  3. 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.
  4. The ack ledger (see below).
  5. 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.js does NOT write public URLs (it PARSES legacy public URLs at line 131 and resolves all reads via createSignedUrl on 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.
  6. 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;

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)

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

Acceptance


10. Accessibility / sensory / WCAG-per-theme

Enforced, not assumed

WCAG per theme

Acceptance


11. Gesture safety

Rules (Option 3, tamed)

Acceptance


12. Graduation + future hooks

Graduation (the fade) in this build

Future hooks (build the firewall, not the feature)


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

  1. 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.
  2. 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.
  3. Theme-bleed guardrail: subject-owned fragments keep HER hue.
  4. The verb is Recognize (not Approve/Celebrate); generates the reinforcer, non-contingent. Redo = per-item 'Ask for a change'.
  5. F5 hard-day reach-out OWNS the whole screen; tasks wait; one quiet 'wins are here' line; no competing CTA. (Replaces 'F5 above Recognize'.)
  6. 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.
  7. 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.
  8. Levity (F4) decoupled fallback; empty-open only; one-liner DEFERRED to fast-follow (ship photo + sticker + Pip-joke first).
  9. 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.
  10. One per-(caregiver,signal) ack ledger, RLS-scoped; each caregiver gets their own moment; F5 ack subject-unreadable.
  11. 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.)
  12. 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.
  13. Self-Care is a door, never a prompt (no badge/count/'did you self-care?').
  14. 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.
  15. Graduation: voice softens never drops; D1 display layer dormant in P1; D2 admin-only soft step-down; relationship channels fade last.
  16. 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.
  17. 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.

  1. accentInk method: hand-tuned per-theme lookup vs programmatic darkening. (Blocks all-theme ship, not P1 default.)
  2. Chroma-cut amount per candy theme (Cotton/Mint fail, Bubblegum/Golden borderline): needs a visual + WCAG pass. (Blocks all-theme ship.)
  3. Mint / Cotton Candy Sky / Golden Hour / Pillow Fort token sets must be derived/locked (only 4 of 8 done). (Blocks all-theme ship.)
  4. Caregiver default theme when unset: recommend Dream Space - confirm and lock.
  5. 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).
  6. Community: ships ever vs defer indefinitely (most defensible to cut).
  7. Co-caregiver warmth: YOU-zone arbiter (recommended) vs route through MADDY Group F.
  8. 'Ask the circle': auto-route to ALL supporters (lowest decision-count, canon-preferred) vs caregiver picks.
  9. Browsable Learning shelf: ships vs in-context only (safest).
  10. Confirm future section names exactly 'This circle / For you / Together'.
  11. 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.
  12. 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.columns check, then ADD if absent). F2 routing is forward-only. No engineer should skip this migration on the false assumption it is already shipped.
  13. Device-tokens table in scope for multi-device caregivers - confirm.

Revisit triggers (re-open conditions, not decisions)


16. Phased build plan with acceptance criteria

Mirrors FLOW. ~70% rides shipped pipes.

Phase 0 - Pronoun/name engine (BLOCKING)

Phase 1 - The channel on existing pipes

Phase 2 - Two-way warmth + load + arbiter + push privacy

Phase 2.5 - Levity lane (after #207 buckets fixed)

Phase 3 - The fade (dimmer, not rebuild)

Cross-phase


17. Canon-compliance checklist

Pre-merge gate against CAREGIVER.md, BRAND.md, RESEARCH.md section 8, GRADUATION.md, CLAUDE.md reward canon.


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