A batch without a trigger only marks the dashboard snapshot stale since
v1.42, so a snapshot an earlier spec left for the account painted the day
final before this journey's own night counted. Foreground evicts.
Since an empty mood list carries the one add control and the header drops
its own, the journey clicked a header button that unmounted as the list
resolved. Start the profile without entries, assert the empty-state add,
then the header add once an entry exists. Both carry a data-slot.
An empty measurement list carries its add action in the empty state,
not the header, so the write-grant journey waited on a header button
that never appeared. It takes whichever add control the page shows, the
way the other add flows already do.
The mobile consistency check pinned the language select to the date of
birth's grid row. It now asserts the select sits in the display card,
outside the profile form, while the date of birth stays in the form.
The header add disappears once an empty Impfpass has loaded; the
journey now waits for a record or the empty state and clicks whichever
add control is on screen.
A list that is empty now carries its add action in the empty state
instead of the header, and on the phone shell the dashboard's add opens
the capture sheet. The overflow, mood timestamp, measurement flow,
onboarding tour and delegated-write journeys accept either control; the
measurement list's empty-state add gets a stable data-slot for that.
On the phone shell the header's 'Add' opened a dropdown while the plus
in the bottom nav, one thumb below, opened the capture sheet. The header
button now opens the same sheet there; the desktop rail keeps the
dropdown. The onboarding tour anchors on whichever of the two is laid
out.
The theme-color tags followed the operating system's scheme, while the
app's theme is its own setting and defaults to dark: a phone in light
mode showed a light address bar or status bar over the dark app. The
client now rewrites both tags to the theme the page painted. The
toaster read Sonner's "system" for the same reason and now gets the
resolved theme as well.
Below 600 px Sonner stacks toasts 16 px above the bottom edge, which on
a phone is on top of the bottom bar: the "new version" hint covered the
tabs and the add button until dismissed. A mobile offset lifts toasts
above the bar and the home indicator.
pwa-mobile.spec.ts covers the hint's position and its Reload, a bottom
sheet standing on a stand-in keyboard, 16 px fields in portrait and held
sideways, and the sign-in card held sideways.
Without a connection a signed-in page opens the worker's offline page:
signed-in pages carry `private, no-store` from the proxy and are never
cached, so this is also what an offline launch of the installed app
shows. The page now offers a real 64 px retry control, reloads by itself
when the connection returns, follows the device's light or dark scheme
and keeps clear of the notch and home indicator. The worker's comment on
the page cache says plainly that only public pages land in it.
The single reload after a stale-shell chunk failure moves into
src/lib/pwa/chunk-reload.ts and now runs in the root error boundary too,
and it recognises a failed dynamic import as WebKit and Firefox word it.
pwa-offline.spec.ts runs in the service-worker project and proves the
offline strip, the offline page and the automatic recovery against the
real worker.
On /insights/mood the line chart used to paint first and the calendar,
whose aggregate is the slower read, pushed it down when it landed
(CLS 0.16 at 390 px on a cold load). The page now holds both on
final-height skeletons until the chart's read has settled and the
calendar's has landed, then shows them in the same frame. The calendar's
height is written in container-query units, so its box is the right size
from the first paint, in the skeleton and the painted grid alike. A
server wrapper prefetches both reads in parallel (time-boxed, skipped
under a record switch, off when SSR prefetch is off), so on a warm cache
they are in the first HTML. The route and the prefetch share
`moodInsightsWire`, so the seeded cell is the route's shape.
The "Log a mood entry" and "Take a mental-wellbeing check-in" text links
under the heading are gone. Logging is the header plus, which opens the
mood form in place; the check-in keeps its own place in the navigation.
The plus is one shared component, `SubPageAddButton`, now also behind
the measurement pages' capture link and the workouts page, which drew
its own. An audit of every Insights page found no other text add-button
of this kind; the remaining buttons are empty-state calls to action.
A sweep keeps it that way.
`e2e/v142-qol.spec.ts` covers all three surfaces of this change set.
The wrench beside Upload opens Settings, Appearance, Documents, the way
the illness journal offers its display options: preview cards or a
compact list, and months stacked as blocks or flowing across the full
width with the month name inline above the document that opens it. The
choice is stored per account in `users.documents_layout_json` behind
GET/PUT /api/documents/inbound/layout (preserve-when-absent, module
gated, the read delegable like the rest of the vault), carried in the
account-settings backup and cleared by the data wipe.
Deleting from the vault now always asks first. The bulk bar's Delete and
a focused card's Delete key both go through one confirmation on the page,
and the detail sheet's Delete opens its own; before, all three deleted on
the spot with only an undo toast behind them.
The selection bar is two tight lines: the count with an X at its right
edge, then one action row with Delete alone at the right in the
destructive style. Below 1024 px the verbs are icon-only at the 44 px tap
floor, so the row stays one line at 360 px. The bar carries the
selection-bar slot the Coach launcher already steps aside for.
The day route sends a night's length in min and a device score in score.
The day read the night as a bare 412 and the score in the wire's word;
it now reads a duration and the reader's word for points. The phone
sheet's header takes the short date, so a long weekday and month no
longer cut off the year at 360 px.
On a 13-inch laptop the rail's list overflowed and a classic scrollbar
with arrow buttons appeared inside it. The rows now step down by window
height (gaps close at 940 px, 36 px rows at 820 px, 32 px at 740 px), so
1366 x 768 fits every entry collapsed and expanded. Where the list still
scrolls, the native scrollbar is hidden, fades mark the edge that has
more, and a focused row scrolls clear of the fades.
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.
The timeline now opens a day through the shell's day layer (openDay) and reads ?day= with its parser instead of a copy of both; the capture picker's life-event form starts on the picker's defaultDate like the other forms.
The opt-in timeline module gets its page (#613). At 768 px and up the
record's lanes (life, conditions, allergies, medications, vaccinations,
doctor and procedures, labs, documents) are drawn in one SVG on a shared
time axis, with a year, month or week grid, open periods ending in an
arrow, a dashed lead-in where a start is unknown, dose marks and pauses on
the medication they belong to, a Today line and a selection line. Up to
three neutral value lines run below as monthly means, chosen in a menu.
Recharts stays for every value chart; this surface is plain SVG like the
heatmaps.
All geometry is pure (timeline-geometry.ts): window, scale, row packing
and label placement through one occupancy map, so no label overlaps
another label or a bar, none runs past the card edge, and labels that do
not fit are left out rather than squeezed. The width comes from a
ResizeObserver, so a docked day panel re-runs the layout.
The bar under the chart names the selected month's entries as chips, the
month's means and "Open 3 Jan.", which puts ?day= in the URL for the day
layer. The chart is one tab stop: arrows move the selection by a day, a
month or a year with the zoom, Home and End jump to the ends, Enter opens
the day. A visually hidden table lists every entry for screen readers.
The legend says once that stacked means at the same time, not cause and
effect.
Below 768 px the timeline is a chronicle, newest first by month or year,
with an Ongoing block for what has no meaningful start, rails beside the
rows an episode or course spans, and empty stretches named as such.
Switching the module on in Settings opens the readiness inventory: each
lane carries, is thin or is empty, with one link per gap and the verdict
as a count. The same inventory sits as a dismissible card on the timeline
while a gap is open, and under Layers as "Data coverage".
Life events are entered in a sheet (title, kind, a date to the day, month
or year, an optional end, an optional note) from the timeline, the
capture picker and the dashboard's add menu; one surface line maps the
capture kind to the timeline module, and it is offered in one's own
record only. Deleting asks first.
Server routes are built in parallel; the e2e journey answers them with the
contract's shapes. Copy in all seven locales; the timeline and lifeEvents
release-pending prefixes are gone.
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.
- Load each settings section on its own: the section route imported all
twenty-one statically. A preload starts the chunk beside the record
gate's read, and the loading state paints the settings card shell.
/settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
run on each boot is plain TypeScript now, held to the published schema
by an equivalence test over good and broken blocks. The canonical unit
table moves to its own module, so the display transform no longer
pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
instead of the smooth scroll that lost the end, then follow late
layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
across on a phone and three or four on a desktop, with the month
headings kept.
- Add the admin card for the measurement table maintenance: a status
read on the maintenance route, one calm confirmation, and the run's
state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
watched at 403, aggregate cap 4335 with the reason written down.
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.
The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.
The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.
Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.
When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.
The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.
The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.
Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
The running status line now carries its seconds right after the text, in
the line's own muted tone, instead of at the far edge of the column. The
live count and every finished round read "4 s", the same spacing as the
settled summary, with a no-break space so a round never wraps between
number and unit.
Reply pills (follow-ups, clarifying choices, memory and plan decisions)
sit directly under the answer, and the action row is the last thing in
the column. Beside a pointer the row stays invisible until hover or
focus; above the pills it left an empty band between an answer and its
replies. Hovering or focusing the message still reveals the row without
moving anything.
Keyboard focus in the Coach panel is a solid ring in the neutral
text-field focus tone, never the purple ring: the pills, Undo and Change
merge it over the button primitive, and the hand-built controls carry it
directly. A structural test keeps the purple ring out of the panel.
A disabled native select now looks disabled, like Input and Select, so a
thinking depth locked by the operator no longer reads as a live choice.
In Settings, thinking depth takes the shape of Tone and Reply length
beside it; a memory entry's Forget is an icon like its edit; the
operator's highest-depth label sits on the switches' left edge.
The chat POST is answered from a stream the spec feeds, so each live
state is observed: the status line moving from thinking to fetching to
summarising, a table previewed before the first token, the trail closed
in every state and after a reload, at 1440 and 390 px in both themes.
Clarifying choices, memory and plan decisions answer through pills, an
entry in the memory list is edited in place, and the thinking depth
shows the operator's cap and lock. The dialog spec reads the pills
where it read the card.
The panel groups conversations by the browser's local day, so the e2e
fixture's "an hour ago" landed in yesterday when CI ran just after
midnight. The turn-steps test reads its markup by splitting rather than
with a replace CodeQL reads as an incomplete sanitiser.
The today check now reads briefingGeneratedAt from the cached payload
and treats a payload without it as not from today. The integration
and e2e fixtures that seed a fresh briefing write it, as every real
generation does.
Below md the lines under the 80 px dial (the score delta, how long it
has held, what it rests on) were capped at the dial's width. In German
they broke into three to five rows each, and beside a short lead that
column set the height of the read row and left a gap above the facts.
The score column now dissolves into the grid below md: the dial stays
the top-right cell beside the lead, and its meta lines take a
full-width row right after the read row, flush with the dial's edge,
one line each. The facts move one row down. From md up the column is
the same flex column as before (desktop captures are pixel-identical).
The Today overview e2e renders German at 390 and 360 px with a short
and a long lead and asserts the dial size, one row per meta line, the
lines flush with the dial, the meta row right after the read row, and
exactly one row gap above the facts.
Below md the score ring sat centred under the Today facts as a 168 px
dial, where the bottom navigation covered it. The hero is now one
grid with two arrangements: on a phone the ring is a compact 80 px
dial at the top right beside the lead and signal line, flush with the
card's trailing edge, and the Today facts run the full card width
under both; from md up the ring spans both rows of the trailing
column exactly as before (desktop captures are pixel-identical).
Without a lead the facts take the leading cell, so the ring never
sits beside an empty row.
ScoreRing gains a `responsive` size (80 px, md:168 px) whose label is
left out below md; the ring keeps its aria-label and the surrounding
link names it. The meta lines under the phone dial are capped to its
width, so the lead keeps at least 180 px beside it at 360 px.
Each fact now stacks its muted label above its foreground value on
the reading edge at every width, instead of a label-left /
value-right row that broke long values into a ragged right-aligned
block. Rows stay at the 44 px tap floor below md.
The Today overview e2e covers 1440, 390 and 360 px in both themes
for an AI lead, a long AI lead, an AI-off day and a quiet day, and
asserts the dial size, its place beside the lead, symmetric insets,
full-width facts and stacked 44 px rows on a phone.
The docked panel was part of the page, below the top bar. It is now a
column of the shell beside the content column, portalled through a new
side-panel slot: it runs from the top of the window to the bottom, flush
right, and the top bar ends at its left edge. Shut, the panel is gone
entirely and the top bar runs to the window's edge. The panel's header row
is the top bar's band, so the two bottom borders draw one line; the panel
takes the sidebar surface and border, like the rail on the left.
The top bar says where the reader is on desktop: "Coach", then the open
conversation's title, truncated. The panel toggle is its last item, 12 px
from its trailing edge, so it stands against the open panel and at the
window's edge when the panel is shut; it carries a tooltip and is 28 px
beside a fine pointer, 44 px with touch.
New chat is a round 48 px primary button with a plus, 24 px in from the
panel's corner (16 px in the phone sheet), with a tooltip. The top bar's
action slot and the new context and side-panel slots share one portal
helper.
Focus opens the details tooltip, and the click that Enter or Space then
fires toggled it shut again. A click with no pointer-down before it is a
keyboard activation and now always opens; a tap or a mouse click still
toggles, and Escape closes it.
The panel header buttons shrank to 28 px from md and the conversation rows
to 36 px from sm, so a touch tablet wide enough to dock the panel got
pointer-sized targets. They now shrink only under pointer-fine: 28 px
header buttons, a 36 px gear in the drawer, 36 px rows and row menus
beside a mouse or trackpad, the 44 px floor with touch at any width.
A unit test refuses a width breakpoint on a control size in the three
panel files; an e2e case measures the docked panel at 1280 px with touch
and with a fine pointer.
The tokens and model no longer sit as right-aligned text under a Coach
answer. The row is one left-aligned group: copy, read aloud, try again, a
details icon, then the time. The details icon is a focusable button whose
tooltip names the model and the token count; it opens on hover, on
keyboard focus and on a tap, and a second tap, a tap elsewhere or Escape
closes it. Strings in all seven locales; the joined tokens-with-model
string is gone.
The toggle shows the panel-right open glyph while the panel is open and
the close glyph while it is shut, both mirrored so the chevron points the
way the panel moves on a click. Docked and open, the toggle keeps the
panel's width as its right margin, so it sits beside the panel's leading
edge and travels with it (200 ms, none under reduced motion). Labels and
aria-expanded are unchanged.
The Coach page cancelled the shell's container padding with negative
margins, so <main> kept its reserved scrollbar gutter and painted a dead
strip to the right of the panel on classic-scrollbar platforms. The shell
now renders /coach full bleed: no centred container, no padding and no
reserved gutter, and the page fills what <main> leaves below the top bar.
A refusal card (shared record, module off) keeps the padded frame.
The panel header takes one 12 px inset with a gap of 8 between title and
icons, a text-lg title, and 28 px icon buttons beside a pointer (44 px on
touch). Conversation rows take the control radius and a 36 px pointer
floor.
The muted line under the lead repeated what the lead had just said: a lead
about blood pressure sat above "Your latest blood pressure is sitting in
the optimal band · ↓ ~10 mmHg…". The web client only dropped the headline
when the lead quoted it word for word, and other clients had no rule at
all. The digest now carries `signalLine`, built next to the lead: when the
lead already talks about the top signal's metric (matched in every shipped
language) or says its headline, only the delta remains, and with no delta
there is no line. The hero renders the field verbatim.
/coach?settings=data opened the conversations sheet from the first
render. The server and hydration renders do not know the viewport and
take the narrow layout, so on a desktop the sheet was open until the
docked panel replaced it; only the batching of the hydration re-render
kept it off the screen. The deep link now opens the sheet only once
hydrated on a viewport that does not dock the panel; on a docked one the
panel is revealed as before.
The desktop deep-link spec records every mount of the sheet from the
first byte and expects none; a phone deep-link spec covers the sheet
path and serves as the watcher's positive control.
The vault's filter row gains a procedure facet beside the condition
facet. It lists the record's procedures and surgeries that hold at least
one live document, headed the way the visits page heads them (the
reason, else the practice, else the kind) with the body site and date
beneath, and narrows the list through the existing `encounterId` filter.
Same control, placement and clear behaviour as the condition facet; the
choice rides the URL as `?encounter=`, and an upload dropped into the
filtered view is filed against the procedure.
The usage read publishes the choices as `linkedProcedures` (additive),
decrypting only the label columns, and returns none when a sharing
grant does not cover the visits section.
On a phone an active facet now gives up label width before the search
field does, and the search keeps room for its icon and padding.