# Form schema: the JSON an LLM emits

A form is one JSON object. POST it to `/api/forms` to create (or PUT to
`/api/forms/:id` to re-publish). There is no builder UI by design: the schema
*is* the interface, small enough to write by hand or generate from a prompt.

```json
{
  "title": "Contact us",
  "description": "Optional blurb under the title.",
  "submit_label": "Send",
  "success_message": "Got it, we'll be in touch.",
  "webhook_url": "https://example.com/hook",
  "theme_accent": "#e11d48",
  "fields": [ <field>, <field>, ... ]
}
```

`title` and `fields` are required; the rest default sensibly. Optional form-level
extras: `webhook_url` (POST each response as JSON), `theme_accent` (brand accent,

## Field

```json
{
  "type": "text",
  "name": "email",
  "label": "Work email",
  "required": true,
  "placeholder": "you@company.com",
  "options": ["A", "B"],
  "visible_when": <condition>
}
```

| key | required | notes |
|---|---|---|
| `type` | yes | see the type list below |
| `name` | yes | the submitted/stored key. letters, digits, underscores. unique per form. |
| `label` | yes | shown to the respondent. Supports recall (`{{other}}`). |
| `required` | no | default `false`. Only enforced when the field is *visible* (see below). |
| `placeholder` | no | text inputs + the `select` prompt |
| `options` | `select` `radio` `multichoice` `ranking` | the choices |
| `min` `max` | `scale` / `rating` | scale range (default `0`..`10`); for `rating`, `max` = number of stars (default `5`) |
| `min_label` `max_label` | `scale` | optional end captions (e.g. "Not likely" / "Very likely") |
| `visible_when` | no | conditional visibility; omit for always-shown fields |

### Types

| `type` | what it is | stored as |
|---|---|---|
| `text` `email` `number` `tel` `url` `date` | single-line inputs (with native validation) | string |
| `textarea` | long text | string |
| `select` | dropdown, single choice (`options`) | string |
| `radio` | single choice, buttons (`options`) | string |
| `checkbox` | one on/off box | `"yes"` or `""` |
| `multichoice` | **pick several** (`options`), a checkbox group | array of strings |
| `rating` | tap 1..`max` stars (default 5) | string number |
| `scale` | `min`..`max` button row (default 0..10 = NPS), optional end labels | string number |
| `ranking` | drag `options` into order (number dropdowns without JS) | array, best-ranked first |
| `statement` | display-only copy, no input (great with recall) | nothing |
| `step` | a page break that splits a long form into steps; `label` titles the step | nothing |

## Conditional logic: `visible_when`

A field with `visible_when` is shown only when its condition is true, evaluated
against the **other fields' current answers**. A condition is one of three
shapes, and groups nest, so any AND/OR tree is expressible:

```json
{ "field": "attending", "op": "equals", "value": "Yes" }          // leaf
{ "all": [ <cond>, <cond> ] }                                     // AND
{ "any": [ <cond>, <cond> ] }                                     // OR
```

### Operators (`op`)

| op | true when | `value` |
|---|---|---|
| `equals` / `not_equals` | answer (≠)= value | string |
| `in` / `not_in` | answer is (not) one of the list | array of strings |
| `empty` / `not_empty` | answer is (not) blank | (omit) |
| `gt` / `lt` | answer is numerically >/< value | numeric string |

A checkbox test is `{ "field": "x", "op": "equals", "value": "yes" }`.

### What you can rely on

- **Chaining works.** `B` can depend on `A`, and `C` on `B`. If `A` flips and
  hides `B`, then `B` reads as empty for `C`, so the whole chain collapses
  cleanly. (Don't make conditions reference each other in a cycle.)
- **The server is the authority.** Show/hide is JS enhancement; on submit the
  server re-runs the exact same rules. A required field that's hidden does
  **not** block submission, and an answer to a hidden field is **discarded**
  (stored as `""`). You can't fool it with a hand-crafted POST.
- **No JS still works.** With scripting off, every field shows and the form
  submits, and the server keeps it correct. Conditional logic only ever *improves*
  the experience; it's never load-bearing.

See [`examples/event-rsvp.json`](./examples/event-rsvp.json) for a form that uses
every shape above.

## Recall (answer piping)

Write `{{field_name}}` in any `label`, the form `description`, a `statement`, or
the `success_message`, and it's replaced with that field's answer:

```json
{ "type": "text", "name": "name", "label": "Your name" },
{ "type": "statement", "name": "hi", "label": "Thanks {{name}}, two more questions." }
```

In-form text updates live as the respondent types (JS); the `success_message` is
filled server-side from the stored answers. Unknown or malformed tokens are left
as literal text. Without JS, in-form recall spans are simply empty.

## Multi-step forms

Drop a `step` field anywhere to start a new page; fields after it belong to that
step, and its `label` is the step's title. Fields before the first marker form
the opening step.

```json
{ "type": "step", "name": "step_you", "label": "About you" },
{ "type": "text", "name": "first_name", "label": "First name", "required": true },
{ "type": "step", "name": "step_prefs", "label": "Your preferences" },
{ "type": "multichoice", "name": "tracks", "label": "Which tracks?", "options": ["A","B"] }
```

It's progressive enhancement: with JS you get a progress bar and Back/Next, and
"Next" won't advance past a missing required answer on the current step. Steps
whose every field is hidden by conditions are skipped automatically. **Without
JS the steps stack into one scrollable page** and submit normally, so steps
never gate the form working. Use them only for long forms; short forms don't
need them.

## What gets stored

- Every declared field is stored under its `name` (multichoice/ranking as JSON
  arrays; statements store nothing).
- **Any other submitted parameter is stored too, with no setup.** Share a prefilled
  link like `/f/your-form?lead_id=42&utm_source=newsletter` and those land in the
  submission automatically. There are no "hidden field" declarations; whatever
  rides along on the POST (or the page URL) is kept (bounded, and the `_ava*`
  name prefix is reserved). The `~` character in a name is reserved for ranking.

See [`examples/product-feedback.json`](./examples/product-feedback.json) for a form
using the new field types, recall, and a conditional follow-up.

## Compatibility

Unknown fields are ignored rather than rejected, so a schema saved by an older
version still loads. That is how `logo_url` was retired: the field was removed
from the model, and stored schemas that still carry it simply drop it on the next
save. Field **names** are the exception - they are validated on every write
(`[A-Za-z0-9_-]`, 64 chars, no `_ava` prefix, no duplicates) because a name is a
storage key, an HTML attribute value, a recall token, and part of a `data-*`
attribute in the dashboard.

A field's `name` is assigned once and then pinned. Renaming a question changes its
`label`, never its `name` - otherwise the answers already collected under the old
key would be orphaned.

## `file` - attachments

```json
{"type":"file","name":"photo","label":"A photo of the problem","required":true,"max":2}
```

`max` is how many files this question accepts, 1 to 3 (default 1).

| Limit | Value |
|---|---|
| Per file | 20 MB, on every plan including Pro |
| Files per response | 3, across all file questions |
| Total per response | 30 MB |
| Accepted | jpg jpeg png gif webp heic heif pdf doc docx xls xlsx pptx csv txt |

Every upload is checked twice: the extension must be on the allowlist, **and** the
first bytes must match the format it claims to be. A `.png` that is really an
executable is refused, because the person who downloads it is you.

Files are stored on the server's disk, never in the response JSON. What the
response holds is the filename (or `"2 files: a.pdf, b.pdf"`), and the files
themselves are attached to the submission. They are downloadable only by signed-in
members of your organisation, from `/app/files/{id}`, always as an attachment with
a content type we set - never rendered inline, so an uploaded HTML or SVG file
cannot run as a script.

Two honest caveats. A rejected submission cannot refill a file input: browsers
forbid it, so the respondent must choose the attachment again, and the form says
so. And a form with a file question posts `multipart/form-data`, which still needs
no JavaScript, but does mean the whole submission is a single larger request.

## Prefilling a question from the URL

Any query parameter named after a field fills that field in. The parameter name
is the field's `name`, not its label:

```
/f/your-slug?email=ada@example.com&plan=Pro&topics=Sales,Support
```

| Field type | What to put in the URL |
|---|---|
| `text` `email` `number` `tel` `url` `date` `textarea` | the value |
| `select` `radio` | a value that matches one of `options` exactly |
| `checkbox` | `1`, `yes`, `true` or `on` to tick it |
| `multichoice` | comma-separated option values |
| `rating` `scale` | the number |

Three rules worth knowing:

- **The respondent's answer wins.** A parameter only fills a question they left
  blank. If they edit a prefilled field, their edit is what gets stored.
- **A URL cannot tick a box on someone's behalf.** If the browser didn't submit
  that field at all - an unticked checkbox, a hidden conditional question - the
  parameter is ignored. Consent has to be given, not linked.
- **It degrades.** Without JavaScript nothing appears prefilled, but the value is
  still forwarded to the server and used for any question left blank.

A parameter that doesn't match any field is stored with the response as-is, which
is how `?utm_source=newsletter` and `?lead_id=42` work with no setup at all.
