Skip to content

INFO

This page is ARCHITECTURE.md from the repository, as it stands.

Architecture ​

What exists in this repository, in the present tense. Intent and future plans live in the product spec; this file describes only what is built. Any change to the Firestore schema, the security rules, or the cadence engine updates this file in the same commit.

Current state: all eighteen build steps exist. Steps 1–9 are the shared list app and the statistical engine; 10–18 are the intelligence layer, behind a Cloud Function proxy and App Check. Everything in 10–18 is inert until the project is on Blaze with an Anthropic key and an App Check site key configured — the app runs on pure statistics without them, which is a supported state rather than a broken one.

Stack ​

PieceChoice
BuildVite 8, React 19, TypeScript 7 (strict, noUncheckedIndexedAccess)
UIantd-mobile 5 for chrome and controls, themed from src/styles/tokens.css via the CSS-variable bridge src/styles/adm-theme.css; rows and question surfaces stay hand-written CSS
BackendFirebase 12 — Auth (Google only) and Firestore
TestsVitest 4 (unit), @firebase/rules-unit-testing 5 (emulator)
Projectsmart-list-app-22667, Firestore (default) in nam5
DocsVitePress 2 (prerelease, on the same Vite), the guide in docs/guide/, see "The docs site"

Layers ​

One-directional. A layer imports only from layers below it.

1. src/lib/        pure logic     — imports nothing outside src/lib
2. src/data/       data access    — Firestore in, plain objects out
3. src/hooks/      subscriptions  — wires layer 2 into React
4. src/components/ + src/screens/ — rendering only

Modules ​

src/lib/types.ts ​

Every domain type as plain data. Timestamps are Millis — epoch milliseconds from the device clock, never a Firestore sentinel. Contains the persisted document shapes (StapleDoc, TaskDoc, NoteDoc, ListItemDoc, SessionDoc, StoreDoc, HouseholdDoc, UserDoc, PurchaseDoc), the value objects they are built from (PurchaseEntry, Season, Recurrence, LatLng), and the projections the pure engine consumes (StapleSnapshot, CadenceResult, AutoAddCandidate).

src/lib/time.ts ​

Local-time date math: DAY_MS, daysBetween, intervalDays (rounded to whole days, minimum 1), addDays, monthOf, startOfLocalDay, dayKey, and addMonthsClamped (Jan 31 + 1 month lands on Feb 28, not Mar 3).

src/lib/cadence/ — the recurrence engine ​

Client-side arithmetic over at most twenty numbers per staple. computeCadence is the entry point; cadenceFor adapts a StapleSnapshot to it.

ModuleHolds
statsmedian, mad, regularity
seasonmonth-range predicates that wrap the year, and crossesSeasonBoundary
intervalsgaps between purchases, seasonal exclusion, the six-interval window, the per-unit series
confidencethe 0.20 / 0.45 regularity bands
predictprior blending, per-unit horizon, computeCadence, cadenceFor
leadTimetrip cadence from store visits, and the two-day fallback
autoAddwhich staples earn a place on a list today
dormancywhen a staple has gone quiet, with the seasonal exemption
snoozesnooze horizon and the six-hour duplicate-check-off window

A CadenceResult carries n, medianDays, madDays, regularity, effectiveIntervalDays, nextDueAt, confidence, and inSeason. medianDays and madDays describe observed real-world gaps; regularity describes the per-unit series the prediction is built from, so it is deliberately not madDays / medianDays.

src/lib/normalize/ ​

canonicalize reduces a typed string to a resolution key: lowercase, depunctuate, drop quantity and filler tokens, drop a trailing "for …" clause, and singularize conservatively. Percentage variants such as 2% survive as variant markers, so 2% milk and milk stay distinct — whether they are the same staple depends on what else the household buys and is not decidable from the strings. Fuzzy matching is src/lib/resolve/'s job, layered on top at add time; the normalizers themselves stay deterministic string surgery.

src/lib/sessions/ ​

boundaries holds the 75-minute inactivity threshold, the strict comparison that keeps a session open at exactly 75:00, and the backdating of closedAt to the last activity. (The checked-item listener's window used to live here as a constant; it is the household's to set now — see src/lib/lookBack/.) storeInference holds haversine distance and inferStore, which returns a match, an explicit ambiguity, a nearest (within two kilometres — close enough to pre-select as an answer to correct, never to apply on its own), or nothing — a 150 m match radius with an 80 m collision radius, so two stores in one shopping centre produce a question rather than a guess.

src/lib/lookBack/ ​

How far back the app reads, as arithmetic. LookBack (in types.ts) is what the household document stores: days, one number for everything; overrides, the switch that puts the surfaces' own numbers in force at all; and surfaces, a per-surface number or null for "same as everything", over five surfaces — trips, calendar, people, questions, todos. readLookBack is the total reader (the rules let any member write any shape here, so a string, a negative or a missing map all read as something the app can run on; a document from before the switch reads as switched on exactly when a surface holds a number, so nobody's window moves on upgrade); clampDays rounds and clamps to whole days in [1, 365]; lookBackDays / lookBackStart answer one surface; lookBackWindows answers all five at once as {days, since} pairs for a now; parseDays is the Settings row's parse (blank, digits, or invalid — not clamped, the data layer clamps). notesSince / tasksSince / questionsSince are the filters every seam applies, inclusive at the boundary, each by the one stamp a document ages by — a note by updatedAt, a to-do by createdAt (it has no other), a question by askedAt — and each hands the SAME array back for a null window, so a memo keyed on the result holds. Ninety days is the default and it is fixed, not "unlimited": a household's rhythm fits in three months, and it is what the checked-item listener can carry without reading the household's whole shopping life at boot.

Two rules every consumer follows. A document is never hidden by the window — notes stay on their shelves, to-dos on their lists, each opens as it did; only what is DERIVED from them forgets. And the window is fixed per household snapshot (useLookBackWindows reads Date.now() inside its memo), the checked listener's own rule made everyone's: a boundary that slid with the clock would re-run every derivation on every render for a change nobody could see.

src/lib/recurrence/ and src/lib/promotion/ ​

nextOccurrenceAt walks a local calendar grid anchored to the rule, never to when a task was completed. promotionCandidates groups manual adds by canonical name and offers to track anything added three times — within the household's trips look-back, which is what the checked-item listener carries (ninety days unless Settings says otherwise; src/lib/lookBack/), plus whatever is open now.

src/lib/notes/ ​

What the Notes screen shows, as arithmetic: the archived split, keyword matching, the three sort orders, the one-line preview under each title, and noteText / splitNoteText — the seam that turns a stored title and body into one editable surface and back. noteText stands the title apart with one blank line; splitNoteText absorbs that one line, so the separator is never stored and every blank line past it belongs to the writer. Matching is substring over folded text (lowercased, combining marks stripped, whitespace collapsed) and every whitespace-separated token must appear somewhere, though not in the same field.

It deliberately does not reuse canonicalize or trigramSimilarity. Both are right for their own jobs and wrong for prose: canonicalize strips quantity words and singularizes, so a note holding "300g strong white flour" would stop being findable by "300g"; trigramSimilarity ranks short entity names, and over a multi-paragraph body the trigram set dilutes, so a note gets less findable the more is written in it.

detectFacts (facts.ts) scans prose — note bodies and to-do detail lines — for phone numbers, emails, URLs and street addresses, conservatively and in a fixed claim order (email, url, phone, address; a span claimed once is dead to the rest), returning every deduplicated fact in the writer's order, each with the tel: / mailto: / https:// / maps href a chip navigates to; factChips is the first six (MAX_FACTS), the strip's ceiling, and the note shelf's closed line counts the whole. Tuned to miss before it lies — see DECISIONS, "A false chip is worse than a missed one".

captureNoteTodos (todos.ts) scans a note's body lines — never its title line — for lines opening with a to-do marker (todo / to-do in any case behind at most a list bullet, then a colon, a spaced dash, or whitespace; the two-word to do only with its colon), returning the titles to create and the text with each captured line pinned (now a to-do). The pin in the text is the idempotency, exactly as the pinned dates carry theirs — see DECISIONS, "A to-do typed in a note is captured, and its line is pinned". A note's area IS the to-do list its captures land on — one value, no translation, since canonicalGroupId collapsed the one pair that used to need it. todoChips walks the pinned lines back the other way: each line whose to-do is still OPEN on the note's own list yields a chip carrying the task's id and real title — matched by exact title first (the line is the very text the task was created from; canonicalize strips numbers, so "pack box 2" and "pack box 3" are canonical twins), canonical equality as the drift net. A completed, renamed-away or deleted task yields no chip. stampCompletedTodos shares that exact resolution (one resolveTodo, so the chip and the stamp can never disagree): completing a to-do rewrites each line's pin to "(done Sep 3, 2026, 2:41 PM)" — locale-free, device clock — and capture skips any "(done …)" parenthetical, so a completed to-do can never be resurrected by the note's next save. todoSources reads the same lines backwards — task id → the note(s) whose pinned lines resolve to it, archived notes included — for the task row's mark and its way-back chip.

completionStamp.ts and answeredStamp.ts are the two stamp SHAPES, each a handful of lines importing nothing (the second takes only Span as a type). They exist as their own modules because several scanners must agree about a shape and none may import another: the to-do capture and the question capture both refuse to re-capture a stamped line, and detectDates refuses to chip the date inside one — a completion and an answer are records of the past, and these scanners read a note for what is still ahead of it. Both are matched loosely (any (done …) / (answered …) parenthetical), so a hand-typed one earns every refusal too. The WRITERS live with the features that need the pins' day format — stampCompletedTodos in todos.ts, stampAnsweredQuestion in questions/ask.ts — which is what keeps these two from ever depending on the date layer that depends on them. Fixtures bind each pair: a test stamps a line and asserts every scanner leaves it alone.

src/lib/groups/ ​

The shared group namespace as arithmetic: groupSlug (a name folded to one comparable form, exactly as search folds — no longer an identity, only how sameGroupName tells two names apart, and kept in preference to foldForSearch because the two induce different equivalence classes and it is the slug's that says "School-run" and "School run" are one name), canonicalGroupId (the one value a shelf is stored under — 'home' folds onto 'personal', applied inside readTask and readNote so nothing above layer 2 meets both words; see DECISIONS, "One shelf, one stored word"), groupName (built-ins are Personal and Work on both surfaces, on either spelling — see "The first shelf wears a shared household's name" under Households for what a shared household calls the first and does with the second; a custom name comes from its document, and a group with no document is UNNAMED_GROUP — "Untitled group" — because an opaque id is not a name and showing one would put a key on screen), rejectGroupName (blank, a reserved NAME — Personal or Work, not Home, which is a reserved id only — a duplicate name, or a name alive on the other door, which is refused and pointed at the chip that joins it deliberately), rejectGroupRename (the same, minus the id-shape rules, which belonged to the birth of a slug id; excludes the group itself, scans every door, and accepts a ghost id whose document is gone), taskGroupCards / noteGroupCards (ONE SCREEN's cards, flat and by name: the DOOR (level: null) shows the built-ins first — Personal then Work, Work only where it is a shelf (builtinShelves) or holds something on this surface — then the top-level customs, and groups only, never content; inside a top-level group, its subgroups; inside a subgroup, nothing, by the ceiling. Declared on the surface OR implied by content, so nothing an id still names can become unreachable, and a group brings its PARENT onto the door with it. Counts are ROLLUPS — the group plus everything filed under it; only a custom empty through its whole subtree and holding no children is removable, and archived notes hold a notes group open), canHoldGroups (whether a group's screen may hold groups at all — the writers' half of the ceiling, asked before a screen renders a group section or a New group box; false for a group whose document is not in hand, a ghost or one not yet loaded, since nothing could be filed under it anyway), and notesAreaPath / tasksGroupPath (where a group lives).

tree.ts beside it is nesting as arithmetic: groupParent, groupAncestors, groupDepth (0 or 1, never more), fileTargetTree (every place a to-do or note can be filed, for the row's move sheet — Personal, Work, then the customs, each with its own children under it, and a child whose parent is not itself a destination lifted to the top rather than lost), groupSubtree (self-inclusive, because every caller wants the folder counted with what it holds — and, at two levels, just the group and its direct children), groupParentChoices (the built-ins and the top-level customs, flat), and rejectGroupParent — self, cycle, missing parent, a destination that is itself a subgroup, and a group that holds groups trying to leave the top. All flat checks, which is what MAX_GROUP_DEPTH (2) buys.

Two levels, and the ceiling is in the data model. groupParent refuses to RENDER a third level as firmly as rejectGroupParent refuses to write one: a parent that is itself a subgroup, a dangling parent, or a cycle all read as null, so however a document is mangled every group stays reachable at depth 0 or 1. That is what makes breadcrumbs, tree pickers and recursive move validation not merely avoided but inexpressible — every list of groups in the app is flat.

The cap and the acyclicity are product rules, not boundaries. Firestore rules cannot traverse a parent chain and this app's are membership-only by design, so rejectGroupParent runs on one device with one snapshot and two phones can write a cycle between them regardless. Every reader here is therefore total against data that breaks the rules: a chain that returns to where it started is not a chain (groupParent answers null, so BOTH ends of a cycle render as roots — visible and fixable — rather than as children of each other, which would make both vanish), a parent naming a group that does not exist is no parent, and every walk is bounded by the group count.

src/lib/groups/search.ts ​

The level search as arithmetic. levelScope says what a screen's level covers — a group and everything under it (groupSubtree), or null for the door, which covers everything on its surface, ghost ids included — and inLevel asks whether a document's group is inside it. searchGroupCards narrows a level's cards to the names containing the query, folded as search folds, and from the door reaches one level further down, each found subgroup saying where it lives (In Renovation) in place of a hint; the cards come from the surface's own builder, so a found card carries its own level's count, removable flag and hold sheet. An empty query is no search and hands the level's cards back untouched. matchesQuery (in src/lib/notes/) is the one text rule both surfaces match documents with.

src/lib/people/ ​

@ mentions found in prose, under the chip scanners' charter. detectPeople (detect.ts) claims one token per mention — letters, digits, hyphen, underscore, opening with @ and ending at whitespace or punctuation, so @sam. is Sam and a full stop — and never guesses at a bare capitalised word. The fact shapes go first as refusals: [email protected] is an email and the @sam inside a pasted URL chips nobody. There is no orchestrator sharing claims across scanners, so the agreement with detectFacts is bound by fixtures feeding both scanners the same strings, the completionStamp way. The known people arrive as an argument (calendarEntries' pattern) and stay a pure input; resolution folds the handle against every name and alias, greedy only where that is safe — @sam chen claims two tokens only when somebody already answers to that two-word fold. An unresolved mention comes back with personId: null, the unborn state the note's save pass turns into a person. personAppearances and peopleNamedIn (appearances.ts) take the household's people-and-tags look-back as since: a note last written, a to-do made or a question asked before it is not read, so a person's page and the directory's group filter forget the same texts. The people themselves are documents and are never windowed — the roster, the merge and every resolution see everyone. coMentioned and sharedFacts (related.ts) are the page's two crossings, over the same live texts and the same window: everyone else the texts naming the person name, most often first (a question counts when it is theirs, and an addressee naming them counts back), and the facts in those texts — by href, detectFacts' own identity — that texts NOT naming the person also carry, each with those texts, newest first. A fact only the person's own texts carry is left out: it leads nowhere new. me.ts is the account's link to a person (personForUid) and the readings over it (mentionsPerson, questionFor, youLine), see the Firestore section on people.

fold.ts holds the handle fold — lowercased, -/_ read as spaces, whitespace collapsed, and what the handle grammar cannot carry gone: accents stripped, apostrophes and punctuation dropped, the Latin letters that decompose to nothing spelled out (Sam O'Brien, José and Søren fold to sam obrien, jose, soren, the folds of the tokens the @ menu types for them; letters of any script survive) — which is WITHHELD from the public contract for groupSlug's reason: it is how prose resolves against what exists, never a key anything may derive identity from. The barrel exports the operations built on it instead: nameFromHandle (what a bare handle births), insertHandle (the typed token the @ menu inserts — a word resolved back through the fold, never an id: "Sam Chen" → sam_chen), handleFor (that token whole, with the nickname glued on in parentheses when there is one — sam_chen_(Sammy) — so the text says both what they are called and what the household calls them, and either half resolves), and renamedPerson / mergedAliases, the alias arithmetic that lets a rename or a merge cost no prose because the mention in the text is the link.

src/lib/questions/ ​

The ask marker, through the to-do capture's machinery (ask.ts). captureNoteQuestions scans a note's body lines — never its title — for lines opening ask @sam: … / ask @sam … (the @ makes the colon optional) or bare ask: … (colon required — "Ask the plumber about the boiler" is prose), returning the questions to create, each carrying the handle after ask @ or null for a question addressed to nobody, and the text with each captured line pinned (now a question). The pin is the idempotency, the to-dos' own idiom; capture refuses completion-stamped lines, so a stamped to-do can never double as a question. Tags stay in the captured text — they belong to the line and are derived, never stored. questionChips walks the pinned lines back: each line whose question is still OPEN from this note — unanswered and not archived (sourceNoteId scopes it; a question knows its note, so no list-matching) — earns a chip with the question's id and real text. One private resolveQuestion — exact text, then a whitespace-collapsed lowercase fold (deliberately not canonicalize, which is grocery-tuned) — is shared by the chips and the answered stamp, resolveTodo's precedent, so the two can never disagree about which question a line means. stampAnsweredQuestion writes the answer back over the pin in the shape ../notes/answeredStamp reads: a short answer rides the line ((answered Sep 3, 2026: two years from install)), while one over ANSWER_INLINE_MAX, or carrying parentheses or a newline the one-line stamp cannot hold, leaves the date alone in the line and the body on the document. Distinct from seasonal/profile's household-profile questions ("do you have a fireplace"), which interview the household about its purchases; these are questions the household asks its own people.

tags.ts is the crossing axis. detectTags reads #renovation off a question's own text on every render — the handle's grammar with a #, one token, deduped by fold in the writer's order, and refusing a purely numeric token so "issue #4" tags nothing. Nothing is stored, so there is no array to drift from the words and editing the question edits its tags — the pinned dates' seam. tagGroup(tag, groups) answers the group a tag names, built-ins included, or null: a LOOKUP over the live documents through sameGroupName, never a derivation of an id from a name, which is the distinction groupSlug's withholding protects. A tag that resolves navigates to that group's notes; one that names nothing is a concept that groups questions and filters /questions?tag=, with no document and nothing to clean up — and the day a group of that name is made, every question carrying the tag starts pointing at it with nothing rewritten. sameTag is exported for the same reason sameGroupName is: an index or a filter comparing tags any other way would invent a second equivalence. textWithoutTags renders a question's sentence with the tag tokens taken out, for the rows that carry those tags as chips beside it — the handles' own bargain, where the text carries the value and the chip renders it, so nothing is read twice. The stored text is never touched.

src/lib/tasks/ ​

taskEditText / splitTaskEdit (edit.ts): the task row's edit surface, which is the note page's pattern through the notes' own seam — the first line is the title, one blank line stands it apart, the details follow. These two carry only the task-shaped edges: a blanked first line keeps the current title (a to-do cannot be called nothing), details are end-trimmed and cleared to null rather than '', and a detail-less task seeds as "title\n\n" so the editor's end-of-text caret lands ready for the detail.

lineContextAt / NamingContext (src/lib/context.ts): the line that put a row somewhere, split at the span that did it — one slicer shared by the three surfaces that derive references from text (the calendar's days, a person's appearances, a to-do's way back). The preview quotes the line rather than naming the document, because a title says which note and the line says what is happening. Withheld wherever the line would only read the row back: a date in a title, a recurring slot's rule, and a captured to-do whose line is its own title plus a marker.

selectTasks / taskTagIndex / TASK_SORTS (select.ts): what the flat view shows, the shape selectNotes set — one pure entry point doing the filtering and the sorting together, so no screen reimplements either. Open to-dos only, narrowed by a group set the caller expanded through groupSubtree and by a tag through sameTag, ordered newest-first, A–Z, or by day with the undated last (Infinity, so a to-do with no day sorts after every dated one rather than burying them).

src/lib/dates/ ​

detectDates (detectDates.ts) scans the same prose for the days it names, under the same charter and the same architecture (claim spans, fixed pass order, dedupe by destination day, writer's order; every day, and dateChips is the first six, MAX_DATES, the strip's ceiling — the note shelf's closed line counts the whole, and the calendar carries every day a note names). It claims ISO 2026-08-28, slashed dates only with a four-digit year, capitalised month-name forms in either order (yearless ones roll forward), and the relative phrases today, tomorrow and in N days/weeks/months. It refuses, by name: bare m/d (recipe fractions), two-digit years, weekday names, lowercase months, bare ordinals, and month-or-year alone. It also refuses a span outright, whatever it holds: a to-do's completion stamp, "(done Sep 3, 2026, 2:41 PM)". That shape lives in notes/completionStamp.ts — its own module, because the to-do capture reads it too (a stamped line never re-captures) and neither scanner may import the other — and pinDates walks the same refusal, so a stamp is never rewritten either. The two scanners never contest a span — the phone pass refuses date shapes by name, and the forms claimed here contain nothing the fact passes want.

A relative phrase resolves against the anchor the caller passes — the document's own write time — and is pinned at save: pinDates appends the resolved day in parentheses ("in 3 days (Aug 28, 2026)", year always included), and from then on the parenthetical is the value; the relative words are never re-resolved, so editing the document cannot move the day. The text itself carries the date — no schema, offline-merge safe, user-editable. Day-stepping walks the local calendar (addLocalDays), never milliseconds.

A day reaches the calendar only in brackets. [Sep 5, 2026] is a commitment; a bare "Sep 5" is a mention. DetectedDate.committed carries the difference — the test is adjacency, the character before the claim's span and the one after it, which works unchanged for [tomorrow (Sep 5, 2026)] because a pinned phrase's span already swallows its own parenthetical. Both kinds still get a chip, because a mentioned day is worth a glance; only a committed one gets a square. Where one day is said twice, the committed phrasing wins the strip's dedupe however late it comes, or the document would carry a commitment the calendar never saw.

commitLabel writes the brackets, from pinnedDayLabel, so writer and reader stay the same code. newlyMentioned(previous, next, anchor) answers the one question the offer needs — did this save add a day the document did not have, and not commit to it — by comparing whole texts rather than remembering anything. pinDates still pins inside brackets, and rescheduleDate needs no bracket handling at all: it rewrites the claim's span, which sits inside them.

Old documents were rewritten, once. migrateToBrackets (src/data/) is the app's only real data migration, and it exists because the usual answer does not reach here: everywhere else a schema change is absorbed by a total reader's default, and the reader IS the migration — but these days live in prose, so prose had to change. It reads every note and to-do, pins each against ITS OWN anchor and then brackets what it finds (bracketDates), batches the writes, and stamps household.bracketsAt last. Pinning first is load-bearing: the write moves a note's updatedAt, which is the anchor an unpinned "tomorrow" resolves against, so bracketing without pinning would change the day in the same breath it recorded it.

It runs from the client (useBracketMigration), because this app has no operator to run a script. That is safe because the pass is idempotent (a bracketed day is skipped, so a second device writes nothing), resumable (the stamp lands only after every write, so a half-finished run resumes), and faithful (every day that filed before still files after). Households created since are born stamped, so they never run it.

calendarEntries and monthGrid (calendar.ts) derive the Calendar screen: text dates from live notes and open tasks, plus recurring tasks' slots walked forward from each task's stored pending nextOccurrenceAt — the pending slot renders even when its day has passed; slots before it are not invented. Every entry carries the GROUP its document lives in — never null, where the field it replaced threw a note's away — and only narrows the whole derivation to a set of group ids, which the screen builds from groupSubtree so that filtering to a folder means everything filed inside it. since is the household's calendar look-back, and it is applied to the DAY as much as to the document: an entry leaves only when the document AND the day it names are both behind the window — an old note's plan for next winter is still a plan, while the day it filed last spring is a record. Recurring slots are exempt: a weekly chore made a year ago is re-armed by every completion, so when it was made says nothing about its age. pinnedDayLabel exports the pin's day format ("Sep 3, 2026") for the one surface that writes a document FOR a day — the calendar's own capture — so the writer and the scanner are the same code.

rescheduleDate is the reverse: it moves a day IN the text that names it, replacing every phrase claiming that day (a pinned phrase whole, parenthetical and all, since the pin is the value and the words in front were only its input; ISO stays ISO). It shares the scan with detectDates, so the day a chip shows and the day a move rewrites cannot be found by two different rules, and answers null for text that does not name the day — which is how a caller learns whether the day lived in a title or a body without scanning twice. nextDayFor (calendar.ts) is where "the next day" is: one day on for something still ahead, tomorrow for something whose day has gone, the clock time carried along for a recurring slot.

uncommitDate takes a day off the calendar the same way: it is the inverse of bracketDates, removing the brackets round every phrase committing to that day and leaving the words exactly where they were, so "call the vet [Sep 4]" becomes "call the vet Sep 4" — off the calendar, still chipped because the note still names the day, and committed again by typing the brackets back. It shares the scan and the null contract with rescheduleDate, and frees EVERY committed phrase on the day for that function's own reason: one entry per day per document, so a second pair of brackets left standing would leave the entry where it was. A phrase that merely mentions the day is untouched — it was never on the calendar to take off it. Only the two characters go and nothing around them moves, which is a rule you can predict by looking at the text; the price is that brackets doing some other job around a date come apart like any others, which is isBracketed's pure adjacency showing through rather than anything this pass decides.

The public contract ​

src/lib/index.ts is the surface layers 2–4 may use. It deliberately withholds the engine's primitives (median, purchaseIntervals, blendPrior, confidenceFor, …) so that no consumer can assemble a second cadence path that skips prior blending, per-unit scaling, or the seasonal gate. groupSlug is withheld for the same kind of reason: it folds a name for sameGroupName to compare, and a consumer holding it could turn a name back into a document id — the derivation opaque ids exist to end. src/lib/layering.test.ts enforces both the purity of layer 1 and the withheld surface mechanically.

Firestore ​

Paths, all household-scoped except users/ and usage/:

users/{uid}
usage/{uid}
households/{hid}
households/{hid}/members/{uid}
households/{hid}/stores/{storeId}
households/{hid}/tasks/{taskId}
households/{hid}/staples/{stapleId}
households/{hid}/purchases/{purchaseId}
households/{hid}/items/{itemId}
households/{hid}/sessions/{sessionId}
households/{hid}/suggestions/{suggestionId}
households/{hid}/notes/{noteId}
households/{hid}/chatMessages/{messageId}
households/{hid}/notes/{noteId}/photos/{photoId}
households/{hid}/notes/{noteId}/presence/{uid}
households/{hid}/groups/{groupId}
households/{hid}/people/{personId}
households/{hid}/questions/{questionId}
households/{hid}/serviceEvents/{eventId}

households/{hid} carries the household's own facts — name, region, memberUids, the profile and the dismissals — and lookBack: {days, overrides, surfaces: {trips, calendar, people, questions, todos}}, how far back the app reads (see src/lib/lookBack/). Written at birth stating the default so the document says what the app does; read totally, because the household update rule validates no field; changed only through setLookBack, which clamps to whole days in [1, 365] with dotted paths, so one surface's change leaves the others alone and a household from before the map grows it on the first write.

serviceEvents is the failure record — the intelligence layer's first, and now the writes' too — {fn, code, message, at, uid}, at from the device clock like everything else. A refused write records as fn: 'write' with a message that opens with WHAT was being written in a person's words ("a to-do — INVALID_ARGUMENT: …"), because the code alone cannot say that and it is the one thing somebody opening the list wants to know. Every seam in src/data/intelligence.ts returns null rather than throwing, which is the layer's whole contract and does not change; what changed is that the failure is now written down on the way past instead of vanishing, so a household that finds an assisted feature has gone quiet can find out why. code is the FirebaseError code the browser actually received — the cause itself stays in Cloud Logging.

The seam reaches the writer through one module-scoped sink (setIntelligenceFailureSink), set by the app shell, which is the only thing that knows both the household and the signed-in user; threading a writer through every hook and screen would put the diagnostics into every signature in the app. Writes are fire-and-forget by construction — this is the error path, and a failure to record a failure must not become one — and each write opportunistically prunes a handful of rows older than thirty days, because there is no background pass in this app and housekeeping is not worth inventing one. Settings lists the recent ones under Service, absent entirely when nothing has failed: a record, never a badge or a count.

groups holds the household's CUSTOM to-do/notes groups — {name, tasks, notes, createdAt, createdBy} — one namespace serving both tabs. (Both tasks and notes say which DOOR shows the group; there is no third flag for the calendar, and deliberately — see the Calendar section and DECISIONS, "The calendar filters the namespace; it does not join it".) The id is opaque: an auto-id, immutable, and unrelated to the name, which is a display attribute the household renames whenever it likes (renameGroup writes that one field and nothing else, which is why renaming needs no migration — every list keeps pointing at an id that never moves). Ids written before that was true are folded names and still resolve; that is history showing through, not a rule, and nothing derives an id from a name in either direction. See DECISIONS, "A group's id is opaque; its name is display". The built-ins (Personal, Work) have no documents — layer 1 supplies them virtually, which is what makes them undeletable, and unrenameable by the same sentence (a shared household's Work is not deleted either: it is simply not a shelf there, and shows only while something is filed under it — builtinShelves). tasks/notes flags say which front doors show the group; joinGroup flips one and never touches the name; the recursive member rule covers the collection like every other.

parent — a group id, or absent for the top of a door — is how groups nest. ONE field on the ONE document both doors read, which is what makes a move land on Tasks and on Notes at the same instant: there is no per-surface parent to leave behind. A built-in may be a parent (they are doors, not documents), which is only possible because canonicalGroupId ended the two-word shelf — a child stored under 'home' and one stored under 'personal' would otherwise have rendered in different folders on the two doors.

people holds everyone the household names in its own prose — {name, aliases, nickname, job, uid, createdAt, createdBy} under an opaque auto-id, for groups' reason and with groups' consequence: the id never moves, so nothing anywhere has to be rewritten when the name does. A person is born at a note's commit — the blur, or leaving the note with something typed, since the browser's back button takes the editor away without one — whole, from the first @handle that resolves to nobody, never from the light save while typing; no note stores a person id, because the mention in the text is the link, resolved by detectPeople on every read. aliases holds the folds the person also answers to: renamePerson pushes the old name's fold on (a surname learned in March is a rename of one document, never an edit to any note), and mergePeople folds any number of people's handles into the survivor's and deletes their documents — one write on the survivor, one delete each — and deletePerson removes one nothing names any more, one delete and nothing to touch. nickname is what the household calls them for short, typed on purpose on the person's page (setNickname; null until then, and blank reads as null): one more fold the person answers to (personFolds — name, nickname, aliases — is what detectPeople, the @ menu, the People search and the search grammar all resolve through), shown and editable where the aliases are hidden history, and what the @ menu splices beside the name in parentheses (handleFor), which detectPeople reads back as one mention with the parenthetical as one more fold — so a mention typed by hand as @bob_(Bobby) births Bob with the nickname Bobby. A merged-away person's nickname joins the survivor's aliases. job is what they do — "plumber", "the letting agent" — the nickname's shape again (setJob; null until then, blank reads as null, one line), except that no mention resolves through it: it is a fact about the person, shown on their page and carried to a merge's survivor when the survivor has none — and one the People search and the merge card's roster answer to (searchPeople, on the job's own search fold, so "plumb" finds every plumber the household wrote down and an accent costs nothing). Its box completes from the jobs the household has written for everyone else (jobUniverse / jobChoices in src/lib/people/jobs.ts, the # menu's bargain on one field: prefix on the search fold, newest person first, no "new job" row). Its box is a field of its own under the tools line (PersonField, the nickname's box beside it), the chips under it while it is focused and the typing is not yet a job, picked on pointer-down so the pick beats the blur. uid is the account this person IS — null for everyone until a member says "This is me" on the person's page (claimPerson, which also writes null onto whichever entry that account claimed before, since an account is one person; releasePerson is "Not me"), and carried to a merge's survivor when the survivor has none, the job's rule. It is the one link from a person to a sign-in, and what it unlocks is a reading: personForUid (src/lib/people/me.ts) says who the signed-in account is, and mentionsPerson / questionFor say whether a text or a question is theirs — through the mention scanner, never a field, so a to-do is Sam's because it says @sam, whoever typed it. See DECISIONS, "An account is a person who said so". Timestamps are device-clock Millis like everything else. The recursive member rule covers the collection as written — no rules change — and it holds tens of documents, sorted client-side, so no index either.

questions holds what the household asked in its own prose and KEEPS after answering — {text, groupId, askOf, sourceNoteId, askedAt, askedBy, answer, answeredAt, answeredBy, archivedAt}. The one derived thing that earns a document, because the answer changes somewhere other than where the question was written. groupId is the group the source note lived in, so a question inherits its shelf and every filter that shelf already has (moveQuestion writes that one field; readQuestion runs it through canonicalGroupId and defaults 'personal', the notes' reader's own rule). askOf is a person id or null — a question addressed to nobody is a first-class state, one about the boiler rather than for a person — and a null or unresolvable askOf simply renders without a chip. Tags are not stored: detectTags reads them off text on every render, so editing the question edits its tags. sourceNoteId naming a deleted note yields no way back rather than an error. Answering is one gesture landing two records (answerQuestionStampingNote, completeTaskStampingNotes' shape): the answer on the document, and the source note's pinned line stamped (answered Sep 3, 2026: two years from install) so the note stays self-contained and the answer stays findable by the note search. A question nobody is going to answer is put AWAY instead — archivedAt, the notes' own state and the notes' own manners: archiveQuestion writes the one field and no edit stamp, restoreQuestion clears it, deleteQuestion is offered from the archived shelf alone, and the source note's pinned line is left exactly as it is, because the pin is the idempotency and rewriting it would re-arm capture (DECISIONS, "A question is put away, not answered falsely"). Every reader treats an archived question as it treats an archived note — off the index's lists and counts, the group shelf, the More count, the person and tag pages, the co-mention shelf, the note's chips and the assistant's snapshot — and readQuestion reads a document written before the field as live. Recursive member rule, no rules change; hundreds of documents at the outside, sorted and split client-side, so no index.

usage/{uid} is the intelligence layer's budget for one person — {day, units, month?, monthUnits?}: the UTC calendar day and the units spent on it, and the UTC month and the units spent in it (the month pair arrived later; a document without it reads as a month with nothing spent) — written only by the Cloud Functions through the Admin SDK (functions/src/budget.ts) and never readable or writable from a client. What a person sees of it comes through the usageReport callable, which reads their own document and charges nothing. See "The proxy" below.

assistantThreads/{uid}/messages is the assistant drawer's thread and the one place a transcript persists: {role: 'user' | 'assistant', text, at, by, photos?, notes?} with at from the device clock, by empty for the assistant, photos — present only on a reply that shows some — an array of {noteId, photoId, url} references to note photos, never copies of them, and notes — present only on a reply whose turn made some — an array of {noteId, title, group} references to the notes it made, group being where each was filed as the doors say it, for the chips under the reply. Per member, not per household — see DECISIONS, "The thread in the drawer is yours": the conversation is readable by its owner alone, while every action a turn lands goes to the shared collections in the household's sight. The {uid} document itself never exists; only the subcollection under it. The client keeps roughly the newest twenty (trimChat); the listener orders by at alone, so no composite index. The rules carve this subtree out of the recursive member rule (exclusion in the catch-all, since OR'd rules cannot be narrowed) and re-grant it to the owner alone. The pre-migration chatMessages collection is left in place and still member-gated: current clients never read it, but a phone on the previous build does, and a permission-denied listener would read there as "can't sync".

Security rules (firestore.rules) ​

Membership is the only axis. memberUids is denormalized onto the household document so the check is one cached get() per request.

  • users/{uid} — owner only.
  • usage/{uid} — nobody, explicitly. An unmatched path is denied anyway; the statement is there so the omission cannot be mistaken for one, and so the rules suite can assert it.
  • households/{hid} — read for members; update for members who remain members after the write, so a member can enroll someone or remove another member but can never write memberUids: [] — a state that would brick the household, since updating it again needs a membership nobody holds and delete is refused. Create is carved out for a user enrolling themselves, because the membership check cannot run against a document that does not exist yet; delete is refused. No field is validated — a member may write lookBack: 'banana' — which is why every reader of the household document is total. Update is also open to a non-member joining by invite (isJoinByInvite): the document carries inviteCode and inviteExpiresAt, set by any member, and a signed-in stranger who knows the code writes it BACK unchanged beside arrayUnion of their own uid. Writing the same value is the proof of knowledge — a wrong code changes the field and fails the equality — and an unchanged value is not an affected key, so diff().affectedKeys() may name memberUids alone. The array after must be the array before plus exactly one uid, the caller's own: no enrolling a friend, no touching the expiry to revive a dead link. The rules see the array AFTER the transform, which rules.test.ts proves — it is what the whole branch rests on. The code is never read by the joiner (they cannot read the document until they are in it) and is never spent by joining; a member re-minting or clearing it is how a leaked link is retired. Nothing on the server takes part, so joining works without App Check or a deployed function. Membership is not an ordinary field: a member may update anything but memberUids and ownerUid, and the array moves only by the invite branch (in), a joiner leaving (isLeaving: a shared household, not the owner, exactly themselves, nothing else touched) or the owner removing one other member (isRemoving: the owner stays). owner() is ownerUid, written at creation and never rewritable, falling back to the first member for a document from before the field. The owner can never leave and never remove themselves, so the household always keeps its creator and the empty array stays unreachable.
  • households/{hid}/{col}/{rest=**} — read and write for members, except assistantThreads. The exclusion lives in this rule's own condition because rules are OR'd across matching statements: a stricter nested rule cannot take back what a broader one grants, so the broad one has to decline first. The shape matters too: it requires at least one collection segment so it cannot match the household document itself. A bare {sub=**} also matches zero segments, which silently granted write on the household and overrode allow delete: if false.
  • households/{hid}/assistantThreads/{uid}/{rest=**} — that member only, and only while they are a member: the one subtree where membership is not enough. {rest=**} rather than a bare messages match, so anything the thread grows later inherits the owner-only shape.

tests/emulator/rules.test.ts covers the matrix, the invite branch included (live code, wrong code, expired code, no code, a friend, two at once, a sneaked expiry); tests/emulator/data.test.ts proves the bootstrap lands all three documents and rejoins instead of duplicating, that a shared household appends after the solo one, that a stranger joining by invite lands whole, and that addedAt / checkedAt survive a round trip as plain numbers rather than server sentinels. tests/emulator/storage.test.ts covers the bucket's rules (see "Notes" above), which read memberUids through the cross-service lookup and so need both emulators up.

Indexes ​

firestore.indexes.json declares exactly one composite index: the purchase rebuild filters on stapleId and orders by at, the one query in the app that does both. Every other query filters on a single field, or orders by one, never both — the collections involved hold tens of documents, so sorting in the client costs nothing and avoids a composite index per view. The recent-trip listener orders without filtering, which a single-field index already serves. The emulator does not enforce composite indexes, so the emulator suite cannot notice a missing one; the index file is the contract that deploys it.

Data access (layer 2) ​

src/firebase/ initializes the SDK exactly once: app.ts reads VITE_FIREBASE_* (with emulator defaults so VITE_USE_EMULATORS=true is the only variable needed locally; authDomain is the page's own hostname on any hosted origin — several domains serve this app, and the OAuth handshake must stay first-party on each — falling back to the configured value on localhost, which serves no /__/auth/* helpers), firestore.ts is the single initializeFirestore call, storage.ts is the single Storage handle (null when no bucket is configured, and every photo affordance is then absent rather than dead), and auth.ts holds Google sign-in.

Persistence is persistentLocalCache with the multi-tab manager, so writes queue in IndexedDB and flush on reconnect, and CACHE_SIZE_UNLIMITED: a household's text is a few megabytes at the outside, and the only thing the default LRU could ever evict is the offline copy of something about to be needed in a shop with no signal. Against the emulator it drops to memoryLocalCache, because IndexedDB outlives the emulator and would resurrect deleted documents on the next run — VITE_EMULATOR_PERSISTENCE=true turns it back on for tests/e2e/offline-boot.mjs, the one test whose subject is the cache itself, which runs its own dev server to get it.

The transport is experimentalAutoDetectLongPolling by default and switches to experimentalForceLongPolling once a sticky localStorage latch is set (src/firebase/longPolling.ts, its own Firebase-free module so the set-once behavior is unit-testable). The bootstrap sets the latch after two consecutive hung reads — some phone networks hold a half-open stream the SDK's own detection never gives up on — and reloads exactly once; enableLongPollingFallback answers true only when the latch is NEWLY set, which is what makes a reload loop impossible on a storage that throws or drops writes.

src/data/ takes a Firestore as its first argument everywhere, so the same functions run in the app and under the emulator test suite. read.ts converts snapshots to the plain types in src/lib/types.ts, defensively: a document written by an older build still yields a usable object rather than crashing a screen someone is looking at in a shop. paths.ts holds every collection path. src/data/notes.ts keeps its own reader rather than adding to read.ts, following the newer collections (stores.ts, sessions.ts, suggestions.ts).

Households ​

Signing in for the first time creates one household with one member — the solo household, first in users/{uid}.householdIds and marked shared: false. There is no onboarding. An account may belong to more: createSharedHousehold makes a second one from Settings (shared: true, under the name the person gave it), and joinHousehold enrolls this account in someone else's by invite (see "Security rules" for the branch that admits it). Both append to the back-pointer, and the back-pointer is only ever appended to: ensureHouseholds returns the whole list in join order, and subscribeHouseholdIds keeps it live so a household started or joined on another device arrives as a new card rather than at the next cold start. ensureHousehold is the solo id alone — the shape every emulator suite boots with. The user document also carries tones, this account's colour per workspace (readTones, total; see "A workspace's colour is the account's own" under Screens). The household document also carries the invite (inviteCode, inviteExpiresAt, both null when none is live), minted by createInvite from sixteen random bytes and cleared by revokeInvite; the code's shape and the link that carries it live in src/lib/households/invite.ts. households/{hid}/members/{uid} ({displayName, joinedAt}) is written by every way in — bootstrap, starting, joining — and read by subscribeMembers for the Members screen; memberUids on the household stays the authorization record. The ways out: leaveHousehold (a joiner; one batch — the member document first, while the subcollection rule still sees a member, then arrayRemove on the household, then the person's own back-pointer and colour) and removeMember (the owner; the household and the member document). The removed person's user document is theirs alone, so subscribeHouseholds reports a household that refuses this account (permission-denied) and the hook answers with forgetHousehold on its own record, which flows back through the live listener; the refusal is membership, not the path, so forgiveListenerErrors clears the watchdog when the household on screen changes. The back-pointer therefore grows and shrinks; the solo household is never left, so index 0 never moves. An assistant thread left behind stays unreadable.

The lookup reads users/{uid}.householdIds rather than querying households by membership: a collection query is rejected by the rules, because membership is a property of each document and the rules cannot evaluate a query that might return one the caller may not read. The back-pointer exists so this is a single document the user always owns.

Creation is two writes, not one batch. The subcollection rule calls isMember(), which get()s the household — and inside a batch that read sees the pre-batch state, where the household does not exist yet, so the member document is denied on a null dereference and takes the whole batch with it. The household lands first; the member document and the back-pointer follow together.

The boot reads run under a 12-second deadline (useHousehold): a read that REJECTS as unavailable retries every 5s as it always did, while a read that HANGS counts a strike and retries after 1s — two consecutive strikes while the browser reports online set the long-polling latch above and reload once. An abandoned timed-out attempt keeps running; ensureHouseholds takes a superseded guard, checked before each create write, so a late success can never fork a second household under a live attempt. The fresh users/{uid} write merges and omits householdIds entirely — only arrayUnion ever creates the field, so a wrongly-empty read can never erase the back-pointer, and the live listener ignores an empty snapshot for the same reason. useHousehold returns the list (hids), the household on screen (hid: the one the shell asked for when the account belongs to it, else the solo one) and every household document that has answered (households, for the door cards); hid stays null until the list is known, so the boot screen holds and nothing downstream can act on a guess. While hid is null the boot screen explains itself: on unreachable it names the likely phone causes and offers "Try again now"; offline it says setup will continue on its own.

Both boot reads go to disk first (src/data/cacheFirst.ts). The user document and the household ids it names are facts that do not go stale once written — the list is only ever appended to — and they gate every subscription in the app — so a returning phone answers them from IndexedDB and opens at once, instead of holding the whole app shut behind a round trip it does not need. getDoc is not that read: its cache fallback waits until the SDK concludes it cannot reach the server, which is fast when a request is refused and slow — the full twelve-second deadline, then again on every retry — when requests hang, which is what a captive portal, a filtering proxy and a half-open mobile connection all look like. It has to be BOTH reads: ensureUserDoc awaits in front of ensureHouseholds, so one of them alone still leaves the boot shut. The server read still runs, awaited by nothing, purely so the watchdog hears an answer.

Tasks ​

A task is a one-shot commitment; completing it destroys it. A recurring task is the exception, and its rule is fixed rather than learned — rent is due on the 1st whether or not you paid on time last month. nextOccurrenceAt is always derived from the rule and never typed in.

Completing a recurring task re-arms it onto the next slot of its own grid rather than marking it done, so completing late cannot drag the schedule later with it. A pending occurrence whose day has passed stays visible on that day — late exists — but lateness is never MARKED: the row and the calendar state the plain date in the same muted voice as any other, and there is no red, no badge, and no count of things you failed to do.

A detail line's detected facts (the same detectFacts the notes use) render as at most two compact row chips on a second line of the row, a link's chip reading only its host at rest (shortFactLabel) — outside the label button, which owns the tap that edits, and an anchor may not live inside a button anyway. They hide while the detail is being edited. ComingUp and the Regulars note render task text too and deliberately get no detection: those are whole-row controls with no second line to give.

A task lives in a group — the namespace the notes share, wearing the same names on both tabs: the built-ins Personal (stored as 'personal', with the older 'home' folded onto it by canonicalGroupId in the reader; nothing migrates, and every task written before the field existed reads back there) and Work (in a shared household, only while something is filed under it — see Households), plus the household's own. The Tasks tab shows GROUPS ONLY — Personal, Work, then the top-level customs by name, each with its rolled-up count, and nothing else; every to-do lives inside a group. A level's cards sit in ONE container (.group-door): rows inside a single raised surface, hairlines between, one size smaller than a list card — the rows keep their .card classes, which the smoke suite counts. The container wears a title row (.door-head: "Groups", how many, a caret) that folds the rows away and back; the fold is a per-device preference in localStorage (groupDoorFolded:{surface}:{level}), one per door and level, so folding Work's subgroups leaves the root as it was. Above the container sit the level's controls: the level search (see Search, below) and the New group link. Each card opens /tasks/{groupId}, the group's own screen: the level search and its capture at the top, the New group link that files a subgroup here, its subgroup cards (top-level groups only — a subgroup's screen is to-dos alone, by the ceiling), then its to-dos. Adds (typed, captured, or asked for) land on the group that is open. Holding a custom card offers Remove among its other actions, and only when the group is empty through its subtree; the built-ins have no documents, so they carry no hold at all. A group made here exists for the tasks only — the notes door does not show it until something real crosses.

The door also carries a view switch — Groups or All — and All is the one view that crosses shelves: /tasks/all, every open to-do at one level, whatever group it is filed on. Shelves are the default and the reason the app is legible, but they cannot answer "what is there, everywhere?", and no screen could before this one. Its own static route, and the switch is two LINKS rather than a toggle, because a view is a place — linkable, stable across a reload, and where a to-do's tag chip lands, none of which component state could offer; it sits ahead of /tasks/:taskListId in the router, since static outranks dynamic and "all" would otherwise read as a group with that name. The switch (ViewSwitch, one component on all four screens) rides both doors and both flat views, so each says which of the two is on screen and the other is one tap away; it is absent on a door with nothing in it either way, as the row it replaces was. The flat view wears the door's own title — "Tasks", "Notes" — and no Back, because it is a mode of that page rather than a place under it; the switch is the way between the two, and backName names either mode by the door. Three narrowings, all in the query string beside each other and all from the header: ?q= (the search grammar — words, #tags, @people), ?g= (a group from the filter sheet, expanded through groupSubtree), and ?sort= (the sheet's Order). Every row names its own shelf there, which is the one thing it cannot otherwise say. There is no capture box — a to-do typed here would have no group to land on — and no completed toggle: completing is the end of the thing, everywhere.

The Notes door carries the same switch, and All is /notes/all: every active note, whatever group it is filed on, with the same three narrowings in the query string and the same reasoning behind each. It renders ordinary NoteRows — so a row archives, holds to move, and carries its counts exactly as it does on its own shelf — and every one of them wears its group as a chip that goes there. No New button, for the reason there is no capture box on the to-dos'. No archived shelf: that is a recovery surface and it belongs beside the notes it recovers. No questions shelf either, since those are keyed to a group and /questions already crosses them. selectNotes takes the same only set selectTasks does, so a folder means a folder and everything in it on both.

Notes ​

Prose the household keeps: the boiler's model number, the sourdough ratio, what the vet said. Distinct from TaskDoc.notes, which is a single detail line on a to-do and dies when it is completed — a note is the record itself, and nothing ever completes it.

A note lives in a group — the same namespace the to-dos use, wearing the same names AND the same stored values on both tabs: the built-ins Personal ('personal', with the to-dos' old 'home' folded onto it by canonicalGroupId inside the readers) and Work ('work', a shelf of its own in a solo household and in a shared one only while something is filed under it), plus the household's own. The Notes tab shows GROUPS ONLY, the same clean door the Tasks tab is — Personal, Work, then the top-level customs by name with their rolled-up counts, in the same one container of rows, the level search and the New group link above it — and every note lives inside a group. Groups nest exactly once (see the ceiling above): a group's screen shows the level search with the sort beside it, then ONE LINE of the ways to add here — New note, then the New group link that files a subgroup here (top-level groups only — a subgroup's line is New note alone) — then its subgroup cards, then its own notes and their archived shelf. New note left the header for that line: the header holds the title and the tools, and every way of adding to a level sits together under it (ActionLine, below). The built-ins open /notes/personal / /notes/work; a custom group opens /notes/area/{groupId} wherever it sits, because /notes/:noteId owns the bare segment and an id could wear a note id's shape. New creates into the group that is open and search stays inside it. A group made on the Notes door exists for the notes only until a to-do captured from one of its notes flips its tasks flag on — the lazy half of the shared namespace, ensureTaskGroup and its mirror ensureNoteGroup.

A group is managed by HOLDING its card on the door — a long press, or a right-click, opening one sheet: Rename, Move, Open, and Remove when the group is empty through its whole subtree. useHold again, the calendar's own, and one ActionSheet for the whole list. Rename cannot be a sheet action, because an action returns void and a rename has to be able to refuse and say why — so it opens GroupRename, which renders where the card was, the shopping and to-do rows' own idiom. Removal asks card.removable rather than re-deriving it. Built-in cards carry no hold: they have no document, so there is nothing to offer, and the press falls through to the platform. Renaming a ghost, whose document is gone, writes the document back. See DECISIONS, "A group is managed from the door".

A to-do or a note is filed by holding its row — useHold again, the calendar's own — which opens GroupPicker, the one sheet that answers "which group?" anywhere it is asked (moving a group, a to-do, or a note). Four surfaces hold now: a calendar entry, a to-do row, a note row, and a group's card. On a group's own screen, at rest, a held to-do opens the calendar's kind of menu first — Move up, Move down, Move to another group, the edges offering no way past themselves — and the picker is one row of it; on the flat view and mid-search, where the rows are not one group's, the hold goes straight to the picker. The same sheet reaches past the edge of the tree. When the account is in more than one workspace, GroupPicker ends with an "Another workspace" head and one row per other household (elsewhere, from HouseholdsContext), and a shopping item's name — a row that never had a hold — gains one that opens the same list as an ActionSheet. A pick there is moveTaskToHousehold, moveNoteToHousehold or moveItemToHousehold (src/data/transfer.ts): the document written anew in the destination and deleted from the source in one batch spanning both households, because two queued mutations are strangers to the offline queue — a refused create would roll back alone while the delete still ran — whereas a batch is refused whole and the source keeps what it had; each write's isMember() reads pre-batch state, where both households exist. What crosses is decided in layer 1 (src/lib/households/transfer.ts), every field by name: the words, the clocks and a to-do's rule cross; the group, a person id, the order key, a regular and a trip do not, since each names a document in the household being left. The thing lands on the destination's first shelf, to be filed from there with that household's own picker — the picker never loads another household's groups. Only an OPEN item crosses (canTransferItem): a checked one is a purchase, and a purchase belongs to its trip. A note brings its receipts under the same receipt ids; the questions it asked stay behind (a dangling sourceNoteId was already tolerated). moveTask and moveNote are their own writers, never a widening of updateTask / updateNote: each declares the destination on its door in the same gesture (ensureTaskGroup / ensureNoteGroup), or a group would hold content while still being offered as joinable there; and moveNote deliberately leaves updatedAt alone, because a move is not an edit — see DECISIONS, "A move is not an edit".

A group's rows are in the household's order. order on the to-do is a fractional key (src/lib/tasks/order.ts): a base-62 string compared lexicographically, with a key between any two (keyBetween, the midpoint of the first differing digit, one character longer only when the two touch), so a row slides between its neighbours forever without renumbering. byHouseholdOrder puts keyed rows first in key order and the rest after, oldest first — the listener's order, which is what every group showed before the field existed, so a group nobody reordered reads exactly as it did and a new to-do (order: null) joins the bottom either way. Up and down go through reorderWrites: one write when both new neighbours carry keys or are the edges, and once per group — the first move inside an unkeyed stretch — every row on the screen keyed in its new order, because an unkeyed neighbour cannot bound a midpoint. setTaskOrders writes each key as one field on one document, so two phones placing different rows offline both land; two phones making that first keying at once each write a full set and the later one wins per document, which still sorts. Keys mean nothing across groups, so moveTask clears the key and the row joins the new group's tail. The flat view (/tasks/all) keeps its own three sorts and reads no keys — rows from many groups have no order between them. See DECISIONS, "Order is a key between neighbours".

Groups nest, and a card is a door rather than a disclosure — what a group holds renders INSIDE it, one tap through the card, on its own screen. No carets, no indent, no nested cards, no "2 groups" line: every list of group cards is a .map over cards that all sit at the same depth, because the screen IS the depth. A folder's count is its SUBTREE's, or a folder holding twelve things through its children would read as empty — and a folder showing 15 beside children holding 11 and 10 would read as broken. A group on a door brings its parent onto that door with it, whether or not the parent is declared there, so a child never becomes unreachable under a folder the other door owns. "Move this group", in the held card's sheet, opens GroupPicker over groupParentChoices plus "Top of the list", which is always offered and always first — coming back out needs no permission, and it is how a folder somebody regrets is undone. Back from inside a subgroup names its PARENT rather than the door. Removal needs an empty subtree and no children, since removing a folder over its children would orphan them.

Making a group is behind a text link — one action on the level's ActionLine (src/components/CaptureLink.tsx): a line of .linkish links, each a way of adding to the level, and under the whole line the one box that is open; CaptureLink is the same thing with a single action, which is what the calendar's day panel still uses. A household makes a group a handful of times, and a full-width box with a solid Add button under every card was the door's loudest thing for its rarest gesture; the cards are what the door is for. The link stays put when the box opens, above it, and reads Cancel while it is open: the words say what tapping does, and a phone has no Escape key, so a toggle is the only way back that works everywhere (Escape does it too). Nothing closes it by itself — a blur-to-close would take the join chips, which sit inside the open box, out from under the finger tapping one. The refusal line and the join chips are inside the disclosure with the box, because both belong to the question it asks. The calendar's day panel wears the same shape for "Add a to-do on this day", beside the note link it always had. See DECISIONS, "A rare box waits behind a link".

Nothing folds, because nothing nests on screen. The chevron, the per-device fold memory and the "2 groups" tally are gone with the inline expansion they served: every screen shows one flat list, and what is behind a card is behind the card. See DECISIONS, "Groups nest once, and the doors show groups only".

Inside the same disclosure, under the box, each door offers the groups the OTHER one already has as chips (joinableGroups, narrowed by what is typed with the same fold search uses). Each chip carries its group's id, and tapping it calls joinGroup on that document: a name cannot find a document once the document can be renamed out from under it, so creating no longer silently joins, and typing a name the other door already has is refused with a line pointing at the chip. Archived notes hold a group open, since removing it would strand the shelf. Every note written before the field existed reads back as personal: the migration is the total reader's default, not a rewrite, exactly as the to-dos' split arrived. Writers that do not know a group — the calendar's day note — land on personal, the safe shelf, and so does an add_note whose area the model left out or garbled.

A note is a title, a body, an area, archivedAt, and photoCount — the last a READ CACHE of its photos subcollection, see Photos below. Title and body are stored apart but edited as one surface. The list row, the A–Z order and the search all want a title without parsing a body first, so both are fields; but a person opening a note sees one page and starts typing, and the first line is the title — set apart from the body by one blank line, which belongs to the seam rather than to either field: noteText writes it, splitNoteText absorbs it, and typing the body straight onto line two commits identically. noteText / splitNoteText in src/lib/notes/ are that seam, and they are exact inverses from the stored side — which is what makes committing on every blur safe. They also carry notes written before this existed, whose title was typed into its own field: those simply open with the title already on line one. Nothing migrates.

A to-do typed into a note becomes a to-do. At commit — the same blur the date pinning rides — a body line opening with a to-do marker ("todo buy stamps", "To-do: call the vet", "- TODO — book the van", "to do: pack") is captured by captureNoteTodos: the text after the marker becomes a task through the ordinary addTask writer, and the line is pinned with (now a to-do) so the next blur cannot create it twice — the pin in the text is the idempotency, the same design as the pinned dates. The note's own area picks the shelf, verbatim: a work note's captures land on the work to-do list, a Personal note's on Personal — one namespace, one value, nothing to map. Dates are pinned before capture runs, so "todo: renew permit in 3 days" becomes a task whose title carries the resolved day — which is what puts it on the calendar. The title line never captures — the first line names the note — and the scan is conservative by the chip scanners' charter: the marker must open the line, and the two-word form needs its colon, so "To do this, unplug the router" stays prose. See DECISIONS, "A to-do typed in a note is captured, and its line is pinned".

Each captured line also earns a chip in the note's facts strip (todoChips in layer 1, TodoChips beside DateChips), riding quiet with the dates: "☐ buy stamps", deep-linking to the task on the to-do list it landed on. Arriving there scrolls the row into view and flashes it once with the accent wash — an event, never a persistent style (navigation state {highlight}, read by TasksScreen). The chip exists only while the task is still open on that list; completed, renamed away or deleted, it goes absent rather than dead — see DECISIONS, "A captured line grows a chip to its to-do".

The link runs both ways. On the Tasks screen, a captured to-do's chip strip carries the way back: "from “Moving house”", opening the note, with the calendar's from convention so Back returns to the list. The strip is on the row whether or not the row is being edited — see the Screens section. todoSources derives the pairing (one walk over the notes per render, shared by every row) from the same pinned lines by the same resolveTodo the forward chips use, so the two directions can never disagree; nothing is stored on the task, and a deleted note leaves no dead mark. An archived note still counts — it is still where the to-do came from, and it still opens. Both directions take the household's to-do-links look-back as since — a note last written, or a to-do made, before it earns no chip and no way back.

The pin has a second and final state: completing the to-do writes the completion back into the note. The row's box runs completeTaskStampingNotes (the one completion path), which completes the task and rewrites the pin on every line that captured it — "(now a to-do)" becomes "(done Sep 3, 2026, 2:41 PM)", dated and timed from the device clock, locale-free like every pin. Which lines get stamped is decided by the same resolveTodo the chips use, against the open set as it stood at the tap, so the stamp lands exactly where the chip pointed a moment before. A stamped line is terminal — capture skips any "(done …)" parenthetical, so completion can never resurrect the task — and editable like every pin: deleting the parenthetical re-arms capture. The stamp earns no chip of its own: the date scanner refuses its whole span (the shape is shared, completionStamp.ts), so a finished to-do leaves the note's strip and the calendar exactly as it found them — the day something was finished is a record, not a plan. Recurring tasks stamp nothing; completing one re-arms it, so its lines still name a live to-do. See DECISIONS, "Completion writes itself back into the note" and "A completion stamp is a record, so nothing chips it".

A note's row says one thing about what it carries: its photos. On its title line, in the faint voice the date wears, a photo count when there is one — and nothing else. The row used to draw the page's whole shelf line (facts, dates, to-dos, questions, people, each re-derived by six scanners per row on every render) and a list of forty notes was forty rows of small numbers nobody read; the title and the snippet say what a note is about, and the page says what it holds. The photo count stays because it is the one kind a title cannot hint at and a row cannot derive: the receipts are a subcollection, and a subscription per row is what the door was built to avoid. So the note document carries photoCount, a read cache the photo writers move with increment (which merges across offline clients) and the note page heals UPWARD when the receipts it subscribes to outnumber it — a note from before the field existed reads as carrying none until it is opened once. Never downward: the subscription starts empty, and writing that back would erase a right answer for the beat before the receipts arrive. The receipts stay the truth.

Photos. A note can carry photos, never inline: the BYTES live in Storage at households/{hid}/notes/{noteId}/photos/{photoId}.jpg (client-recompressed JPEG, 2560px longest edge). The size is two-tier, because the two jobs are not the same one: a photo that RIDES a callable payload and is then dropped — the receipt and pantry intakes — is scaled to 1568, which is what vision reads a label at, while a photo that is KEPT is scaled to 2560, which is what a person zooming into small print on a phone needs. Quality steps down (0.8, 0.7, 0.6) and only then the resolution, so a dense photo lands under the 2 MB Storage rule without being scaled past legibility. That re-encode is also what strips EXIF: a canvas holds pixels and nothing else, so no GPS, capture time or device survives into the bucket — a photographed prescription label never carries the home it was photographed in. The property is a side effect of a resize done for bandwidth, so tests/e2e/photo-exif.mjs pins it with a GPS-tagged JPEG, and anything that ever bypasses drawScaled must strip EXIF itself. Firestore holds one small receipt document per photo — the download URL (stamped once at upload, so no reader ever asks Storage again), the path, and the cached transcript. Upload writes bytes first, receipt second; delete removes the receipt first — at no point does a receipt name bytes that are gone, and orphaned bytes are the cheaper failure. A camera button in the note's header opens the OS picker (no capture attribute, so camera and library are both offered); the photos themselves ride as a row of thumbnails under the facts strip, each opening the full-size swiping viewer (antd-mobile ImageViewer.Multi) at its own photo — walked there by ref on every open, because defaultIndex seeds the carousel only at its first mount and the viewer stays mounted across closes. The index is then kept LIVE through onIndexChange, so the app always knows which photo is on screen rather than only which one was tapped: removing a photo walks the viewer to its neighbour — one back when the last is leaving, the same index otherwise, since the row closes up — and closes only when the note has no photos left. Deciding that on the COUNT alone left the carousel on an index the shrunken row no longer had, and the footer, which is drawn from the photo at that index, rendered nothing: an open viewer on a blank slide with its controls gone. A failed read is likewise remembered by photo id rather than as a flag, so "couldn't read it" appears under the photo it happened to and not under whichever one you swiped to next. The scroll wheel zooms the open photo: a plain wheel is relabelled as the ctrl+wheel the viewer's own pinch engine already understands, so the app owns no zoom arithmetic. The relabelling is document-level and capturing (the viewer renders in a portal), so it exempts the footer explicitly — a wheel over a transcript scrolls the words rather than zooming the photo behind them. Touch needs no exemption: the footer is a sibling of the viewer's touch-action: none stage, not a descendant of it. Both rows — the facts strip and the thumbnails — fold into one shelf under the editor: its closed line is icons and counts, one segment per kind the note carries, and the full rows unfold on demand. The closed line is the one row a carrying note always pays for, so it is the short one: --shelf-row-h (2.25rem) pulled up to --shelf-gap under the editor, its chips at the tool bar's compact 2rem, with the stack's 2rem of bottom padding kept between it and the tab bar so a thumb aiming at the toggle does not land on a tab (DECISIONS, "The shelf's closed line is the short row"). The strips are paid only while unfolded, at their own heights, and the transient chips (the undo offer, an upload in flight) surface without being asked, riding the closed line as themselves. Every row is fixed-height and scrolls sideways — by touch, and under a mouse by the wheel, whose vertical turn is redirected along the row (useWheelScroll, non-passive because React's root wheel listener cannot prevent the page's own scroll; the assistant sheet's photo strip takes the same ref, being this row seen from the sheet) — and the editor's height subtracts the open rows through one --editor-taken variable, so the writing surface moves only when a row appears or goes. It subtracts the workspace bar the same way (--editor-chrome, set through body:has(.ws-bar) since the bar is the container's sibling, from the --ws-bar-h token the bar itself is sized by): drawn only for an account in two or more workspaces, the bar otherwise made the note page taller than the viewport by its own height, and the page grew a scrollbar that scrolled nothing else. Each photo's viewer footer can read its text through transcribePhoto, correct that reading in place ("Edit text", a textarea wearing the panel's own metrics so the buttons do not move under the finger) or take it back entirely ("Clear text", which writes null and so is what puts "Read text" back — an emptied correction clears for the same reason, since a person deleting every word is withdrawing the reading rather than saying the photograph is blank) — on demand only, cached on the receipt, appended to the note only by an explicit "Add to note" that runs through the editor's own commit path. A permanent note delete cascades through the receipts and bytes (deleteNote); archiving leaves photos alone. A note moved to another household keeps its bytes where they were: the receipts cross verbatim (transferPhotoReceipt), url and path unchanged, and nothing under transfer.ts calls deleteObject. Reads need nothing — url is a token download URL, which is what lets an <img> render without a rules check — so every member of the destination sees every photo. Two things follow. transcribePhoto and the assistant's photo fetch reach the bytes through the receipt (notePhotoPath): the caller proves membership of the household the receipt is in, as before, and the receipt's path says where the object is, validated against the one shape a path may have before the bucket is touched — a receipt is only ever written by a member, so the gate is the same strength it was. And a later delete by a destination member who is not in the source household fails at the Storage rules, which gate on the path's household, and is swallowed as deleteNotePhoto always swallowed a failed object delete: invisible garbage, tolerated; the mover, still a member of the source, deletes clean. Storage access is governed by storage.rules, and the gate is household membership: every read, write and delete under households/{hid}/notes/{noteId}/photos/ checks request.auth.uid against the household document's memberUids through the cross-service firestore.get() — the same array, and so the same answer, the Firestore rules give for the note itself. The path is still three server-generated random ids deep and no list is ever allowed, but that is defence in depth rather than the authorization; download URLs stay token-capabilities, which is what lets an <img> render without a rules check. tests/emulator/storage.test.ts proves the whole matrix — member, stranger who knows the path, unauthenticated, member of another household — against the emulator suite, which is why npm run test:emulator starts the Storage emulator alongside Firestore and Auth. Uploads are capped at 2 MB and image/jpeg only — which the client aims under rather than discovering at the far end: quality steps down first and the resolution only as a last resort, and that last resort is measured too, so nothing unsized ever reaches the rules.

A big file is scaled by the DECODER, not after it. createImageBitmap decodes at full size, so a 16 MB screenshot at several tens of megapixels is hundreds of megabytes of RGBA before any of this arithmetic runs — the decode a phone rejects, reported as a photo that would not attach. So the head of the file is read first (imageDimensions in src/lib/photo/, a pure reader for PNG, JPEG, GIF and WebP headers, total and null for anything it cannot say), and when it says the image is bigger than the kept edge the decode is asked for the smaller bitmap through resizeWidth/resizeHeight. It is a hint in both directions: a decoder that ignores it hands back the full bitmap and the canvas scales it as before, and a format the reader does not know — HEIC among them — is decoded exactly as it always was. The canvas is also painted white before the draw, because JPEG has no alpha and a transparent screenshot over an unpainted canvas encodes to ink on black; and a null 2D context now throws rather than being swallowed, which used to upload a blank photograph as a success.

A failure says which failure it was: the toast names the cause — unreadable, too detailed to store, offline (Storage has no offline queue, unlike Firestore), refused — and carries "Try again", which works because the file is kept rather than dropped. One line for every cause was the complaint. Without a configured bucket (VITE_FIREBASE_STORAGE_BUCKET), the photo affordances are absent rather than dead — the intelligence rule, applied again.

The body is plain text. There is no markdown renderer and no formatting: the editor is a textarea filling the screen, live from the moment the note opens, with no edit mode to enter and no preview to toggle. New makes an empty note and drops you into it with the keyboard up, because opening one is an intention to write rather than to look at a blank page.

A blank note carries a how-to under its first line (NoteHowTo): the five markers a line can open with — todo, ask @sam:, @sam, [Sep 4, 2026], #renovation — each in one line, with the rule that a marker must open its line. Every one of them already worked and none was written down anywhere a person would find, which is the gap it closes. It goes at the first keystroke, with nothing to dismiss and nothing stored: the note stops being blank, so the panel stops being true. Inert (aria-hidden, no pointer events) and absolutely positioned inside the editor's box, so it takes no height from the --editor-taken arithmetic and never covers the caret or the placeholder. The examples are claims about the layer-1 scanners and have to be checked against them — a cheat sheet that drifts teaches a rule the app does not have.

Detected facts — a phone number, an email, a URL, a street address (detectFacts, scanning the draft live) — surface as an action-chip strip beside the editor, never as a rendered view of it: "Call 07700 900431", "Map 123 Main St". The strip is one fixed-height line (--fact-row-h) that scrolls sideways, and the editor's computed height subtracts it only while a fact exists, so the writing surface moves once when the first fact appears and not again. tel:/mailto: chips hand off to the OS; web and maps chips open in a new context with the opener severed. The list's snippets stay plain — those rows are already links.

Questions ride the strip too, one chip per question still open from this note (questionChips), each deep-linking to it on the index. A question surfaces in exactly four places and nowhere else: the note that asked it, the group it lives in (a Questions (n) shelf on that group's notes screen, where answering happens in place — its chips the compact row chip, and Answer the row's one accent verb, the app's inline action rather than another pill), the person it is for, and /questions behind More. On the index and the group shelf each row also carries the note rows' tray (QuestionRow's onArchive, the same .row-delete control and DownlandOutline glyph the note row and the note page use): a question nobody will answer is put away onto an Archived (n) shelf at the foot of /questions, the notes' archived shelf to the word — plain rows, Restore, and the one Delete — and leaves every other surface. No badge, no count carried out to a tab, no red — unanswered is an ordinary state and the app has no opinion about how long it lasts, the voice lateness gets. The household's questions look-back HIDES questions asked before it — off /questions, the group shelf, the More sheet's count and the assistant's snapshot alike, answered or not — and never styles them; a question's own chip on its note is per-note and stays whatever its age.

People the prose names ride the same strip: personChips resolves the draft's @ mentions against the live people list and renders display names over the handles — a name over a stored id, as groupName does — one chip per person, absent when a mention resolves to nobody, and cut at six (MAX_PERSON_CHIPS); the shelf's closed line counts everyone the note names (namedPeople, the uncapped list the chips are a slice of), not the strip's ceiling. While a handle is half-typed under the caret the @ menu floats AT the caret (MentionMenu in a .mention-pop, positioned by the mirror measurement in components/caret.ts — under the line like an editor's completion, flipping above only near the editor's floor; only once two characters follow the @, and showing at most eight rows, because on a bare @ a big household's menu was the whole directory over the words being written): a row of people whose folds the fragment prefixes, ordered by most recent mention (mentionRecency over every note, memoised) then by newest person, picked by pointer-down so the tap beats the blur-commit — or by keyboard: the arrows move the loud chip, Tab takes it, Escape puts the menu away, all handled on the textarea (the keys land there, never in the menu) and only while the menu is open, so Tab otherwise stays the browser's and Enter is always a newline. Picking splices a typed token (handleFor: the person's whole name through insertHandle, with the nickname in parentheses glued on when they have one — @shiyi_liu_(Alice)) over the fragment — a word resolved back through the fold at save, never an id; the menu's row shows the nickname beside the name so the word about to land is visible. When the fragment's fold exactly equals an existing person's, that person leads pre-selected and a "someone else…" row trails, which only closes the menu: the distinguishing act is typing more (@samk), and nothing is ever applied silently (inferStore's rule). Against an empty household the menu renders nothing, and typing a whole handle with the menu ignored — or offline — works identically: the menu is an accelerant, never a dependency.

The textarea is deliberately uncontrolled. A controlled one round-trips every keystroke through Firestore's local callback and back, which on a long note is enough to move the caret under someone mid-sentence. It is seeded once per note, keyed by id so switching notes reseeds it, and committed on blur. The screen header tracks the typed first line so it does not read "Untitled" over a note that plainly is not — display only; the write still waits for blur.

It merges when the document changes under it, and reseeds only when nothing is unsaved. The assistant's edit_note, or another member of the household saving the same note, would otherwise leave the old text on screen to be committed back over the new on the next blur. A ref (settled) holds what the editor and the document last agreed on, in stored form so the seam's blank line cannot read as a change, and settledAt which version that is. An arriving version while the textarea still matches settled is simply taken — the caret carried through it by the same merge when the editor has focus, since assigning a textarea's value would drop it at the end under a person mid-sentence; while keystrokes are unsaved it is merged — mergeText (layer 1, notes/merge.ts): three-way at the word, base settled, mine the screen, theirs the arrival, both sets of changes kept and the caret carried with the words being typed; where both changed the same words both versions stand, this screen's first. A version written from an OLDER base than this screen holds (its baseAt, below, names one that is not settledAt) merges against that older one instead, mine being whatever this screen holds: the other phone was offline and its save replaced ours, and the screen keeps the last eight versions it has seen for exactly this. The merged text is this screen's, unsaved, and goes out on the next pause. See DECISIONS, "Merge, not a lock".

It saves after a pause in typing, and commits on blur. A light save (save, 1.5 s after the last keystroke, and on leaving the note) writes the text as it stands — no pinning, no to-do or question capture, no people born, which all wait for the blur that ends the thought — and moves settled as a commit does, so its own echo is not read as an arrival. Leaving the note runs the light save and then the births alone (leave): the browser's back button and a swipe take the editor out of the document without a blur, and a @handle typed just before named nobody until the next blur; pinning and capture still wait for one. The blur's commit still does everything it did, measuring the day offer from committed (the text at the last blur) rather than from settled, so a day typed and saved mid-thought is still offered. Every text write from the editor carries baseAt: settledAt — the updatedAt of the version it was written from — which is what lets another screen tell a descendant from a version written apart. Writers that do not know their base (the assistant, a calendar move, a question's answer) write it null, which readers treat as "descends from whatever I hold".

What was replaced is kept for one tap back — a "Just changed · Undo" chip in the strip, which restores through the same commit path typing uses and goes the moment they type — only on a plain reseed (nothing unsaved, no older base) whose updatedBy is this account: the assistant's rewrite (written as the person it acted for) or their other phone. Another member's save reseeds silently, no chip: undoing it would overwrite their work (see DECISIONS, "An undo for another person's edit is an overwrite"). A merge offers nothing: nothing was replaced.

It says who else is here, and where. notes/{noteId}/presence/{uid} is one small document per phone with the note open — name, at (device clock), caret (an offset in stored form, null while their editor is not focused) — written by useNotePresence (layer 3) on arrival, every fifteen seconds, and when the caret moves (at most every two seconds; leaving the editor is written at once), only while online, and deleted on leaving the note; subscribeNotePresence is the one listener, held only by the open note's screen. Readers judge freshness themselves (othersPresent, layer 1: three heartbeats, and a clock ahead of ours is fresh), against a clock the hook moves along between snapshots, so a phone that stopped talking drops off without a new document. On screen: a small badge on the editor's top edge (.note-presence) saying presenceLine — "Bob is writing", "Alice and Bob are here", "Alice is writing, Bob is here" — and, for each person whose caret is known, a thin bar wearing their first name (.note-peer) at caretPosition of their offset carried across the seam (fromStored) into THIS screen's text, measured only while somebody else is writing and re-measured when they move, when the text here changes, and on scroll. Both are inert: awareness, never a gate — nothing is locked, the merge above is what keeps two people's words.

Its height is computed rather than flexed, from --screen-head-h, --tab-bar-h and the container's own padding: nothing above it carries a height, so flex: 1 would have nothing to divide. dvh rather than vh, because a phone's visual viewport shrinks when the keyboard appears.

Its Back is named for where it lands: the group the note lives in when you arrived from its own list, and the calendar or the to-do list when one of their chips brought you here (backName in src/lib/groups/, the reverse of the two path builders beside it, so a route shape and its name stay one fact). A path it cannot name says only "Back".

A note row is the grocery row's two targets — the label opens it, the control on the right gets rid of it — carrying a title, a date and one clipped line of the body. The X archives rather than deletes: a note is often the only copy of something, which is true of nothing else in this app, so nothing here is ever one tap from gone. A permanent delete is offered only from inside the archived shelf. Archiving leaves updatedAt alone, or restoring an old note would float it to the top of "recently updated" and the order would stop meaning anything.

The one delete outside that shelf is a note nobody ever wrote in. Creating a note is a navigation — the New button writes the document and lands you in it — so backing out of one is how a blank comes to exist, and the shelf fills with "Untitled" nothings that were never anything. discardIfBlank (layer 2) drops it when the screen closes. Every guard re-reads the stored document rather than trusting the screen that asked: blank by isBlankNote (whitespace counts, a title never does), not archived, and carrying no photo receipts — a photo is content with no words in it, so a blank note holding one stays, and this writer never has to cascade. There is nothing in a discarded note to recover, which is what makes the exception safe rather than an erosion of the rule.

One listener carries the whole collection, archived notes included, and the split is client-side — the shelf needs them and a where would cost the empty index file for nothing.

Notes carry #tags, as questions and to-dos do — detectTags over the title and the body on every render, never stored. The tags FIELD an early build wrote is still not read, and that distinction is the whole design: a stored taxonomy is a second copy of the truth that drifts from the words, while a tag read out of the prose cannot. Editing the words edits the tags.

Search and tags answer different questions, and one box answers both. Typing "renovation" into the search finds every note containing the word; typing #renovation finds the notes that CARRY the tag — the narrower claim, this note is about the renovation because somebody said so — and never the word in prose (see Search, below). A half-typed # offers the tags the group is currently writing about, off the live notes only, as completions under the box. There is no tag index row and no ?tag= any more: a tag is a token in ?q=. A tag that names a group IS that group wherever it is read, so a tag chip on a row navigates to the group's notes.

Tags have a home of their own behind More, beside People, and it works the way People does: /tags is a directory (TagsScreen) — A–Z, one row per word with a count of the live things carrying it inside the household's people-and-tags look-back (tagCatalog and tagAppearances take the same since, so a row's count and its page agree; the # menus and the search bar's tag chips read the same window through noteTagIndex / taskTagIndex, while a typed #tag still finds every document, old ones included — the search grammar is per document and is not windowed), searched by part of a word (?q=) and narrowed by ?g= through groupSubtree, both from the header — and /tags/:tag is one tag's page (TagScreen), every note, to-do and question carrying it, shelved by group, each row quoting the line that tagged it (tagAppearances, personAppearances' twin in src/lib/tags/appearances.ts, over detectTagMentions' spans). Nothing there writes: a tag is the word in the text, so there is no New, no Rename and no Merge — renaming a tag means editing the lines that say it, which the page opens each of. A tag chip on a note row, a to-do row or a question row goes to that page, as a person chip goes to a person's; the index row above a list is what filters that list. A tag that names a group still goes to the group.

The note editor completes a half-typed tag exactly as it completes a handle: tagAt beside mentionAt, the same two-character threshold, the same caret float, the same highlight and keys, and TagMenu beside MentionMenu. Only one can be open, because a caret sits after one opener. The candidates are every tag the household has already written — notes, to-dos and questions together, newest first (tagUniverse) — since there is no tag document to list. There is no "create tag" row: a tag nobody has used yet is how every tag starts, and typing it whole is the path that never needed the menu.

One header, one grammar. Every list screen — People, Tags, Questions, All to-dos, both doors and every group screen — renders ToolHeader: the screen header with two icons in its right slot, Search and Filter, each closed until asked for, so the resting screen is only its list. Search slides a bar in under the header, focused, with Cancel; it stays open whenever the query is non-empty, so a result's Back lands on the same search, and Cancel clears and closes. The box itself is SearchField (components/SearchField.tsx, the merge card's box too): a small ✕ inside the field at its right edge, there only while there is text, which empties the box and keeps the focus — and, in the header, keeps the bar open — where Cancel closes it; the browser's own clear on type="search" is hidden so there is one ✕ everywhere. The box holds its own draft, because the query lives in the URL and comes back a render late — a controlled input reset to the stale prop between two fast keystrokes lost the first — and reads the prop as an acknowledgement: a value the field sent is settled, one it never sent (Cancel, Escape, Back onto another query) replaces the draft, and values in flight wait in order so an echo that has fallen behind the typing is never read as a change. The one exception is the People directory (search.always): its box is the resting screen — no icon, no Cancel, never focused on arrival, Escape clearing — because a directory is opened to look somebody up. Filter opens FilterSheet, a bottom sheet holding the group tree (All, then every group two levels deep with a caret to fold a parent's children, groupTree) and the sort, whichever the screen has; a row applies on the tap and the sheet closes. An applied group is one summary chip under the header, its path spelled out ("Renovation › Kitchen ✕"), and a dot on the icon; the sort shows nowhere but the sheet. A Who section — Everyone, or Mine — sits between the two on All to-dos, the calendar and Questions, and only once the signed-in account has claimed a person (personForUid; the row wears that person's name in the nickname's voice): "Mine" is what NAMES that person, through the mention scanner (mentionsPerson; a question also by askOf, questionFor), wherever it is filed, and it rides the query string as ?who=me beside ?g=, each write preserving the other, with its own "Mine ✕" chip under the header. A link carrying who=me onto a phone whose account has claimed nobody is inert — everything shows — rather than showing nothing. Which screen has which: the directories and Questions filter by group; All to-dos and All notes by group and order; a notes group by order; to-do groups and the doors search only; the calendar filters by group, its title bar back for the purpose; All to-dos, the calendar and Questions also by who. See DECISIONS, "Search and filter live behind the header".

The grammar (src/lib/search/search.ts, parseQuery / matchesSearch): a query is words, tags and people, told apart by how they are typed. #boiler matches a document's tags by prefix, never the word in a sentence; @sam matches a document that mentions someone answering to that handle, through the fold the mentions resolve through (an unresolved mention answers by its typed handle); anything else is words, every token somewhere in the folded text. All of them narrow together — AND, no OR, no quoting. While a # or @ token is being typed at the end of the query, the tags in view (tagChoices) or the household (mentionChoices) are offered as chips under the bar, and a tap finishes the token (completionAt / completeQuery). The People and Tags directories search their own names with plain words and offer no completions.

On a door or a group screen the search looks at that level and everything filed under it, and never up. What it finds is documents and folders both. From a group's screen the rows become every active note (or open to-do) in the subtree — the group's own and its subgroups' — that matches, each brought up from a subgroup saying which (NoteRow's where, TaskRow's showGroup), and the cards become the subgroups whose names match (searchGroupCards); the archived shelf and the # completions read off the same widened set. From a door the level is everything on that surface: every group at the top and one down, and every document wherever it is filed — rendered by DocumentHits as ways in, not as content rows, because the door never renders content: a note opens on its page with Back leading to the door, search and all; a to-do lands on its group with the row flashing. A subgroup's screen searches only itself. The query is q in the query string, written with replace like every filter in the app. See DECISIONS, "Search looks down, never up".

Staples and the purchase stream ​

A check-off is two writes. The purchases/{id} document is the truth: it carries its own id, so two offline clients checking off the same staple cannot collide. Both the document and the cache carry sessionId, the trip the purchase happened on, which is how a purchase knows its shop: the session owns the shop, so re-attributing a whole trip stays one write and no purchase is ever rewritten. It is absent on entries written before the field existed and on purchases that honestly have no trip — a promotion seed, a receipt read days later, a past purchase typed in by hand — and absent means "no evidence about where this was bought", never "bought anywhere". rebuildPurchaseCache carries it back from the truth, or the repair would erase every shop the app had learned. staple.purchaseHistory[] is a denormalized read cache capped at 20 — rendering forty staples costs forty reads rather than three hundred, and the cadence engine never needs more than the last six intervals.

The cache is rebuildable. rebuildPurchaseCache re-reads the newest twenty purchase documents and rewrites the array, which is the repair for the one known failure mode: a capped array cannot use arrayUnion, so two concurrent appends can drop an entry. Losing one is tolerable precisely because it is not the truth. A staple seeded by promotion writes its seed to both, or a rebuild would erase history the cache claimed to have.

buildCheckOffWrites builds both payloads without performing them, so a test can assert directly that at is a plain device-clock number and never a serverTimestamp() sentinel.

Item-to-staple resolution at add time tries exact canonicalName matching first, then the trigram shortlist in src/lib/resolve/ with the model seam behind it; at check-off only the exact match is consulted. An unresolved item still checks off normally.

Sessions ​

A trip is a burst of check-offs, and the pause after it means you left. Sessions are created implicitly by the first check-off — never by asking "which store are you at" — and closed lazily on read: on mount and on every return to visibility, an abandoned session is closed. No timer and no scheduled function, so it works offline and costs nothing.

A session opens without waiting for a location fix. getCurrentPosition's timeout covers acquiring a position, not a permission prompt nobody has answered, so awaiting it could block the very tap that checks an item off. The fix is requested alongside and lands on the session whenever it arrives, or never — which is an ordinary outcome, not an error.

It opens without waiting for a read, either. ensureSession is synchronous: it answers from the open-session listener's last snapshot (sessionRef), or from the trip this device just opened and the listener has not echoed yet (opened), and otherwise opens a new one with a client-generated id. It never performs a getDocs, because on a connection that is up but not answering — the half-open state a phone in a supermarket lives in — a read waits on the server for ten seconds or so before falling back to the cache, and what it was holding up was the row moving. The check-off's writes now go out in the same tick as the tap, which is what src/data/items.ts promised all along. The opened ref is also what keeps two quick taps on one trip: both would otherwise see no open session and start their own. The listener normally catches up within the same tick, since Firestore raises local writes to listeners before the network sees them.

closedAt is backdated to the session's last activity rather than set to whenever somebody reopened the app, because the trip ended when the check-offs stopped, and a store visit recorded three days late would corrupt the trip cadence that lead times are derived from.

Items that were on the list and never checked off are recorded on the closed session as evidence for out-of-stock detection, and leftBehind (src/lib/sessions/leftBehind.ts, layer 1) reads it: the closed trips to a regular's own shop — pinned by hand, or learned from its purchases (stapleShop); a regular under Anywhere has no shop to count trips to — newest first, and how many in a row from the newest carried the regular in uncheckedStapleIds. A trip that bought it, or did not list it, ends the run; a trip elsewhere is not evidence about this shop and neither breaks nor extends it; an open trip has decided nothing. Two in a row (LEFT_BEHIND_TRIPS) earns one muted line on the Regulars row and under the Shop section of the regular's page — "Not found at Kroger on the last 2 trips there." (LeftBehindLine, the one sentence both render) — and fewer earns silence. It is a derivation over the recent-sessions listener, so nothing is stored and a re-attributed trip changes the answer at once. See DECISIONS, "A trip that came home without it is a sentence, not a verdict".

useCheckOff is the single place a check-off happens, because it is three things at once: the item leaves the list, a trip opens if none is running, and — if the item resolves to a staple — a purchase enters the stream. A repeat within six hours is treated as the same purchase, so two people in one shop record one.

Auto-add and dormancy ​

A client-side sweep, because there is no server in this design. It runs on the first settled snapshot after the app opens and on every return to the foreground — never on a timer.

Idempotency is three stacked guards, because two phones opening the app at the same moment both decide to add the same milk:

  1. The item id is derived — auto_{stapleId}_{local day of the due date} — so two devices computing the same cadence write the same document, even if both were offline when they decided.
  2. computeAutoAdds skips any staple the household already has in hand: one with an unchecked item, and one this trip has checked off. The second half is the same tripChecked chain the list renders its "Checked off" section from, so the sweep and the screen agree on what is still being handled — see below.
  3. The same batch stamps lastAutoAddedForDueAt, and a candidate is only offered when it differs. That also gives deletion the right meaning: removing an auto-added item does not bring it back until a new purchase moves the due date.

Guard 2 covers the trip because guard 3 cannot. A check-off writes a purchase, the purchase moves nextDueAt to a new cycle, and a new cycle is exactly what releases guard 3 — by design, since that is what lets a staple be offered again next time round. So for any staple due again inside its own lead window — milk on a two-day rhythm against the two-day fallback lead, or anything whose store is visited about as often as the item runs out — nothing else stood between the check-off and a fresh offer for the cycle it had just started. The sweep runs on every return to the foreground, and the first check-off of a trip raises the location permission prompt, so the item reappeared before the undo bar had expired and could never be cleared. Widening guard 2 to the trip is the fix; the lead window itself is left alone, because a long lead is right when trips are rare and an item due in four days with the next trip in five belongs on the list now.

The sweep shares the cadence map with the screens rather than recomputing it, so what it decides and what the user is looking at cannot disagree about the time.

Dormancy is written by the same sweep and never deletes anything: the staple stays, with one quiet question attached. Low-confidence staples are never auto-added at all — they collect on a collapsed "might be running low" shelf, because a wrong guess on the list costs more trust than a missing one costs convenience.

Stores, location and lead time ​

A store is an attribute of a staple, never a list of its own. Items on a list group by shop with one section open and the rest collapsed, so pinned items are visible without cluttering the trip. Which section is open is a per-device preference in localStorage, not household data.

Nothing is filed by hand. A shop reaches an item by two routes, and only one of them is stored:

storeOf(item) = staple.store            when overriddenBy.store === 'user'
             ?? learnedStore(staple)    derived, every render
             ?? anywhere

staple.store means pinned by a person — it is written only from the Shop panel on a staple's screen, and overriddenBy.store rather than the value is what marks it, so that pinning something to anywhere on purpose ("I buy this wherever, stop filing it") is distinguishable from never having pinned it at all. Everything else is derived. That asymmetry is the whole design: a pin cannot be trodden on by inference because inference writes nothing, and a re-attributed trip changes what the app believes immediately, with no sweep to run and nothing to migrate.

A shop is a place, not a label ​

A store carries the identity of a specific storefront: Google's placeId, that place's own coordinates, and an address. The id is what makes two branches of one chain two shops — names collide, and coordinates drift with whatever fix created the record — so it is also the dedupe key: findByPlaceId means the same physical shop cannot be saved twice and have its visit history split.

A shop named by hand carries a name and wherever the phone was. That is a working shop — it groups, it is assignable, it is learned from — that simply cannot be recognised by walking into it until a place is bound. attachPlace does that the first time you shop there with a key configured, and leaves the name alone: "Kroger on the way home" is what the household calls it, and replacing that with the sign over the door is the app arguing with you.

Shops come into existence by shopping ​

There is no setup step. The first check-off of a trip opens a session and takes a fix, without blocking the tap. inferStore reads the fix against shops we already hold, and attributionFor (src/lib/sessions/attribution.ts, pure) turns that plus what the Places lookup found into one of three verdicts:

inferStoreLookupVerdict
matchskipped, unless the matched shop has no placeIdattribute; bind the legacy shop once
anya placeId we already holdattribute to it — exact, and it outranks any guess
ambiguousranask, tied shops first
nearestfound a shop hereattribute: a bad indoor fix at a shop we know
nearestran, found nothingleave — not a shopping trip
nonefound placesask, offering them to save
noneran, found nothingleave
nearest / noneno key, so never ranattribute the guess / ask — exactly the pre-lookup behaviour

The lookup runs only where proximity is not confident, so a clean match costs nothing: roughly one Places call per new shop plus one per genuinely uncertain trip, not one per trip.

The two leave rows are what stops the app inventing trips. Proximity cannot tell a bad fix inside Kroger from the kitchen table with Kroger three streets away — both put you a few hundred metres from a shop you know — and attributing the second to the first records shopping that never happened, which then teaches the app that this is where you buy things. "Is there a shop where I am standing" is exactly what a nearby lookup answers, and a house is not a shop. Recording nothing is the honest outcome; the trip stays assignable from the header chip or from Recent trips.

Without a key every lookup returns nothing and lookupRan is false, which the table treats as "no evidence either way" and falls back to the behaviour that shipped before the lookup existed. A household with no key is not worse off.

The picker is never a modal. It renders inline on the list the way the seasonal question does, and an unanswered question just means the trip has no shop attached. A check-off never waits on it. It offers three ways to answer, in the order they are usually right: a shop already saved, tied ones first; a real place found around you, which saves a storefront rather than a name; and a typed name, which is the way through with no signal, no key, or a shop Google has never heard of.

One write re-attributes a trip, however long ago it was ​

The trip's shop lives in the list header beside "Finish trip", and tapping it opens the picker over every known shop. Correcting it is a single setSessionStore: purchases carry sessionId and the session carries the shop, so every item ticked off on that trip is re-attributed at once and no purchase is ever rewritten.

That holds for a trip that has already ended, which is the ordinary case — you get home, unpack, and only then tick things off, by which point the session has expired. Recent trips (/settings/trips) lists the trips that bought something, newest first, with the day, the count and the shop. Opening a trip shows what it bought — listedTrips hands back each trip's items, not only their count — and setting the shop is the same single write. The picker is offered outright only on a trip with no shop; a placed trip keeps it behind "Change shop", because a shop the app got right is read often and corrected rarely. The Settings row above it says how many still have no shop, from the same listedTrips in layer 1 that builds the list — counting one set and listing another is how you get "2 without a shop" and then find one. Nothing to reconcile: a trip from three weeks ago is as correctable as this afternoon's, and everything bought on it moves with it. The list reaches back as far as the household's trips look-back (ninety days unless Settings says otherwise) — what each trip bought is counted from the checked-item listener, which carries that window of check-offs (below, Screens) — and the screen says so. The recent-sessions listener itself stays the newest fifty, unwindowed: learnedStores resolves a staple's newest five purchases to shops through it, and a day window would blind shop learning for anything bought less often than monthly.

Adding a shop by hand lives at /settings/shops. It searches Places by name or address — "kroger southlake", "1305 E Southlake Blvd" — biased toward where the household already shops, so a shop added from an armchair is tied to a specific storefront exactly as one saved by walking into it. That search is the one part of the feature that does nothing without an API key, and says so rather than presenting a dead box.

What an item learns ​

learnedStore (src/lib/stores/learn.ts, layer 1) reads a staple's cached purchases, resolves each one's trip through subscribeRecentSessions, and answers with the dominant shop of the newest five that have one — requiring at least three, and a strict majority. Buy milk at Kroger three times running and it files itself; buy it wherever and it stays under Anywhere; one odd trip does not unfile it, and a 2-2 split answers nothing rather than picking whichever the iteration reached first, because two devices holding the same data must not disagree. The window counts purchases that can say something, so a run of receipt imports with no trip cannot push the real evidence out of view.

Deleting a shop unpins whatever was filed there by hand, or those staples would keep an override pointing at a document that no longer exists: never grouped there again, and never free to learn anywhere else either.

Lead time ​

Visits are recorded when a session closes, backdated to when the trip actually ended, and feed the trip cadence that lead time is derived from: an item due before your next expected visit goes on the list now, one due after it waits. Medium confidence doubles the window — a shakier prediction is offered earlier, never held back, because the premature item is the cheap failure and the empty shelf the expensive one. Fewer than three visits, or a staple stored at anywhere, falls back to a fixed two-day lead, doubled the same way at medium confidence.

PWA ​

vite-plugin-pwa in generateSW mode with autoUpdate. The service worker precaches the shell only and declares no runtime caching: Firestore keeps its own offline queue in IndexedDB and talks over a long-lived stream, and a worker that intercepted that would break sync in ways that look like data loss. It is enabled in dev as well, so the end-to-end run exercises the registration path that ships.

The build splits vendor code into three chunks — react, firebase and antd — via rolldown's codeSplitting groups in vite.config.ts, so an app-code change re-downloads tens of kilobytes rather than the whole megabyte; the service worker precaches all of them under the existing glob.

The icon set — favicon.png, icons/icon-192.png, icons/icon-512.png, icons/maskable-512.png, icons/apple-touch-icon.png — is derived from one source, art/app-icon.png, which deliberately lives outside public/: everything under public/ ships and is precached, and the source is 1.4 MB. Two treatments: the favicon and the "any"-purpose icons are cropped to the art's tile with corners masked to transparency (they sit on arbitrary surfaces); the maskable and apple-touch icons are the tile full-bleed on black with no alpha, because those platforms cut their own shape and the art's internal margins are the safe zone.

Screens (layers 3 and 4) ​

src/hooks/ owns every subscription; no component calls onSnapshot. One listener carries all open items across every list, and the screens filter it — the lists are tens of documents, and a where plus orderBy would need a composite index for no gain. firestore.indexes.json carries only the purchase rebuild's pair as a result. A second listener carries the checked items since the household's trips look-back (subscribeRecentlyChecked, a single-field range on checkedAt from a since the hook hands it, so no index either). Checked items are the one household set that only grows, and every reader of that listener looks at the recent past — the trip still running, the trips worth attributing, three adds worth promoting — so it is windowed by the calendar rather than by how long the household has been shopping. useLookBackWindows (src/hooks/useLookBack.ts) turns the household's setting into one {days, since} per surface, fixed per household snapshot, and App.tsx hands each screen its own; the trips listener WAITS for the household document (since is null until then, and null opens nothing) rather than opening at the default and reopening at the stored value a moment later, since a reopen is a full re-read and a pure derivation over the default is free. A changed setting reopens it. An item that ages past the cutoff mid-session leaves at the next open, which nothing on screen can tell from the day before.

Routing is react-router-dom: / for the list of lists, /list/:listId, /tasks for the list of to-do groups, /tasks/all, /tasks/:taskListId (any group slug), /tidy-up, /notes for the list of notes groups, /notes/all (the flat view, ahead of the rest for the reason /tasks/all is), /notes/personal and /notes/work (static routes, not a :noteListId param — /notes/:noteId shares the segment, two dynamic patterns on one path are ambiguous, and static outranks dynamic), /notes/area/:areaId for custom notes groups wherever they sit (their ids could wear a note id's shape, so they get a segment of their own), /notes/:noteId, /calendar, /privacy, /receipt, /questions (every question, open first and newest first, answered ones kept on a shelf underneath, the header's search over the questions' words, tags and people and its filter sheet narrowing by group (?q= and ?g=, subtree semantics and all, each write preserving the other)), /people and /people/:personId (the directory of everyone the household has named — filed by family name, each row wearing its person's nickname beside the name in the muted voice (the same .nick the @ menu and the search's offers use, and the merge roster too) because a directory filed by family name stands near-duplicates next to each other and the name alone is the one thing that cannot tell them apart — and "you", in the same voice, on the entry this account has claimed — and their job under the name in the subtitle voice every row wears (.row-note), with the header's search over names, nicknames, folded-away aliases and jobs (searchPeople, the merge card's own fold, so "chen" and "sam_chen" both find Sam Chen and "plumber" finds whoever was given that job; ?q= in the query string, written with replace like the notes search) and its filter sheet narrowing to whoever that corner's live texts name (peopleNamedIn), since people carry no group of their own, each write preserving the other and the rows carrying both back — and one person's page — a derivation over the live notes and tasks via personAppearances, grouped by the group each text lives in as a heading over one rows card, each row showing the line that names the person with the mention itself in the accent voice, with one quiet line under the title for what is done TO the person (.person-tools): "This is me" — first, because it is the one tool about the READER: the account claims this entry (claimPerson, releasing whichever it claimed before), the header then says "you" beside the name and the word becomes "Not me"; absent while another account holds the claim, since there is nothing honest to offer there — then Rename, then Merge where there is anyone to merge with, then Remove — only while nothing in the household names them (peopleReferenced, over EVERY document whatever its state and age: an archived note, a completed to-do and an answered question still say a name, and the look-back window is a rule about screens, not about what exists), asked once in the merge card's shape, and landing on the directory; a mention typed later births them again — and under it the two things TYPED about them, Nickname and Job, as fields of their own (PersonField, the Look back fields' clothes: the label over a full-width box, always a box, a draft re-seeded from the document only while nothing is being typed, commit on blur or Enter, Escape putting the stored value back; a refusal — a nickname that makes no handle — stays under the box with the words still in it, and the job's box offers the household's other jobs as chips while it is focused). Under the rows, two shelves that read sideways, each absent when empty: Named alongside — the people named with them in at least two of the same texts (CO_MENTION_MIN; one shared text is left out as coincidence, DECISIONS "One shared text is coincidence"), as the quiet person chips every note wears, most often first with the count in the nickname's voice, each a way to that page (coMentioned) — and Same details elsewhere — each phone number, email, link or address from the person's texts that other texts carry too, as its own fact chip (the note page's, shown short) over the rows of those texts in NoteRow's shape with the group under the title (sharedFacts). See BACKLOG, "Let me see options for search by related content", for the crossings not built. The header keeps the name and the way back, and a rename takes the tools line's place while it runs — Merge opens an inline card, never a sheet: a search over names, nicknames, folded-away aliases and jobs (searchPeople), a multi-select roster that scrolls inside the card so the confirm stays reachable however many people the household names, a "Keep this name" row naming the survivor — this page's person by default, and any of the people involved instead, because which spelling a household keeps is a fact about the household and the page that happened to be open is not evidence of it — and one danger-voiced button that states the consequence in the chosen direction ("Sam becomes Sammy…") and names the survivor ("Merge Sam into Sammy") before folding everyone else into them, because a merge deletes documents and has no unmerge. Keeping somebody else's name navigates to their page with replace, since the document being read is the one that just went; no New button, because a person is made by naming them in prose), /settings and its four subscreens — /settings/shops, /settings/trips, /settings/knows, and Regulars at /settings/regulars with /settings/regulars/photo and /settings/regulars/:stapleId beneath it, plus the two household screens — /settings/workspace/new (start a shared one) and /settings/members (who is in this one, and the invite link) — and /join/:hid/:code, the far end of an invite link. Sign-in is a gate above the router, not a route — and the address a signed-out arrival came in on survives the sign-in by being remembered, not by trusting the address bar: the popup leaves it alone, but an installed app that leaves for the OAuth handshake can come back at its start URL, which made an invite link a thing to tap twice. On the signed-out side the shell writes the path and query to localStorage (rememberSignInReturn, firebase/auth.ts) whenever the path is one worth returning to (isReturnPath: anything but /, /index.html and /privacy); on the signed-in side it takes the entry back, keeps it only if it is under a day old (signInReturn, both layer 1 in households/prefix.ts), and if it differs from the address on screen puts it back with replaceState and re-asks for its household — before the router mounts, which waits on the household anyway.

A household is a scope, and the second one is a card, not a group. An account may belong to more than one household (see "Households" above), and one is on screen at a time: every hook in the shell is scoped by the one hid, exactly as before, so no screen below the doors changed. The solo household owns the bare paths — /notes, /tasks/work — as it always has; a shared household lives under /h/{hid}/…, and the shell mounts BrowserRouter with that as its basename, keyed by household, because the router builds its history once and only strips the base it was born with. Every link inside the app is written against the bare shape and lands under whichever base is mounted, so backName, the tab bar's prefix match and the assistant's matchPath needed no change — they read useLocation().pathname, which is base-relative. src/lib/households/prefix.ts is the only reader and writer of the prefix: householdPrefix parses it, prefixedPath builds one. The shell reads it once before anything mounts and again on every popstate (Back across households lands outside the mounted base, which would otherwise draw nothing); a prefix the account does not own falls back to the solo household on the bare path — but only once the list is KNOWN, because until then the boot screen holds and the * catch-all cannot rewrite a cold deep link on a guess. The way between households is switchTo on HouseholdsContext (src/hooks/useHouseholds.ts): push the new address, then ask for the other household, and the keyed router remounts reading what it finds.

On screen a household is a workspace. The word people see — in the bar, the picker, Settings, the join screen, the move sheet — is workspace; the schema, the rules, the paths and this code keep household, because a rename there would touch every document path for no one's benefit (see DECISIONS). WorkspaceBar is one line of chrome above every screen, drawn by the shell as soon as a household is known: a dot, the name, a caret. Tapping it opens the picker — a Popup in the filter sheet's shape holding every workspace as the doors' own card (householdCards), the one on screen lit and ticked, and Start a shared workspace under them — and a pick calls switchTo(id, doorOf(pathname)): the switch keeps the TAB and drops the rest of the path, because a note's id or a group's means nothing over there (doorOf in prefix.ts, a pure map from a bare path to its door). The bar sits ABOVE <main>, outside the container, so it spans the whole width: a strip with one centred control on it. It is drawn only when the account is in more than one workspace — with one there is nothing to switch to, and a shared one is started from the row in Settings. There is no other way between workspaces: the doors carry no card and no subtitle.

A workspace's colour is the account's own. Six tones — emerald, blue, purple, rose, rust, slate — are six settings of the four accent tokens in tokens.css, and of the neutrals with them (ground, surfaces, border, the three text greys, the card shadow: the sage values with their hue turned to the tone's at the same lightness and chroma, emerald keeping the sage itself — see DECISIONS, "A workspace's colour is yours"), applied by data-ws on the document root for the workspace on screen (so antd-mobile's portalled sheets recolour too) and on any element wearing a tone of its own: the picker's dots, the swatch row. The choice lives on users/{uid}.tones, a map by household id, written by setWorkspaceTone with a merge and kept live by subscribeUserHouseholds beside the back-pointer — never on the household document, because a colour tells YOUR workspaces apart and the same shared one may be blue on your phone and rose on someone else's. Unchosen, a workspace takes its tone from its place in the account's list (defaultTone: the solo one emerald, the shared ones round the other five), so two workspaces differ from the first second. Settings' Workspace section carries the Colour row: six swatches on their own line under the label, the one in force ringed, one tap to change. --auto-add and --danger are not tones and do not move, so an auto-added row and a refusal keep their own voices in every workspace. A household cannot sit in the group tree — the two-level cap (groups/tree.ts) forbids a third level, and a group is a document inside a household — which is why the way between them is chrome above the doors and not a folder in them.

The first shelf wears a shared household's name, and Work is no shelf there. BuiltinNames (groups/groups.ts) is the one place what the built-in shelves ARE on screen lives: builtinNamesFor(household) answers {personal: household.name, work: false} for a shared household and the defaults ({personal: 'Personal', work: true}) otherwise, and every reader that renders a name or lists the shelves — builtinName, groupName, backName, the card and tree builders, the parent picker, the filter tree, the move sheet's targets — takes it as an optional trailing argument. Components read it through useBuiltinNames() from the same context. work: false means the mine-versus-job split does not exist in that household (the workspace is the split), so Work is not drawn empty and not offered as a destination; but an id something still names stays reachable, so builtinShelves(names, holdsSomething) lists it for as long as content or a subgroup sits under it on the surface in hand — the rule that keeps a custom group open, applied to a built-in. Its stored word stays reserved as a group name either way, since the id can reappear. Ids never change; builtinName(id) with no names still says which ids are built in, which is what the detection sites use, and builtinName('work', names) is "Work" in every household, for the route, the back link and the content still filed there. tagGroup deliberately still resolves #personal and not the household's name: the search grammar names shelves by their stored word.

Device-local keys that are really per household are namespaced by hid: the seasonal pass's month, the review pass's stamp, the open shop section, and the doors' fold state. The client-side passes (useAutoAdd, useSeasonal, useReviewPass, useBracketMigration) run for the household on screen only — other members' phones cover a shared one, and the solo one is only ever looked at by its one member.

Nesting added no routes. A group is addressed by its id at a flat path, never by its position: a path-addressed URL like /notes/area/renovation/kitchen would encode where a group currently SITS, so re-parenting would break every link to it — the exact failure opaque immutable ids exist to prevent. Depth is a display fact, read from a document the app already subscribes to. backName needed no change either: it resolves through groupName, so it follows renames and nesting for free. /privacy is the one page on BOTH sides of that gate: OAuth verification requires the policy readable without an account, so the signed-out branch checks the path by hand (no router exists out there) and serves the same router-free PrivacyScreen the signed-in route uses; the sign-in landing links to it. The routes are flat — there is no Outlet — and /staples, /staples/photo and /staples/:stapleId redirect to their new homes, because an installed PWA's own history, a bookmark and any link already shared all outlive a route rename.

A fixed bottom tab bar (antd-mobile TabBar) moves between /notes, /calendar, /tasks, /lists and More, in that order; the app opens on Notes — / is a redirect to /notes, so the PWA's start_url and every old bookmark land there, and the Lists home lives at /lists. The bar stays visible on every other route and highlights the nearest parent tab (/notes/* counts as Notes, /calendar as Calendar, /list/*, the photo intakes and Tidy up as Lists), and it carries no badges by design. More is a launcher, not a screen: its slot opens one ActionSheet over whatever is on screen, holding People, Questions and Settings (Settings apart under a divider — content above, the app below), each row a real route the sheet merely navigates to. The destinations live in one array (MORE_DESTINATIONS in AppTabBar.tsx) that both the sheet's rows and the highlight's prefixes read, so the More slot lights exactly when the current route is one of its own — /settings/*, every subscreen included, /people/* and /questions. The Questions row carries its open count ("Questions · 4 open") inside the sheet and nowhere else — open questions inside the questions look-back, the same cut the screen makes, so the number and the list agree. The sheet pushes no history entry; any navigation closes it, so Back never digs through a menu that was only a springboard. The bar itself is never gated: the two photo intakes live as quiet chips on the Lists home when the intelligence layer is on, and note creation lives on each notes area's own New button.

The Calendar screen carries no screen title — the month bar ("‹ August 2026 ›") is its name, a "Calendar" heading over it said the same thing twice, and the grid wants the height more. It is entirely derived: calendarEntries (src/lib/dates/) reads the live notes and tasks each render, so a day carries a dot because text names it or a recurring task's stored pending slot (walked forward on its own rule) lands on it — nothing is persisted, indexed, or subscribed beyond the listeners the app already holds. The selected day rides in the query string (?d=YYYYMMDD, written with replace), which is what lets every date chip in the app deep-link to its day; a month browsed with the arrows rides as ?m=YYYYMM with nothing selected, because moving between months must not invent a selection. A pending occurrence whose day has passed renders on that day in the same muted voice as any other past day.

Holding an entry — or right-clicking it — offers to move it a day, or take it off one. (useHold lives in src/components/ and serves the to-do and note rows too; the calendar had it first.) Held rather than shown: a row wearing a button is a row you read past a button, and moving a thing is rarer than seeing what is on a day. The sheet (antd-mobile ActionSheet) names the day it will land on rather than saying "snooze", and offers Open beside it so a hold meant as a tap is not a trap. useHold (src/components/useHold.ts) is the gesture: a timer the finger can outrun (past HOLD_SLOP_PX the list is being scrolled), plus contextmenu for the two platforms that have the gesture natively — a desktop right-click and Android's own long-press — whose default is prevented so the browser's menu does not open over ours. iOS raises no such event and is served by the timer, with -webkit-touch-callout: none keeping the callout off. The click a touch long-press leaves behind is swallowed, and only that one: a right-click is followed by no click, so it deliberately does not set the flag.

The move itself keeps the screen derived. A note or one-shot to-do dated by its text has that text rewritten (rescheduleDate over the title, then the details); a recurring task's slot is the one dated thing in this app that is a FIELD, so snoozeOccurrence moves that field and leaves the rule alone — every later slot is still the rule's, and completing re-arms from the rule, so a snooze can never drift a schedule. The day it lands on is then selected: the entry has left this list, and the honest confirmation is the day it went to, with the entry on it.

"Take off this day" is the same walk with uncommitDate in the middle, and it answers the one thing this screen could not do: a day filed by mistake had to be found in the note and unbracketed by hand. It says "this day" rather than "the calendar" because the document may commit to others and the gesture only ever reaches the one being held, and it is absent for a recurring slot, whose day comes from a rule rather than from any text — the honest answer there is to edit the rule, not to look like this menu can. Nothing is selected afterwards, because nothing went anywhere: the entry leaves the day's list under the write, which is the confirmation. The words stay; removing the sentence would be this screen editing prose it does not own. rewriteDatedText in CalendarScreen is the shared walk both actions take — title first, then the details, never both blindly, because every pass here answers null for text that does not name the day — and it answers whether anything was written, which is what lets the move confirm on screen only when there was something to confirm.

The calendar filters by group, and joins the namespace as a VIEW. The header's filter sheet (ToolHeader, the same one every list screen wears — the title bar came back for it; the month bar under it stays the month bar) narrows the screen to one group and everything filed inside it, both doors' groups because a day carries notes and to-dos side by side, spelled out as the summary chip under the header — and, once the account has claimed a person, to "Mine": the notes and to-dos that name that person are filtered BEFORE the days are read off them (mentionsPerson over noteText and the to-do's edit text), so a day is theirs when the text that put it there says their name; ?who=me, kept the same way. The filter rides the query string as ?g=<id>, beside ?d= and ?m=, so a filtered calendar is a link and Back undoes a filter; every write of d or m carries it, since setParams replaces the whole query. There is no calendar boolean on GroupDoc and there should not be: the calendar owns no content, so the only correct answer to "which groups may it show" is all of them, and a flag would permit a dated task whose group is calendar-hidden — the calendar lying about what is on a day.

A selected day's panel ends in two links of one voice, and keeps the screen derived. "Add a to-do on this day" is a CaptureLink — the box behind the link, as the group doors' is — where it used to be a full-width box with a solid Add button under every day you tapped, twice the weight of the note link beside it for the same kind of act. Open, it writes an ordinary home to-do whose title ends in the day, committed — "dentist [Sep 3, 2026]", commitLabel over pinnedDayLabel's locale-free format, exactly what the scanner reads back onto this screen; typed relative phrases are pinned first, as the Tasks screen pins them. "Start a note for this day" seeds an ordinary note with the day label as its body and opens it fresh — focused, caret on the empty first line, because the first line is the title and the title is yours to type (fresh rides the navigation state, gone after a reload like from). Both captures land in the group being FILTERED to, or Personal when nothing is — and in the folder itself, never one of its children, because the filter is a view and an add needs one shelf; guessing a child would be the calendar inventing filing. Each declares the destination on its own door in the same gesture (ensureTaskGroup / ensureNoteGroup). The calendar still persists nothing of its own; moving an entry off a day is editing its text.

Regulars is deliberately not on the bar. It is a ledger you consult rather than somewhere you go while standing in a shop, and it does not earn one of five permanent destinations; it is a row on the settings screen, beside Shops, Recent trips and What the app knows. Sign out lives there too. There is no app header: the screens start at their own titles, and the connection pill appears above them only when there is something to say.

The Notes tab opens on the list of groups — Personal, Work (where it is a shelf) and the household's own, one container of rows under the level search, as the Notes section above describes. Each group's list keeps its search and sort in the query string rather than in component state, so opening a note and coming back lands on the same filtered list; it is written with replace, because a history entry per keystroke would turn the back gesture into a way of un-typing a search one letter at a time. A note opens as one page you write on — a live textarea from the moment it appears, no read mode and no toggle — with any detected phone, address, email or link offered as chips beneath it, exactly as the Notes section above describes; its Back falls back to the note's own area when there is no navigation state to carry it somewhere more specific.

The quantity stepper appears only for quantityMode: 'prompt' staples and only at check-off — at add time you do not yet know what you will buy. It defaults to the last quantity, so the usual amount is one tap, and it is skippable.

Promotion is offered inline on the list, where the context already is, rather than filed into a queue. It counts adds, so the checked-item listener exists alongside the open-item one — the household's trips look-back of it, which is the window three adds have to land in. The auto-add sweep reads both for its own reason: an item ticked off on the trip still running counts as handled, so it is not offered again (see Auto-add and dormancy).

Regulars — now at /settings/regulars — opens with a dismissible note explaining that the engine improves with use and needs a few purchases first. The screen's restraint is otherwise indistinguishable from a broken feature: a new household sees "Learning" against every row and has no way to tell that the silence is deliberate. Dismissal is a regularsNoteDismissed flag in localStorage — per device, like lastStore, and no Firestore document is involved.

Capture lives on each list screen and adds to that list. It is not on the home screen, because until the classifier ships nothing can route a typed string to the right list, and asking the user to choose is the friction the design exists to remove. On a to-do group it waits behind its link: the line under the header reads New to-do, then New group where one may be made, and the box — with the Repeats rule under it — opens under the whole line when New to-do is tapped, exactly as the group box does (ActionLine, one open at a time, Cancel or Escape putting it away and forgetting the rule with it). The notes side has the same line with New note, a plain action, since a note is made by going into it. The doors carry New group alone — a note or a to-do needs a group to land in — and a subgroup's line carries only its own New.

Under a group's open to-dos sits its Done shelf — the notes' archived shelf to the word: closed by default, Done (n), and inside it this group's completed one-shots within the to-dos' look-back, newest first (doneTasks, layer 1), each row the title over "Done Sep 3, 2026 · Sam" (the completer resolved through personForUid, absent when nobody has claimed that account) and a Put back that reopens it (uncompleteTask). Not the subtree's and not while searching, which searches what is open. A repeating to-do never lands there — it re-arms — and the look-back is what keeps a year of finished one-shots from piling under the list. A put-back to-do's note line keeps its "(done …)" stamp, which the capture reads as "made once already", so the to-do is not made twice.

A list row is two targets: the box checks the item off, the name opens it for editing in place. The box keeps an oversized hit area to pay for the width that split costs it. A task row splits the same way — the box completes, the title opens the editor. The editor IS the note page's pattern, through the same layer-1 seam (taskEditText / splitTaskEdit in src/lib/tasks/, built on noteText / splitNoteText): one surface where the first line is the title, a blank line stands it apart, and the details — the fact that makes a task doable away from home (the filter size, the number to call) — follow, shown under the title on the resting row with line breaks preserved. The textarea grows with the text; Enter breaks a line, the save is the blur, Escape puts the saved text back, and the caret opens at the end — a detail-less task seeds as "title\n\n" so the caret lands under the separator, ready for the detail rather than mid-name, because details accrete rather than get retyped. The task-shaped edges of the seam: a blanked first line leaves the name alone (a to-do cannot be called nothing), and cleared details go back to null.

The row's chip strip — the detail line's facts and dates, and the "from" chip back to the note a captured to-do came from — is a second full-width line of the row (.task-facts), wearing the compact row chip (.chip .compact: the app's pill one size down, no tap height, clipped rather than wrapped — the one design every chip inside a list row wears, on to-do, question and note rows alike; a full-size chip is a tap target on a strip of its own), and NEVER unmounts. While the row is being edited it reads the draft, the note page's own arrangement: chips beside the working surface, so a just-typed number is offered without a tap away. At rest it reads the saved text, anchored at the task's own write time — the same chips, from what is actually stored. That the strip outlives the edit state is what makes a chip tappable at all: it used to be drawn only while editing, and the press that reached for it could blur the editor away and take the chip out of the DOM before the click landed. See DECISIONS, "The row's chips live on the row, not only inside its editor". A task made from a seasonal suggestion arrives with the suggestion's reason as its details. Only the "might be running low" shelf keeps a single whole-row control, since it has nothing to edit inline.

Checking an item off is undoable for a few seconds, and the undo reverses the purchase, not just the list row — the purchase stream is the only thing cadence learns from, so a mis-tap left behind would quietly shorten the staple's interval with nothing on screen to explain it. The undo bar is pinned to the viewport rather than placed in the page, and its clock runs only while the page is visible and focused: the first check-off of a trip raises the location permission prompt at the same instant the bar mounts, and an undo that expires under a prompt was never really offered. Session writes are not awaited before the tick lands, so the bar appears with the tap rather than a round trip later.

Checked items are completed, not gone: the current trip's check-offs stay below the list, struck through, and a tap puts one back — the purchase reversed by the timestamp the check-off stamped on both documents, minutes after the undo bar expired. What counts as "this trip" is the session-gap chain (tripChecked), the same 75-minute constant that ends a session, so the section empties itself by the next shop with no timer and no cleanup job. The purchase document is deleted rather than voided, because an undo means the event did not happen and a voided record would have to be filtered by every reader forever.

The settings screen (/settings) is a menu, not a ledger. Anything with a screen's worth of content has a screen — Regulars, Shops (/settings/shops), Recent trips (/settings/trips) and What the app knows (/settings/knows) are four rows under one Learned heading, each stating what it holds. Below them sit the two household facts that are actually anyone's to change, Name and Region, as rows that edit in place; then, in the solo workspace, Start a shared workspace (/settings/workspace/new: one name field, and the screen goes to the new workspace's Members page once the back-pointer says it is there to go to), or in a shared one, Members (who is in it, "started it" on the creator's row; the creator's X on every other row removes that person after a confirm; a joiner's Leave this workspace at the bottom, after a confirm, lands on Settings in the solo workspace), and in either a Colour row (this account's tone for the workspace; see "A workspace's colour is the account's own" above) (/settings/members: who is in it from useMembers, and the invite — Make an invite link mints one, shown whole with when it lapses, with Share (the phone's share sheet, the clipboard otherwise), New link and Withdraw; leaving or removing is not offered yet). Under the Workspace card, a Look back card of its own — a section like Learned and Workspace, always open, since it is about every surface rather than the workspace's name and members. At rest it is one field and a switch: Everything, the days every surface reaches back, and Different limits per area. On, the switch reveals a field per surface — Trips, Calendar, People and tags, Questions, To-do links and assistant — each blank ("Same as everything · 90" as its placeholder) unless given its own number; off, the numbers stay stored and stop acting (lookBack.overrides). The fields are DaysField: real inputs, always inputs, the label and hint on one line and the number on the next with "days" inside its right edge — not tap-to-edit rows, which put label, hint and value on one line and made only the label tappable. Each keeps a draft re-seeded from the document only while nothing is being typed, commits on blur or Enter through parseDays, and puts the stored value back on Escape or anything that is not digits. The switch is SwitchRow: a native checkbox with role="switch", skinned as a pill in CSS (.switch) — native for the Repeats checkbox's reason, that antd-mobile's hides its real input from the locators the e2e suites use. Blank on a surface writes null; blank on Everything writes nothing. The data layer clamps (setLookBack), so a typed 400 reads back 365 on the next snapshot, which is the honest feedback. Then Sign out. It was seven equally weighted cards, three of which were usually paragraphs explaining they had nothing in them.

/settings/knows is the ledger: every answered profile fact stated back in the exact words its question used, facts implied by the tracked staples (marked as such, with nothing to forget — they would be re-inferred), and the dismissed items. Those three are separate groups on purpose. The verbs are opposites — forgetting a fact makes its question askable again, restoring a dismissal makes an item suggestable again — and a dismissal is not something the household stated about itself: ListScreen writes one when a suggestion becomes a task, which is bookkeeping. .fact stays the class for a ledger row alone, so that counting facts counts facts. Facts are never edited there, only forgotten — the question's wording is part of the answer's meaning, so a changed circumstance is handled by the question becoming askable again. Cadences deliberately do not appear: they live with their staples.

The collapsible shelf the ledger used to hide behind is gone. It existed because the ledger was competing for room on a crowded screen; a <details> inside a screen that is itself the ledger is a shelf inside a cupboard.

Connection health is a watchdog rather than an error handler, because a Firestore listener that cannot reach the server never reports an error — it retries silently. src/data/health.ts watches for an unanswered ask: from the moment the app requests data it does not have — a subscription opening, or the bootstrap read in ensureUserDoc that gates everything else — until the first answer comes back. Eight seconds of that silence while the browser claims to be online is what a blocking extension looks like from inside the app, and renders as "can't sync". Time since the last snapshot proves nothing and is deliberately not measured: a healthy idle listener is silent because nothing changed.

Only a server snapshot is an answer (noteSnapshotFrom, the one line every subscription writes). A cache-served snapshot arrives precisely when the path is shut — instantly, and again on every local change — so counting it would silence the indicator exactly when the app has most to say: your data is on screen, correct, and nothing you write is leaving the phone. Before the cache-first boot the distinction was academic, because a blocked cold start had nothing on disk to serve.

That rule has two consequences every subscription carries, both learned from the strip lighting over a healthy connection and staying lit. First, the server's answer to an already-correct cache is nothing but metadata — fromCache flipping — and onSnapshot swallows metadata-only events by default, so every subscription listens with includeMetadataChanges: true (the shared LISTEN_OPTIONS), or its ask would starve exactly when the cache was warm and right, which is most of the time. Second, an ask can be abandoned: a subscription torn down before its first server answer — a note's photos, left before the round trip lands — has no listener left alive to answer for it, so noteListening returns a release and the watched wrapper ties it to every unsubscribe. Asks are held individually for that reason, though any one server answer still clears them all: one answer proves the path, and the path is the thing being measured. The bootstrap read participates because it must — with an empty cache it fails before any subscription opens, so it is the only ask the watchdog ever sees. When that read is rejected as unavailable, useHousehold retries on an interval instead of surfacing the SDK's "client is offline" message, which is worded wrongly for this case and describes a condition the SDK recovers from on its own. tests/e2e/blocked-firestore.mjs reproduces the blocked case end-to-end as part of npm run test:e2e; src/data/health.test.ts holds a fixture for each way the first implementation got this wrong.

No write is ever awaited before the UI updates. Firestore fires the local callback immediately, so awaiting the round trip is what turns a working offline app into a frozen one in a shop with no signal. tests/e2e/offline-add.mjs holds that line for the three shapes a dead connection takes — refused, hung, and airplane mode — and the row appears in tens of milliseconds under each, survives a reload, and reaches the server when the path returns.

But every write is owned (owned in src/data/writes.ts, wrapped around the writers in tasks.ts, notes.ts, items.ts and the transcript). A queued mutation can still be REFUSED when it finally arrives — a rules change, a document past the limit, a household somebody was removed from — and Firestore then rolls the document back out of the cache, so the row leaves the screen it appeared on. void write discarded that rejection and nothing said anything anywhere. owned attaches a recording branch that handles its own rejection and hands the original promise back unchanged, so the caller's void and the awaiting callers both behave exactly as before; the only difference is that the failure reaches serviceEvents (through setWriteFailureSink, installed beside the intelligence sink) and the connection strip, which grows a chip naming what was refused — "a to-do wasn't saved" — until it is tapped away. The writer names its own write, because it is the seam that knows.

The strip also counts what is WAITING. notePending in src/data/health.ts sums the documents with hasPendingWrites across the collections a person types into (tasks, notes, items), walked only when the query-level flag says something is pending, so the ordinary case costs one boolean per snapshot. The offline chip is a button now, like its can't-sync neighbour: it reads "offline · 3 waiting" and opens to say those changes are saved on this phone and will sync — the reassurance its hover title used to hoard, which a phone cannot show, with a number on it.

The intelligence layer (steps 10–12) ​

Two rules shape all of it. The model labels; it never predicts — cadence is medians over observed intervals, computed on the client, and crossing that line would give different answers on Tuesday than on Monday. It is an accelerant, never a dependency — every failure degrades to pure statistics and manual entry, and the client treats a null answer as ordinary.

The proxy (functions/) ​

Nine callable Cloud Functions on Node 22 — resolveEntity, classifyStaple, captureTasks, suggestSeasonal, readReceipt, readPantry, transcribePhoto, assistantChat and reviewPass. transcribePhoto is the one call that reaches into Storage itself: the client names a photo (hid/noteId/photoId, validated), the function checks the caller's household membership against the household document with the Admin SDK, downloads the object, and reads it — the bytes never ride the request, which keeps the payload phone-sized and the bucket free of CORS configuration. assistantChat is the one call that carries prior turns: it runs Sonnet (claude-sonnet-5) rather than the shared Opus constant, may run up to three server-side web searches (web_search_20260209), and answers with an action schema that can add, or annotate a to-do it was shown, and nothing else. When the request names the open note's photos it fetches up to three of them from Storage exactly as transcription does — membership proven first, bytes never on the request — and attaches them as images; a photo that fails to download is skipped, because this layer degrades before it errors. When the request names its household (hid, validated and membership-proven like the photos), the turn also gets a read_note client tool: the model asks for a note by the title the snapshot showed, the function fetches the household's notes once (lazily, archived excluded, and cut to the look-back the request carries as notesSince — notesWithin in noteLookup.ts, reading a note with no updatedAt as edited when it was made, the client reader's own default, so the tool can find every title the snapshot showed and no note the snapshot withheld; a client from before the field sends nothing and gets no window), matches by folded-title equality (functions/src/noteLookup.ts — the fold is foldForSearch's twin, and it is equality, never the search box), and hands back title and body cut at 8000 characters (READ_NOTE_CHARS), the same cap the open note lives under, ending in a "[cut here — N more characters …]" line when the note was longer (cutNoteText, the twin of the client's cutNote), so the model can say how much it is missing and knows not to offer a rewrite. At most eight notes per turn (READ_NOTES_CAP); past the cap the tool answers with a refusal the model can read. Client tools loop inside askStream — the model's tool-use stop grows the transcript with its results and the SAME turn continues — bounded at four rounds for the assistant: the prompt asks for batched reads, so two a round meets the budget, and the rounds do not scale with it because each is a model call paid in the person's seconds. The budget's end is told to the model, not thrown at the person: a stop past the cap gets "budget exhausted" as its results and one closing round to answer from what it has, so only a model that stops for tools even then fails the turn; server tools still never loop (pause_turn remains a failure by design). A request without hid (a client from before the field existed) simply gets no tool and runs as before. It is also the one STREAMING callable, and the one whose answer is PROSE. Every other call constrains its output to a JSON schema; the assistant's reply is ordinary text, and its actions arrive through a strict client tool, take_actions, whose input is the old answer's actions array (ASSISTANT_ACTIONS_SCHEMA) validated by the API on the way in. It was one schema-constrained object with reply first, streamed by an extractor that read the reply's characters out of the forming JSON — and free prose generated inside a grammar mask was garbling inside words. See DECISIONS, "The reply is prose; the actions are a tool call". take_actions is unbudgeted (ClientTools.unbudgeted): serviced in any round, never counted, and text written around it is one reply, whereas a read_note starts a new round whose text replaces the last — the reply is the last round that said anything (collectReply, functions/src/reply.ts), which is also what each streamed SNAPSHOT is (sendChunk, only when the caller asked to stream; whole-text each time, never deltas — idempotent chunks a flaky network can duplicate or replay without scrambling the display). Beside the reply the stream carries an ACTIVITY line for everything the model does — a search, named once its query has finished streaming; a note read, or asked for and not found; its actions arriving; the read budget closing — an error chunk naming the failure in the client's own vocabulary before the callable throws, and a done chunk with the stop reason and counts (the union is in index.ts, mirrored in src/data/intelligence.ts).

The reply is prose under a small contract, not Markdown. The prompt tells the model what the bubble draws — paragraphs separated by a blank line, a list as "- " lines, **bold** for a name that has to stand out, a link as its bare URL, nothing else — and replyBlocks (src/lib/chat/replyText.ts, layer 1) recognises exactly that, plus a heading line as a bold line, for ReplyText to draw as <p>, <ul>/<ol> and <a> inside the assistant bubble (a <div> now; the person's own bubbles stay a <p>). Anything outside the contract stays text with its newlines kept, so an unexpected shape degrades to words, never to nothing. See DECISIONS, "The reply is prose with a contract, not Markdown". The same prompt paragraph tells the model that a question need not be about the household — a how-to, a recommendation, a fact — and to answer it outright, web search included, without reporting that the notes do not cover it; the first version prefaced every such answer with the snapshot's silence. A reply with no letter or digit in it and no actions is refused as empty rather than stored — ":" was once persisted as an answer. max_tokens on prose is a warning, not a failure: text cut short is still text, and the drawer says it was cut short. The client replaces its preview bubble with each snapshot, keeps the activity lines as a log for the turn, guards both with a per-send turn counter AND a per-turn settled flag (so neither a newer turn's stream nor this turn's own stream past its deadline can write into the bubble), and gives up at 105s (over the function's 90s ceiling), aborting the stream it was reading. The callable's final value is {reply, actions} as it always was. The Anthropic key lives in Secret Manager and is bound per function; it is never in the repository or in client config. Calls run at write time and are cached on the document that needed them — nothing calls the model while a list renders.

enforceAppCheck: true is not optional here. The platform rejects an unattested call before any of this code runs, so an unauthenticated request costs nothing; without it, anyone who reads the client bundle has a free inference endpoint in front of a billable key.

Attestation bounds who may call; a daily budget bounds how much. Every callable, after validating its request and before fetching or asking anything, spends units against usage/{uid} through charge (functions/src/budget.ts): a Firestore transaction, so two calls landing at once cannot both read the same balance and both pass. The price list is relative, not a tariff — a labelling pass is one unit, vision three, a chat turn two, the review pass twenty-five — against DAILY_UNITS (300) per person per UTC day, and under that MONTHLY_UNITS (3000) per UTC month, ten full days' worth: the day fits a heavy day of typing, photos and chat several times over and lets the dearest call run a dozen times, not a thousand; the month stops a phone that hits the daily cap every day. applyCharge checks the day first, so the message names the month only when today is untouched. Settings shows both as meters (UsageMeters, fed by useUsage through the free usageReport callable): a percentage and when each allowance comes back, from meterPercent and describeReset in src/lib/usage/meter.ts. Absent, not empty, while the layer is off or has not answered. Exhaustion is resource-exhausted with {reason: 'budget'} in the error's details — the same code a rate limit carries, because the platform's set is closed. The labelling seams read it as null like every other failure and the app carries on with statistics and manual entry; the assistant drawer, where a person is watching, reads the reason (failureCodeOf, src/lib/chat/failure.ts) and says the allowance is used up and when it comes back, rather than "busy, try again in a minute". The decision (applyCharge) is pure and unit-tested; the document is server-only by rule.

Responses are constrained with output_config.format, so the caller receives validated JSON rather than prose it has to parse. A safety decline arrives as stop_reason: "refusal" and is handled as an ordinary outcome.

Entity resolution (spec 7a) ​

Getting this wrong is the silent killer: history fragments across six spellings of one item, nothing accumulates enough intervals to predict anything, and the cadence engine looks broken when the real failure is upstream.

src/lib/resolve/ is pure and runs first, cheapest layer to dearest:

  1. the raw string has resolved somewhere before,
  2. it normalizes to an existing canonicalName,
  3. trigram similarity at or above AUTO_MERGE_SIMILARITY (0.82),
  4. otherwise the model, with a shortlist.

Substring containment never auto-merges, at any score. "milk" is a substring of "2% milk", so every string metric scores that pair high — and scores "almond milk" identically high. One is more detail about the same thing, the other is a separate consumption stream. A test asserts the metric genuinely cannot separate them, which is the evidence for the rule.

The shortlist also includes every candidate sharing a head noun, even below the similarity floor. "whole milk" scores only 0.42 against "2% milk", and its existence is the evidence that this household distinguishes milk variants — which is what makes 2% a third staple rather than more detail about the first. A shortlist filtered by similarity alone would hide exactly that.

Only confidence: high merges without asking, and only onto a staple that was on the shortlist. Resolution is fire-and-forget: an item is written the instant it is typed with stapleId: null, and an unresolved item is a fully functional list item — which is also what happens offline, the normal case in a shop.

Every raw string that resolves onto a staple is appended to sourceNames, and every purchase carries its own sourceName, so a bad merge can be reconstructed and undone.

Classification (spec 7b) ​

Written once onto staple.prior, then left alone — re-classifying later would move predictions for reasons the user cannot see. The cadence engine already blends it with decreasing weight and drops it entirely at eight intervals.

The classifier's quantity test is not "can you buy this in bulk" but does buying N multiply the time until you need more — which is why AA batteries are never despite being countable and non-perishable, and toilet paper is prompt.

Fields the user has pinned by hand are never overwritten. applyClassification skips any field carrying an overriddenBy marker.

Availability ​

INTELLIGENCE_AVAILABLE gates every call on an App Check site key being configured. Without one, the functions reject every request, so calling them would be one guaranteed-failing round trip per item typed. Not configured means not available, and not available is a supported state.

Steps 13–18 ​

Chore detection and task capture (7c) ​

captureTasks splits a paragraph on intent rather than punctuation and notices which items are chores. A chore's rule is proposed by the model and then runs deterministically: rent is due on the 1st whether or not it was paid on time. Anything genuinely one-off gets a null rule, because inventing a rhythm for it means it returns forever.

The seasonal pass (6) ​

Two halves, and only one needs a model.

src/lib/seasonal/pass.ts is the deterministic half: tracked staples whose window opens next month, found locally, same answer every run, offline.

The generative half exists because an item never added is otherwise permanently invisible — sunscreen bought before the app existed. It may suggest only from categories the household has confirmed, enforced both in the prompt and again on the client. That gate is the whole spam-prevention mechanism, and it is stronger than a dismissal list because it works before the first bad suggestion rather than after it.

Every question asks about a purchase, never an object. "Do you use a fireplace?" is unanswerable for anyone with a gas one — they own a fireplace and buy nothing for it, so both answers are wrong and whichever they pick teaches the app something false. "Do you buy firewood?" has one honest answer, and it is the answer the suggestion actually needs. A test enforces the shape; hints carry the cases where a reasonable person would still hesitate.

The question offers yes and no, and only those. An earlier build added a text escape hatch for the case where both are wrong ("it's a gas fireplace"); it went with the other individual chat entry points — see DECISIONS, "One chat surface: the drawer" — so a question neither button fits is left unanswered, and it keeps its slot until it is.

src/lib/seasonal/profile.ts holds the question bank. Its decisive property is that it is finite: about fifteen household-defining facts, asked one at a time, ahead of the season each matters for, and then done forever. Item suggestions never terminate; questions do. Facts the household's own staples already imply are never asked — tracking dog food answers the pets question.

The question is derived from the profile on every render, not scheduled. An earlier version computed it once a month inside an effect, which meant a reload lost it and the monthly flag stopped it returning: one chance to answer, ever. Only the generative call is scheduled, because only it costs anything.

Both share one "Coming up" slot — a question or items, never both. Each item appears on the list its category shops at, carried by a destination on the bank entry: overseed surfaces with the hardware run, pet food with the food shop. The bank decides, not the model — the model labels an item with a category, and where a category is bought is a fact about shopping that no phrasing changes. This also keeps "Add" free of routing: the list you are looking at is where it goes. The question keeps a single slot on the Grocery list, the one everybody opens.

Items offer four responses, because the app knows neither your inventory nor your arrangements: add, have it (creates a tracked staple with an implied acquisition, no purchase invented, so next year it comes from the deterministic half), it's a task (an errand the model mistook for a purchase — the name moves to Tasks and is dismissed as an item suggestion, reversibly), or not for me.

Receipts (7f) ​

src/lib/receipt/seam.ts is pure and carries the two rules that matter.

Seam detection is positional, never by name. Overlap between photographed segments is found by matching a run of at least three consecutive lines. Name dedupe would be wrong in a way that silently loses purchases: two MILK lines are usually two real purchases. A segment with no detectable seam is appended whole, erring toward a duplicate the review step catches over a missing line nobody sees.

The receipt validates itself. ITEMS SOLD is the cleanest signal, the subtotal the fallback. Price is extracted for this check and then discarded, never stored. On a mismatch the user re-shoots one segment rather than starting over.

Names are returned verbatim — GV WHL MLK GAL, not "Great Value Whole Milk" — because those strings are a per-store dialect meant to be cached and reused.

The capture flow lives at /receipt — behind the + in the middle of the tab bar, because it is something you do when you walk in the door, not a property of any one list (absent when the layer is off). Its first job is filling the list: you bought these things, you will buy them again, and typing them back in is the manual work the app exists to remove.

Shooting never waits on the model. Every photo is accepted the instant the shutter closes; its read runs in the background, in parallel with the others, and a part that failed to read retries from its stored image without re-shooting. Review opens once the reads settle.

Each line carries two names: rawName, verbatim register dialect (GV WHL MLK GAL 007874203972), and name, what a person would write on a list ("whole milk"). The screen shows the human one — with a local code-stripping fallback (tidyReceiptName) for functions deployed before the field existed — while the verbatim string stays underneath as the dialect cache's key and the seam's evidence. Every line has a one-tap Add: a matched line goes to its staple's list with the staple attached; a new name goes to the grocery list, where the ordinary promotion machinery decides whether it becomes a staple — the receipt never creates staples itself.

Recording purchase history rides along for matched lines (noon of the printed day, grouped per staple with quantities summed: two MILK lines are two units of one restock, not a burst of zero-day intervals; same-day duplicates skipped by default). Matching is local-only — the dialect cache, then the canonical name — and every recorded line caches its register string, so the next receipt from that store matches itself. The printed checksum is a footnote, not a gate: a disagreement is one quiet line offering to add the missing part.

Pantry photos (7g) ​

readPantry takes a location tag as an input, not a label: a fridge yields perishables on short cadences, a garage yields stockpiled durables. It seeds staples with priors and an empty purchase history — a photo shows what you have, not what you buy — so the cadence engine still starts from zero. The prompt is explicitly conservative, since only the front row is visible and decanted goods are unidentifiable.

The intake lives at /settings/regulars/photo — "Track by photo", a quiet chip on the Lists home beside the receipt scanner's. Both chips are the gated part, absent rather than dead when the intelligence layer is off. Pick the location, photograph one product or a whole shelf, and every returned item is a proposal behind a checkbox — already-tracked names are marked and disabled. The route keeps the domain word while the screen it belongs to is labelled "Regulars". Its own back target is Lists, not Regulars, because it is reached from the Lists home rather than from the screen it files into. Confirmed items are created as estimated staples with the location as the prior's category. profileHints only ever answer unasked questions, and only with yes — a photo shows presence, never absence, and never re-opens something the household already answered. Answered hints land in the settings ledger like any other fact. Photos are downscaled client-side before upload; nothing is stored.

The assistant (7h) ​

Chat appears in exactly one place: the assistant drawer. A floating button (ChatFab, bottom-right, over every section) opens a bottom sheet holding your one real thread: households/{hid}/assistantThreads/{uid}/messages, private to its owner and converging across their phones, roughly the newest twenty kept and the last ten turns sent to the model. Earlier builds also placed one-turn ask boxes on the Tasks and shopping list screens and an escape hatch beside the seasonal question; those individual entry points are gone — see DECISIONS, "One chat surface: the drawer" — and the button is absent rather than dead when the layer is off.

It runs assistantChat — Sonnet (claude-sonnet-5) rather than the shared Opus constant, because a conversation pays for latency in a way one-shot labelling does not — and may run up to three server-side web searches (web_search_20260209) per turn, so "what size filter does a Carrier 59SC5 take? make it a to-do" lands the looked-up size in the task's details. The model answers from what it knows or looks up (standard sizes, compatibilities); the fact goes in the task's details rather than only the reply, because the reply scrolls away and the detail is what needs to be in hand later.

The reply STREAMS into the sheet: the moment a turn is sent, an assistant bubble appears with three pulsing dots, and the reply's text grows in place as the function forwards it. What the model is DOING streams beside it as a log, not a status: a muted line per search (named once its query has streamed), per note read or asked for and not found, for its actions arriving and for the read budget closing — lines that stay for the turn (reduceActivity in src/lib/chat/, idempotent under a replayed chunk, a named search replacing the unnamed line it started as) rather than one "Searching the web…" that the first reply character wiped and nothing brought back. Once the reply lands the same lines sit behind a disclosure under it, with a one-line summary — "2 searches · 1 note read", "cut short". The seam is assistantChatStream, which answers {kind:'answer'} or {kind:'failure', code} — total like every other seam, but saying WHICH failure (AssistantFailureCode in src/lib/chat/failure.ts: rate limited, declined, unfinished, unreachable, not signed in, unreadable, empty), from the function's own error chunk when it got that far and the thrown code when it did not. What streams is a preview only: the turn's persisted reply is the callable's final text, and a failed turn writes nothing.

The log follows the newest words until you look away. The sheet is a column — the log scrolling, the composer fixed under it — and the dots, the activity lines and the streaming bubble all live inside the log, so whatever grows it grows it below the fold. It used to pin to the bottom only when a message landed or the streamed text changed length, and everything else that changes the log's height while a turn is in flight — an activity line, the dots giving way to those lines, the summary under a finished reply, a failure notice, the photo picker folding open and shrinking the log frame by frame, the keyboard shrinking the sheet — left the newest thing out of sight. A ResizeObserver on the log's content (.chat-log-inner) and on the log itself now pins on every change of size, and a layout effect pins before paint on every change of the thread's shape, while one ref says whether to: stuck, set from the log's own scroll events by atEnd (src/lib/chat/pin.ts, a few pixels of slack), true while the reader is at the end, false the moment they scroll up to read something older, true again when they come back or send. scrollTop on the log itself, never scrollIntoView, which would drag the page behind the mask; and the Popup's afterShow still does the first pin, because the sheet animates in with no height.

Its action schema is adds, one annotation, and one rewrite — add_item (with an explicit shopping list), add_task, add_note (each with a destination: personal, work, or one of the household's groups by the path the snapshot showed — "Kitchen › Renovation" — personal unless they clearly mean their job or a group), annotate_task, and edit_note — planned by planAssistantActions in src/lib/chat/, which decides what each returned intent means before anything is written; the hook only performs the writes. The snapshot carries a "Groups" line listing every custom group by path (groupPaths, src/lib/chat/groups.ts, parents before children) and a "To-dos in …" line for each group that holds any, so a to-do filed there can be annotated; resolveGroupPath reads a path back to an id — whole path first, bare name when only one group bears it, the first shelf's own name as personal — and an unrecognised word is unstated rather than invented, so it lands on Personal as every assistant write used to. Custom groups used to be kept out of the snapshot because the schema could not target them; see DECISIONS, "The assistant is told the household's groups".

Actions land when the tool call lands, not when the reply ends. The function sends an actions chunk the moment take_actions runs, and the hook plans and writes them then, while the reply is still streaming; the resolved answer carries the same list for a client that did not stream, and a signature set in the hook makes the two deliveries one write each. A note the turn makes is remembered by id — addNote returns it — and rides with the reply as notes on the message (ChatNoteRef: id, title, and where it was filed, as the doors say it), drawn as chips under the bubble, under the streaming bubble first, each opening the note. See DECISIONS, "Actions land when the tool call lands". The destructive verbs are otherwise deliberately absent: the drawer floats over screens it cannot see, and a remove on a guess is the one mistake that loses data. So a wrong guess can put something on the wrong shelf, one tap from fixed, but never take a row away. Adds get no undo bar, as everywhere. A failed turn writes nothing to the thread; on the phone that asked, its question wears the failure's own sentence and a Retry underneath it, this phone's news only and never a turn in the conversation — filed where it belongs rather than as one line at the bottom that the next send erased, which left a thread of questions with no answers and no record of why. Only an "unreachable" failure clears itself when the network returns; a decline or an empty reply was never about the network. A question nothing answered is also left OUT of the history the next turn sends (historyWindow), because sent back it put the same question in front of the model twice — once as history it never answered, once live — and a reply of ":" was once what came of that. See DECISIONS, "A thread in the drawer, one turn in the ask boxes", for why the outcome-not-transcript rule bends for this thread.

annotate_task is how information reaches a to-do that already exists. The context snapshot lists open to-dos with their existing details in parentheses; the planner matches the echoed title by canonical name, narrowed to the action's to-do list when it names one, so a same-titled to-do on the other list is out of reach. The note is appended to the details already there, never a replacement: the model saw those details as one line in a snapshot and cannot be trusted to re-send them, and "add the vet's number" must not eat the filter size somebody typed by hand. An add_task carrying a note whose title matches an open to-do is planned as the same annotation, whatever kind it came back labelled — "add the vet's number to the flea treatment task" lands on the existing row, never beside it as a duplicate. A title matching no open to-do becomes its own to-do carrying the note, because wrong shelf beats vanished; an annotation with nothing to attach is dropped, because performing it could only mean touching details it was not given.

The assistant reaches the two built-in groups only. Its action schema says home and work for to-dos and personal and work for notes — the same shelf under both of its stored names, which is why home on an add_note is read as personal rather than refused; the system prompt tells it the home list is shown to the household as "Personal", and the context labels the lists by their display names. Custom groups stay out of the snapshot deliberately: the schema cannot target them, and a to-do the model can see but never reach only invites annotations that go nowhere.

edit_note rewrites the note on screen, and can reach nothing else. The schema has no field naming a note: the planner supplies the id from the route, so a model that invents a target has nowhere to put it. It is dropped unless a note is open AND the model was shown that note whole — NOTE_CONTEXT_CAP in useAssistant is both the snapshot's slice and the guard's yardstick, so a rewrite of a note the model saw only the head of cannot delete the tail. What comes back replaces the note: name is the new first line, note the new body, through the same title/body seam typing uses. The blank-name guard every action passes is what keeps an edit from emptying a note. On screen the change lands through NoteScreen's reseed (below), which also offers the one tap back. See DECISIONS, "The assistant may rewrite the note in front of you, and nothing else".

The assistant is told what the app is. Its system prompt ends with APP_CAPABILITIES (functions/src/capabilities.ts): a plain-prose, present-tense description of every screen, the note grammar, groups, photos and their read text, the calendar, people, tags, questions, Settings, offline behaviour, and the assistant's own limits — so "how do I make a question in a note?" is answered from a document rather than a guess, and the prompt tells it to say so when the document does not cover the question. It is a claim made to the household, held honest two ways: prompts.test.ts asserts the note markers appear in it in the blank note's own words, and CLAUDE.md puts the file under the same same-commit rule as this one. The whole system prompt is sent as one cached block (cacheSystem in askStream, cache_control: ephemeral), and each pass logs cache_read_input_tokens so a silent invalidator shows up as a read of zero.

The context snapshot carries note titles only — except the note on screen. The FAB is rendered inside the router (AssistantFab in App.tsx), so it knows when the current route is an open note; while one is, that note's full text (capped at 8000 chars with the same cut marker, inside the callable's own 24000-char context cap, CONTEXT_CHARS) joins the snapshot, and the system prompt tells the model "this note" means that one. So "summarize this note" and "make to-dos out of this" work while reading it, and no note's body is ever sent from anywhere else — everywhere else the assistant sees titles alone — the titles, the to-dos and the questions all from inside the household's to-do-links look-back, the same cut the function makes for read_note, so the snapshot never names a note the tool would refuse and the tool never hands over a note the snapshot withheld — and reads the rest: the request names the household, and when a title tells the model which note holds the answer, it reads that note through the read_note tool (see The proxy above — fetched server-side behind the same membership check, folded-title equality, at most eight notes a turn, 8000 chars each) — without asking first, several in one batch when several titles fit the question, because the fullest answer is the point and "want me to check that note?" spends a whole turn on a yes the question already gave. While it reads, the wait line names the note; the drawer's busy states say what is actually happening — Reading your note "Boiler service"…, Searching the web for "…"… — rather than one generic word. Notes read this way are readable, never editable: edit_note still reaches only the note on screen. See DECISIONS, "The assistant reads a note by asking for it". Photos ride only when picked (below) — and the reply can show one back: read_note appends a numbered listing of the note's photos, each with the head of its transcript where one exists, and the open note's photos ride the snapshot the same way; a second household-gated tool, show_photo({title, photo}), resolves the title and number server-side to the stored photo (the same folded-title match, the note screen's own addedAt order), refuses in words the model can read when there is no such note or number, and puts {noteId, photoId, url} on the answer's photos — at most three, each once. The drawer persists them on the assistant's message and renders them under its bubble as thumbnails, each a link into the note that holds it (the viewer lives there), closing the sheet on the way. The model is told it can show only what a listing named and can neither make nor find images. Nothing is staged by default, because most questions about a note are about its text and unasked images are tokens and seconds nobody chose to spend. Two kinds share one selection. The note's photos: AssistantFab holds the same photo subscription the note screen holds (one SDK listener serves both), and the request names the staged ones — ids only, in pick order, the bytes fetched by the function after the same membership check transcription makes — so "what's on this receipt? add what we're out of" works while looking at the note that holds the receipt. Photos from the phone: the strip's first tile opens the camera or the library (a plain <input type="file" accept="image/*" multiple>, no capture, so the system sheet offers both); each file goes through toJpegBase64 in src/components/photo.ts — the receipt intake's own encoding, 1568 px and EXIF-stripped by the canvas — and rides the request inline as images, never stored anywhere, held in the hook only until the turn that carries it lands. The function keeps at most three images in all, the note's first, and tells the model under the snapshot which came from where. The staging surface lives on the thread useAssistant returns (attachable / selected / togglePhoto / addDevicePhoto), with the pick arithmetic — the cap, the order-as-numbering, a fourth pick displacing the oldest, a deleted photo pruned rather than sent as a ghost id, and the two kinds told apart by the device: id prefix — as fixtures in src/lib/chat/selection.ts. Leaving a note drops that note's picks and keeps the phone's (keepDevicePicks); a delivered turn consumes exactly its own picks, so a failed turn keeps them for the retry and a pick made mid-generation survives for the next send. An unstaged phone photo is let go entirely — it was only ever here for one turn. No note photo is ever named from any other screen.

The snapshot carries the Questions sections too — answers included. Questions live as documents (households/{hid}/questions), and the document is the only place the whole record exists: a long answer never reaches the source note's stamped line, so no amount of read_note finds it. The drawer is therefore handed the same questions array the app already subscribes (plus people, to name who a question is for), and questionSnapshotLines (src/lib/questions/snapshot.ts, fixture-tested) folds them into two lines: open questions (newest asked first, capped at 12, "text" (for Name)) and answered ones (newest answered first, capped at 8, the answer riding sliced at 150 chars). The caps are the point — the snapshot is working context, the Questions screen's shelf is the memory — and the system prompt tells the model what the sections are, that an (answered …) stamp met while reading a note means the same thing, and that it can neither ask nor answer a question: no action does either.

Transient bars are one component. Toast (src/components/) carries the card, the edge, the enter and exit motion, and the countdown; UndoBar and DayOffer are thin wrappers over it, keeping their own reasoning next to the thing it explains. The edges are claimed and the reasons written into the CSS: bottom is the undo bar's, where a thumb already is on a list; top is everything else's, clear of the screen header. A screen shows one bar at a time, an error before an offer. What earns one is narrow — an event, at most one action, safe to miss — and DECISIONS, "What earns a toast", records what stays inline instead.

The exit is driven by a timer in Toast, never by animationend. The bar arrives over --dur-open and leaves over --dur-close, and the timer is CLOSE_MS from src/components/motion.ts, the vocabulary's JS half, which MUST match the tokens. Under prefers-reduced-motion the durations are zero, so animationend would never fire — an accessibility setting would have stranded the bar on screen permanently — and the timer goes to zero with them. Reduced motion therefore removes the movement and keeps the leaving.

Motion is one vocabulary. tokens.css names it: --dur-fast (120ms — a caret turning, a press wash fading after the finger lifts), --dur-open (200ms) and --dur-close (150ms) for anything that folds, with --ease-out on the way in and --ease-in on the way out. Closing is faster than opening on purpose; DECISIONS, "A fold closes faster than it opens", says why. Under prefers-reduced-motion the three durations go to zero at the root, so every transition keeps its end state and drops the travel — one rule, not one per component. The seven <details> shelves (the six .shelf sections and the chat's "What it did") are Shelf (src/components/): still a <details> for its semantics and the suites' selectors, but the summary's click is React's and the body is a Fold, because the native disclosure snaps and its one animation (::details-content with interpolate-size) is Chrome's alone — on an iPhone every shelf snapped beside a folding door. Shelf keeps the element's open through the leaving so the rows can fold out before they are hidden. The .shelf summary carries the chevron the flex display had cost it, turned by its own aria-expanded in the shop sections' idiom. The fold heads (.group-head, .door-head) take their wash the instant they are pressed and fade it only on release.

Fold (src/components/) is the one way a React-rendered body opens and shuts. It wraps what used to be expanded ? <ul/> : null at the shop groups (StoreGroup), the group door (GroupCards) and the group tree's children (GroupTree), and keeps that pattern's one virtue: closed and settled, it renders nothing, so a folded shop with sixty rows costs nothing. Open, it renders the body in a grid whose single track the CSS animates from 0fr to 1fr — no measured heights, no JS on open. Three rules, argued in DECISIONS, "A fold keeps nothing mounted and moves nothing on first paint": a body open on the render that mounts Fold paints at rest and only a later flip of open earns the enter motion; unmounting after a close waits on a timer matched to --dur-close (motion.ts holds OPEN_MS/CLOSE_MS, which MUST match the tokens, and reducedMotion()), zero under reduced motion, never transitionend; and the state that starts the leaving is derived during render, since an effect would be a frame late. data-motion is on the element only while it moves — it is what starts an enter from nothing and clips the body only then, so at rest a focus ring inside is never cut. The tree's caret is now one chevron turned, the shop sections' idiom, where it used to swap two icons. The same primitive folds a trip's detail (TripsScreen), the explanation under the offline and can't-sync chips (App), and the chat's photo picker (ChatDrawer, where onSettled pins the log once more at the picker's final height). Every control that folds something wears the one caret, .group-caret, turned by its own aria-expanded; the trip row and the chips gained theirs with their motion. The capture links' box (ActionLine), the search bar under a tool header (ToolHeader), the Regulars explainer (RegularsNote) and the note shelf's facts and photos strips (NoteScreen) fold the same way; for the strips the editor keeps pace, transitioning its own computed height at the fold's timings, so the two move as one (DECISIONS, "One motion, the fold"). Every collapsible in the app now moves, or deliberately does not; antd's own sheets and drawers keep the library's motion. Everything else that moves — a card pressed, a box taking focus, a checkbox filling, the switch, a meter's fill, the toast — spends the same tokens: --dur-fast for a press or a focus, --dur-open/--dur-close for an arrival or a leaving. The one duration outside the vocabulary is the row flash, an event fading over 1.8s, and the chat dots' ambient pulse, which reduced motion stops outright.

When a save introduces a day the document has not committed to, DayOffer appears at the top of the screen — clear of the header, since a bar over Back and the title takes away the way out at the moment somebody has just stopped typing — naming the day with one action. It expires rather than waiting to be dismissed and remembers nothing: ignoring it is the answer, and it is the common one. Accepting appends commitLabel through the same commit path typing uses, so the day is pinned, captured and written exactly as if it had been typed by hand — at the end of a note's body, or the end of a to-do's title, where a to-do already wears its day. Its countdown is useVisibleCountdown, shared with the undo bar, so the "an undo that cannot be seen is not an undo" lesson is not re-derived for a second transient bar.

In the sheet this is a photo-stack button left of Send, live on every screen — the phone's camera and library are always behind it — carrying the count: the number staged once anything is, else the open note's photo total. The count is load-bearing rather than decorative: the sheet covers the note, so without it nobody can see there are photos to offer at all. It opens a strip inside the sheet, never a second sheet — modal over modal gives two dismiss layers and an ambiguous back gesture — whose first tile, always, is "From device" (a dashed outline, so it reads as a door rather than a picture), then any phone photos already taken this turn, then the note's; the strip is the note page's own thumbnail row, down to the token. A file the phone cannot decode is said so in one quiet line above the composer, and the rest still land. A picked thumbnail wears an accent ring AND its send-order number, so the state never rests on colour alone, and that number is what makes the cap legible without a "max 3" label anywhere. With the strip closed over them, the staged photos ride as small removable chips above the input; showing both at once would be the same three photos twice in one sheet. The one thing the eye gets free and a screen reader does not is the displacement — a fourth pick releasing the earliest — so that alone is announced through a polite live region (.sr-only, the app's first), while aria-pressed carries the ordinary attach and detach. /notes/personal and /notes/work match the same route pattern, but a Firestore auto-id can never be either word, so the lookup finds nothing there and nothing extra is sent.

The sheet and the keyboard. The popup is fixed to the bottom of the LAYOUT viewport, and on iOS that viewport does not shrink when the keyboard comes up — only the visual one does, and dvh tracks the browser's bars, not the keys — so the composer sat exactly under the keyboard the first time the input took focus. While the sheet is open the drawer reads window.visualViewport (height and offset against innerHeight) and writes the covered height to --keyboard-h on the root; .chat-sheet is lifted by that much and capped at what is left above the keys, and the log's ResizeObserver re-pins the newest message as it shrinks. Where the browser resizes the layout viewport instead, the difference is zero and nothing moves.

The review pass and Tidy up (7d) ​

The pass proposes; it never decides. It writes only to households/{hid}/suggestions, never to a staple or cadence directly. So a cadence that moved can be traced to a proposal someone accepted, the pass failing breaks nothing, accept-rate per type shows which prompts earn their keep, and a model that answers differently across runs is merely inconsistent rather than an unreproducible bug in the data.

It runs weekly and only past a purchase threshold, because against a thin dataset it will confidently describe patterns that do not exist.

src/lib/suggestions/ holds the deterministic gate every proposal passes before anyone sees it: a proposal naming a staple that no longer exists is dropped silently; duplicates against pending and within a batch are removed; dismissals — recorded on the household document's dismissals array as {type, targets, at, permanent} and read back with the household — are suppressed for ninety days when temporal, permanently for merges and splits, since "milk and almond milk are different things" does not become untrue in ninety days. The reader keeps type, targets and at; permanence is re-derived from the type, so the stored flag is a courtesy to anyone inspecting the document. The queue is capped at five by dropping the least valuable, so a split arriving late displaces a cadence nudge rather than queuing behind it.

Most types never reach the queue. "Still buying this?" is answered inline where the staple already is; co-occurrence and prior drift are observations with nothing to accept and are not surfaced at all.

The Tidy up card is last on the home screen and deliberately unbadged. Every suggestion is the app asking for maintenance on its own data model, and nagging about that inverts the relationship. If it is never opened, everything keeps working.

The docs site ​

docs.planet4.me is a help site for the household, and it is written, not generated: docs/guide/*.md and the home page docs/index.md, in the household's own words, one page per part of the app — what it is, getting started, lists and regulars, to-dos, notes, calendar, people, households and workspaces, the assistant, settings, and an FAQ. It is built with VitePress (docs/.vitepress/config.mts), the tool the sister product's help site at docs.planet4.us uses, with the default theme recoloured to the app's own palette in docs/.vitepress/theme/custom.css — the values from src/styles/tokens.css, copied, because VitePress's variables and the app's tokens are different vocabularies for the same numbers. See DECISIONS, "The docs site is VitePress after all, and the guide is written".

The guide is a claim about the app in the same sense functions/src/capabilities.ts is, and CLAUDE.md puts it under the same rule: a change to what a person can see or do updates the guide in the same commit. It is written from the capabilities document — every sentence in the guide has a sentence there to stand on — so the two say the same things in two registers: the capabilities document tells the assistant, the guide tells a person. <kbd> names a thing on screen (a button, a row, a tab) and renders as a small key cap; a ::: tip block is an aside.

scripts/docs-prepare.mjs runs before every build and dev server. It copies the app's icons from public/ into docs/public/, and the repository's own documents — README, ARCHITECTURE, DECISIONS, BACKLOG, CLAUDE — into docs/developers/, each with a front-matter title, a note saying which file it is, and its first line retitled ("# ARCHITECTURE" reads as shouting on a page whose tab already says Architecture). Both directories are generated and ignored, so the repository holds one copy of everything. Those pages are the site's Developers section: present-tense reference for anyone changing the code, searchable alongside the guide.

VitePress supplies the rest: local search (⌘K / Ctrl K, an index built at build time and fetched on first use), the light/dark switch, the sidebar and outline, prev/next, last-updated dates from git, a sitemap for docs.planet4.me, and clean URLs — Hosting's cleanUrls is on for the docs target so /guide/lists is served rather than redirected. Output goes to docs-dist/, served by Hosting target docs: a second Firebase Hosting site (smart-list-app-docs, custom domain docs.planet4.me) rather than a path under the app, so the app's service worker cannot swallow a docs navigation and the docs never ride into the app's precache. npm run docs:build is the target's predeploy hook and runs in CI, where a dead link fails the build; npm run docs:dev serves the site locally with live reload. VitePress is pinned to an exact version: it is the one dependency here on a prerelease line, chosen because that line runs on the Vite 8 the app already uses.

Configuration ​

.env.local (untracked, see .env.example) supplies VITE_FIREBASE_*, an optional VITE_GOOGLE_PLACES_API_KEY, and VITE_USE_EMULATORS. No analytics.

Vite substitutes these at build time, so they are properties of a bundle rather than of a deployment: a build made without them cannot be repaired by supplying them afterwards. Absent, app.ts throws while the module graph is still evaluating, React never mounts, and the page is blank. A configResolved guard in vite.config.ts fails a production build that lacks the three app.ts requires, so that failure happens on the machine that can fix it; emulator builds (VITE_USE_EMULATORS=true) are exempt, since app.ts defaults them.

Hosting serves two sites. Target app serves dist/ with a predeploy hook that runs npm run build; target docs serves docs-dist/ with one that runs npm run docs:build. So firebase deploy builds what it ships, for both. Hooks are per target, so --only firestore:rules triggers neither, and --only hosting:app builds and ships the app alone.

Commands ​

CommandDoes
npm run devVite dev server
npm testLayer 1 unit suite — no network, no emulator
npm run test:emulatorLayer 2 + rules, wrapped in firebase emulators:exec
npm run buildTypecheck then production build
npm run emulatorsAuth (9099) and Firestore (8080) emulators
npm run test:e2eBrowser smoke test against a running dev server + emulators
npm run check:appcheckProves App Check is enforcing, rather than merely configured
npm run docs:buildThe help site (VitePress) into docs-dist/, after scripts/docs-prepare.mjs
npm run docs:devThe help site with live reload, at localhost:5173
npx firebase deployBuilds both sites via their predeploy hooks, then ships rules, indexes, dist/ and docs-dist/