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 fromformat-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 twosr-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 isrole="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’saria-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 thepolite 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.
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.
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.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
Decide direction by script, not a language list
Decide direction by script, not a language list
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.Never assume Intl is complete
Never assume Intl is complete
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.Hold the unterminated tail on a stream
Hold the unterminated tail on a stream
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.
Related
Shell & Adapters
Logical insets that mirror with direction.
Errors & Recovery
What each failure looks like on the phone.

