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".

Diagram

present

missing

๐Ÿท๏ธ direction(tag)

Intl.Locale.textInfo?

โšก primary answer

๐Ÿ“– fallback tables

โœ… ltr | rtl

Quick Start

  1. Ask which way the text runs
    ts
    import { direction } from "praisonai-mobile/ui/i18n/locale";
    
    direction("ar");      // "rtl"
    direction("en-GB");   // "ltr"

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

  2. Split a streaming answer into sentences
    ts
    import { sentences } from "praisonai-mobile/ui/i18n/segment";
    
    sentences("en", 'He said "stop." Then left.'); // two 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.

Diagram

answers

no data

๐Ÿท๏ธ direction(tag)

Intl.Locale.textInfo?

โœ… ltr | rtl

๐Ÿ“– directionFromTables(tag)

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.

ts
import { directionFromTables } from "praisonai-mobile/ui/i18n/locale";

directionFromTables("az-Arab"); // "rtl" โ€” script beats language
directionFromTables("ar-Latn"); // "ltr" โ€” script beats language
RuleBehaviour
RTL languagesBase 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 languageaz-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-insensitivelyaz-arab, az-ARAB, az-Arab all resolve the same.
Region subtags are not scriptsA 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 onlyPer 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 directionar-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.
TotalNever 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.

ts
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:

ValueDerived fromConsequence
activeLocaleresolveLocale(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.directionthe 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.

ts
import { requestedLocales } from "praisonai-mobile/app/main";

requestedLocales({ root, locales: ["ar", "en"] }); // ["ar", "en"] โ€” injected
requestedLocales({ root });                          // navigator.languages
MountDeps fieldTypeMeaning
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.

ts
// 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`);
PropertySourceWhat it positions
--keyboard-heightgeometry.composerBottomPxLifts the composer above the keyboard.
--inset-topgeometry.scrollTopPxClears the status bar / notch.
padding-inline-start / -endlogicalInsets(direction, โ€ฆ)The safe-area clearance, on the leading edge under RTL.
ts
import { logicalInsets } from "praisonai-mobile/ui/i18n/locale";

logicalInsets("ltr", 16, 8); // { startPx: 16, endPx: 8 }
logicalInsets("rtl", 16, 8); // { startPx: 8,  endPx: 16 } โ€” swapped

Sentence segmentation

sentences(locale, text) asks Intl.Segmenter first and falls back to fallbackSentences(text).

Diagram

answers

no data

๐Ÿ“ sentences(locale, text)

Intl.Segmenter?

โœ… string[]

๐Ÿ“– fallbackSentences(text)

ts
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
RuleBehaviour
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 tailThe trailing half-sentence is its own segment, so a streaming answer is spoken as it arrives.
A lone terminator is a complete sentenceA 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 guaranteefallbackSentences(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.

Diagram

built

threw / absent

๐Ÿท๏ธ formatFn(locale, โ€ฆ)

Intl constructor via memo()?

โšก primary ICU output

๐Ÿ“– ASCII fallback tables

โœ… formatted string

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
    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"

    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
    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"

    formatElapsedLocalised takes seconds: number | null; null, negative, and NaN all render as strings.unknownValue.

When does the fallback fire?

Diagram

underscore, e.g. en_US

Intl API missing (old WebView)

valid BCP 47, e.g. en-US

๐Ÿท๏ธ locale tag

which case?

๐Ÿ“– Intl throws โ†’ ASCII fallback

๐Ÿ“– constructor unavailable โ†’ ASCII fallback

โšก primary ICU output

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

RuleBehaviour
Every entry point is totalAn unparseable tag ("en_US", empty, garbage) never throws โ€” the function returns a well-formed ASCII fallback.
timeZone is a required argument on formatDatePass 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.unknownValueOn 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-independentNumber โ†’ 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 failuresA 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 scriptIntl.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

  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:

Regionaria-liveWhat flows through it
politepoliteStatus ticks that can queue โ€” “Sendingโ€ฆ”, “Received.”, small counters.
assertiveassertiveApproval 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 keyEnglish 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 keyEnglish 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.

KeyConstantEnglish (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.

Diagram

yes

no

๐Ÿ”„ refreshEmptyState()

kind changed?

๐Ÿท๏ธ emptyStateName(strings, view)

โญ๏ธ no re-announce

๐Ÿ—ฃ๏ธ polite live region

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.

ts
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.

Diagram

yes

no

yes

no

๐Ÿ“ publish

โฑ๏ธ inside interval?

โญ๏ธ skip โ€” return same state

๐Ÿ” segment + advance clock

โœ… sentence completed?

๐Ÿ—ฃ๏ธ say chunk

๐Ÿ’พ new state โ€” clock advanced

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.

ts
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>
InputfocusAfterDisable returnsWhy
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.

Diagram

non-null

null

๐ŸŽจ paint(row)

๐Ÿท๏ธ accessibleName(strings, row)

โœ… set aria-label

๐Ÿงน remove aria-label

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.

ts
// 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 kindaria-label sourceWhy it matters
usernull โ€” the paragraph is announced by its own textSame 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.
textnull โ€” the paragraph is announced by its own textLabelling a paragraph replaces its answer with a summary of it.
reasoningnull โ€” the paragraph is announced by its own textThe 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).
tooltoolRowName(status, name, argsPreview) โ€” includes the status wordThe 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.
approvalapprovalRowName(name, state.status) โ€” includes the decision stateWhile 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.
errorerrorRowName(kind, message) โ€” the errorTitle in front of the provider proseThe 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 + timeSee 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.

ts
// 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.

ts
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 statechatRowName(en, row) returnsScreen 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.

ts
// 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.

KeyEnglish defaultPurpose
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.
actionConfirmDeleteConfirmThe 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.
chatDeleteFailedThat conversation could not be deleted.Assertive announcement when storage.remove rejects.
speakerUserYou said:The visually-hidden speaker announced ahead of your own message, so a screen reader can tell your question from the answer to it.
reasoningLabelReasoning: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).
userNotStoredNot saved โ€” this message is not in the stored conversationThe 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.

ts
import { markMissing, isMarked } from "praisonai-mobile/ui/i18n/bundle";

markMissing("save");        // "โŸฆsaveโŸง"
isMarked("โŸฆsaveโŸง");         // true  โ€” opens โŸฆ AND ends โŸง
isMarked("โŸฆsave");          // false โ€” a half-bracketed string

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.


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