# Building the Annotation Interface

`instance_display`, the display types, and what a screenshot can prove. It ships with the `potato-skill` skill:

```
/plugin marketplace add davidjurgens/potato-skill
/plugin install potato-skill@potato
```

---

The page an annotator sees has two halves: the item on top, controlled by
`instance_display`, and the form below it, controlled by `annotation_schemes`.
The navbar, progress indicator, Next button and keyboard hints come for free and
are worth leaving alone.

## The item: `instance_display`

Without it, Potato renders the field named by `item_properties.text_key` as
plain text. That is the right answer for a text classification task and the
wrong one for everything else.

```yaml
instance_display:
  fields:
    - key: body               # required: the field in your data file
      type: text              # required: from the display registry
      label: "Comment"        # optional heading above it
      span_target: true       # optional: spans may be drawn on this field
      display_options: {}     # optional, and type-specific
  layout:
    direction: vertical       # or horizontal
    gap: "20px"               # a CSS length, as a string
  resizable: true             # default true
```

`fields` is required and cannot be empty. Each entry needs `key` and `type`.
An unknown `type` fails validation with the valid list in the message.

**The default path collapses horizontal whitespace, and `instance_display`
keeps it.** This is the one reason to declare the block on a task that is
otherwise plain text. Same item, same server, only the config differing:

```
no instance_display    def f(x):\n if x > 0:\n return x\n return -x
type: text             def f(x):<br/>    if x > 0:<br/>        return x<br/>    return -x
```

Runs of spaces and tabs are squeezed to one on the default path, so two
levels of indentation become one, a diff loses its gutter and a
column-aligned table loses its columns. Nothing is logged and the config
validates either way. Declare `instance_display` for anything where
horizontal position carries meaning — code, diffs, logs, transcripts with
speaker columns, poetry — and the field is rendered inside a
`preserve-whitespace` container with real line breaks. `type: text` is
enough; you do not need `code` unless you want the copy button and the
scroll container.

### Laying the item out

`instance_display.layout` is the whole of it: `direction` (`vertical` or
`horizontal`) and `gap`. There is no per-field width, span or weight. That
matters more than it sounds, because `direction: horizontal` divides the width
**equally** between fields regardless of what is in them.

Four fields on a 1500px window gave me four 198px columns: an 800x600 image
rendered at 148x111 next to a one-line caption, beside a dialogue that ran 763px
tall while its neighbours were under 200px. Each column is as wide as the
narrowest thing deserves and as tall as the tallest thing needs.

So for the common asks:

- **An image beside its text.** Two fields, `direction: horizontal`. Accept the
  even split, or weight it with `base_css` — see below.
- **Very long text.** Keep it `vertical` and give the field
  `collapsible: true` with `collapsed_by_default: true`, which renders a Show
  toggle instead of the body. A 7,000-character transcript then costs one line
  of page until the annotator wants it.
- **A multi-party conversation.** `dialogue` with `speaker_key` and `text_key`
  naming your fields, plus `show_turn_numbers: true`. Each speaker gets its own
  colour down the left edge and a bold name; `alternating_shading: true` adds
  row banding. This is the one that needs no CSS at all.

**Weighting a horizontal row needs `base_css` and a positional selector.**
Each field is wrapped in a `.display-field-resizable` div, and that wrapper is
the flex child — so a rule on `.display-field[data-field-key=...]` computes and
does nothing, which is a confusing half-hour. The wrapper carries no key, so the
only handle is position:

```css
/* base_css: give the image three shares and the caption one */
.display-field-resizable:nth-child(1) { flex: 3 1 0; }
.display-field-resizable:nth-child(2) { flex: 1 1 0; }
```

That took my image from 148px to 289px wide. It is positional, so it breaks if
you reorder `fields` — leave a comment in the config saying so.

**`max_height` and `min_height` take a bare number or a CSS length.** They are
options of the resizable wrapper rather than of any one display type, so they
work on every field: `max_height: 220` means 220px, and `max_height: "15rem"`
comes through as `15rem` and computes to 240px. `50%` and other units pass
through too. That matches `layout.grid.gap` and `instance_display.layout.gap`,
so the whole block takes lengths the same way.

When you do get an option wrong, the validator names the near miss — "is not an
option for display type 'text' … Did you mean 'max_height'?" — and lists what
that type accepts, which is the fastest way to check anything in
`display_options`.

**What you get for free, and it is more than you would guess.** The generated
form is properly structured: every radio has an associated label, every question
is a `fieldset` with a `legend`, images carry `alt`, and the document has `lang`.
That is what a screen reader needs, and none of it requires configuration. The
gap I have measured is that the page has no `<h1>` — the task name lives in the
navbar as plain text — so there is no top-level landmark for heading navigation.
Custom canvas widgets are a separate question from the form; check those
yourself if your annotators rely on assistive technology.

### The 24 display types

| Type | For |
|---|---|
| `text` | Plain text |
| `html` | Rich content (sanitized) |
| `code` | Source with syntax highlighting |
| `document` | DOCX, Markdown and similar — files, not a string field. See below |
| `pdf` | PDF via PDF.js |
| `spreadsheet` | Tables, annotated by row or cell |
| `image` | Images, optionally zoomable |
| `gallery` | Several images with captions; from 2.9.0, videos too |
| `depth_map` | Depth data with windowing and a colormap |
| `video` | Video player |
| `audio` | Audio player |
| `audio_dialogue` | Interview or podcast turns; one recording, per-turn play buttons that seek into it |
| `dialogue` | Conversation turns, optionally threaded |
| `conversation_tree` | Branching conversations, collapsible |
| `multi_agent_discussion` | Several agents, colour-coded, filterable |
| `pairwise` | Two things side by side |
| `agent_trace` | Agent steps with type badges |
| `coding_trace` | Coding-agent steps with diffs and terminal blocks |
| `cot_trace` | Long chain-of-thought with a progress rail |
| `eval_trace` | Three panes: reasoning, calls, final answer |
| `web_agent_trace` | Screenshots with SVG overlays |
| `interactive_chat` | Live chat, then the trace |
| `live_agent` | Live agent viewer with controls |
| `live_coding_agent` | Live coding agent with intervention |

`display_options` differ per type, and from 2.8.2-11 a key the display does not
accept is a validation error that names the alternatives:

```
instance_display.fields[0].display_options.no_such_option is not an option for
display type 'text', so it would be ignored silently. Accepted options:
collapsed_by_default, collapsible, key, max_height, preserve_whitespace.
```

Before that release nothing looked inside `display_options` at all: a misspelled
option passed `validate --strict` with "OK — no issues found" and the page came
up wrong with no error anywhere. If your Potato predates it, spell-check these by
hand — that is where an hour goes.

To see what a display takes:

```python
from potato.server_utils.displays.registry import display_registry
[d for d in display_registry.list_displays() if d["name"] == "multi_agent_discussion"]
# optional_fields: ['collapse_environment', 'show_addressees', 'show_legend',
#  'show_turn_numbers', 'speaker_key', 'text_key', 'thread_replies']
```

**A display that reads a list has its own idea of the field names.** The
conversation and trace displays (`dialogue`, `multi_agent_discussion`,
`agent_trace`, `cot_trace`, `eval_trace`) take `speaker_key` and `text_key`,
defaulting to `speaker` and `text`; `gallery` takes `url_key` and `caption_key`.
Point one at turns shaped `{index, agent, text}` and the turns render with no
speaker at all — just the text, in a `dialogue-turn` div with an empty
`speaker-0` class and no name anywhere — while the annotation schemes above and
below it read the same list correctly, because those take `agent_key`. Set
`speaker_key: agent` and the names appear, with a colour per speaker. Nothing
warns, and a reader skimming the page sees a conversation rather than an error.

**`spreadsheet` takes `headers` or `columns`, and both work.** Give it
`{headers: [...], rows: [[...]]}`, `{columns: [...], rows: [[...]]}`, or a plain
list of row dicts, in which case the headers come from the first row's keys. I
put the first two side by side on one page and they render identically, header
row included.

**`pairwise` takes its labels from `display_options`, not from the item.**
`labels: ["Shift supervisor", "Automated report"]` names the two cells for the
whole study; a `label` carried inside the data is ignored and the cells read "A"
and "B". The data itself is `{left: ..., right: ...}` or a two-element list.

**`pairwise` sits side by side at its defaults** from Potato 2.8.2: `cell_width`
defaults to `auto`, which splits the row evenly and subtracts each cell's share
of the gap. Measured at 1280px, both cells 398px wide on the same line.

Before that the default was a literal `50%` in a flex row with a 16px gap and
`flex-wrap: wrap`, and two halves plus a gap never fit, so the second cell wrapped
onto its own line at every width — a comparison display that never compared. On
an older build, set it under half:

```yaml
- {key: summaries, type: pairwise, label: Two summaries,
   display_options: {show_labels: true, cell_width: 48%}}
```

**`document` expects a file that Potato ingested, not a field in your JSON.** The
renderer reads `{rendered_html, metadata, text}` produced by the format pipeline
(`data_directory`, `format_handling`). Point it at a plain string and that string
is emitted as the field's HTML: Markdown is not rendered and `**Speaker:**` shows
its asterisks, which setting `format_handling: {enabled: true, default_format:
markdown}` does not change — that block renders markup in a field the pipeline
already produced, not a raw string you supplied. Paragraph breaks do survive: the
wrapper carries `document-preserve-structure` and `white-space: pre-wrap`
whatever you set, so `preserve_structure` is a no-op because it is always on
rather than because there is nothing to act on. `show_outline` draws nothing.
`style_theme` reaches the DOM as a `document-theme-<value>` class and takes
`minimal` or `print` besides the default; a fourth value is refused at boot with
all three named. It is a working span target either way, and
spans anchor to the raw field value, newlines included. For a transcript you are
shipping in a data file, `text` is the display you want.

### Geometry schemes need a display field for their media

A geometry scheme (`image_annotation`, `spatial_annotation`, `region_caption`,
`video_annotation` …) draws on a bitmap it does **not** fetch. `source_field`
names the data key; the canvas takes its image from the `<img>` that
`instance_display` renders on the page. Remove the image field from
`instance_display` and the canvas comes up empty — no `<img>` in the DOM at all —
even though the form still carries `data-source-field` and the media URL is still
in the HTML.

So an image annotation task needs both halves:

```yaml
instance_display:
  fields:
    - {key: image, type: image, label: Screenshot}
annotation_schemes:
  - annotation_type: image_annotation
    name: regions
    description: Draw a box around each problem.
    source_field: image
    tools: [bbox]                # required; the error at boot names it
    labels: [Broken, Confusing]
```

and the consequence is that **the image renders twice**: once as the display
field, once inside the canvas. That looks like a misconfiguration and is not. If
the duplicate is distracting, hide the display copy rather than deleting it:

```css
.display-field.display-type-image { position: absolute; left: -100000px; }
.display-field-resizable:has(> .display-field.display-type-image) { height: 0 !important; }
```

(Observed on a task built from this pack; the CSS is one working answer, not the
only one.)

### `span_target`

A `span` scheme works without `instance_display` at all — the default text
rendering supplies the wrapper spans are measured against. `span_target` is how
you name the anchor field *once you define `instance_display`*, and then exactly
one contract applies:

```python
display_registry.get_span_target_capable_types()
```

Twelve types accept it. Nine anchor spans by character offset in the standard
`.text-content` wrapper; `pdf`, `spreadsheet` and `agent_trace` anchor their own
way — into the PDF.js text layer, per cell, and per step. Setting `span_target`
on anything else fails validation and the error lists what is allowed.

**`target_field` is about schemes, not fields.** The field a span lands in comes
from where the drag was, always: I put three span-target fields on one page with
a single scheme naming none of them, and all three spans recorded against the
right field with the right offsets. So you do not need it to make a span land
correctly.

What it does is say which field a scheme *owns*, which is how a drag is routed
to the right scheme when several span schemes share a page. Name it when you
have more than one span **scheme**, not when you have more than one span-target
**field**. It does not restrict: a scheme pinned to one field still records
elsewhere, deliberately, because the alternative is an author pinning their only
scheme to one field and silently losing every drag in the others.

**A geometry scheme finds its media in `instance_display` if it has to.**
`image_annotation` looks at `text_key` first, then falls back to the rendered
image on the page, so a task with an `image` display field works without any
extra wiring. Name `source_field` on the scheme anyway when the item carries
more than one image, since the fallback takes the first one it finds; the
console says which it used.

**A span's offsets index the field as it was rendered, not as the data file
holds it.** On a plain text field those are the same string. On a `dialogue`
field they are not: the anchor is the turns joined as `"{speaker}: {text}"`, one
per line, so an offset of 145 counts the speaker labels and their separators
too. Reconstruct that exact string before slicing a dialogue span, or read the
span's `text` from the export instead of computing it.

It follows that **a display option which changes the rendering changes the
anchor**. Turning on `show_turn_numbers` puts a `[1] ` in front of every turn
and moves every offset after it. Settle the display options for a span-target
field before anyone annotates against it, and treat changing them afterwards the
way you would treat editing the item text.

**Several span schemes can share one field, and their spans are independent.**
Two schemes anchored to the same `target_field` — one marking entities, one
marking which sentence is first-hand — draw over each other on the page and are
stored separately, each span carrying its own `schema` and `target_field`. Use
that instead of folding two questions into one label set. A span may also
enclose one already drawn, in the same scheme or another, and both are kept.

Spans do not live with the labels on disk. They are their own top-level key in
`user_state.json`, `instance_id_to_span_to_value`, so a scheme that looks empty
in `instance_id_to_label_to_value` may be fully annotated — see
`getting-the-data-out.md` for the exported shape.

## The form: `annotation_schemes`

Three keys are required on every scheme regardless of type: `annotation_type`,
`name`, `description`. `description` is the question text the annotator reads,
so write it as a question.

**`name` is the storage key, so it has to be unique within the phase.** Since
2.8.2 two schemes sharing a name are refused, and the message says why: they also
share one HTML input group, so answering the second clears the first and only one
answer is saved. It applies across types: a `radio` and a `multiselect` sharing one name collide
the same way, because the collision is the key rather than the widget. Names differing only in case are two names, and the same name on a phase
page and in the annotation phase is fine, since those store separately.

Nine more work on every type:

| Key | Effect |
|---|---|
| `label_requirement` | `{required: true}`, or bare `true` |
| `required` | Shorthand for the above |
| `display_logic` | Show this scheme only under a condition |
| `layout` | Where this scheme sits in the form grid — **not** how its choices are arranged. See below |
| `humanize_labels` | **On by default.** Turns `not_relevant` into "Not relevant" — and also title-cases labels that were already prose, giving "Information **Or** Advice". Set `false` for prose labels |
| `session_level` | One answer per session rather than per item |
| `turn_level`, `turn_binding`, `turn_label` | Bind the scheme to conversation turns |

Everything else is type-specific and listed in `annotation-types.md`.

An `instance_display` field's own options — the ones `describe_display_type`
lists as optional for that type — can be written flat on the field or nested
under `display_options`, and the nested form wins when both are given. Only keys
the type actually declares are picked up flat, so a misspelling is still
silently ignored: check the rendered page, not the config.

### Quote `Yes` and `No` in a label list

```yaml
labels: [Yes, No]        # parses as [True, False]
labels: ['Yes', 'No']    # what you meant
```

YAML reads `Yes`, `No`, `On`, `Off`, `True` and `False` — in any capitalisation —
as booleans, so the most common label pair in annotation reaches Potato as two
Python bools. The scheme then **does not render at all**: `identifier_utils`
accepts a label that is a string or a dict and raises on anything else.

`validate --strict` catches it from Potato 2.8.2 on, and names the cause:

```
annotation_schemes[0].labels[0] came through as the boolean True. YAML reads
Yes/No, On/Off, Y/N or True/False in any capitalisation as a boolean [...]
Quote it: labels: ["Yes", "No"].
```

On any earlier build `--strict` said `OK — no issues found` — it checked that
`labels` was a list and never looked at the elements — and the scheme simply
**did not render**, with one line in the boot log as the only signal:

```
[ERROR] potato.server_utils.schemas.identifier_utils: Failed to generate layout
        for schema 'q1': Invalid label format: True
```

If that scheme was `required`, every `Next` after it failed with `400 /annotate`
— an annotator stuck on item one with no visible question to answer.
`grep "Invalid label format" server.log` after any edit to a label list, on a
version whose validator you have not tested.

Quoting is the fix and it costs nothing, so quote every short label. The dict
form (`{name: 'No', key_value: '2'}`) is also safe, because the check that
rejects a bool accepts a dict with a `name`.

### `select`, `constant_sum` and `ranking`: fixed in 2.8.2-10, broken before it

Three widgets used to answer for the annotator or lose the answer they gave. All
three are fixed as of `v2.8.2-10-ge54fdc26`, and all three were silent, so if you
are on an earlier build the symptom is in the data rather than on the screen.

A `select` emitted one `<option>` per label and no placeholder, so the browser
preselected the first one and `syncAnnotationsFromDOM` stored it. Register, press
Next three times touching nothing, and three items came back annotated with the
first label. `required: true` could never fail either, because a select always
had a value. It now opens on a disabled `-- select one --` and three empty Nexts
store nothing.

`constant_sum` drew a live "Allocated: 40 / 100" counter and let Next through
anyway, so a distribution question came back holding 40 points. Requiredness now
reads the declared total and blocks with `(40 of 100 allocated)` in the message.
`soft_label` had the same gap by a different route — `min_per_label` floors
stopping the other sliders absorbing a drag — and the same check catches it.

`ranking` wrote its config order into the hidden input on every render and
flagged it `data-modified`, so arriving at an item counted as ranking it, and
returning to one overwrote the real ranking with the default. It now starts
empty, and an order survives next-then-previous.

To check a build you did not test yourself, all three take about a minute: fresh
user, three Nexts touching nothing, then read `instance_id_to_label_to_value` —
it should be `{}`. Then rank something, advance, come back, and read it again.

`range_slider` had the milder version: both thumbs at a default band the server
did not have, so an annotator who agreed with what was on screen recorded
nothing. It now opens at the ends in grey, reading "No range set", with both
hidden inputs empty and no `data-modified` until the first drag. (`pairwise` used
to pre-select its tie tile the same way; that went in 2.8.2.)

### `required` on a composite widget means "touched", not "answered"

A scheme that answers through a hidden JSON input — `agent_scorecard`,
`handoff_review`, `failure_attribution`, `consensus_tracking`, `tool_call_review`,
`image_annotation`, most of the trace family — is checked for requiredness by
testing that input for a non-empty string. One click anywhere inside satisfies
the whole scheme.

Measured on 2.8.2-10, with all three schemes on the page marked `required: true`.
Answer nothing and Next is blocked, naming all three. Then pick a responsible
agent, flag one of five handoffs, click one of ten scorecard cells, and Next goes
through with:

```
scorecard = {"agents":{"planner":{"did its own job":1}},"team":{},"milestones":{}}
attribution = {"responsible_agent":"coder","decisive_step":null,"reason":""}
```

Four agents times two dimensions plus two team dimensions is ten questions, and
the check is satisfied by one. `constant_sum` is the exception, because its
budget rule was added by hand; nothing generalises it to declared cells.

So `required` on these buys you "the annotator interacted with it" and not much
else. If completeness matters, say the count in the question text ("score all
four"), and check it at export — the stored JSON has the shape you need, one key
per cell, so a per-item count is a few lines of pandas rather than an eyeball
pass.

### Free text is one line unless you say otherwise

`annotation_type: text` renders `<input type="text">`. A question phrased "in a
sentence" or "explain why" gets a single-line box, and annotators type a
paragraph into it. Two ways to get a real textarea:

```yaml
  - annotation_type: text
    name: why
    description: Why?
    multiline: true          # -> <textarea>
    rows: 4
```

```yaml
    textarea: {"on": true, rows: 4, cols: 60}    # the older form; same result
```

Either way the input is named `<scheme>:::text_box`, so a selector that assumes
`textarea[name=…]` breaks on the default. Use `[name='<scheme>:::text_box']`.

### Keyboard shortcuts

`key_value` per label, on any type that has labels:

```yaml
labels:
  - name: "Yes"
    key_value: y
  - name: "No"
    key_value: n
```

**The key is `key_value`.** `key_binding` looks right, validates, renders, and
does nothing — an unrecognized key inside a label is not checked, not even by
`--strict`. Confirm the shortcuts exist rather than assuming: `potato preview`
prints a keybinding count per scheme, and `--format json` gives you the actual
list. An empty list where you expected shortcuts means the key name is wrong.

`sequential_key_binding: true` on the scheme numbers the labels 1, 2, 3… instead,
which is often what you want and cannot be misspelled per label.

Add shortcuts whenever there are nine or fewer choices. An annotator doing 500
items notices nothing else as much. `horizontal_key_bindings: true` lays the
hints out in a row.

### Conflicts are real, and only `preview` reports them

Two schemes both declaring `key_value: '1'`:

```
$ potato validate config.yaml --strict     -> OK — no issues found.
$ potato preview config.yaml               -> KEYBINDING CONFLICTS:
                                                Key '1' used by both 'first:One' and 'second:Alpha'
$ grep -i conflict server.log              -> nothing
```

On the live page, pressing `1` sets the **first** scheme and leaves the second
untouched. The second scheme's shortcut is silently dead. Nothing warns at
startup — a claim to the contrary was in an earlier version of this file and is
wrong.

Two behaviours to keep apart:

- **`sequential_key_binding` schemes are allocated distinct keys.** Three schemes
  all using it come out as `Q W E R T`, then `A S D F G H J`, then `N Y`. Preview
  lists these as conflicts on `1`, `2`, `3` because it reports the raw claim
  rather than the allocation. Those are not real.
- **Explicit `key_value` is honoured as written and never reallocated.** Two
  schemes claiming the same character collide for real, and the loser is silent.
  Mixing explicit `key_value` with `sequential_key_binding` elsewhere is the
  usual way to get one.

A scheme with its own tool keys — `image_annotation` reserves keys for the bbox
and polygon tools — can have a label key and a tool key fire together on the same
press. That generator does warn:

```
WARNING image_annotation: Keybinding conflict … key 'r' is bound to bbox tool and
also to label 'Confusing order'. Both will fire.
```

So there are two conflict detectors with different coverage and neither is the
startup log. **Press the key on a running page.** It is the only check that
covers both.

### Conditional questions: `display_logic`

```yaml
- annotation_type: radio            # the gate
  name: has_anecdote
  description: Does this post tell a personal story?
  labels: ["Yes", "No"]
  humanize_labels: false

- annotation_type: span
  name: anecdote_span
  description: Highlight the part that is the anecdote.
  labels: [Anecdote]
  display_logic:
    show_when:
      - schema: has_anecdote
        operator: equals
        value: "Yes"
```

Both schemes have to be in the same config. A `show_when` naming a scheme that is
not there is a startup error, not a silent no-op -- which is the one good outcome
in this area.

`show_when` is a non-empty list; conditions are ANDed. Operators: `equals`,
`not_equals`, `contains`, `not_contains`, `matches`, `empty`, `not_empty`,
`gt`, `gte`, `lt`, `lte`, `in_range`, `not_in_range`, `length_gt`, `length_lt`,
`length_in_range`. `empty` and `not_empty` take no value; the range operators
take `[min, max]`.

`schema` must name a real scheme; a reference to a scheme that does not exist,
or a cycle, fails validation.

**`not_equals` is true on a gate nobody has answered yet.** An unanswered scheme
reads as undefined, and undefined is not equal to anything, so a follow-up gated
with `not_equals` is on screen from the moment the page loads -- which is the
opposite of what the block was added for. Gate on the positive values with
`equals`, or AND a `not_empty` condition in front:

```yaml
display_logic:
  show_when:
    - {schema: misleading, operator: not_empty}
    - {schema: misleading, operator: not_equals, value: "No"}
```

**A tile scheme can be gated on, but check it on the page.** `pairwise`, `bws`,
`ranking` and `triage` answer by clicking a tile and writing to a hidden input,
which the gate reads through a different path from the one radios and checkboxes
use. Measured on Potato 2.8.2: a `{schema: <triage>, operator: equals, value:
accept}` gate hides its dependent at load, shows it on *accept* and hides it
again on *reject*. Before 2.8.2 it fired in neither direction. If the version is
not yours to choose, drive the gate once in a browser before you design a page
around it.

Two things to watch either way. A gate on a scheme inside a **phase page** takes
the DOM fallback rather than the live answer map, and that path requires the
widget to have marked its input `data-modified` -- `triage` does not. And what a
tile scheme stores is not what it shows, so write the condition against the
stored value (see below).

**A tile scheme's stored value is not its label.** `pairwise` records `A`, `B` and
`tie`; `labels:` and `tie_label:` are what the annotator reads, not what lands in
the data. Written as `value: "Summary A"` a condition could never have matched
even if the gate worked, and the same names are what you filter on at analysis
time.

A required **span** scheme is enforced server-side only: the client cannot see
whether a span was drawn, so `Next` posts, the server answers
`400 Required annotation(s) not completed: <scheme>`, and the annotator gets a
small corner toast naming the internal scheme name rather than the question.
Requiring a span is fine; just make sure every item is one where drawing a span
makes sense, quality-control items included.

A hidden scheme may still be `required` — the server skips requiredness checks
for schemes their own `display_logic` is hiding. Answers already given to a
scheme that later becomes hidden are kept and saved, so a respondent who changes
the gate answer leaves values behind on the hidden follow-up. Tell the
researcher: it is a data-cleaning rule, not a bug.

**A conditional scheme is invisible to a screenshot.** The render captures the
page as it loads, so a scheme behind `display_logic` is not in the picture and
its absence is not evidence of anything. Verify it another way: comment out the
`display_logic` block, screenshot, put it back. Otherwise you are shipping the
most important widget in the task unseen. This is a real failure mode: a task
whose whole point is highlighting a span, verified with a screenshot in which
the span widget does not appear.

## Arranging the questions: `layout`

Without a `layout` block every scheme stacks full-width in config order, and a
task with a dozen questions is a dozen screens of scrolling. Potato has a form
grid with collapsible groups, and almost nothing points at it.

```yaml
annotation_schemes:
  - annotation_type: radio
    name: sentiment
    description: "What sentiment does this carry?"
    labels: [Positive, Negative]
  - annotation_type: multiselect
    name: topic
    description: "Which topics apply?"
    labels: [Politics, Sport]
  - annotation_type: text
    name: notes
    description: "Anything else worth recording?"
layout:
  grid:
    columns: 3                 # 1-6; the base grid
    gap: "0.75rem"
  breakpoints:
    mobile: 480                # one column below this
    tablet: 768                # spans reduced below this
  groups:
    - id: primary
      title: Primary classification
      description: Required for every item
      schemas: [sentiment, topic]
    - id: details
      title: Additional details
      collapsible: true
      collapsed_default: false
      schemas: [notes]
```

Every name in `layout.groups.schemas` must be a scheme in the same config —
that is one of the few nested blocks where a wrong name fails at boot rather
than being ignored.

Per scheme, `layout` says how much of that grid it takes:

```yaml
- annotation_type: text
  name: notes
  description: Anything else?
  layout:
    columns: 3                 # span the full 3-column grid
    order: 9                   # explicit position
    min_width: "200px"
    max_width: "400px"
    align_self: start
```

`columns` is 1–6, `rows` is 1–4, both clamped rather than rejected. A working
model is `examples/advanced/grid-layout/`; the full key list is in
`docs/configuration/form_layout.md` and in `config-keys-nested.md`.

**Validation is real here**, unlike most nested blocks: a `layout.grid.columns`
outside 1–6, a non-string `layout.grid.gap`, a duplicate group id, an empty
`layout.groups.schemas`, or a group naming a scheme that does not exist all fail
at boot with a message naming the problem.

Three things to know before using groups:

- **`collapsed_default: true` is a decision to have the question skipped.** Use
  it only for questions most items genuinely do not need.
- **A slider in a narrow column is unreadable.** The tick labels and the value
  bubble collide. Give `slider`, `range_slider`, `soft_label` and
  `semantic_differential` a `columns: 2` span or more, or leave them full width.
- **A grouped form goes to one column at `tablet`, not at `mobile`.** The
  container's column count follows the `tablet` threshold, so
  `breakpoints: {tablet: 768}` — the default — means a grouped form is a single
  column in any window narrower than 768px, which is what a split screen usually
  is. Measured on a three-column, three-group form: at a 640px viewport with
  `tablet: 700` every group computed one track; the same page with `tablet: 300`
  computed `164px 164px 164px`. An ungrouped form holds its grid down to
  `mobile`. If your annotators work narrow, design for the column and treat the
  grid as a bonus.

  Per-scheme spans are reduced separately from the container, at widths that are
  not all yours to set, so read the computed span rather than the config when the
  window is not wide:

  ```js
  [...document.querySelectorAll('.annotation-form')]
    .map(f => f.dataset.schemaName + ' ' + f.dataset.gridColumns
              + ' -> ' + getComputedStyle(f).gridColumn)
  ```

A mistyped `layout` block validates, so confirm the attribute exists at all
before debugging the width:

```js
[...document.querySelectorAll('.annotation-form')]
  .map(f => f.dataset.schemaName + ' -> ' + (f.dataset.gridColumns || 'MISSING'))
```

## Form density

No config key sets this, and it is easy to let a task grow into something slow
to annotate one reasonable question at a time. What to reach for, in order:

1. **One scheme per judgment**, always. `designing-a-task.md` covers why.
2. **`display_logic`** so follow-ups appear only when they apply. A question
   that is not shown costs nothing.
3. **A purpose-built type** where the questions are really one question
   repeated: ten ratings on the same scale is one `multirate` rather than ten
   `likert` schemes.
4. **`layout.grid`** to put short questions side by side. The five schemes in
   `examples/advanced/grid-layout/` come out in three rows rather than five.
5. **`layout.groups`** to give the rest headings, so the page reads as three
   sections rather than twelve questions.
6. **Split the task** if it is still long. Two passes over the corpus with five
   questions each cost the same annotator time as one pass with fifteen, and
   each pass has an annotator holding fewer definitions in their head at once.

Question count is a poor proxy for how long an item takes; how far the
annotator scrolls is a better one, and you can measure it. Screenshot
`full_page=True` and read the page height against a 900px viewport. Anything
past two screens is worth restructuring.

## Page-level appearance

| Key | Effect |
|---|---|
| `annotation_instructions` | Collapsible instructions banner; text or a path |
| `header_file` | **Inert.** The header template path is no longer configurable and nothing reads the key; `validate` reports it |
| `custom_footer_html` | HTML on every page |
| `header_logo` | Logo in the navbar |
| `base_css` | An extra stylesheet |
| `task_layout` | Custom HTML for the form area; the escape hatch |
| `layout` | The form grid and groups — see *Arranging the questions* above |
| `hide_navbar` | Hide the top bar |
| `jumping_to_id_disabled` | Remove the jump-to-item control |

The navbar's two arrows are **previous and next _unannotated_**, not previous
and next. On a stretch the annotator has already answered they do nothing and
say nothing, which reads as a dead button; the jump-to-item box beside them is
how you get back to an item that is already done. `jumping_to_id_disabled`
removes that box and with it the only way back.
| `horizontal_key_bindings` | Lay shortcut hints out horizontally |
| `highlight_linebreaks` | Make line breaks visible in item text |
| `keyword_highlights_file` | Highlight given keywords in the item |
| `ui`, `ui_config` | Interface toggles |
| `ui_language` | Interface language code |

Two of those carry a contract the key name does not:

**`keyword_highlights_file` reads 6 shapes.** The documented one is a CSV or
TSV with a `keyword` header column; `label`, `schema` and `color` are optional
beside it, matched by name in any order, and `keyword`/`word`/`pattern`/`term`
are accepted spellings.

```
keyword,label,color
latch,Defect,#ffcc00
Ridge*,Place,
```

One keyword per line also works, as do JSON, JSONL and YAML holding a list of
strings, a list of objects, or a `{keyword: label}` map. The sixth shape is a
headerless delimited file, read positionally as keyword, label, schema; it warns
and tells you to add a header. A `*` matches any run of word characters.

The boot log names the shape it read and the count — `Loaded 3 keyword highlight
patterns from … (read as delimited with a header (keyword, label, color))` — so
read that line rather than the page. A header the loader does not recognise is
not an error: the file is read positionally and the header row itself becomes a
keyword.

**Keyword highlighting covers `text_key` plus every field with
`span_target: true`**, and nothing else — a field with no span target has
nowhere to draw. Each match carries its own `target_field`, and
`/api/keyword_highlights/<instance_id>` reports `fields_scanned`, which is the
way to check that the field you care about was one of them.

**`list_as_text` takes three sub-keys**: `text_list_prefix_type` (`alphabet`,
`number`, `bullet` or `none`), `horizontal`, and `alternating_shading`. A bare
`true` turns it on with the defaults.

Reach for `task_layout` last. Hand-written layout HTML stops tracking changes
to the schema generators, and most of what people want it for is
`instance_display.layout` plus scheme order.

## Checking it

```bash
potato preview config.yaml                        # what the config declares
potato preview config.yaml --screenshot out.png   # what it looks like
```

The screenshot boots the task in a real browser and reports every uncaught
exception, `console.error` and failed request. Most annotation UI is built by
JavaScript after the HTML arrives, so validation cannot see any of it.

**It captures the viewport, not the page.** The PNG is 1280x900 — on a task with
an instructions banner that is the banner and part of the item, and none of the
questions. To see the whole page, drive a browser against a running server and
screenshot with `full_page=True`: `running-a-task.md` has the driver.

Some of what it reports is not yours. Every phase page of a healthy task logs
`GET /api/current_instance 404` and two `[SpanManager]` errors, because a phase
page has no instance. Filter those before reacting.

**Look at the PNG.** A clean exit means nothing threw, not that the interface is
usable. Check: every question present, nothing overlapping, labels readable, the
item rendered rather than showing a raw path or a broken image, choices not
running off the edge.

What the screenshot cannot show you, and you have to check another way:

- anything behind `display_logic` (see above)
- anything that appears after an interaction: a span being drawn, a canvas
  tool, a media player mid-playback
- phase pages: pass `--phase consent` (also `instructions`, `training`,
  `poststudy`)
- how it behaves after navigating away and back, which is where saved state
  goes wrong

## Common UI failures

| Symptom | Cause |
|---|---|
| The item shows a file path instead of an image | No `instance_display`, so the path rendered as text |
| A span scheme with nothing to highlight | No field has `span_target: true` |
| `span_target is set but type X does not support span annotation` | Wrong display type; the error lists the twelve that work |
| A scheme is missing from the page | It is behind `display_logic` and the condition is not met |
| Choices run off the right edge | Many labels in a horizontal `layout`; use vertical |
| A keyboard shortcut does nothing | The key is `key_value`, not `key_binding`. Check with `potato preview` — an empty keybinding list means the label key is misspelled |
| A shortcut works but the wrong label lights up | Two labels claimed the same key; the conflict warned at startup |
| Labels read "Information Or Advice" | `humanize_labels` is on by default; set it `false` |
| A feature you configured does nothing | Typo'd key. Unknown keys only warn — run `potato validate --strict` |
