THE FORM CANON (gf-173, per Mike — follow this EXACTLY)
The practice bench is the lab's Forms page (site/lab/forms.html, https://deufel.github.io/system-css/lab/forms.html); the engine rules live in the form/fieldset sections of system.css. If a product form doesn't look like the bench specimens, the product form is wrong.
- Everything lives in
<fieldset>s. A fieldset is ONE ROW of fields;<legend>names the group (the legend's own<small>is the GROUP state slot). A form-section heading is<h3>. - Every field — checkboxes included — is label → small → control:
<label><span>Name</span><small></small><input/></label>(checkbox:<label><input/><span>Text</span><small></small></label>). The EMPTY<small></small>is mandatory — it reserves the 1lh state row so the layout never shifts when a message lands. <small>is STATE, almost never a caption. Self-descriptor hint text is a smell — if information is truly needed, it belongs in the page's info popover. State lights the slot through the canonical channels only:aria-invalid="true"on the control (danger hue + message in that field's small) or.suc/.inf/.wrn/.dgron the label or fieldset. Never a separate error div.- Everything fills the entire width — controls by the canon's own
rules; a lone submit
buttonin a.columndoes NOT stretch on its own, it takesclass="fill"(gf-313: the wizard's buttons). Row shares ride--row-width: <number>— a BARE NUMBER (it is a registered<number>property; a flex shorthand like1 1 100%is INVALID and silently falls back to 1). Since gf-173 labels flex under legended fieldsets too; buttons keep hugging. - The submit runs through datastar:
data-on:submit__prevent={"@post('"+url+"', {contentType: 'form'})"}. A file input additionally requiresenctype="multipart/form-data"on the form (gf-52 — without it the bundle silently drops the file). A second endpoint on the same form = adata-on:click@post button (formaction can't fire under __prevent; nested forms are parser-dropped, gf-63). - The handler stores → publishes → answers 204. Never HTML back,
never a redirect on a datastar form; the fat-morph re-lands truth.
requiredgenerates the asterisk — never hand-write one. <div role="form">is the sanctioned formless spelling for a CQRS group (checkboxes that post individually) — same recipe, no submit.- State rides the OUTLINE, never the surface (gf-174): a stated field keeps its neutral raised background; the locked hue colors only the border and the small (the engine re-hues the bg back to ambient — don't fight it, don't add tinted backgrounds).
- Fieldsets breathe: the engine gives a fieldset 0.35lh after
itself inside a form (gf-174) — never add manual spacers between
groups. An
<hr>may draw the seam between CONCERNS in one form; a<details><summary>folds a rarely-touched tail (fields inside follow the same canon). - The segmented groove splits EVENLY (gf-175): chips share the groove equally (flex: 1) and the checked chip wears the ACTIVE state paint (--bg: 1, inverted ink — same as [data-ui-state="active"]). Be prudent: don't wear the groove where the longest option can't fit its equal share.
- The LOCKED form (gf-175): the reading state — NOT disabled.
data-ui-state="locked"on the form (field chrome stands down, controls sleep, buttons stay live) + realreadonlyon text controls for AT (aria-readonly doesn't apply to role=form). The state rides a SIGNAL + data-attr bindings so fat-morphs re-land it intact (the orgEdit precedent); Unlock flips the signal, Save re-locks through the normal 204+publish, Cancel just re-locks and the morph restores stored values. Specimen on the bench. - THE SINGLE-ROW FORM (gf-183, per Mike): for concise surfaces
that must not hog space — a chat composer, a compact search, an
inline sign-in. ONE legend-less
<fieldset>, BARE controls (aria-label + placeholder carry the name; no label grid, no state small), the submit hugging its own width (buttons hug in every fieldset). Weight fields with--row-widthas usual. The moment a field needs a visible name or a state slot it has outgrown the pattern — use the full canon. - THE ACTION ROW (gf-176, per Mike): submit bottom-RIGHT, cancel
bottom-LEFT, SPREAD apart —
<div class="spread">as the form's last row (cancel/destructive first, submit last; a lone submit rides an empty<span></span>on the left). Opposite-effect buttons get maximum visual separation so a user can't fat-finger the wrong one. Never a centered or adjacent pair. - THE DATABASE FLOWS (gf-176 — wired specimens on the lab's Forms
bench):
(1) EDIT/SAVE/CANCEL — the draft mechanism: Edit seeds signals from
the stored value (the seed expression is server-rendered, so morphs
keep it fresh), typing stays client-side, Cancel discards, Save posts
signals → 204 → morph re-lands truth.
(2) UNLOCK/AUTOSAVE/LOCK — no draft, no Save: the bound field posts
on
data-on:input__debounce.500ms, the morph carries the receipt ("saved …" in the legend's small), Lock re-sleeps the chrome. (3) SQL PREFLIGHT (gf-177) — the draft debounces into a CHECK op that validates WITHOUT writing (business rules + a uniqueness query, self excluded) and stores draft+verdict in a scratch row; the morph disables Save and speaks a CONCISE per-rule message in the error small (which speaks ONLY on error); Save re-validates and lets the real UNIQUE constraint backstop a race; Cancel clears the scratch — nothing was ever written. (4) DEBOUNCED STORED-STATE VALIDATION — the value stores raw; the RENDER judges it and the morph delivers aria-invalid + the message in the field's small. Validation is stored state re-rendered — never client truth; a second tab goes red at the same instant. - THE LAYOUT-SHIFT DOCTRINE (gf-178, per Mike): whenever possible,
do not cause layout shift. Editing must not reflow the page. For
edits with significant validation, use the ANCHORED SMALL DIALOG:
.modal.content-1.anchored— the subject wearsanchor-name, the dialog wearsposition-anchor(the sanctioned inline pair). ⚠ Chrome gives DIALOGS no implicit invoker anchor yet (popovertarget does, commandfor on a dialog does NOT — verified live, gf-183): the trigger wearsanchor-name, its click stampsel.style.positionAnchoron the dialog BEFOREcommandforopens it (pinned from the first paint), and the server re-stamps it by morph from the stored edit state. One dialog, many triggers. It opens pinned beside the subject in the top layer (zero shift), growing toward inline-START (edit triggers live at row ends), with amin-inline-sizein the recipe — without one, auto width silently SHRINKS into a cramped position-area region instead of overflowing it, andposition-try-fallbacksnever fire (fallbacks trigger on overflow only — verified live, gf-184). The platform gives the focus trap + an inert page (exactly ONE edit open, by construction) and Escape/light-dismiss ARE cancel — wiredata-on:closeto the cancel op so an abandoned edit can never leak scratch state. One dialog per surface, always in the DOM (the selected-row idiom), content + anchor server-rendered from stored edit state, kept open across morphs bydata-preserve-attr="open". Inline editing remains right ONLY for trivial single-value flips (a status select, a checkbox) — anything with preflight rules earns the dialog. .stressis the lab's resize handle (resize: horizontal) — for stress-testing specimens only; product surfaces size by composition.- Radios have two spellings: a PLAIN group rides the checkbox
recipe (input → span → small, one label per option, per-option state
smalls); a short exclusive choice worn like a control is the
SEGMENTED
.tabsgroove — adiv role="group"with a.legendchild (NOT a fieldset — its anonymous box breaks tile stretch). Both A label may carry an ICON before its span (gf-362: Manufacturer | Wholesaler | Retailer with factory · truck · store); a disabled option keeps its chip and says why intitle. are specimens on the lab's Forms page.