system.cssthe engine GitHub

The form canon

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/.dgr on the label or fieldset. Never a separate error div.
  • Everything fills the entire width — controls by the canon's own rules; a lone submit button in a .column does NOT stretch on its own, it takes class="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 like 1 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 requires enctype="multipart/form-data" on the form (gf-52 — without it the bundle silently drops the file). A second endpoint on the same form = a data-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. required generates 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) + real readonly on 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-width as 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 wears anchor-name, the dialog wears position-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 wears anchor-name, its click stamps el.style.positionAnchor on the dialog BEFORE commandfor opens 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 a min-inline-size in the recipe — without one, auto width silently SHRINKS into a cramped position-area region instead of overflowing it, and position-try-fallbacks never 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 — wire data-on:close to 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 by data-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.
  • .stress is 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 .tabs groove — a div role="group" with a .legend child (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 in title. are specimens on the lab's Forms page.