Appearance
The Consumer delivery goal — five end-to-end deliverables
THE GOAL, IN ONE SENTENCE
Five Consumer features closed END TO END, in this order, with the approved design and the architecture as the source of truth — and nothing counted as done because it is partially wired.
Owner instruction, 2026-09-07, and it is the standard this page is written against:
"I want us to maintain clear progress tracking and avoid considering the Consumer build complete simply because individual features are partially wired. There is still substantial work pending across Consumer BioData and the broader Consumer experience."
Order agreed 2026-09-07. Baseline commit 4bb8068, Android build cb06ea1, backend qr-setu-dev.
⚠⚠ WHAT "DONE" MEANS HERE, BECAUSE THIS IS THE WHOLE POINT
A deliverable is closed only when all six are true. Five of six is not "nearly done", it is open — and the fifth is the one this project keeps skipping.
| # | Condition | Why it is on the list |
|---|---|---|
| 1 | The screen exists and renders every state the design declares | A screen with one state is a screenshot |
| 2 | The barrel binds a service.supabase.ts, not a stub | QRS-1132: four features have a live backend and a stub seam, so the screens show fixtures. check:rpc is green because they are dead |
| 3 | Every control reaches a real destination | QRS-1106: six Home CTAs shipped dead with every gate green |
| 4 | The parity contract has every row verdicted, and pass rows carry reachableFrom | QRS-1001: a green contract covered a PIN gate no user could reach for a month |
| 5 | Compared against the artboard via npm run design:spec, not from memory | Measured: 7 of 11 visual defects on one screen were "the design says X, the code says Y" |
| 6 | Its QA cases pass on a device, and new cases were added in the same change | The suite is tools/qa/cases-*.mjs; check:qa-suite gates it |
⚠ Condition 2 is the new one and it is the reason this page exists. A real RPC plus a deployed Edge Function plus a screen that renders is still zero product if the seam between them returns a fixture.
D1 · Bind the five stub seams — the highest value per unit of work
Why first: the backends are already built, deployed and verified. manage-chat is live on Dev at v2; get_my_conversations is defined in three migrations, get_my_prefs and get_my_consumer_activity in one each. The only missing piece is the client seam, so this turns four screens from fixtures into product without touching a migration.
Measured today: for chat, consumerActivity, consumerPrefs, collection and meetings there is no service.supabase.ts on disk at all — only the interface and the stub. The barrel binds createStubChatService, createStubConsumerActivityService, createStubConsumerPrefsService, createStubCollectionService.
| Seam | Backend state | Consequence today |
|---|---|---|
chat | manage-chat v2 live · get_my_conversations · get_conversation_messages | ✅ bound. ⚠ Binding it exposed QRS-1133 — every message would have rendered as incoming |
consumerActivity | get_my_consumer_activity | ✅ bound, after the RPC was rewritten to project raw activity instead of a finished list (CR-26.0.1-152) |
consumerPrefs | get_my_prefs | ✅ bound, after set_my_prefs was added — there was no write path at all (CR-26.0.1-153) |
collection | no table | Saved is honest-empty; the seam stays a stub on purpose |
meetings | schema exists | Frame only; the real screen is D4 |
⚠ collection is deliberately NOT bound. There is no saved store in the schema, so a Supabase impl would be a fiction. It stays a stub and Saved stays honest-empty — and that distinction is recorded rather than left looking like an oversight.
Done when: chat, activity and prefs are Supabase-backed and bound; the three screens read real data; a preference survives a reinstall; a message sent from the consumer side arrives in the merchant thread on Dev.
⚠ WHERE D1 ACTUALLY STANDS — one of four clauses met, and saying so is the point of this page
| clause | state | evidence |
|---|---|---|
| chat, activity and prefs Supabase-backed and bound | ✅ met | grep -n "createStub" packages/data/src/index.ts no longer lists any of the three; check:claims reports 20 of 30 seams real, up from 17 |
| the three screens read real data | ⚪ unverified | The seams issue real RPCs and 343 tests pass, but the tests run against the seam's own STUB, injected deliberately. A passing test proves the wiring composes, never that a row arrived. Needs a signed-in pass on :8080 |
| a preference survives a reinstall | ⚪ unverified | set_my_prefs is proven on Dev by functional probe, including that the locked-topic coercion is surgical. Survival across a reinstall is a device test and the web preview cannot run it |
| a consumer message arrives in the merchant thread on Dev | ⚪ unverified | manage-chat send is live and the seam calls it; nobody has driven both halves of one thread |
🔎 So D1 is "bound", not "done", and the distinction is exactly what condition 2 of the six was written to force. Binding the barrel was the necessary step and it is the step that was missing for weeks — but a bound seam plus a green test suite is still the QRS-1132 shape one level up if nobody watches a real row reach a real screen.
⚠⚠ D1 IS PART BACKEND. THIS SECTION SAID "THE ONLY MISSING PIECE IS THE CLIENT SEAM" AND THAT WAS WRONG (measured 2026-09-07)
| Seam | Read | Write | State |
|---|---|---|---|
chat | get_my_conversations ✅ · get_conversation_messages ✅ | manage-chat, 10 actions ✅ | ✅ BOUND 2026-09-07 |
consumerActivity | get_my_consumer_activity — ⚠ returns the wrong SHAPE, see below | none | 🔴 blocked, backend |
consumerPrefs | get_my_prefs ✅ | none | 🔴 blocked, backend |
collection | no table | n/a | stub by design |
manage-account accepts exactly four actions — update_email, change_password, delete_account, claim_slug (index.ts:215-218). No Edge Function anywhere writes prefs or notification read state, so "a preference survives a reinstall" is unreachable by client work alone (QRS-1135).
⚠ get_my_consumer_activity() DERIVES THE NOTIFICATION LIST SERVER-SIDE, AND @qrsetu/domain DERIVES IT AGAIN — the same fact modelled twice, with two vocabularies that disagree. The RPC returns { items: [{ id, kind, count, at }], unread } with kinds biodataRequest, biodataRemoval, message; consumerNotifications(activity, readIds, today) expects raw activity and emits 11 snake_case kinds (biodata_request, new_message, …). CLAUDE.md puts derivation in @qrsetu/domain — "services return stored rows as-is" — and this seam's own header agrees, so the RPC is on the wrong side of that line and needs rewriting to project activity rather than conclusions. It also returns only a TOTAL unread, while the artboard renders a per-item dot.
⚠ Two live read-state tables exist for one fact (QRS-1134): notification_reads (0 rows, no reader) and consumer_notification_reads (0 rows, read by the RPC). The design settles the name in both halves — prototype/mobile-console/notification-reads.js: "On lift this becomes a notification_reads table" — and ADR-0031 settles the principle: the axis is the bounded context, never the audience. Consolidate while both are empty.
What binding chat actually cost, because it is the pattern for the other four
⚠ The seam did not just switch on. Re-reading the RPCs off live Dev — rather than trusting the interface's own "transcribed from the migration, not inferred" note — found the contract stale in four places, because ADR-0032 re-shaped conversations onto principals after it was written:
- QRS-1133, P0:
toChatMessagecomparedsenderKind(user | workspace | system) againstviewerSide(consumer | merchant) — disjoint sets, so every message in every thread would have rendered as incoming with no delivery state. Invisible because the stub suppliedconsumer/merchantforsenderKind. The stub was not merely incomplete; it was self-consistent in a way the database is not. workspaceIdwas non-nullable; the RPC returns null for a person-to-person thread.counterpartyKind,counterpartyUserIdandisRequestwere missing entirely — andisRequestdrives a designed state.listMessagespromised oldest-first while the RPC orders DESC.
🔎 The transferable lesson for D2-D5: a field-for-field contract is a claim about a MOMENT. Only a re-read is a claim about now, and the stub is exactly what stops you noticing.
D2 · The Marriage BioData editor, completed — the launch feature and the biggest single block
Built today: hub · section screen · field sheet · per-field visibility · tier chip · publish declaration · link card · liveness footer.
Not built, and each is a designed surface with states: photographs (4 slots, cover, 4:5) · people (structured, max 10) · the opening/emblem composer (28 emblems, 13 seeded, kuldaivat first) · the 12 themes · custom fields (6 types, max 8, conflict rules) · the family-layout picker (tree · vine · cards · list).
⚠⚠ D2 SCOPED 2026-09-07 — THE BOTTLENECK IS NOT EFFORT, IT IS THAT FOUR OF SIX PIECES TOUCH A GATED SURFACE
Measured against the code and live Dev, not estimated. Two facts make D2 much cheaper than it looks, and one makes it slower than it looks.
✅ The whole domain layer is already ported. look.ts · people.ts · photos.ts · opening.ts · custom.ts · visibility.ts · familyDrawing.ts (16 tests) · journey.ts · fields.ts all exist with tests. D2 is client rendering over finished pure logic, not new derivation.
✅ All six pieces share ONE write action and need NO new backend. manage-biodata's update patchable list (helpers.ts:281-289) is field_tiers · hidden · people · photos · opening · custom_schema · custom_values · theme_id · family_layout. There is no per-piece EF contract to negotiate and no migration ordering to respect.
⚠ But four of six touch a gated surface, which is the axis that actually orders this work:
| piece | domain | write | gate | state |
|---|---|---|---|---|
| People (max 10) | people.ts ✅ | update{people} ✅ | none | 🟢 BUILT 2026-09-07 — 16 tests, 21 contract rows, 3 drift entries. ⚠ The whole-list save is blocked (QRS-1143): no real save, conflict or reload driven against Dev |
| Custom fields (6 types) | custom.ts ✅ | update{custom_schema,custom_values} ✅ | shares the field registry with the existing FieldSheet | ⚪ ready, sequence after People |
| Family layout | ids in look.ts ✅ | update{family_layout} ✅ | 🟡 2 icons into @/ui (systemic, ADR-0015) + 8 copy keys in 3 languages | QRS-1140 |
| Opening composer (28 emblems) | opening.ts ✅ | update{opening} ✅ | 🟡 own-picture path needs consumer upload | partly blocked by QRS-1139 |
| 12 themes | look.ts ✅ | update{theme_id} ✅ | 🟢 UNBLOCKED — biodataMixHsl matches Chromium byte for byte on 24/24, mutation-proven | QRS-1138 closed; the strip itself is still to build |
| Photographs (4 slots) | photos.ts ✅ | update{photos} ✅ | 🔴 manage-media cannot serve a consumer at all | QRS-1139 |
✅ The themes gate is CLOSED, and it needed measurement rather than a decision. The 12 theme records use mix() twelve times and biodataTokenCss emits color-mix(in oklab, …), which React Native cannot parse — its parser returns null and falls back silently, the failure that once rendered every family portrait as a black disc. biodataMixHsl now computes the blend in TypeScript, and a real Chromium is the reference: tools/capture-oklab-reference.mjs captures the pixel the browser paints for every blend the themes declare, in both schemes, and the test asserts equality. 24 of 24 exact, zero off by one, mutation-proven against the naive sRGB blend. 🔎 It was first raised here as an owner decision; that was wrong. The divergence risk was eliminable with a test, not a trade-off to weigh, and the fix landed in packages/domain beside its DOM sibling rather than on the systemic token surface.
⚠ manage-media must be its own change. It is live and merchant-facing (catalog images, card logo, card cover), so extending it belongs in a commit with its own Deno test pass rather than bundled with new consumer UI, where a merchant regression could hide.
🔎 The transferable planning lesson: the first cut of this map called themes and family layout "independent, parallel-safe, no backend". Both were true and both were irrelevant — each is gated on a systemic surface. Order D2 by what is UNGATED, not by what is independent.
⚠ Photographs need manage-media owner-scoped upload, which is deployed — so this is client work, not a migration. ⚠ The family drawing already exists as a shared domain module (packages/domain/src/biodata/familyDrawing.ts, 16 tests) and must be consumed, never re-written.
Done when: every one of the ten content states and thirteen sheets the design declares is reachable and matches the artboard, and a profile can be filled completely without leaving the app.
D3 · Preview and share — closes the owner-side loop
- In-app preview (
BiodataView) — has no file at all today. Three viewer roles × five states in the design. - Share — ⚠ measured: the hub wires
onShareandonCopyto the same copy handler, so the Share button does not share.mintShareexists in the seam with no client call site.
⚠ The share slice needs the BrandQr @/ui primitive, which is the systemic surface — so it is design-first under ADR-0015: pull the design, add a drift-ledger row, then implement.
Done when: an owner can see their profile as a family sees it, mint a per-person link, and share it — and the recipient link opens the right tier.
D4 · Account, Profile, Settings, then Meetings
Account: hub is built; the design declares eight views (hub · details · areas · notifs · appearance · privacy · help · about). areas is marketplace and stays absent. ⚠ Only in-app and WhatsApp are real channels — SMS and email have no sender and must render absent, never as toggles that persist a preference nothing reads.
Meetings: participant half only. Calendar · meeting · invitation queue. ⚠ Hosting is out of scope (QRS-1014, Zoom decision deferred), and the connect gate renders honestly as not-ready rather than as a dead control.
D5 · Scan and the remaining QR makers
Scan: the scan seam is already real and resolve_slug_owner_kind is live — but there is no screen, and /consumer/scan is a dead href (QRS-1008). The scanner shell in src/ui/scanner/ was built for this and has zero importers. Nine payloads → four verdicts.
Makers: 2 of 15 have screens. ⚠ The other thirteen must stay absent rather than appear as tiles that lead nowhere — the fifth product rule.
⚠ Scan is camera work, so it cannot be validated on the web preview at all. It is last for that reason, not because it matters least.
Reminders — a design request, not a pull
⚠ Consumer "Reminders" has no design. What exists is the merchant reminders domain plus a consumer prompts sheet. Building a consumer reminders feature now would be inventing UI, which is banned. It needs a Claude Design round first and is therefore outside D1-D5.
The live localhost preview
The RNW web export is served on localhost:8080 and re-exported as each deliverable lands, so progress is testable continuously rather than at a handover.
⚠⚠ WHAT THE WEB PREVIEW CANNOT TEST, STATED SO A PASS THERE IS NEVER READ AS A PASS. These are native modules or browser-only seams, and the export either lacks them or behaves differently:
| Not testable on localhost | Why |
|---|---|
| PIN gate, lockout, biometrics | isPinSupported() is false on web — the gate does not exist there |
| Splash behaviour | Native only |
| Camera / Scan (D5) | expo-camera vs getUserMedia are different implementations |
| Share sheet, local notifications | Native only |
| Photo picker and image derivatives (D2) | Different implementation on web |
| Press feedback, haptics, gestures | RN maps these onto three different native mechanisms |
| Session persistence across process death | Not a web concept |
So localhost is the right place to test: navigation, every screen state, real-vs-stub data, copy and all three languages, layout and overflow, and whether a control reaches a destination. The Android build stays the gate for everything in the table above.
Progress ledger
Updated as each deliverable closes. open · in progress · closed. A deliverable moves to closed only against the six conditions at the top of this page.
| # | Deliverable | State | Evidence |
|---|---|---|---|
| D1 | Bind the five stub seams | 🟡 seams bound, NOT yet proven end-to-end | All three bound (chat, consumerActivity, consumerPrefs); collection stub by design; meetings is D4. Backend: CR-26.0.1-152..154 on Dev, functionally probed. QRS-1133 fixed + mutation-tested. ⚠ Three of D1's own four "done when" clauses are unverified — see below. |
| D2 | BioData editor completed | ⚪ open | — |
| D3 | Preview and share | ⚪ open | — |
| D4 | Account · Profile · Settings · Meetings | ⚪ open | — |
| D5 | Scan and remaining makers | ⚪ open | — |
Open, and NOT blocking D1-D5
- QRS-1131 —
deploy-web.ymlhas never succeeded, so the public biodata page cannot reach a phone and three QA cases stay blocked. Needs an owner decision on how CI gets its env. It does not block any of D1-D5. - QRS-1127 — session persistence, reported once, not reproduced.
- QRS-998 — the account is created at OTP send, which is why a new signup can still be greeted as returning.
- Disk: both drives are under the 15 GB floor and the sweep reclaims 0 MB — the space is the 10.6 GB Docker VHDX, which never shrinks on its own. A native release build is the thing at risk, not the web export. Compaction stops Docker, so it is the owner's call.