The lab form placed the draw date in the second column of a two-column
grid. DateTimeField sets its date and time halves side by side from sm
up, so the date half was squeezed until a date could only be set
through the calendar. The value and the date now stack in one column,
as in the measurement form, and the date can be typed again.
Refs #1214
The TOTP enable, the source-priority reset, the biomarker and record add
actions, and the about-me, profile-facts, emergency and condition saves
move into SettingsCardActions, right-aligned and last in the card. The
emergency Save waits for a change like its neighbours, an empty list
leaves its add action to the empty state, and the source ladder keeps
its up/down buttons side by side on a phone.
EmptyState now has a single action shape: centred at its own width on
the 44 px tap floor, never stretched edge to edge on some pages and
compact on others; the per-call-site size switch is gone, and the
dashed card takes the card radius. While a list is empty its empty
state carries the add action, so the header drops its own add button
there (measurements, mood, medications, labs, vaccinations, documents,
illness, check-ups, visits, procedures). The dashboard medication tile
of an account without medications offers 'Add a medication' instead of
'no data in the chosen range'.
Field labels no longer lose the space before '(optional)' or set the
colon apart from a unit, and a question label carries no colon after its
question mark. Cancel next to a primary is the outline button in every
form, the illness sheet's selects span the field width, and the reminder
sheet drops a description that repeated the page subtitle. On a phone a
sheet now opens with focus on itself rather than its first field, so a
date or name field no longer raises the keyboard over a sheet nobody has
read yet.
A point and a calendar cell are small targets; one tap that opened the
day opened days by accident. On a touch screen the first tap shows the
value again and the tooltip's calm 44 px row opens the day, on every
day-linked chart and calendar alike. A date in a list still opens in one
tap; a click on a fine pointer still opens directly. The tooltip keeps
not gliding into place.
A tap on a point, a bar, a calendar cell or a date now opens the day at
once, as a click does; the tooltip's second step is gone and the tapped
value heads the day. The dashboard's charts (not its today area), the
adherence chart, the rhythm events, the ECG list and detail and the rest
mode notes open their days too.
The day's header carries the close control top left, the Coach panel's
own toggle and glyph, the date on one centred line and the arrows on the
right. While the day is docked, the Coach button and the documents bar
move clear of it.
Smooth the layer: the tooltip no longer glides into place on touch, the
neighbouring day is read before a step, the sheet animates in 300 ms and
not at all under reduced motion, and the opener stays put while the page
beside the docked day reflows.
Move the chart's doors to the day (click, the touch tooltip's button, the
dashed line through the open day, the row of day dots, the caption) into
one hook and give it to every chart with a date axis: recovery, sleep
duration and stages, the mood line and calendar, the intake calendar,
water and caffeine, lab markers, custom metrics, questionnaires and basal
temperature. Heatmaps share one adapter; every chart's keyboard way to a
day is its data table or the dated list beside it.
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day
over whichever page is open: docked beside it from 1280 px, the way the
Coach's conversations panel sits, a sheet from the right below that, and a
bottom sheet on a phone that opens at a little over half the height and
grows to full height from its handle. On /coach it is always a sheet, so
there is never more than one docked panel. Back and Escape close it, the
previous and next day replace the history entry instead of adding one,
Alt with an arrow steps a day, focus moves to the day's heading on open and
returns to whatever opened it on close, and a polite region names the day.
A malformed or future date is dropped from the URL.
The day shows the value it was opened from with its comparison, what ran
through the day, the readings against the person's own usual range on a
short number line (eight curated, the rest behind "All values"), and what
happened in order of the clock, each row linking to its record. Loading,
failure, an empty day and areas a grant does not cover each read as such.
The footer offers the timeline when that module is on and capture for the
day, with the capture forms starting on that date.
Doors into it: a click on a daily point of a metric chart (a tap shows the
value and the tooltip offers the day on touch screens), the dates in the
chart's data table, a quiet row of dots under the axis for days with
entries and a ring for notable ones, the lab chart and lab readings, the
measurement, mood and intake lists, symptom and illness day headings,
episode onset and recovery, document dates, workout dates, the workout
detail, the mood and adherence heatmaps, and the days a Coach answer's
get_day calls read, shown as chips. The next planned visit carries what
happened since the last one, each date opening its day, with a doctor
report for that window.
Not here, and frozen by a guard: the dashboard's today area, mini charts
in tiles, the cycle calendar, model-written text, future dates, the
clinician share link and the doctor-report PDF.
The wide tier let the trend column grow to 8rem while the sparkline it
holds is a fixed 72px glyph, so at 1920 px an empty 56 px strip opened
between the sparkline and the chevron. The column now stays 72px in both
tiers; the extra width goes to the range bar and the name, as before.
The wide-list spec reads the resolved grid tracks and asserts the trend
track is 72px at every width it covers.
On a wide window the compact lab list was one five-column row whose only
flexible column was the name: the name column sat mostly empty while the range
bar and the trend stayed small at the right edge.
The list's card is now a container, and the columns follow its width instead of
the window's. Below 64 rem of card the row is the lg table it was, with the
range bar at 12 rem; from 64 rem the bar column grows from 12 to 18 rem and the
trend column from 72 px to 8 rem, and the bar fills its column. The two tiers
exclude each other, so the result does not depend on the order the CSS is
emitted in. The trend sparkline itself is unchanged.
From lg up a long analyte name wraps instead of being cut off; a phone keeps the
single line with an ellipsis, and the bar keeps its own 12 rem cap there.
The shared 1280 px frame is untouched; a Playwright spec measures it on /labs,
/measurements and /mood at 1280, 1920 and 3440 px.
Fixes#1057
Day keys anchored at noon UTC went through the zone-aware
dateShortSmart, which shows them as the next day in UTC+12..+14. A
dateShortSmartCalendar formatter reads the key in UTC with the same
year rule, and the predictions panel, the illness timeline, the
intraday pulse day label, the nutrient chart and the coach result
chart use it. The lab chart dates a date-only reading by its calendar
date and a timed one in the reader's zone, as the lab lists do. The
timezone formatter guard now fails on a noon anchor handed to a
zone-aware formatter.
The range badge renders nothing for a reading without a reference range, so
that row had four cells instead of five and the range bar and the trend
moved one column to the left. The badge now sits in a cell of its own. The
chevron is hidden from lg up, where the row's hover already says it opens;
a phone keeps it.
Readers label every linked reading with the marker's unit, so changing
it relabelled stored numbers without converting them. PUT
/api/biomarkers/{id} now answers 422 biomarkers.unit.locked with
meta.readingCount when the unit differs and any reading (tombstoned
ones included) is linked. The marker editor says so under the unit
field.
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.
Both now run as a background run (`DocumentAiRun`, migration 0366):
- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
answers 202 with a run id once every quick refusal has passed. Without
the header it answers exactly as before, which the iPhone app relies on.
A second request while a read of the same document is queued or running
attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
route answers with, or the failure code and status it would have given.
Scoped to the caller; another account's run is a 404.
The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.
Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.
The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
A numeric lab reading was stored with its value as received and the marker's
unit stamped on it, so 5.6 mmol/L against a marker kept in mg/dL became
5.6 mg/dL and read as far below range. Both the manual/API write and the
scan commit did this; nothing compared the two units.
- POST /api/labs returns 422 labs.unit.mismatch, with the marker's unit and
the reading's unit in meta, and writes nothing. The unit is compared only
when one was sent: on the biomarkerId path it may be omitted, and the
marker's unit is then the stated one. A qualitative reading has no unit.
- The scan commit skips such a row with reason unit_mismatch, so the outcome
reads partial or failed instead of a clean save, and the toast names the
analytes concerned rather than saying "duplicates".
- The extract response carries the matched marker's unit. The review screen
shows a calm badge on a row in another unit, starts it unticked like a
duplicate, and treats it as a blocking error on the unit field if it is
ticked anyway.
- A small lab-specific normaliser forgives spelling only: spacing, the micro
prefix, the case of the litre, and a unit printed in capitals for a short
list where capitals cannot mean another unit. Case is otherwise significant,
so mIU/L and MIU/L, or g/L and G/L, are never merged.
The document-commit path already refuses a mismatch and is left as it is.
Readings stored wrongly before this cannot be identified, because the
original unit was never kept; they are not touched.
Adds EN and DE strings; the other locales are left to the maintainers. The
OpenAPI spec and the error-code catalogue carry the new code.
Fixes#1094
The browser gave up on its own: 120 s for the document routes, 90 s for
a lab scan, and the 15 s default on the lab text path, while a slow
self-hosted model with a raised response timeout was still allowed to
answer. A five-page report read by a local model failed on the client
side with the server still working.
Each request now sizes its abort from ai.provider.responseTimeoutMs on
the account payload, times the model calls the route can make, plus
30 s: one call for suggest and summary, three for Read with AI (the
transcription, then the lab staging read and its corrective retry), two
for a lab scan (the read and its retry). With the setting unset that is
90 s, 210 s and 150 s.
Refs #1090
The review stage is a list of readings to compare and correct, but it sat in
the default 448 px dialog, so with several readings it only grew taller.
- ResponsiveSheet gains an opt-in 6xl width tier (72rem). Nothing else
changes width; the Sheet branch on phones ignores the tier as before.
- The review stage uses it. The picking stage stays narrow.
- Each row lays out from the width of its container, not the window:
stacked in a narrow dialog, and once the dialog is wide enough the reading
sits on the left with its fields on the right.
- The field grid (value, unit, date) is a container of its own, so it takes
three columns when the fields have room for them, whatever the window
width. The result-text span follows the same query.
- The dialog-overflow e2e spec covers the review stage at 390, 560, 768,
1280, 1920 and 3440 px. 560 is wider than a phone's field grid needs but
below the window sm breakpoint, which is what tells a container query from
a window one.
Refs #1056
A date-only value is stored at 12:00 UTC. The lab list and history, the
document cards and pickers, the document date suggestion, extracted
fact dates, source-picker rows and the compliance and mood heatmaps
formatted that instant in the reader's zone, which is the next day from
UTC+12 to UTC+14.
`useCalendarDate` formats such a value through the UTC-pinned calendar
formatter the vaccination list already uses, with the same field order
and locale as instant dates. Lab readings hold both kinds, so
`useLabDate` reads a noon-UTC anchor as its calendar date and a reading
typed with its time of day in the reader's zone. A document without a
filing date shows its upload day, an instant, in the reader's zone.
Range bars and the lab list, detail rows, chart tooltip and reference
ranges printed numbers with a plain `String()`, so a German reader saw
"1.8 mIU/L" and "61.3 kg" under a German interface. The same was true
of the mean, GMI, eA1C and the advanced indices on the glucose panel.
`RangeBar` now prints value, bounds and delta through the locale number
formatter and takes an optional `formatValue` for surfaces with their
own precision. Lab values keep their trim rule (whole numbers bare, at
most two decimals) in `formatLabValue`, which now hands the digits to a
caller-supplied locale formatter; `useLabNumber` wires it to the
reader's locale for every lab surface. Inputs are unchanged.
A PDF longer than the pages a scan renders was read from its first ten
pages, and the cut was recorded only in the wide event. The person checking
the extracted rows had no way to know that values on later pages were never
read.
The rasterizer now returns the PDF's page count, the extract response
carries pageCoverage { read, total } when the cap applied, and the review
screen says how many of the pages were read.
A reference window of 0-130 padded the axis to -13, a tick that reads
like a value for a concentration that cannot be negative. The pad only
goes below zero when the data already does.
The compact list was a five-column table at every width. Below the fixed
columns (badge, 12rem range bar, 72px trend, chevron) the name column
collapsed to nothing, the badge sat over the value and the row panned
sideways off the card. Below lg the same cells now fold into two lines
per row; the desktop subgrid is unchanged.
The values table needed ~510px, so on a phone its edit and delete
buttons sat in a sideways-scrolling strip and the range bar labels were
clipped. Below lg each reading is a stacked row with its actions beside
it.
The add sheet's three footer buttons did not fit a phone row in German
and pushed Cancel off the left edge; the secondary save takes a row of
its own there, and the footer wraps instead of overflowing on desktop.
The scan review's 'links to existing marker' chip truncates instead of
running past its row.
The review dialog silently dropped any selected reading that could not be
written (no date, no unit, no value) and saved the rest. When nothing was
left it showed "Select at least one reading to save", which described a
different situation and said nothing about what was missing.
A save is now all-or-nothing over the selected readings. When one is
incomplete, nothing is written; the dialog states how many readings need
attention, marks each missing field (aria-invalid, inline message, red
border, required marker on the label) and moves focus to the first one.
Marks appear after a save attempt and follow the edits. The "nothing
selected" message is kept for that case only.
Validation is a pure function next to the payload mapping, so the rule that
blocks a save and the rule that builds the payload cannot drift apart.
Adds EN and DE strings; the other locales are left to the maintainers.
Refs #1055
Every AI surface reads its capability from the ai block on
/api/auth/me, but setting up or removing a provider, signing in to or out
of Codex, saving the fallback chain, the central-Codex opt-in, the
local-OCR switch, a minted consent receipt, the admin's server key and
central Codex, and a guardian's save inside a managed record evicted only
their own reads. The surfaces stayed on the old answer until a reload.
All of these writes, and the ones that already refreshed the payload
(consent, modules, Hide Coach, the operator's switches), now evict one
bundle, aiInputDependentKeys. A structural test holds every client write
to those endpoints to it.
Reading a document, scanning a lab report and turning a medication
description into a schedule follow documentAi, labsOcr and
medicationExtract. Unavailable, the action is not offered. With only the
document-reading consent missing, the place that reads asks for it and
grants exactly that (ai_extraction), nothing wider.
Refusals are read by meta.errorCode: a 403 is no longer always called a
missing consent, and a spent daily budget no longer shows the rate-limit
sentence.
The reset after "Save and add another" cleared the biomarker and passed
the picker `undefined`, which switched it to its own internal state. It
went on showing the marker it last held while the form value was empty,
so the next save refused with "Pick a biomarker first" under a visibly
picked marker, and a later pick could land on the first catalogue entry.
The picker is always controlled now (an empty pick shows the
placeholder), and "Save and add another" keeps the marker, the draw date
and the visit, clearing only the reading, its note and its printed
range. Focus moves to the value field for the next entry.
`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.
Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.
So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.
The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
useRecordCapabilities answers per section now. canWriteDomain and
canManageDomain read the two lists the account payload publishes; in
one's own record they are always true, and under a refused, pending or
unproven context always false. canManage becomes the coarse switch,
true when any section is manageable, and is what the banner and the
navigation read.
Every add, edit and delete control names its own section: labs, mind,
profile, illness, cycle, medications, measurements, documents. Creates
that a WRITE route answers (visits, practitioners) ask canWriteDomain.
The generic delete button, the selection bar, the capture picker, the
dashboard quick entry and the bottom nav take the section from their
callers. Controls whose routes resolve the caller rather than the
record (settings shortcuts, chart overlay preferences, custom metrics,
the Today rail, OCR, efficacy targets, credential tabs) ask for the
caller's own record instead, and the customise-shortcut guard now
requires that shape.
The vault has no delegated write route, so its controls stay withheld
under every grant, and the documents page says so in one sentence
where the upload control would be, in all seven locales.
A floor at or above its ceiling fell through to the one-sided branch and
was read as "minimum only", which paints a high reading green. That is
the wrong direction for a transcription error to fail in: the colour
answers a question nobody could answer from that data.
Each one-sided branch now requires the OTHER bound to be absent, so a
present-but-impossible pair lands on the neutral dot instead. It matches
lab-reference-range-bar.tsx, which renders nothing for the same input —
the two components sitting next to each other should not disagree about
what a nonsense range means.
The file had no test. It has one now, covering all five window shapes
plus the zero-width band; with the two conditions relaxed again, exactly
the two new cases fail and the four existing ones stay green.
The free-text reference-range parser decided what a printed window meant from
an alternation of German and English words. The app ships six locales, and a
lab prints in the language of the country it stands in, so `jusqu'à 5,0`,
`hasta 5,0`, `fino a 5,0` and `do 5,0` all fell through to `low: null,
high: null`. The string was stored, the reading was then judged against the
biomarker's own band or against nothing at all, and no surface anywhere said
that the lab had printed a window the app could not read.
The vocabulary is TABULATED here, not derived from `messages/<locale>.json`
the way an app-owned word list would be. These words are prose a lab printed
on paper; the app owns no string they could be read out of, and the language
of a report has nothing to do with the language of the UI. So the table is
hand-written, keyed by the shipped `Locale` union so that adding a locale
fails to compile until someone has answered how that language prints a range,
and every locale's words are tried against every input at once. The cost is
written down at the table: a report in a language absent from it stays
text-only.
Three things had to change beside the word lists. Introducing words are now
stripped only on a second pass, because Italian "da", Polish "od" and French
"de" also state a floor on their own and stripping first read "da 3,5" as a
bare number. Input and table are diacritic-folded and their apostrophes
normalised, so a table row written "jusqu'a" meets a report's "jusqu'à" and
the curled apostrophe a word processor emits. And one-letter separators ("a",
"e", "y") are only accepted with whitespace on both sides, so "3.5e5" is not
read as a window. Decimal commas already worked and are pinned by the new
cases.
An unreadable window now has a name. `isUnreadableRange` separates "the report
stated nothing" from "the report stated something nobody could read" — the two
were indistinguishable at every call site, which is what let the French case
pass unnoticed. The manual form already showed the reader that the text was
kept but not read; the two ingest paths now count it into their wide event, so
an unsupported notation surfaces as a number rather than as silence.
The German and English lists are the previous alternation split word for word,
and the existing suite covering them passes unchanged.
A lab panel files itself against the visit it came from, and never has to. The
OCR review and the manual form both offer the suggestion through the one shared
field, anchored on the draw date rather than on now, so backdating a reading
moves the offer with it. The commit writes one link PER RESULT ROW, which is
why the join is m:n: a marker re-run on a different day belongs to its own
visit. Best-effort by design — the readings are the write that matters and must
not be lost to a link that could not be made.
The condition side gets a Visits about this card with an action that pre-links
the episode. From the episode side only, and that is a decision: an episode
spans weeks and a visit is a point, so matching the other way would be wrong
often enough to teach a person to ignore every suggestion in the product.
A due checkup gains a second action beside erledigt. Filing the visit as done
satisfies the checkup through the same forward-only, idempotent primitive the
checkbox uses, so doing both is one satisfaction and no error. The two actions
answer different questions — one says it happened, the other says where.
An appointment now appears where a person looks, and NEITHER surface relaxes
the reminder-table exclusion that keeps it out of the preventive-care lists.
Both read the visit table: a quiet line under the dashboard checkup block for
anything inside two weeks, and one rail item for today or tomorrow. A diff in
either that touches origin is the wrong fix.
A visit write now also refreshes the reminder reads. Closing a checkup moves
its next-due date on three surfaces that would otherwise keep showing it due
until their own poll.
OpenAPI regenerated in the same commit.
The gate the diet shipped with matched `<SettingsCardHeader` in three
component folders. That is not where every card description lives, so five
real ones were outside its reach — including the full backup's, which the
sweep's own shortlist had named and which stayed at 351 German characters
across three sentences while the guard reported zero violations.
Two ways in were missing. A card header can sit in a folder the walk never
entered (`labs`), and a description can be FORWARDED to the header by a shell
— `ExportCardShell`, `ImportCardShell` — where the prop is on the shell's tag,
not the header's. The gate now matches both, over seven roots, and the
"finds what it is meant to check" floor moved with it.
`SubPageShell` is deliberately not included, with the reason written at the
list: its description renders as `text-foreground text-sm`, the body tier,
where the standards allow prose. Gating it would enforce a rule §3 does not
make for that tier.
The five, all six locales:
- full backup — the twelve-item enumeration was the description. It is now
the card's opening body paragraph, where a list of what is in the file
belongs; the description is one sentence.
- local OCR — the how-it-works sentence stays in the slot; "less accurate,
review every value" is safety copy and reads foreground in the body.
- CSV import, dose-history import — second sentence to the body.
- the ECG page — the "does not interpret the waveform" claim gets its own
paragraph rather than riding the page explainer.
Proven by reverting the full backup's text: the widened gate reports it as
both two sentences and over length, at the call site, in both languages. The
old gate could not see the key at all.
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.
Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.
`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.
The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.
Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.
The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.
Move the decisions into the primitives so a call site cannot re-open them:
- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
header-to-body step is the container's, not a margin somebody remembers.
A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
and it mis-aligned action rows against their own forms wherever two
neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
licence call sites used to stack explainer paragraphs into a muted
`text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
the card. `align="start"` spells the one exception, a card whose only
actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
a group of cards, which is what Settings needs; Settings had been using a
card header with no card, which reads as a card that failed to paint.
Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.
Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
The dose-history purge was the one destructive verb in the intake family
that removed rows outright. Both siblings, the single-event DELETE and the
bulk delete, stamp `deletedAt` and bump `syncVersion`; the purge called
`deleteMany`, so a mis-tap was final and a client offline at purge time kept
every row forever, because a row that no longer exists produces no tombstone
for the sync feed to carry. It tombstones now, scoped to live rows so a
second purge counts zero. No inventory refund: a purge wipes a history as
bookkeeping, and refunding every dose ever taken would inflate the current
container count instead.
The manual water quick-add and the lab-result un-delete both wrote health
data and reached only the wide event, which expires. Months later nothing
could answer who put a day's total there or who brought a reading back, and
with a second person able to act on a shared record that question has a
second possible answer. Both write a durable row now, named after the
neighbours they sit beside.
The refused-delegation audit comment claimed the row was written for the
owner's benefit. It is not: it is filed under the caller, it is only written
for a header refusal, and a header names an account the caller merely
asserts. Filing under that claim would let anyone write rows into any
account's activity feed by naming it. The comment says what the row is now,
and points at `recordDelegatedAccess`, which is the half the owner does see.
Two comments in the biomarker surfaces still claimed the `SetNull` foreign
key left the readings behind when a marker is deleted. That stopped being
true when delete became a "drop this and its history" action and the
confirmation copy was rewritten in six locales to say so.
The purge route gains a call-shape assertion rather than a count assertion,
because a count comes back identical from a `deleteMany`. The lab restore
route had no test at all and now has one.