07 · Renderer — React Native (@rasd/native)
Purpose: Design and implementation guide for @rasd/native, the React Native / Expo renderer of Rasd Forms: what it shares with @rasd/react, how every element type maps to RN primitives, and how keyboard, performance, storage, media, sync, theming, RTL and accessibility are handled on phones in the field.
Audience: Engineers building @rasd/native; developers at UN/NGO organisations embedding it into an existing Expo or bare React Native app.
TL;DR
@rasd/nativeexposes the same public API as@rasd/react(RasdProvider,FormRenderer, hooks,defineElement, registry) over@rasd/core; only the component implementations, the theme runtime (StyleSheet instead of CSS variables) and the platform adapters differ.- Targets React Native ≥ 0.81, New Architecture only, Expo SDK ≥ 54, Hermes, Android 7+ (API 24) and the iOS floor of the RN release in use (15.1 on RN 0.81); tested against RN 0.81 / 0.85 / 0.87 and Expo SDK 54 / 56 (research/08).
- Every element type renders to plain RN primitives (
TextInput,Pressable,Switch,Modal,FlatList/FlashList); heavy pickers and capture widgets are optional peers or live in@rasd/media; unknownx:*types render a placeholder, never crash. - Storage is
@rasd/storage-sqlite(expo-sqlitedefault,op-sqliteoptional, SQLCipher when available, key inexpo-secure-store); attachments are files under the app documents directory, referenced by relative path. - Sync is foreground-driven (
AppState, NetInfo, finalize, manual);expo-background-taskis an opportunistic accelerator with a 15-minute floor and no guarantees (research/04). - RTL is per form via Yoga
directionon the form root (noI18nManagerrestart); font scaling is honoured up totypography.maxFontScale(2.0); every control is ≥ 48 dp and carriesaccessibilityRole/Label/State. - Ships an Expo config plugin (
app.plugin.js) that writes backup-exclusion rules, permission strings and background-task identifiers, plus a ~100-line Expo Module for iOS backup exclusion and a security self-report. - No HTML is ever rendered on native: labels/notes use a safe markdown subset rendered to
Text(research/11).
1. Scope and support matrix
| Item | Decision |
|---|---|
| Package | @rasd/native (ESM-only, built with react-native-builder-bob, exports with react-native condition first; source-available FSL license, see 15) |
| Peers (required) | react ^19, react-native >=0.81 <1 (New Architecture only) |
| Peers (optional) | expo >=54, expo-sqlite, expo-file-system, expo-secure-store, expo-background-task + expo-task-manager, @react-native-community/netinfo, react-native-gesture-handler ^3, react-native-reanimated ^4, @react-native-community/datetimepicker, @react-native-community/slider, @shopify/flash-list, expo-image, expo-haptics, @op-engineering/op-sqlite, react-native-keyboard-controller (capture peers such as expo-camera, expo-location, react-native-svg belong to @rasd/media) |
| Not a target | Web via react-native-web (use @rasd/react); Legacy Architecture; Expo Go for production (see §20) |
| Test matrix | RN 0.81 (last legacy-capable release, New Arch enabled) / 0.85 (SDK 56) / 0.87 (Strict TS API — no react-native/Libraries/* deep imports); Android API 24, 28, 34; iOS 15.1, 17, 18 |
The floor is RN ≥ 0.81 / SDK ≥ 54 (spine §12): RN 0.79+ Metro honours exports by default and SDK 54 is the last SDK that tolerates the Legacy Architecture, which @rasd/native does not support.
2. API parity with @rasd/react
Everything named in the spine §11 exists in @rasd/native with the same signature. The table lists what is identical and what necessarily differs.
| Surface | Identical | Native difference |
|---|---|---|
<RasdProvider storage license theme locale registry sync> | Props, context shape, license/theme/locale resolution | storage defaults to nothing (host passes createSqliteStorage(...)); accepts a security policy object (defined in 16); wraps children in the Yoga-direction root View |
<FormRenderer definition submissionId? initialData? onChange onFinalize onSave locale? readOnly? validateOn?> | All props and callbacks; validateOn: 'change' | 'blur' | 'page' | 'finalize' | Extra optional prop keyboardVerticalOffset?: number (default 0; pass the host header height); renders a KeyboardAvoidingView + scroll container instead of a <form> |
Hooks useRasdForm, useField, useSubmission, useSync, useLicense, useTheme, useLocale, useDirection, useRasdBusy | Same return types | useTheme() returns resolved tokens and a styles(factory) memoised StyleSheet helper; useDirection() returns 'ltr' | 'rtl' for Yoga, not a DOM dir |
defineElement({ type: 'x:foo', component, builder?, valueSchema? }) | Same | component receives RN-flavoured field props (ref is a TextInput-compatible focus target) |
Registry: components ({ TextField, … }) and renderers ({ 'select_one': MySelect }) | Same keys, same accessible-props contract | Extra layout slots: FormScroll, PageTransition, Sheet (bottom-sheet host), DatePicker, Slider |
| Per-part customisation | render per part | styles per part (StyleProp<ViewStyle | TextStyle> or (state) => StyleProp); no classNames, no unstyled CSS switch (an unstyled boolean still disables default StyleSheets) |
| Engine semantics, REL, validation, i18n, versioning, license states | Identical (all in @rasd/core) | — |
Anything an app writes against @rasd/react hooks (useField, useRasdForm) ports 1:1; only JSX for custom components changes.
3. Architecture and render pipeline
flowchart TD
Host["Host app screen"] --> P["RasdProvider<br/>storage · license · theme · locale · registry · sync · security"]
P --> D["Direction root View<br/>style.direction from useDirection"]
D --> FR["FormRenderer"]
FR --> E[("FormEngine<br/>@rasd/core")]
FR --> KAV["KeyboardAvoidingView"]
KAV --> SC["FormScroll<br/>ScrollView or FlashList"]
SC --> PG["Page"]
PG --> EL["Element components<br/>via registry"]
EL -- "useField(path)" --> E
E -- "autosave 2000 ms" --> ST[("StorageAdapter<br/>@rasd/storage-sqlite")]
EL -- "capture" --> MD["@rasd/media adapters"]
MD --> ST
- One engine per
FormRenderer.createFormEngine(def)is created once (keyed bydefinitionHash+submissionId) and lives in a ref; React state is not the source of truth. Elements subscribe withuseField(path), which usesuseSyncExternalStoreagainst a per-path selector, so a keystroke re-renders exactly one field plus any dependents the engine marks dirty. - Navigation.
settings.navigation: "paged"(default) mounts one page at a time insideFormScroll; page transitions are a horizontal slide via Reanimated 4 when present, otherwise an instant swap;PageTransitionis a registry slot."scroll"renders the flattened element list in one scroll container (FlashList when installed, see §6). - Autosave. Every engine change schedules a
submissions.patch(id, partial, { bumpRev: true })aftersettings.autosaveMs(2000 ms default, trailing debounce); anAppStatechange tobackground/inactiveflushes immediately (iOS gives roughly 5 s).onSavefires after the write resolves; aRASD_STORAGE_QUOTAor write error surfaces throughonErrorand a persistent banner ("Not saved – 3 changes pending"), never a silent drop. - Busy state.
useRasdBusy()reflects engine recomputation, pending autosave and media processing so the host can gate "Finalize".
4. Element → React Native component mapping
All components use theme tokens, expose styles/render per part and satisfy the accessible-props contract (§14). "Sheet" = the Sheet registry slot; default is a Modal (presentationStyle="pageSheet" iOS / full-height on Android) with a drag handle, search box and FlashList/FlatList body.
Element type | Default RN implementation | Notes / props honoured |
|---|---|---|
text | TextInput (multiline → multiline, textAlignVertical="top", numberOfLines 3, grows to 8) | format: email → keyboardType="email-address", autoCapitalize="none"; phone → phone-pad; url → url. mask applied as a controlled formatter. maxLength forwarded. bind.sensitive → importantForAutofill="no", autoComplete="off", autoCorrect={false}, textContentType="none", contextMenuHidden |
number | TextInput keyboardType="number-pad" (integer) / "decimal-pad" (decimal), inputMode mirrored | Accepts , and ٫ as decimal separators and Arabic-Indic/Extended digits; normalises to ASCII on save (research/13); unit rendered as an end adornment; thousandsSeparator display-only; iOS InputAccessoryView with Next/Done because number pads have no return key |
date / time / datetime | DatePicker slot. Default = built-in JS calendar grid + hour/minute wheel in a Sheet (works in Expo Go, supports min/max, Hijri display). @rasd/native/pickers/community wraps @react-native-community/datetimepicker (native pickers) for hosts that install it | Value stays ISO-8601; calendar: "hijri" affects display only via @umalqura/core-style table because Hermes calendar support is unverified |
select_one | ≤ 7 choices or appearance.variant: "radio" → Pressable rows in an accessibilityRole="radiogroup" View; "buttons"/"chips" → wrapped Pressables with accessibilityState.selected; "dropdown", search: true or > 7 choices → trigger button + Sheet with search; "likert" → horizontal equal-width buttons | choiceFilter evaluated by the engine; other adds an inline TextInput; randomize seeded per submission; dataset-backed lists query datasets.query() with debounce 200 ms and ≥ 2 chars |
select_multiple | Same as above with accessibilityRole="checkbox" rows; Sheet keeps a selected-count footer | minSelected/maxSelected enforced with live count; exclusive values clear the rest |
rank | Ordered list with drag handle (RNGH 3 + Reanimated 4, see §7) and always-visible ▲/▼ buttons | Value string[]; drag disabled when peers absent or reduced motion is on |
rating | Row of Pressable icons (star/smiley) or numbered buttons; container accessibilityRole="adjustable" with accessibilityActions increment/decrement | max ≤ 10 inline, otherwise falls back to number |
range | Slider slot: @react-native-community/slider when installed; default = stepper (−/+ buttons, 48 dp) + numeric readout | showValue, step; slider track mirrored manually in RTL |
checkbox | Switch (accessibilityRole="switch") or, with appearance.variant: "buttons", a Pressable checkbox row | Boolean value |
consent | Scrollable statement Text (safe markdown), method control: tap = "I agree" Pressable; signature = @rasd/media signature pad; verbal = enumerator attestation switch; withdraw button when allowWithdraw | Stores { granted, at, textVersion, locale, method }; bind.sensitive treatment always on |
matrix | Horizontal ScrollView grid (sticky first column) on ≥ 600 dp; below that each row becomes a card with the column controls stacked | rows[] × columns of select_one/number/text |
geopoint | @rasd/media geolocation adapter (expo-location): live accuracy readout, "Capture" button, auto-accept at accuracyThreshold, optional map (MapLibre RN, optional) | Shows mocked warning on Android; allowManual opens lat/lng inputs |
geotrace / geoshape | Same adapter; list of captured points with remove; auto mode uses watchPositionAsync at intervalSeconds while the screen is focused | Foreground only by default (store policy) |
image | @rasd/media camera/picker adapter (expo-camera / expo-image-picker), thumbnail Image (≤ 240 dp, resizeMode="cover"), retake/remove | Resize to maxPixels (default 1280 long edge), JPEG quality (0.7), EXIF stripped, sidecar geotag; multiple/maxCount grid |
audio / video / file | expo-audio recorder (mono ~32 kbps default), expo-image-picker video, expo-document-picker file | maxDurationSeconds, maxBytes, accept enforced before storing |
barcode | Sheet with expo-camera CameraView (barcodeScannerSettings.barcodeTypes from formats), torch toggle, allowManual TextInput | Haptic on scan (expo-haptics optional) |
signature | @rasd/media signature pad: RNGH pan gesture drawing into react-native-svg, exported to PNG (see 14); no WebView | penColor; clear/undo; landscape hint on narrow screens |
note | Text tree from the safe markdown subset; style colours; collapsible → Pressable header with accessibilityState.expanded | Links only via host onOpenLink; images only from the media allow-list |
hidden / calculate | Nothing rendered | Values flow through the engine |
group | View; variant section (heading), card (surface + radius), collapsible (header toggle, children unmounted when collapsed), field-list (compact rows) | relevant hides the whole subtree |
repeat | List of instance cards (§6, §7) with add/remove/reorder; confirmDelete uses Alert.alert | min/max/count, itemLabel (REL), addLabel, removeLabel, keyField |
x:<name> | From registry; unregistered → placeholder card "Unsupported element x:name" and one console.warn | Placeholder is not focusable; blocks finalize only if the element is required |
5. Keyboard handling and focus flow
- Container.
FormRendererrendersKeyboardAvoidingView(behavior="padding"on iOS,undefinedon Android) aroundFormScroll, aScrollViewwithkeyboardShouldPersistTaps="handled",keyboardDismissMode="on-drag"(iOS"interactive") andautomaticallyAdjustKeyboardInsetson iOS. Android relies onwindowSoftInputMode="adjustResize"(Expoandroid.softwareKeyboardLayoutMode: "resize", the default). Hosts with a translucent header passkeyboardVerticalOffset. - Scroll-to-focused. On focus each field measures itself against the scroll container (
measureLayout) and callsscrollToso the input sits above the keyboard with a 96 dp margin plus the sticky "Next" bar height (WCAG 2.4.11 — the focused input is never obscured); in FlashList modescrollToIndex({ viewPosition: 0.3 })runs first, then the fine adjustment. - Return-key flow. A
FocusManagerinFormRendererkeeps the ordered list of focusable refs on the current page. Text/number inputs getreturnKeyType="next"(last one"done"),submitBehavior="submit"(do not blur) andonSubmitEditing→focusManager.next(); when the next element is not a text input (select, photo) focus moves to its trigger viasetAccessibilityFocusand, for sighted users, scrolls it into view.multilinefields usereturnKeyType="default". - Optional keyboard-controller adapter. Hosts that already ship
react-native-keyboard-controllerregisterFormScroll: KeyboardAwareFormScrollfrom@rasd/native/keyboard-controller; it replacesKeyboardAvoidingViewwithKeyboardAwareScrollViewfor interactive Android keyboard handling. Nothing else changes. - Edge cases. Blur commits the value and, under
validateOn: 'blur', runs validators; opening aSheetcallsKeyboard.dismiss()first so the sheet is not clipped; a pending scroll is cancelled when the keyboard hides during a page transition.
6. Long forms, repeats and performance
| Concern | Guidance |
|---|---|
| Rendering strategy | paged keeps only the current page mounted (typical page ≤ 30 elements → plain ScrollView). scroll mode flattens elements into rows and virtualises: FlashList (@shopify/flash-list, optional peer, New-Architecture build) when installed, else FlatList with initialNumToRender=12, windowSize=7, maxToRenderPerBatch=8, removeClippedSubviews (Android) and getItemLayout for fixed-height rows (notes, single-line inputs) |
| Repeats | ≤ 20 instances render inline; above that the repeat body becomes its own virtualised list with an "Instances 21–40" pager; each instance is React.memo'd on (instancePath, rev); adding an instance scrolls to it and focuses its first field |
| Re-render budget | Keystroke → paint ≤ 16 ms on a 2019 low-end Android (Snapdragon 4xx class); page transition ≤ 100 ms; form open ≤ 1.5 s for a 300-element / 5-page definition; heap ≤ 150 MB with 30 photo thumbnails |
| Memoisation | Components are precompiled with the React Compiler (target: '19', research/08); no inline style objects (all through theme.styles(...)); choice rows are memo'd on (value, selected, label); useField selectors return stable references |
| Engine work off the render path | Recompute is batched per microtask inside @rasd/core; large count()/sum() over repeats recompute incrementally; a recompute > 8 ms triggers useRasdBusy() |
| Datasets | Choice lists over 200 rows are never held in React state; the Sheet queries datasets.query(name, { search, filter, limit: 50 }) page by page (label_norm LIKE / FTS5 on SQLite) |
| Images | Thumbnails are generated once (≤ 240 dp) and cached under the attachment id; the full-size photo is loaded only in the viewer; expo-image is used when installed (recyclingKey), otherwise Image |
7. Reordering repeats (gestures)
repeat.props.allowReorder and rank share one internal reorder-adapter (mirrors the builder's dnd-adapter boundary):
- Default implementation: RNGH 3
Gesture.Pan()activated by a long-press (250 ms) on the drag handle, item offset in a Reanimated 4useSharedValue, neighbours shift withwithTiming(120 ms); auto-scroll near list edges; light haptic on pick-up/drop whenexpo-hapticsis present. Reanimated 4 requires RN ≥ 0.83 (research/03); on 0.81–0.82 or when the peers are absent the drag handle is not rendered. - Always-available equivalent (WCAG 2.2 SC 2.5.7): every instance card has a ⋯ menu (
Pressable,accessibilityRole="button") with Move up, Move down, Move to top/bottom, Delete; the same actions are exposed asaccessibilityActionsso TalkBack/VoiceOver users act without dragging. - Reduced motion: when
AccessibilityInfo.isReduceMotionEnabled()is true, drag stays enabled but neighbour animations are instant. - Engine contract: reordering calls
engine.moveRepeat(path, from, to)(a@rasd/coreoperation added for this purpose); instances carry stable ids, souseFieldsubscriptions survive the move; the audit trail records{ event: 'reorder', field, old: from, new: to }. react-native-sortablesis a candidate replacement behind the same adapter if the in-house gesture code becomes a maintenance burden.
8. Storage wiring (SQLite)
import { createSqliteStorage } from '@rasd/storage-sqlite';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
const KEY_OPTS = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
const NS = 'pdm';
const KEY_ID = `rasd.dbkey.${NS}`; // namespaced: one device may hold two agencies' datasets
async function openStorage() {
let key = await SecureStore.getItemAsync(KEY_ID, KEY_OPTS);
if (!key) {
key = toHex(await Crypto.getRandomBytesAsync(32)); // 256-bit random key, hex-encoded (64 chars < 2 KB)
await SecureStore.setItemAsync(KEY_ID, key, KEY_OPTS);
}
const storage = createSqliteStorage({ driver: 'expo', namespace: NS, encryptionKey: key });
await storage.open({ namespace: NS }); // WAL, PRAGMA key, migrations
return storage;
}
- The renderer never touches SQL; it only calls the
StorageAdapter(submissions.patch,attachments.put,datasets.query). Driver details are in 09. driver: 'expo'needs theexpo-sqliteconfig pluginuseSQLCipher: truefor encryption (not available in Expo Go → plaintext DB,onSecureStoreUnavailable/loud warning,PRAGMA secure_delete=ON);driver: 'op'uses op-sqlite's SQLCipher build. Keys live inexpo-secure-storeunder the namespaced idrasd.dbkey.<namespace>(WHEN_UNLOCKED_THIS_DEVICE_ONLY, ≤ 2 KB, 00 §7) — never MMKV/AsyncStorage. SecureStore items survive reinstall on iOS, sostorage.wipe()also deletes the key (crypto-shredding is the wipe primitive, research/11).- Missing key at open (device restored from backup, Keystore reset) ⇒
RasdErrorRASD_STORAGE_KEY_UNAVAILABLE; the provider surfaces it through itsonErrorprop and renders an error fallback rather than an unencrypted database (never re-key, never open plaintext — 09). - Autosave writes go through
withTransactionAsync; the sync engine and renderer share one adapter instance (single JS engine, no leader election needed on RN).
9. Attachments and the file system
- Root:
Paths.document(expo-file-systemSDK 54+ API; legacydocumentDirectoryviaexpo-file-system/legacyfor older hosts) →rasd/<namespace>/attachments/<attachmentId>.<ext>and…/thumbs/<attachmentId>.jpg. Never the cache directory (OS may purge), neverMediaStore/Photos unless the host opts in. - Store relative paths. The iOS container path changes between installs/updates;
attachments[].localUriis persisted relative to the namespace root and resolved on read. - Backup exclusion: the config plugin writes Android
dataExtractionRules(cloud-backup + device-transfer, merged withexpo-secure-storerules) excludingdatabases/and the Rasd directory; on iOS the bundled Expo Module setsisExcludedFromBackupon the namespace directory at firstopen().securityReport()reports whether both are active. - Writes are atomic (write to
<id>.tmp, then rename); SHA-256 is computed on write and stored inattachments[].sha256;attachments.sizeOf()powers the storage estimate banner (storage.estimate()). - Purge-after-sync default deletes the file after the server ACK and keeps only metadata; deletion of the file plus key destruction is the "secure delete" story on iOS/Android.
10. Media capture and the permissions flow
@rasd/media (native files) wraps expo-camera, expo-image-picker, expo-location, expo-audio, expo-document-picker; @rasd/native only calls the adapter interface. Rules:
stateDiagram-v2
[*] --> Idle
Idle --> Rationale: user taps Capture
Rationale --> Requesting: user continues
Requesting --> Granted: granted
Requesting --> DeniedAskable: denied and canAskAgain
Requesting --> DeniedBlocked: denied and cannot ask again
DeniedAskable --> Idle: inline hint plus allowManual fallback
DeniedBlocked --> Idle: Open Settings via Linking.openSettings plus manual fallback
Granted --> Capturing
Capturing --> Idle: value stored or cancelled
- Request on first use of the question, never at app start; show a one-sentence rationale in the form locale first (Play/App Store guidance).
- Denied and blocked ⇒ the question stays answerable through
allowManual(geopoint, barcode) or gallery pick (image, whensourceallows); aRasdErrorRASD_MEDIA_PERMISSION(details.reason: 'denied' | 'blocked') is emitted to the host'sonErroronce per question and session (14), and the audit trail recordspermission_denied. - Foreground only by default: no
ACCESS_BACKGROUND_LOCATION, no background audio, noREAD_MEDIA_IMAGES(system photo picker instead) — see research/11 §11 for the store-policy consequences. - Bare RN hosts must add the manifest permissions and Info.plist usage strings themselves (§17); the config plugin does it for Expo.
11. Connectivity and background sync
- Signals.
@rasd/nativeexportscreateNetInfoConnectivity(syncEngine)(wraps@react-native-community/netinfo:isConnected,isInternetReachable,type,details.isConnectionExpensive): it subscribes to NetInfo andAppStateand drives the engine —syncNow('online')on reconnect,syncNow('foreground')when the app becomesactive, finalize →syncNow('finalize')— and feeds the metered signal intopolicy.attachments.onMetered(10 §4). The engine still runs its own reachability probe because captive portals report "connected". - Foreground first. The engine is single-flight and priority-ordered (outbox → attachments → forms → datasets → records); the UI always renders last sync and pending counts from
useSync(). - Background top-up.
// src/backgroundTask.ts — imported from the app entry, top level
import * as TaskManager from 'expo-task-manager';
import * as BackgroundTask from 'expo-background-task';
TaskManager.defineTask('dev.rasd.sync', async () => {
const { syncEngine } = await getRasd(); // reopen storage if needed
const report = await syncEngine.syncNow('background'); // policy.background caps the run: ~25 s budget, attachments ≤ 5 MiB
return report.error ? BackgroundTask.BackgroundTaskResult.Failed : BackgroundTask.BackgroundTaskResult.Success;
});
export async function registerRasdBackgroundSync() {
await BackgroundTask.registerTaskAsync('dev.rasd.sync', { minimumInterval: 15 });
}
Limits (research/04): 15-minute floor, OS decides when, skipped on low battery/no network, never on the iOS simulator, stops after force-quit; iOS needs UIBackgroundModes: ["processing"] and BGTaskSchedulerPermittedIdentifiers (the plugin adds dev.rasd.sync). Because tus uploads are resumable, a task killed mid-upload loses at most one chunk (adaptive 256 KiB–8 MiB, 10 §4).
- Android foreground service. Not shipped by Rasd. Hosts needing multi-minute uploads with the screen off implement a
dataSyncforeground service (Android 14+:FOREGROUND_SERVICE_DATA_SYNCtype plus Play declaration) and drivesyncEngine.syncNow()from it;progressevents feed a notification that shows counts only, never answers. - iOS. Suspended apps cannot keep sockets; sync resumes on next foreground. Files stay in the default Data Protection class C so background tasks can read them.
12. Theming with StyleSheet
useTheme() resolves the theme JSON (tokens.color/typography/spacing/radius/elevation/motion/control, components overrides, mode) into a frozen object and a memoised StyleSheet factory keyed by (themeId, mode, contrast, density, fontScale, direction):
const { tokens, styles, mode } = useTheme();
const s = styles((t) => StyleSheet.create({
input: {
minHeight: t.control.height, // 48
borderWidth: t.control.borderWidth,
borderColor: t.color.outline,
borderRadius: t.radius.md,
paddingHorizontal: t.spacing.unit * 3,
color: t.color.onSurface,
fontFamily: t.typography.fontFamily, // fontFamilyRtl when direction === 'rtl'
fontSize: t.typography.baseSize,
},
}));
- Elevation tokens map to
boxShadow(RN ≥ 0.76 New Architecture) with anelevationfallback on Android; motiondurationMsbecomes 0 under reduced motion. - Modes:
useColorScheme()for light/dark,AccessibilityInfo.isHighTextContrastEnabled(Android) /isDarkerSystemColorsEnabled(iOS) for high contrast,isReduceMotionEnabledfor motion,isBoldTextEnabledbumpsweights.regulartomedium; the host may force any mode through thethemeprop. - Fonts are theme assets: static TTF/OTF only (variable fonts are not cross-platform on Android, research/07), embedded with the
expo-fontconfig plugin (production) oruseFonts(Expo Go); the default OFL Arabic family ships in@rasd/themes. Per-script family:fontFamilyRtlis used when the resolved direction is RTL, and per-run when a label's first strong character is Arabic-script. - Component overrides:
theme.components.SelectOne.variant,Button.radiusetc. are read by the default components;registry.componentsreplaces whole components; per-partstyleswins over both.
13. Font scaling, RTL and i18n on Hermes
- Font scaling.
allowFontScalingstays true; everyText/TextInputsetsmaxFontSizeMultiplier={tokens.typography.maxFontScale ?? 2}; layouts useminHeightand wrap, never fixed heights; the snapshot matrix runs at font scale 1.0 / 1.3 / 2.0. - Direction without restart.
useDirection()resolvestheme.direction ?? settings.localeMeta[locale].dir ?? scriptOf(locale);RasdProviderrenders<View style={{ direction, flex: 1 }}>sostart/end,marginStart/End,paddingStart/End,insetInlineStart/EndandflexDirection: 'row'flip per subtree (research/13 §5). All Rasd styles use logical properties; directional icons usetransform: [{ scaleX: -1 }]; clocks/checkmarks are not mirrored. Verify non-rootdirectionon Android/Fabric per RN release (flagged unverified in research) — the CI device matrix includes an Arabic form in an LTR host app. I18nManagercaveat.I18nManager.forceRTL()is global and needs an app reload (Updates.reloadAsync()); Rasd never calls it. Arabic-only host apps may setallowRTL(true)+forceRTL(true)once at first launch; both configurations are supported.- Text inside inputs.
writingDirectionis iOS-only and buggy inTextInput(RN issue #54399); Rasd instead setstextAlignfrom the first strong character of the current value; numeric, phone, ID, barcode and coordinate fields are LTR islands (textAlign: 'left',direction: 'ltr'); interpolated values in messages are wrapped in U+2066…U+2069 isolates. - Hermes Intl. Only
Intl.NumberFormat,DateTimeFormat,Collatorare used; plurals come from@rasd/core's compiled CLDR functions (Intl.PluralRulesis absent on Hermes; the FormatJS polyfill path costs ~150 KB gz and is not used);numberingSystemon iOSDateTimeFormatis ignored, so digit rendering of dates goes through the samesettings.numberingnormaliser as numbers.
14. Accessibility (TalkBack / VoiceOver)
| Requirement | Implementation |
|---|---|
| Accessible name | Every control: accessibilityLabel = localized label (+ ", required" when required, + hint); accessibilityLabelledBy on Android points at the label Text nativeID; the label is also visible text (never placeholder-only) |
| Roles | radio inside radiogroup, checkbox, switch, button, adjustable (rating/range with accessibilityActions increment/decrement), header on page/group titles, summary for the progress text |
| State/value | accessibilityState={{ checked, selected, disabled, expanded, busy }}, accessibilityValue={{ min, max, now, text }} on rating/range/progress |
| Errors | Error Text has accessibilityLiveRegion="polite" (Android) and is announced with AccessibilityInfo.announceForAccessibility() on both platforms; on finalize, focus moves to the error summary (setAccessibilityFocus) whose items are buttons that jump to the field |
| Page change | Announce "Page 3 of 7, {title}" and move focus to the page heading; hidden pages/collapsed groups are unmounted (not display: none), so nothing focusable is left behind |
| Touch targets | Min 48 × 48 dp (control.minTouch), spacing ≥ 8 dp; hitSlop extends small icons |
| Contrast / high contrast | Default themes pass 4.5:1 text / 3:1 UI; rasd-high-contrast selected automatically from OS signals unless forced |
| Motion | Reduced motion disables slide transitions and reorder animations |
| Lint/tests | eslint-plugin-react-native-a11y; RNTL role/name queries by default; manual TalkBack (Android 9, 13) and VoiceOver (iOS 17) passes per release |
15. Low-end Android guidance
- Hermes bytecode is precompiled by the host build; ship no
import.meta, no top-level await, noconsole.login production (the redacting logger stripsdebug). - Keep the JS thread free while typing: engine recompute is microtask-batched, autosave debounced 2000 ms, dataset search debounced 200 ms, thumbnails generated once at capture (never re-encoded on render).
- Prefer
PressableoverTouchableOpacity; avoidModalstacking (one Sheet at a time); avoidLayoutAnimation; usestartTransitionfor page changes so keystrokes win. - Images: 1280 px long edge / JPEG 0.7 (~150–350 KB) at capture, thumbnails ≤ 240 dp, never the full-size photo in a list (see §6).
- CI runs the example app on an Android emulator profile with 1 GB RAM, API 24 and measures form open, keystroke latency and heap (research/08); regressions > 15 % fail the build.
- Battery: GPS
watchPositionAsyncruns only while a geo question is focused/visible; sync backs off (1 s → 5 min full jitter) and respectsisConnectionExpensivefor attachments when the policy says so.
16. Expo config plugin and native module
@rasd/native ships app.plugin.js (Expo config plugin) and a tiny Expo Module (RasdSecurity, Swift + Kotlin, ~100 lines) that is autolinked in development builds and bare RN and feature-detected at runtime (absent in Expo Go). Decided: the module ships inside @rasd/native — there is no separate @rasd/native-security package and no 18th package (00 §3); hosts that cannot link it (Expo Go, bare RN without install-expo-modules) keep working, and securityReport() says which controls are therefore inactive.
// app.json
{ "expo": { "plugins": [
["@rasd/native", {
"backupExclusion": true, // Android dataExtractionRules + iOS isExcludedFromBackup
"backgroundSync": true, // UIBackgroundModes: processing + BGTaskSchedulerPermittedIdentifiers: dev.rasd.sync
"permissions": { "camera": true, "location": "whenInUse", "microphone": true, "photos": false },
"usageDescriptions": { "camera": "Take photos of distribution sites for monitoring reports." }
}],
["expo-sqlite", { "useSQLCipher": true }],
["expo-font", { "fonts": ["./assets/fonts/NotoSansArabic-Regular.ttf"] }]
]}}
The plugin: writes/merges res/xml/rasd_data_extraction_rules.xml and sets android:dataExtractionRules/android:fullBackupContent (merging with expo-secure-store's rules, never overwriting); adds NSCameraUsageDescription, NSLocationWhenInUseUsageDescription, NSMicrophoneUsageDescription (localised via CFBundleLocalizations when strings are given per locale); adds background modes/identifiers; sets android.softwareKeyboardLayoutMode to resize if unset; never requests background location or media-read permissions. The module exposes setExcludedFromBackup(path), securityReport() (SQLCipher on?, rules present?, exclusion applied?, https-only?) and preventScreenCapture(bool) passthrough to expo-screen-capture when installed.
17. Installation
Expo (development build; Expo Go only for prototyping):
npx expo install expo-sqlite expo-file-system expo-secure-store expo-crypto expo-background-task expo-task-manager \
@react-native-community/netinfo react-native-gesture-handler react-native-reanimated
pnpm add @rasd/core @rasd/native @rasd/storage @rasd/storage-sqlite @rasd/sync @rasd/media @rasd/license @rasd/themes
npx expo prebuild && npx expo run:android
Add the plugin (§16), the Reanimated Babel plugin (react-native-worklets/plugin in Reanimated 4), and wrap the root in GestureHandlerRootView.
Bare React Native (≥ 0.81, New Architecture): run npx install-expo-modules (recommended; the Expo packages above then work unchanged) or use @op-engineering/op-sqlite (driver: 'op') with the @rasd/storage-sqlite fileSystem adapter option and react-native-keychain through the @rasd/storage key-provider interface. Then: pod install; manifest permissions (CAMERA, ACCESS_FINE_LOCATION, RECORD_AUDIO as needed); Info.plist usage strings; dataExtractionRules XML (template in @rasd/native/templates/); BGTaskSchedulerPermittedIdentifiers; Hermes on (default); Metro package exports (default since RN 0.79). Without the Expo Module the host performs iOS backup exclusion itself and securityReport() says so.
18. Testing
| Layer | Tooling | What Rasd provides |
|---|---|---|
| Unit / component | Jest + @react-native/jest-preset (RN 0.85+) or jest-expo; RNTL 14 (async render, host-only queries) | @rasd/testing renderForm(def, { platform: 'native', storage: fakeStorage(), media: fakeMedia() }), fake clock, network fault injection, form fixtures; mocks for expo-* modules |
| Stories / snapshots | @storybook/react-native v10 on-device + @storybook/react-native-web-vite for Chromatic; matrix en/ar/en-XB × light/dark/high-contrast × font scale 1.0/1.3/2.0 | Story decorators for theme, direction, font scale |
| E2E | Maestro flows on the Expo example (EAS Workflows or CI emulators); Detox is community-only for Expo | Flows: open form offline (adb shell svc wifi disable), fill 5 pages, capture photo (mock camera), finalize, re-launch, verify draft restored, reconnect and assert synced |
| Performance | Emulator 1 GB RAM API 24 profile; PerformanceObserver/performance.now() marks around open/keystroke/page | Perf fixture: 300-element / 5-page / 3-repeat form |
| Accessibility | eslint-plugin-react-native-a11y; RNTL role queries; manual TalkBack/VoiceOver checklist per release | Checklist in apps/example-expo/a11y.md |
Example RNTL test:
const user = userEvent.setup();
const { engine } = await renderForm(pdmForm, { platform: 'native' });
await user.press(screen.getByRole('radio', { name: 'Yes' }));
await user.type(screen.getByLabelText('Household size, required'), '٥');
expect(engine.getState().data.hh_size).toBe(5); // Arabic-Indic digit normalised
19. Example Expo app structure (apps/example-expo)
apps/example-expo/
app.json # plugins from §16, android.allowBackup=false
app/_layout.tsx # GestureHandlerRootView > RasdProvider (storage, sync, license, theme, locale)
app/index.tsx # assigned forms + pending counts (useSync)
app/form/[id].tsx # <FormRenderer definition submissionId onFinalize/>
app/drafts.tsx # useSubmission list, resume/delete
app/settings.tsx # locale, theme mode, sync now, storage estimate, securityReport()
src/rasd.ts # openStorage(), createSyncEngine(), createLicense(), createNetInfoConnectivity()
src/backgroundTask.ts # TaskManager.defineTask('dev.rasd.sync') — imported first in _layout
assets/fonts/ # static TTFs (Inter, Noto Sans Arabic)
maestro/ # offline-fill.yaml, rtl.yaml, resume-draft.yaml
_layout.tsx sketch:
export default function Layout() {
const rasd = useRasdBoot(); // opens storage, license, sync once
if (!rasd) return <Splash />;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RasdProvider storage={rasd.storage} sync={rasd.sync} license={rasd.license} theme={rasdField} locale="ar">
<Stack />
</RasdProvider>
</GestureHandlerRootView>
);
}
20. Known limitations
- Expo Go: no SQLCipher, no config-plugin effects, no
RasdSecuritymodule → plaintext database with a warning banner; license state isevaluating; prototyping only. - Background sync is best-effort (15-minute floor, OS-scheduled, not on simulators, dies on force-quit); iOS gives no long-running upload without a foreground app.
- Hermes Intl gaps (§13) and RTL caveats (§13): no
Intl.PluralRules/ListFormat/DisplayNames; Hijri calendar via tables;I18nManagerrestart avoided; non-root Yogadirectionre-verified per RN release; caret quirks with numbers in RTL on Android. - Reorder gestures need RNGH 3 + Reanimated 4 (RN ≥ 0.83); otherwise menu-only.
- No HTML in labels/notes; unsupported markdown falls back to plain text.
- iOS SecureStore values persist across reinstall and are capped ~2 KB; on some Android 12+ devices Auto Backup may still transfer data device-to-device despite
allowBackup=false— the exclusion rules are the real control. - Native date pickers vary by OS version and ignore
calendar: "hijri"; the built-in JS picker is the consistent path. - Not supported: Legacy Architecture, react-native-web through
@rasd/native,Modal-in-Modalsheets, WebView-based widgets.
21. Acceptance criteria
-
@rasd/nativetype-checks against RN 0.81, 0.85 and 0.87 (Strict TS API) withreact ^19; noreact-native/Libraries/*imports;publintandattw --packpass. - Every element type in spine §4.3 renders from the default registry; unknown
x:*shows a placeholder and never throws. -
useField,useRasdForm,useSubmission,useSync,useLicense,useTheme,useLocale,useDirection,useRasdBusybehave identically to@rasd/reactin the shared conformance suite (@rasd/testing). - Keystroke → paint ≤ 16 ms, page transition ≤ 100 ms, form open ≤ 1.5 s (300-element fixture) on the 1 GB API 24 emulator profile; heap ≤ 150 MB with 30 thumbnails.
- Focused input is never obscured by keyboard or sticky bar on iOS 15+/Android 7+; return key advances through all text/number fields on a page; iOS number pads show Next/Done.
- Repeat with 200 instances scrolls at 60 fps in FlashList and FlatList modes; reorder works by drag (when peers present) and by ⋯ menu; audit records the move.
- Autosave persists within 2 s of a change and on background; killing the app and relaunching restores the draft with all values, repeat instances and attachments.
- With
useSQLCipher: true,PRAGMA cipher_versionis non-empty, DB key lives only in SecureStore,storage.wipe()removes DB, files and key; without SQLCipher the warning path andsecure_deleteare active. - Attachments are stored under the namespace directory with relative paths, survive an iOS app update, are excluded from Android backup and iOS iCloud backup (
bmgr backupnowrestore test andsimctlattribute dump in CI). - Permission denial (askable and blocked) leaves every capture question answerable through its fallback and emits
RASD_MEDIA_PERMISSIONwithdetails.reason; no background-location, background-audio or media-read permissions appear in the merged manifest. - Background task registered with
minimumInterval: 15runssyncNow('background')on a physical device and never runs when unregistered; NetInfo offline → online triggers sync within 5 s. - Arabic form inside an English host app renders fully RTL (layout, icons, text alignment) without app restart; numeric/ID fields stay LTR; snapshots at font scale 2.0 show no clipping.
- TalkBack and VoiceOver: every control has role, name and state; errors are announced; page changes move focus to the heading; all targets ≥ 48 dp.
- Config plugin output verified by MASTG-style checks (rules present, usage strings present, background identifiers present);
securityReport()returns the expected flags on the example app. - Maestro offline-fill, resume-draft and RTL flows pass on CI emulator and on one physical low-end Android device per release.
Open questions
- Should the built-in JS date picker be the permanent default, or should
@react-native-community/datetimepickerbecome a required peer once its Hijri/min/max behaviour is verified across OS versions? - FlashList v2 (New-Architecture rewrite) API stability and the exact minimum RN version — confirm before making it the documented recommendation over
FlatList. - Is a first-party Android
dataSyncforeground-service recipe worth maintaining, given Play declaration overhead, or do we keep it as documentation only? - Verify Yoga non-root
directionon Android/Fabric for RN 0.85–0.87 and on Android 7–9 devices before promising per-form RTL without caveats. - Should
engine.moveRepeat()be added to the@rasd/corepublic API (needed for reorder)? 17 · API reference already lists it onFormEngine, but the spine §11 list omits it — the spine should be amended.
Related documents
- 00 · Decisions & conventions
- 03 · Architecture
- 04 · Form schema spec
- 05 · Logic & expressions
- 06 · Renderer — React
- 09 · Offline storage
- 10 · Sync protocol
- 12 · Theming
- 13 · i18n, RTL & accessibility
- 14 · Media & field capture
- 15 · Licensing & billing
- 16 · Security & data protection
- 17 · API reference
- 18 · Engineering practices
- 21 · Getting started