Skip to content

Forms ​

Lukeflow's headless JSON form stack — a framework-agnostic engine, a React renderer, a React builder, and a cross-origin embed SDK. The form behaviour (schema contract, dependency-graph evaluation, validation, logic) lives in one tested place; every surface — the fill/response runtime, the visual designer, the embeddable widget — drives that same engine rather than re-implementing it. It is the backing library for the Forms capability and the most mature library in the fleet.

Production-ready · vendored

Repository: luke-forms · Type: Headless library (monorepo) · Packages: form-core / form-react / form-builder / form-embed

Overview (one tested engine driving every surface) ​

A form is stored as a plain { entities, root, settings } JSON document. @lukeflow/form-core turns that document into a live, headless engine: it builds a dependency graph over the fields, settles conditional show/hide, calculate, and logic rules to a fixpoint in a bounded number of passes, runs validation, and hands back the collected data. It has zero React or DOM dependencies — the same engine runs in a browser, in Node, and inside tests.

Everything else is an adapter over that core:

  • the renderer (form-react) wires the engine to a fully accessible DOM;
  • the builder (form-builder) drives the engine's pure builder operations from a palette/canvas UI;
  • the embed SDK (form-embed) hosts a rendered form in a cross-origin iframe on any third-party site.

Because the behaviour is centralized, a stored form authored in the builder renders identically in the fill runtime and the embed — the byte-compatible schema is golden-tested so deployed forms keep loading unchanged.

Where this fits

luke-forms is a pure UI/engine library. It has no backend of its own: form definitions, versions, submissions, and access control live in Core Engine (referenced by formKey). Secure live data reaches fields through a host-provided proxy — see Minions below.

Architecture ​

Four npm-workspace packages, published (privately) and versioned together. Each owns exactly one layer:

PackageOwnsPublic surface
@lukeflow/form-coreThe headless engine — schema contract, dependency-graph + topological settlement, validation registry, field-type registry, the safe expression parser and the JS logic layer, the Minions data-source contract, the pure builder operations, and the design-time data-contract derivation. No React, no DOM.createFormEngine, validateSchema / repairSchema / migrateSchema, builder ops, expression + JS evaluators, data-source helpers, deriveDataContract, buildFormTemplate
@lukeflow/form-reactThe React adapter + reference renderer — a complete, a11y-wired <FormRenderer> for the full field set, plus i18n, theming, an error boundary, and print/PDF.FormRenderer, useFormEngine, MinionProvider / useMinionData, LocaleProvider
@lukeflow/form-builderThe visual designer — palette, canvas, settings panel, Problems panel, a Data Structure view (design-time data contract), and a live preview built on the headless builder ops and WAI-ARIA keyboard drag-and-drop.FormBuilder, useFormBuilder, DataStructureView
@lukeflow/form-embedA zero-dependency SDK to embed a form on any site via a cross-origin iframe and a secure postMessage bridge (ships an IIFE embed.js global).index API, embed.js

The dependency direction is strict: form-core ← form-react ← form-builder; form-embed is standalone. React is a peer dependency (react >=18, built against React 19) and is externalized from every bundle, so the consuming app keeps a single React copy.

Consumption model ​

The packages are published to a private registry via changesets, but in practice they are vendored as built dist into Consumer UI — which is the deployed application. consumer-ui depends on the built artifacts (originally through file: links during cutover) rather than pulling from npm. See Consumption below.

Key features ​

  • ~Form.io-parity field set — text / textarea / email / url / password / phone / number / currency / checkbox / select / radio / checkboxes / tags / file / signature / date / time, plus panels, layout containers, and data grids.
  • Breadth controls — rating, ranking, matrix, address block, and rich-text fields, each fully keyboard- and screen-reader-operable.
  • Conditional logic — show/hide, calculate, and a logic table (show / hide / enable / disable / require / setValue) with a defined value precedence (seed < default < customDefault < calculate < logicSetValue < user), clearOnHide, and bounded multi-pass settlement that reaches a fixpoint in 1–3 passes.
  • Grids with per-row calculated cells (intra-row chains) and per-row validation; a 5000-row calc grid inits in ~17 ms.
  • Two logic tiers — a safe expression grammar (default, untrusted-author-safe) and a trusted-author JavaScript layer (calculateValueJs / customConditionalJs / customValidationJs / customDefaultValueJs), gateable and sandboxable (see Security & accessibility).
  • Minions — a secure data-source contract: fields fetch options, type-ahead results, file storage, and async validation through a host-provided MinionClient whose auth/authz live entirely server-side.
  • Wizard forms, localization (LocaleProvider), CSS-token theming, resume-from-snapshot (serialize / restore), schema migrations, an evaluation trace, an error boundary, an onEvent observability hook, print/PDF (printSubmission), autofill suppression, and a leave-guard.
  • Data Structure view — a design-time Design ⇄ Data toggle in the builder that shows the data contract a form produces: every field's submission key, value type (string / number / string[] / row[] / object …), how it accepts input, and its constraints, beside an example submission payload. Backed by the pure deriveDataContract(schema) in form-core, which mirrors the runtime submission shape (grids nest as row[], layout containers flatten, addressBlock expands, persistent:false excluded) — so what it shows is exactly what an integration receives. It also offers Generate template — a pure buildFormTemplate(schema) derives a spreadsheet template (one column per fillable field; addressBlock expands, repeating grids reported separately) and a ready-to-use CSV (UTF-8 BOM, opens in Excel) to prefill and run against the form (e.g. an outbound campaign); the FormBuilder onGenerateTemplate prop lets a host emit a real .xlsx instead.
  • Author-owned submit button — a schema containing a button that submits (a "Submit Order", say) makes the renderer stand its own button down, rather than drawing a second one underneath doing the same thing. Detected by the pure submitButtonId(schema) in form-core, which mirrors the renderer's rule exactly and walks only what is reachable from root (an orphaned button entity renders nothing, so it must not count). An explicit submitLabel still overrides the inference.
  • Data Structure view controls — hideDataView removes the builder's Design/Data toggle entirely for forms whose data contract tells the author nothing actionable (Lukeflow hides it on every form), and falls back to the canvas if a host hides it while the view is open. buildFormTemplate takes includeKeys (scope the columns to one filler's fields) and metaColumns (fixed columns prepended, e.g. who the row is being sent to) — see Who fills.
  • Submission gate slot (beforeSubmit) — host content rendered inside the form, immediately above the Submit button, for anything a filler must clear before submitting (a consent tick, a captcha, terms). It appears only where the Submit button itself does: never under readOnly or submitLabel: null, and in a wizard only on the last step — a gate above a "Next" button would be misleading. Used by the public embed to place its Turnstile challenge where the filler is already looking rather than at the top of a long page. The slot follows whichever button actually submits — including an author's own, wherever it sits in the tree — and is suppressed when the engine has conditionally hidden it, since a gate above a button that isn't there implies a submit the filler cannot reach.
  • Every value is checked for KIND and DOMAIN, not just constraints. Each declarative rule (min, maxLength, pattern…) only applies once the author sets its attribute, so a field with no constraints used to accept anything: a number stored "abc", and a select accepted a value its author never offered. Two base rules close that — type (the value must parse as the kind the field holds; blanks are required's business and bounds are min/max's) and option (a choice must be one on offer, inert for minion-backed fields whose options aren't in the schema). Both are enforced in both implementations and covered by the shared parity fixture.
  • A checkbox parses strings rather than trusting truthiness. Boolean(str) is true for every non-empty string, so a posted "no" or "false" recorded consent. Only an explicit true token (true/1/on/yes/y/checked) reads as checked — which matters wherever a caller posts strings instead of booleans (an embed, a CSV import, an API client).
  • Per-field validation timing (validateOn) — a field can reveal its error on first blur or first change instead of waiting for a submit attempt. Revealing validates that one key only, so a field the filler hasn't reached yet never lights up early. Left unset (the default) a form stays submit-only. It is hidden in the builder for grid cells, which surface errors at grid level rather than inline.
  • The settings panel offers only settings that do something. Editors are gated by what the renderer can actually honour for that node: labelPosition is hidden for radio/checkboxes (a <legend> is laid out by the UA inside the fieldset border and can't move into a grid column), and the six shell-only settings — description, tooltip, labelPosition, hideLabel, customClass, validateOn — are hidden for a grid cell, which renders as a bare control in a <td> with no label element, class hook, or inline error. A grid cell's label still becomes its column header.
  • content-visibility-based virtualization (virtualize) for long forms — off-screen rows skip layout and paint but stay in the DOM, so focus-on-error, tab order, browser Find, and validation all keep working (unlike JS windowing).

Security & accessibility ​

Security is the library's headline strength, and the model is explicit about two principals: the author (builds the form — trust depends on your deployment) and the filler (submits data — never trusted). A filler can only supply field values, which are always treated as data; the interesting boundary is how much the author is trusted, because a form definition carries two author-controlled surfaces: HTML markup and JavaScript logic.

Author HTML is sanitized by default. content/html markup is run through DOMPurify before it reaches the DOM, stripping <script>, event-handler attributes, and javascript: URLs. The sanitizer is SSR-aware: with no DOM it escapes markup to inert text rather than emit anything unsanitized, and a DOM-backed policy can be injected for real SSR HTML.

Expressions are safe even for untrusted authors. calculateValue / customConditional / customValidation / customDefaultValue and logic when/value run through an in-house exprParser (not JavaScript) that replaced the vulnerable expr-eval dependency. Member access is disabled, hostile identifiers (constructor, __proto__, prototype, …) are blocked, unknown identifiers resolve to undefined, and evaluation is fail-open — a bad expression records a diagnostic but never blanks a field or wedges a submit. A prototype-pollution guard backs this.

Author JavaScript has three postures, chosen by author trust:

Author trustPostureHow
Fully trusted (internal authors)built-in Function evaluator (fast; not a security boundary)default
Untrusted, no JS neededdisable JS entirely — only the safe expr layer runsallowJs: false
Untrusted, JS requiredtrue realm isolationinject createQuickJsEvaluator()

The QuickJS WASM sandbox runs each snippet in a WebAssembly VM with its own realm and globals, a per-eval memory cap (default 8 MiB), and a wall-clock deadline (default 100 ms). Author JS therefore cannot reach window/process/fetch, cannot escape via the Function constructor, cannot exhaust host memory, and cannot hang the tab; communication is JSON-only and a throw/timeout/overflow fails open. quickjs-emscripten is an optional peer dependency, installed only for this posture.

CSP-friendly

The renderer needs no unsafe-inline / unsafe-eval for its own code. QuickJS needs only wasm-unsafe-eval; the built-in Function evaluator needs unsafe-eval, so prefer allowJs: false or QuickJS under a strict CSP. Full guidance in docs/SECURITY.md.

Accessibility targets WCAG 2.1 AA for both the renderer and the builder. axe-core (wcag2a + wcag2aa) runs in CI via Playwright across Chromium, Firefox, and WebKit — over the renderer, wizard, data-grid, builder, error state, expanded combobox, modal/tabbed settings, the breadth controls, and dark mode — failing on any serious/critical violation. Most criteria are marked Supports; two are Partially (resting input-border contrast for 1.4.11; tooltips not individually Escape-dismissible for 1.4.13). Automated testing catches only ~30–40% of WCAG issues, so a manual assistive-technology matrix (NVDA/JAWS/VoiceOver/TalkBack) ships as a checklist — but has not been human-executed (see Status & gaps).

Technology ​

ConcernChoice
LanguageTypeScript 5.6 (strict)
Repo layoutnpm workspaces monorepo (packages/*, examples/*)
UI frameworkReact 19 (peer react >=18, externalized)
Bundlertsup (ESM + CJS + .d.ts per package)
Unit testsVitest (+ fast-check property/fuzz), jsdom
E2EPlaywright across Chromium / Firefox / WebKit (incl. a layout-invariant matrix — jsdom has no layout engine, so box overflow, stretch, grid placement and reading order are only assertable in a real browser)
Visual regressionPixel baselines on Chromium/Linux, gated behind VISUAL=1; regenerated by the Visual baselines workflow
A11y testing@axe-core/playwright
HTML sanitizerDOMPurify 3.4 (SSR-aware)
JS sandboxQuickJS via quickjs-emscripten (optional peer)
Expression enginein-house exprParser (replaced expr-eval)
ReleaseChangesets; TypeDoc for API docs; API-Extractor-style surface guard

Local development ​

Standard npm-workspace flow; the root build builds packages in dependency order (typecheck and the API-surface guard read each package's built dist/*.d.ts, and dist is gitignored, so build first):

bash
npm install

# build all packages in dependency order (core → react → builder → embed)
npm run build

npm run typecheck     # tsc --noEmit across the workspace
npm run api           # public-API surface guard (enforced in CI)
npm run size          # per-bundle size-budget check
npm test              # ~570 Vitest tests across 36 files
npm run e2e           # 17 Playwright specs (incl. axe-core a11y + the layout matrix)
                      # NOTE: needs Node <=22 locally; Node 24 breaks Playwright's loader

npm run bench         # engine perf benchmark (see docs/BENCHMARKS.md)

The test suite is deep: ~591 tests over 39 files, including property-based fuzz suites (engine.fuzz, security.fuzz — 30+ escape/RCE/pollution attempts plus hundreds of random inputs), perf tests, and golden schema tests. The 15 E2E specs cover the renderer, builder, wizard, grid, keyboard nav, minions, signature, and accessibility.

CI runs three jobs on every push/PR:

build-test:    npm ci → build → typecheck → api (surface guard)
               → size (budget) → test
e2e:           build → playwright install (3 engines) → e2e
visual:        pixel baselines (Chromium/Linux) — pushes to main only, not PRs
supply-chain:  npm audit --omit=dev --audit-level=high   (BLOCKING; runtime tree = 0 vulns)
               → npm sbom (CycloneDX)  → upload SBOM artifact

The supply-chain gate blocks on any high/critical advisory in runtime dependencies — the reason expr-eval (which had an unfixed high) was replaced by the in-house parser — and every build uploads a CycloneDX SBOM of the production tree.

Consumption ​

The packages are versioned for a private registry, but the live consumption path is vendored built dist into Consumer UI (the deployed app). The cutover replaced consumer-ui's in-tree engine/renderer/builder in two waves — renderer first (low risk), builder second:

PackageReplaced in consumer-ui
@lukeflow/form-coresrc/lib/formSchema.ts, src/lib/expression.ts
@lukeflow/form-reactin-tree FormRenderer.tsx
@lukeflow/form-builderformBuilder.ts, FormBuilderPage.tsx

During local development the app points at the local builds via file: links (file:../luke-forms/packages/form-core, …), so npm run build in luke-forms must run first. React stays a single copy — if a duplicate surfaces in Vite's optimize step, dedupe react / add the packages to optimizeDeps.exclude. Rendering a stored form is a matter of:

tsx
import { FormRenderer } from "@lukeflow/form-react";
import "@lukeflow/form-react/styles.css";

<FormRenderer
  schema={schema}                 // stored { entities, root, settings }
  initialValues={{ qty: 2 }}
  onSubmit={(data) => save(data)}
  readOnly={false}
/>;

Status & gaps ​

  • Version 0.1.0-alpha.0 — not npm-published. The packages are licensed UNLICENSED and consumed as vendored dist, not from a public registry. The alpha version reflects the release channel, not the maturity of the code — this is the most battle-tested library in the fleet.
  • Manual accessibility audit not human-run. The AA conformance claim is self-assessed: axe-core is enforced in CI, but the manual assistive-technology matrix (NVDA / JAWS / VoiceOver / TalkBack) is shipped as a test plan and has not been executed by a human. No third-party-signed VPAT exists.
  • Two known partial WCAG criteria — resting input-border contrast (1.4.11) and non-Escape-dismissible tooltips (1.4.13). Both are tracked and neither blocks the AA text/UX criteria.
  • QuickJS is opt-in at the library level — the default Function evaluator is explicitly not a security boundary. It is wired in consumer-ui, whose renderer defaults allowJs to false and whose fill path opts in with a lazily-loaded, memoized isolate (the single-file embedded-WASM variant, so there is no separate .wasm asset to serve or 404); until it loads, author JS stays off rather than falling back. The builder package's own in-modal preview still uses the default evaluator — the author running their own code in their own browser.
  • The field-type registry is published as a fixture. npm run fixtures:field-types writes fixtures/field-types.json from createDefaultFieldTypeRegistry(). luke-agents copies it and fails a test when a type is in neither its supported list nor a documented exclusion — the reverse direction of agent-schema-cases.json, which travels the other way. It exists because the form agent silently fell to 13 of 48 types and told a user the builder had no tabs, panels or stepper while the palette showed all three.
  • Validation is mirrored server-side, not shared. The declarative rules are re-implemented in Java in core-engine so a tampered payload can't skip them, kept honest by fixtures/validation-parity.json — a 91-case table executed against both implementations, with a revision tripwire that fails the Java build if the two copies drift. Expressions, author JS, and async/minion validation stay client-only by design.

For how Forms scores against the rest of the fleet, see the Completeness Scorecard.

Lukeflow Manual · documentation snapshot July 2026