> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Tag["🏷️ direction(tag)"] --> Probe{"Intl.Locale.textInfo?"}
    Probe -->|present| Primary[⚡ primary answer]
    Probe -->|missing| Tables[📖 fallback tables]
    Primary --> Dir["✅ ltr | rtl"]
    Tables --> Dir

    classDef tag fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef probe fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef primary fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Tag tag
    class Probe probe
    class Primary,Tables primary
    class Dir out
```

## Quick Start

<Steps>
  <Step title="Ask which way the text runs">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>

  <Step title="Split a streaming answer into sentences">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>
</Steps>

***

## Text direction

`direction(tag)` asks ICU first and falls back to explicit tables.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Tag["🏷️ direction(tag)"] --> Intl{"Intl.Locale.textInfo?"}
    Intl -->|answers| A["✅ ltr | rtl"]
    Intl -->|no data| FB["📖 directionFromTables(tag)"]
    FB --> A

    classDef tag fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef probe fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef fb fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Tag tag
    class Intl probe
    class FB fb
    class A out
```

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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.                                                                                                                                                                                                                                                                                                |

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

<Note>
  **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.
</Note>

<Note>
  `"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.
</Note>

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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](https://github.com/MervinPraison/PraisonAI/pull/4607) direction fix reachable for the first time. |

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

### `MountDeps.locales` — injectable, deterministic

`requestedLocales(deps)` returns `deps.locales` when supplied, most-preferred first, and otherwise reads the host's `navigator.languages`.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import { requestedLocales } from "praisonai-mobile/app/main";

requestedLocales({ root, locales: ["ar", "en"] }); // ["ar", "en"] — injected
requestedLocales({ root });                          // navigator.languages
```

| `MountDeps` field | Type                | Meaning                                                                                                                     |
| ----------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `locales?`        | `readonly string[]` | The locales the user prefers, most-preferred first. Injected so a test is deterministic; defaults to `navigator.languages`. |

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

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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. |

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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
```

<Warning>
  `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`.
</Warning>

***

## Sentence segmentation

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Text["📝 sentences(locale, text)"] --> Seg{"Intl.Segmenter?"}
    Seg -->|answers| A["✅ string[]"]
    Seg -->|no data| FB["📖 fallbackSentences(text)"]
    FB --> A

    classDef text fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef probe fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef fb fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Text text
    class Seg probe
    class FB fb
    class A out
```

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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](#screen-reader-hygiene). |
| Seam guarantee                           | `fallbackSentences(text).join("") === text` for every input — no character is ever lost.                                                                                                                                                                                                                                              |

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

***

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Fn["🏷️ formatFn(locale, …)"] --> Memo{"Intl constructor via memo()?"}
    Memo -->|built| Primary["⚡ primary ICU output"]
    Memo -->|threw / absent| Fallback["📖 ASCII fallback tables"]
    Primary --> Out["✅ formatted string"]
    Fallback --> Out

    classDef fn fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef probe fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef primary fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Fn fn
    class Memo probe
    class Primary,Fallback primary
    class Out out
```

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

<Steps>
  <Step title="Format a number, a count, or a date">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>

  <Step title="Format a relative or elapsed time">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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`.
  </Step>
</Steps>

<Note>
  **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"`.
</Note>

### When does the fallback fire?

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Tag["🏷️ locale tag"] --> Q{"which case?"}
    Q -->|"underscore, e.g. en_US"| BadTag["📖 Intl throws → ASCII fallback"]
    Q -->|"Intl API missing (old WebView)"| NoApi["📖 constructor unavailable → ASCII fallback"]
    Q -->|"valid BCP 47, e.g. en-US"| Valid["⚡ primary ICU output"]

    classDef tag fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef probe fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef fb fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Tag tag
    class Q probe
    class BadTag,NoApi fb
    class Valid out
```

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

<Note>
  **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.
</Note>

<Note>
  **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.
</Note>

***

## User interaction flow

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

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

<Note>
  **`engineNotReady` is announced assertively, on purpose.** When the app boots into an engine that is not answering yet ([`booted.notReady`](/docs/features/mobile/boot-failures#an-unreachable-engine-boots-with-a-warning)), `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.
</Note>

The notice text is a localisable `Strings` key added in [PR #4672](https://github.com/MervinPraison/PraisonAI/pull/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."`                                       |

<Note>
  **`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](/docs/features/mobile/settings-screen#how-a-refusal-is-spoken).
</Note>

### Secret rows

The OpenAI API key row ([API Keys](/docs/features/mobile/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](/docs/features/mobile/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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Refresh["🔄 refreshEmptyState()"] --> Changed{kind changed?}
    Changed -->|yes| Name["🏷️ emptyStateName(strings, view)"]
    Changed -->|no| Skip["⏭️ no re-announce"]
    Name --> Polite["🗣️ polite live region"]

    classDef event fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef decision fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef name fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef out fill:#10B981,stroke:#7C90A0,color:#fff

    class Refresh event
    class Changed decision
    class Name name
    class Polite,Skip out
```

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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

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

### 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](/docs/features/mobile/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](#sentence-segmentation)) and rate-limits the segmentation check itself.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Publish[📝 publish] --> InInterval{⏱️ inside interval?}
    InInterval -->|yes| Skip[⏭️ skip — return same state]
    InInterval -->|no| Check[🔍 segment + advance clock]
    Check --> Completed{✅ sentence completed?}
    Completed -->|yes| Speak[🗣️ say chunk]
    Completed -->|no| NewState[💾 new state — clock advanced]
    Speak --> NewState

    classDef event fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef decision fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef action fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef terminal fill:#10B981,stroke:#7C90A0,color:#fff

    class Publish event
    class InInterval,Completed decision
    class Check,Speak action
    class Skip,NewState terminal
```

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](/docs/features/mobile/protocol#frame-buffering).

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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. |

<Warning>
  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"`.
</Warning>

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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Paint["🎨 paint(row)"] --> Name["🏷️ accessibleName(strings, row)"]
    Name -->|non-null| Set["✅ set aria-label"]
    Name -->|null| Remove["🧹 remove aria-label"]

    classDef paint fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef name fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef set fill:#10B981,stroke:#7C90A0,color:#fff
    classDef remove fill:#F59E0B,stroke:#7C90A0,color:#fff

    class Paint paint
    class Name name
    class Set set
    class Remove remove
```

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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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](/docs/features/mobile/transcript-user-row#accessibility).                                                                                                                                                                                                         |
| `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](https://github.com/MervinPraison/PraisonAI/pull/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](https://github.com/MervinPraison/PraisonAI/pull/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](/docs/features/mobile/errors-and-recovery#an-error-row-shows-the-kind-in-words).                                            |
| `chat` (in chats list) | `strings.chatUpdated(chatRowName(strings, row), updatedLabel)` — title + time   | See [History & Reopen](/docs/features/mobile/history-and-reopen#the-chats-screen).                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |

### 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](https://github.com/MervinPraison/PraisonAI/pull/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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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.

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

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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 |

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

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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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));
```

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

### 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](https://github.com/MervinPraison/PraisonAI/pull/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](/docs/features/mobile/transcript-user-row#three-storage-states).                                                                      |

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Note>
  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"`.
</Note>

***

## Best Practices

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Shell & Adapters" icon="mobile-button" href="/docs/features/mobile/shell-and-adapters">
    Logical insets that mirror with direction.
  </Card>

  <Card title="Errors & Recovery" icon="triangle-exclamation" href="/docs/features/mobile/errors-and-recovery">
    What each failure looks like on the phone.
  </Card>

  <Card title="Route Focus" icon="crosshairs" href="/docs/features/mobile/route-focus">
    Focus and announcements on every route change.
  </Card>

  <Card title="Composer Behavior" icon="keyboard" href="/docs/features/mobile/composer-behavior">
    The composer's draft, autosize, and key policy.
  </Card>

  <Card title="Empty Chat" icon="message-square-dashed" href="/docs/features/mobile/empty-chat">
    The empty-state panel these strings and the polite announcement serve.
  </Card>
</CardGroup>
