Skip to content

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.

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:

/* 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:

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:

- {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:

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:

.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:

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

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:

  - annotation_type: text
    name: why
    description: Why?
    multiline: true          # -> <textarea>
    rows: 4
    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:

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

- 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:

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.

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:

- 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:

[...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:

[...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

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