Planet4.Me docs
Open the app

#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 rowuseHold 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.