Locale, Direction & Segmentation
How the mobile UI decides text direction, splits sentences, and formats numbers, dates, and durations on hosts without full Intl support.
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
- Ask which way the text runs
ts import { direction } from "praisonai-mobile/ui/i18n/locale"; direction("ar"); // "rtl" direction("en-GB"); // "ltr"directionis total: it returns"ltr"or"rtl"for any string, and never throws. - Split a streaming answer into sentences
ts import { sentences } from "praisonai-mobile/ui/i18n/segment"; sentences("en", 'He said "stop." Then left.'); // two sentencessentencesdrives 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.
import { directionFromTables } from "praisonai-mobile/ui/i18n/locale";
directionFromTables("az-Arab"); // "rtl" โ script beats language
directionFromTables("ar-Latn"); // "ltr" โ script beats language| Rule | Behaviour |
|---|---|
| RTL languages | Base set: ar, arc, az-arab, ckb, dv, fa, he, ks, ku-arab, nqo, pnb, ps, sd, syr, ug, ur, yi. Spoken Arabic varieties: aeb, acm, ajp, apc, ary, arz. Persian-script and Perso-Arabic languages: bal, glk, haz, lrc, mzn, skr. Rohingya (Hanifi script): rhg. Source: RTL_LANGUAGES in src/praisonai-mobile/ui/src/i18n/locale.ts. |
| Script subtag beats the language | az-Arab and ku-Arab are RTL (LTR languages in an RTL script); ar-Latn and ks-Deva are LTR (RTL languages in an LTR script). |
| Script matched case-insensitively | az-arab, az-ARAB, az-Arab all resolve the same. |
| Region subtags are not scripts | A 2-letter or 3-digit region is not mistaken for a script โ ar-EG stays RTL, ar-001 stays RTL. |
| Script subtag is read at position 1 only | Per BCP 47, a script subtag sits immediately after the language and nowhere else. directionFromTables("ar-EG") reads no script and answers "rtl" from the language table; directionFromTables("az-Arab-IR") reads Arab at position 1 and answers "rtl". |
| Unicode / transform / private-use extensions cannot flip direction | ar-EG-u-nu-latn (Arabic phone reporting a Latin numbering system, which is what a real device sends), fa-IR-u-nu-latn, ur-PK-u-nu-latn, he-IL-t-en-latn, and ar-x-latn all stay "rtl". en-US-u-nu-arab stays "ltr". An extension subtag beginning with -u-, -t-, or -x- never changes text direction. |
| Total | Never throws on any input โ empty string, garbage, and malformed tags all resolve to a direction. |
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.
const requested = requestedLocales(deps); // navigator.languages or deps.locales
const activeLocale = resolveLocale(requested, ["en"], "en");
const bundle = createBundle(requested[0] ?? "en", {}, "silent");
root.setAttribute("dir", bundle.direction);The split is honest, not aspirational:
| Value | Derived from | Consequence |
|---|---|---|
activeLocale | resolveLocale(requested, ["en"], "en") | Only the English table ships today, so this falls back to "en" โ a British "en-GB" request lands on "en", not a blank screen. |
bundle.direction | the requested tag, requested[0] | An Arabic device reading English words still lays out right-to-left. This is what makes the #4607 direction fix reachable for the first time. |
MountDeps.locales โ injectable, deterministic
requestedLocales(deps) returns deps.locales when supplied, most-preferred first, and otherwise reads the host’s navigator.languages.
import { requestedLocales } from "praisonai-mobile/app/main";
requestedLocales({ root, locales: ["ar", "en"] }); // ["ar", "en"] โ injected
requestedLocales({ root }); // navigator.languagesMountDeps field | Type | Meaning |
|---|---|---|
locales? | readonly string[] | The locales the user prefers, most-preferred first. Injected so a test is deterministic; defaults to navigator.languages. |
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.
// main.ts โ geometry into CSS custom properties.
const geometry = geometryOf(layout);
screen.style.setProperty("--keyboard-height", `${geometry.composerBottomPx}px`);
screen.style.setProperty("--inset-top", `${geometry.scrollTopPx}px`);
const logical = logicalInsets(bundle.direction, geometry.composerLeftPx, geometry.composerRightPx);
composer.style.setProperty("padding-inline-start", `${logical.startPx}px`);
composer.style.setProperty("padding-inline-end", `${logical.endPx}px`);| Property | Source | What it positions |
|---|---|---|
--keyboard-height | geometry.composerBottomPx | Lifts the composer above the keyboard. |
--inset-top | geometry.scrollTopPx | Clears the status bar / notch. |
padding-inline-start / -end | logicalInsets(direction, โฆ) | The safe-area clearance, on the leading edge under RTL. |
import { logicalInsets } from "praisonai-mobile/ui/i18n/locale";
logicalInsets("ltr", 16, 8); // { startPx: 16, endPx: 8 }
logicalInsets("rtl", 16, 8); // { startPx: 8, endPx: 16 } โ swappedSentence segmentation
sentences(locale, text) asks Intl.Segmenter first and falls back to fallbackSentences(text).
import { fallbackSentences } from "praisonai-mobile/ui/i18n/segment";
fallbackSentences("Version 1.2.3 is out."); // one sentence
fallbackSentences('He said "stop." Then left.'); // two sentences| Rule | Behaviour |
|---|---|
| Does not split on a decimal point | "Version 1.2.3 is out." is one sentence โ a terminator glued to the next character is a decimal, not a sentence end. |
| Splits after a quoted full stop | 'He said "stop." Then left.' is two sentences. |
| Keeps the unterminated tail | The trailing half-sentence is its own segment, so a streaming answer is spoken as it arrives. |
| A lone terminator is a complete sentence | A one-character sentence made only of a terminator is complete. endsSentence("ใ") === true and completedLength("ja", "ใ") === 1. CJK writes short sentences; a lone ใ (or ., !, ?, ๏ผ, ๏ผ) is a whole one, and without this the screen-reader announcement stalls. See Screen-reader hygiene. |
| Seam guarantee | fallbackSentences(text).join("") === text for every input โ no character is ever lost. |
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
- Format a number, a count, or a date
ts import { formatNumber, formatCountLocalised, formatDate } from "praisonai-mobile/ui/i18n/format-intl"; formatNumber("en", 1234.5); // "1,234.5" formatCountLocalised("ja", 15000); // "1.5ไธ" formatDate("en", 1_700_000_000_000, "UTC"); // "Nov 14, 2023"timeZoneis a required argument onformatDateโ passnullfor the host’s zone, but it must be typed. - Format a relative or elapsed time
ts import { formatRelativeLocalised, formatElapsedLocalised } from "praisonai-mobile/ui/i18n/format-intl"; import { en } from "praisonai-mobile/ui/i18n/strings"; const now = Date.now(); formatRelativeLocalised("en", en, now - 10 * 60 * 1000, now, null); // "10 minutes ago" formatElapsedLocalised("en", en, 3720); // "1h 02m" formatElapsedLocalised("en", en, 5.25); // "5.3s"formatElapsedLocalisedtakesseconds: number | null;null, negative, andNaNall render asstrings.unknownValue.
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
| Rule | Behaviour |
|---|---|
| Every entry point is total | An unparseable tag ("en_US", empty, garbage) never throws โ the function returns a well-formed ASCII fallback. |
timeZone is a required argument on formatDate | Pass null for the host’s zone, but the argument must be typed. A defaulted zone is how a chat saved at 23:40 shows tomorrow’s date. |
Unknown values render as strings.unknownValue | On formatElapsedLocalised, null, negative, and NaN seconds all render as unknown โ not as 0s. The engine not observing a call begin is not the same as the call returning instantly. |
| Fallback outputs are ASCII and locale-independent | Number โ String(value); count โ whole integer via String(whole); date โ ISO YYYY-MM-DD (toISOString().slice(0, 10)); padded โ padStart(2, "0"); sub-10s duration โ toFixed(1); relative time โ the localisable Strings table (minutesAgo / hoursAgo / daysAgo). |
memo() caches successes and failures | A bad tag costs one throw per process, not one per frame โ safe in a streaming transcript row that re-formats on every publish. |
| The primary path picks the right script | Intl.NumberFormat in Arabic may render Arabic-Indic digits; formatPadded never pads an Arabic-Indic number with an ASCII "0". The fallback uses ASCII by design, but the primary path never mixes scripts. |
User interaction flow
- Modern iOS โ the primary path answers
An Arabic user opens the app on a modern iOS WebView.
Intl.Locale.textInfoanswers"rtl", and the composer, send button, and safe-area padding all mirror to the correct side. - 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. - Bad stored locale โ the fallback answers
A user’s stored preference is
"en_US"(an underscore tag from an older settings write).memo(...)cachesnull,formatDatereturns the ISO date,formatElapsedLocalisedreturns"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:
| Region | aria-live | What flows through it |
|---|---|---|
polite | polite | Status ticks that can queue โ “Sendingโฆ”, “Received.”, small counters. |
assertive | assertive | Approval prompts, errors, and the boot-time engineNotReady notice. An approval BLOCKS the run, so waiting politely for the queue to drain is waiting for something that will not happen until the user answers. Approvals must interrupt. |
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 notice text is a localisable Strings key added in PR #4672:
Strings key | English text |
|---|---|
engineNotReady(detail) | "The engine is not answering yet ({detail}). You can still type; sending will retry it." |
settingRejected(label) | "{label} was not changed: that value was refused." |
Secret rows
The OpenAI API key row (API Keys) adds four Strings members, and none of them ever contains any part of the key.
Strings key | English text |
|---|---|
secretPlaceholder | "Paste a key to set or replace it" |
secretStored(label) | "{label} saved." |
secretCleared(label) | "{label} removed." |
actionClearSecret | "Remove" |
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.
| Key | Constant | English (from en) |
|---|---|---|
emptyTranscript | โ | "Ask something to begin." |
emptyAbout | โ | "PraisonAI answers questions, explains things, and works through tasks with you." |
emptyNeedsKeyTitle | โ | "Add an API key to start" |
emptyNeedsKeyBody | โ | "PraisonAI answers using your own OpenAI account. Paste a key in Settings and this chat is ready." |
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.
import { emptyStateName } from "praisonai-mobile/ui/a11y/names";
import { en } from "praisonai-mobile/ui/i18n/strings";
const view = { kind: "needs-key", title: "Add an API key to start",
body: "PraisonAI answers using your own OpenAI account. Paste a key in Settings and this chat is ready.",
action: { label: "Open settings", route: "settings" } } as const;
emptyStateName(en, view);
// "Add an API key to start PraisonAI answers โฆ this chat is ready. Open settings"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.
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.
import { focusAfterDisable } from "praisonai-mobile/ui/a11y/focus";
focusAfterDisable({
focusedId: "approval:a1:allow",
disabledIds: ["approval:a1:allow", "approval:a1:always", "approval:a1:deny"],
enabledIds: ["approval:a1:allow", "approval:a1:always", "approval:a1:deny", "composer"],
containerId: "approval:a1",
});
// -> { kind: "element", id: "composer" } โ never the dead button, never <body>| Input | focusAfterDisable returns | Why |
|---|---|---|
focusedId === null | { kind: "none" } | Focus is already elsewhere; do not move it. |
focusedId is not in disabledIds | { kind: "none" } | Moving focus the user did not ask to move is its own bug โ an approval resolving in the background must not steal the caret out of the composer. |
focusedId is in disabledIds, and another enabledIds entry is not in the disabled set | { kind: "element", id: <survivor> } | The user stays inside the group they were operating. |
focusedId is in disabledIds, and every enabledIds entry is being disabled too | { kind: "element", id: containerId } | The row itself takes focus; its accessible name carries the decision state so the user hears “Approval required: bash. Sending your answer.” instead of nothing. |
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.
// dom.ts โ paint() names the row first, on insert AND update.
const name = accessibleName(strings, row);
if (name === null) el.removeAttribute("aria-label");
else el.setAttribute("aria-label", name);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.
| Row kind | aria-label source | Why it matters |
|---|---|---|
user | null โ the paragraph is announced by its own text | Same reason as text: an aria-label on your message would replace it with a summary. The speaker "You said:" is rendered as visually-hidden content ahead of the message, not as a label, so the paragraph keeps word / sentence / character navigation. See Transcript User Row. |
text | null โ the paragraph is announced by its own text | Labelling a paragraph replaces its answer with a summary of it. |
reasoning | null โ the paragraph is announced by its own text | The row now carries a visually-hidden .sr-only span with strings.reasoningLabel ahead of the text, as additive content โ the same remedy the user row uses. Before PR #4873, a .row-reasoning was told apart from an answer by CSS alone (left rule, softer colour, smaller font), none of which reaches the accessibility tree โ so a reader heard the model’s private working and its answer in one undifferentiated run. Reachable through the remote engine (reasoning: true). |
tool | toolRowName(status, name, argsPreview) โ includes the status word | The status previously reached the DOM only as data-status, which app.css turned into a border colour โ a screen-reader user heard “rm, โ” for a tool that failed. |
approval | approvalRowName(name, state.status) โ includes the decision state | While a decision is in flight every button on the row is disabled, and a disabled button is announced as “dimmed” or skipped โ the row went silent at the exact moment the user was waiting to hear what happened. |
error | errorRowName(kind, message) โ the errorTitle in front of the provider prose | The message is whatever the provider said; the kind is the only dependable part of the announcement. errorRowName(kind, message) is what the aria-label carries; PR #4873 additionally paints the same title as a bold .error-title span inside the row, so sighted users see the kind in words too. See Errors & Recovery โ An error row shows the kind in words. |
chat (in chats list) | strings.chatUpdated(chatRowName(strings, row), updatedLabel) โ title + time | See History & Reopen. |
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.
// dom.ts โ the reasoning kind is announced by an additive sr-only word.
const label = el.ownerDocument.createElement("span");
label.className = "sr-only";
label.textContent = strings.reasoningLabel;
const said = el.ownerDocument.createElement("span");
said.textContent = row.text;
el.append(label, said);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.
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.
import { chatRowName } from "praisonai-mobile/ui/a11y/names";
import { en } from "praisonai-mobile/ui/i18n/strings";
chatRowName(en, { title: "Quarterly plan" }); // "Quarterly plan"
chatRowName(en, { title: "" }); // en.untitled โ never ""| Row state | chatRowName(en, row) returns | Screen reader says |
|---|---|---|
title: "Quarterly plan" | "Quarterly plan" | “Quarterly plan, button” |
title: "" | en.untitled | “Untitled, button” |
title: "" returning "" | never | “button” โ indistinguishable from the row above |
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 lives on the button's own dataset, not the parent's text.
del.dataset["chatTitle"] = row.title;
del.setAttribute("aria-label", strings.deleteChat(row.title));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.
| Key | English default | Purpose |
|---|---|---|
chatUpdated(title, when) | ${title}, ${when} | The chat row’s accessible name โ one string so the visible and spoken time cannot drift. |
deleteChat(title) | Delete ${title} | Resting label on a chat row’s delete button โ names the chat so a list of “Delete” buttons is not indistinguishable to a screen reader. |
actionConfirmDelete | Confirm | The armed button text. |
deleteChatConfirm(title) | Delete ${title}? Tap Confirm to delete it. This cannot be undone. | Armed aria-label and the assertive announcement โ names the chat and says the consequence. |
chatDeleted(title) | ${title} was deleted. | Assertive announcement after a successful delete. |
chatDeleteFailed | That conversation could not be deleted. | Assertive announcement when storage.remove rejects. |
speakerUser | You said: | The visually-hidden speaker announced ahead of your own message, so a screen reader can tell your question from the answer to it. |
reasoningLabel | Reasoning: | The visually-hidden .sr-only label painted ahead of a reasoning row’s text โ additive content that tells a screen-reader user the model’s private working apart from its answer. Rendered into the DOM for the first time by PR #4873; reachable only through the remote engine (reasoning: true). |
userNotStored | Not saved โ this message is not in the stored conversation | The caveat on an unstored user row โ a turn that ended with no end.userIndex. Says what happened and what it means, so the reader is not left guessing whether reopening finds the message. See Transcript User 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.
import { markMissing, isMarked } from "praisonai-mobile/ui/i18n/bundle";
markMissing("save"); // "โฆsaveโง"
isMarked("โฆsaveโง"); // true โ opens โฆ AND ends โง
isMarked("โฆsave"); // false โ a half-bracketed stringisMarked(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.
Best Practices
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
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
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
Logical insets that mirror with direction.
Errors & RecoveryWhat each failure looks like on the phone.
Route FocusFocus and announcements on every route change.
Composer BehaviorThe composer’s draft, autosize, and key policy.
Empty ChatThe empty-state panel these strings and the polite announcement serve.