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

***

## 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 and errors. **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 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.

***

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

***

## 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>
</CardGroup>
