Skip to main content

21 · Getting started & cookbook

Purpose: Copy-paste quickstarts that take a developer from an empty project to an offline-first, synced, licensed, themed Rasd Forms integration on Vite, Next.js, Expo and a plain HTML page — plus the twelve most common customisations, a before-you-ship checklist (security, accessibility, performance), a troubleshooting table, an FAQ and an upgrade-guide skeleton. Audience: Developers at UN/NGO organisations integrating Rasd Forms into their own web app, PWA or React Native app; the Rasd team maintaining apps/playground-web, apps/example-next and apps/example-expo, which are the executable versions of these recipes.

TL;DR

  • Minimum viable web integration is four packages (@rasd/core, @rasd/react, @rasd/storage, @rasd/storage-dexie) and eight lines of JSX; sync, PWA, license, media and builder are adapters you add later without touching the form code.
  • Every recipe follows the same order: storage → provider → renderer → sync → PWA → license → theme — offline is the default (spine P1) and the renderer never awaits the network.
  • Toolchain baseline: React 19, Vite 7/8 (vite-plugin-pwa 1.3.x), Next.js 15/16 with Serwist 9 (never next-pwa), Expo SDK ≥ 54 / RN ≥ 0.81 New Architecture, Node 22 LTS (≥ 22.13) for tooling (research/08, research/05).
  • On localhost, 127.0.0.1, *.local and Expo dev the license state is evaluating; in production refresh through a tokenEndpoint proxy on your backend — end-user devices never call Rasd.
  • Example forms: docs/examples/pdm-food-distribution.form.json and docs/examples/facility-monitoring-visit.form.json (both validate against schema/rasd-form.schema.json).
  • The library never registers a service worker, never reloads the page and never manages end-user identity — the recipes show exactly where you plug those in.
  • Unit-test with @rasd/testing (renderForm, fakeStorage, fakeClock, fakeMedia, faultyNetwork) — no real IndexedDB, camera or network needed.

1. Before you start

NeedPackagesNotes
Render a form (web)@rasd/core @rasd/react @rasd/storage @rasd/storage-dexie (+ peer dexie ^4.4)@rasd/themes optional; built-in ids rasd-light, rasd-dark, rasd-high-contrast, rasd-field
Render a form (RN/Expo)@rasd/core @rasd/native @rasd/storage @rasd/storage-sqlite @rasd/media (+ expo-sqlite, expo-secure-store, expo-file-system)Development build; Expo Go = plaintext DB, prototyping only (07 §20)
Sync@rasd/syncNeeds an RSP server: @rasd/server reference or your own (10)
Offline PWA@rasd/pwa (+ vite-plugin-pwa 1.3.x or serwist 9.x)You own sw.ts and the manifest
Production license@rasd/license (+ @rasd/server/license on your backend)Trial needs nothing
Builder@rasd/builderLazy-load it; separate chunk
Framework-agnostic embed@rasd/element or the self-hosted rasd-forms.iife.jsShadow DOM <rasd-form>
Tooling@rasd/cli (rasd validate, rasd convert xlsform, rasd types, rasd theme check, rasd license check, rasd doctor)Apache-2.0

All packages are ESM-only with exports maps (react-native condition first); use pnpm 11 (or npm ≥ 11) and a bundler that honours exports — Vite, Next 15+ and Metro ≥ 0.82 do. Test with Vitest 4 on web and Jest + @react-native/jest-preset on native; do not require() them from CommonJS configs (research/08 §5, §10).

flowchart LR
A["Which host?"] -->|"React SPA"| V["Section 2: Vite"]
A -->|"Next.js"| N["Section 5: Next.js"]
A -->|"React Native"| E["Section 6: Expo"]
A -->|"Non-React site"| H["Section 11: script tag"]
V --> S["Section 3: sync"] --> P["Section 4: PWA"] --> L["Section 7: license"] --> T["Section 8: theme"]
N --> S
E --> S

2. Quickstart 1 — Vite + React: render, autosave, list drafts

pnpm create vite@latest pdm-demo --template react-ts && cd pdm-demo
pnpm add react@^19 react-dom@^19 dexie@^4.4 @rasd/core @rasd/react @rasd/storage @rasd/storage-dexie @rasd/themes
mkdir -p src/forms && cp ../rasd-forms/docs/examples/pdm-food-distribution.form.json src/forms/

src/rasd.ts — one storage adapter per app, created once at module level (one IndexedDB database per namespace); storing the definition lets drafts pin formVersion + definitionHash:

import { createDexieStorage } from '@rasd/storage-dexie';
import type { FormDefinition } from '@rasd/core';
import pdmJson from './forms/pdm-food-distribution.form.json';

export const pdm = pdmJson as FormDefinition;
export const storage = createDexieStorage({ namespace: 'pdm-demo' });
export const ready = storage.open({ namespace: 'pdm-demo' }).then(() => storage.forms.put(pdm)); // both idempotent

src/main.tsx awaits ready before createRoot(...).render(<App />). src/App.tsx:

import { useState } from 'react';
import { RasdProvider, FormRenderer } from '@rasd/react';
import '@rasd/react/styles.css';
import { storage, pdm } from './rasd';
import { DraftList } from './DraftList';

export function App() {
const [draftId, setDraftId] = useState<string | undefined>();
return (
<RasdProvider storage={storage} theme="rasd-field" locale="en">
<DraftList formId={pdm.id} onOpen={setDraftId} />
<FormRenderer
key={draftId ?? 'new'} // remount to switch between drafts
definition={pdm}
submissionId={draftId} // undefined ⇒ new UUID v7 draft
onSave={(s) => console.debug('autosaved', s.id, s.clientRev)}
onFinalize={(s) => { console.log('finalized', s.id); setDraftId(undefined); }}
/>
</RasdProvider>
);
}

src/DraftList.tsx — drafts are just submissions rows with status: 'draft'; storage.on('change') fires after every autosave commit in every tab:

import { useEffect, useState } from 'react';
import type { SubmissionSummary } from '@rasd/storage';
import { storage } from './rasd';

export function DraftList({ formId, onOpen }: { formId: string; onOpen: (id: string) => void }) {
const [drafts, setDrafts] = useState<SubmissionSummary[]>([]);
useEffect(() => {
const load = () => storage.submissions
.list({ formId, status: 'draft', order: 'updatedAt', direction: 'desc', limit: 50 })
.then((r) => setDrafts(r.items as SubmissionSummary[]));
void load();
return storage.on('change', load);
}, [formId]);
return (
<ul aria-label="Drafts">
{drafts.map((d) => (
<li key={d.id}><button onClick={() => onOpen(d.id)}>{d.instanceName ?? d.id}{new Date(d.updatedAt).toLocaleString()}</button></li>
))}
</ul>
);
}

Without further configuration you get autosave every settings.autosaveMs (2 000 ms in the example) flushed on page change, visibilitychange, pagehide and unmount; skip logic, calculations and constraints from the RFD; RTL for locale="ar"; open() requests navigator.storage.persist(). No encryptionKey was passed, so the console prints one red warning — see 09 §8 for deriveKeyFromPassphrase() before you ship PII.

  • pnpm dev, fill two pages, reload: the draft is listed and reopens on the same page.
  • DevTools → Application → IndexedDB → rasd__pdm-demo shows forms, submissions, attachments, datasets, outbox, kv.

3. Quickstart 2 — add sync

pnpm add @rasd/sync
// src/rasd.ts (continued)
import { createSyncEngine } from '@rasd/sync';
export const sync = createSyncEngine({
storage,
baseUrl: import.meta.env.VITE_RSP_URL, // e.g. https://forms.example.org/api (RSP paths are under /v1)
getAuthToken: async ({ forceRefresh }) => auth.getAccessToken({ forceRefresh }), // your IdP; Rasd never manages users
policy: { requireHttps: !import.meta.env.DEV }, // http://localhost only in dev
});

Pass it to the provider (<RasdProvider storage={storage} sync={sync}>) and show status — the UI must always be able to display last sync time and pending counts (spine §8):

import { useSync } from '@rasd/react';
export function SyncBar() {
const { state, pending, lastSyncAt, lastError, syncNow } = useSync();
return (
<div role="status">
{state} · {pending.submissions} submissions / {pending.attachments} attachments pending · last {lastSyncAt ?? 'never'}
{lastError && <span> · {lastError.code}</span>}
<button onClick={() => syncNow()}>Sync now</button>
</div>
);
}

Nothing else changes: FormRenderer already writes finalized submissions to storage.outbox; the engine drains it on online, app foreground, finalize and a 15-minute timer, in the order submissions → attachments → forms pull → datasets pull (→ records when policy.records.enabled), with exponential backoff and full jitter (1 s → 5 min cap) on failures. Create the engine once at module level (single Web-Locks leader across tabs). For a local server run the @rasd/server reference (Hono + Postgres + tus, 10 §9) and point VITE_RSP_URL at it; @rasd/testing's rspConformance() verifies a server you write yourself.

  • Finalize offline (DevTools → Network → Offline): the submission row is queued and pending.submissions: 1; go online: useSync().state passes through syncing (the engine's SyncStatus.state is running, 10 §4.9) and the row moves queued → sending → synced.
  • A 401 triggers exactly one getAuthToken({ forceRefresh: true }); a second 401 parks the engine in authRequired (RASD_SYNC_AUTH) until resume().
  • A server 4xx on one item marks only that submission rejected ({ code, message, field } surfaced in the UI); the user fixes it and re-finalizes — the rest of the batch still becomes synced.

4. Quickstart 3 — offline PWA

Rasd never owns your service worker; it contributes routes (11 §2). With vite-plugin-pwa 1.3.x (generateSW):

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import { rasdRuntimeCaching } from '@rasd/pwa/workbox-config';

export default defineConfig({
plugins: [react(), VitePWA({
registerType: 'prompt', // never 'autoUpdate' for field apps
workbox: {
runtimeCaching: rasdRuntimeCaching({ apiOrigin: 'https://forms.example.org' }),
cleanupOutdatedCaches: true, navigateFallbackDenylist: [/^\/v1\//], maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
},
manifest: { name: 'PDM Monitor', short_name: 'PDM', start_url: '/', display: 'standalone', lang: 'ar', dir: 'rtl',
icons: [{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' }, { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }] },
})],
});

If you author sw.ts yourself (injectManifest), call registerRasdRoutes({ apiOrigin }) from @rasd/pwa/sw after precacheAndRoute(self.__WB_MANIFEST). Then wire the update prompt so a mid-form reload can never lose data:

import { useRegisterSW } from 'virtual:pwa-register/react';
import { useServiceWorkerUpdate, useInstallPrompt, precacheForms } from '@rasd/pwa';

const { needRefresh: [needRefresh], updateServiceWorker } = useRegisterSW();
const sw = useServiceWorkerUpdate({ vitePwa: { needRefresh, updateServiceWorker } }); // applyUpdate() returns 'deferred' while useRasdBusy()
const install = useInstallPrompt();
{sw.updateAvailable && <button disabled={sw.busy} onClick={() => sw.applyUpdate()}>Update app</button>}
{install.canPrompt && <button onClick={install.prompt}>Install</button>}
// after login or "Download for offline":
await precacheForms({ definitions: await storage.forms.listLatest(), maxBytes: 200 * 1024 * 1024 });

Facts behind this recipe (research/05): Background Sync is Chromium-only, so the IndexedDB outbox is the queue and sync events are only an accelerator; Safari purges storage of non-installed sites after 7 days of Safari use without interaction, so tell iOS users to Add to Home Screen; Chrome no longer requires a SW to install and Lighthouse's PWA category is gone — verify with the checklist instead.

  • pnpm build && pnpm preview, load once, go offline, reload: shell, form, fonts and choice images render; a draft can be filled and finalized.
  • Deploy a new build while a draft is dirty: banner shows, applyUpdate() returns 'deferred', reload happens only after finalize.

5. Quickstart 4 — Next.js App Router

@rasd/core is directive-free (runs in Server Components and route handlers); @rasd/react and @rasd/pwa entries carry 'use client'. Pass storage as a factory so nothing touches IndexedDB during SSR:

// app/providers.tsx
'use client';
import { RasdProvider } from '@rasd/react';
import { createDexieStorage } from '@rasd/storage-dexie';
export function Providers({ children }: { children: React.ReactNode }) {
return <RasdProvider storage={() => createDexieStorage({ namespace: 'moda' })} theme="rasd-field" locale="ar">{children}</RasdProvider>;
}
// app/layout.tsx (server component)
import '@rasd/react/styles.css';
import { Providers } from './providers';
export default function Layout({ children }: { children: React.ReactNode }) {
return <html lang="ar" dir="rtl"><body><Providers>{children}</Providers></body></html>;
}

// app/forms/[id]/page.tsx (server component validates, client component renders)
import { validateFormDefinition, type FormDefinition } from '@rasd/core';
import { FormClient } from './form-client';
export default async function Page({ params }: { params: Promise<{ id: string }> }) { // params is a Promise in Next 15+
const def: FormDefinition = await loadDefinition((await params).id); // your data source (DB, RSP GET /v1/forms/{id}/versions/{version}, file)
const v = validateFormDefinition(def); if (!v.ok) throw new Error(v.errors[0].message); // zod runs on the server only; production render paths never import it
return <FormClient definition={def} />;
}

form-client.tsx is 'use client' and renders <FormRenderer definition={definition} locale="ar" /> — pass locale explicitly so the first paint's dir matches the server. Load the builder with next/dynamic(() => import('@rasd/builder'), { ssr: false }). For offline, use @serwist/next (webpack) or @serwist/turbopack (Next 15/16 default) with rasdSerwistRoutes() placed before defaultCache so /v1/* stays NetworkOnly — full worker in 11 §3.4. next-pwa is archived; do not use it.

  • next build produces no hydration warnings on the form page; IndexedDB is first opened in an effect (check the Performance panel).

6. Quickstart 5 — Expo app with SQLite, camera and GPS

npx create-expo-app@latest pdm-mobile && cd pdm-mobile
npx expo install expo-sqlite expo-file-system expo-secure-store expo-crypto expo-camera expo-location expo-image-picker \
@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

app.json — the config plugin writes backup-exclusion rules, usage strings and background identifiers (07 §16):

{ "expo": { "plugins": [
["@rasd/native", { "backupExclusion": true, "permissions": { "camera": true, "location": "whenInUse" },
"usageDescriptions": { "camera": "Photos of the distribution site for the monitoring report." } }],
["expo-sqlite", { "useSQLCipher": true }]
] } }

src/rasd.ts — key in SecureStore under the namespaced id rasd.dbkey.<namespace> (00 §7, 09 §8.2), whole-database SQLCipher, never MMKV/AsyncStorage:

import { createSqliteStorage } from '@rasd/storage-sqlite';
import { createSyncEngine } from '@rasd/sync';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
const KEY_OPTS = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
const toHex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); // 64 hex chars, well under SecureStore's ~2 KB cap
const NS = 'pdm';
const KEY_ID = `rasd.dbkey.${NS}`; // namespaced key id: rasd.dbkey.<namespace> (spine §7)

export async function boot() {
let key = await SecureStore.getItemAsync(KEY_ID, KEY_OPTS);
if (!key) { key = toHex(await Crypto.getRandomBytesAsync(32)); 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
const sync = createSyncEngine({ storage, baseUrl: process.env.EXPO_PUBLIC_RSP_URL!, getAuthToken: () => auth.token() });
return { storage, sync };
}

app/_layout.tsx and a form screen:

import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { RasdProvider, FormRenderer } from '@rasd/native';
import { native as media } from '@rasd/media'; // expo-camera / expo-location / expo-image-picker, lazily loaded
import { rasdField } from '@rasd/themes';

export default function Layout() {
const rasd = useRasdBoot(); // awaits boot() once; <Splash/> meanwhile (same helper as 07 §14)
if (!rasd) return <Splash />;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RasdProvider storage={rasd.storage} sync={rasd.sync} media={media} theme={rasdField} locale="ar"><Stack /></RasdProvider>
</GestureHandlerRootView>
);
}
// app/form/[id].tsx
<FormRenderer definition={pdm} submissionId={draftId} onFinalize={() => router.replace('/')} />

The example form's image and geopoint questions now capture through the media adapters: permission requested on first use with a one-sentence rationale, denied-and-blocked falls back to allowManual/gallery, photos resized to 1 280 px long edge and EXIF-stripped, GPS auto-accepted at accuracyThreshold (14). Build with npx expo prebuild && npx expo run:android; Expo Go cannot load SQLCipher or the config plugin.

  • Airplane mode: fill, photograph, capture GPS, finalize, force-quit, relaunch — the draft list shows the finalized item as queued; connectivity ⇒ synced.
  • PRAGMA cipher_version is non-empty in the dev build; storage.securityReport().encryption === 'sqlcipher'.

7. Quickstart 6 — the license token (trial → production proxy)

Nothing is needed on dev origins (evaluating). To test real states locally, get a 7-day trial token with rasd license trial (email, no card) and pass it as RASD_LICENSE at build time or createLicense({ token }). In production, refresh through your backend so devices never call Rasd (spine §9):

// src/rasd.ts (web or native)
import { createLicense } from '@rasd/license';
export const license = createLicense({ tokenEndpoint: '/api/rasd-license', storage }); // → <RasdProvider license={license}>

The SDK POSTs { current, sdk, app } to tokenEndpoint when < 14 days remain (jittered, never blocking render). A Hono proxy on your server, holding the org secret (15 §7):

// server/rasd-license.ts (Node ≥ 22 / Hono 4)
import { Hono } from 'hono';
const app = new Hono();
let cache: { token: string; serverTime: string; fetchedAt: number } | null = null; // one token serves every device for ≤ 24 h

app.post('/api/rasd-license', async (c) => {
if (!(await isAuthenticatedStaff(c))) return c.json({ error: 'unauthorized' }, 401); // your session/bearer check
if (cache && Date.now() - cache.fetchedAt < 24 * 3600_000) return c.json({ status: 'ok', ...cache });
const body = await c.req.json<{ current: string | null; sdk: string; app: string }>();
const r = await fetch('https://license.rasd.dev/v1/tokens/refresh', {
method: 'POST', headers: { authorization: `Bearer ${process.env.RASD_ORG_SECRET}`, 'content-type': 'application/json' },
body: JSON.stringify({ current: body.current, app: 'https://forms.example.org', sdk: body.sdk }),
});
const data = await r.json(); // { status: 'ok'|'lapsed'|'revoked'|'rotate', token?, serverTime }
if (data.status === 'ok' || data.status === 'rotate') cache = { token: data.token, serverTime: data.serverTime, fetchedAt: Date.now() };
return c.json(data, r.status as 200);
});
export default app;

@rasd/server/license ships this handler ready-made for Hono, Express and Next route handlers, plus the X-Rasd-License middleware that piggy-backs a fresh token on RSP responses (zero extra device requests).

sequenceDiagram
participant D as Device SDK
participant H as Your backend tokenEndpoint
participant R as license.rasd.dev
D->>H: POST /api/rasd-license with current, sdk, app
alt token cached under 24 h
H-->>D: 200 status ok, token, serverTime
else cache stale
H->>R: POST /v1/tokens/refresh with org secret
R-->>H: 200 status, token, serverTime
H-->>D: same body
end
D->>D: verify Ed25519 offline, apply only if exp is not earlier than current

Verify with rasd license check --env RASD_LICENSE --app https://forms.example.org (exit 0 ok / 2 limited / 3 invalid). Remember the invariants: limited(soft) only adds a watermark, hard blocks new submissions only; drafts, sync and storage.export() always work.

  • Fake clock (createLicense({ clock })) day 0 → 60 → 90: activegracelimited; a refreshed token restores active within one render.

8. Quickstart 7 — custom theme, per-part overrides, Tailwind mode

A theme is JSON that extends a built-in and overrides tokens (12, schema/rasd-theme.schema.json, full example examples/theme-agency-blue.json):

// src/theme.json
{ "$schema": "https://schemas.rasd.dev/theme/v1.json", "id": "acme-field", "name": "ACME Field", "extends": "rasd-field", "mode": "light",
"tokens": { "color": { "primary": "#0B5FA5", "onPrimary": "#FFFFFF", "focus": "#0B5FA5" },
"typography": { "fontFamily": "'Source Sans 3', system-ui", "fontFamilyRtl": "'Noto Sans Arabic', system-ui", "baseSize": 17 },
"control": { "minTouch": 52, "height": 52 } },
"components": { "Button": { "radius": "full" }, "SelectOne": { "variant": "buttons" } } }
import { createTheme } from '@rasd/themes';
import themeJson from './theme.json';
const theme = createTheme(themeJson, { extends: 'rasd-field' }); // validates, resolves {color.primary} references, fills defaults
<RasdProvider theme={theme}></RasdProvider> // modes (dark, high contrast, density) resolve from OS unless you pass `mode`

Tokens become --rasd-color-primary etc. on .rasd-root (never :root), so plain CSS also works: .rasd-root { --rasd-radius-md: 2px }. Per-part hooks — every part has class="rasd-<Component>__<part>" and data-part, and classNames/styles/render accept values or (state) => value:

<FormRenderer definition={pdm}
classNames={{ TextField: { input: 'font-mono', root: (s) => (s.invalid ? 'shake' : '') } }}
styles={{ Button: { primary: { letterSpacing: '0.02em' } } }} />

Tailwind mode: set unstyled on the provider (the CSS file is not needed; semantic classes, data-part and ARIA remain) and style parts with utilities or with @apply against the stable class names — e.g. .rasd-TextField__input { @apply rounded-md border border-slate-300 px-3 py-2 focus:ring-2 }. Fonts are theme assets that must work offline: self-host WOFF2 (assets.fonts in the theme) and precache them. Run rasd theme check src/theme.json — it validates the JSON and lints WCAG contrast (onPrimary/primary ≥ 4.5:1).

9. Quickstart 8 — custom element type with a builder plugin

Register x:beneficiary-lookup in the renderer registry; the same defineElement() carries builder metadata so the inspector is generated for free (06 §5, 08 §11):

import { z } from 'zod';
import { defineElement, createRegistry, FieldWrapper, type ElementComponent } from '@rasd/react';
import type { BuilderPlugin } from '@rasd/builder';

type Beneficiary = { id: string; name: string };
const BeneficiaryLookup: ElementComponent<Beneficiary> = ({ field, element }) => (
<FieldWrapper field={field}>
<input {...field.inputProps} inputMode="numeric" dir="ltr" value={field.value?.id ?? ''}
onChange={(e) => field.setValue(lookup(e.target.value, element.props?.dataset as string))} /> {/* lookup(): your in-memory index of the dataset rows (storage.datasets.query cached at mount); async → setValue later is fine */}
</FieldWrapper>
);
export const beneficiaryLookup = defineElement({
type: 'x:beneficiary-lookup', component: BeneficiaryLookup,
valueSchema: z.object({ id: z.string(), name: z.string() }),
builder: { icon: 'search-user', label: { en: 'Beneficiary lookup', ar: 'بحث عن مستفيد' },
inspector: [{ kind: 'dataset', key: 'props.dataset', label: { en: 'Dataset' }, required: true }] },
});
export const registry = createRegistry({ elements: [beneficiaryLookup] });
export const acmePlugin: BuilderPlugin = { id: 'org.acme.monitor', elements: [beneficiaryLookup] };

Use <RasdProvider registry={registry}> for rendering and <FormBuilder plugins={[acmePlugin]}> for authoring. In the RFD the element is { "type": "x:beneficiary-lookup", "name": "beneficiary", "props": { "dataset": "beneficiaries" } }; devices without the registration render the placeholder card and round-trip the value — never a crash. Rules: render through FieldWrapper (label id, aria-describedby, error state), keep the value JSON-serialisable, and put anything vendor-specific in ext["org.acme.monitor"].

10. Quickstart 9 — custom REL function

import { registerFunction } from '@rasd/core';
registerFunction('acme_ration', (hhSize: unknown, ctx) => Math.min(Number(hhSize ?? 0), 8) * ctx.host.rationKg,
{ pure: true, minArgs: 1, maxArgs: 1, returns: 'number' });

Then in the form: "calculate": "acme_ration(${hh_size_total})". Names match ^[a-z][A-Za-z0-9_]*$ and should carry a vendor prefix; core names cannot be redefined; functions must be synchronous, side-effect free when pure: true, and receive frozen values plus a read-only ctx (locale, meta, host from createFormEngine(def, { host }) or the provider's registry.functions). Declare them when validating in CI — validateFormDefinition(def, { functions: ['acme_ration'] }) — because an unknown function at load is RASD_EXPR_UNKNOWN_FUNCTION and the form refuses to open (05 §8). Server lookups belong in datasets (pulldata()) or a custom element, never in an expression.

11. Quickstart 10 — plain HTML site: script tag / <rasd-form>

Self-host the versioned IIFE (it bundles React 19, the renderer, Dexie storage, sync and license) and use the custom element (11 §11):

<script src="/vendor/rasd/1.4.2/rasd-forms.iife.js" integrity="sha384-…" crossorigin="anonymous"
data-api="https://forms.example.org" data-locale="ar" data-license="eyJhbGciOiJFZERTQSIs…"></script>
<style>rasd-form { display:block; min-height:60vh; --rasd-color-primary:#0B5FA5 }</style>
<rasd-form definition-url="/forms/pdm-food-distribution.form.json" locale="ar" dir="rtl"></rasd-form>
<script>
Rasd.ready.then(() => {
const el = document.querySelector('rasd-form');
el.addEventListener('rasd:finalize', (e) => console.log('finalized', e.detail.submission.id));
el.addEventListener('rasd:error', (e) => console.error(e.detail.error.code));
});
</script>

Attributes: definition-url, definition-id, submission-id, locale, dir, theme, license, api, namespace, read-only, validate-on; object properties definition, theme, registry; events rasd:ready|change|save|finalize|error|sync. Styles live inside the open Shadow DOM, tokens inherit through it, popovers portal inside it. React apps should prefer @rasd/react (one React, tree-shaking); @rasd/element is the npm form of the same element. CSP: script-src 'self', worker-src 'self', img-src 'self' blob:, no 'unsafe-eval'. Never embed via cross-origin iframe for offline work — storage is partitioned and Safari gives frames 10 % quota (research/05 §7).

12. Quickstart 11 — import an XLSForm

pnpm dlx @rasd/cli convert xlsform ./pdm_kobo.xlsx -o src/forms/pdm.form.json # exit 1 on errors, 0 with warnings
pnpm dlx @rasd/cli convert xlsform ./pdm_kobo.xlsx -o src/forms/pdm.form.json --strict # CI gate: W_XPATH_UNMAPPED becomes an error
pnpm dlx @rasd/cli validate src/forms/pdm.form.json

label::Arabic (ar) / label::English (en) headers become BCP-47 keys (ar, en) automatically; the original header strings are recorded in ext["org.getodk.xlsform"].languages so export reproduces them. Programmatically (20):

import { importXlsform } from '@rasd/xlsform';
import { RasdError } from '@rasd/core';
try {
const { definition, warnings } = await importXlsform(await file.arrayBuffer()); // structural errors throw RasdError (RASD_XLSFORM_IMPORT)
warnings.filter((w) => w.code === 'W_XPATH_UNMAPPED').forEach((w) => console.warn(w.sheet, w.row, w.column, w.path, w.message));
await storage.forms.put(definition);
} catch (e) {
if (e instanceof RasdError && e.code === 'RASD_XLSFORM_IMPORT') showImportErrors(e.details); // { sheet, row, column }
else throw e;
}

What maps: survey/choices/settings sheets, label::Lang (code) columns, relevant/constraint/calculation/choice_filter (XPath → REL, including ${x}, selected(), if(), count(), ../, hyphenated ODK function names such as string-length( and selected-at(), begin_repeat, begin_group, Kobo rank/kobomatrix. ODK meta rows (start, end, deviceid, username) are not elements — they are rewritten to ${meta.startedAt}, today(), ${meta.deviceId}, ${meta.username}. Unmappable expressions are kept verbatim in ext["org.getodk.xpath"] with a cell-level W_XPATH_UNMAPPED warning and the property is left empty; the original row survives in ext["org.getodk.xlsform"] so re-export (exportXlsform(def, { target: 'kobo' })) is lossless. Expect the large majority of real forms to import with zero errors; review each warning in the builder's Logic panel before publishing.

  • rasd validate on the converted file exits 0; opening it in <FormBuilder> shows every W_XPATH_UNMAPPED inline in the Logic panel.
  • rasd convert rfd src/forms/pdm.form.json --to xlsform --target kobo round-trips the untouched sheets byte-for-byte (property-based test in the acceptance criteria of 20 · Interoperability).

13. Quickstart 12 — tests with @rasd/testing

// vitest.config.ts: environment 'jsdom', setupFiles ['fake-indexeddb/auto', 'vitest-axe/extend-expect']
import { test, expect } from 'vitest';
import { renderForm, fakeStorage, fakeClock } from '@rasd/testing';
import { axe } from 'vitest-axe';
import type { FormDefinition } from '@rasd/core';
import pdmJson from '../src/forms/pdm-food-distribution.form.json';
const pdm = pdmJson as FormDefinition;

test('declined consent hides the distribution questions', async () => {
const f = await renderForm(pdm, { locale: 'ar', storage: fakeStorage(), clock: fakeClock() });
await f.fill('consent', { granted: false }); // consent value is an object (spine §4.3); pages are relevant on ${consent}.granted = true
expect(f.engine.getState().fields.get('food_received')?.relevant).toBe(false); // ODK semantics: hidden, not validated, excluded on finalize
expect(f.queryByLabelText(/هل استلمت أسرتك الغذاء/)).toBeNull(); // f spreads Testing Library's render result; locale is 'ar' here
const r = await f.finalize(); // Result<Submission, ValidationResult>
if (r.ok) expect(r.value.data).not.toHaveProperty('food_received'); // irrelevant values are stripped from the finalized submission
});
test('autosave lands within autosaveMs', async () => {
const f = await renderForm(pdm, { storage: fakeStorage(), clock: fakeClock() });
await f.fill('adults', 5); f.clock.advance(2_000); await f.clock.tick(); // advance the debounce, then flush pending IndexedDB writes
expect((await f.submission()).data.adults).toBe(5);
expect((await f.submission()).status).toBe('draft');
});
test('has no a11y violations', async () => { const f = await renderForm(pdm); expect(await axe(f.container)).toHaveNoViolations(); });

(Field names follow the example form; adjust if your copy differs.)

renderForm() wraps <RasdProvider><FormRenderer/></RasdProvider> with Testing Library + user-event and returns the Testing Library render result plus { engine, storage, clock, field(name) /* the rendered element */, fill(name, value), next(), prev(), finalize() /* Result<Submission, ValidationResult> */, expectError(name, matcher), submission(), kill(), restart() }kill()/restart() simulate process death and reopen the same fake storage, which is how the "resume a draft after a crash" test is written; engine-level facts (relevant, required, errors) come from f.engine.getState().fields.get(name). Add media: fakeMedia({ camera: { files: [jpeg] }, geolocation: { fixes: [...] } }) for capture questions and network: faultyNetwork() for sync tests; every fake throws RasdErrors with the production codes, so the troubleshooting table below applies to tests too. On native use Jest + @react-native/jest-preset + RNTL 14 with renderForm(def, { platform: 'native' }). For end-to-end, Playwright with context.setOffline(true) and a chromium-ar-rtl project on web, Maestro flows on Expo (18).

14. Before you ship — security, accessibility and performance checklist

The recipes above are deliberately minimal. Before a field deployment, walk this list once per app; every item maps to a normative rule elsewhere and most are verifiable with rasd doctor, rasd theme check or the @rasd/testing suite.

Security & data protection (16, 09 §8)

  • Encryption at rest is on: web passes encryptionKey (deriveKeyFromPassphrase() or a host-wrapped key), native uses SQLCipher (useSQLCipher: true, key in SecureStore/Keychain/Keystore — never MMKV/AsyncStorage); storage.securityReport().encryption !== 'none' on real devices and the red console warning is gone.
  • bind.sensitive: true is set on every PII question in every form (name, phone, ID numbers, precise geopoints where policy requires); settings.encryption.mode is submission when the server operator must not read answers.
  • Sync runs over TLS only (policy.requireHttps: true outside dev); the host bearer token from getAuthToken() lives in memory (web) or SecureStore (RN), never localStorage; a 401 flow ends in re-login + sync.resume().
  • CSP is strict — default-src 'self', no 'unsafe-eval' (REL never needs it), worker-src 'self', img-src 'self' blob:, connect-src limited to your RSP origin, tus origin and tokenEndpoint; the IIFE, if used, is self-hosted with SRI and versioned URLs.
  • Devices never contact Rasd origins in production: the network capture of a release build shows only your origins; the license refreshes through tokenEndpoint or X-Rasd-License.
  • Backup exclusion is active on native (config plugin backupExclusion: true); logout uses two-step confirmation before storage.wipe() and never Clear-Site-Data while pending counts are non-zero.
  • Forms are served from a dedicated origin without analytics or tag managers — anything on the form origin can read decrypted answers in memory.

Accessibility & i18n (13, 06 §13, 07 §14)

  • Every custom element renders through FieldWrapper (or replicates its contract: label id, aria-describedby = hint + error, aria-invalid, aria-required) — this is what keeps vitest-axe at 0 violations for x:* types.
  • Touch targets stay ≥ 48 px (control.minTouch; rasd-field keeps 48 even in compact density, the §8 theme raises it to 52) and text contrast ≥ 4.5:1 — rasd theme check fails the build below 24 px / on failing contrast pairs; high-contrast and reduced-motion modes come from OS signals unless you force mode.
  • Arabic and any other RTL locale render with dir="rtl" from the locale alone (no host I18nManager restart), mirrored icons and Arabic-Indic digit input normalised to ASCII on save; run the Storybook/Playwright chromium-ar-rtl matrix or renderForm(def, { locale: 'ar' }) in CI.
  • Font scaling to 200 % (typography.maxFontScale, allowFontScaling) does not clip labels or hide the finalize button; keyboard-only and screen-reader passes (NVDA/VoiceOver on web, TalkBack/VoiceOver on native) are in the release checklist.
  • Every drag interaction in the builder has its non-drag equivalent (⋯ menu: move up/down/into) — required for WCAG 2.2 SC 2.5.7 and already true for the shipped builder; keep it true for your paletteGroups/plugins.

Performance & offline robustness (06 §10, 07 §15, 11 §10, spine §12 budgets)

  • Bundle budgets hold on your build: form-runner (core + react + storage-dexie + sync) ≤ 120 kB min+gzip excluding React; the builder is a lazy chunk; heavy element types (matrix, geo, signature, media) load on demand and are picked up by your precache glob.
  • Renderer budgets hold with your registry and theme: keystroke → paint ≤ 16 ms mid-range / ≤ 50 ms on the reference device (Android 7, 1 GB RAM, 4× CPU throttle), first render of a 500-question paged form ≤ 1 s to interactive; useField re-renders only the edited field and its REL dependents (React Profiler check on the 500-question fixture).
  • Offline first-run works: shell, @rasd/react/styles.css, theme fonts (self-hosted WOFF2/TTF), choice images and lazy element chunks are precached; precacheForms({ maxBytes }) runs after login/formUpdated; navigator.storage.persist() was requested from a user gesture and estimate().persisted is true on installed PWAs.
  • Storage pressure is handled: RASD_STORAGE_QUOTA shows a banner, synced attachments purge (retention.purgeSyncedAfterDays), and storage.estimate() is visible in a settings/diagnostics screen alongside last sync time and pending counts.
  • Update safety: a new deploy while a draft is dirty produces applyUpdate() === 'deferred' and no reload; older cached JS against a newer database opens read-only (RASD_STORAGE_DOWNGRADE, 09 §6) rather than corrupting anything.
  • Sync engine is a singleton per app (createSyncEngine() once at module level, Web Locks leader on web); the UI never awaits the network to render a form.

15. Troubleshooting

SymptomCause → fix
RASD_SCHEMA_INVALID at load (details.path = /pages/0/elements/3/type)Type misspelled or x: prefix missing; run rasd validate form.json.
RASD_EXPR_PARSE on ${household size}Names match ^[a-zA-Z_][a-zA-Z0-9_]*$; write ${hh_size}.
RASD_EXPR_CYCLE / RASD_EXPR_UNKNOWN_FUNCTIONMutually dependent calculates (break the cycle or use once()) / host function not registered on this registry (§10).
A question shows "This question needs an app update"Unknown x:* type — register it via defineElement() in the registry.
Red console warning "storage is unencrypted"Pass encryptionKey (deriveKeyFromPassphrase() on web, SecureStore key on native).
RASD_STORAGE_QUOTA bannerCheck storage.estimate(); cap precacheForms({ maxBytes }); sync more often so synced attachments purge.
RASD_STORAGE_DOWNGRADE, storage read-only (called RASD_STORAGE_VERSION_AHEAD in 11 §5.2 — same condition)Older cached JS against a newer database — apply the pending SW update when not busy; drafts stay visible and exportable meanwhile.
RASD_STORAGE_KEY_UNAVAILABLE on nativeBackup restore / Keystore reset; show your onStorageError fallback, never open unencrypted.
Sync parked in authRequired (RASD_SYNC_AUTH)getAuthToken({ forceRefresh: true }) still yields a rejected token; re-login, then sync.resume().
Sync offline although onlineProbe to /v1/ping fails: CORS, connect-src, or requireHttps against an http dev server.
Item rejected after syncServer 4xx { code, message, field } — surface it; user fixes and re-finalizes.
Duplicate uploads from two tabsTwo engine instances — call createSyncEngine() once at module level.
License limited on stagingapps[] lacks the staging origin (list apex and https://*.example.org separately); WebView schemes are not dev origins.
RASD_LICENSE_INVALID unknown_kidUpgrade @rasd/license (new signing keys ship in minor releases).
Camera/GPS falls back to file input on webNo media prop — pass import { web as media } from '@rasd/media'.
Metro: "Unable to resolve @rasd/react"Use @rasd/native; the platform split is by package, not file extension.
Jest: Cannot use import statementPackages are ESM-only; use Vitest on web or @react-native/jest-preset with transformIgnorePatterns allowing @rasd/*.
Vite PWA: builder chunk fails precache (> 2 MB)Raise maximumFileSizeToCacheInBytes or exclude the builder chunk from the glob.

rasd doctor (CLI and the playground dev panel) prints storage estimate, persistence, migration state, license state and outbox age in one screen — attach it to bug reports.

16. FAQ

  • Does my production app call Rasd servers? No. Verification is offline (embedded Ed25519 key); refresh goes through your tokenEndpoint or your RSP server's X-Rasd-License header. Only siteKey mode calls Rasd, and it warns outside localhost.
  • What happens when the subscription lapses? grace (30 days) then limited: watermark (soft) or no new submissions (hard). Drafts finish, sync continues, storage.export() always works.
  • Can I use React 18? @rasd/react is tested against 18.3, but the peer is ^19 and every recipe assumes 19.
  • Expo web / react-native-web? @rasd/react + @rasd/storage-dexie, plus workbox-cli with rasdWorkboxConfig() after expo export -p web (11 §3.5).
  • How do I ship a new form version without losing drafts? Publish a new immutable version; diffDefinitions() yields a plan; drafts migrate opt-in via migrateSubmission(); finalized items are never migrated and the server accepts any published version.
  • Can I run everything on my own infrastructure? Yes: @rasd/server (RSP + license proxy) is self-hostable, and an offline rasd-license.rlt file removes the last outbound call for air-gapped deployments.
  • Where do custom fields and KPIs live? In ext["org.yourslug"] at any node; Rasd validates it is an object and round-trips it untouched.
  • Is REL evaluated with eval? Never — Pratt parser plus sandboxed evaluator; strict CSP without 'unsafe-eval' is tested in CI.
  • Which packages are open source and which need a token? @rasd/core, @rasd/xlsform, @rasd/testing, @rasd/cli and @rasd/themes are Apache-2.0; the renderers, builder, storage adapters, sync, PWA, media, license and server packages are source-available (FSL-1.1-Apache-2.0) and need a valid RLT for production use after the 7-day trial (spine §12; the per-package gating policy is a configuration of the token's features[], see 15).
  • Do I need a storage adapter just to try the renderer? No — <RasdProvider> without storage falls back to MemoryStorage from @rasd/storage with a dev warning; drafts vanish on reload, so every recipe here passes a real adapter.
  • Can I write REL expressions in the builder as I would in XLSForm? Mostly yes: ${name} references, selected(), if(), count(), ../, and/or/not(), = and the hyphenated ODK names in call position (string-length(, selected-at(, count-selected() all parse unchanged; XPath axes and instance() node-sets do not (05 §16).
  • How do I mark which questions are PII? Set bind.sensitive: true — the single PII flag (04 §9, 16 §2): values are masked in read-only views and the "sent" list, redacted from instanceName, logs and crash reports, field-encrypted at rest when whole-DB encryption is unavailable, and E2E-encrypted when settings.encryption.mode is field/submission; rasd validate warns W_SENSITIVE_WITHOUT_ENCRYPTION when sensitive elements exist but settings.encryption.mode is none, and W_INDEX_ON_SENSITIVE when you index one (index columns are cleartext).
  • What if a device stays offline for months? Sync is opportunistic and the outbox is durable; the license rides out exp + grace (about 90 days on monthly plans) before limited, and even then drafts finish, sync continues and export works — field data is never lost because of licensing.

17. Upgrade guide (skeleton)

All runtime packages (@rasd/core, @rasd/react, @rasd/native, @rasd/storage*, @rasd/sync, @rasd/media, @rasd/pwa, @rasd/license, @rasd/themes, @rasd/element, @rasd/builder) are released as a fixed-version group on a 4-week train — upgrade them together (pnpm up "@rasd/*"@latest); tooling packages (@rasd/cli, @rasd/xlsform, @rasd/testing, @rasd/server) version independently (19). Per release, the changelog carries the following headings; each MAJOR ships a codemod (rasd migrate <from> <to>, 18 §8) after a deprecation release with a runtime warning, and removals come no earlier than 12 months after the deprecation.

SectionContents
Compatibility matrixReact / RN / Expo / Node / browsers this release was tested against (regenerated from CI).
RFD spec (rasd version)New properties (ignore-and-preserve within a MAJOR), deprecations (≥ 12 months notice), converter for MAJOR bumps.
Storage schemaDexie/SQLite version bump? Additive only in a MINOR; forward-only migrations run in open(); older code opens newer DBs read-only.
RSPNew endpoints/fields only within /v1; server before clients.
LicenseNew signing kids (upgrade @rasd/license before keys rotate); token features[] additions.
Theme tokens / CSS partsAdded or renamed --rasd-* variables and data-part names; rasd theme check flags removed tokens.
Public APIDeprecated exports (warn one MINOR, remove next MAJOR); codemod coverage.
Steps1) read the matrix, 2) upgrade the group, 3) rasd validate your forms + rasd theme check, 4) run @rasd/testing suite, 5) deploy server before clients, 6) roll out with the SW update prompt (never forced while busy).

18. Acceptance criteria for these recipes

  • Every code block in this document exists, unchanged, in apps/playground-web, apps/example-next, apps/example-expo or apps/docs and is executed in CI (typecheck + the checklist assertions above).
  • A developer with no prior exposure completes §2 in ≤ 15 minutes and §2–§4 in ≤ 60 minutes on a clean machine (timed in onboarding sessions).
  • The Vite recipe passes the offline checklist in 11 §10 and the strict-CSP run.
  • The Expo recipe passes the Maestro offline-fill.yaml and resume-draft.yaml flows on an API 24 emulator.
  • rasd doctor output is documented with a screenshot in the docs site (en/ar).
  • Every RASD_* error code and W_* warning code named in §14–§15 exists in the error registry of 17 · API reference / 04 · Form schema spec (a docs-lint job fails on unknown codes).
  • Every machine-checkable item in §14 is covered by rasd doctor, rasd theme check, size-limit or the @rasd/testing/Playwright suites; the manual items (screen-reader pass, dedicated origin, logout policy) appear on the docs-site release checklist (en/ar).
  • The XLSForm recipe imports the pyxform xlsform_spec_test.xlsx and choice_filter_test.xlsx fixtures used by the corpus job in 20 with zero errors and lists every W_XPATH_UNMAPPED with sheet/row/column.

Open questions

  • Should we publish a create-rasd scaffold (pnpm create rasd@latest --template vite|next|expo|html) that generates these files, or keep the recipes copy-paste only until the API stabilises?
  • Do we expose a useStorage() hook so components like DraftList do not import the module-level adapter (currently the pattern in 06 §19)?
  • Should requireHttps exempt loopback origins automatically so the Vite quickstart needs no policy override?
  • Is pnpm dlx @rasd/cli the primary CLI invocation, or do we also publish a rasd bin package for npx rasd?
  • Should the docs site host the IIFE on cdn.rasd.dev for CodePen-style demos given the "self-host in production" rule?
  • renderForm().field(name) returns the rendered element (17 · API reference); tests keep reaching for engine.getState().fields.get(name) for relevant/errors — should @rasd/testing add a state(name): FieldState helper so recipes read naturally?
  • The XLSForm importer's return shape is { definition, warnings, report } in 20 and { definition, warnings, unmapped } in 17; this recipe only destructures the common { definition, warnings } until that is reconciled.
  • The old-code/new-database condition is RASD_STORAGE_DOWNGRADE in 09/17 but RASD_STORAGE_VERSION_AHEAD in 11 §5.2; one name should win before the troubleshooting table is published.

00 · Decisions & conventions · 01 · Vision & scope · 02 · Requirements · 03 · Architecture · 04 · Form schema spec · 05 · Logic & expressions · 06 · Renderer (React) · 07 · Renderer (native) · 08 · Builder · 09 · Offline storage · 10 · Sync protocol · 11 · PWA & embedding · 12 · Theming · 13 · i18n, RTL & accessibility · 14 · Media & field capture · 15 · Licensing & billing · 16 · Security & data protection · 17 · API reference · 18 · Engineering practices · 19 · Roadmap & work breakdown · 20 · Interoperability · Schemas: rasd-form.schema.json, rasd-theme.schema.json · Examples: theme-agency-blue.json, examples/*.form.json · Research: 05 · PWA & embedding, 08 · Library engineering