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