Settings now reads as nine groups: Account, Tracking, Arrangement,
Notifications, Health profile, AI, Connections, Data and System. On a
phone one button names the current section and opens the grouped list,
replacing the strip of every section.
What moved, every old address still working:
- Sessions, trusted devices and security activity sit under Security;
the privacy page anchors forward there.
- Notifications holds every reminder kind: the Coach nudges and the
checkup reminder switches joined, and a card names the reminders set
where their subject lives (dose times, cycle reminders).
- Arrangement holds order and view only. Mood tags, the labs biomarker
catalog and on-device text recognition, and the injection sites live on
module pages under /settings/modules/<id>, linked from each Modules row
and from the module's own wrench. /settings/layout/mood redirects.
- The timezone saves on change in the Display card, with a one-tap offer
of the device zone.
- Screening hints sit beside the life circumstances they come from.
- System gains Preview features with the Topics switch.
Duplicates: the Coach and MCP pages show their state and link to the one
switch in Modules (turning MCP off keeps tokens), the Coach overlay shows
the provider and model with a link to Settings > AI instead of a second
set of selects, and the privacy page drops its copy of the export links.
A guard walks every historical /settings address through the redirects
and every forwarded anchor to a page that renders.
The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
Every personal provider form showed a stored key and offered no way to
take it back, since saving with an empty key field keeps the stored one.
The OpenAI, Anthropic, local model and gateway forms now carry a
confirmed "Remove API key" while a key is stored. It sends the existing
PATCH /api/user/ai-provider with that one key as null, which clears the
column and leaves the provider choice, model and the other keys alone.
ConfirmButton took a `destructive` prop and dropped it on the way to its
dialog, so every delete, revoke and remove built on it confirmed with a
neutral primary button while ConfirmDialog confirmed the same kind of act
in red. The prop is forwarded now, and the call sites whose act takes
something away pass it. A guard holds every confirmation whose slot names
a destroying act to the destructive style; resets stay neutral.
The timeline's Segmented, the document sources' SegmentedChoice, the
medication detail's SegmentedToggle and the icon-only ViewToggle were
four implementations of the same control in two looks, two at 36 px on
a phone. ui/segmented.tsx replaces all four, plus the hand-rolled range
rows above the lab and custom metric charts, the mood dimension window
and the inventory quantity mode: a muted track with the chosen segment
raised, radio semantics with roving focus by default, pressed-button
semantics for view switches and form sheets, icon-only segments, and
44 px per segment on a coarse pointer. A test holds the definition to
that one module.
Thirty-three hand-rolled underlined text links and buttons (Open
schedule, Select all, View all, Retry, Learn more, the device-code copy
action, ...) carried three different focus rings, none on half of them,
and a 16 px target on a phone. They now render through one InlineAction
primitive: tone primary / muted / foreground, size xs / sm, a focus ring,
and min-h-11 on a coarse pointer so the desktop layout does not move. A
structural test refuses a new hand-rolled copy.
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
About forty compact rows and seventy cards still offered a retry on a
spent read budget and drew an error card on a refusal, while a third
primitive (ReadFailureRow) did the right thing at eight places.
QueryErrorRow and QueryErrorCard now take the query's `error` and read
it through readFailureKind, which moves to src/lib/api next to
ApiError: a refusal (403) draws the surface's refused state or nothing,
a spent budget (429) says so without a retry, an ended session (401)
says so and offers sign-in instead of going blank, and only a real
failure offers the retry. ReadFailureRow is gone and every site passes
its error; a guard test fails on a site that does not.
The coach read strip keeps its card in the error state and holds one
body height across loading, failure and a one-line read.
The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.
- Both grant routes now require `confirm: true`; anything else writes
nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
and the data that would go there, with one Agree button, in Settings > AI
and on the Insights overview while AI is closed by the missing consent
alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
since, recipients); the settings card renders active-since and the
withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
confirmation, and an integration test that a fresh operator-key account
gets no background AI text until it agrees.
- Composition amounts show only the decimals they carry, so 100 µg no
longer reads as 100.000 µg; the remove button stays on the amount row.
- AUDIT-C remove controls are icon-only, so they no longer spill out of
the card or cover the earlier answer.
- Life-circumstance labels keep their colon when they wrap, checkbox
groups sit in two columns and the weekly-minutes sentence ends.
- Link chips get a 44 px hit area for their remove control.
- The AI usage level reports a failed save with a sentence and the
error id instead of a bare "Error".
- The Dawarich home-country and admin refresh-gap labels sit above
their fields.
Settings, AI gains one card with the three levels, each with what it does
and the server's estimate of tokens per day, labelled as one. Levels above
the operator's ceiling are shown disabled with the reason; a capped choice
is named and kept; a stored choice can return to the server default. The
automatic document read says when the frugal level rests it. Admin, Coach
gains the ceiling, the default and the sync gap, and the provider readout
the accounts per level. Strings in all seven languages.
The admin card's save confirms with a success toast like its neighbour
assistant-section; it is a settings write without counts, so it is pinned
in the success-affordance guard at one.
Refs #1217
Cmd/Ctrl+K or the search control at the end of the top bar opens a
combobox over a client-side index built from the lists the navigation
already renders: pages, Insights pages, every Settings section and card
(deep-linked by a new anchor prop on SettingsCardHeader), the Appearance
sub-pages, module switches and a few actions. Entries follow the module
map, the Coach capability, the admin role and a shared record's grant;
inside somebody else's record no cards, switches or backup are offered.
Ranking is prefix, then word start, then substring, then a small edit
distance, then subsequence, with a short multilingual synonym list. The
empty state shows recent places (per account, in localStorage) and the
main actions. The palette is its own lazily loaded chunk; the top bar
shows a search field where it has room and a magnifier otherwise. The
module switch mutation moves into a shared hook used by the Modules hub
and the palette.
The page was one card titled like the page, holding seven grey panels
with three primary Saves, each left-aligned mid-card. Provider, fallback
chain, response timeout, document reading, consents and the check row
are separate Settings cards now, each with one action row at its foot.
The chain rows keep the name on its own line on a phone, and the cards
below the provider card wait for its reads so they do not jump.
Thinking depth (Off, Low, Medium, High) sits under the model in the
Coach's quick settings and in Settings → Coach. It renders the resolved
block from the account payload: levels above the operator's cap are
disabled and say so, the field locks with one sentence when the
operator switched reasoning off, and Off reads Minimal where the
provider cannot stop thinking. The provider's own reasoning select
leaves the quick settings and says the Coach has its own.
The operator gets a switch to allow deeper thinking and a select for
the highest depth. The memory list shows where each entry came from,
lets it be edited in place, and is linked from the quick settings with
its count. The deep-linked data section no longer draws a ring.
The provider and model selects in the Coach's settings already lock while
a write is in flight; the reasoning select did not, so a second PATCH
could race the first and one change could be lost.
The model fields carried the form label's colon and the lookback picker
an uppercase section-style label and a fit-content 36 px trigger. All four
fields now share one unadorned label and the full-width 44/40 px control.
One select under the model field of the Local and OpenAI-compatible
provider forms: Default, Off, Low, Medium, High, with a one-line hint
that Off leaves the answer budget for the answer itself. It reuses the
NativeSelect, label and hint layout of the field above it, seeds from
the stored entry, and saves with the form. Strings in all seven
locales.
Refs #1126
The placeholder said "Default (~120)" while most surfaces fall back to
60 s and the briefing to 180 s. The placeholder and the help line now
interpolate both numbers from the constants the server applies
(PROVIDER_DEFAULT_TIMEOUT_MS, AI_BUDGETS.comprehensive.timeoutMs), in
all seven locales, and a test fails if a locale writes a number of its
own.
Refs #1090
The response-timeout setting reached the briefing, the status notes and
the Coach, which threaded it by hand. Document reads, document
extraction, lab OCR and lab staging called the provider without a
timeout, so every client fell back to its own 60 s literal and a slow
self-hosted model was cut off however high the setting stood.
The setting now rides on the provider instance. resolveProvider,
resolveProviderChain and resolveProviderForTest stamp the record's
aiResponseTimeoutSeconds on everything they return, and each wire
client reads its ceiling through callTimeoutMs: the setting, else the
surface's own timeoutMs, else 60 s. A call site no longer has to pass
it, so a new one cannot forget it. The connection test runs under the
same ceiling a read does.
The Coach nudge tick and the reaction line keep their short ceilings
(timeoutPolicy: "surface-ceiling"): the tick runs accounts one after
another under a shared budget, the reaction line's claim lease is
shorter than the largest setting, and both have deterministic copy
that stands in.
/api/auth/me publishes the effective value as
ai.provider.responseTimeoutMs, so a client can size its own request
timeout from it instead of guessing.
Refs #1090
Three cards put a full sentence into the SettingsCardHeader status slot
(mood reminder, low-stock warning, the app-log refresh button), which
does not wrap: the row grew to 1000px and /settings/notifications
panned sideways at every width. The sentence moves into the card body,
and the slot is capped at the row so a long status wraps instead.
Also on a phone: the provider-chain add control wraps, the report
picker's standard-template action drops under its sentence instead of
running past the card, and the dashboard layout rows put their switches
and arrows on a second line so the tile name is no longer cut to five
characters.
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.
The AI consent card turned any failed read of the receipt (a rate limit, a
network drop) into "Withdrawn" and offered a grant. It now says the read
failed, with a retry. The Coach history note is one sentence.
Settings, AI says which AI features the server's operator switched off and
shows the one setup hint while no provider is configured. The consent
withdrawal says what it deletes and what stays, and a document-reading
consent on file gets its own withdrawal. Regenerate follows the briefing
capability and the connection test names an operator refusal.
The Coach memory (facts and stored conversations) is never gated on the
Coach: Settings, Coach always shows it, and Settings, AI shows it while
the Coach page is out of reach, whenever rows exist.
Onboarding offers AI only where it can be set up here: the done-screen
panel and the getting-started row go away while the operator has AI off
or inside somebody else's record.
The Anthropic vision check listed the Claude 3 and 4 families by prefix,
so a key pinned to claude-sonnet-5, claude-opus-5 or a Fable model was
told the provider cannot read images, and lab and document OCR refused
the upload even though the model handles image and PDF blocks.
Replace the per-release prefix list with a rule: any claude-<tier>-<major>
slug from the 4 family upward, plus every claude-fable-* model, counts as
vision-capable. Claude 3 handling is unchanged. The Settings preset list
gains claude-sonnet-5 and claude-opus-5 alongside the existing entries.
- Vorsorge dashboard tile title joins the dashboard heading ladder
(titleAs=h2 like its sibling tiles).
- A user's own note on the mobile measurement card renders in the
foreground colour; user data is never muted.
- The compact Vorsorge list row expresses its density on the Card
instead of overriding the content slot's padding with an off-scale
step.
- The AI-consent card holds its space with a skeleton while the receipt
loads instead of popping in and shifting the section below.
POST /api/consent/ai/web now accepts an optional { intent } body. Without
one (the AI-settings mount heal) a standing revocation still blocks the
mint; intent "affirmative" is the user's own consent act and may
supersede an earlier revocation, exactly as a first grant would. The
consent card offers the explicit grant control whenever consent does not
stand, and the audit row records which intent minted the receipt.
The document auto-read opt-in already declared "affirmative" on enable;
the settings-mount heal posts no body. Both stay as they are.
Bring the remaining settings and dashboard surfaces onto the shared
anatomy: the API and MCP revoke buttons become the destructive variant
instead of an outline hand-tinted red; the runtime-actions row places
its raw-data setting and status lines above the actions, not under them;
the account section paints its card and header while loading instead of a
lone spinner; the share revoke becomes a right-aligned row action rather
than a full-width alarm strip per link; the recent-workouts tile and the
MCP and notifications empty states compose Card and EmptyState instead of
hand-rolled bg-card shells.
The three AI configuration reads each returned null on a non-OK response,
so a failed read painted the default provider form as if the account were
configured that way. Throw on a non-OK response so the query reports its
error, and render a QueryErrorCard with retry when any read fails. Test
watched the misleading form render first, then pass on the honest error.
The central-codex and auto-read consent notes are safety copy the user
has to read before enabling a data-sharing path, but both rendered as
muted extra-small meta. Promote them to foreground text-sm and keep only
the decorative icon muted.
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.
Names LiteLLM, OpenRouter and vLLM, states what it does not share with
the OpenAI provider and why, and keeps the Local provider's own section
intact — it still speaks the same wire and remains the right pick for a
model on your own machine.
Its own form with its own three fields, its own entry in the provider
dropdown and in the fallback-chain editor, and copy in six locales that
says plainly where the OpenAI key does not go.
The chain editor offers the entry but the default chain does not carry
it: it resolves only against a base URL somebody saved.
The read endpoint reports the gateway's base URL and model, and the
bearer as presence only. OpenAPI regenerated.
Settings → AI "About me" had one Clear control wired to
`save.mutate({ aboutMe: "", allergies: "" })`. One tap, no dialog, and
the allergy line went with the note — while the toast it raised said
"About-me note cleared", which is what the control looks like it does.
The allergy line is the first thing a clinician reads and the column is
overwritten in place, so there is nothing to restore afterwards.
Scope the control rather than only gating it: it now writes `aboutMe`
alone and omits `allergies`, which the PUT leaves untouched by contract,
so the payload cannot carry the allergy line away even if the dialog is
ever dropped. Clearing allergies stays possible in the allergies field
itself, which is a deliberate act on the thing being emptied. The
confirmation on top guards the note.
The same one-tap shape ran through thirteen other controls that
overwrite a column or drop a credential with nothing to restore from.
They route through the shared confirmation now: both Codex disconnects
(personal and the instance-wide shared account), the AI server key, the
profile photo, the threshold reset — all, per metric, and the override
switch, whose off position performed the same irreversible delete from a
control that reads as a display toggle — the source-priority order, the
dashboard layout, the Insights overview, a single target range, a
remembered Coach fact, a past Coach plan, a logged cycle day and a custom
cycle symptom.
`ConfirmButton` moves to `components/ui`, since it is no longer a
settings-local helper, and gains `ConfirmDialog`: the same dialog without
a trigger, for destructive controls that are not buttons. A switch has to
stay on when the dialog its off position raised is cancelled, and a
control that could not reach for the primitive reached for nothing —
which is a fair part of why those were the ones that shipped unguarded.
Copy lands in all six locales.
The settings mount fires an idempotent consent heal so an account that
predates the gate does not fall into the no-key state. It looked only for
an active receipt, so a deliberate withdrawal — which sets revokedAt —
read the same as never having consented, and the next visit to the AI
settings granted again, writing an audit row for an act the user never
performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the
grant; one undone by a page load is not a withdrawal.
The mint now distinguishes the two callers. The mount heal grants only to
an account with no consent history at all and leaves a revocation
standing; switching document auto-read on is the consent act itself and
may supersede an earlier revocation. The revocation is re-checked inside
the transaction so a withdrawal landing mid-mint is not overwritten.
The revoke endpoint has existed since v1.4.40 but only the native client
called it: on the web, consent could be given and never taken back. The
AI settings now carry the standing decision and the control that changes
it, in all six languages.
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.
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 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.
The comprehensive briefing warm/cron path wrapped generation in a fixed
130 s outer abort, but the per-user response-timeout setting accepts
10-600 s and the AI settings tell users to raise it for slow local
models. Any account whose response timeout was >= ~130 s had every
comprehensive warm clipped by the warm bound, not the provider, so the
generation never landed: no cached briefing row was written and the
dashboard fell to the hard empty state.
Size the warm outer abort from the resolved effective timeout
(`aiResponseTimeoutSeconds * 1000`, falling back to the comprehensive
surface budget) plus a fixed headroom for feature extraction, prompt
assembly, and the cache write. A raised setting now extends the warm and
nightly budgets instead of being silently capped. An absolute ceiling
(the 600 s write-time maximum plus the headroom) keeps a wedged call from
pinning the worker while staying above the largest value a user can set.
Both the nightly loop and the forced on-visit warm read the per-user
setting and use the derived budget.
Make the empty state more useful: record the upstream HTTP status on a
failure marker and classify it (timeout / auth / rate-limit / provider /
format), so the "couldn't generate" state points at the right lever —
raise the response timeout for a timeout, re-check the provider for an
auth rejection — instead of one generic line. Keep-last-good behaviour is
unchanged.
Align the response-timeout field copy with the actual default budget
(~120 s, not ~180 s) across the placeholder, the OpenAPI description, and
the inline note.
Introduce one canonical "what this is / is not, not a medical device"
statement plus a layered, calm disclaimer set, and apply it on the
surfaces that earn a context note.
- New shared `MedicalDisclaimer` component renders the copy as plain
React text (no markdown renderer, per the XSS posture). Variants:
intendedPurpose, coach, insights, labs, medications, emergency,
dataPosture.
- New `selfDescription` i18n namespace across all six locales: the
intended-purpose line, the per-surface notes, the emergency note, and
an explicit data-posture statement (self-hosted, data stays on your
instance, encrypted at rest, BYOK / local model, no third-party
analytics).
- Apply once per surface, layered not plastered: a calm standing line on
the Coach new-chat hero, one framing line in the Insights layout shell
(covers the briefing and trends), a labs note (ranges vary, only a
clinician interprets), a neutral medications note, and the data-posture
line where the provider key is configured. Mental-wellbeing keeps its
existing crisis copy; records keep their existing note.
- Extend the Coach system prompt (EN + DE) with a self-description ground
rule so the Coach answers "what are you / where does my data go"
honestly and stays non-diagnostic.
App-authored copy was already observational; the few diagnose/treat
tokens are negations, idioms, or framed as the user's own self-reported
statement, so no softening was required.
Add isError branches with a retry affordance to the mood, illness, and
vorsorge lists so a failed read no longer renders as "no entries yet"
and invites duplicate re-entry, matching the labs and cycle pattern.
Make the admin backups table reachable on a phone with a card fallback,
name the admin language and timezone selects and the AI raw-data switch
for screen readers, and replace the touch-unreachable title= help on the
glucose clinical panels with a tap-capable info popover.
Route the password-strength label through the AA-tuned semantic tokens,
underline the register login link so it is not distinguished by colour
alone, give the labs load error alert semantics, and explain why the
target editor Save button is disabled with aria-invalid and a live
alert.
Code-review and design follow-ups before the v1.22 cut. No behaviour change
beyond the items below.
Coach / insights:
- Coach SSE route: a non-provider error raised inside the open producer (e.g. a
DB failure persisting the reply) now records on the wide event and forwards to
GlitchTip instead of masquerading as a provider outage; it still surfaces an
error frame since the stream is open.
- Lab ↔ outcome correlations: hedge faint-tier pairs ("a faint hint, if
anything … too small to lean on") so the narrated confidence never outruns the
effect, matching the daily engine.
- Reminder sweep: mint the plan-review reminder and clear the plan's reviewDate
in one transaction so a half-applied write can't duplicate the reminder.
- Fold the two raw provider-fallback runners onto one shared chain walker.
Design / accessibility:
- Lift sub-44px tap targets to the 44px mobile floor (suggested-action card,
Coach bubble affordances), brighten the resting timestamp clock for non-text
contrast, and route the lab chart height through the shared constant.
- Replace ad-hoc arbitrary type sizes with the scale token and static size-11
icon buttons with the named icon-lg variant.
- Settings: add the missing About card-header descriptions, normalise the
low-stock numeric widths, and relax the notification-status rows from nested
cards to a divided list.
Housekeeping:
- Drop three unused exported types (and the now-orphaned source const), re-stamp
v1.21.5 comments to v1.22, and register the live-judge eval harness as a knip
entry so the script-only module resolves.
Add three per-user AI settings, all configured in the app (no new env vars),
behind one migration (0198):
- Response timeout (seconds). A per-user upstream timeout threaded onto the
provider call. A local/self-hosted backend can take more than the legacy
60 s default to load a model on the first request, which timed the Coach
out before the first token. Defaults to a generous ~180 s when unset;
validated to 10–600 s.
- True token streaming for the local OpenAI-compatible client. The client now
POSTs with stream:true and parses the SSE delta chunks, emitting tokens as
they arrive, with a per-idle-gap timeout instead of a whole-call ceiling and
a graceful fallback to the buffered path when a server cannot stream. The
Coach route runs the provider call inside the response stream behind a
keepalive heartbeat so a reverse proxy no longer idle-drops the connection
during model load or generation. Grounding, refusal, and sentinel-stripping
still run on the complete reply before any token reaches the client.
- Dedicated document-scan (Lab-OCR) provider. Off by default — the OCR path
uses the main provider chain. When enabled, lab-report processing resolves a
separate provider/model/key, with a hint when the chosen model is not
vision-capable. The new key column is AES-256-GCM at rest and registered in
the encrypted-columns registry and the rotation script.
i18n: EN + DE translations for the new Settings → AI strings (other locales
carry interim copies to keep locale parity green).
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.