Skip to main content
Text direction, sentence splitting, and number/date/duration formatting all answer on every host, degrading to explicit fallbacks when Intl has no data — or when a stored locale reaches the UI as an underscore tag like "en_US".

Quick Start

1

Ask which way the text runs

direction is total: it returns "ltr" or "rtl" for any string, and never throws.
2

Split a streaming answer into sentences

sentences drives the screen-reader announcement policy, so only finished sentences are spoken.

Text direction

direction(tag) asks ICU first and falls back to explicit tables. The fallback is exported as directionFromTables(tag) so app code and tests can call it directly on hosts where the primary path answers first — an older Android WebView without Intl.Locale.textInfo.
The fallback table is only consulted on hosts where Intl.Locale.textInfo is absent — older Android and iOS WebViews. On every host that has textInfo, Intl answers first and the table is invisible. The list above was reconciled against Intl.Locale.textInfo across a ~50-tag corpus so the fallback path agrees with the primary path on locales like aeb, arz, rhg, and skr that a phone actually reports in the wild.
Why this matters on old WebViews. direction() answers from Intl.Locale.textInfo first; the table below it is only reached on hosts where textInfo is absent — the older Android WebView the fallback exists to serve. Before this fix, the table scanned every subtag for a four-letter one and matched latn inside -u-nu-latn, so an Arabic phone with a Latin numbering-system extension had its whole UI mirrored the wrong way. The fix reads position 1 only; the drift test in locale.test.ts now widens its ICU comparison to tags carrying extensions and enforces a non-vacuity floor so a host where ICU answers nothing cannot pass while comparing nothing.
"ltr" is the default for anything unrecognised: laying an Arabic UI out left-to-right is ugly, but laying an English one out right-to-left is unusable. The asymmetry decides the default.

How main.ts wires the locale

The composition root no longer hardcodes locale: "en". It reads the requested locales, resolves the string table against what actually ships, and derives direction from the tag the user asked for.
The split is honest, not aspirational:
The direction comes from the tag the user requested, not the resolved one. That is the whole point: the string table is English until more tables exist, but layout direction is decided the moment a device reports an RTL locale — no translation required.

MountDeps.locales — injectable, deterministic

requestedLocales(deps) returns deps.locales when supplied, most-preferred first, and otherwise reads the host’s navigator.languages.
Injecting locales is what lets a test pin RTL layout without touching the global navigator. When absent, requestedLocales prefers a non-empty navigator.languages, falls back to [navigator.language], and finally to ["en"] when there is no navigator at all.

Logical insets drive the composer geometry

layout/insets.geometryOf derives the composer geometry, and logicalInsets(direction, left, right) maps the physical edges onto logical padding — so RTL puts the safe-area on the leading edge.
geometryOf produces physical composerLeftPx / composerRightPx — correct as measurements. Writing padding-left: composerLeftPx directly puts the send button’s clearance on the wrong side of the screen in Arabic. logicalInsets is the mapping to padding-inline-start / -end; insets.ts deliberately knows nothing about language, so the adapter lives in locale.ts alongside direction.

Sentence segmentation

sentences(locale, text) asks Intl.Segmenter first and falls back to fallbackSentences(text).
The seam guarantee is why a caller can hold back the last segment until it is finished and resume from the same cursor: text.slice(0, n) and text.slice(n) recombine exactly.

Number, date, and duration formatting

Chat-list dates, message timestamps, tool-call durations, and transcript counts all come from format-intl, which asks Intl first and falls back to ASCII when a tag is refused. Every formatter shares the pattern of directionFromTables and fallbackSentences: an Intl constructor is memoised behind a try/catch, and every entry point has a non-throwing answer.

Quick Start

1

Format a number, a count, or a date

timeZone is a required argument on formatDate — pass null for the host’s zone, but it must be typed.
2

Format a relative or elapsed time

formatElapsedLocalised takes seconds: number | null; null, negative, and NaN all render as strings.unknownValue.
The “just now” threshold is 45 seconds exactly. formatRelative / formatRelativeLocalised return the localised “just now” string for a delta strictly less than 45 seconds (seconds < 45). At 45s exactly and beyond, they switch to "N minutes ago" (or the localised equivalent). format.ts and format-intl.ts share this boundary — it is pinned separately in both, so the two paths cannot drift. Pinned by "formatRelative's just-now threshold is 45 seconds exactly".

When does the fallback fire?

The underscore case is the one the fallback exists to serve first: new Intl.NumberFormat("en_US") throws RangeError, and locale.ts accepts underscore tags as a valid stored preference.

Rules

The "en_US" trigger. Every Intl constructor throws RangeError on an underscore tag, and locale.ts accepts underscore tags as a valid stored preference (tag.split(/[-_]/)). So the fallback is not a hypothetical old-WebView path — it fires whenever a stored preference reaches the UI as "en_US" instead of "en-US". That is the case the fallback exists to serve, first.
Determinism split with format.ts. ui/src/format.ts emits ASCII and ISO deterministically and is what the package’s own tests assert against; format-intl.ts is the path a renderer uses on top, and is additive — nothing in format.ts changes. Read this split before writing your own formatter; the header comment in format-intl.ts is the source of truth.

User interaction flow

1

Modern iOS — the primary path answers

An Arabic user opens the app on a modern iOS WebView. Intl.Locale.textInfo answers "rtl", and the composer, send button, and safe-area padding all mirror to the correct side.
2

Older Android WebView — the tables answer

The same user opens the app on an older Android WebView with no Intl.Locale.textInfo. directionFromTables("ar") answers "rtl", and the UI still mirrors correctly — the fallback is not a downgrade in behaviour.
3

Bad stored locale — the fallback answers

A user’s stored preference is "en_US" (an underscore tag from an older settings write). memo(...) caches null, formatDate returns the ISO date, formatElapsedLocalised returns "1h 02m", and the chat list still reads correctly. No row blanks; no timestamp goes missing.

Screen-reader hygiene

Two live regions, a non-live transcript, and a composer that names itself.

Two live regions — one polite, one assertive

The shell mounts exactly two sr-only live regions: Both regions are sr-only (visually hidden, still announced) and their aria-live attribute is pinned by tests — flipping assertive to polite is the mutation the pinning catches.
engineNotReady is announced assertively, on purpose. When the app boots into an engine that is not answering yet (booted.notReady), main.ts renders a <p class="row row-notice" data-tone="warning"> into the transcript and sets the same text on the assertive region — not polite. The user is about to type into something that cannot reply yet, so the warning must interrupt rather than wait behind queued status ticks. Flipping it to polite would let the user start typing before hearing that sending will only retry.
The notice text is a localisable Strings key added in PR #4672:
settingRejected(label) is announced assertively too. When Settings refuses a value — validateInput returns null, set returns false, or the persist throws — the field’s inline role="alert" node and the assertive live region are set to "{label} was not changed: that value was refused." The label names the setting because the field may already have scrolled off, and “was not changed” is the fact the user needs: the old value is still in force. polite would let the user re-tap Save before hearing what happened. See Settings Screen → How a refusal is spoken.

Secret rows

The OpenAI API key row (API Keys) adds four Strings members, and none of them ever contains any part of the key. secretPlaceholder is the field’s placeholder and its accessible name — deliberately not a masked stand-in like sk-…abcd, because the field is empty on every paint. It says “Paste” rather than “Enter”: on a phone nobody types an API key, so the instruction that matches what the user is about to do is the one written for them. secretStored(label) and secretCleared(label) are announced through the assertive live region — the same channel as settingRejected — because a control that changes nothing visible and says nothing has, for the user, done nothing. Neither ever carries any part of the key. actionClearSecret labels the Remove button, but the button’s accessible name is ${actionClearSecret}: ${label} (e.g. “Remove: OpenAI API key”), so a list of otherwise-identical “Remove” buttons is disambiguated for a screen-reader user navigating by control name.

Empty-chat strings

The empty transcript panel (Empty Chat) adds four user-visible strings, all constants — not functions. The action button on the panel is not a new string — it is strings.recoveryLabel("settings")“Open settings”, reused so the app names the destination the same way everywhere.

Empty-chat region

The panel renders as a real landmark, named through its own heading, and its words are read into the polite live region when the state appears or changes kind. The panel is <section role="region" aria-labelledby="empty-state-title"> — a landmark named through aria-labelledby, not aria-label. A label on a generic container replaces its contents in the accessibility tree (the same rule 3 already in a11y/names.ts), costing the reader the heading, sentence, and button; a name that points at the panel’s own heading keeps all three browsable.
emptyStateName(strings, view) composes `${title} ${body}${action?.label ? ' ' + action.label : ''}`. The composition root feeds this into the polite live region when the empty state appears or changes kind — so a fresh chat is not silent to a screen reader, and the “welcome → needs a key” transition (rule 3 of empty-state.ts) is announced. The action is part of the sentence because a reader hears the region before reaching anything focusable in it: “Open settings” arriving three tab stops later is a discovery, not an instruction.
An empty role="log" announces nothing at all — that is why the region and its announcement exist. A screen-reader user who lands on a fresh chat, or taps New chat, would otherwise be told nothing about a screen that has just changed completely.

The transcript is a log, not a live region

The transcript element is role="log" with aria-label={strings.appName} and no aria-live. It is mutated on every publish, so making it a live region would restart the reader on each token batch and no sentence would ever finish — the announcer pipes completed sentences into the polite / assertive regions instead. A test asserts the transcript does not carry aria-live; this is the one-line regression its own comment warns about.

The composer is labelled as the field, not as the button beside it

The message textarea’s aria-label is strings.composerLabel — never strings.actionSend. The Send button sits inside the same form control and shares its accessible tree, and a prior regression pointed the composer’s label at the button’s name, so a screen reader announced the message field as “Send, edit text”. A test asserts the composer’s label is composerLabel and not actionSend.

Starting a new chat empties the live regions

When the user starts a new chat, the shell empties the polite and assertive live regions in addition to resetting the announcer state. Resetting the announcer decides what to say next; it does not empty the regions themselves — so without this clear, the previous conversation’s answer lingered in the accessibility tree of an apparently empty chat. See Overview → New chat.

The sentence check runs at most once per ANNOUNCE_INTERVAL_MS, even when nothing new is spoken

The screen-reader announcer holds back unfinished sentences (see Sentence segmentation) and rate-limits the segmentation check itself. Two cursors advance without necessarily producing speech:
  • lastStreamAtMs — records that the check ran, so the next publish inside the interval is skipped.
  • spokenChars — advances even when the completed prefix trims to an empty string, so the same completed prefix is not re-checked forever.
Both are persisted across publishes. If either advances, the announcer returns a new state object; if nothing at all moves (74 out of every 75 publishes on a busy stream), the announcer returns the same state object by identity, so the caller can skip touching the live region entirely. Without these advances, a stretch where no sentence completes — a markdown table, a code block, a JSON dump, a bulleted list — used to leave the rate limit permanently open and re-segment the whole accumulated answer on every publish. Measured for 160 kB of unterminated text at the real publish cadence: 175 ms per publish (quadratic in answer length, ~700 ms of blocked main thread across one long answer) → 4.5 ms. The visible cost: a sentence that completes just after a check waits up to ANNOUNCE_INTERVAL_MS to be spoken. That is what the rate limit is for; when speech is flowing, behaviour is identical to before. The upstream side of this pipeline is the SSE reader.

Focus after a button goes disabled

The approval row disables its three buttons the instant a decision is in flight (b.disabled = !row.actionable), which stops a double tap sending two answers. But the user just pressed one of those buttons, so focus is on it — and disabling the focused element drops focus to <body> with no event and no sound: the screen reader reads nothing, the next Tab starts from the top of the document, and a blind user cannot tell whether their answer went through. focusAfterDisable decides where focus goes instead, as a pure function over ids.
The survivor search excludes the disabled set: enabledIds.find((id) => !disabledIds.includes(id)). Flipping that to find(() => true) (or dropping the guard) returns the first “enabled” id without checking whether it is one of the ids about to become disabled — and focus lands on the dead button the user just pressed. VoiceOver then drops focus to <body>, and the user loses their place in the conversation. Pinned by "focus never lands on a control that is itself being disabled" and its pair "focus stays put when the focused control is NOT being disabled".
This is a decision function, not a document.activeElement call: focusAfterDisable returns a FocusTarget (none | element | restore) and the renderer applies it. Everything above the renderer is pure and testable.

Transcript rows carry accessible names

Every transcript row now carries an accessible name — paint() calls accessibleName(strings, row) on insert and update, so the whole names.ts module reaches the DOM for the first time. Until this landed, accessibleName, toolRowName, approvalRowName, and errorRowName had eleven tests and zero callers anywhere in the app — no transcript row carried an aria-label at all.
The label repaints on update, not just insert: a tool inserted running and finishing in place would otherwise announce “Running” for the rest of the conversation. A row that changes kind in place must not keep a stale label either — the removal path applies there too. null is a real answer, not a gap. For a text row its own words are its name, and labelling it would replace the answer with a summary of it — so null removes the aria-label attribute rather than writing "null" or "". aria-label="" and no aria-label are different to a screen reader; the test fake (testing/src/fake-dom.ts) grew a real removeAttribute so a test could tell them apart.

Tool status is now text, not colour alone

dom.ts renders a <span class="tool-status"> carrying strings.toolStatus(row.status) in words, repainted on update — so status is no longer a colour-only channel. app.css styled [data-status="failed"] and [data-status="ok"] with a border colour and had no rule at all for unresolved, so a tool that never came back rendered pixel-for-pixel identically to one still running. The .tool-status span is what removes the “colour is the only channel” gap the transcript layer was written against (the view-model comment at ui/src/transcript/view-model.ts:173).

A reasoning row is now told apart by content, not colour alone

Since PR #4873, paint() for case "reasoning" renders a visually-hidden .sr-only span carrying strings.reasoningLabel ahead of the text — additive content that supplements the prose rather than replacing it, exactly the pattern the user row already uses for its "You said:" speaker.
accessibleName returns null for the reasoning kind on purpose (rule 3: a label on prose replaces the prose in the accessibility tree), so the label is content, not an aria-label. Before this, a .row-reasoning was distinguished from an answer by CSS alone — a left rule, a softer colour, a smaller font — none of which reaches the accessibility tree, so a reader arrowing through a transcript heard the model’s private working and its actual answer as one undifferentiated run.
The reasoning row is only reachable through the remote engine (reasoning: true); the default in-process engine declares reasoning: false, so a default-engine transcript never paints one.

Chat-row accessible name

Every chat row has a name: a titled row is announced by its title; an untitled one by the localised “Untitled” string — never as an empty accessible name.
The fallback is en.untitled, not the empty string. A nameless row reads as just “button” and cannot be told apart from its neighbours — which is exactly what a per-row accessible name exists to prevent. The guard is row.title === "" ? strings.untitled : row.title; flipping the === to !== reads a real title as Untitled and leaves an untitled row nameless.
The row now carries more than a name. The Open button’s label is strings.chatUpdated(chatRowName(strings, row), row.updatedLabel) — the title plus the last-updated time. A Delete button sits alongside, labelled strings.deleteChat(row.title) at rest or strings.deleteChatConfirm(row.title) once armed.
The clean title for the Delete label is carried on the button’s own dataset (data-chat-title), not re-derived from parentElement.textContent. Reading the parent folded in the visible time and the button’s own word, so arming produced labels like “Delete Trip plan5m agoConfirm” — pinned by "a delete label names ONLY the chat, not its time and button text" in main.test.ts. The armed label uses deleteChatConfirm(title), which names the consequence (“Delete Trip plan? Tap Confirm to delete it. This cannot be undone.”) — pinned by the same test.

New user-visible strings

The Strings interface gained members for the delete flow and the timed chat row, plus speakerUser and userNotStored for the user-message row.

Missing-translation marks need both brackets

The missing-strings reporter wraps an untranslated key as ⟦…⟧, and isMarked only recognises a string carrying both brackets.
isMarked(s) returns true only when s starts with and ends with . A half-bracketed string — a translator’s own literal or — is not a marked-missing key, so the report never blames translations that are actually present.
The guard is s.startsWith("⟦") && s.endsWith("⟧"). Flipping the && to || counts a lone bracket as a marked-missing key and reports a real, present translation as absent. Pinned by "a half-bracketed string is not a marked-missing one".

Best Practices

az-Arab is right-to-left and az is not. A hand-written list of RTL language codes gets script variants wrong; the script subtag decides, and ICU is asked before the table.
Older Android WebViews ship without Intl.Locale.textInfo and Intl.Segmenter, so directionFromTables and fallbackSentences are exported precisely to exercise the degraded path. But the fallback is not only an old-WebView concern: every Intl constructor throws RangeError on an underscore tag, and locale.ts accepts "en_US" as a valid stored preference — so the number, date, and duration fallbacks fire in production the moment a stored locale reaches the UI with an underscore.
Announcing a half-typed sentence makes a screen reader say “The file cont” and then repeat the whole sentence. Speak only completed sentences and keep the tail until it terminates.

Shell & Adapters

Logical insets that mirror with direction.

Errors & Recovery

What each failure looks like on the phone.

Route Focus

Focus and announcements on every route change.

Composer Behavior

The composer’s draft, autosize, and key policy.

Empty Chat

The empty-state panel these strings and the polite announcement serve.