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.

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.

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.

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.

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.