Four bundles, four schedules, one drag model.
| Bundle | Loaded when | What it is |
|---|---|---|
form |
a page contains a form | Enhances the rendered form |
builder |
the builder window or admin page opens | Palette, canvas, inspector, Theme Studio |
entries |
the entries window opens | The submissions table |
widget |
somebody has the widget on their desktop | Recent submissions |
They are separate because a visitor filling in a contact form should not download the form builder.
The config blob every bundle depends on, printed by PHP as an inline script.
interface RuntimeConfig {
restUrl: string; // …/wp-json/allterrain-forms/v1
wpRestUrl: string;
nonce: string; // empty for a logged-out visitor
adminUrl: string;
version: string;
canEdit: boolean;
canRead: boolean;
locale: string;
i18n: Record< string, string >;
}The nonce is empty when nobody is logged in, deliberately: it is per-user and therefore uncacheable, and a page cache would otherwise serve one visitor's nonce to everybody.
Filter it with alltfo_script_config.
The reason the builder is a native window rather than an iframe. All three ride
wp.os.dragManager — the shell's own pointer pipeline, shared with the
wallpaper's file tiles and every other window.
| Payload type | Emitted by | data |
|---|---|---|
allterrain-forms/field |
the palette, and every field on the canvas | { fieldType?, fieldId?, field?, isNew } |
allterrain-forms/form |
the forms list | { form } |
allterrain-forms/entry |
every row in the Entries window | { entry, formId, formTitle } |
This is the cross-app story: drop a submission onto your window and decide what it means to you.
wp.os.ready( () => {
wp.os.dragManager.registerDropTarget( {
id: 'my-plugin/inbox',
element: document.querySelector( '#my-inbox' ),
accept: ( payload ) => payload.type === 'allterrain-forms/entry',
onEnter: () => inbox.classList.add( 'is-dropping' ),
onLeave: () => inbox.classList.remove( 'is-dropping' ),
onDrop: ( session ) => {
// The whole entry, not just an id — so you can render something
// meaningful immediately rather than making a REST call mid-drag.
const { entry } = session.payload.data;
createTicket( entry.title, entry.fields );
},
} );
} );data.entry is the same shape alltfo_prepare_entry() returns: id, formId,
formTitle, title, status, date, values, fields[], starred, quiz.
allterrain-forms/field on the canvas — from its own palette, from its own
canvas, or from a second builder window, which is what a shared drag manager
buys and an iframe could not.
Media payloads (openstation/file and its older spellings) on an image-choice
option's image well, so a photograph dragged out of WP Explorer becomes that
option's picture.
Fired on document after a successful AJAX submission.
document.addEventListener( 'alltfo-submitted', ( event ) => {
const { formId, entryId } = event.detail;
} );Fired at the bundle, not by it. Dispatch it when a form arrives in the DOM after first paint — a modal, an AJAX-loaded page, a block preview — and every unenhanced form on the page is enhanced. Idempotent: forms already booted are skipped.
document.dispatchEvent( new CustomEvent( 'alltfo-refresh' ) );os-window-content-loaded — the shell mounts a native window's markup after the
bundle has already run, so this is what triggers a mount into it.
os.drag.start / os.drag.move / os.drag.end — used to paint the drag source,
and to position the canvas's insertion marker.
os.alltfo_entry.changed — a cross-window broadcast the entries window and the
widget subscribe to, so a new submission appears without a refresh.
Registered through the shell's public registerTitleBarButton surface as
allterrain-forms/preview — the same seam the shell's own editor→preview pairing
uses. OpenStation loads the allterrain-forms-titlebar script at startup, so
the eye appears on restored builder windows without downloading the builder.
The button keeps its label, icon, right-hand placement, and order before the
shell's Related button. Its owner is allterrain-forms-titlebar, allowing
OpenStation to unregister it if the plugin is deactivated mid-session.
When a builder opens, it calls providePreviewSource() with live access to its
current form, dirty state, and save method. The title-bar button uses the most
recently provided source; closing that builder withdraws it. This shared state lives
on window so the separately built title-bar and builder bundles can exchange
it without loading each other.
Pressing it saves any unsaved work first — the preview is a render of the
stored form, so previewing without saving would quietly show the last saved
version and look like the builder had lost the edit — then opens
?alltfo_preview_form=<id> as a paired window.
Saving again refreshes that window rather than stacking a second copy, which is what makes the builder-and-preview-side-by-side loop work.
Everything degrades: with no shell there is no title bar, and the builder's own Preview button opens the same URL in a tab.
Namespace allterrain-forms/v1. Everything but two routes requires
alltfo_edit_forms or alltfo_read_entries.
| Route | Method | Needs |
|---|---|---|
/submit |
POST | public |
/track |
POST | public |
/config |
GET | alltfo_edit_forms |
/forms |
GET, POST | alltfo_edit_forms |
/forms/<id> |
GET, POST, DELETE | alltfo_edit_forms |
/forms/<id>/duplicate |
POST | alltfo_edit_forms |
/forms/<id>/preview |
POST | alltfo_edit_forms |
/forms/<id>/merge-tags |
GET | alltfo_edit_forms |
/forms/<id>/analytics |
GET | alltfo_read_entries |
/entries |
GET | alltfo_read_entries |
/entries/<id> |
GET, POST, DELETE | alltfo_read_entries / alltfo_delete_entries |
/entries/export |
GET | alltfo_read_entries |
/themes |
GET, POST | alltfo_edit_forms |
/themes/<id> |
DELETE | alltfo_edit_forms |
/demo |
GET, POST, DELETE | alltfo_edit_forms and developer mode |
/config returns every registered field type with its supports, its
settings defaults, and — for a composite — the parts it can be told to
show, resolved through alltfo_name_parts / alltfo_address_parts. The builder
draws its controls from that, so a field type registered by a plugin gets the
same inspector the built-ins do without shipping any JavaScript.
/demo is the demo-data generator behind the analytics window's developer panel.
GET reports what exists, POST generates one chunk — call it until remaining
reaches zero — and DELETE removes every generated form and entry. It answers
404 when developer mode is off, which is why the window asks before drawing
the panel rather than drawing it and letting the buttons fail. A user without
alltfo_edit_forms gets the authorisation code instead, so a client can tell "not
allowed" from "switched off".
Every submission it makes goes through the ordinary pipeline, and everything it creates is tagged so removal takes back exactly that and nothing else — a real submission to the demo form survives being cleaned up.
/submit is public by definition — it is how a stranger sends a form. It is
the one route with a permission_callback returning true, and everything
downstream of it treats its input as hostile. The checks that would normally live
in a permission callback all depend on which form was posted, so they happen
inside the pipeline instead.
/entries/export returns the CSV as a string in a JSON envelope, not as a
file response. The entries window is a native window inside a single-page shell,
and navigating to a download URL would take the whole desktop with it; the bundle
turns it into a Blob and saves it locally.
Requests are routed through wp.os.fetch when the shell is present, which pulses
the window's title-bar activity dot and routes a 401 into the shell's own
re-authentication flow — so a session that expires mid-edit is recovered rather
than silently losing the save.
src/shared/logic.ts and src/shared/calc.ts have PHP twins in
includes/logic.php and includes/calc.php, and they must agree.
The browser hides and shows fields as the visitor types; the server decides which fields were actually required. If they disagree, the visitor is shown a form they cannot submit, with an error about a field they cannot see — the worst bug this plugin can have. For calculations, a disagreement means the number somebody was shown is not the number that was stored, which on an order form is a charge dispute.
So they are not tested twice. One table each, in
tests/fixtures/logic-cases.json and calc-cases.json, read by both suites:
logic-cases.json ──┬── tests/vitest/logic.test.ts
└── tests/phpunit/tests/logic.php
A case added to one language is a case added to both. 113 shared cases.
The server remains the authority. Nothing the browser decides is trusted:
alltfo_visible_fields() recomputes visibility from the submitted values,
alltfo_apply_calculations() recomputes every total, and validation only ever runs
against those.
No eval(), no new Function(), in either language. A formula is tokenised,
converted to postfix by the shunting-yard algorithm, and evaluated over a stack.
The only things that can come out are numbers.
Functions are a whitelist — min, max, sum, avg, round, ceil, floor,
abs, sqrt, pow — and that whitelist is the security boundary. Anything
added through alltfo_calc_functions must be pure and numeric.
References resolve before tokenising: {field} becomes a numeric literal,
{repeater} becomes its row count, and {repeater.sub} becomes either one
literal per row (as the sole argument of sum/avg/min/max) or the
parenthesised total across rows (anywhere else). See "Repeaters in formulas"
in field-types.md for the grammar; the shared fixture table holds both engines
to it.
src/dnd.ts exports the same interface whether or not there is a shell:
import { getDragManager, buildPayload, insertionIndex } from './dnd';
element.addEventListener( 'pointerdown', ( event ) => {
getDragManager().start( {
payload: buildPayload( 'my-plugin/thing', element, { thing }, event ),
origin: event,
// Called when the press never travelled far enough to be a drag, so one
// element can be both a button and a drag handle without a click firing
// after a drop.
onClickOnly: () => open( thing ),
} );
} );Inside OpenStation this is wp.os.dragManager. Outside it, a smaller
implementation with the same interface — deliberately, so the builder has exactly
one drag code path. A builder with two is a builder where the fallback is broken
and nobody notices, because the people who would notice are all running the
shell.
Pointer events rather than HTML5 drag-and-drop, in both: HTML5 drag has no
programmatic cancel (Escape, alt-tab and system modals all strand the state), and
setPointerCapture anywhere in the ancestry silently stops dragstart firing at
all.
Typing { in any input with Insert a value opens its searchable picker next
to the control. Pick a value with the mouse, or search and press Enter; arrow
keys move through results and Escape returns to the input. Picker navigation
is captured before desktop shortcuts, so arrows cannot switch desktops, open
Overview or toggle Show Desktop while selecting a reference. The inserted tag
replaces the triggering brace at the cursor. Pasting an existing formula does
not open the picker. The Insert button also remains available.
Notification and confirmation text uses the server's merge-tag catalogue and
syntax ({field:f1}). Its preview describes each known tag as
{the value of Question label} instead of displaying invented answers. Unknown
tags remain visible. This is only a builder preview change; saved tags and
submission-time resolution are unchanged.
Calculation inputs, both in the inspector and the Formula editor, offer numeric
questions, priced choices and repeater references using calculation syntax
({f1}, {attendees.age}). They exclude the field being edited. The Formula
editor continues to calculate its numeric result using the displayed sample
answers.
Conditional is a compact button beside Required/Optional in each field card’s title bar. It opens a dialog with Show/Hide, all/any, and the full rule editor, ready to add conditions without an enable checkbox. Changes stay in a draft until Save conditions, which enables the condition; Cancel or Escape discards them. Clear removes the saved rules, disables the condition, and closes the dialog. Cancel is absent when neither the saved condition nor the draft has rules; Save is disabled for an empty draft. Copy condition inside the dialog also edits only this draft and enables the copied rules when saved. An active condition is indicated by the button’s filled dot.
The bottom condition strip remains directly editable. The + Add rule button
adds a rule, each rule has a Delete button, and Clear removes all rules while
leaving the strip open. These structural changes can be undone.
Condition values use os-select for predefined choices (including checkbox
groups), opinion scales, star ratings, toggles/consent and countries. Numeric
options follow the rendered field's limits; sliders with up to 1,000 steps also
use a selector. Open-ended answers and continuous or larger slider ranges keep
an input. This applies to the dialog, inline strip, inspector, notifications and
confirmations. Value selectors and free-text condition inputs have a 100px minimum width. Unavailable values
from older rules remain visible as disabled options until replaced; opening an
editor never silently changes a saved rule.
Copy condition also appears in Conditional logic / Conditions panels. The
section whose button was clicked is always the destination. The chooser only
asks for Copy from, listing other fields, notifications and confirmations
and previewing the full rule set. Applying replaces
the destination's condition, including enabled state, show/hide, all/any and every
rule. The new rules are independent objects, so changing < 6 to ≥ 6 in the
copy leaves the original intact. Self-copy, missing references and copies that
would make a field depend on itself are excluded. Changes use the normal dirty,
save and undo flow; no new schema keys or public runtime hooks are introduced.
The desktop canvas preview now allows up to 820px (100px wider than before). The desktop palette and inspector reserve 100px less combined, giving that space to the draggable previews while preserving the narrower-window layouts. Shell button hosts receive no extra padding on hover; their internal components own the button dimensions.
The builder's Export, Import and Validate YAML controls use portable form packages. src/shared/form-package.mjs provides parsePackage(text), stringifyPackage(value), packageValidator(schema), and packageObjects(value, schema) for the builder and offline validator. These are development modules, not new properties on window.wp. The shared JSON Schema lives in schemas/form-package-v1.schema.json. File parsing is local; the existing authenticated REST client handles server export, dry-run validation and import.
src/mio/assistant.ts registers private window tools through the feature-detected
wp.os.mio.registerWindow(instanceId, context) API reviewed in OpenStation PR #816.
begin_form_edit, list_form_options, validate_form_yaml and apply_form_edit
are scoped to the native builder lease. They are not global WordPress abilities.
The actual lease is disposed when the root disconnects or the builder is destroyed.
Help sources in docs/mio/ are compiled as raw Markdown through Vite; update these
files alongside field/setting changes and rebuild the builder bundle.
See workflow, error and retry contract, REST routes and upstream API feedback.
The recovery API adapter declares tool effects and supplies turn lifecycle, opaque editor revisions, document metadata, bounded history compaction and an operation-status resolver. Newer MIO receives structured no-effect validation rejections and confirmed server receipts. Older MIO keeps its original argument and result shapes. The integration tests can run against the sibling API source:
ATF_MIO_SOURCE=../alcazaba-plugin npm test -- --run tests/vitest/mio-contract.test.tsThe environment variable temporarily allows Vite to read that source directory for tests; it introduces no runtime or published-package dependency. Without it, only the two optional upstream conformance tests are skipped.
The response action handoff records the button proposal and its implementation in the developer’s unmerged working checkout.
The optional MIO responseActions callback offers Preview only after a confirmed save. Its callback captures the saved form ID, calls api.getForm(id, signal) to refresh the authorized URL, and opens the existing native preview without autosaving. The shell owns button rendering, pending/error state and callback disposal. See the response action handoff and implementation status.