Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).
Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
The recurring no-briefing day had a chain of causes, closed end to end:
Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.
Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
The third and final layer of the stale-tile bug: saving invalidated the
dashboard snapshot with the default refetchType 'active', but the dashboard
page is unmounted while the user sits in settings — the cell only went stale,
and the snapshot query's deliberate refetchOnMount: false then suppressed the
re-read on same-tab navigation back. The save, reset and ring mutations now
invalidate with refetchType 'all', refreshing the cell mounted or not
(proven live in one SPA session against the real query client).
The sortable settings editors (dashboard layout, medication order, modules,
mood tags x2) painted a second page scrollbar: the sr-only drag hint below
each list is position: absolute, no ancestor was positioned, so it resolved
against the initial containing block, escaped the shell's overflow clipping
and lengthened the DOCUMENT by its static offset. The list wrappers are now
relative; the e2e overscroll guard asserts the one-scroll-floor on these
routes and was negative-tested against the revert.
The Local provider speaks the standard JSON wire: response_format by default,
one self-healing fallback per endpoint when a server rejects it (dialect
cached per base URL) — LiteLLM, OpenRouter, vLLM, LM Studio and plain Ollama
all work from the same form, and the form + provider docs now say so. A
gateway that shims a Claude-family model behind a synthesized tool call is
parsed from the tool-call arguments instead of yielding silently empty
insights.
The insight token budget is configurable (INSIGHTS_MAX_TOKENS, default 2500,
clamped 500-8000) and unified across all six call sites; a reply cut off
mid-JSON returns a distinct truncation error instead of a generic parse
failure. A briefing withheld by the number-grounding check now says so on the
card instead of pretending nothing was generated. The provider test button
answers 4xx with a bad_request category instead of claiming the endpoint was
unreachable.
Sleep repair follow-up: measurements carries a second full unique index — the
natural key (type, measured_at, source, sleep_stage) — so a key-format
migration could tombstone the old rows while their re-keyed replacements
collided with that index and were silently dropped (live incident: a
full-history re-sync erased a user's sleep). Planned creates now run a
natural-key rescue probe: a match, live or tombstoned, is updated in place —
fresh value, the new externalId, deletedAt cleared. A rescue-probe failure
holds the watermark so nothing is lost past the overlap.
The sleep source-discrepancy annotation now reaches the surfaces: SleepNight
carries the MAIN session's annotation (naps never cross-compare), the analytics
slim slice / dashboard snapshot / iOS summary thread it additively, and the
dashboard sleep tile renders the shared discreet marker (extracted from the
sleep panel — one implementation, two surfaces). Served totals unchanged,
pinned by tests.
Apple Health medications groundwork (server side of the medication-import
request): IntakeSource gains APPLE_HEALTH; medications accept an external
mirror identity (source-exclusive by policy) and dose imports carry the HK
dose-event UUID — both idempotent per stable key, with the concurrent-create
race resolved to the winner. Migration 0239, additive only. Entries without
the new fields behave byte-identically.
Hardening: doctor-report aggregation + PDF charts compute ranges with loops
instead of spread calls — a report over a sample-dense year cannot overflow
the call stack (the class fixed for the Google Health full sync).
Google Health: the fullSync rollup-defer tracker collected its touched keys via
a single spread push — one argument PER READING, so a multi-year heart-rate
history (six figures of samples) overflowed the call stack and aborted the
metrics collection mid-cycle, exactly on the accounts with the most data. A
plain loop replaces the spread. Reported by a self-hoster; the v1.28.21 sync
diagnostics confirmed every other read on that account healthy in one log line.
Documents: the vault card's read-by-AI tag no longer takes its own line — a
small glyph rides inline on the date-and-size row. The detail view drops the
status bar under the preview entirely; with automatic reading on and nothing to
review the whole block collapses, so the content starts straight with the
document's fields. The provenance contract moves with it: the e2e specs now pin
the card marker instead of the sheet pill, and the unmounted status-pill
component is removed rather than left dead.
A one-time background pass re-reads each connected account's Google Health
sleep history so nights stored twice before the sleep fix collapse to their
true total (new sleep_repaired_at marker, migration 0238; watermark-safe,
enqueued once at boot per un-repaired connection).
The sleep night read reports when two writers disagree about the same night —
the served total is unchanged, the disagreement rides along as an annotation
(sourceDiscrepancy on the night DTO) so clients can mark the number instead of
silently picking a side.
A GLP-1 medication whose weight has plateaued on the current dose says so next
to the estimated drug level (efficacy tab + insights) — a factual note from the
existing detector, association only, verb-guard covered.
Google Health diagnostics: the sync log now separates a daily-totals read that
returned nothing from one the importer could not parse
(googleHealth.rollup.emptyResponse vs droppedAll), and the connection test's
structure probe reports the raw response envelope key names when a rollup type
parses to zero — a per-account naming drift becomes visible from one report.
The GLP-1 estimated active-substance curve (the modelled drug level from logged
doses) now also renders in the efficacy (Wirkung) tab and in the Insights
medication overview, not only under the injection tab. Both reuse the same
self-contained DrugLevelChart, gated on the GLP-1 treatment class, so the drug
level sits next to the efficacy readouts. Additive — no existing surface changes;
non-GLP-1 medications render nothing.
The detail sheet's AI block over-removed: the read-with-AI action and the
read-provenance pill are both contract surfaces (the action is the explicit read
path when auto-read is off / for an external provider; the pill is the read-state
indicator). Only the redundant block title + wand were the wasted row. Restore
the action and pill; drop just the title. The action row still collapses when
auto-read is on, as before.
Documents vault card: date + size and an AI-read marker now sit directly under
the title instead of the meta line running off under the thumbnail.
Document detail view: a larger close control replaces the small X and the
redundant top-right shortcut; the inline read-with-AI row under the preview is
gone (the suggest / summarise / show-text actions stay); a button to ask about
the document sits next to share and download.
Mental-wellbeing cards no longer wash the whole tile on hover — only the title
is the open target, matching the medication cards.
Medication API settings render inline instead of behind an expander that never
had anything beside it.
Document vault shows a per-file thumbnail: a server-rendered preview,
downscaled and re-encoded so location and camera metadata are dropped,
served only to the file's owner over an authenticated request. Files that
cannot be previewed keep their type icon. Generation is bounded — a header
sniff refuses an over-large source before decode, so a crafted image cannot
exhaust the preview worker.
Also folds in admin-managed central AI access — an administrator connects
one access on the server and each user opts in or keeps their own provider,
off until an admin turns it on — and trims the redundant documents-search
caption and per-file searchable/read badges.
Render a small JPEG preview for each stored document and show it at the
card's leading edge, falling back to the kind icon when none exists.
- Generate previews in the background on upload (new document-thumbnail
pg-boss queue) plus a boot-time corpus backfill for existing documents;
upload never blocks on or fails because of it. Images are downscaled via
@napi-rs/canvas, a PDF's first page is rasterised then downscaled, every
other type is skipped. Generation never throws.
- Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table
(migration 0236) so the list/detail queries that omit the original blob
never drag the preview either. Same AES-256-GCM string codec + fail-closed
decrypt as the content index; wired into the key-rotation walk.
- Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail
(auth + module gate + userId-scoped query + fail-closed decrypt), always
image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an
authed lazy <img>, onError falls back to the icon.
- Add hasThumbnail to the document DTO so the card only fetches a preview
that exists.
Also drop the "search looks inside your documents" caption from the overview
(the index-all prompt stays) and the per-card searchable marker; both were
noise. Remove the now-unused i18n keys across all six locales.
Sharing a single document from the vault minted a full-record share: the
create form always defaulted `resourceTypes` to ["Patient","Observation"]
and never signalled a documents-only intent, so the served /c/<token> view
aggregated the whole doctor report — every vital, lab, medication and
wellness figure — alongside the attached file. An empty section blob was
indistinguishable from "defaults", so the report always resolved to the
full record.
Introduce an explicit documents-only share:
- add a `documentOnly` flag to the create payload; when set the server
forces an empty report scope (all-off sections, no FHIR resource types,
FHIR API off) and requires at least one document, so a document link can
never widen into a record — whatever the body sends.
- gate the clinician-view aggregation on an enabled section: a share with
no section on never calls the report aggregator, so no health metric
leaves the database. The recipient sees only the attached document(s).
- the document detail Share action opens the create form in documents-only
mode: hide the FHIR toggle, the FHIR resource-type checkboxes and the
history-range selector, keep the picker/label/expiry/passphrase, and
always carry the launched document into the created link.
- Settings sharing keeps the full record-share form unchanged.
Extract the create-payload builder as a pure function and cover the scope
contract, the empty-scope data load, and the served-view metric-free
guarantee with tests; refresh the share e2e and regenerate the OpenAPI
contract.
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.
- Schema (migration 0235): three encrypted columns on AppSettings for the
operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
(default false). Register the new encrypted columns in the rotation registry
and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
chain only when they opted in AND the operator connected it. Tokens refresh
server-side against the singleton row (CAS-style), same primitives as the
per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
consent machinery (self-snapshot and document surfaces alike). No existing
consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
honest copy on account pooling, shared rate limits, and training-by-default;
a per-user opt-in switch shown only once the operator has connected it, off by
default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
consent gate, vision capability, and document order; a mocked e2e for both
the admin connect surface and the per-user switch.
The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
Lab-report OCR now rasterizes a PDF to page images for any non-Anthropic
vision provider, exactly as the document vault already does — Anthropic keeps
its native PDF block, every other vision provider (codex/OAuth, OpenAI, local)
reads the rendered pages. The capability probe reports `pdfSupported` whenever
the picked provider reads PDFs natively or the server-side rasterizer is
available, so the "scan a PDF" affordance shows for a codex provider too.
Drop the dedicated document-scan (Lab-OCR) provider: labs OCR always uses the
main configured provider chain. Removes the settings card, the `aiOcr*`
resolution branch, the GET/PATCH handler fields, the OpenAPI schema fields, the
i18n keys across all locales, and the five backing `User` columns (migration
0234). The on-device tesseract fallback is untouched.
Dashboard hero: stack the overdue-dose action directly beneath the verdict
text (tight gap, left-aligned) on every viewport instead of pushing it to the
far container edge on desktop.
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.
- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
tokens, plaintext athlete id, incremental cursor, and backfill marker on
`User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
the stored refresh ciphertext, because Strava rotates its refresh token on
every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
activity maps to one `Workout` row. Activity name/description stay in metadata
as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
into the workout ladder below the device-native wearables and above
MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
state, cross-account re-auth rejection, and a surgical `www.strava.com`
connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
docs/integrations/garmin.md documenting the Apple Health / Google Health
Connect path and Garmin's withheld proprietary scores, cross-linked from both
platform docs, plus a non-connector info note in the Connections panel.
Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
Close the gap between the welcome carousel's AI promise and the payoff:
a fresh keyless user used to meet "No AI provider connected" on every AI
surface, because the only setup prompt was a skippable card on the final
screen.
Add an "insights" row to the getting-started checklist, derived from real
provider state — any personal key, local model, OAuth sign-in, or the
operator's shared key flips it done via /api/user/ai-provider. It persists
past onboarding, self-satisfies on connect, and dismisses like the others.
Elevate the done-screen AI card into a value-first panel: it leads with a
static, clearly-labelled example briefing (no model call, no egress, no
consent), then the local-first ladder — local model, bring-your-own-key,
subscription sign-in with the training caveat — and the honest line that
HealthLog is fully useful without AI. All three exits stay, so the panel
gates nothing. On a deployment with a shared provider it says so plainly.
Extract the worked example into a shared SampleBriefingCard. Add
onboarding.ai.* copy across all six locales and retire the old
single-card done-screen keys.
Replace the collapsed bottom-of-card "Chat about this document" outline
button with a neutral, ghost Coach icon pinned to the top-right of the
document detail-sheet header, mirroring the Insights ask-coach affordance.
The icon uses the muted-foreground token only — never the primary/FAB
gradient — and opens a document-scoped chat drawer.
Present the chat in the Coach chat drawer chrome (right-side sheet on
desktop, bottom sheet on phones; same width caps; close-only, no maximize
or conversations rail) with a quiet "Chatting about: <document>" scope
badge. The drawer reuses the existing pure conversation body and the
existing client hooks, so it still posts /api/documents/inbound/[id]/chat
verbatim: no tools, no health snapshot, fenced document, inbound/replay
refusal, outbound screen, numeric grounding, consent + budget + per-user
rate bucket, indexed-only, plain-text render — the route is untouched.
Thread isolation is unchanged (the general Coach filters documentId: null;
the document route always scopes { documentId: id }).
Add a small reusable headerAction slot to ResponsiveSheet for the header
icon. Retire the DocumentChatPanel container and the AI-section chatSlot;
keep the pure DocumentChatConversation body and documentChatErrorKey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPyLteNeTWwEC154X7vLWc
When "read documents automatically with AI" is on, the detail sheet no longer
shows the manual per-document action row (Read / Suggest / Summarise) — reading
already happens on upload, so the buttons only nagged. The section header and
the searchable status pill stay, and content search keeps working. With the
setting off the manual controls render exactly as before.
Drop the "review before saving" heading and its grey sub-caption from the
suggestion panel; the reviewable fields and the apply / dismiss controls stay.
Remove the now-unused documents.assist.reviewTitle / reviewHint keys from every
locale.
The bundled (Turbopack) copy of pdfjs-dist failed its render path in the
standalone image with a bare Error, so a scanned PDF on a non-Anthropic
provider fell back to 'PDF scanning needs a Claude vision provider' — while the
identical un-bundled module rasterizes the same document fine (verified in the
container end to end on the real document). Externalize pdfjs-dist and
@napi-rs/canvas so the server loads the real modules, and hoist pdfjs-dist to
the top level in the image so the runtime import resolves. Also log the render
error message (not just its name) and drop the redundant 'not saved' note from
the document summary panel.
Remove the two per-document AI copy blocks in the document detail sheet:
the external-egress notice and the "read this document" explainer. The
settings-toggle honesty confirm already states the egress trade, so the
per-document repeat is redundant. Drop the now-unused `egressExternal`
prop and the `documents.ai.egressNotice` + `documents.ai.available` keys
across all six locales. Keep the capability `egress` DTO field — the API
still reports it.
On /insights/recovery, the metric block rendered an outer heading plus an
explainer above a tile whose own header repeated the same title. Move the
explainer inside the tile as a `description` beneath its `TileHeader` and
promote the tile title to a real `<h2>` (new `titleAs` affordance), so each
metric shows one heading with the blurb under it and the h1->h2 outline
stays nested. Applies to every metric the block renders.
Add the per-medication "Wirkung" tab (hidden for one-shot and no-target meds)
rendering the target series with a start marker, dose-change markers, shaded
pause bands and a faint reference band, an adherence lane, a neutral
before/after-start card with honest empty states, an optional level-shift note,
and a retarget chooser. Weave a compact, read-only efficacy summary into the
Insights medication surface that reads the same DTO and links through to the
tab. All copy is descriptive; add a banned-verdict-verb guard test over the
medications.efficacy namespace across all six locales.
Bring the structural card-anatomy offenders onto the spacing scale. The
cycle wellness-tile family (ring tile, phase-education, insight summary,
cycle view) and the insights wellness-scores tile drop banned p-5/px-5
py-5 for p-4 md:p-6 and keep their premium hue surface; their header and
info glyphs move off text-primary to foreground, spinners to muted. The
onboarding hand-rolled shells (anamnesis card, baseline form,
getting-started checklist, tour tooltip) drop p-5/space-y-5/rounded-2xl
for p-4 md:p-6/space-y-4/rounded-xl. settings/anamnesis-section moves its
three raw Card+CardTitle shells onto SettingsCard + SettingsCardHeader.
Establish one inset system on the Insights surface. SubPageShell's flat
px-6 header sat 8 px right of every card body on mobile — track the card
slot inset (px-4 md:px-6) so the heading-vs-body step disappears.
DeviceScoreTile and ResilienceTile hand-rolled a muted h-4 header row;
route them through TileHeader size=sm (foreground icon, shared gutter),
latest reading in the right slot. ScoreAnatomyView dropped its banned p-5
+ centred uppercase title for a left-aligned TileHeader on p-4 md:p-6, so
the ring hero stays a centred identity block while the header shares the
reading edge with the stacked assessment card. SparklineDeltaTile swaps
its hand-copied header row for the TileHeader primitive so it can't
drift, and its contributor label rounds off the sub-xs size.
SettingsShell's content column carried pb-24 md:pb-0 that double-counted
the bottom-nav clearance AuthShell's <main> already reserves, stacking
~96 px of dead scroll below the last card on mobile. AdminShell still
carried the pre-#154 min-h-[calc(100dvh-12rem)] column reserve on <main>,
over-reserving on short sections at every breakpoint.
Delete the redundant SettingsShell gutter, move AdminShell's floor off
<main> onto its grid, and factor the budget-derived grid floor into one
shared SUB_SHELL_GRID_FLOOR constant so the two shells can never drift.
Extend the guard to /admin/* and measure against each element's
content-box bottom so a redundant sub-shell pb-* now fails instead of
inflating the allowed budget.
The overdue-dose CTA sat in a flex-wrap row, so a full verdict sentence
pushed the take/log button onto its own line — wasted vertical space.
Adopt the PageHeader inline-action pattern: items-start justify-between,
the sentence gets min-w-0 flex-1 and wraps internally, the button gets
shrink-0 and stays beside the text. Tighten the greeting to verdict gap
from space-y-3 to space-y-2.
The mobile bottom-nav More overflow appended the shared utility tail
(Settings, Notifications) after the feature destinations. Those two are
already reachable from the user/avatar menu, so the hub duplicated them
across two menus on the same screen. Return feature destinations only;
the utilities stay in the user menu. Sidebar footer is unaffected.
Flip the three hub assertions to lock the feature-only contract.
Add a single switch in AI settings — off by default — that turns on ambient AI
reading of uploaded vault documents. Turning it on reveals a once-shown,
vendor-blind honesty confirm (subscription providers may use the content to
improve their models; a self-hosted local model never egresses) that must be
acknowledged before the setting is written. Copy is translated across all six
locales.
Add an e2e suite mocking the capability probe, the chat history GET, and the
streaming chat POST so the panel renders (v1.27.31 lesson): the entry opens an
empty safety-noted surface, a turn streams a reply that settles to one assistant
bubble, a non-indexed document shows the calm hint, and no provider leaves no
chat surface. Clear the streamed tail + optimistic bubble once the persisted
twin lands so the reply never double-renders.
Extract DocumentChatConversation (pure) from the chat panel so the open
conversation surface is statically renderable, and pin: composer + always-on
safety note, plain-text/XSS-safe assistant render (raw HTML escaped), the
streaming tail vs thinking placeholder, mapped error keys, and the container's
indexed vs not-indexed gating. Assert the AI section renders the chat slot only
in the provider-available branch.
Add a scoped 'Chat about this document' panel beneath the Read-with-AI
actions. Opens a conversation grounded only in the document's stored text:
a message list + input streaming the assistant reply token-by-token over
the SSE route, with prior turns loaded via GET.
- Availability: the panel renders in the provider-available branch of the
AI section (a pure chatSlot keeps the section statically renderable), and
gates internally on whether the document is content-indexed. Not indexed
shows a calm read-it-first hint, never an error.
- Plain-text only: assistant prose renders via ProseBlocks / StreamedProse
as React children (no markdown library, no dangerouslySetInnerHTML).
- Streaming reuses the Coach SSE frame parser; the wire frames are a subset.
- A one-line safety note states answers describe the document and are not
medical advice, consistent with the summary panel's not-a-diagnosis.
- Query keys via the central factory; history invalidated on the done frame.
- i18n x6 for every new string.
Introduce a shared PageAuthGate (header + tile skeleton) and adopt it on the
top-level pages that flashed a lone centered spinner while auth resolved:
mood, measurements, achievements, illness, mental-wellbeing, checkups, cycle,
and the labs / medications / custom-metrics / illness detail routes. The
auth-resolving frame now reserves the page geometry instead of snapping in
after a spinner.
Port the six medication dialogs (log-intake, intake-edit, dose-history-add,
take-all-due, log-injection-site, intake-import) from the centred Dialog to
ResponsiveSheet with the sticky-footer slot, matching every other module's
add/edit form. Form-backed dialogs associate their footer submit button via a
useId form id so Enter-to-submit and the footer button share one form; the
nested slot-nudge confirm rides the same sheet. Primary actions now sit within
thumb reach on phones.
- Lab groups not backfilled to a catalog biomarker now carry a muted
'not linked to a biomarker yet' affordance in both list and card views, so
an inert row no longer reads as a broken link next to clickable neighbours.
- The biomarker detail page links to the document vault filtered to
lab-result reports (gated on the documents module), giving a path from a
value to its source scans. A per-value document relation remains a
data-model follow-up.
Reading localStorage/matchMedia in a mount effect tripped the
react-hooks/set-state-in-effect rule. Model the stored preference and the
OS preference as external stores read via useSyncExternalStore with an
SSR-stable 'dark' server snapshot, and apply the <html> class from a
DOM-only effect — no setState-in-effect, same hydration-seam fix.
Add GET /api/documents/inbound/capability (document provider order + vendor-blind
egress class), repoint the vault capability hook to it, and show a per-egress
notice in the detail sheet before a document read leaves the machine. Cover the
document provider order, the egress consent gate, and the notice with tests.
i18n x6 + OpenAPI regenerated.
Co-Authored-By: Marc-André Bombeck
- Onboarding 'more sources' chips anchor to their provider card
(/settings/integrations#oura etc.); connection cards gain scroll anchors.
Apple Health (no web card) stays on the page root.
- Cycle module gains a header settings wrench for parity with labs /
medications / illness; the tab strip is now controlled so the wrench jumps
to the settings tab and ?tab= deep-links keep working.
- CustomMetricList load error uses QueryErrorCard with retry; post-create
navigation switches from a full-page window.location.assign to router.push.
- CustomMetricDetail + medications list swap their hand-rolled error blocks
for the shared QueryErrorCard.
- Side-effect delete now uses the themed AlertDialog confirm instead of a
native window.confirm.