Skip to main content

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/builder is a browser-only, lazily loaded React package (<FormBuilder>), built on @rasd/react, @dnd-kit/react 0.5.x (pinned, behind an internal dnd-adapter), zustand and immer. 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 for onChange, JSON view, preview and validation.
  • The inspector is generated from element metadata (built-in registry + defineElement().builder), so custom x:* types get panels for free; ext is 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 from parseExpression, and shows a dependency graph with unreachable-question warnings from static analysis.
  • Versions: diffDefinitions(from, to) from @rasd/core drives a C/T/B (compatible / transform / breaking) diff view; breaking changes block publish; publishing goes through the host's onPublish callback with definitionHash and 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)"]
RegionContentsNotes
Toolbarform 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, PublishPublish 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
CanvasVertical 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 badgeCards 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 fieldsRendered outside the DragDropProvider so drag frames never re-render it
Bottom panelsLogic, Translations, JSON, Versions, Preview, ProblemsEach 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

SensorConfigurationRationale
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
KeyboardSensorSpace/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/outdentKeyboard 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):

ContainerAcceptsNotes
form rootpagepages cannot nest (SurveyJS/ODK model)
pagegroup, repeat, leaf
groupgroup, repeat, leafmax nesting depth 4 (page not counted); deeper drops are refused with an announcement
repeatgroup, repeat, leafmoving 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 accessibility plugin is configured with localized screenReaderInstructions and announcements (onDragStart/Over/End/Cancel) using position language: "Picked up question hh_size, position 3 of 8 in Household"; strings live in the builder.dnd.* catalog namespace (en/ar/fr/es at launch, the builder Tier-1 set of §12 and 13 · i18n). Announcements go to a single aria-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-motion or theme motion.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 localized label shown in the Undo tooltip and the change log.
  • Coalescing: consecutive setProp/setLocalized on 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; applyJson is 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: renameElement is a distinct command (a replace on …/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 "Was hh_size renamed to household_size?".
  • zustand store 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.2setValue, 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 expr field): 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 host registerFunctions with signatures and one-line docs (incl. ODK aliases like count-selected( shown as aliases); dataset names inside pulldata(; choice values after selected(${q}, '.
  • Validation on every keystroke (debounced 150 ms): parseExpression errors (RASD_EXPR_PARSE with position) underline the offending span; semantic checks: unknown reference, unknown function, arity, self-reference/cycle (calculate graph), 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):

CodeConditionSeverity
LOGIC_CYCLEcycle 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 keyerror
LOGIC_ALWAYS_FALSErelevant is literally false/false() or a contradiction on one reference (${a}='yes' and ${a}='no')warning "question is unreachable"
LOGIC_DEAD_CHAINrelevant depends only on questions that are themselves unreachablewarning
LOGIC_FORWARD_REFin paged navigation, relevant/count depends on a question on a later pagewarning
LOGIC_REQUIRED_READONLYrequired and readonly with no default/calculate → form can never finalizeerror
LOGIC_TRIGGER_ALWAYSa complete trigger whose when is constant trueerror
LOGIC_FILTER_NEVERchoiceFilter referencing a key that no choice carrieswarning
LOGIC_REPEAT_SCOPE${child} of a repeat referenced outside without []/indexerror

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; defaultLocale star; add-locale dialog (BCP-47 picker with native names, sets settings.localeMeta[locale].dir for 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 needs zero/one/two/few/many/other, fa/am/bn treat 0 as one, my has only other).
  • 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 as mt state; per-form glossary/DNT list (WFP, place names) is passed in context.
  • 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 one applyTranslations command. XLSForm export/import of label::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; dir follows localeMeta; 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"; validateOn policy switch; branch coverage — which relevant branches 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; onFinalize shows the would-be Submission JSON (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_INVALID details).
  • 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 applyJson command; uids are re-matched by name. Unknown properties survive (must-ignore-and-preserve within rasd MAJOR).
  • Guards: reject payloads > 2 MiB, __proto__/constructor/prototype keys, rasd MAJOR ≠ 1 (offer conversion if a converter exists), and duplicate names (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-seeded plan step is shown), B (breaking) and a human sentence ("hh_size type changed number → text (widening)", "Question old_note removed — 3 drafts on devices may hold values"). Grouped by element; a side-by-side JSON diff (jsondiffpatch with objectHash = name) is available for power users.
  • Breaking-change detection (block publish unless fixed or a new form id is created): type change other than widening to text; reusing a retired name with a different type; changing a repeat's name; removing a choice list still referenced; moving a question into/out of a repeat; narrowing select_multiple → select_one; re-publishing an existing version; unchanged definitionHash. 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 using compareVersions from @rasd/core — the same comparator checkPublishRules gates on. It was specified as localeCompare(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 named rasd-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-locales en-XB (RTL) and xx-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

ActionShortcut (Mod = Ctrl/⌘)
Undo / RedoMod+Z / Mod+Shift+Z (also Mod+Y)
Command paletteMod+K (all commands, plugin commands, "insert ")
Quick insert/ in an empty label; Enter on a selected card = add same-type question after
Duplicate / DeleteMod+D / Delete or Backspace (confirm when the node has children or logic references)
Move up/down · Move to top/bottomAlt+↑/↓ · Alt+Shift+↑/↓
Indent / Outdent (flipped in RTL)Alt+→ / Alt+← (LTR); Alt+← / Alt+→ (RTL)
Group selection / UngroupMod+G / Mod+Shift+G
Rename (name) / Edit labelF2 / Enter
Select all in container · extendMod+A · Shift+↑/↓
Save draft now / PublishMod+S / Mod+Shift+P
Panels: Logic · Translations · JSON · Preview · ProblemsMod+Shift+L · T · J · P · M
Focus palette / canvas / inspectorMod+1 / Mod+2 / Mod+3
Deselect / close panelEsc

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
Bundleshell ≤ 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 (nodesY.Map of Y.Map, childrenY.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 lockingonChange 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 host mediaAllowList.
  • Preview uses the renderer's DOMPurify allow-list; the builder itself renders labels as text (no HTML) in cards.
  • Drafts in storage.kv inherit storage encryption; the builder never writes to localStorage.
  • 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.

FailureDetectionBehaviour
Crash / tab close mid-editstored 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 rejectstoolbar 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 unavailableworker error / timeoutfalls 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 outpromise settles with errorerror 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 dirtyprop identity change + dirty flagfromRfd(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 throwsper-panel error boundarythe 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 payloadparse → validateFormDefinition stepthe applyJson command is rejected atomically; editor text and gutter errors remain; the canvas keeps the last valid document (§9)
Import file corrupt or oversizedguards in §9/§17rejected with a reason before any command is dispatched — imports are one atomic command, never partially applied
License drops to limited mid-sessionuseLicense state changeediting and Publish disable immediately; the open document stays visible; autosave and export keep working (§1, §4.3)

19. Testing strategy

LayerWhatTooling
Unitcommands: 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 duplicatesVitest 4 + fast-check
Unitlogic analysis: every warning code has positive/negative fixtures; REL autocomplete scope rules (repeat/parent/absolute); JSON view line/col mappingVitest
Componentinspector generated from metadata for every built-in type; ext editor with/without schema; translations grid states; a11y of cards, menus, dialogsRTL + vitest-axe
E2E — pointer DnDdrag question into group / repeat / across pages / illegal target; auto-scroll on long form; multi-select drag; drop indicator depthPlaywright with page.mouse (steps: 20) and touch emulation project
E2E — keyboard onlyinsert, 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 menucomplete every drag scenario via ⋯ menu only; live-region text assertedPlaywright + @axe-core/playwright
E2E — flowsJSON round-trip, translations CSV/XLIFF export→import, versions diff blocks a type narrowing, publish success/409, crash recovery (reload mid-edit restores draft)Playwright
VisualStorybook 10 stories × (en, ar, en-XB) × (light, dark, high-contrast) × densityChromatic / toMatchScreenshot
Perf500-element fixture drag p95, validation time in workerPlaywright + 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.kv within 2 s and on tab hide; reload offers draft restore; nothing is written to localStorage.
  • Inspector panels for all v1 element types and for a plugin x:* type are generated from metadata; ext round-trips byte-for-byte when untouched.
  • Visual logic rules emit REL that parseExpression accepts; non-representable REL opens in code mode without loss; all nine warning codes in §6.3 have E2E fixtures; LOGIC_CYCLE blocks 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; onPublish receives definitionHash and changelog.
  • License state limited makes the builder read-only and hides/disables Publish while export and autosave still work; features lacking "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/react bundle 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 default VersionsSource?
  • 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 analyzeLogic API name/shape in @rasd/core for the dependency graph and warnings.
  • Phase-3 collaboration: Hocuspocus (self-hosted) vs hosted Liveblocks for Rasd Cloud customers.