---
name: docujoint-forms-authoring
description: Declare docujoint write surfaces — block forms that edit or append rows, inline column controls (dropdowns on a cell), dynamic option sources (from row proposals, a declared enum, or the documents of a type), and feedback capture that records without touching the vault. Use when adding any way for a reader to answer, triage, assign or annotate.
---

# Forms, controls and capture

Two halves, and keeping them apart is the whole design:

> **The FORMAT declares what is editable. The HOST declares where writes go.**

`format.yaml` says a block has an `assign` form that sets `Owner`. Whether
submitting it edits the file, opens a proposal, or is refused outright is the
host's call (`dj dashboard` vs `dj dashboard` vs the cloud app). The same
definition is therefore safe in a read-only viewer and useful in an editor,
with no per-host branching in the definition.

## A block form

```yaml
blocks:
  open-questions:
    columns:
      - { name: Q, id_grammar: "q<n>", unique: error }
      - { name: Question, required: true }
      - { name: Status, enum: shared.q_status, unknown: error }
      - { name: Resolution }
      - { name: Options, optional: true }
    forms:
      reply:
        label: Answer                                   # the button's text
        set: { Resolution: "{choice} {note}", Status: answered }
        fields:
          - { name: choice, label: Answer, kind: options, from: Options,
              mode: single, other: true, required: true }
          - { name: note, label: Additional context, kind: textarea }
```

- **`set:`** = an *edit* form. Keys are columns of this block; values are
  templates interpolating `{field}` names. It edits the row it was opened on.
- **`row:`** = an *append* form. Same shape, but it adds a new row instead.
- `set:` and `row:` are mutually exclusive, and a form needs one of them.
- A form with several fields renders as a panel stacked under its row; the
  answer, once recorded, stacks in the same card.

Field kinds: `text` · `textarea` · `options` · `image` · `date` · `number`
(`min`/`max`/`step`) · `rating` (`max`) · `section` (a heading grouping the
fields under it) · `diagram` (mermaid, parse-validated with live preview) ·
`concepts` (pick documents off the relationship graph, optional `types:`) ·
`person` (pick from the host-supplied people directory — `people.yaml` beside
format.yaml, or `--people`; the pick writes the bare HANDLE, the portable
spelling documents store, and with no directory the field degrades to a plain
text input).

## Dynamic dropdowns — where the choices come from

An `options` field never carries a hardcoded list. It reads a vocabulary the
vault or the format **already maintains**, so the picker cannot drift from the
documents:

| Source | Choices are | Use when |
| --- | --- | --- |
| `from: <column>` | that ROW's cell, split on `separator:` (default `;`) | the document proposes per-row options (an `Options` column listing the candidate answers for *that* question) |
| `from_enum: <column>` | the column's declared `enum:` | the format already states the vocabulary (a Status column) |
| `from_concepts: <type>` | the titles of every document of that type | the roster lives in the vault (a `person` type, a component list) |

```yaml
    forms:
      set-status:                                  # the vocabulary the format declares
        label: Status
        set: { Status: "{value}" }
        fields:
          - { name: value, kind: options, from_enum: Status }
      assign:                                      # the roster the vault maintains
        label: Assign
        set: { Owner: "[{who}](</Team/{who}.md>)" }
        fields:
          - { name: who, label: Owner, kind: options, from_concepts: person }
```

`from_enum:` must name a column of this block that actually declares an enum,
and `from_concepts:` a real type — both are checked when the definition loads,
so a typo is a finding, not an empty menu at runtime.

`mode: multi` joins selections with `separator:`; `other:` (default **true**)
adds a free-text escape hatch — a closed option list with no way out is a trap
that silently produces wrong answers.

Note the `assign` example: `set:` writes a **link**, so assignment joins the
Team document rather than stringly naming a person. Values you may later want
to filter, group or traverse by should be written as links, not bare text.

## Inline controls — a dropdown on the cell itself

A single-field edit form can be attached to the column it writes, so the value
IS the control:

```yaml
    display:
      columns:
        - { col: Status, style: chip, card: badge, control: { form: set-status } }
        - { col: Owner,  style: md,               control: { form: assign } }
```

The cell renders as a menu button showing the current value; picking writes
through the named form. Rules the loader enforces:

- `control:` needs an **edit** form (one with `set:`) — an append form has no
  cell to write to.
- The column must not feed a `derive:` rule. **Derived state is computed, never
  stored**; an inline editor would quietly make stored state cheap, which is the
  same rule `forbid_columns` exists to keep.
- A form exposed as a column control is dropped from the row's action buttons —
  one write, one surface, never the same write offered twice.

Menus position themselves against the trigger and close when it scrolls out of
view, so a control works inside a scrolling table without clipping.

## Capture — recording without touching the document

Some answers should not edit the corpus at all. `feedback.capture` declares a
form that writes to a **sidecar ledger**, leaving the vault byte-identical:

```yaml
feedback:
  statuses: [new, acknowledged, applied, rejected]
  default: new
  capture:
    comment:
      on: concept                     # a form on the document as a whole
      label: Leave feedback
      fields:
        - { name: text, label: Comment, kind: textarea, required: true }
        - { name: screenshot, label: Screenshot, kind: image }
        - { name: usefulness, kind: rating, max: 5 }
    triage:
      on: rows                        # a form on every row of a block
      block: anomalies
      label: Triage
      submit: replace                 # one record per row+kind; append (default) keeps every one
      when: read-only                 # only offered where documents cannot be edited
      fields:
        - { name: choice, label: Verdict, kind: options, from: Options, required: true }
        - { name: note, label: What you found, kind: textarea }
```

`when: read-only` is how a capture and a write-through form coexist for the same
question: where the host can write, the reader answers the document; where it
cannot, the same question is captured to the ledger instead. Declare the
condition — do not assume one form supersedes another.

Two more keys a capture kind may declare:

```yaml
    proposal:
      on: rows
      block: features
      label: Propose fix
      beside: { column: Gap, label: View proposal }   # card layouts: link the
                                                      # kind's records from this
                                                      # column's label
      review:                                         # reader verdicts on a record
        - { label: Endorse, status: acknowledged }    # each targets a DECLARED status
        - { label: Ignore,  status: rejected }
```

`review:` renders verdict buttons on records still carrying the default status
— but only where the host asserts a status endpoint (serve mode does; a static
render needs `--feedback-status-url`). An unknown status, a `beside:` column
that is not a real column of the block, or `beside:` on a concept-level kind
are authoring errors. Delete is the same shape: it renders only where the host
declares `--feedback-delete-url` (serve mode does), and removes the record
outright — a status verdict is the reversible path.

## Write modes — the host's half

| mode | what submitting does |
| --- | --- |
| `direct` | edits the markdown, referee-gated (`dj dashboard`) |
| `proposal` | stages a change set and returns it for review (`dj dashboard --propose`, the cloud app's PR path) |
| `capture` | ledger only; documents untouched |
| *(absent)* | no write path — forms render read-only |

In `proposal` mode the page **stages** operations instead of applying them. A
change set is a list of declarative INTENTS — `{concept, block, action, row,
fields}` — replayed in order and gated **once, as a set**, not per edit. Intents,
not diffs: they survive an upstream edit that a line-based patch would not, which
is what lets the cloud app turn a session of answers into one pull request.

Design forms so an intent stays meaningful on its own: prefer `set: { Status:
"{value}" }` over templates that depend on what a neighbouring cell happened to
say when the form was opened.

## Checklist

- Every option list sourced (`from` / `from_enum` / `from_concepts`), never
  hand-listed in the form.
- `other: true` unless the vocabulary is genuinely closed.
- One surface per write: a column control, or a row action, not both.
- Nothing editable that a `derive:` rule reads.
- Values worth filtering by written as links.
- The write path chosen by the host, not branched on inside the definition.
