08 · Builder (@rasd/builder)
Purpose: Design and implementation guide for the web drag-and-drop form builder that edits Rasd Form Definitions (RFD v1) — its information architecture, DnD engine, state/undo model, inspector, logic editor, translations, preview, JSON view, versions/publish flow, plugin API, theming, i18n, performance, testing and acceptance criteria.
Audience: Engineers building @rasd/builder; developers at UN/NGO organisations embedding the builder in their own admin consoles or extending it with custom element types.
TL;DR
@rasd/builderis a browser-only, lazily loaded React package (<FormBuilder>), built on@rasd/react,@dnd-kit/react0.5.x (pinned, behind an internaldnd-adapter),zustandandimmer. It never ships in the field-runner bundle.- Layout: palette · canvas (tree of pages ⊃ groups/repeats ⊃ questions) · inspector · toolbar, plus bottom panels Logic · Translations · JSON · Versions · Preview toggled by
features. - Every drag has a non-drag equivalent (⋯ action menu: Move up/down/top/bottom/into…, Indent/Outdent, Duplicate, Delete) plus keyboard DnD and localized live-region announcements — WCAG 2.2 SC 2.5.7 is a design input, not a fix-up.
- State is a normalized Builder Document Model edited only through named commands that produce immer patches; undo/redo is the inverse-patch stack (500 ms coalescing), drafts autosave every 2 s to
storage.kv, and the definition is re-serialized to pure RFD foronChange, JSON view, preview and validation. - The inspector is generated from element metadata (built-in registry +
defineElement().builder), so customx:*types get panels for free;extis edited as JSON/YAML with host-supplied schema hints. - The logic editor emits REL from a visual conditions-and-actions UI, offers a raw REL editor with
${name}/function autocomplete and parse-position errors fromparseExpression, and shows a dependency graph with unreachable-question warnings from static analysis. - Versions:
diffDefinitions(from, to)from@rasd/coredrives a C/T/B (compatible / transform / breaking) diff view; breaking changes block publish; publishing goes through the host'sonPublishcallback withdefinitionHashand a semantic changelog. - Targets: 500 elements at 60 fps drag, keystroke-to-paint ≤ 50 ms, dnd-adapter chunk ≤ 25 kB gz; collaboration (Yjs) is a phase-3 sketch; React Native gets a reduced "reorder & edit labels" tablet mode, not full DnD.
1. Scope, package boundaries and public API
@rasd/builder depends on @rasd/react (renderer used by preview, field registry, useLicense, useTheme, useLocale), @dnd-kit/react, zustand, immer (00 §3). It is a separate lazy chunk: hosts load it with React.lazy(() => import('@rasd/builder')) / next/dynamic(…, { ssr: false }); the runtime PWA never precaches it (research/05). Budgets: dnd-adapter ≤ 25 kB gz; builder shell ≤ 180 kB gz; the REL code editor and the YAML editor are further lazy chunks loaded on first focus.
// @rasd/builder — public surface (names frozen in 00 §11; props detailed here)
import { FormBuilder } from '@rasd/builder';
<FormBuilder
definition={def} // FormDefinition (RFD v1) or undefined → "new form" wizard
onChange={(def, meta) => …} // debounced (250 ms) pure RFD + { dirty, command, definitionHash }
onPublish={async (req) => …} // PublishRequest → PublishResult (host talks to its server)
plugins={[beneficiaryLookupPlugin]} // BuilderPlugin[] (element types, palette groups, panels)
locale="ar" // builder-chrome locale (RTL derived from settings.localeMeta / builtin list)
theme={rasdField} // Theme JSON for builder chrome (preview uses the form's own theme hint)
features={{ logic: true, translations: true, json: true, versions: true, preview: true, library: true }}
readOnly={false} // forced by license state `limited`
versions={{ list, get }} // optional VersionsSource; default = RasdProvider storage.forms
extSchemas={{ 'org.wfp.moda': modaExtSchema }} // JSON Schemas for ext editor hints
onMachineTranslate={mt} // optional MT hook for the Translations tab
/>
interface PublishRequest {
definition: FormDefinition; // version already bumped, updatedAt set
previous?: { version: string; definitionHash: string };
definitionHash: string; // definitionHash(definition) from @rasd/core
diff: ReturnType<typeof diffDefinitions> | null; // { changes, plan, loss } vs previous
changelog: SemanticChange[]; // human-readable, localized
force: boolean; // admin override: publishes B-class CONTENT changes anyway (00 §167, 17 §496). Never overrides the identity rules — version reuse, monotonicity, unchanged content — which return before it is consulted.
}
type PublishResult = { ok: true; version: string; publishedAt: string }
| { ok: false; code: 'RASD_VERSION_EXISTS' | 'RASD_SCHEMA_INVALID' | 'RASD_SYNC_REJECTED' | (string & {}); message: string; details?: unknown };
<FormBuilder> must be rendered inside <RasdProvider>; it reads storage (drafts, published versions), license (features must include "builder"; state limited ⇒ read-only + no publish; drafts and export always work), registry (element components + builder metadata) and theme. Without a provider it creates one with MemoryStorage and warns once.
Clean-room note: SurveyJS Creator's EULA forbids competing derivatives; the builder borrows patterns documented in research/02 and research/03, never code.
2. Information architecture
flowchart LR
subgraph Shell["FormBuilder shell"]
TB["Toolbar: title, version, undo/redo, save state, locale, preview, publish"]
PAL["Palette<br/>Questions, Choice, Capture, Structure, Advanced, Library"]
CAN["Canvas / Tree<br/>pages contain groups/repeats contain questions"]
INS["Inspector<br/>schema-driven panels"]
BOT["Bottom panels<br/>Logic, Translations, JSON, Versions, Preview, Problems"]
end
Store[("Builder store<br/>BDM + command history")]
PAL -- "insert command" --> Store
CAN -- "move / select / duplicate" --> Store
INS -- "setProp command" --> Store
BOT -- "edit / view" --> Store
Store -- "toRfd() memoized" --> RFD["FormDefinition (pure RFD)"]
RFD --> V["validateFormDefinition + analyzeLogic"]
V -- "problems" --> BOT
RFD --> OC["onChange(def)"]
| Region | Contents | Notes |
|---|---|---|
| Toolbar | form title (editable), current version + dirty dot, Undo/Redo, autosave status ("Saved 12 s ago" / "Saving…" / "Offline — saved locally"), locale switcher for content being edited, view toggles, Publish | Publish disabled while Problems has errors, license is limited, or definitionHash equals the last published hash |
| Palette (inline-start) | Groups: Questions (text, number, date, time, datetime, rating, range, checkbox), Choice (select_one, select_multiple, rank, matrix), Capture (geopoint, geotrace, geoshape, image, audio, video, file, barcode, signature, consent), Structure (page, group, repeat, note), Advanced (hidden, calculate, x:* plugin types), Library (host/org question-library fragments, Kobo-style) | Search box filters by label/type; each item is draggable and has an "Insert" button (inserts after selection). Collapsible; on < 1024 px becomes a sheet |
| Canvas | Vertical list of page cards; each page holds a nested, sortable tree of element cards showing type icon, name, label (current content locale, fallback badge), logic badges (relevant/required/calc/constraint), translation-missing badge, problem badge | Cards are memoized on (uid, index, depth, selected, dragging, problemCount). Above 60 visible cards the list is virtualized; above 200 elements pages start collapsed. Slash quick-insert (/) at the caret of an empty label opens the palette search (Tally pattern) |
| Inspector (inline-end) | Panels for the selected element(s): General · Type · Logic · Validation · Appearance · Data · Advanced. Multi-select shows the intersection of common fields | Rendered outside the DragDropProvider so drag frames never re-render it |
| Bottom panels | Logic, Translations, JSON, Versions, Preview, Problems | Each is a lazy chunk; features hides tabs |
Selection model: single click selects; Shift-click extends a range within the same container; Ctrl/Cmd-click toggles; "Create group from selection" (Kobo pattern) wraps a contiguous selection in a group. Selection is a list of uids stored outside undo history (undo does not change selection unless the selected node disappears).
3. Drag and drop
3.1 Library and adapter
Per research/03: @dnd-kit/react 0.5.0 (MIT, 2026-06-11) is the maintained dnd-kit line (@dnd-kit/core 6.3.1 has been frozen since 2024-12); it is still 0.x, so it is pinned exactly and hidden behind packages/builder/src/dnd-adapter/ with four exports:
export function DndRoot(props: { onDragStart, onDragOver, onDragEnd, sensors?, plugins?, children }): JSX.Element;
export function useSortableItem(o: { id: string; index: number; group: string; type: NodeKind; accept: NodeKind[]; disabled?: boolean }): { ref, handleRef, isDragging, isDropTarget };
export function useDropTarget(o: { id: string; accept: NodeKind[]; disabled?: boolean }): { ref, isOver };
export function useDragOverlay(): { activeId: string | null; render(node: ReactNode): ReactNode };
Only these files import @dnd-kit/* (react, dom, helpers, collision). Swapping to Atlassian pragmatic-drag-and-drop or legacy dnd-kit is a one-directory change; the adapter is unit-tested through its public hooks, not through dnd-kit internals. size-limit fails CI if the adapter chunk exceeds 25 kB gz.
3.2 Sensors and activation
| Sensor | Configuration | Rationale |
|---|---|---|
PointerSensor (mouse + touch + pen) | dnd-kit defaults: touch Delay(250 ms, tolerance 5 px), mouse Delay(200 ms) or Distance(5 px); drag from the explicit handle activates immediately; preventActivation for nested interactive elements (label inputs, buttons inside cards) | Canvas must still scroll on tablets; cards contain inputs |
KeyboardSensor | Space/Enter pick up, arrows move (offset 10 px → adapter maps to logical index moves), Escape cancels, Space/Enter/Tab drops; ←/→ swapped under dir="rtl" for indent/outdent | Keyboard drag is required by SC 2.1.1 but is not sufficient for SC 2.5.7 |
DragSensor (native HTML5) | Enabled only on the palette for external drops (dragging a JSON fragment / XLSForm file into the canvas imports it) | Native DnD is unreliable for internal reordering |
touch-action: none is set on the handle only. Modifiers: RestrictToElement(canvasEl) on the vertical axis.
3.3 Nested containers, legality, collision
Node kinds: page, group, repeat, leaf (any non-container element — questions plus note, hidden, calculate and non-container x:*; the kind is deliberately not called "question" because per 00 §13 a question is an element that stores a value, which a note does not). Legal parents are declared once and enforced by dnd-kit's type/accept and by the command layer (so keyboard/menu moves obey the same rules):
| Container | Accepts | Notes |
|---|---|---|
| form root | page | pages cannot nest (SurveyJS/ODK model) |
page | group, repeat, leaf | |
group | group, repeat, leaf | max nesting depth 4 (page not counted); deeper drops are refused with an announcement |
repeat | group, repeat, leaf | moving a question into/out of a repeat on a published form is a B-class change → the drop is allowed but flagged in Problems and blocks publish |
plugin container (x:* with builder.container: true) | per plugin accepts |
Collision detection: pointer-within / closest-edge (@dnd-kit/collision), containers get collisionPriority: Low so items win inside them; an empty container exposes a full-height "Drop here" target. Drop position is projected from the pointer's vertical position (before/after) and horizontal offset (indent into previous sibling if it is a container and open) — the classic sortable-tree projection. A 2 px --rasd-builder-dropIndicator line with an inline-start caret shows the projected depth; a red indicator + not-allowed cursor shows an illegal target.
Cross-container moves are applied optimistically in onDragOver (move() from @dnd-kit/helpers on the flattened list) with a snapshot taken in onDragStart and restored when event.canceled; the command moveNode is committed once in onDragEnd, so a drag is one undo step.
Auto-scroll: dnd-kit scrolling plugin on the canvas scroll container (threshold 15 % of the viewport edge, speed capped at 25 px/frame). Drag overlay: the feedback plugin clones the card; for multi-select drags the overlay shows a stack with a count badge and all selected nodes move as one command.
3.4 Virtualization
The canvas renders a flattened array { uid, depth, parentUid, collapsed } computed from the BDM (memoized). Above 60 visible rows @tanstack/react-virtual 3.14.x virtualizes it with overscan: 8; row heights are measured (labels wrap in Arabic). Sortable items keep stable ids and indexes across virtual windows; keyboard and menu moves are O(1) re-renders and are the fast path on low-end machines. Collapsed containers render as a single row and accept drops "into".
3.5 Accessibility (WCAG 2.2 SC 2.5.7, 2.1.1, 4.1.3)
- Every card exposes a ⋯ menu (
aria-haspopup="menu"): Move up, Move down, Move to top, Move to bottom, Move into… (picker listing legal containers with breadcrumbs), Indent, Outdent, Duplicate, Add question after, Delete. Menu labels are full sentences for AT ("Move question ‹hh_size› up, position 3 of 8 in group Household") — this alone satisfies SC 2.5.7 (technique G219). - dnd-kit's
accessibilityplugin is configured with localizedscreenReaderInstructionsandannouncements(onDragStart/Over/End/Cancel) using position language: "Picked up question hh_size, position 3 of 8 in Household"; strings live in thebuilder.dnd.*catalog namespace (en/ar/fr/esat launch, the builder Tier-1 set of §12 and 13 · i18n). Announcements go to a singlearia-live="polite"region; drops and menu moves also announce. - Handle:
role="button",aria-roledescription= localized "draggable",tabIndex=0, focus ring 2 px 3:1; after any move focus returns to the moved card. Target sizes ≥ 24 × 24 CSS px, default 32 (WCAG 2.5.8). - Reduced motion: transitions off under
prefers-reduced-motionor thememotion.reduced.
4. State management
4.1 Builder Document Model (BDM)
The store does not mutate RFD arrays in place. It holds a normalized tree with stable internal ids (element name is user-editable and cannot be the identity):
interface BuilderNode { uid: string; parent: string | null; kind: NodeKind; element: Omit<Element, 'elements'> | Page; children: string[] }
interface BuilderDoc {
form: Omit<FormDefinition, 'pages'>; // meta, settings, choiceLists, datasets, logic, ext …
nodes: Record<string, BuilderNode>; rootChildren: string[]; // pages in order
uidByName: Record<string, string>; // rebuilt after each command (O(n))
}
toRfd(doc): FormDefinition (memoized per doc reference; ≤ 20 ms for 500 elements) produces pure RFD for onChange, JSON view, preview and validation. fromRfd(def, prev?) assigns uids and, when prev is given (JSON-view edits, external definition prop change), re-matches uids by name then by position so selection and history survive. Unknown properties and every ext object are copied by reference and round-trip untouched (P4).
4.2 Commands, patches, undo/redo
All mutations go through dispatch(command); the reducer runs produceWithPatches (immer 11.1.x, enablePatches()) and pushes { id, label, patches, inversePatches, at, coalesceKey? } onto the history stack (research/03 §4.1). Rules:
- Named commands only (
insertNode,moveNode,removeNodes,duplicateNodes,setProp,setLocalized,renameElement,wrapInGroup,setChoiceList,setSettings,applyJson,importFragment,applyTranslations, …); each has an inverse by construction and a localizedlabelshown in the Undo tooltip and the change log. - Coalescing: consecutive
setProp/setLocalizedon the same(uid, path)within 500 ms merge into one entry (typing a label is one undo); a drag is one entry; a multi-select delete is one entry;applyJsonis one entry. - History cap 200 entries; older entries fold into a checkpoint. Redo stack clears on a new command. Undo/redo never changes non-document UI state (panel, zoom); selection is restored from the entry's
selectionBefore/After. - Rename detection:
renameElementis a distinct command (areplaceon…/element/name), so the Versions diff can distinguish rename from remove+add; when the user deletes and re-adds a same-type element with a new name in one session, the builder proposes "Washh_sizerenamed tohousehold_size?". zustandstore slices:doc(immer),history,selection,ui(panels, collapsed, zoom, contentLocale),problems(derived, async),save(dirty flag, lastSavedHash, status). Selectors are shallow; cards subscribe per uid.
sequenceDiagram
participant U as User
participant DnD as dnd-adapter
participant S as Store (commands)
participant V as Validator (idle/worker)
U->>DnD: drop card (pointer / keyboard / ⋯ menu)
DnD->>S: dispatch(moveNode{uid, parent, index})
S->>S: produceWithPatches → history.push
S-->>DnD: new flattened list (memoized)
S->>V: schedule validate(toRfd(doc)) [debounce 300 ms]
V-->>S: problems[] (errors/warnings + unreachable)
S-->>U: announce("Moved … position 4 of 8"), focus card, autosave in 2 s
4.3 Dirty tracking and autosave
dirty = definitionHash(toRfd(doc)) !== save.lastSavedHash. Autosave writes the draft 2 s after the last command (and immediately on visibilitychange: hidden, pagehide, and before publish) to storage.kv under rasd:builder:draft:<formId> ({ definition, historyTail(last 50 entries), savedAt }), so a crash or tab close never loses work; on mount the builder offers "Restore unsaved draft from 14:03?" when the stored hash differs from the incoming definition. onChange fires debounced (250 ms) with pure RFD; hosts persist wherever they like. When the license state is limited, autosave still runs (data protection principle P6) but publish is disabled.
5. Inspector — schema-driven
Panels are generated from metadata, never hand-written per type. Built-in element types register the same metadata shape that plugins use through defineElement().builder:
interface BuilderElementMeta {
label: Localized; description?: Localized; icon: ReactNode | string; // string = built-in icon name
group: 'questions' | 'choice' | 'capture' | 'structure' | 'advanced' | (string & {});
defaultProps?: Partial<Element>; // applied on insert (e.g. { props: { kind: 'integer' } })
inspector: InspectorField[]; // type-specific fields (rendered in the "Type" panel)
container?: { accepts: NodeKind[] }; // for x:* containers
valueSchema?: ZodType; // enables sample-data generation and typed autocomplete
validate?(el: Element, ctx: { def: FormDefinition }): Problem[]; // extra builder-time checks
}
type InspectorField =
| { kind: 'text' | 'localized' | 'number' | 'boolean' | 'select' | 'color' | 'expr' | 'choiceList' | 'dataset' | 'json' | 'media'; key: string; label: Localized; help?: Localized; when?: (el) => boolean; options?: {…}; required?: boolean }
| { kind: 'custom'; key: string; component: React.ComponentType<{ value; onChange; element; def }> };
Common panels come from the base element shape (00 §4.2) and apply to every type: General (name with regex ^[a-zA-Z_][a-zA-Z0-9_]*$ and scope-uniqueness check, label/hint/guidance as localized editors with a per-field locale switcher and Mini-Message preview, media), Logic (relevant, required bool/expr toggle + requiredMessage, readonly, default value/expr, calculate), Validation (constraint + message, validators[] list editor with severity), Appearance (variant options filtered per type, columns, size), Data (bind.sensitive, saveIncomplete, trackChanges, index), Advanced (ext editor, per-element "Edit JSON", element id/path read-only). expr fields embed the REL editor (§6.2) inline.
ext editor: JSON by default, YAML toggle (yaml package, lazy chunk); vendor keys autocompleted from extSchemas and from keys already present in the form; when a schema exists for a key, the editor validates and offers property completion; without a schema it only enforces "object per vendor key" and rejects __proto__/constructor keys. Ext is never reformatted on round-trip unless the user edits it.
6. Logic editor
6.1 Visual rule builder → REL
The Logic tab lists rules in three sections mirroring the RFD: element logic (relevant, required, readonly, constraint, calculate, choiceFilter, count), calculated values (logic.calculated[]) and triggers (logic.triggers[], when + actions). A rule row is conditions (nested AND/OR groups) → effect. Each condition is { ref, operator, operand }; the operator list is per value type (string: =, !=, contains, startsWith, regex, empty, notEmpty; number/date: =, !=, <, <=, >, >=, between; select_multiple: selected, countSelected =, any of, all of; repeat: count). The builder emits REL exactly per 00 §5:
// visual: "food_received = yes AND (hh_size > 5 OR vulnerable selected 'pwd')" → relevant of `top_up`
{ "relevant": "${food_received} = 'yes' and (${hh_size} > 5 or selected(${vulnerable}, 'pwd'))" }
Round-trip: the visual editor parses existing REL with parseExpression and stores only the REL string — the visual form is a projection. If isVisualisable(ast) holds (05 §18: a conjunction/disjunction tree of depth ≤ 2 whose leaves are comparisons, selected, empty/notEmpty, countSelected/count tests, regex and dateDiff comparisons) it renders visually, otherwise the rule opens in code mode with a "not representable visually" badge (never lossy). Visual edits are emitted with printExpression (canonical spacing, =, and/or); when the round-tripped AST equals the original AST the original text is preserved, so imported XLSForm expressions stay byte-identical on export. Actions for triggers are the action types defined in 05 §10.2 — setValue, clearValue, notify, jumpTo, complete (the spine's §4.1 example shows complete) — surfaced in the visual model as set value, clear value, show message, jump to page and end form; scope of a rule can be a question or a group/page (Kobo pattern). "Add rule" pre-fills the current selection.
6.2 Raw REL editor
- CodeMirror 6 (lazy chunk ≤ 120 kB gz, loaded on first focus of any
exprfield): REL syntax highlighting (small stream tokenizer, not a full Lezer grammar), bracket matching,${triggers reference completion. - Autocomplete sources:
${name}for every question in scope with type + label preview — inside a repeat, siblings first, then${../name},${/name},${repeat[].field}templates;${meta.*}keys; functions from the core registry plus hostregisterFunctions with signatures and one-line docs (incl. ODK aliases likecount-selected(shown as aliases); dataset names insidepulldata(; choice values afterselected(${q}, '. - Validation on every keystroke (debounced 150 ms):
parseExpressionerrors (RASD_EXPR_PARSEwithposition) underline the offending span; semantic checks: unknown reference, unknown function, arity, self-reference/cycle (calculategraph), type warnings (${date_q} + 1), scope errors (${name}of a repeat child referenced outside the repeat without index or[]). - "Test" panel: pick sample values for referenced questions, see the result and type — evaluated with the sandboxed core evaluator (10 ms step budget, no
eval).
6.3 Dependency visualization and unreachable-question warnings
The Problems panel and the Logic tab's Dependencies view use the static graph that @rasd/core builds for the engine — per-expression dependenciesOf(ast) and the (templatePath, property) graph of 05 §9 — exposed to the builder as analyzeLogic(def) → { graph, cycles, warnings } (name to be confirmed in 05). Rendered as a collapsible list per element ("depends on: hh_size, food_received · used by: top_up (relevant), fcs_score (calculate)") with a "Show graph" button that draws an SVG DAG (dagre-like layout in a lazy chunk; ≤ 300 nodes, otherwise list only). Warnings (each with a jump-to and, where possible, a quick-fix):
| Code | Condition | Severity |
|---|---|---|
LOGIC_CYCLE | cycle among calculate/relevant/count dependencies (core raises RASD_EXPR_CYCLE with the full path) | error (blocks publish; engine refuses to load) |
LOGIC_UNKNOWN_REF | ${x} where x is not an element/meta key | error |
LOGIC_ALWAYS_FALSE | relevant is literally false/false() or a contradiction on one reference (${a}='yes' and ${a}='no') | warning "question is unreachable" |
LOGIC_DEAD_CHAIN | relevant depends only on questions that are themselves unreachable | warning |
LOGIC_FORWARD_REF | in paged navigation, relevant/count depends on a question on a later page | warning |
LOGIC_REQUIRED_READONLY | required and readonly with no default/calculate → form can never finalize | error |
LOGIC_TRIGGER_ALWAYS | a complete trigger whose when is constant true | error |
LOGIC_FILTER_NEVER | choiceFilter referencing a key that no choice carries | warning |
LOGIC_REPEAT_SCOPE | ${child} of a repeat referenced outside without []/index | error |
7. Translations tab
Model per research/13 §9: a virtual grid, rows = every Localized slot (pages[i].title, el.label, el.hint, el.guidance, el.requiredMessage, el.constraintMessage, validators[j].message, choice labels, meta.title/description, trigger messages, repeat.addLabel/removeLabel, media.image/audio per locale), columns = settings.locales. Grid is virtualized (rows × locales can exceed 5 000 cells).
- Header per locale: completeness % and missing count;
defaultLocalestar; add-locale dialog (BCP-47 picker with native names, setssettings.localeMeta[locale].dirfor RTL scripts automatically:ar, ckb, fa, prs, ps, ur, he, sd, ug); remove-locale requires confirmation and is undoable. - Cell states:
missing(shows fallback text in a muted "showing default" style),translated,mt(machine-translated, needs review),stale(source changed after translation — flagged, never auto-cleared),error(Mini-Message placeholder mismatch vs source; missing plural category for the locale — Arabic needszero/one/two/few/many/other,fa/am/bntreat 0 asone,myhas onlyother). - Filters: missing only, stale only, by page/element, search (Arabic-normalized: NFKC, tashkeel-stripped, alef/yaa/taa-marbuta folded).
- MT:
onMachineTranslate({ from, to, strings: [{ id, text, context }] })batch of ≤ 100 strings; results land asmtstate; per-form glossary/DNT list (WFP, place names) is passed incontext. - Round trip: export CSV/XLSX (
path, field, type, <defaultLocale>, ar, fr, …— Kobo "Update translations" shape) and XLIFF 2.1 (srcLang/trgLang,unit id= path,segment state,srcDir/trgDir, notes carry element type and choices). Import shows a diff (added/changed/conflicts vs current) and applies as oneapplyTranslationscommand. XLSForm export/import oflabel::Arabic (ar)columns is delegated to@rasd/xlsform(20 · Interoperability). - RTL sanity: warn when a locale tagged RTL has a first label whose first strong character is Latin (MT leftovers), and vice versa.
8. Preview
The Preview panel mounts the real @rasd/react <FormRenderer> from toRfd(doc) inside a RasdProvider with MemoryStorage, the form's own theme hint (settings.theme.themeId + overrides, host may override) and CSS containment (contain: layout style), so builder chrome and preview theme never leak into each other. Controls:
- Device frames: phone 360 × 740, small phone 320 × 568 (WCAG reflow), tablet 768 × 1024, desktop fluid; frame sets width/height and
dir. - Locale / RTL: any locale in
settings.locales;dirfollowslocaleMeta; forced RTL toggle for LTR locales (layout audit). - Modes: light/dark/high-contrast, density, font scale 1.0/1.3/2.0, reduced motion — the same modes-as-data as 12 · Theming.
- Run live: values flow through the real engine (
createFormEngine); the "Engine" side panel shows current values, relevance and computed fields; "Fill with sample data" generates values from element types/valueSchema(respecting constraints where cheap); "Jump to page";validateOnpolicy switch; branch coverage — whichrelevantbranches were exercised in this preview session (green/grey badges on the canvas), copying the "test every branch" practice from Formbricks/Kobo. - Preview submissions never enter the outbox;
onFinalizeshows the would-beSubmissionJSON (attachments as placeholders) with a "copy" button. - Content is rendered by the renderer's sanitized path (DOMPurify allow-list, media allow-list) — the builder never injects HTML itself (16 · Security).
9. JSON view
Full-form JSON, two-way:
- Editor: same CodeMirror chunk, JSON mode, folding, search; schema-aware completion from
docs/schema/rasd-form.schema.json. - Validation on idle (300 ms): syntax first, then
validateFormDefinition(zod + JSON Schema); errors map to line/column via a JSON source-map and appear as gutter markers and in Problems (RASD_SCHEMA_INVALIDdetails). - Apply on Ctrl/Cmd+Enter or blur; while the JSON editor has focus it is the source of truth and canvas edits are queued as disabled ("Finish editing JSON to continue"). Apply is one
applyJsoncommand; uids are re-matched byname. Unknown properties survive (must-ignore-and-preserve withinrasdMAJOR). - Guards: reject payloads > 2 MiB,
__proto__/constructor/prototypekeys,rasdMAJOR ≠ 1 (offer conversion if a converter exists), and duplicatenames (surfaced, not auto-fixed). - Per-element "Edit JSON" (inspector → Advanced) edits only that subtree with the same guarantees.
- Export/Import buttons: download
<id>-v<version>.form.json; import accepts.form.json,.xlsx(via@rasd/xlsform, warnings shown), and SurveyJS JSON (best-effort importer, phase 2).
10. Versions and publish flow
Versioning rules are normative in 00 §4.3b and grounded in research/12.
The Versions tab lists published versions (versions.list() — default: storage.forms when the provider's storage has them; hosts with a server should supply { list, get }) with version, publishedAt, definitionHash, and the stored semantic changelog. Selecting two versions (or "draft vs latest") opens the diff view:
diffDefinitions(from, to)→{ changes, plan, loss }; each change is rendered with its compat class C (compatible), T (compatible with transform — the auto-seededplanstep is shown), B (breaking) and a human sentence ("hh_sizetype changed number → text (widening)", "Questionold_noteremoved — 3 drafts on devices may hold values"). Grouped by element; a side-by-side JSON diff (jsondiffpatch withobjectHash = name) is available for power users.- Breaking-change detection (block publish unless fixed or a new form
idis created): type change other than widening totext; reusing a retirednamewith a different type; changing a repeat'sname; removing a choice list still referenced; moving a question into/out of a repeat; narrowingselect_multiple → select_one; re-publishing an existingversion; unchangeddefinitionHash. Warnings (require acknowledgement, recorded in changelog): removed elements, narrowed choices, tightened constraints, new required questions, dataset reference changes. - Version string: auto-suggest next (
"3"→"4","2026.3"→"2026.4"); enforce monotonic compare against the newest known version usingcompareVersionsfrom@rasd/core— the same comparatorcheckPublishRulesgates on. It was specified aslocaleCompare(numeric: true), which made two orderings for one concept: "the newest published version" decided one way and "is this version greater" decided another, which is how a monotonicity check ends up passing something it should refuse.
sequenceDiagram
participant A as Author
participant B as Builder
participant C as @rasd/core
participant H as Host onPublish
participant S as Host server (RSP)
A->>B: click Publish
B->>C: validateFormDefinition(def)
C-->>B: ok / errors
B->>C: diffDefinitions(prev, def)
C-->>B: { changes, plan, loss }
alt B-class change present
B-->>A: blocked: fix or new form id
else warnings only
B-->>A: review dialog (changelog, version, acknowledge)
A->>B: confirm
B->>C: definitionHash(def)
B->>H: onPublish({ definition, definitionHash, diff, changelog, force })
H->>S: PUT form version (immutable)
S-->>H: 201 / 409 RASD_VERSION_EXISTS
H-->>B: PublishResult
B-->>A: success toast + version list refresh (or error with jump-to)
end
After a successful publish the builder stores { version, definitionHash, changelog, plan } in the local versions cache, sets lastSavedHash, and starts a new draft from the published definition. Publish is a host responsibility: the builder never talks to servers.
11. Plugin API
import { defineElement } from '@rasd/react';
import type { BuilderPlugin } from '@rasd/builder';
export const beneficiaryLookup = defineElement({
type: 'x:beneficiary-lookup',
component: BeneficiaryLookupField, // renderer component
valueSchema: z.object({ id: z.string(), name: z.string() }),
builder: {
label: { en: 'Beneficiary lookup', ar: 'بحث عن مستفيد' },
icon: 'search-user', group: 'advanced',
defaultProps: { props: { dataset: 'beneficiaries', minChars: 3 } },
inspector: [
{ kind: 'dataset', key: 'props.dataset', label: { en: 'Dataset' }, required: true },
{ kind: 'number', key: 'props.minChars', label: { en: 'Min. characters' }, options: { min: 1, max: 10 } },
{ kind: 'expr', key: 'props.filter', label: { en: 'Filter (REL)' }, help: { en: 'Row is shown when true' } }
],
validate: (el) => el.props?.dataset ? [] : [{ code: 'X_DATASET_REQUIRED', severity: 'error', message: { en: 'Dataset is required' } }]
}
});
export const wfpPlugin: BuilderPlugin = {
id: 'org.wfp.moda',
elements: [beneficiaryLookup],
paletteGroups: [{ id: 'wfp', label: { en: 'WFP modules' }, items: [{ label: { en: 'Household roster' }, fragment: householdRosterFragment }] }],
inspectorPanels: [{ id: 'kpi', label: { en: 'Indicators' }, appliesTo: () => true, component: KpiPanel }], // writes into ext['org.wfp.moda']
commands: [{ id: 'wfp.autoName', label: { en: 'Auto-name from indicator' }, shortcut: 'Mod+Shift+N', run: (api) => … }],
extSchemas: { 'org.wfp.moda': modaExtSchema }
};
The defineElement().builder shape is the spine's { icon, label, inspector } extended with group, defaultProps, container, validate (recorded in assumptions). Plugin commands receive a BuilderApi (getDefinition, dispatch, select, announce, t) — the same API used by keyboard shortcuts and the command palette. Fragments are RFD element arrays; on drop, names that collide are suffixed (_2) and reported. Plugins are validated at mount (duplicate type, missing icon) and never crash the builder — a faulty plugin panel renders an error boundary.
12. Theming of the builder and builder i18n
- Builder chrome consumes the same theme JSON as the renderer through
useTheme(); tokens map to--rasd-*on the builder root (.rasd-builder[data-theme][data-color-scheme][data-contrast][data-density][dir]) plus builder-only derived tokens--rasd-builder-canvasBg,--rasd-builder-panelBg,--rasd-builder-selection,--rasd-builder-dropIndicator,--rasd-builder-problemError/Warning. CSS in@layer rasd.builder, parts namedrasd-Builder__<part>+data-scope="rasd-builder" data-part. Light/dark/high-contrast/density resolve exactly as in 12 · Theming; the preview renders the form's theme independently. - Builder UI strings live in the
builder.*namespace of the Rasd catalog (separate file so runtime bundles never carry them); Tier-1 for the builder at launch:en,ar,fr,es; others load lazily. Arabic is reviewed by a native speaker; the RTL layout mirrors the shell (palette on the inline-start = right side in Arabic), directional icons flip, keyboard indent/outdent flips, DnD announcements and instructions are localized, digits follow the chrome locale. Pseudo-localesen-XB(RTL) andxx-LS(long text) run in Storybook snapshots. - Content locale (what is being edited) and chrome locale are independent; the toolbar shows both.
13. Keyboard shortcuts and command palette
| Action | Shortcut (Mod = Ctrl/⌘) |
|---|---|
| Undo / Redo | Mod+Z / Mod+Shift+Z (also Mod+Y) |
| Command palette | Mod+K (all commands, plugin commands, "insert |
| Quick insert | / in an empty label; Enter on a selected card = add same-type question after |
| Duplicate / Delete | Mod+D / Delete or Backspace (confirm when the node has children or logic references) |
| Move up/down · Move to top/bottom | Alt+↑/↓ · Alt+Shift+↑/↓ |
| Indent / Outdent (flipped in RTL) | Alt+→ / Alt+← (LTR); Alt+← / Alt+→ (RTL) |
| Group selection / Ungroup | Mod+G / Mod+Shift+G |
Rename (name) / Edit label | F2 / Enter |
| Select all in container · extend | Mod+A · Shift+↑/↓ |
| Save draft now / Publish | Mod+S / Mod+Shift+P |
| Panels: Logic · Translations · JSON · Preview · Problems | Mod+Shift+L · T · J · P · M |
| Focus palette / canvas / inspector | Mod+1 / Mod+2 / Mod+3 |
| Deselect / close panel | Esc |
Shortcuts are declared in one table (shortcuts.ts) consumed by the key handler, the command palette and the "Keyboard shortcuts" help dialog (Mod+/), so they never drift.
14. Performance targets
| Metric (Chrome, 2020 mid-tier laptop; 500 elements, 3 locales) | Target |
|---|---|
| Time to interactive after chunk load | ≤ 1.5 s |
| Drag frame time (pointer) | ≤ 16 ms p95; overlay only re-renders the source and target rows |
| Keystroke-to-paint in inspector label field | ≤ 50 ms |
Command apply + toRfd + flattened list | ≤ 25 ms |
| Full validation + logic analysis | ≤ 150 ms, off the main thread (Web Worker; falls back to requestIdleCallback) |
| JSON view open (serialize + highlight) | ≤ 300 ms |
| Memory (heap) with history at cap | ≤ 150 MB |
| Bundle | shell ≤ 180 kB gz; dnd-adapter ≤ 25 kB; code editor chunk ≤ 120 kB; graph chunk ≤ 40 kB |
| Chrome catalog per locale | ≤ 20 kB gz, lazy — not the ≤ 4 kB of 13 §14.3. That row governs `@rasd/react |
Techniques: normalized model, memoized flatten, per-uid subscriptions, virtualization above 60 rows, inspector outside the DnD provider, validation in a worker on the serialized RFD, startTransition for panel switches, no layout reads during drag. A Playwright perf test asserts drag p95 on a synthetic 500-element fixture and fails the build on regression > 20 %.
15. Collaborative editing (phase 3 sketch)
Not MVP. Design (research/03 §4.2): mirror the BDM into Yjs 13.6.x types (nodes → Y.Map of Y.Map, children → Y.Array, long labels → Y.Text); y-indexeddb 9.0.x for offline persistence; relay via Hocuspocus 4.6.x or y-websocket 3.1.x, authenticated with the host's bearer token; awareness protocol carries presence, selection and cursor per user; Y.UndoManager({ captureTimeout: 500, trackedOrigins: new Set([localOrigin]) }) gives per-user undo that ignores remote edits. Open problems: array move semantics under concurrent reorders (use delete+insert with tombstone reconciliation and a deterministic tie-break), publish must snapshot a quiescent doc, and history panel becomes per-user. Until then: optimistic locking — onChange meta carries baseHash; hosts return 409 and the builder offers "reload latest / keep mine as copy".
16. React Native builder feasibility
Full DnD authoring on phones is not planned: dnd-kit's 250 ms touch activation, small screens and SC 2.5.7 make free drag the wrong primary interaction, and no RN library provides keyboard/screen-reader drag. Recommendation: a later, reduced "Field-edit mode" for tablets in @rasd/native (phase 3): reorder within a container with react-native-sortables 1.10.x (the library supports both architectures; Rasd runs it on RNGH 3 + Reanimated 4 and targets the New Architecture only, per 00 §12), edit labels/hints/translations, toggle required, edit choices, and "Move into…" pickers — no type changes, no logic editing. To enable this, the builder's platform-agnostic core (BDM, commands, history, validation, translation model) is exposed as the DOM-free subpath @rasd/builder/core; the web shell is the only consumer today.
17. Security notes specific to the builder
- Never
eval; REL is evaluated only via the core sandbox with step/time budgets. - Imported JSON/XLSX: size cap 2 MiB, prototype-pollution keys rejected, unknown
x:types kept and rendered as placeholders, media URLs shown but not fetched unless on the hostmediaAllowList. - Preview uses the renderer's DOMPurify allow-list; the builder itself renders labels as text (no HTML) in cards.
- Drafts in
storage.kvinherit storage encryption; the builder never writes tolocalStorage. - Publish payloads carry
definitionHash; hosts should verify it server-side.
18. Failure modes and recovery
The builder's guiding rule mirrors P5/P6: author work is never lost, and every failure degrades to a visible, recoverable state.
| Failure | Detection | Behaviour |
|---|---|---|
| Crash / tab close mid-edit | stored draft hash ≠ incoming definition hash on mount | "Restore unsaved draft?" prompt (§4.3); declining keeps the stored draft until it is explicitly discarded |
storage.kv write fails (quota — RASD_STORAGE_QUOTA) | autosave promise rejects | toolbar shows "Not saved — storage full"; onChange keeps firing so the host can persist elsewhere; Export JSON (§9) is the escape hatch |
| Validation worker crashes or is unavailable | worker error / timeout | falls back to requestIdleCallback on the main thread (§14); Problems panel shows a "results may be stale" badge until the next pass completes |
onPublish rejects, throws or times out | promise settles with error | error surfaced with its code (RASD_VERSION_EXISTS → jump to the version field); draft and history untouched; retry is safe because published versions are immutable (00 §4.3b) |
Host swaps the definition prop while the doc is dirty | prop identity change + dirty flag | fromRfd(def, prev) re-matches uids; if the drafts differ the builder offers "reload latest / keep mine as a copy" — the same UX as the §15 optimistic-locking 409 |
Plugin component or validate throws | per-panel error boundary | the faulty panel/element renders an inline error and the plugin is flagged; the document and other plugins are unaffected (§11) |
| JSON apply with an invalid payload | parse → validateFormDefinition step | the applyJson command is rejected atomically; editor text and gutter errors remain; the canvas keeps the last valid document (§9) |
| Import file corrupt or oversized | guards in §9/§17 | rejected with a reason before any command is dispatched — imports are one atomic command, never partially applied |
License drops to limited mid-session | useLicense state change | editing and Publish disable immediately; the open document stays visible; autosave and export keep working (§1, §4.3) |
19. Testing strategy
| Layer | What | Tooling |
|---|---|---|
| Unit | commands: apply→undo→redo round-trip is identity (property-based over random command sequences); fromRfd(toRfd(doc)) uid re-matching; coalescing windows; rename detection; version compare; shortcut table has no duplicates | Vitest 4 + fast-check |
| Unit | logic analysis: every warning code has positive/negative fixtures; REL autocomplete scope rules (repeat/parent/absolute); JSON view line/col mapping | Vitest |
| Component | inspector generated from metadata for every built-in type; ext editor with/without schema; translations grid states; a11y of cards, menus, dialogs | RTL + vitest-axe |
| E2E — pointer DnD | drag question into group / repeat / across pages / illegal target; auto-scroll on long form; multi-select drag; drop indicator depth | Playwright with page.mouse (steps: 20) and touch emulation project |
| E2E — keyboard only | insert, move (Space/arrows/Space), indent/outdent, delete, undo, publish flow — without any pointer in en and ar (RTL key flip) | Playwright projects en, ar (locale: 'ar') |
| E2E — action menu | complete every drag scenario via ⋯ menu only; live-region text asserted | Playwright + @axe-core/playwright |
| E2E — flows | JSON round-trip, translations CSV/XLIFF export→import, versions diff blocks a type narrowing, publish success/409, crash recovery (reload mid-edit restores draft) | Playwright |
| Visual | Storybook 10 stories × (en, ar, en-XB) × (light, dark, high-contrast) × density | Chromatic / toMatchScreenshot |
| Perf | 500-element fixture drag p95, validation time in worker | Playwright + performance.measure |
20. Acceptance criteria
-
<FormBuilder>renders a 500-element, 3-locale fixture with p95 drag frame ≤ 16 ms and no dropped keystrokes in the inspector. - Every structural operation (insert, move, indent/outdent, duplicate, delete) is achievable via pointer drag, keyboard drag, and the ⋯ action menu; announcements are localized in
en/ar/fr/es; axe reports zero serious/critical issues on all panels. - Undo/redo round-trips 1 000 random commands with a byte-identical
toRfd(); typing a label is one undo entry; a drag is one undo entry. - Autosave writes to
storage.kvwithin 2 s and on tab hide; reload offers draft restore; nothing is written tolocalStorage. - Inspector panels for all v1 element types and for a plugin
x:*type are generated from metadata;extround-trips byte-for-byte when untouched. - Visual logic rules emit REL that
parseExpressionaccepts; non-representable REL opens in code mode without loss; all nine warning codes in §6.3 have E2E fixtures;LOGIC_CYCLEblocks publish. - Translations tab shows completeness per locale, flags stale/mt/plural-category errors (Arabic six categories), and CSV + XLIFF 2.1 export→import is lossless.
- Preview runs the real engine, supports device frames, locale/RTL, theme modes and font scale 2.0, and never enqueues submissions.
- JSON view validates against
rasd-form.schema.json, maps errors to lines, preserves unknown properties, rejects__proto__keys and > 2 MiB payloads. - Versions tab classifies changes C/T/B via
diffDefinitions; each B rule in 00 §4.3b blocks publish; unchanged hash and reused version are blocked;onPublishreceivesdefinitionHashand changelog. - License state
limitedmakes the builder read-only and hides/disables Publish while export and autosave still work;featureslacking"builder"shows the license notice. - Builder chrome renders correctly in Arabic RTL (mirrored panels, flipped icons and shortcuts) and passes contrast checks in light/dark/high-contrast.
-
size-limit: dnd-adapter ≤ 25 kB gz, shell ≤ 180 kB gz; the runtime@rasd/reactbundle contains no builder code.
Open questions
- Should builder-internal
uids be persisted (e.g.ext["dev.rasd.builder"].uid, stripped on publish) to make rename detection robust across sessions, or is session-only detection sufficient? - CodeMirror 6 vs a lighter in-house editor for REL/JSON: is a ≤ 120 kB gz lazy chunk acceptable for admin consoles on low bandwidth? (05 §18 currently describes the advanced editor as a tokenizer-highlighted monospace textarea — the two documents must converge on one answer.)
- Where do published versions live for hosts without RSP — is
storage.forms(get(id, version)) enough as the defaultVersionsSource? - Question-library governance: per-org fragments only, or a Rasd-curated humanitarian module set (household roster, WASH, protection) shipped with the builder?
- Should the Versions tab also render device-reported "drafts on old versions" counts (requires an RSP endpoint not yet in 10 · Sync protocol)?
- Exact
analyzeLogicAPI name/shape in@rasd/corefor the dependency graph and warnings. - Phase-3 collaboration: Hocuspocus (self-hosted) vs hosted Liveblocks for Rasd Cloud customers.
Related documents
- 00 · Decisions & conventions
- 04 · Form schema spec
- 05 · Logic & expressions
- 06 · Renderer (React)
- 07 · Renderer (native)
- 12 · Theming
- 13 · i18n, RTL & accessibility
- 15 · Licensing & billing
- 16 · Security & data protection
- 17 · API reference
- 18 · Engineering practices
- 20 · Interoperability
- Research: 02 · Form-builder libraries, 03 · Drag-and-drop & builder UX, 07 · Theming & design tokens, 12 · Versioning & migration, 13 · i18n runtime & tooling