13 · Internationalization, RTL & Accessibility
Purpose: The normative design for how Rasd Forms localizes form content and library chrome, negotiates and switches locales, formats numbers/dates/digits, lays out right-to-left scripts, renders Arabic typography, supports low-literacy respondents, and meets WCAG 2.2 AA on web and React Native.
Audience: Engineers building @rasd/core, @rasd/react, @rasd/native and @rasd/builder; developers at UN/NGO organisations who need to know exactly what they get (and what they must still do) when they ship an Arabic/English/French form to enumerators.
TL;DR
- Three string layers: form content (
LocalizedStringin the RFD, resolved by@rasd/core), library chrome (flat JSON catalogs,en/ar/fr/esat launch, host-overridable), and formatting (nativeIntl.NumberFormat/DateTimeFormat/Collatoronly — compiled CLDR plural functions instead of a requiredIntl.PluralRules, no FormatJS polyfills, because Hermes lacks them; see research). - Two locales exist at runtime: the chrome locale (
<RasdProvider locale>) and the form locale (negotiated per<FormRenderer>againstsettings.locales). Inside a form root, chrome,dir, digits and calendar all follow the form locale and switch atomically without restart. - Fallback is per string (
ar-JO→ar→defaultLocale→ any → name/empty), never blank like XLSForm; fallback text renders withlang+dir="auto", and the builder shows completeness,stale,mtand plural/placeholder errors. - Digits and calendars are settings, not locale side effects:
settings.numbering(latninputs by default),settings.calendar(islamic-umalquradisplay), inputs always normalise to ASCII on save. - RTL is by construction:
diron.rasd-root/ Yogadirectionon native, logical properties only,<bdi>/isolates around interpolated values, LTR islands for numbers/IDs, mirrored directional icons, drag-and-drop with a non-drag path (WCAG 2.2 SC 2.5.7). - Accessibility floor: WCAG 2.2 AA mapped criterion by criterion; 48 px targets (24 px hard floor), 4.5:1/3:1 tokens, visible labels,
aria-describedbyerrors, focus never obscured,aria-livestatus messages; TalkBack/VoiceOver parity on native. - Verified by axe (0 violations per story), a manual screen-reader matrix, RTL/pseudo-locale (
en-XB,xx-LS) snapshots at font scale 1.0/1.3/2.0, andrasd i18n checkin CI.
1. Model: three layers, two locales
| Layer | Where it lives | Owner | Resolved by |
|---|---|---|---|
| Form content (labels, hints, messages, choice labels, consent text, per-language media) | RFD LocalizedString values (04 §11) | Form author / translators | @rasd/core resolveLocalized() at engine time → FieldState.label is already localized |
| Library chrome (Next/Back, "Required", "Saved", sync/license/builder strings, screen-reader announcements) | JSON catalogs @rasd/react/locales/<lc>, @rasd/native/locales/<lc>, @rasd/builder/locales/<lc> | Rasd + community; host may override | useLocale().t(key, vars) |
| Formatting (numbers, dates, lists, collation, plurals) | @rasd/core i18n primitives + native Intl | Runtime | useLocale().formatNumber/formatDate/formatList/collator |
Chrome locale = <RasdProvider locale> (default navigator.language on web, expo-localization/react-native-localize on native, settings.defaultLocale during SSR). It governs everything rendered outside a form root: sync widgets, license watermark, builder shell.
Form locale = negotiated per <FormRenderer> (§3). It governs the content strings, the dir/lang of the form root, digits, calendar, and the chrome inside that root (an Arabic form shows Arabic "التالي", not English "Next"), so one root is one coherent reading direction and language. When no chrome catalog exists for the form locale, chrome falls back to the provider locale, then en. submission.meta.locale records the form locale in use at finalize.
// @rasd/core (i18n primitives, ≤ 5 kB gz of the core budget)
export type LocalizedString = string | { [bcp47Locale: string]: string };
export interface LocaleConfig {
locale: string; // negotiated form locale, e.g. "ar-JO"
fallbacks: string[]; // ["ar", "en"] — computed, see §2
dir: 'ltr' | 'rtl';
numbering: 'latn' | 'native'; // settings.numbering / localeMeta override — governs INPUT digits (§5)
numberingSystem: string; // resolved CLDR id for DISPLAY: "latn" | "arab" | "arabext" | "beng" | …
calendar: 'gregorian' | 'islamic-umalqura';
hourCycle?: 'h12' | 'h23'; timeZone?: string;
}
export function negotiateLocale(requested: string[], available: string[], defaultLocale: string): string;
export function resolveLocalized(ls: LocalizedString | undefined, cfg: LocaleConfig): { text: string; locale: string; fallback: boolean };
export function formatMessage(text: string, vars: Record<string, unknown>, cfg: LocaleConfig): string; // Mini-Message
export function pluralCategory(locale: string, n: number): 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
export function normalizeDigits(input: string): string; // ٠١٢…/۰۱۲…/০১২… → 0-9, ٫ → ".", ٬ removed
export function createLocaleConfig(locale: string, settings: FormSettings): LocaleConfig;
export function translationReport(def: FormDefinition): TranslationReport; // shape in §6
// @rasd/react & @rasd/native (identical surface)
useLocale(): { locale; dir; t; tf; formatNumber; formatDate; formatList; collator; setLocale; config };
useDirection(): 'ltr' | 'rtl';
Exact signatures are frozen in 17 §2.6 (@rasd/core) and 17 §3.3 (hooks); this document owns the semantics.
t() resolves chrome keys; tf() resolves a LocalizedString (optionally with Mini-Message variables). Both are pure functions of one LocaleConfig, which is why a locale switch is atomic.
2. Localized strings and the fallback chain
A LocalizedString is a plain string (interpreted as settings.defaultLocale) or a BCP 47 map. Resolution order for the active locale (04 §11.1):
- exact tag (
ar-JO); - language-only parent (
ar), then any other region of the same language (ar-*, insettings.localesorder); settings.defaultLocale;- any locale that has a non-empty value;
- dev builds: the element
namein[brackets]; production: empty string (andW_MISSING_TRANSLATIONat validate time).
Rules that differ from XLSForm/Collect (which render a blank cell): a fallback is visible — the renderer sets lang="<resolved locale>" and dir="auto" on the text node so an English fallback inside an Arabic form aligns and is pronounced correctly (SC 3.1.2), and the builder badges it "showing default". Curated exceptions to step 2: ckb (Sorani) and ku (Kurmanji) never fall back to each other (ku-Arab is not ckb); prs/fa-AF (Dari) does fall back to fa; zh-Hans/zh-Hant split by script, never by region alone. fallbacks[] in LocaleConfig is computed once per negotiation from these rules.
Localized properties are listed in 04 §11.2; per-language media (media.image/audio/video, also on choices) follow the same chain, so an Arabic audio prompt is used for ar-SD and an English image is reused for every locale that lacks its own.
3. Locale negotiation and runtime switching
flowchart TD
A{"FormRenderer locale prop?"} -->|yes| L["BCP 47 lookup against settings.locales"]
A -->|no| B{"persisted choice kv locale:formId?"}
B -->|yes| L
B -->|no| C["RasdProvider locale"] --> L
L -->|exact or parent or same-language match| R["form locale"]
L -->|no match| D["device locales navigator.languages or getLocales"]
D -->|match| R
D -->|no match| E["settings.defaultLocale"] --> R
R --> F["LocaleConfig: dir, numberingSystem, calendar, fallbacks"]
- Algorithm: BCP 47 lookup with script/region truncation, then best-fit by macro-language, implemented in-house (~40 lines; never
@formatjs/intl-locale, 73.5 kB gz). Candidates are tried in the order above; the first that yields a match againstsettings.localeswins. - Persistence: the enumerator's explicit choice is stored in
storage.kvunderlocale:<formId>(andlocale:*when the host passesrememberLocale: 'global'); a draft resumes in the locale it was last edited in (submission.meta.locale). - Switching:
engine.setLocale(locale)anduseLocale().setLocale()rebuild oneLocaleConfig; the renderer updatesdir,lang, digits, calendar and chrome in the same commit — no remount, no RN restart (Yogadirectionon the rootView, 07 §13). Values, page position, focus and undo state are preserved; a polite live-region announcement ("Language: العربية") is made in the new language; an audit event{ event: "locale", old, new }is appended whensettings.audit.enabled. - Language switcher UI: built into
PageNavoverflow (web) / header menu (native) wheneversettings.locales.length > 1; labels use the locale's native name from catalog metadata (neverIntl.DisplayNames, absent on Hermes), each option carries its ownlang/dir. <rasd-form locale="ar" dir="rtl">attributes map to the same props;diris an explicit override for hosts that must force direction.
4. Interpolation and plural rules (Rasd Mini-Message)
Form-authored and chrome strings share one deliberately small ICU MF1-valid subset (00 §4.3a, 04 §11.3): {var}, {n, plural, =0 {…} zero {…} one {…} two {…} few {…} many {…} other {…}} with #, {x, select, a {…} other {…}}, and ' escaping. Nothing else (no rich tags, skeletons or nested selects beyond one level) — this keeps the parser ≈ 2 kB, keeps strings valid for Weblate/Crowdin ICU checks, and stays convertible to MessageFormat 2.
- Plural selection uses compiled CLDR 48 functions per bundled locale (make-plural style, < 1 kB each) in
@rasd/core;Intl.PluralRulesis used only as a fallback for locales without a compiled function and when the engine provides it; elseother. Deterministic across web, Hermes and server. - Categories the validator enforces (
W_PLURAL_CATEGORY_MISSING):
| Locales | Categories required |
|---|---|
ar | zero, one, two, few, many, other |
en, ur, sw, so, ckb, ku, ps, ha, tr | one, other |
fa/prs, am, bn, hi | one (covers 0 and 1), other |
fr, es, pt | one, many, other |
uk, ru, pl | one, few, many, other |
my, ja, zh, vi | other |
- Values inserted by
{var}are formatted perLocaleConfig(numbers → §5, select values → their label, dates →formatDate) and wrapped in bidi isolates (<bdi>on web, U+2066…U+2069 on native) so"يجب ألا يتجاوز {max}"never reorders around a Latin value. - Placeholders must match across locales (
W_PLACEHOLDER_MISMATCH); unparsable strings areE_MESSAGE_SYNTAXat validate time (04 §15). A string that still reachesformatMessage()unparsed throwsRasdErrorRASD_I18N_SYNTAXin dev and returns the source text verbatim in production (17 §2.6) — never a blank label. - Escaping example:
"Enter '{'code'}' as printed"renders literal braces.
5. Numbers, digits, dates and calendars
| Concern | Rule |
|---|---|
| Digit display | settings.numbering (00 §4.3a, 04 §4.1) governs data-entry fields: latn (default) renders inputs with ASCII digits via -u-nu-latn; native renders them with the locale's CLDR default. Display-only strings (formatted numbers in labels and messages, readouts, dates, progress) always follow the locale's CLDR default — arab for ar, ar-JO, ar-LB, ckb; latn for ar-MA, ar-AE, ur; arabext for fa, ps; beng for bn — so the spine default is "latn inputs / locale-native display". settings.localeMeta[locale].numbering overrides per locale. LocaleConfig.numberingSystem is the resolved display id; inputs use latn unless numbering: "native". Either way inputs normalise to ASCII on save. |
| Number inputs | <input type="text" inputmode="numeric|decimal"> (never type="number" for IDs/phones); accepts ٠-٩, ۰-۹, ০-৯, ٫, , and .; normalizeDigits() runs on every change so the stored value is ASCII; grouping (thousandsSeparator) is display-only when unfocused; props.unit renders as an inline-end adornment. |
| Formatting API | formatNumber(n, opts) = cached Intl.NumberFormat(localeWithNu, opts); avoid notation: 'compact'/signDisplay in core (missing on Hermes iOS). Formatter cache: 50 entries LRU per provider (Intl constructors cost 1–5 ms on low-end Android). |
| Dates | Stored ISO-8601 (04 §10.3); displayed with Intl.DateTimeFormat(locale-u-ca-<cal>-nu-<ns>, { dateStyle }). Hermes iOS ignores numberingSystem in DateTimeFormat, so output digits are post-mapped by the same digit table. formatToParts/formatRange are never used in core. |
| Hijri | settings.calendar: "islamic-umalqura" or props.calendar: "hijri" (display/picker only). Web/WebView: Intl calendar islamic-umalqura. Native and any engine failing a self-test at startup: the lazy @rasd/core/i18n/hijri module (Umm al-Qura table 1356–1500 AH ≈ 1937–2077 CE, ~8 kB gz) drives conversion, and month names come from the catalog. Pickers show the primary calendar large and the other small ("١٥ صفر ١٤٤٨ · 30 Jul 2026") to reduce transcription errors. Hijri labels are lang="ar" RTL even in an English UI. |
| Time zone | Display in the device zone; store with the device offset. Never convert respondent-entered dates. |
| Lists / collation | formatList = in-house join tables (", " / "، " and " و"); collator = Intl.Collator(locale, { sensitivity: 'base', ignorePunctuation: true, numeric: true }) + the Arabic normaliser (NFKC, tashkeel/tatweel strip, أإآٱ→ا, ى→ي, ة→ه optional, ك/ک, ي/ی, digit map) used for choice search and dataset label_norm (06 §11). |
Edge cases: an Arabic-Indic digit typed into a barcode or mask field is normalised too; % and +962 tokens are rendered inside an LTR isolate; a range/rating readout is display-only and therefore follows LocaleConfig.numberingSystem; exported CSV/XLSX and every value on the wire always contain ASCII digits.
6. Translation workflow
The builder's Translations tab (08 §7) is the primary tool; everything it does is also available headlessly through @rasd/core (translationReport, applyTranslations command) and @rasd/cli.
stateDiagram-v2
[*] --> missing
missing --> mt: onMachineTranslate
missing --> translated: human edit or import
mt --> translated: reviewed
translated --> stale: source changed
mt --> stale: source changed
stale --> translated: re-translated
- Cell model: rows = every localized slot (path + field, e.g.
pages[2].elements[4].label,choiceLists.yes_no.choices[0].label,…media.audio), columns =settings.locales; the full row inventory is 04 §11.2. Per-cell state (missing | translated | mt | stale, plussrcHash) is stored in the RFD underext["dev.rasd.i18n"].cells["<path>#<locale>"]— Rasd's own reverse-DNS key, round-tripped like anyext(04 §12). On top of that state the grid paints anerroroverlay when the cell fails a Mini-Message check (placeholder mismatch, missing plural category) — the fifth visual state listed in 08 §7. Stale is flagged, never auto-cleared. - Completeness:
translationReport(def)→{ perLocale: { [lc]: { total, translated, missing, stale, mt, percent } }, issues: Issue[] }(Issueper 04 §15 / 17 §2.6). Issues reuse the schema codesW_MISSING_TRANSLATION,W_LABEL_MISSING,W_PLURAL_CATEGORY_MISSING,W_PLACEHOLDER_MISMATCH,W_LOCALE_DIR_MISMATCH(RTL-tagged locale whose first label starts with a Latin strong character — MT leftovers).validateFormDefinition(def, { translations: 'warn' | 'error' })lets a host make missing translations a publish blocker; the builder's Publish button honours it. - Machine translation hook:
onMachineTranslate({ from, to, strings: [{ id, text, context }] }) => Promise<{ id, text }[]>— the<FormBuilder onMachineTranslate>prop of 08 §7; provider-agnostic, batches ≤ 100 strings,contextcarries element type, choices and the per-form glossary/do-not-translate list (WFP,UNRWA, place names). Results land asmt; Tier-1 languages require human review before publish (policy in the host, surfaced by the report). Rasd ships no MT vendor package; docs give Azure AI Translator (F0: 2 M chars/month free) and DeepL recipes (research §9). - Export / import:
- CSV/XLSX — columns
path, field, type, <defaultLocale>, ar, fr, …(Kobo "Update translations" shape so field partners feel at home); UTF-8 with BOM; import shows a diff (added / changed / conflicts) and applies as one undoableapplyTranslationscommand. - XLIFF 2.1 (OASIS; superset of 2.0 — import accepts 2.0 and 2.1, export writes 2.1 and can omit 2.1-only attributes with
xliffVersion: '2.0'): one<file>per form,srcLang/trgLang,<unit id="<path>">,<segment state="initial|translated|reviewed|final">,srcDir/trgDir,<notes>with element type/choices, anmtnote for machine output. Mini-Message placeholders travel verbatim (valid ICU for TMS checks). - XLSForm —
label::Arabic (ar),hint::…,guidance_hint::…,constraint_message::…,required_message::…,media::image::…,settings.default_language, headerName (code); because XLSForm has no fallback, exported cells are filled from the chain or intentionally blank (20 · Interoperability).
- CSV/XLSX — columns
- CLI:
rasd i18n check <form.json>prints the report and exits non-zero on errors;rasd i18n export --format xliff|csv/rasd i18n importmirror the builder.
7. Library UI strings (chrome catalogs)
-
Format: flat JSON per locale, Mini-Message values, namespaces
nav.*,validation.*,a11y.*,sync.*,license.*,media.*,builder.*(builder in a separate file so the field runner never carries it; ~150 runtime keys), plusmeta: { nativeName, englishName, dir, plurals[], numbering, calendar, completeness }. -
Bundled at launch:
en(in the main bundle),ar(MSA, reviewed by a native speaker),fr,esas code-split entries@rasd/react/locales/<lc>(each ≤ 4 kB gz), precached byprecacheForms. Roadmap Tier-1 (same quality bar):uk,fa+prs,ps,ur,ckb,ku,so,am,ti,ha,bn,my,sw,tr,pt,ru; Tier-2 community/MT-seeded on demand. No comparable library shipsckb/so/am/ti/ha/pschrome — a real differentiator. -
Host extension — the
messages/loadLocale/onMissingKey/pluralRulesprops of 17 §3.1:<RasdProvidermessages={{ ar: { 'nav.next': 'التالي' }, 'ar-SD': { /* … */ } }}loadLocale={(lc) => import('./locales/' + lc + '.json')}onMissingKey={(key, lc) => report(key, lc)}pluralRules={{ rhg: (n) => 'other' }}/>Deep-merge, region beats language beats built-in; unknown keys warn once in dev;
loadLocaleis awaited byuseRasdBusy()and never blocks rendering (English shows meanwhile). -
CI (
rasd i18n check --catalogs): everyt()key exists inen, no orphan keys, Mini-Message parses, plural categories complete per CLDR, placeholders identical across locales,meta.completenessregenerated; pseudo-localesen-XA(accents, +40 % length) anden-XB(RTL mirror) are generated at build time for tests. Community workflow: Weblate/Crowdin, per-language CODEOWNERS.
8. RTL layout rules
The active direction is LocaleConfig.dir (settings.localeMeta[locale].dir → built-in list ar, ckb, fa, prs, ps, ur, he, sd, ug → ltr), applied as dir + lang on .rasd-root (web) and Yoga direction on the root View (native — never I18nManager.forceRTL, which needs an app reload).
| Concern | Web (@rasd/react) | Native (@rasd/native) |
|---|---|---|
| Layout | Logical CSS only (margin-inline-start, inset-inline-*, text-align: start); a stylelint rule bans physical left/right in @layer rasd | start/end, marginStart/End, insetInlineStart/End, flexDirection: 'row' under the root direction; a lint bans left/right styles |
| Labels & hints | Inherit dir, implicit unicode-bidi: isolate | Text first-strong (Android) / writingDirection: 'auto' (iOS) |
| Mixed-script values | <bdi> around {var} values, dataset labels, respondent names, choice labels containing Latin codes | U+2066 (LRI) / U+2067 (RLI) / U+2068 (FSI) … U+2069 (PDI) around inserted values |
| Free-text inputs | dir="auto" (unicode-bidi: plaintext) — an English answer in an Arabic form aligns left, an Arabic answer right | textAlign from the first strong char of the current value (writingDirection is iOS-only and broken in TextInput, RN #54399) |
| Numbers, phone, ID, barcode, coordinates, typed dates | LTR island: dir="ltr" + text-align: end; never bidi-override | direction: 'ltr', textAlign: 'left'; expect caret quirks on Android with numbers |
Neutral edge characters (+962, %, (3), /) | Whole token inside the isolate; in placeholder/title use U+200E/U+200F or FSI/PDI | Same control characters |
| Placeholders in another language | Own dir per placeholder language | textAlign per placeholder script |
| Icons | [dir=rtl] [data-part=icon-directional] { transform: scaleX(-1) } for chevrons, back/next, list carets, undo/redo, indent; not for checkmarks, clocks, media controls, search, GPS pins | transform: [{ scaleX: -1 }] on the same set |
| Progress, sliders, range, rating, steppers | Fill from inline-start; RN-style components that do not flip are mirrored manually | Track and thumb mirrored manually when direction === 'rtl' |
| Date pickers | Grid order and month arrows follow dir; Hijri labels <bdi lang="ar"> | Same, in the JS picker |
| Choice lists | Control at inline-start, label text isolated; long Latin values in <bdi> | Same |
| Rank / repeat reorder / builder DnD | Keyboard ← / → for indent/outdent are swapped under dir="rtl"; drop-indicator caret on inline-start; dnd-kit KeyboardSensor codes rebound per instance; ⋯ menu (Move up/down/into…) is the non-drag path (SC 2.5.7); announcements localized | Reorder via ⋯ menu + accessibilityActions; drag optional |
| Fallback strings | dir="auto" + lang | textAlign from first strong char |
| Fonts | typography.fontFamilyRtl when dir="rtl"; per-run Arabic family via unicode-range | fontFamilyRtl when RTL, per-run when a label's first strong char is Arabic-script |
Snapshot/visual tests run every component under en, ar, en-XB and xx-LS (§13).
9. Arabic typography
- Fonts: the default OFL family in
@rasd/themesis Noto Sans Arabic (Noto Naskh Arabic for long consent text is an optional theme asset); IBM Plex Sans Arabic is the documented alternative. Agency Latin fonts (Lato, Proxima Nova, Univers) lack Arabic glyphs, sotypography.fontFamilyRtland per-script fallback are mandatory;rasd theme checkwarns when an RTL locale is declared and no Arabic-capable family is present. Fonts are theme assets that must work offline: WOFF2 precached on web, static TTF (no variable fonts) embedded via theexpo-fontconfig plugin on native (research §8). - Subsetting recipe:
pyftsubset … --unicodes="U+0600-06FF,U+0750-077F,U+08A0-08FF,U+FB50-FDFF,U+FE70-FEFF,U+061C,U+200C-200F,U+2066-2069" --layout-features='*' --flavor=woff2— never dropmark/mkmk(harakat positioning),rlig,init/medi/fina/isol; include the bidi control characters so they do not fall back to another font; useunicode-rangeso Latin-only pages never download the Arabic subset. - Metrics: Arabic glyphs read smaller than Latin at the same size, so the theme applies
typography.lineHeightRtl(default 1.7, vs 1.5 for Latin) and never lets body text drop below 16 px;letter-spacing: 0andtext-transform: noneare forced under[dir=rtl](tracking breaks cursive joining); no faux italic/bold (font-synthesis: none), only real weights (400/500/700 shipped); underlines usetext-decoration-skip-ink: auto+text-underline-offset: 0.15emso dots and descenders stay legible. - Diacritics: tashkeel are never stripped for display (only in the search normaliser); constraint messages authored with harakat must round-trip through XLIFF/CSV unchanged (UTF-8, NFC on save).
- Numerals in text: follow §5; Eastern Arabic digits inside a Latin sentence stay inside their isolate.
10. Low-literacy support and cognitive load
Low literacy (respondents and some enumerators).
- Audio prompts:
media.audioper locale on elements and choices (04 §11.5); a ≥ 48 px play button next to the label. Auto-play is governed bysettings.audioPrompts: "manual" | "auto"(defaultmanual) — proposed, not yet in thesettingstable of 04 §4.1; it lands there or becomes a provider-level policy per the open question below. Inautomode the prompt plays when the question receives focus/page entry, after the first user gesture has unlocked audio (browser autoplay policy), and the enumerator can mute from the form menu (SC 1.4.2). Audio never blocks input, and nothing auto-plays for longer than 3 s without a visible stop control. - Images/icons per choice:
Choice.media.imagerenders an icon grid (appearance.variant: "buttons",columns: 2–3,size: "lg") with the text label under the image. Image-only choices are deliberately not offered: SC 1.1.1 requires the label as thealt/accessible name anyway, so the label always stays visible and doubles as the text alternative. - Large text:
rasd-fieldtheme (baseSize18,control.height/control.minTouch48,borderWidth2,focusRingWidth3 — 12 §1); thespaciousdensity mode takes both to 56 for gloves and sunlight. Font scaling honoured totypography.maxFontScale(2.0); layouts wrap, never clip. - Numeric keypads for
number/phone/ID (inputmode); ODK-quick-style auto-advance for single-select is proposed asappearance.variant: "buttons"+props.autoAdvancerather than anappearance.extflag — whether it ships in v1 and how it is announced to screen-reader users is an open question below. - Rohingya and unwritten languages: ship audio-only labels (
label= a short transliteration for the enumerator,media.audiofor the respondent).
Cognitive load guidance for enumerators (documented in the builder's Problems panel as lint hints, not errors):
- Prefer
navigation: "paged"with one topic per page (the builder hints above ~12 questions on one page — an ergonomics lint in the Problems panel, not aW_*code from the validation catalogue of 04 §15, which is exhaustive for v1.0); show progress ("3 of 7") anditemLabelon repeat rows. - Plain language, active voice, no double negatives; hints under 120 characters; guidance for definitions.
- Consistent placement: label → hint → control → error; Next always at the same position; no timeouts (autosave, resumable drafts).
- Pre-fill and calculate (
default.expr,calculate,pulldata) so nothing is typed twice (SC 3.3.7);readonlydisplay of preloaded facts. - Chunk long IDs with
mask; confirm destructive actions (confirmDelete); a review screen before finalize with an error summary that jumps to fields. - Sensitive questions: neutral wording,
bind.sensitivemasking,consentfirst, withdraw path visible.
11. WCAG 2.2 AA conformance mapping (form controls)
| SC | Level | What Rasd does (web / native) |
|---|---|---|
| 1.1.1 Non-text content | A | Choice/label images use the label as alt/accessibilityLabel; decorative icons aria-hidden / importantForAccessibility="no"; audio prompts have a text label |
| 1.3.1 Info & relationships | A | <label for>, <fieldset><legend> for choice groups (role="radiogroup"/group), headings for pages/groups; RN accessibilityRole="header", radiogroup, accessibilityLabelledBy |
| 1.3.4 Orientation | AA | No orientation lock: portrait and landscape both render; the 320 px reflow rule (1.4.10) and PageNav sticky behaviour are orientation-independent; native does not set screenOrientation |
| 1.3.5 Identify input purpose | AA | autocomplete tokens on format: email/phone/url fields unless bind.sensitive (respondent PII must not enter browser autofill) |
| 1.4.1 Use of colour | A | Errors = icon + text + border; required = * + "required" text; never colour alone |
| 1.4.2 Audio control | A | Audio prompts (§10) are manual by default; auto mode plays only after a user gesture, exposes a visible stop/mute control on the prompt and in the form menu, and never overlaps two clips |
| 1.4.3 / 1.4.11 Contrast | AA | Default themes ≥ 4.5:1 text, ≥ 3:1 borders/focus/checkmarks; rasd theme check fails themes below; rasd-high-contrast theme; forced-colors respected |
| 1.4.4 Resize text / 1.4.12 spacing | AA | rem sizing, no fixed heights, tested at 200 % zoom; RN font scale to 2.0 |
| 1.4.10 Reflow | AA | 320 px CSS width without horizontal scroll; matrix stacks below 600 px; wide tables scroll inside their container |
| 1.4.13 Content on hover/focus | AA | Guidance popovers dismissible (Esc), hoverable, persistent |
| 2.1.1 / 2.1.2 Keyboard, no trap | A | Every control operable; Enter never submits from single-line inputs; arrow keys in radio groups/rank; sheets and pickers trap focus only while open and return it on close |
| 2.2.1 Timing | A | No time limits; autosave every autosaveMs |
| 2.4.3 Focus order | A | DOM order = visual order in both directions; page change moves focus to the page heading |
| 2.4.6 Headings & labels | AA | Page/group titles as headings; W_LABEL_MISSING |
| 2.4.7 Focus visible | AA | 2 px ring, 3:1, :focus-visible, never outline: none without replacement |
| 2.4.11 Focus not obscured (min) | AA | Sticky PageNav + scroll-padding-bottom; native scroll-to-focused with 96 dp margin above the keyboard |
| 2.5.3 Label in name | A | Accessible name starts with the visible label |
| 2.5.7 Dragging movements | AA | Rank, repeat reorder, builder DnD all have ⋯ menu / accessibilityActions equivalents |
| 2.5.8 Target size (min) | AA | control.minTouch 48 px in every bundled theme and every density (spacious 56; compact stays at the 48 px floor — the resolver clamps up to 48, 00 §10); WCAG's 24×24 px hard floor is never approached and rasd theme check errors below it (12 §9); inline links exempt |
| 3.1.1 / 3.1.2 Language | A / AA | lang on .rasd-root; lang on fallback strings and Hijri labels |
| 3.2.1 / 3.2.2 On focus / on input | A | Locale switch and auto-advance never move focus unexpectedly; quick advance is opt-in and announced |
| 3.2.6 Consistent help | A | Guidance toggle and language switcher in the same place on every page |
| 3.3.1 / 3.3.3 Error identification & suggestion | A / AA | Text error bound via aria-describedby, aria-invalid; messages say what to do (constraintMessage, ranges); finalize summary lists errors with jump links |
| 3.3.2 Labels or instructions | A | Visible labels always; placeholders never the only label |
| 3.3.4 Error prevention (legal/data) | AA | Review screen before finalize; confirmDelete; consent withdrawal |
| 3.3.7 Redundant entry | A | Prefills, calculate, pulldata; values persist across pages |
| 3.3.8 Accessible authentication | AA | N/A — Rasd delegates auth to the host |
| 4.1.2 Name, role, value | A | Native semantics or full ARIA patterns (§12); custom x:* elements must use FieldWrapper or replicate the accessible-props contract |
| 4.1.3 Status messages | AA | Autosave/sync status aria-live="polite"; finalize failure role="alert"; per-page error count announced once (debounced 500 ms), not per keystroke |
12. Screen-reader semantics on web and React Native
| Element type | Web pattern | RN props |
|---|---|---|
text, number, date/time | <input>/<textarea> + <label for>, aria-describedby = hint + error, aria-invalid, aria-required, inputmode | TextInput accessibilityLabel = label (+ ", required"), accessibilityLabelledBy (Android), accessibilityHint = hint, accessibilityState.invalid via error text with accessibilityLiveRegion="polite" |
select_one (radio/buttons/chips) | <fieldset><legend> + role="radiogroup"; native radios or role="radio" aria-checked | Container accessibilityRole="radiogroup" + accessibilityLabel; options radio + accessibilityState.checked; iOS: first option's accessibilityHint repeats the question (VoiceOver skips group labels) |
select_multiple | role="group" + checkboxes | checkbox + accessibilityState.checked; selected-count in Sheet footer announced |
| Search select (dataset) | WAI-ARIA combobox: role="combobox", aria-expanded, aria-activedescendant, listbox options windowed | Trigger button opens Sheet; results list accessibilityRole="list"; count announced |
rank | Listbox with "Move up/down" buttons per item; keyboard arrows; live announcements ("Moved Water to position 2 of 5") | Cards with accessibilityActions increment/decrement + ⋯ menu; announceForAccessibility on move |
rating, range | role="slider"/radiogroup, aria-valuemin/max/now/text | accessibilityRole="adjustable", accessibilityValue, accessibilityActions increment/decrement |
checkbox, consent (tap) | Native checkbox; consent statement in a labelled region | switch/checkbox; statement Text focusable and readable |
matrix | <table> with row/column headers, or stacked cards with aria-labelledby per cell below 600 px | Row groups accessibilityRole="header" per row label; each cell labelled "row – column" |
geopoint, image, signature, barcode, audio | Buttons with explicit names ("Capture location"), status text live-polite ("Accuracy 8 m"), thumbnails with alt | button + accessibilityLabel; capture status via announceForAccessibility |
note | Region with heading; collapsible = <button aria-expanded> | Text; collapsible Pressable + accessibilityState.expanded |
group, repeat | <section aria-labelledby>; repeat rows as <article> with itemLabel; add/remove buttons named with the row label | Row header + ⋯ menu; adding a row moves focus to its first field |
| Page navigation | Prev/Next/Finalize buttons; page heading focused on change; progress role="progressbar" + text "3 of 7" | Buttons; setAccessibilityFocus to heading; progress accessibilityValue.text |
| Error summary | role="alert" container with a list of <a href="#field"> links | Focused summary; items are buttons that jump |
Screen readers must never hear raw REL, Mini-Message syntax or attachment ids; hidden/calculate elements render nothing.
13. Testing
| Check | Web | Native | Gate |
|---|---|---|---|
| Automated a11y | vitest-axe on every story (0 violations, en/ar/en-XB), @axe-core/playwright on example apps | eslint-plugin-react-native-a11y; RNTL role/name queries; every control asserts role + name + state | PR |
| RTL / pseudo-locale snapshots | Storybook 10 matrix en · ar · en-XB · xx-LS × light · dark · highContrast × font scale 1.0 · 1.3 · 2.0; Chromatic diffs; Playwright projects chromium-ar-rtl | @storybook/react-native-web-vite same matrix; Maestro rtl.yaml (Arabic form in an English host, no restart) | PR / nightly |
| Bidi unit tests | Assert <bdi> around every interpolated value, dir="auto" on free-text inputs and fallback strings, LTR islands on numeric fields | Assert isolates U+2066–2069 in formatted messages, textAlign rules | PR |
| i18n lint | rasd i18n check --catalogs (keys, orphans, syntax, plurals, placeholders); rasd i18n check on examples/*.form.json | same | PR |
| Formatting determinism | Golden tests for formatNumber/formatDate/pluralCategory per locale on V8, SpiderMonkey, JavaScriptCore | Same goldens on Hermes (Android 7 ICU 56 device profile, iOS 17) | nightly |
| Manual screen-reader matrix (per minor release) | NVDA + Firefox, NVDA + Chrome, JAWS + Chrome, VoiceOver + Safari (macOS, iOS 17), TalkBack + Chrome (Android 9, 13) | TalkBack (Android 9, 13), VoiceOver (iOS 17) on the Expo example | release |
| Contrast / target size | rasd theme check on all bundled themes and every theme in examples/ | same | PR |
| Translation round-trip | Playwright: CSV and XLIFF export → edit → import is lossless; stale/mt states survive | — | PR |
Pseudo-localization: en-XA catches truncation and hard-coded strings; en-XB catches physical CSS and unflipped icons; xx-LS (+40 % length, Arabic-script filler) catches clipping in Sheets, buttons and matrix headers.
14. Failure modes, security and performance
14.1 Failure modes and degradation
Nothing in this document may cost the enumerator data or block a form from rendering. Every row degrades to something readable.
| Failure | Detection | Behaviour |
|---|---|---|
Chrome catalog for the negotiated locale fails to load (loadLocale rejects, offline, 404) | promise rejection | English (bundled) chrome renders; one dev console warning; onMissingKey fires per key; useRasdBusy() clears; the form itself still shows its own localized content |
Requested locale is not in settings.locales | negotiation (§3) | Falls through device locales → settings.defaultLocale; the requested tag is kept in the audit locale event; never a blank form |
A LocalizedString has no value for the active locale | resolveLocalized() | Fallback chain §2; dev shows [name], production shows the empty string; W_MISSING_TRANSLATION was already raised at validate time |
| Mini-Message string unparsable at runtime | formatMessage() | Throws RASD_I18N_SYNTAX in dev, returns the source text verbatim in production; E_MESSAGE_SYNTAX blocks publish earlier |
No compiled plural function for the locale and no Intl.PluralRules (Hermes) | startup capability probe | pluralCategory() returns other; the other branch is mandatory, so a message always renders; rasd i18n check had warned |
Engine rejects calendar: "islamic-umalqura" (startup self-test fails) | self-test | Lazy @rasd/core/i18n/hijri table module takes over; dates outside 1356–1500 AH fall back to Gregorian only, with a diagnostic |
Hermes iOS ignores numberingSystem in DateTimeFormat | known engine gap | Output digits are post-mapped by the digit table (§5); goldens cover it |
| Arabic font asset missing or not precached | document.fonts / native font check | System Arabic fallback family; font-synthesis: none keeps faux bold off; rasd theme check had warned at build time |
Form locale is RTL but I18nManager.isRTL is false | native root | Yoga direction on the root View flips the form anyway — no app restart, and the surrounding host app is untouched (07 §13) |
Autoplay policy blocks an auto audio prompt | play promise rejection | Silently degrades to manual until the first user gesture; the play button stays visible; no error toast |
| Paste mixes Arabic-Indic, Extended and ASCII digits into a number field | normalizeDigits() on change | Digits are mapped; a remainder that is still not numeric becomes a visible constraint error, never a silent truncation |
| Locale switched mid-draft | setLocale() | Values, page, focus, undo stack preserved; polite announcement in the new language; audit event locale; submission.meta.locale records the locale in force at finalize |
| Rapid validation churn while a screen reader is active | live-region debounce | One announcement per 500 ms window; per-keystroke chatter is suppressed (SC 4.1.3) |
14.2 Security and privacy
- Catalogs and translations are data, never code. Host
messages/loadLocaleresults and RFD strings are inserted as text; the Markdown subset of 04 §11.4 is the only rich path and goes through the DOMPurify allow-list on web / nativeTexton native. NodangerouslySetInnerHTML, noeval, no template compilation at runtime. - Bidi spoofing. Rasd adds isolates (
<bdi>, U+2066–2069) itself and strips the bidi override characters U+202A–U+202E from dataset rows, respondent free text and imported translations before display, so a crafted label cannot reorder a rendered value (Trojan-Source-style attack on a reviewer reading a submission). Isolate characters are preserved; overrides are not. - Machine translation is an egress channel.
onMachineTranslatesends form-definition strings to a host-chosen third party — never submission data, never attachments. It is opt-in (the prop is absent by default), batched, and the glossary/DNT list travels with it. Organisations must clear the provider in their DPIA (16 · Security & data protection); air-gapped deployments simply omit the prop. - Autofill.
autocompleteis suppressed onbind.sensitivefields so respondent PII never lands in the enumerator's browser profile (SC 1.3.5 row in §11). - Announcements leak nothing. Live regions and
accessibilityLabels carry resolved labels only — never raw REL, Mini-Message source, attachment ids, tokens orextpayloads (§12). - Pseudo-locales are dev-only.
en-XA,en-XBandxx-LSare generated at build time for tests and are excluded from published bundles;rasd i18n check --catalogsfails if one is listed insettings.localesof an example form.
14.3 Performance
| Budget | Target | Note |
|---|---|---|
i18n primitives in @rasd/core | ≤ 5 kB gz | Inside the 45 kB core budget (00 §12); Mini-Message parser ≈ 2 kB |
| Compiled plural function per bundled locale | < 1 kB gz | make-plural style; no @formatjs/* (the polyfill path costs ~150 kB gz on Hermes — research §2) |
| Chrome catalog per locale | ≤ 4 kB gz | Code-split entry, precached by precacheForms; builder.* lives in a separate file so the field runner never carries it |
| Hijri table module | ~8 kB gz | Lazy; loaded only when the engine self-test fails or a form asks for islamic-umalqura |
| Arabic WOFF2 subset | ≤ 40 kB | unicode-range keeps it off Latin-only pages; static TTF on native |
Intl formatter construction | 1–5 ms on low-end Android | 50-entry LRU per provider keyed by locale + options; never constructed inside a render |
Locale switch (setLocale) | < 100 ms to first paint, 200-element form, 2019 mid-range Android | One LocaleConfig rebuild + one commit; no remount, no storage read |
translationReport() on a 5 000-cell grid | < 200 ms | Runs in the builder's validation worker (08 §14); the grid is virtualised |
| Choice search with the Arabic normaliser | < 16 ms per keystroke, 10 k rows | Normalised label_norm is precomputed at dataset write time (06 §11), not per keystroke |
15. Acceptance criteria
-
resolveLocalized()implements the chain in §2 (incl.ckb≠ku,prs→fa); fallback text renders withlanganddir="auto"on web and first-strong alignment on native. -
negotiateLocale()follows §3; the enumerator's choice persists per form;setLocale()switchesdir, digits, calendar and chrome in one commit without losing values, focus or page. - Mini-Message parser rejects everything outside the subset; plural selection is identical on V8, JSC and Hermes for all bundled locales;
W_PLURAL_CATEGORY_MISSING/W_PLACEHOLDER_MISMATCHfire correctly (Arabic six categories,fazero-as-one). -
settings.numbering: "latn"(default) renders inputs with ASCII digits while display-only strings keep the locale's CLDR digits;"native"uses CLDR digits in inputs too —arabforar-JO,latnforar-MA; typing٤٢٫٥stores42.5; exports and wire payloads contain ASCII digits only. - Hijri display works on web via
Intland on Hermes via the table module; both calendars visible in the picker. - Translations tab: completeness %,
stale/mtnever auto-cleared, CSV and XLIFF 2.1 export→import lossless,onMachineTranslatebatches ≤ 100 with glossary context,W_LOCALE_DIR_MISMATCHshown. - Chrome catalogs
en,ar,fr,espassrasd i18n check; hostmessagesoverrides deep-merge with region > language > built-in; runtime bundle contains nobuilder.*strings. - RTL table (§8) passes for every element type: no physical CSS in
@layer rasd, directional icons flip and non-directional do not, numeric/ID inputs are LTR islands, keyboard indent/outdent flips, DnD/rank/reorder have a non-drag path. - Arabic text uses
fontFamilyRtl,lineHeightRtl1.7, no letter-spacing; the default Arabic font subset keepsmark/mkmk/rligand bidi controls; theme check warns on missing Arabic family. - Every element story is axe-clean in
en,ar,en-XBat font scale 2.0 and passes the WCAG table (§11): targets ≥ 48 px (≥ 24 hard floor), focus ring 2 px 3:1, focus never obscured, errors bound byaria-describedby, status messages via live regions. - TalkBack and VoiceOver: role, name, state and error announcements for every element (§12); page change focuses the heading; audio prompts and choice images have text alternatives.
- Manual SR matrix and Maestro
rtl.yamlpass on the release candidate; formatting goldens pass on the Android 7 device profile.
Open questions
- Should
settings.numberingaccept explicit CLDR ids ("arab","arabext","beng") in addition tolatn | native, for forms whose locale default is wrong for the operation (e.g. anarform for Morocco)? - Is a form-level
settings.audioPromptsthe right home, or should audio auto-play be a renderer/provider policy (device-level enumerator preference)? - Should
autocompletedefault toofffor all fields in enumerator-administered forms (respondent PII), with opt-in per element for self-administered surveys? - Do we need
props.autoAdvance(ODKquick) onselect_onein v1, and how is it announced to screen-reader users? - Which Tier-1 locales beyond
en/ar/fr/esship in the first two minors, and who owns Arabic (MSA) review — the founder's team or a paid reviewer? - Should the manual SR matrix include Windows Narrator + Edge, given UN desktop fleets?
- Does the bidi-override strip (§14.2) apply to form-authored labels too, or only to dataset rows and respondent free text? Stripping everywhere is safer; a form author who deliberately embeds U+202E in a test form would lose it silently.
- Should
validateFormDefinitionfoldtranslationReportissues in by default, or stay opt-in via{ translations: 'warn' | 'error' }? Folding them in makes every host see i18n warnings; keeping them separate keeps the load path cheap on device.
Related documents
00 · Decisions & conventions · 02 · Requirements · 04 · Form schema spec · 05 · Logic & expressions · 06 · Renderer React · 07 · Renderer native · 08 · Builder · 11 · PWA & embedding · 12 · Theming · 14 · Media & field capture · 16 · Security & data protection · 17 · API reference · 18 · Engineering practices · 20 · Interoperability · Research: 13 · i18n runtime, 07 · Theming, 09 · Field features, 01 · Landscape, 03 · DnD