Fields, validation timing, error presentation, submit states.
Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them.
Before you write a word of consequence copy, read product-facts.md. Who is alerted, what becomes permanent, who can read it afterwards and whether it can be undone are decided there — and where they are not, that is a blocker rather than something to phrase well.
A blind test of this document against the incident-report form found the guidance here was 22 words against 35 lines and two sketches for tab homes — a 30:1 fidelity gap, and worst exactly where forms are dangerous. This is the repair.
(insets.top + spacing.tight)
✕ 24pt glyph, 44pt target, no bottom border
↕24
SAFETY RECORD eyebrow · text.secondary
↕8
Report an incident display 34/800
↕8
Staff only. Students are never caption · text.secondary
notified and never see this.
↕32
WHAT AND WHEN eyebrow — section header
↕12
One-line summary label 13/700 SENTENCE case · text.primary
┌──────────────────────────────┐ ↕6
│ Elbow to the nose │ h≥44 · radius.input · 1px border.control
└──────────────────────────────┘ surface.card fill · body 15/400 · pad-h 12
↕16 ← between fields
Where Optional optional marker: caption · text.secondary
┌──────────────────────────────┐
│ Mat, lobby, parking lot… │ placeholder text.tertiary (legal — it is on white)
└──────────────────────────────┘
↕24 ← between sections
Severity
( Low )( Moderate )(▓Serious▓) chips h32 · gap 8 · WRAP, never h-scroll
selected = surface.hero + text.onHero
↕32
┌──────────────────────────────┐
│ Submit report │ h48 · radius.input · one filled primary
└──────────────────────────────┘ label = rowTitle 16/600, NOT `label` 13 — see below
Save as draft ↕16 · text link · 44pt target
Spacing: label→input 6 · field→field 16 · section→section 24 · header block→first section 32 · last field→primary 32 · primary→text link 16.
On a screen that owes a consequence line, that last 32 is spent differently. The full sequence, so there is only one reading:
last field ↕24 [ summary line ] ↕6 [ consequence line ] ↕12 primary
[ failure block ] ↕12 ← when a submit failed
The failure block takes the consequence line's place, in the same slot, 12pt above the primary. It does not stack with it: once a submit has failed, what the button would do matters less than what just happened, and two blocks of explanatory text above one button is a wall. The consequence line comes back when the person edits anything, because at that point the failure is stale.
A screen with no summary line simply drops that row and its 6: last field ↕24 consequence ↕12 primary. The 24 always attaches to the last field, whatever comes next.
Inputs sit on the page, never inside a white card. An input's fill is surface.card, so
an input inside a card is white-on-white with zero fill separation — it would survive on its
border alone. Group fields with space and an eyebrow header instead. (If a field group must
be a card, the inputs inside it invert to a surface.page fill: same 1.119:1 delta, other
way round.)
Mark what is optional, not what is required. Most fields in Konjo's forms are required;
marking the minority is quieter and reads faster. Optional in caption / text.secondary,
right-aligned on the label row.
Chips wrap. They never scroll horizontally — a horizontal strip hides options off-screen, which is how the current incident form loses most of its severity choices.
The reference target for this chapter is
reference/konjo/form-target-light.jpg and its dark
twin — open one with Read and compare. It is generated from the token source, so it is current.
The select trigger — the control that opens a sheet
A form field that opens a sheet or picker is not a text input, and it is the most common control on a real Konjo form. Its anatomy:
Student label 13/700 sentence case
↕6
┌────────────────────────────────────────┐ minHeight controlHeight.control 44 — ALWAYS
│ Choose a student › │ surface.card · 1px border.control
└────────────────────────────────────────┘ radius.input · pad-h 12
placeholder body 15/400 · text.tertiary
chevron 16pt · text.tertiary
┌────────────────────────────────────────┐ same control, grown by its content
│ (AT) Ana Torres › │ an avatar plus padding is already 56
│ ● Green Belt · 14 months │ value body 15/400 · text.primary
└────────────────────────────────────────┘ supporting caption 13/400 · text.secondary
- It looks like an input because it is one. Same fill, same border, same radius, same height floor. The only differences are the trailing chevron and that its content is a value rather than a cursor.
- The value is
body, the label above it islabel. A trigger that renders its value in the label's weight reads as a heading, not a field. - One floor, and it grows.
controlHeight.control44 whether it holds a value or not. A value with an avatar or a second line passes 44 on its own — an avatar plus padding is already 56 — so there is no second floor to remember and a single-line value does not get inflated to 64. Never a fixed height, never truncation: a name is the one string nobody may mangle. - Empty state is a placeholder, not a label repeat. "Choose a student", not "Student".
text.tertiaryfor the placeholder and chevron is legal here because the trigger's fill issurface.card. On a trigger sitting directly on the page it would fail — usetext.secondary.- Errors present exactly as an input's do: border swaps to
feedback.errorBorder, still 1px, message incaption/feedback.errorText6pt below. - Focus is 2px
border.focus, same as an input. A keyboard user tabs through triggers. - Screen reader: one stop,
accessibilityRole="button", label reading"<field>. <value>. <helper>."— or"<field>. none chosen."when empty, and with"Error: <message>"appended in the error state.fieldControlProps({ label, value, helper, error })composes exactly that; pass the value, because a label without it tells a blind instructor which field they are on and nothing about what it holds. Decoration inside the trigger — avatar, rank swatch, chevron — is hidden.
Validation belongs to the trigger, not the sheet. A picker never validates; there is no blur event on a control you tap, so a trigger validates on submit and then re-validates live once it has been in an error state, so the error clears the moment it is fixed.
The form's own frame
Scroll inset. A screen inside a tab stack clears the tab bar at insets.bottom + 80. A
modal has no tab bar, so it clears at insets.bottom + spacing.section — enough that the last
control is not against the home indicator, and no more.
The primary sits in flow, at the end of the content — not stuck to the bottom. A sticky
footer on a form implies you can submit at any point, which is false while three fields are
empty, and it permanently costs the height of a button on the smallest screens. Reaching the
button by scrolling past the fields is the correct order of operations. (spacing.stickyTop and
spacing.stickyBottom exist for the surfaces that genuinely need a pinned action — a media
viewer, a wizard step — and a form is not one.)
The keyboard. Nothing about this is optional, and none of it was written down:
- The focused control stays visible. Wrap the scroll in keyboard avoidance so the field being typed into is never behind the keyboard.
- Never autofocus a form. The person decides where to start, and a keyboard covering the screen on arrival hides the thing they came to read.
- Return moves on, it does not submit. In a single-line field,
returnKeyType="next"moves to the next control; in the last field and in any multiline, it is"default"and inserts a newline. A form is never submitted by a key a person pressed to get to the next line. - Tapping outside dismisses the keyboard, and dismissing never submits or validates.
- Scroll position is preserved when the keyboard opens and closes.
Leaving with unsaved work. ✕ or a back gesture with anything typed asks once, in a sheet —
never Alert.alert, which the design system has no control over:
Discard this promotion? title 22/700, left-aligned
You'll lose what you've typed. caption · text.secondary
[ Keep editing ] row, ≥44pt — the safe option FIRST
[ Discard ] row, accent.red — the destructive option SECOND
The safe option is first because it is the one a person taps by reflex. Nothing typed is lost until the second row is chosen.
Selection controls — checkbox, toggle, switch
The mobile system had none of these written down, which meant any screen needing an on/off control invented one. Three shapes, chosen by what the control does:
| Shape | Use | Anatomy |
|---|---|---|
| Checkbox | One item in a set, where several can be on. "Send this note." | Checkbox — a 24pt box, radius.input, 1px border.control; checked fills surface.hero with a text.onHero check glyph. The whole row is the 44pt target, not the box. |
| Switch | An immediate setting that takes effect on flip, with no submit. Settings screens. | The platform switch, tinted surface.hero when on. Label left, switch right, row ≥44pt. |
| Chip | Choosing among options in a row, where the choice is the content. Filters. | As search and filter — 32pt, radius.pill, surface.card + border.control, selected fills surface.hero on surface.card. |
A checkbox is not a chip. A chip is a value you are picking; a checkbox is a decision about a thing that is already there. Using a chip as a checkbox is how a screen ends up with a row of pills nobody can tell apart from filters.
- Checked state is
accessibilityState={{ checked }}withaccessibilityRole="checkbox"— never{ selected }, which is a chip's state. A switch isrole="switch"with{ checked }. - The label is the tap target, not just the box. A 24pt box alone is under the floor.
- Never colour alone. The check glyph carries the state as shape; the fill reinforces it.
- Selection controls are exempt from the loud budget. A filled checkbox is a control state, the same way a selected chip is.
Repeating field groups
A form that collects several of a thing — test notes, emergency contacts, tags on a session.
The whole group is one Field; the members are rows
inside it.
Test notes Optional one Field labels the GROUP
↕6
┌────────────────────────────────────┐ each row: minHeight controlHeight.control 44
│ Strong low blocks, timing improved │ surface.card · 1px border.control · radius.input
└────────────────────────────────────┘ body · grows to multiline as it wraps
☑ Send to Ana ↕6 the row's own control, under it, not beside
↕12 between members
┌────────────────────────────────────┐
│ Keeps dropping her guard when tired│
└────────────────────────────────────┘
☐ Send to Ana ↕6
↕12
┌────────────────────────────────────┐
│ Add a note… │ a ghost trailing row, always present
└────────────────────────────────────┘ placeholder text.tertiary
- A ghost trailing row, never an "+ Add" button. Typing into it spawns the next empty one. One less control, and the affordance is where the thumb already is. The ghost row never counts toward the group's total.
- 6pt from a member to its own control, 12pt between members. The member and its control are one thing; the gap between members has to be the larger of the two or they read as a single list.
- A member is an
Inputwithmultiline="grow"— it wraps and grows, but floors at 44 rather than at the long-form 88. A note is usually one line and occasionally two; three fields starting at 88 is a wall. - Deleting is emptying. Clearing a row's text removes it on blur. No delete button per row: a destructive control repeated six times down a form is six chances to lose work.
- Each member names itself for the screen reader — "Note 2 of 6" — because
Fieldlabels the group and a bare input in a repeating list is otherwise unidentifiable. - If the group has a summary, it sits above the primary, not inside the group. See below.
A summary line above the primary
Some groups need a count of what will happen — "2 of 6 notes will be sent to Ana." It is
caption / text.secondary, on the page, directly above the consequence line, with 6pt
between them.
- It is
accessibilityLiveRegion="polite". When it is the only confirmation a person gets, a blind user has to hear it change. - It names the person, not the count alone. "2 of 6" is arithmetic; "2 of 6 notes will be sent to Ana" is a fact about a human being.
- Before the subject is chosen, it says the neutral form — "2 of 6 notes will be sent to
the student" — rather than rendering an empty name or hiding. This holds for every
name-bearing string on a screen: a checkbox label, a consequence line, a confirmation. Each
has a
{Name}form and a neutral form, and the screen never renders a gap where a name goes. - Never a pronoun. The app does not hold anyone's pronouns, so "she is notified" invents a fact about a real person. Repeat the name, or say "they".
Helper text, and the consequence line
Helper text sits 6pt under its field, in caption / text.secondary, and says something
the label cannot: what the value will be used for ("Appears on the certificate"), who will see
it ("The student can read this"), or what it is relative to ("From Green Belt"). One line. If it
needs two, it is not helper text — it is a decision the field should be making for the person.
- It survives the error. An error message appears below the helper, not instead of it; the helper explains the field and the error explains the failure, and losing the first to show the second removes context exactly when someone is confused.
- It is part of the field's accessible label, not an
accessibilityHint— a hint is for an outcome the label does not imply, and this is describing the field itself. - Never a place for instructions. "Enter the student's full name" is the label failing.
The consequence line — the sentence a serious screen owes before the tap
(serious actions) — is caption / text.secondary, on the page rather than
in a card, 12pt above the primary — and 24 below the last field, or 6 below the summary
line when there is one. See the sequence above. It sits close enough to the
button to be read as part of it. It is never inside a feedback fill: it is not a warning, it is
a statement of what the button does.
Other field types
- Multiline —
Inputwithmultiline, flooring atcontrolSize.multilineMinHeight(88, about three lines ofbody), grows with content, never scrolls internally on a form. Same fill, border and radius as a single-line input,bodytype, 12pt padding. - Dates and times — always the native picker, opened by a trigger as above. On RN-web,
which is the surface CI walks, "the native picker" is the browser's
dateinput; give it the same 44pt height andborder.controledge so the form does not change shape between platforms. - Optional is marked, required is not. Right-align the word
Optionalincaption/text.secondaryon the label row. Marking every required field with an asterisk in a form where almost everything is required marks nothing. - Character counts only where a real limit exists,
caption/text.secondary, below right, appearing at 80% of the limit rather than sitting there from the first keystroke.
Errors. Validate on blur, never on keystroke — NN/g: "displaying error messages while the
user types feels like an unwarranted scolding." A field never touched shows nothing. On
submit, everything validates at once, the first invalid field shows its error, and the
scroll animates to it at motion.duration.base with focus moved there.
The error state is: the input's border swaps border.control → feedback.errorBorder (still
1px — 2px is the focus ring, and using it for errors would make the two states collide), and
the message appears 6pt below in caption / feedback.errorText. The message reflows; the
fields below shift down. Nothing truncates.
Focus is a 2px border.focus ring replacing the 1px border.control — 4.67:1
against the unfocused state, which is what WCAG 2.4.13 asks for, and it describes exactly a
2px perimeter. borderWidth.emphasis 2 is legal here and in two other places only — see
borders. Without it the
form is unusable on RN-web with a keyboard, which is the only surface CI actually walks.
Never disable the submit button. There is no disabled text colour in this system —
text.quaternary is explicitly not a text colour — so a greyed button has no legal label.
An always-enabled button that validates on tap and jumps to the problem also beats a dead
button that never says why. In flight: the fill stays, the label becomes "Submitting…", a
16pt spinner appears to its left, inputs go editable={false}, triggers stop responding but
do not change appearance — there is no legal disabled colour, and greying four controls to
say "briefly busy" costs more than it tells anyone — taps are ignored, and the
button carries accessibilityState={{ busy: true }} — the relabel and the spinner are both
visual, and without the state a screen-reader user cannot tell the tap registered.
The success confirmation is a ValidationBanner with severity="success" above the list
the record joined, one line, naming what happened and to whom. Not a toast — the row is already
on screen, and notifications forbids a toast for something the screen shows.
A failed submit never replaces the form. The Error formula below is for a screen that failed to load; running it here would destroy everything the user typed. Instead, a bordered block appears above the primary — what failed, in plain words, no error codes — and the button relabels "Try again". Distinguish "nothing was lost, check your connection" from "this was already submitted".
Success is not a full-screen confirmation. Pop back to the list the record now belongs to, render it at the top, and put a one-line confirmation above it that says what happened and to whom: "Report submitted. Owners and head instructors were alerted." A takeover hides the very fact that matters — that the record exists now.