New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.
The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.
The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
The written date in the day's header is now the way to any other day. A
click (or Enter, or Space) opens a month calendar beside it: the day on
screen is selected, every day that holds anything carries a dot read from
GET /api/day/index for the month shown, nothing past today can be picked,
and a Today button sits under the grid. Picking a day swaps it in like a
step, without a new history entry; Escape closes only the calendar and
focus returns to the date. The header keeps its one line and gains no
icon; the arrows stay. The same popover serves the docked column and both
sheets, with 40 px cells for a finger.
On /coach the day now docks beside the conversation like everywhere else,
right of the conversations, instead of opening as a sheet. A neighbouring
panel can fold the docked day to its edge without moving focus
(yieldDay).
The day's count line reads "3 values, 2 entries".
Every bundle value that chained facts with " · " now reads as a phrase,
a comma list or a parenthesis, in all seven locales, and the em-dash
separators in the day, documents, correlation, sharing, environment,
vaccination and Apple Health import strings become sentences or clauses.
The day view's running list names the record first, with the dose in
the name and a life event's category as a chip, and puts a quiet day
count below: "Day 4, since 31 December" instead of a dot chain. Document
meta, location periods, vaccination rows and mood crosstab rows drop
their composed dots too.
A guard walks every bundle and fails on any " · " outside a reasoned
allowlist, and on an allowlist entry that no longer needs its place.
Beside All, 12 months and 3 months the timeline takes a range of the
person's own choosing, from and to, through the app's date field. Each
change is clamped softly: never past today, never before the record's
first entry, never inverted, never longer than fifteen years. On a phone,
where the chronicle has no zoom control, a range button opens the same
fields with a way back to the whole record. A range with nothing in it
keeps its fields instead of the empty-record notice.
The zoom and the range live in the URL (zoom, from, to), so Back and a
bookmark return to them; a parameter that does not read falls back to
All. The API accepts zoom=range with both ends, at most fifteen years,
and picks the bucket by its length: quarters from two years, months from
120 days, weeks from 42 days and single days below, a new day bucket
whose dashed bridge spans at most three missing days. A range borrows
the grid and the keyboard steps of the fixed zoom nearest its length.
The selection bar heads a day bucket with that day.
Each value line takes the colour its type carries in the measurement
list (a --chart-N token); where two chosen lines would share one, or a
type has none, the later line in the menu's order takes the first free
data token, and a sixth line with none left falls back to the
foreground. The swatch beside the name, the line, the dashed bridge,
filled and hollow points and a ring on the selected bucket all inherit
one colour from their group. The menu, the selection bar and the phone
chronicle mark each value with the same dot. The colour map moves to an
icon-free module so the timeline does not pull the list's icons.
The selection bar is a cross-section of one bucket and now says which:
its head names the quarter, month or week in full, its entries are the
ones that touch that bucket, and each mean keeps its readings count.
Values go through the day view's formatter, so sleep reads as a
duration and the unit preference applies; the server sends canonical
units, which the old formatter took for display units. Before anyone
picks a day the bar opens on the newest bucket it has something for in
the requested window (documents, which it never lists, no longer count),
and a short line says how to pick another.
The round button floated over the list's bottom right and covered the
last conversation, which the list offset with a pb-20 reserve. It is now
the first control of the panel header, left of the title, in the docked
panel and the phone sheet alike: 28 px beside a fine pointer, the 44 px
floor on touch, with its accessible name and a tooltip. The reserve goes
with it, and the header packs its touch-sized buttons tighter so the
title keeps its word at 390 px.
Deleting a conversation hides the row and sends the DELETE only after
the six-second undo window. The pending delete lived in a timer alone,
so a reload, a closed tab or an installed app sent to the background
inside the window dropped it without a request, and the conversation
came back. When the window did run out, the hide filter was released
while the paginated history still held the row, so it showed again
until the refetch landed.
Every surface that deletes a conversation (the Coach panel, the
conversations page, the settings memory card) shares one hook, and the
fix lives there:
- a scheduled delete commits early when the page is hidden, on
pagehide and on unmount, and the request is sent with keepalive
- a committed row leaves every list cache, head list and every history
search variant, before anything else changes, and stays hidden
- deletes not yet confirmed are kept in sessionStorage; the next page
hides them and sends them again, so its list read cannot bring the
row back before the old page's request lands; a 404 counts as gone
- a failed delete restores the lists and says so instead of the row
quietly returning
- Undo after the commit reports false, so the rail does not reopen a
thread that is gone
Every bucket with a reading gets its point; one with fewer than three
readings is drawn hollow. A short gap (one quarter, two months, four
weeks) is bridged with a dashed stroke between the two real points; a
longer one leaves the line open. The selection bar and the value table
for screen readers name each bucket's readings and say "no value" where
there is none. The phone chronicle names each bucket once, at its newest
month. The value menu takes up to six lines. The legend's causality line
is gone with its keys in all seven bundles.
From 1280 px the day sits beside the page, and its control at the top
left took it away entirely: after a page change there was no way back to
the day short of finding it again. The control now hides the day the way
a sidebar collapses. A narrow edge stays on the right, holding the date,
with a button that opens the last day again on this page or any other.
Focus lands on that button when the day collapses and on the day's
heading when it comes back.
The last open day is remembered per browser, bound to the account and
the record it was read in, and removed at every session end. The Coach
button keeps clear of the edge as it does of the open day. Sheets below
1280 px and on phones close as before, and Back still closes them.
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.