Customizing Input Forms for Request Workflows¶
When a workflow is started — either by an admin from the Workflows page or by an end-user from the request portal — Floh shows a small dialog that asks for the workflow's input variables (a person's email, a requested amount, a start date, and so on). That dialog is the request workflow input form, sometimes called the Custom Run-Start Form in the code and the audit log.
By default, Floh auto-generates the form from your workflow's variable list and shows the fields stacked in declaration order. That's enough for most workflows. The Input Form tab in the workflow designer lets you take over the layout when "the default field stack" isn't enough — for example when you want:
- A short intro paragraph at the top of the dialog ("Tell us about your new hire…").
- Section headings that group related fields ("Person", "Access", "Notes").
- Two-column responsive layouts.
- Conditional sections that only appear when an upstream variable is set.
- A summary card on the right that previews what the requester typed.
- Branded layouts with icons, cards, and theme-aware colours.
This page walks you from "I just want to tweak the intro copy" all the way through to the full helper reference and security model. Read top-to-bottom the first time; later you can jump straight to the cheat sheet at the bottom.
Skip the template entirely? Leave the editor empty and the dialog falls back to the auto-generated form. The designer also seeds the editor on first edit — first from the org's default-for-type template (managed in the Template Library) and otherwise from a variable-derived scaffold — so you're never staring at a blank box.
Two ways to author a form (Phase II)¶
Floh now supports two coexisting authoring shapes for a workflow's input form. Pick one per workflow — they are mutually exclusive at save time:
- HTML / Handlebars — the original shape this page describes.
Best when you need pixel-level layout control, conditional sections,
intro copy, summary cards, or a scripted form that talks to
floh.context. Continue reading for the full helper reference and security model. - JSON Forms — a structured layout shape produced by the
standalone Form Builder (@floh/form-builder-app) and
rendered by @floh/form-builder-core. The author defines
fields and grouping in a visual UI; the builder emits a
uiSchema(layout) plus adataSchema(validation). Best when you want a portable form definition that can be re-used in other apps, or when the dialog's needs fit the curated set of layout primitives the builder ships (VerticalLayout,HorizontalLayout,Group,Control,Markdown).
The Input Form tab in the workflow designer surfaces a small format toggle at the top:
- HTML / Handlebars — the legacy editor (default for existing workflows; the rest of this page applies).
- JSON Forms — choose between two views:
- Visual (recommended): an embedded iframe of the
@floh/form-builder-app running on the configured
formBuilderEmbedUrl(see Form Builder visual editor). Every valid, dirty edit in the visual editor flows back into the designer's persisted state — invalid buffers and the post-init handshake echo are intentionally not written back. - Source: three textareas (UI Schema, Data Schema, Sample Values) accepting JSON pasted from the standalone Form Builder. Use this view when you want to edit the raw JSON or when the visual editor isn't deployed in the current environment.
Wire shape¶
When you save a workflow, the API stores either a template
(HTML/Handlebars) or a uiSchema + dataSchema (JSON Forms) —
never both. The route layer rejects payloads that try to declare both
shapes, and the runtime guard rejects rows where one or the other is
missing. JSON Forms payloads cannot opt into the scripted-form fields
(scriptingEnabled, libraries, bridgeAllowList) — those remain
HTML-only. Switching shapes from the designer is a holistic replace:
flipping HTML → JSON Forms wipes the local scripted-form metadata so a
save never trips the route's "scriptingEnabled is only valid with
HTML" rejection.
Authoring with the standalone Form Builder¶
The Form Builder is a separate Angular SPA you can run locally or host alongside Floh. There are two ways to use it from the Floh designer:
- Visual mode (live) — the designer embeds the Form Builder in
an iframe. Every change in the iframe flows back into the
designer's persisted state through a versioned
postMessageprotocol; no copy-paste step. This is the default when the environment is configured with a non-emptyformBuilderEmbedUrl. See Form Builder visual editor for the operations guide and security notes. - Source mode (paste) — the legacy round-trip: design in the builder → copy JSON → paste into the three textareas → save. No live link; if you change the form definition you re-paste.
Both modes write to the same persisted shape (uiSchema +
dataSchema + optional sampleValues) so flipping between them in
the designer is non-destructive — the visual editor materialises
edits into the textareas on every change, and the textareas seed the
visual editor on its next mount.
When the run-start dialog renders a JSON Forms workflow¶
Floh's app-form-template-renderer (used by both the workflow detail
Start Run dialog and the portal's request form dialog) checks
the workflow's inputForm shape on every render:
inputForm.uiSchemaandinputForm.dataSchemaare both set → the dialog mountsfb-form-layout-rendererfrom@floh/form-builder-core, which recursively walks the layout tree and delegates eachControlto the defaultPlainHtmlFieldComponentwidget. Both schemas are required because the layout walker dereferencesdataSchema.propertiesfor everyControl.scope; an in-flight save with only one of the two should never reach the dialog (the server'svalidateJsonFormsInputFormrejects it).- otherwise
inputForm.templateis set → the dialog uses the Handlebars / strict / scripted renderer described in the rest of this document. - otherwise the dialog falls back to the auto-generated variable stack.
Display elements and runtime context (Phase III)¶
JSON Forms layouts can embed read-only Display elements that
echo a value resolved from a host-supplied runtime context
dictionary — the equivalent of {{value "..."}} in the Handlebars
world, but resolved against requester / target-user / workflow
metadata rather than the in-progress form data.
The run-start host (the workflow detail / list dialog in the admin
app and the request-form dialog in the portal) builds the runtime
context dictionary from active session and workflow snapshots and
passes it into app-form-template-renderer, which forwards it
unchanged to <fb-form-layout-renderer [context]>. Snapshot
shaping (PII filtering, firstName/lastName derivation, etc.)
is the host's responsibility, not the renderer's. The shape is
fixed:
{
submitter: { id, email, displayName, firstName, lastName }?,
targetUser: { id, email, displayName, firstName, lastName }?,
workflow: { id, name, version }?,
}
firstName / lastName are derived from displayName when the
session record only carries the combined display name. Branches are
omitted (not carried as null) so a Display bound to a missing
path falls through to its fallback rather than rendering an empty
string.
A Display element references the dictionary with a JSON
Pointer-style contextScope:
{
"type": "Display",
"contextScope": "#/context/submitter/firstName",
"label": "Submitter",
"fallback": "—"
}
The Phase III renderer surfaces the resolved value as plain text
(or the fallback string when the path doesn't resolve). The
format property is reserved for future renderers — format:
"email" (mailto anchor) and format: "date" (localized date
formatting) are on the roadmap but not yet implemented; setting
format today is a no-op rather than an error so a form package
authored against a future builder version still loads.
The author-side validator (@floh/form-builder-core) cross-checks
every contextScope against the form package's
preview.context seed at design time, so a typo (#/context/submiter)
is flagged in the visual builder before the form ever ships.
Routing form fields to differently-named workflow variables (Phase III)¶
By default a Control whose scope is #/properties/firstName
populates a workflow variable named firstName at submit time. When
the form's field name and the workflow variable name diverge — for
example, when a workflow variable is called submitterFirstName but
the form field is called firstName — the Control can carry an
explicit outputVariable override:
outputVariable must be a valid identifier
(/^[A-Za-z_$][A-Za-z0-9_$]*$/) and must NOT be one of the reserved
prototype-pollution names __proto__, constructor, or prototype
— those are rejected at save time. The save-time validator also
enforces uniqueness on the effective output binding
(outputVariable ?? scope's fieldKey) so two Controls cannot collide
on the same workflow variable.
The override is purely a host-side concern; the renderer ignores it. Every form-builder UI tool (admin run-start, portal request form) remaps the values map at submit time so the workflow run sees the variable-keyed shape.
How a template is built — the two technologies you need to know¶
A custom input form template is plain HTML with two layers on top. You don't need to be deeply expert in either, but a one-paragraph mental model helps a lot.
Handlebars (the {{ … }} syntax)¶
Handlebars is a tiny templating language. The only piece of syntax you need to recognise is the double-curly:
{{firstName}}reads a value from the form's data.{{field "firstName"}}calls a Floh helper function namedfieldwith the argument"firstName".{{#if showAdvanced}} … {{/if}}is a conditional block.
Floh registers a small set of form-specific helpers (field, input,
label, …) on top of Handlebars's built-in block helpers (#if,
#unless, #each, #with). The full helper list lives later in this
page; the official Handlebars docs cover the rest.
Useful Handlebars references:
- Handlebars language guide — the short tour that explains expressions, helpers, and blocks.
- Built-in helpers
— the full reference for
#if,#unless,#each,#with,lookup, and friends. - Block comments
—
{{!-- comment --}}. Use these, not HTML comments, around mustache examples (see the{{!-- … --}}callout further down).
Tailwind CSS (the class="…" utilities)¶
Tailwind CSS is a utility-first CSS
framework. Instead of writing custom CSS, you compose layouts and styles
out of short, single-purpose classes inside class="…":
class="flex flex-col gap-3"→ a vertical stack with 0.75rem gaps between children.class="text-sm text-muted-color"→ small, theme-aware muted text.class="grid grid-cols-12 gap-4"+class="col-span-12 md:col-span-6"on each child → a 12-column grid that stacks on phones and goes side-by-side on tablets and up.
Floh ships Tailwind v4 plus the
tailwindcss-primeui
preset (which adds the text-color, text-muted-color, bg-surface-*,
and border-surface palette helpers that match the PrimeNG Lara theme),
plus PrimeIcons for inline glyphs. Every
class is already loaded — you don't add a <style> block or <link>
stylesheet.
Useful Tailwind references:
- Tailwind utility-first concepts
— the right mental model for "writing CSS in the
classattribute". - Responsive variants
—
sm:,md:,lg:prefixes for breakpoint-aware classes. - State variants
—
hover:,focus:,focus-within:,has-[…]:and friends. tailwindcss-primeui— the theme-aware colour and surface utilities (text-primary,bg-surface-100,border-surface).- PrimeIcons gallery — searchable list of
every
pi-…glyph name.
Editor superpower: press
Ctrl+Spaceanywhere inside aclass="…"attribute (or inside a helper hash like{{field "x" class="…|"}}) to get autocomplete for every available Tailwind class. Curated entries surface first with descriptions; the full catalog follows. Insideclass="pi …"on an<i>, the suggestions switch to the 300+ PrimeIcons glyph names. The Styling reference button above the editor opens the same catalog as a searchable drawer.
The default template, walked through line by line¶
When you open a brand-new workflow's Input Form tab, this is the template you'll see (it's also the seeded default in the Template Library):
<p class="mb-4">Please complete the following:</p>
<div class="flex flex-col gap-4">{{allFields}}</div>
{{!-- Tips: Use {{field "variableName"}} to render label + control + description. Use {{label
"variableName"}} / {{input "variableName"}} for custom layouts. Wrap sections in {{#if
variableName}} ... {{/if}} for conditional visibility. --}}
It looks like a tiny snippet because most of the work is happening inside
the {{allFields}} helper. Here's what each line does.
Line 1 — the intro paragraph¶
A plain HTML <p> with one Tailwind class:
| Class | Meaning |
|---|---|
mb-4 |
margin-bottom: 1rem — adds breathing room below the intro line. |
Replace the text with anything — "Tell us about your new hire", "Submit
your access request", "Please confirm the change details below", etc.
Markdown won't work here (it's HTML, not Markdown), but you can use
<strong>, <em>, <a href="…">, and the rest of the standard tags.
Lines 2–4 — the field stack¶
A wrapper <div> that lays out its children as a vertical stack, plus
the magic helper that renders every workflow variable.
| Class | Meaning |
|---|---|
flex |
Turns the <div> into a flexbox container. |
flex-col |
Stacks the children vertically (top to bottom) instead of left to right. |
gap-4 |
Inserts a 1rem gap between each child — consistent spacing without per-field margins. |
{{allFields}} is the most powerful helper Floh ships. It expands to one
labelled, described, validated form control per workflow variable, in
the order you declared them. Concretely, it's roughly equivalent to
writing {{field "var1"}}{{field "var2"}}{{field "var3"}}… for every
variable on the workflow.
The linter treats
{{allFields}}as a promise that "every variable is mounted somewhere", so using it suppresses the per-variable "missing required field" warning for workflows that have a lot of required inputs.
Lines 5–9 — the author tips¶
{{!-- Tips:
Use {{field "variableName"}} to render label + control + description.
Use {{label "variableName"}} / {{input "variableName"}} for custom layouts.
Wrap sections in {{#if variableName}} ... {{/if}} for conditional visibility.
--}}
A Handlebars block comment. Anything inside {{!-- … --}} is
stripped out at compile time and never reaches the renderer or the user.
Important: use
{{!-- … --}}, not<!-- … -->, around any example mustache syntax. HTML comments stay in the output and the Handlebars compiler still parses the mustaches inside them, so an example like<!-- {{field "x"}} -->ends up reported by the linter as "unknown variable x". Block comments are completely invisible to the parser, which is exactly what you want for author notes.
That's the whole default template. Nine lines, three of them just documentation. The next sections show how to grow it.
Your first edits¶
Pick the change that matches your situation and copy-paste.
Change 1 — better intro copy¶
Replace Please complete the following: with something that matches the
workflow:
<p class="mb-4">
<strong>New hire account request.</strong>
Tell us a bit about the new starter — IT will pick up the request as soon as you submit.
</p>
<div class="flex flex-col gap-4">{{allFields}}</div>
Change 2 — render fields in a custom order¶
Replace {{allFields}} with one {{field "name"}} per variable, in the
order you want them shown:
<p class="mb-4">Please complete the following:</p>
<div class="flex flex-col gap-4">
{{field "lastName"}} {{field "firstName"}} {{field "personalEmail"}} {{field "startDate"}}
</div>
You'll get a lint error if you forget a required variable. Either add
the missing {{field "…"}} or fall back to {{allFields}}.
Change 3 — group fields under section headings¶
Wrap related fields in a <section> with a heading and some helper
copy:
<section class="flex flex-col gap-3">
<h3 class="text-lg font-semibold mb-1">Person</h3>
<p class="text-sm text-muted-color mb-2">Who is this account for?</p>
{{field "firstName"}} {{field "lastName"}} {{field "personalEmail"}}
</section>
<hr class="border-t border-surface my-5" />
<section class="flex flex-col gap-3">
<h3 class="text-lg font-semibold mb-1">Access</h3>
<p class="text-sm text-muted-color mb-2">Pick the team and starter role.</p>
{{field "team"}} {{field "role"}}
</section>
That's enough to get a real form into production. The rest of this page is reference material you can dip into when you need it.
How a template renders, behind the scenes¶
Once you understand the default template, here's what actually happens when the dialog opens:
Author HTML+Handlebars
│
▼
Handlebars.compile() ← form helpers ({{field}}, {{input}}, …) registered
│
▼
DOMPurify.sanitize() ← strips <script>, <iframe>, on*, javascript: URLs
│
▼
host.innerHTML = … ← rendered into a plain <div class="floh-template-host">
│
▼
Delegated input/change listener → emits valuesChange to the parent
Three things are worth pinning down:
| Concept | What it is |
|---|---|
| Schema | The JSON-Schema-shaped object built from the workflow's declared variables (firstName, requestedAmount, etc.). Helpers look up titles, defaults, enum options, and required-ness here. |
| Values | The live in-progress map the requester is editing. Bound two-way: rendered controls write back through the parent, the parent echoes updates back into the host. |
| Compiled HTML | A plain DOM subtree (no Angular components, no shadow DOM). Every global stylesheet — the Lara theme, Tailwind utilities, PrimeIcons — applies to it. |
Compile errors never blank the preview mid-edit: if the helpers throw
(e.g. you've typed {{#if foo}} but not yet the closing {{/if}}), the
renderer keeps the previous successful HTML on screen and the linter
strip below the editor surfaces the error.
What "the data context" means¶
Every Handlebars expression — bare path ({{firstName}}), helper
({{value "firstName"}}), or block ({{#if showAdvanced}}) — resolves
against the values map. That map is the merge of:
- Schema defaults (
field.default). - The parent-supplied
values(the user's in-progress edits).
So {{requestedAmount}} inside a paragraph reads the live numeric
value, {{value "firstName"}} reads the live string, and
{{#if confirmed}}…{{/if}} branches on the live boolean. Helpers like
{{label "x"}} and {{required "x"}} ignore the values map and read
the schema only.
Live updates caveat. Form controls emitted by the helpers (the elements with
name="…") are kept in sync with the values map without a full re-render — your caret position and focus survive remote echoes of your own typing. Non-control expressions ({{value "x"}}inside a paragraph,{{#if foo}}branches) only re-resolve on schema/template changes, not on every keystroke. Use a<details>disclosure or a CSS:focus-within/:has()rule for fully reactive show/hide UI today (recipe 4 below).
The form helper reference¶
Floh registers nine helpers on top of stock Handlebars. Skim the table, then dive into the per-helper sections when you need detail.
| Helper | Returns | Mounts a control? |
|---|---|---|
{{field "name"}} |
Wrapper <div> with label + input + description |
Yes |
{{input "name"}} |
Bare control (<input> / <select> / <textarea>) |
Yes |
{{label "name"}} |
<label for="…"> with the schema title |
No |
{{description "name"}} |
<small> with the schema description |
No |
{{value "name"}} |
Escaped text — current value as a string | No |
{{default "name"}} |
Escaped text — schema default as a string | No |
{{required "name"}} |
Boolean — usable in {{#if (required "x")}} |
No |
{{enumOptions "name"}} |
[{value, label}, …] — usable in {{#each}} |
No |
{{allFields}} |
Every field in fieldOrder, each as {{field}} |
Yes (for all) |
The linter treats
{{field}}and{{input}}as mounting helpers and warns if you reference the same variable twice — duplicate mounts emit two independent controls with the sameid, and the user's edit only goes through one of them.
{{field "name"}} — the everyday helper¶
Renders the full label + control + description block, wired up with the
correct for= / id= / aria-describedby= linkage and the right
required / disabled / min / max / placeholder attributes from
the schema. Reach for this 90% of the time.
<div class="flex flex-col gap-3">
{{field "firstName"}} {{field "lastName"}} {{field "requestedAmount"}}
</div>
Hash overrides (more on hash arguments in the next section) target each
piece independently — class styles the wrapper, inputClass /
labelClass / descriptionClass style the inner pieces, and
placeholder overrides the schema-supplied hint:
{{field "requestedAmount" class="rounded border border-surface bg-surface-50 p-3"
labelClass="font-semibold text-color" inputClass="text-lg" descriptionClass="text-muted-color
italic" placeholder="0 – 100 USD"}}
The built-in floh-field-* marker classes are always emitted first, so
global rules in styles.scss keep working even when you stack Tailwind
utilities on top.
{{input "name"}} — bare control for custom layouts¶
Use when you want to compose your own label / hint chrome around the control:
<label for="floh-field-requestedAmount" class="font-semibold mb-1">
How much do you need? <span class="text-red-500">*</span>
</label>
<small class="text-muted-color block mb-1"> Enter a value in USD between 0 and 100. </small>
{{input "requestedAmount" class="w-full text-lg"}}
The id the helper emits is always floh-field-<name>, so a
hand-written <label for="floh-field-requestedAmount"> always lines up.
{{input}} resolves to the right element for the field's type:
| Schema shape | Emitted control |
|---|---|
type: "string" |
<input type="text"> |
type: "string", format: "email" |
<input type="email"> |
type: "string", format: "date" / "date-time" |
<input type="date"> |
type: "string", widget: "password" / format: "password" |
<input type="password"> |
type: "string", widget: "textarea" / format: "textarea" |
<textarea rows="3"> |
type: "string", format: "code" |
<textarea> (multiline) |
type: "number" / "integer" |
<input type="number"> (with min / max) |
type: "boolean" |
<input type="checkbox"> |
type: "string", enum: [...] |
<select> |
type: "array", items: { enum: [...] } |
<select multiple> |
widget: "hidden" |
<input type="hidden"> (no chrome) |
{{label "name"}} — just the label¶
Renders <label for="floh-field-…"> with the schema title (or the
humanized variable name as a fallback) and a * required marker when
applicable.
<div class="grid grid-cols-2 gap-4">
<div>{{label "firstName" class="font-semibold"}}</div>
<div>{{input "firstName"}}</div>
</div>
{{description "name"}} — just the helper text¶
Renders <small id="floh-field-…-desc"> with the schema description.
Returns an empty string when the field has no description set, so it's
safe to drop in unconditionally.
The id matches the one {{input "name"}} references via
aria-describedby, so emitting both keeps screen-reader users in sync.
{{value "name"}} and {{default "name"}} — read-only text¶
Both emit escaped text. {{value}} reads the live in-progress value;
{{default}} reads the schema default. Useful for review sections, copy
that includes a preview of what the user typed, or default-value hints.
<p>You entered <strong>{{value "firstName"}}</strong>.</p>
<p class="text-sm text-muted-color">
Default is {{default "requestedAmount"}} USD if you leave the box empty.
</p>
Reminder: these don't auto-update on every keystroke. They re-resolve when the template / schema changes, not when
valuesdoes. Treat them as a snapshot-on-render rather than a live binding.
{{required "name"}} — boolean for {{#if}}¶
Returns true if the field is in the schema's required array. Useful
for conditional decoration:
<label for="floh-field-firstName">
First name {{#if (required "firstName")}}
<span class="text-red-500" aria-label="Required">*</span>
{{/if}}
</label>
{{enumOptions "name"}} — list for {{#each}}¶
Returns an array of { value, label } records — the same options that
{{input "name"}} would emit on a <select> — so you can iterate the
choices for read-only display, autocomplete via <datalist>, or any
custom layout that doesn't replace the renderer-managed control.
The safe pattern is to mount the actual control with {{field}} (or
{{input}}) and use {{enumOptions}} to render a companion legend or
data list:
{{field "role"}} {{!-- Companion legend so requesters can scan all options at a glance. --}}
<p class="text-xs text-muted-color mt-1">
Available roles: {{#each (enumOptions "role")}}
<span class="inline-block bg-surface-100 rounded px-1 py-0.5 mr-1">{{this.label}}</span>
{{/each}}
</p>
Inside
{{#each}}, usethis.valueandthis.labelrather than the bare names.{{value}}and{{label}}are also helper names and would shadow the per-item fields the iterator exposes.
Don't replace the control with custom radio buttons
The renderer writes back into native form controls by setting
element.value directly, which clobbers each radio's value
attribute and collapses a <input type="radio" name="x"> group
into a single shared value. Custom radio rendering is tracked in
#275. Until
then, use {{field "name"}} (which renders a <select> for enum
fields) and treat {{enumOptions}} as read-only.
Linter caveat
{{enumOptions}} is checked by the linter's unknown-variable
rule but does not count as "mounting" the field for the
missing-required-field rule. If the variable is required, mount
it explicitly with {{field}} or {{input}} (or use
{{allFields}}) — the recipe above does both correctly.
{{allFields}} — render everything¶
Already met in the default template. Emits one {{field}} block per
property in fieldOrder (or Object.keys(properties) if no order is
set). Use it as the body of a catch-all template:
<p class="mb-4">Please complete the following:</p>
<div class="flex flex-col gap-4">{{allFields}}</div>
It's also the linter's escape-hatch for the missing-required-field
rule — using {{allFields}} declares "I want every field rendered",
which suppresses the per-required-field warning for unmounted variables.
Hash arguments — styling and overriding helper output¶
The mounting helpers ({{field}}, {{input}}, {{label}},
{{description}}) accept Handlebars hash arguments for layered styling
and per-template copy overrides:
| Hash key | Helpers | Effect |
|---|---|---|
class="…" |
{{field}} {{input}} {{label}} {{description}} |
Appended to the built-in marker class on the emitted element. |
inputClass="…" |
{{field}} |
Routed to the inner <input> / <select> / <textarea>. |
labelClass="…" |
{{field}} |
Routed to the inner <label>. |
descriptionClass="…" |
{{field}} |
Routed to the inner <small>. |
placeholder="…" |
{{field}} {{input}} |
Wins over the schema's field.placeholder for this render. |
Empty / whitespace-only hash values fall through to the schema-supplied
value, so class="" is harmless and placeholder=" " still picks up
the default.
Hidden fields (
widget: "hidden") deliberately ignore bothclassandplaceholderoverrides — surfacing visible chrome on a server-populated field would mislead authors into treating the hidden value as part of their visual design.
Block helpers — conditionals, loops, and comments¶
Standard Handlebars block helpers are available unchanged. The Handlebars built-in helpers reference covers them in depth; here's the cheat sheet.
| Block | What it does |
|---|---|
{{#if x}}…{{/if}} |
Renders the body when x is truthy. |
{{#unless x}}…{{/unless}} |
Renders the body when x is falsy. |
{{#each xs}}…{{/each}} |
Renders the body once per item in xs. Inside, use this, @index, @key. |
{{#with obj}}…{{/with}} |
Sets obj as the current context for the body. |
{{lookup obj key}} |
Dynamic key lookup ({{lookup obj "fieldName"}}). |
{{!-- comment --}} |
Author-only comment. Use this, not <!-- … -->, around mustache examples — Handlebars parses mustaches inside HTML comments and the linter will flag the inner expressions. |
Sub-expressions work too: {{#if (required "firstName")}}…{{/if}} and
{{#each (enumOptions "role")}}…{{/each}} are the patterns you'll reach
for most often.
Styling deep-dive — Tailwind v4, in practice¶
The renderer host is a plain document-level <div> — no iframe, no
shadow DOM — so every global stylesheet the app loads is in scope:
- The PrimeNG Lara theme for the form controls themselves.
- Tailwind CSS v4 + the
tailwindcss-primeuipreset for utility classes (layout, spacing, colour, typography, responsive variants, state variants likehover:,focus:). - PrimeIcons for inline glyphs.
You don't need to add any <style> block, <link>, or font import —
the bundle is already loaded.
Common starting-point classes¶
| Use | Recommended classes |
|---|---|
| Stack form fields vertically | flex flex-col gap-3 (or gap-4 for major sections) |
| Two-column responsive row | grid grid-cols-12 gap-4 + col-span-12 md:col-span-6 per child |
| Section heading | text-lg font-semibold mt-4 mb-2 |
| Helper / footnote text | text-sm text-muted-color |
| Card-style wrapper | border border-surface rounded p-4 bg-surface-0 shadow-sm |
| Subtle panel inside a card | bg-surface-100 rounded p-3 |
| Theme accent | text-primary (foreground) or bg-primary (background) |
| Status colour | text-red-500 (error), text-green-500 (success), text-muted-color (neutral) |
| Inline icon | <i class="pi pi-check-circle text-green-500"></i> — always pair pi with a pi-… |
| Spin animation on a loader icon | <i class="pi pi-spinner pi-spin"></i> |
Prefer class names over inline
style="…"so your template keeps picking up theme colour and spacing updates automatically when the Lara palette changes.
Editor autocomplete recap¶
The Monaco editor in the Input Form tab knows about the class catalog:
- Press
Ctrl+Spaceanywhere inside aclass="…"attribute to get the full Tailwind + PrimeUI suggestion list. - Curated suggestions (the ★ ones) surface first with descriptions and the emitted CSS rule.
- Inside
class="pi …"on an<i>element, the suggestion list switches to PrimeIcons glyph names (pi-check-circle,pi-user, …). - Click the Styling reference button above the editor to open a searchable drawer of every class with a copy button.
The same autocomplete fires inside helper hash arguments — so
{{field "x" class="…|"}}, {{field "x" inputClass="…|"}}, etc. all
suggest the right utilities.
Interactivity — what works today¶
Custom templates support a deliberate, tightly-scoped set of interactivity primitives. By default a template renders in strict mode: no inline JavaScript at all. Reach for the Handlebars/CSS patterns below first — they cover most use cases.
When you really need live behaviour (cross-field calculations, "show
this section when the amount exceeds N", populating a field from a
helper script), an admin with workflow:author_scripted_form can flip
the Enable JavaScript toggle on the workflow's Input Form tab
to turn on scripted mode. Scripted templates run in a sandboxed,
nonce-restricted iframe with a narrow window.floh API; the full
contract is documented in the Scripted forms (Tier A)
section below.
1. Native HTML form validation¶
Every control emitted by the helpers carries the right HTML5 validation
attributes from the schema (required, min, max, minlength,
maxlength, pattern, type="email", type="number", etc.). The
browser surfaces validation messages on submit and during typing without
any extra effort:
The portal request dialog also runs schema validation on submit and blocks the request from being sent until every required value is present, so client-side validation is belt-and-braces.
2. Show / hide via {{#if}}¶
{{#if foo}} resolves against the values map. For pre-render conditional
visibility — e.g. a section that only appears when an upstream value is
set — this works out of the box:
{{#if (required "managerEmail")}}
<p class="text-muted-color text-sm">A manager email is required for this workflow type.</p>
{{/if}}
This is not a live "hide-on-checkbox-tick" mechanism. The conditional is evaluated once per render, not on every keystroke. For reactive show/hide use the CSS-only patterns below.
3. CSS-only reactive UI¶
Modern CSS gives you several patterns that do react to user input without any JavaScript.
Disclosure with <details>¶
<details class="border border-surface rounded p-3">
<summary class="font-semibold cursor-pointer">Advanced options</summary>
<div class="flex flex-col gap-3 mt-3">{{field "approvalNotes"}} {{field "ccRecipients"}}</div>
</details>
"Show extra fields when this is checked" with :has()¶
<div class="flex flex-col gap-3">
{{field "needsApproval"}}
<div class="hidden has-[input[name='needsApproval']:checked]:block">
<p class="text-sm text-muted-color mb-2">
Approval routing only matters when the box above is ticked.
</p>
{{field "approverEmail"}}
</div>
</div>
The Tailwind v4 has-[…]: variant compiles to a :has() selector. Pair
with hidden and a :has-…:block override to gate a region on a
sibling control's state.
Highlight focused regions with :focus-within¶
<fieldset
class="rounded border border-surface p-3 focus-within:border-primary focus-within:bg-primary-50/30"
>
<legend class="px-1 font-semibold">Contact details</legend>
<div class="flex flex-col gap-3 mt-2">{{field "email"}} {{field "phone"}}</div>
</fieldset>
4. Accessibility hooks¶
The helpers wire up for= / id= / aria-describedby automatically.
You can layer additional ARIA attributes (aria-label, aria-live,
role) directly in your HTML and they pass through the sanitizer
unchanged:
<div role="region" aria-label="Account details" class="flex flex-col gap-3">
{{field "accountName"}} {{field "department"}}
</div>
What does NOT work today¶
In strict mode (the default — no scripting toggle) the sanitizer strips these unconditionally, both server-side on save and client-side on every render:
<script>elements (allowed only in scripted mode — see below).- Inline event handler attributes —
onclick,onchange,oninput,onsubmit,onkeydown, anything starting withon*(a curated subset survives in scripted mode). <iframe>,<object>,<embed>elements (always blocked, even in scripted mode).href="javascript:…"/src="javascript:…"URIs (always blocked).<style>rules with CSSexpression(…)orurl(javascript:…)(always blocked).
These render but break the value round-trip — the renderer's value-sync path doesn't know about them yet, so authoring them produces inputs that look right but submit the wrong (or empty) value:
- Custom radio button groups (
<input type="radio" name="x">mounted by hand from{{enumOptions}}). The sync path treats radios as generic text inputs and overwrites every option'svalueattribute, collapsing the group. Use{{field "x"}}(which renders a<select>for enum fields) and use{{enumOptions}}only for read-only display. - Native
<input type="range">/<input type="color">/<input type="file">and other controls outside the schema's text / number / date / boolean / select coverage. The renderer reads them as plain text and the schema-side coercion may reject the value on submit.
If you need live cross-field behaviour and the CSS-only patterns above don't cut it, jump to Scripted forms (Tier A) below. Otherwise open a feature request describing the underlying need.
Scripted forms (Tier A)¶
When the Handlebars/CSS patterns above aren't enough, an admin with
workflow:author_scripted_form can opt the workflow into scripted
mode to run a small amount of JavaScript inside a sandboxed iframe.
Scripted forms can:
- Set workflow values from inline event handlers
(
oninput="floh.set('amount', this.value)"). - Run one or more
<script>blocks at form mount to wire up cross- field behaviour, prefill computed values, or react to changes viafloh.onChange(...). - Pull in a curated, server-vetted JavaScript library (e.g.
dayjs) from the form-library catalog by listing it under Curated libraries in the designer.
Scripted forms cannot:
- Touch
window,document,parent,localStorage,fetch,XMLHttpRequest,eval,Function, or any other identifier on the blocklist (full list below). Authoring any of those is a save-time validation error, not a silent strip. - Reach the parent SPA. The iframe runs under
sandbox="allow-scripts allow-forms"(noallow-same-origin), so cookies, localStorage, and the parent DOM are out of reach. - Load a
<script src="...">from outside the curated library catalog. External CDNs are blocked by both the sanitizer and the iframe CSP.
Enabling scripted mode¶
- Open the workflow's Input Form tab in the designer.
- Tick Enable JavaScript (only visible to users with
workflow:author_scripted_form). - Optionally select one or more entries from Curated libraries. The list is filtered to libraries the workflow's sponsor organization has enabled in the Form Library Catalog admin page.
- Save. The server validates the template (Handlebars syntax + acorn
on every script body and
on*attribute) before persisting.
Scripted mode is a per-workflow flag, persisted on inputForm.scriptingEnabled.
Toggling it off and saving reverts to strict-mode sanitization on the
next render and emits a workflow.input_form_updated audit event so the
change is visible in the audit log (the event currently records the
variable diff and a cleared flag — see Audit trail for
the exact metadata shape).
The window.floh API¶
Inside the iframe, scripts and on* handlers see exactly one global —
window.floh. Everything else (window, document, parent, …) is
on the static blocklist and rejected at save time. The shipped surface
is intentionally tiny:
| Member | Signature | Purpose |
|---|---|---|
floh.get(name) |
(string) => unknown |
Read the current value of a workflow variable. |
floh.set(name, value) |
(string, unknown) => void |
Write a workflow variable. The new value is mirrored back to the parent renderer's value map and broadcast to every onChange listener. |
floh.values() |
() => Record<string, unknown> |
Snapshot every variable. Returns a copy — mutating the result has no effect on the underlying state. |
floh.onChange(cb) |
((event) => void) => () => void |
Register a listener for value changes. The callback receives { name, value, values } (the just-changed name + value, plus a fresh snapshot). Returns a disposer. |
Things that are deliberately not in the API today (open a feature
request if you need one): direct DOM mutation (floh.fields.toggle,
floh.fields.show), runtime lookup proxies (floh.lookup.*), and
read-only context surfaces (floh.context.user). The bridge protocol
already reserves room for these but no host implementation ships yet.
Example — cross-field calculation¶
<form class="flex flex-col gap-3">
<label>
Amount
<input name="amount" type="number" oninput="floh.set('amount', this.valueAsNumber)" />
</label>
<label>
Tax
<input name="tax" type="number" readonly />
</label>
<script>
var update = function () {
var amt = floh.get("amount");
if (typeof amt !== "number") return;
floh.set("tax", Math.round(amt * 0.07 * 100) / 100);
};
floh.onChange(function (e) {
if (e.name === "amount") update();
});
update();
</script>
</form>
The <script> block (note: no type attribute needed — the server
canonicalizes it on save) recomputes tax whenever amount changes
and on initial mount. Because every value flows through floh.set, the
parent renderer's submit path picks them up automatically.
What the sanitizer accepts in scripted mode¶
| Surface | Allowed | Notes |
|---|---|---|
Vetted on* attributes |
onchange, oninput, onclick, onblur, onfocus, onsubmit, onreset, onkeydown, onkeyup |
Body must be a single expression statement (no ;-chaining, no if/for). Any other on* is stripped wholesale. |
<script> blocks |
Up to 8 blocks per template (MAX_FORM_SCRIPT_BLOCKS); any author-written type (or no type) is canonicalized to application/floh-form on save. |
Blocks are executed in source order inside the iframe, after the curated libraries. |
| Curated libraries | Each entry from the workflow's inputForm.libraries array, validated against the org's catalog enablement at save time. |
Bytes are served from disk by the asset store; every <script> tag carries integrity="…" + crossorigin="anonymous" so a poisoned CDN can't smuggle changes. |
| Handlebars + Tailwind | Same allow-list as strict mode. | Helpers ({{field}}, {{input}}, …) work the same in both modes. |
The blocklist applied to every kept handler body and every script
block (parsed with acorn): window, document, parent, top,
globalThis, self, frames, location, navigator, localStorage,
sessionStorage, indexedDB, fetch, XMLHttpRequest, WebSocket,
EventSource, Worker, SharedWorker, ServiceWorker,
importScripts, eval, Function. The save fails with a
"references blocked identifier" error if any of these appears as an
identifier (member-property accesses like obj.window are exempt — it
is the bare identifier reference that matters).
Sandbox and CSP¶
Scripted forms always render inside an iframe with:
sandbox="allow-scripts allow-forms"— neverallow-same-origin.- A scoped
Content-Security-Policywhosescript-srcis restricted to a per-page nonce shared with the parent SPA host (today: nginx) plus the curated/static/form-libsprefix. - A separate per-mount
messageTokencarried in everypostMessageenvelope to authenticate the bridge. Multiple scripted forms on the same page cannot impersonate each other even though they share a page-scoped CSP nonce.
The deployment requirements for the SPA host are documented in
.cursor/rules/server/scripted-forms-security.mdc. If a future host
serves the SPA HTML, it MUST mint a per-request CSP nonce, swap it
into <meta name="floh-csp-nonce" content="…"> in index.html, emit
the matching Content-Security-Policy header, and send
Cache-Control: no-store on HTML — otherwise the browser will block
every iframe script under the parent's CSP.
Audit trail¶
Every save to a workflow's inputForm emits a
workflow.input_form_updated audit event under the workflow. The
metadata captures the variable-level diff:
addedVariables/removedVariables— sorted root identifiers from{{var}}references.cleared: truewhen the form was set tonull(revert to auto-generated).
Planned: richer metadata for scripted-form changes (
scriptingEnabled: { before, after },scriptCount: { before, after },librariesAdded/librariesRemoved) is tracked in #274 so security reviewers can spot scripting flips and library swaps without diffing templates by hand. Until then,workflow.updatedcarries the fullpreviousState/newStatesnapshot for every save and remains the source of truth for these fields.
Recipes¶
Every recipe below is a complete, paste-ready template body. Drop it into the editor, swap the variable names for the ones declared on your workflow, and watch the preview pane.
Recipe 1 — Basic single-column form¶
The starting point for almost every template. Two lines of intro copy, then every field stacked with a comfortable gap.
<p class="mb-4">
Tell us a little about your request. We'll route it to the right team for review.
</p>
<div class="flex flex-col gap-4">
{{field "summary"}} {{field "requestedAmount"}} {{field "neededBy"}} {{field "notes"}}
</div>
Recipe 2 — Two-column responsive grid¶
Stacked on phones, side-by-side from tablet upwards. Uses Tailwind's
12-column grid with the md: breakpoint variant.
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6">{{field "firstName"}}</div>
<div class="col-span-12 md:col-span-6">{{field "lastName"}}</div>
<div class="col-span-12 md:col-span-6">{{field "email"}}</div>
<div class="col-span-12 md:col-span-6">{{field "phone"}}</div>
<div class="col-span-12">{{field "notes"}}</div>
</div>
Recipe 3 — Sectioned form with headings¶
Group related fields under headings so a long form scans cleanly. Use
<hr> between groups, and text-muted-color for the supporting copy.
<section class="flex flex-col gap-3">
<h3 class="text-lg font-semibold">Person</h3>
<p class="text-sm text-muted-color">
Who is this account for? We use the email to send their welcome instructions.
</p>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6">{{field "firstName"}}</div>
<div class="col-span-12 md:col-span-6">{{field "lastName"}}</div>
<div class="col-span-12">{{field "email"}}</div>
</div>
</section>
<hr class="border-t border-surface my-5" />
<section class="flex flex-col gap-3">
<h3 class="text-lg font-semibold">Access</h3>
<p class="text-sm text-muted-color">
Pick the team and starter role. The team determines which group they're added to in Google
Workspace.
</p>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6">{{field "team"}}</div>
<div class="col-span-12 md:col-span-6">{{field "role"}}</div>
</div>
</section>
Recipe 4 — Conditional sections via {{#if}} and :has()¶
Both flavours of "show this only when …" — Handlebars (good for context
flags evaluated once per render) and CSS :has() (live, reacts to a
checkbox the user is currently editing).
<div class="flex flex-col gap-4">
{{field "needsManagerApproval"}} {{!-- Live reactive: the block below appears the moment the user
ticks the checkbox above, no re-render needed. --}}
<div
class="hidden has-[input[name='needsManagerApproval']:checked]:flex flex-col gap-3 border border-surface rounded p-3 bg-surface-100"
>
<p class="text-sm font-semibold">Approval routing</p>
<p class="text-sm text-muted-color">
Tell us who should approve this — usually the requester's manager.
</p>
{{field "approverEmail"}} {{field "approverNotes"}}
</div>
{{!-- Render-time conditional: appears only when the workflow declares 'budgetCode' as a required
variable. Useful for templates reused across slightly-different workflow types. --}} {{#if
(required "budgetCode")}}
<p class="text-sm text-color">A budget code is mandatory for this workflow.</p>
{{field "budgetCode"}} {{/if}}
</div>
Recipe 5 — Reviewer summary card¶
A "preview what you'll submit" panel rendered alongside the form.
{{value}} snapshots the current values into prose; {{default}}
shows the schema default when the user hasn't typed anything yet.
<div class="grid grid-cols-12 gap-6">
<div class="col-span-12 md:col-span-7 flex flex-col gap-4">
{{field "summary"}} {{field "requestedAmount"}} {{field "neededBy"}}
</div>
<aside class="col-span-12 md:col-span-5">
<div class="border border-surface rounded p-4 bg-surface-50 sticky top-4">
<h4 class="font-semibold text-color mb-2 flex items-center gap-2">
<i class="pi pi-eye text-primary"></i>
Review
</h4>
<dl class="text-sm flex flex-col gap-2">
<div>
<dt class="text-muted-color">Summary</dt>
<dd class="font-medium">{{value "summary"}}</dd>
</div>
<div>
<dt class="text-muted-color">Amount (USD)</dt>
<dd class="font-medium">
{{value "requestedAmount"}}
<span class="text-muted-color">(default {{default "requestedAmount"}})</span>
</dd>
</div>
<div>
<dt class="text-muted-color">Needed by</dt>
<dd class="font-medium">{{value "neededBy"}}</dd>
</div>
</dl>
<p class="text-xs text-muted-color mt-3 italic">
Snapshot taken on render — re-open this page to refresh.
</p>
</div>
</aside>
</div>
Recipe 6 — Themed card with icons and a <details> disclosure¶
Status-coloured header, an inline icon for visual scanning, and a collapsible "advanced" block at the bottom. All native HTML + Tailwind — no JavaScript needed.
<article class="border border-surface rounded-md shadow-sm bg-surface-0">
<header
class="border-b border-surface bg-surface-100 px-4 py-3 rounded-t-md flex items-center gap-3"
>
<i class="pi pi-id-card text-primary text-lg"></i>
<div>
<h3 class="font-semibold text-color leading-tight">New hire account request</h3>
<p class="text-xs text-muted-color">
Fields marked <span class="text-red-500">*</span> are required.
</p>
</div>
</header>
<div class="p-4 flex flex-col gap-4">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-6">{{field "firstName"}}</div>
<div class="col-span-12 md:col-span-6">{{field "lastName"}}</div>
<div class="col-span-12">{{field "personalEmail" placeholder="you@example.com"}}</div>
</div>
{{field "team"}} {{field "startDate"}}
<details class="border border-surface rounded p-3 mt-2">
<summary class="font-semibold cursor-pointer flex items-center gap-2">
<i class="pi pi-cog"></i>
Advanced options
</summary>
<div class="flex flex-col gap-3 mt-3">
{{field "ccManagerOnWelcome"}} {{field "skipBackgroundCheck"}} {{field "notesForIt"}}
</div>
</details>
</div>
<footer
class="border-t border-surface px-4 py-3 text-sm text-muted-color flex items-center gap-2 rounded-b-md"
>
<i class="pi pi-info-circle"></i>
The IT team is notified immediately on submit.
</footer>
</article>
Recipe 7 — Companion legend alongside an enum <select>¶
Mount the renderer-managed control with {{field}}, then use
{{enumOptions}} to render a styled, read-only legend so requesters can
scan every choice at a glance without opening the dropdown. This is the
intended pattern for enum fields until custom radio rendering ships
(see the {{enumOptions "name"}} helper reference earlier in this guide
for the full safety story).
<fieldset class="border border-surface rounded p-3">
<legend class="px-1 font-semibold">{{label "priority"}}</legend>
<p class="text-sm text-muted-color mb-2">
Pick the urgency that matches the impact on the requester.
</p>
{{field "priority"}} {{!-- Read-only legend — does not replace the control above. --}}
<ul class="mt-3 text-xs text-muted-color flex flex-wrap gap-2">
{{#each (enumOptions "priority")}}
<li class="inline-flex items-center gap-1 bg-surface-100 rounded px-2 py-1">
<i class="pi pi-circle-fill text-[6px]"></i>
{{this.label}}
</li>
{{/each}}
</ul>
</fieldset>
Building your own radio buttons or option pills as the actual control is not supported in Phase 1 — the renderer's value-sync path overwrites each radio's
valueattribute. Stick with{{field}}for the mount and use{{enumOptions}}for read-only presentation.
Security model¶
Every template round-trips through two sanitizer passes — strict mode
or scripted mode, gated by inputForm.scriptingEnabled:
| Pass | Where | Strict mode | Scripted mode |
|---|---|---|---|
| Server-side save | sanitizeTemplate() in input-form-template.ts |
Strips <script> / <iframe> / <object> / <embed>, removes every on* attribute, drops javascript: URLs, drops style rules with expression(…) or url(javascript:…). Refuses the save if Handlebars cannot parse the template. |
Same as strict, plus: the curated on* attributes (SCRIPTED_HANDLER_ATTRS) survive when their body is a single expression that passes the acorn blocklist; up to MAX_FORM_SCRIPT_BLOCKS <script> blocks survive (any author-written type is canonicalized to application/floh-form). |
| Client-side render | sanitizeTemplateHtml() in sanitize.ts (DOMPurify) |
Re-runs the strict allow-list on the compiled HTML so an XSS payload smuggled in through a workflow variable can't escape helper output. | Re-runs the scripted allow-list, preserving canonical application/floh-form script blocks and curated on* handlers; everything else still goes through DOMPurify and is stripped on mismatch. |
In scripted mode the actual execution boundary is the sandboxed
iframe the renderer mounts (allow-scripts allow-forms, no
allow-same-origin) under a CSP whose script-src is restricted to a
per-page nonce shared with the parent. The sanitizer is defence-in-
depth — it stops bad bytes hitting disk — but the iframe is what stops
them reaching the requester's session even if the sanitizer is later
weakened or bypassed.
Every template change emits a workflow.input_form_updated audit event
under the workflow. The metadata records the variable diff (added /
removed) and whether the form was cleared. Scripting/library deltas
are not yet split out as first-class audit fields — see
Scripted forms (Tier A) → Audit trail for the shipped
shape and the planned enrichment.
Trust boundary. Templates are author-trusted (only admins with the right role can save them, and only admins with
workflow:author_scripted_formcan flip onscriptingEnabled) but they render under the requester's session. The sanitizer therefore treats them as untrusted at runtime, and scripted bodies execute inside an iframe sandbox — so a compromised admin account, a saved-then-relaxed sanitizer config, or a clever variable-injection trick still can't run script in the parent SPA or read the requester's cookies.
Linter & troubleshooting¶
The Monaco editor surfaces a debounced lint strip beneath itself. The rules:
| Rule id | Severity | Triggered by |
|---|---|---|
syntax-error |
error | Handlebars couldn't parse the template (unbalanced {{#if}}, mismatched quotes, etc.). |
unknown-helper |
error | {{widget "x"}} — the helper name isn't registered. The message lists every available helper. |
unknown-variable |
error | {{field "fooo"}} — fooo is not a workflow variable. Add the variable or fix the typo. |
missing-required-field |
error | A schema-required variable isn't mounted by {{field}} / {{input}} / {{allFields}}. |
duplicate-field-reference |
warning | The same variable is mounted twice — emits two controls with the same id. |
field-without-label |
warning | (Reserved for future use — not currently emitted.) |
Common fixes:
- "Unknown variable" → every field-referencing helper
(
{{field "X"}},{{input "X"}},{{label "X"}},{{description "X"}},{{value "X"}},{{default "X"}},{{required "X"}},{{enumOptions "X"}}) is checked against the workflow's declared variables. Either declare the variable in the workflow's Variables tab, or fall back to a bare path ({{x}}) if the value really lives elsewhere in the data context — bare paths skip validation but also skip the "unknown helper" safety net, so use them sparingly. - "Required variable X is not rendered" → add
{{field "X"}}, or drop in{{allFields}}to render every variable in one go. - Mustaches inside an HTML comment break the linter → wrap example mustaches in a Handlebars block comment instead:
{{!-- Good: this comment is invisible to the parser --}}
<!-- Bad: {{field "x"}} above is parsed and lints as unknown variable -->
- The preview pane went blank momentarily → it didn't, actually — the renderer keeps the previous successful HTML on screen until the next compile succeeds. The error is in the lint strip below the editor.
Quick reference cheat sheet¶
{{! mounting helpers (each emits real form controls) }}
{{field "varName"}}
{{! label + input + description }}
{{field "varName" class="…" inputClass="…" labelClass="…" descriptionClass="…" placeholder="…"}}
{{! styled wrapper + parts }}
{{input "varName" class="…" placeholder="…"}}
{{! bare control }}
{{allFields}}
{{! one block per variable }}
{{! non-mounting helpers (emit text or data) }}
{{label "varName" class="…"}}
{{! <label for="…"> }}
{{description "varName" class="…"}}
{{! <small id="…-desc"> }}
{{value "varName"}}
{{! live value, escaped text }}
{{default "varName"}}
{{! schema default, escaped text }}
{{required "varName"}}
{{!-- boolean for {{#if}} --}}
{{enumOptions "varName"}}
{{!-- [{value,label}, …] for {{#each}} --}}
{{! block helpers (Handlebars built-ins) }}
{{#if condition}}…{{else}}…{{/if}}
{{#unless condition}}…{{/unless}}
{{#each items}}…{{this}} {{@index}}{{/each}}
{{#with object}}…{{this.field}}{{/with}}
{{lookup object key}}
{{! comments here are invisible to both renderer and linter }}
<!-- styling primitives (Tailwind v4 + tailwindcss-primeui) -->
class="flex flex-col gap-3" → vertical stack class="grid grid-cols-12 gap-4" → 12-column responsive
grid class="col-span-12 md:col-span-6" → full on phones, half on tablet+ class="border
border-surface rounded p-3 bg-surface-0 shadow-sm" → card class="text-sm text-muted-color" →
footnote text class="text-primary" → theme accent class="pi pi-check-circle text-green-500" → inline
icon (on `<i>`)</i>
For the full class catalog, hit Ctrl+Space inside any class="…" in
the editor or open the Styling reference drawer above the editor.