Skip to main content

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 (LocalizedString in the RFD, resolved by @rasd/core), library chrome (flat JSON catalogs, en/ar/fr/es at launch, host-overridable), and formatting (native Intl.NumberFormat/DateTimeFormat/Collator only — compiled CLDR plural functions instead of a required Intl.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> against settings.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-JOardefaultLocale → any → name/empty), never blank like XLSForm; fallback text renders with lang + dir="auto", and the builder shows completeness, stale, mt and plural/placeholder errors.
  • Digits and calendars are settings, not locale side effects: settings.numbering (latn inputs by default), settings.calendar (islamic-umalqura display), inputs always normalise to ASCII on save.
  • RTL is by construction: dir on .rasd-root / Yoga direction on 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-describedby errors, focus never obscured, aria-live status 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, and rasd i18n check in CI.

1. Model: three layers, two locales

LayerWhere it livesOwnerResolved 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 overrideuseLocale().t(key, vars)
Formatting (numbers, dates, lists, collation, plurals)@rasd/core i18n primitives + native IntlRuntimeuseLocale().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):

  1. exact tag (ar-JO);
  2. language-only parent (ar), then any other region of the same language (ar-*, in settings.locales order);
  3. settings.defaultLocale;
  4. any locale that has a non-empty value;
  5. dev builds: the element name in [brackets]; production: empty string (and W_MISSING_TRANSLATION at 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 against settings.locales wins.
  • Persistence: the enumerator's explicit choice is stored in storage.kv under locale:<formId> (and locale:* when the host passes rememberLocale: 'global'); a draft resumes in the locale it was last edited in (submission.meta.locale).
  • Switching: engine.setLocale(locale) and useLocale().setLocale() rebuild one LocaleConfig; the renderer updates dir, lang, digits, calendar and chrome in the same commit — no remount, no RN restart (Yoga direction on the root View, 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 when settings.audit.enabled.
  • Language switcher UI: built into PageNav overflow (web) / header menu (native) whenever settings.locales.length > 1; labels use the locale's native name from catalog metadata (never Intl.DisplayNames, absent on Hermes), each option carries its own lang/dir.
  • <rasd-form locale="ar" dir="rtl"> attributes map to the same props; dir is 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.PluralRules is used only as a fallback for locales without a compiled function and when the engine provides it; else other. Deterministic across web, Hermes and server.
  • Categories the validator enforces (W_PLURAL_CATEGORY_MISSING):
LocalesCategories required
arzero, one, two, few, many, other
en, ur, sw, so, ckb, ku, ps, ha, trone, other
fa/prs, am, bn, hione (covers 0 and 1), other
fr, es, ptone, many, other
uk, ru, plone, few, many, other
my, ja, zh, viother
  • Values inserted by {var} are formatted per LocaleConfig (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 are E_MESSAGE_SYNTAX at validate time (04 §15). A string that still reaches formatMessage() unparsed throws RasdError RASD_I18N_SYNTAX in 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

ConcernRule
Digit displaysettings.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 APIformatNumber(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).
DatesStored 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.
Hijrisettings.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 zoneDisplay in the device zone; store with the device offset. Never convert respondent-entered dates.
Lists / collationformatList = 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, plus srcHash) is stored in the RFD under ext["dev.rasd.i18n"].cells["<path>#<locale>"] — Rasd's own reverse-DNS key, round-tripped like any ext (04 §12). On top of that state the grid paints an error overlay 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[] } (Issue per 04 §15 / 17 §2.6). Issues reuse the schema codes W_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, context carries element type, choices and the per-form glossary/do-not-translate list (WFP, UNRWA, place names). Results land as mt; 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 undoable applyTranslations command.
    • 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, an mt note for machine output. Mini-Message placeholders travel verbatim (valid ICU for TMS checks).
    • XLSFormlabel::Arabic (ar), hint::…, guidance_hint::…, constraint_message::…, required_message::…, media::image::…, settings.default_language, header Name (code); because XLSForm has no fallback, exported cells are filled from the chain or intentionally blank (20 · Interoperability).
  • CLI: rasd i18n check <form.json> prints the report and exits non-zero on errors; rasd i18n export --format xliff|csv / rasd i18n import mirror 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), plus meta: { nativeName, englishName, dir, plurals[], numbering, calendar, completeness }.

  • Bundled at launch: en (in the main bundle), ar (MSA, reviewed by a native speaker), fr, es as code-split entries @rasd/react/locales/<lc> (each ≤ 4 kB gz), precached by precacheForms. 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 ships ckb/so/am/ti/ha/ps chrome — a real differentiator.

  • Host extension — the messages / loadLocale / onMissingKey / pluralRules props of 17 §3.1:

    <RasdProvider
    messages={{ 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; loadLocale is awaited by useRasdBusy() and never blocks rendering (English shows meanwhile).

  • CI (rasd i18n check --catalogs): every t() key exists in en, no orphan keys, Mini-Message parses, plural categories complete per CLDR, placeholders identical across locales, meta.completeness regenerated; pseudo-locales en-XA (accents, +40 % length) and en-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, ugltr), applied as dir + lang on .rasd-root (web) and Yoga direction on the root View (native — never I18nManager.forceRTL, which needs an app reload).

ConcernWeb (@rasd/react)Native (@rasd/native)
LayoutLogical CSS only (margin-inline-start, inset-inline-*, text-align: start); a stylelint rule bans physical left/right in @layer rasdstart/end, marginStart/End, insetInlineStart/End, flexDirection: 'row' under the root direction; a lint bans left/right styles
Labels & hintsInherit dir, implicit unicode-bidi: isolateText first-strong (Android) / writingDirection: 'auto' (iOS)
Mixed-script values<bdi> around {var} values, dataset labels, respondent names, choice labels containing Latin codesU+2066 (LRI) / U+2067 (RLI) / U+2068 (FSI) … U+2069 (PDI) around inserted values
Free-text inputsdir="auto" (unicode-bidi: plaintext) — an English answer in an Arabic form aligns left, an Arabic answer righttextAlign 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 datesLTR island: dir="ltr" + text-align: end; never bidi-overridedirection: '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/PDISame control characters
Placeholders in another languageOwn dir per placeholder languagetextAlign 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 pinstransform: [{ scaleX: -1 }] on the same set
Progress, sliders, range, rating, steppersFill from inline-start; RN-style components that do not flip are mirrored manuallyTrack and thumb mirrored manually when direction === 'rtl'
Date pickersGrid order and month arrows follow dir; Hijri labels <bdi lang="ar">Same, in the JS picker
Choice listsControl at inline-start, label text isolated; long Latin values in <bdi>Same
Rank / repeat reorder / builder DnDKeyboard ← / → 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 localizedReorder via ⋯ menu + accessibilityActions; drag optional
Fallback stringsdir="auto" + langtextAlign from first strong char
Fontstypography.fontFamilyRtl when dir="rtl"; per-run Arabic family via unicode-rangefontFamilyRtl 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/themes is 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, so typography.fontFamilyRtl and per-script fallback are mandatory; rasd theme check warns 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 the expo-font config 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 drop mark/mkmk (harakat positioning), rlig, init/medi/fina/isol; include the bidi control characters so they do not fall back to another font; use unicode-range so 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: 0 and text-transform: none are forced under [dir=rtl] (tracking breaks cursive joining); no faux italic/bold (font-synthesis: none), only real weights (400/500/700 shipped); underlines use text-decoration-skip-ink: auto + text-underline-offset: 0.15em so 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.audio per locale on elements and choices (04 §11.5); a ≥ 48 px play button next to the label. Auto-play is governed by settings.audioPrompts: "manual" | "auto" (default manual) — proposed, not yet in the settings table of 04 §4.1; it lands there or becomes a provider-level policy per the open question below. In auto mode 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.image renders 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 the alt/accessible name anyway, so the label always stays visible and doubles as the text alternative.
  • Large text: rasd-field theme (baseSize 18, control.height/control.minTouch 48, borderWidth 2, focusRingWidth 3 — 12 §1); the spacious density mode takes both to 56 for gloves and sunlight. Font scaling honoured to typography.maxFontScale (2.0); layouts wrap, never clip.
  • Numeric keypads for number/phone/ID (inputmode); ODK-quick-style auto-advance for single-select is proposed as appearance.variant: "buttons" + props.autoAdvance rather than an appearance.ext flag — 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.audio for 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 a W_* code from the validation catalogue of 04 §15, which is exhaustive for v1.0); show progress ("3 of 7") and itemLabel on 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); readonly display 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.sensitive masking, consent first, withdraw path visible.

11. WCAG 2.2 AA conformance mapping (form controls)

SCLevelWhat Rasd does (web / native)
1.1.1 Non-text contentAChoice/label images use the label as alt/accessibilityLabel; decorative icons aria-hidden / importantForAccessibility="no"; audio prompts have a text label
1.3.1 Info & relationshipsA<label for>, <fieldset><legend> for choice groups (role="radiogroup"/group), headings for pages/groups; RN accessibilityRole="header", radiogroup, accessibilityLabelledBy
1.3.4 OrientationAANo 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 purposeAAautocomplete tokens on format: email/phone/url fields unless bind.sensitive (respondent PII must not enter browser autofill)
1.4.1 Use of colourAErrors = icon + text + border; required = * + "required" text; never colour alone
1.4.2 Audio controlAAudio 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 ContrastAADefault 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 spacingAArem sizing, no fixed heights, tested at 200 % zoom; RN font scale to 2.0
1.4.10 ReflowAA320 px CSS width without horizontal scroll; matrix stacks below 600 px; wide tables scroll inside their container
1.4.13 Content on hover/focusAAGuidance popovers dismissible (Esc), hoverable, persistent
2.1.1 / 2.1.2 Keyboard, no trapAEvery 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 TimingANo time limits; autosave every autosaveMs
2.4.3 Focus orderADOM order = visual order in both directions; page change moves focus to the page heading
2.4.6 Headings & labelsAAPage/group titles as headings; W_LABEL_MISSING
2.4.7 Focus visibleAA2 px ring, 3:1, :focus-visible, never outline: none without replacement
2.4.11 Focus not obscured (min)AASticky PageNav + scroll-padding-bottom; native scroll-to-focused with 96 dp margin above the keyboard
2.5.3 Label in nameAAccessible name starts with the visible label
2.5.7 Dragging movementsAARank, repeat reorder, builder DnD all have ⋯ menu / accessibilityActions equivalents
2.5.8 Target size (min)AAcontrol.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 LanguageA / AAlang on .rasd-root; lang on fallback strings and Hijri labels
3.2.1 / 3.2.2 On focus / on inputALocale switch and auto-advance never move focus unexpectedly; quick advance is opt-in and announced
3.2.6 Consistent helpAGuidance toggle and language switcher in the same place on every page
3.3.1 / 3.3.3 Error identification & suggestionA / AAText 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 instructionsAVisible labels always; placeholders never the only label
3.3.4 Error prevention (legal/data)AAReview screen before finalize; confirmDelete; consent withdrawal
3.3.7 Redundant entryAPrefills, calculate, pulldata; values persist across pages
3.3.8 Accessible authenticationAAN/A — Rasd delegates auth to the host
4.1.2 Name, role, valueANative semantics or full ARIA patterns (§12); custom x:* elements must use FieldWrapper or replicate the accessible-props contract
4.1.3 Status messagesAAAutosave/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 typeWeb patternRN props
text, number, date/time<input>/<textarea> + <label for>, aria-describedby = hint + error, aria-invalid, aria-required, inputmodeTextInput 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-checkedContainer accessibilityRole="radiogroup" + accessibilityLabel; options radio + accessibilityState.checked; iOS: first option's accessibilityHint repeats the question (VoiceOver skips group labels)
select_multiplerole="group" + checkboxescheckbox + accessibilityState.checked; selected-count in Sheet footer announced
Search select (dataset)WAI-ARIA combobox: role="combobox", aria-expanded, aria-activedescendant, listbox options windowedTrigger button opens Sheet; results list accessibilityRole="list"; count announced
rankListbox 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, rangerole="slider"/radiogroup, aria-valuemin/max/now/textaccessibilityRole="adjustable", accessibilityValue, accessibilityActions increment/decrement
checkbox, consent (tap)Native checkbox; consent statement in a labelled regionswitch/checkbox; statement Text focusable and readable
matrix<table> with row/column headers, or stacked cards with aria-labelledby per cell below 600 pxRow groups accessibilityRole="header" per row label; each cell labelled "row – column"
geopoint, image, signature, barcode, audioButtons with explicit names ("Capture location"), status text live-polite ("Accuracy 8 m"), thumbnails with altbutton + accessibilityLabel; capture status via announceForAccessibility
noteRegion 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 labelRow header + ⋯ menu; adding a row moves focus to its first field
Page navigationPrev/Next/Finalize buttons; page heading focused on change; progress role="progressbar" + text "3 of 7"Buttons; setAccessibilityFocus to heading; progress accessibilityValue.text
Error summaryrole="alert" container with a list of <a href="#field"> linksFocused 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

CheckWebNativeGate
Automated a11yvitest-axe on every story (0 violations, en/ar/en-XB), @axe-core/playwright on example appseslint-plugin-react-native-a11y; RNTL role/name queries; every control asserts role + name + statePR
RTL / pseudo-locale snapshotsStorybook 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 testsAssert <bdi> around every interpolated value, dir="auto" on free-text inputs and fallback strings, LTR islands on numeric fieldsAssert isolates U+2066–2069 in formatted messages, textAlign rulesPR
i18n lintrasd i18n check --catalogs (keys, orphans, syntax, plurals, placeholders); rasd i18n check on examples/*.form.jsonsamePR
Formatting determinismGolden tests for formatNumber/formatDate/pluralCategory per locale on V8, SpiderMonkey, JavaScriptCoreSame 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 examplerelease
Contrast / target sizerasd theme check on all bundled themes and every theme in examples/samePR
Translation round-tripPlaywright: CSV and XLIFF export → edit → import is lossless; stale/mt states survivePR

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.

FailureDetectionBehaviour
Chrome catalog for the negotiated locale fails to load (loadLocale rejects, offline, 404)promise rejectionEnglish (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.localesnegotiation (§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 localeresolveLocalized()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 runtimeformatMessage()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 probepluralCategory() 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-testLazy @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 DateTimeFormatknown engine gapOutput digits are post-mapped by the digit table (§5); goldens cover it
Arabic font asset missing or not precacheddocument.fonts / native font checkSystem 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 falsenative rootYoga 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 promptplay promise rejectionSilently 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 fieldnormalizeDigits() on changeDigits are mapped; a remainder that is still not numeric becomes a visible constraint error, never a silent truncation
Locale switched mid-draftsetLocale()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 activelive-region debounceOne 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 / loadLocale results 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 / native Text on native. No dangerouslySetInnerHTML, no eval, 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. onMachineTranslate sends 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. autocomplete is suppressed on bind.sensitive fields 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 or ext payloads (§12).
  • Pseudo-locales are dev-only. en-XA, en-XB and xx-LS are generated at build time for tests and are excluded from published bundles; rasd i18n check --catalogs fails if one is listed in settings.locales of an example form.

14.3 Performance

BudgetTargetNote
i18n primitives in @rasd/core≤ 5 kB gzInside the 45 kB core budget (00 §12); Mini-Message parser ≈ 2 kB
Compiled plural function per bundled locale< 1 kB gzmake-plural style; no @formatjs/* (the polyfill path costs ~150 kB gz on Hermes — research §2)
Chrome catalog per locale≤ 4 kB gzCode-split entry, precached by precacheForms; builder.* lives in a separate file so the field runner never carries it
Hijri table module~8 kB gzLazy; loaded only when the engine self-test fails or a form asks for islamic-umalqura
Arabic WOFF2 subset≤ 40 kBunicode-range keeps it off Latin-only pages; static TTF on native
Intl formatter construction1–5 ms on low-end Android50-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 AndroidOne LocaleConfig rebuild + one commit; no remount, no storage read
translationReport() on a 5 000-cell grid< 200 msRuns in the builder's validation worker (08 §14); the grid is virtualised
Choice search with the Arabic normaliser< 16 ms per keystroke, 10 k rowsNormalised label_norm is precomputed at dataset write time (06 §11), not per keystroke

15. Acceptance criteria

  • resolveLocalized() implements the chain in §2 (incl. ckbku, prsfa); fallback text renders with lang and dir="auto" on web and first-strong alignment on native.
  • negotiateLocale() follows §3; the enumerator's choice persists per form; setLocale() switches dir, 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_MISMATCH fire correctly (Arabic six categories, fa zero-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 — arab for ar-JO, latn for ar-MA; typing ٤٢٫٥ stores 42.5; exports and wire payloads contain ASCII digits only.
  • Hijri display works on web via Intl and on Hermes via the table module; both calendars visible in the picker.
  • Translations tab: completeness %, stale/mt never auto-cleared, CSV and XLIFF 2.1 export→import lossless, onMachineTranslate batches ≤ 100 with glossary context, W_LOCALE_DIR_MISMATCH shown.
  • Chrome catalogs en, ar, fr, es pass rasd i18n check; host messages overrides deep-merge with region > language > built-in; runtime bundle contains no builder.* 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, lineHeightRtl 1.7, no letter-spacing; the default Arabic font subset keeps mark/mkmk/rlig and bidi controls; theme check warns on missing Arabic family.
  • Every element story is axe-clean in en, ar, en-XB at 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 by aria-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.yaml pass on the release candidate; formatting goldens pass on the Android 7 device profile.

Open questions

  • Should settings.numbering accept explicit CLDR ids ("arab", "arabext", "beng") in addition to latn | native, for forms whose locale default is wrong for the operation (e.g. an ar form for Morocco)?
  • Is a form-level settings.audioPrompts the right home, or should audio auto-play be a renderer/provider policy (device-level enumerator preference)?
  • Should autocomplete default to off for all fields in enumerator-administered forms (respondent PII), with opt-in per element for self-administered surveys?
  • Do we need props.autoAdvance (ODK quick) on select_one in v1, and how is it announced to screen-reader users?
  • Which Tier-1 locales beyond en/ar/fr/es ship 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 validateFormDefinition fold translationReport issues 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.

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