Files
cgalo5758 660485d9b8 Make status/explorations a local notebook git ignores
The exploration directory held 245 tracked files, and one week's
notebook had grown to 28M of captures, per-run CSS copies and model
logs, so every change ended with an argument about what to prune. It is
now a local lab notebook: git ignores it, its tracked files are
untracked, and no tracked file cites a path under it, because another
clone does not have it.

Every live citation is rewritten so the fact stands in the sentence:
the docs pages, AGENTS.md, four main specs, one Go comment, issues.md
and milestones.md. The operator IA page gains the landed IA revisions it
used to point at, and the UX rubric moves to docs/first-contact-rubric.md
because the process requires it. Archived changes, status/archive,
status/log and design/ keep their old paths as frozen history; the
design/ ones are logged as an upstream issue. MAINTAINING.md and the
status README describe the notebook and the harvest that replaces
pruning.
2026-09-19 19:46:52 -05:00

26 KiB

form-library Specification

Purpose

The forms library (internal/forms) is the one way a form exists on either surface: a form is declared once in Go, registered, rendered by the two form parts in one of four layout families fixed by its kind, and parsed by its handler through the same declaration, so what the controls offer and what the server accepts cannot differ. This spec holds the library's contract: the declaration, the kind-to-family pairing (stacked, dense, bar, table), the outcome contract a handler answers with (422 re-render on refusal, HX-Redirect or 303 on a navigating success), the registry and its invariants, the container the part owns, the constraint attributes derived from the declaration, and the closed API. The presentation rules the families obey (labels, hints, errors, commit weight, copy) live in form-conventions; the capture states that review every form live in ui-quality-gate. Born of the 2026-09 forms audit and the forms-library change archived 2026-09-05.

Requirements

Requirement: Every form is a declaration

Every form on the member and operator surfaces and on integration-owned pages SHALL be declared once as a forms.FormSpec value in Go, beside the handler that serves it: a unique name, a kind (create, edit, sub-record, settings, search, preview, confirm), a layout family (stacked, dense, bar, table), a method and a path, an ordered list of forms.Field values, a commit label, and a way out. A Field SHALL declare its submitted name, label, control (from a closed set: text, textarea, number, email, URL, date, date-time, select, checkbox, radio, hidden, static), options where the control has them, hint, help, placeholder, optionality, length limit, range, pattern with its hint, input mode, autocomplete token, the side it exists on, an optional notice, and at most an escape hatch of data-* and hx-* attributes. A field's width in a dense row SHALL be one of five fractions of the row, full, half, third, quarter or sixth, that its control decides as the smallest that holds the control's widest ordinary value (a number and a date a sixth, a date-time a quarter, a select and a text, email or URL control a third, a textarea the full row, a checkbox, a radio group and a static value their own content, a hidden field no column), and a Field SHALL declare a width only as a step of one rung from that fraction, narrower or wider, where the field's values are shorter or longer than its control's usual ones. The registry's invariants SHALL refuse a step on a field whose control has no fraction (hidden, checkbox, radio, static, textarea), a step below a sixth, and a step above a half. The declaration SHALL NOT be generated from the database schema or from a model (design D2, D19; a data schema cannot express label, hint, control or placement), and it SHALL NOT carry conditionals, loops or scoping: a form that needs them needs a template and a recorded decision.

Scenario: A form is one value

  • WHEN a handler serves a form
  • THEN a FormSpec with that form's name exists in the registry, and the template renders it through the form part rather than writing <form>, labels or controls by hand

Scenario: The control set is closed

  • WHEN a form needs a control the set does not offer
  • THEN the control is added to the library with its rendering and its reading, not improvised in a template

Scenario: A step off the ladder is refused

  • WHEN a declaration sets a narrower width on a number field
  • THEN the invariants refuse the declaration naming the field, because a number is already a sixth and no fraction lies below it

Scenario: A short-valued select declares its step

  • WHEN the price form declares its currency select narrower
  • THEN the declaration carries the step and a one-line reason, and the select renders at a quarter of the row instead of a select's third

Requirement: A form's kind decides its layout family

A form's layout family SHALL be decided by its kind and SHALL NOT be the declaring file's choice: a search form is Bar, a settings form is Table, a sub-record form is Dense, a batch form is Rows, and a create, edit, preview or confirm form is Stacked. A declaration SHALL name both, so the registry and the design system's registered-forms table read the same way, and the invariants SHALL refuse any declaration whose family is not the one its kind names (design D10 as corrected in round 4, D20, D21). Wide SHALL be declared only on a Bar form; Columns, the label-column and control-column headings, and the static control SHALL exist only in the Table family; MessageAfter SHALL be declared only on a Preview form and SHALL name a field that form declares; Binding.Body, Binding.Staged and the row-action part SHALL exist only in the Rows family.

Scenario: A kind cannot pick a different family

  • WHEN a settings form is declared in the stacked family
  • THEN the invariants fail, naming the form, its kind, and the family that kind is laid out in

Scenario: A static row exists only in a table

  • WHEN a field declares the static control in a form that is not a table
  • THEN the invariants fail naming the form and the field

Scenario: A body belongs to a batch form only

  • WHEN a Stacked form is rendered with a body fragment on its binding
  • THEN the render refuses, naming the form and the family the body belongs to

Requirement: A search renders as one bar

The form part SHALL render a Bar-family form as one Bootstrap input-group and no section box: a leading input-group-text holding an inline SVG magnifier marked aria-hidden="true" and drawn in currentColor (embedded in the part, because the console ships no icon font and the content security policy forbids fetching one), then the text control with its <label for> visually hidden, then the commit as btn-outline-secondary, then, when the declaration sets a way out, the way out as a second btn-outline-secondary in the same group. Carry-along state SHALL ride as hidden inputs inside the form. A bar SHALL NOT render the "(optional)" marker. A bar SHALL be capped at a search box's measure unless the declaration sets Wide, which drops the cap so the bar spans its content column. Its role="search", its native GET action and its boosted or scoped htmx bindings are unchanged by the family (design D20).

Scenario: A search is one row, not a stack

  • WHEN an operator views a governed list's search
  • THEN the magnifier, the control and the outline button render in one input group, on one line, with no box around them

Scenario: A search's button is never filled

  • WHEN an operator views any search on either surface, the landing surface's lookup included
  • THEN its button renders btn-outline-secondary, so the page's one filled control is its "New …" link

Requirement: A settings form renders as one table

The form part SHALL render a Table-family form as a <form> around one table in a table-responsive container, with no section box: a header row naming the label column, the columns the declaration lists, and the control column; one row per declared field, in declared order, with the field's <label for>, its help icon as the label's sibling and its hint as a muted line under it in the label column; the page's pre-rendered cells for the declared columns, supplied through Binding.Cells keyed by field name and padded to the declared column count; the control and, when the field was refused, its error under that control in the control column; and the commit row below the table. A field whose control is static SHALL render its declared value as muted text in the control column, SHALL emit no name, and SHALL never be parsed. A table SHALL NOT render the "(optional)" marker (design D21). Every other control in a table SHALL set a value and SHALL do nothing else: a table's select SHALL offer only real values and SHALL NOT declare NoneOption, and a boolean SHALL be a checkbox whose label the label column carries, so the control column holds the input alone (design D21 round 5). Removing what a table row stores is an action on that row, declared as an action trigger, and never an empty value a control can submit.

Scenario: The page supplies what only the page knows

  • WHEN a settings row's Source cell holds a status badge
  • THEN the page renders that badge through the shared badge part and passes it to the render as a cell, and the library writes no badge markup of its own

Scenario: A static row offers nothing to submit

  • WHEN a settings table holds a key an operator cannot set on that page
  • THEN its row states where the value is set, carries no control and no name, and a submission that names it anyway is ignored

Scenario: A table's control offers no way to submit nothing

  • WHEN a table row's control is a select or a boolean
  • THEN the select offers only real values and the boolean is a checkbox, and neither can submit an empty choice

Requirement: A preview at rest renders no commit row

The form part SHALL render no commit row at all, neither the commit nor the way out, for a Preview-family form rendered with no message: a preview that has produced nothing has nothing to apply and nothing to discard. A message SHALL bring the commit row back. FormSpec.MessageAfter SHALL name the field the message follows, rendered in the same message element so a script that writes into it keeps working; an empty MessageAfter SHALL put the message before the fields. A commit disabled behind a precondition SHALL render with the classes the enabled commit would have carried (filled, danger, or outline by the same rules, small only in a dense row), and a caller SHALL NOT be able to set them (design D23).

Scenario: Nothing to discard, nothing offered

  • WHEN an operator views the org-type card before choosing a candidate
  • THEN the card renders its "Change default to" select alone, with no "Apply change" and no "Discard"

Scenario: The consequence reads below the control that causes it

  • WHEN an operator chooses a candidate and the preview renders
  • THEN the classification reads below the select, and "Apply change" and "Discard" follow it

Scenario: A disabled commit is the same height as the way out

  • WHEN a preview's commit waits on a precondition
  • THEN it renders with the enabled commit's own classes, so it and the way out beside it are the same size

Requirement: One declaration renders create, edit and the refused submission

The form part SHALL render a FormSpec in one of three modes and no other: unbound (defaults only; a create page), bound to a record (the record's values and no errors; an edit form), and bound to a submission (the submitted raw values, every field error, and the form-level error; a 422 response). The create page and the edit form of one entity SHALL render the same declaration. A field that exists on one side only SHALL declare that side, and the part SHALL render the field on its side only; the declaration carries no justification string (a Go comment beside the declaration says why, if anything does; maintainer, 2026-09-04: a recorded reason nobody reads is noise). A field MAY declare a notice, one line under the control, only where the difference would surprise a person (a value that can be set at creation and never changed); a field SHALL carry at most one visible line under its control, its hint or its notice, never both (maintainer, 2026-09-03: "We also don't need to add superfluous stuff like 'Available after creation'"). An edit form SHALL NOT open showing errors.

Scenario: Create and edit are the same declaration

  • WHEN an operator opens the product create page and then a product's record page
  • THEN both forms render operator.product's declaration, in unbound and record-bound mode, and differ only by the fields declared for one side

Scenario: A one-sided field renders on its side only

  • WHEN a field is declared for the edit side with no notice
  • THEN the edit form renders the field with no line about its side, and the create page renders neither the field nor a gap

Scenario: Only a surprising difference gets a line

  • WHEN a field is declared for the create side with the notice "Cannot be changed after creation."
  • THEN the create page renders that one line under the control and nothing else about the field's side

Scenario: A refused submission re-renders everything typed

  • WHEN a mutation is refused
  • THEN the response renders the same declaration in submission mode with every submitted value, including checkbox and radio states, and every error

Requirement: The handler parses through the declaration

A handler SHALL read a mutation's body through spec.Parse(r), which SHALL read r.PostForm (the query string for a search form) and never r.FormValue, SHALL read only declared field names and ignore the rest, and SHALL return for each field its presence, its raw string, and its typed value, plus a field-error set with per-field and form-level entries. Parse SHALL apply the declaration's rules (trimming, requiredness, length, range, pattern, membership in the option set); a submitted select or radio value outside the options SHALL be an error, never a silent default. A checkbox's absence SHALL read as false, and the library SHALL refuse a checkbox declared as required. A handler SHALL add its own errors by field name or at the form level, and SHALL build the domain object only after the error set is empty. The typed value of an errored field SHALL be absent; its raw string SHALL still be carried back for the re-render.

Scenario: The query string cannot shadow a field

  • WHEN a POST body omits name and the request URL carries ?name=x
  • THEN Parse reports name absent

Scenario: A value outside the options is refused

  • WHEN a select field declared with options A and B receives C
  • THEN Parse records an error on that field and no typed value

Scenario: Undeclared keys are ignored

  • WHEN a body carries the CSRF token, htmx's parameters, and a key no field declares
  • THEN Parse reads none of them and reports no error for them

Requirement: Two forms that write one column share one field

A Field written by more than one form to the same column SHALL be declared once in the library's shared set and included by reference; its name, label, hint, and rules SHALL therefore be identical wherever it appears. A variant SHALL be a new field with a new name, never a copy with loosened rules.

Scenario: The grant description is one field

  • WHEN an operator issues a grant and later extends one
  • THEN both forms carry the field named description, labelled "Note" (the column upstream is the audit note; dense-widths D7), capped at 500 characters, with one hint

Requirement: The registry holds every form and is checked

Every FormSpec SHALL register into one package-level index at construction; a duplicate name SHALL fail at boot. A test SHALL assert, for every registered form: unique field names; a label on every field; a kind, a method and a path, and the one family that kind names; family-scoped declarations used only in their family (Wide in a bar, Columns and the column headings and the static control in a table, MessageAfter on a preview and naming a declared field); no required checkbox; a select or radio with options; no hint on a field of a dense-family form (its explanation is its help icon, so the row's labels align); no hint longer than 100 characters and none whose content words all appear in its label (ui-vocabulary "Copy earns its place"); escape-hatch attributes limited to data-* and hx-*; no em dash in any string. A second test SHALL walk the router's mutation routes and assert that each is either a registered form's path or a declared action trigger (label, method, path, whether the confirm modal guards it); an unlisted mutation route SHALL fail the test.

Scenario: An unlisted mutation route fails

  • WHEN a handler registers POST /partials/operator/thing and neither a form nor an action trigger declares it
  • THEN the route test fails naming the route

Scenario: A hint under a dense field fails

  • WHEN a field of a dense-family form declares a hint
  • THEN the invariants test fails naming the form and the field

Requirement: The form part owns the container

The form part SHALL render the form's outer element with its id (form-<name>), its data-form attribute, its hx-* attributes (verb and path from the declaration, target this and swap outerHTML by default), and hx-disable on the commit; the section box, or in the rows family the caller's body fragment in place of a box; the form-level error slot, rendered on every form and empty when unused, with role="alert"; every field through the field part with a stable wrapper id form-<name>-<field>, suffixed by the instance when the form renders rows; and the commit row (the commit with its in-flight indicator, then the way out), which in the rows family is the tray. A 200 body and a 422 body of the same form SHALL be interchangeable in the same target. A template SHALL NOT render <form>, a form label, a form control, or a form check outside the part; a rows body renders its controls through the field part and its actions through the row-action part.

Scenario: The refused body swaps into the same target

  • WHEN a sub-record panel's form is refused
  • THEN the 422 body renders the same outer element and swaps into the panel where the 200 body would have

Scenario: Wrapper ids are stable

  • WHEN a field named name renders in the form operator.product
  • THEN its wrapper carries id="form-operator.product-name" in every mode

Scenario: A rows body carries no raw control

  • WHEN the anatomy lint reads the template that renders a batch form's body
  • THEN it finds no <form tag, form label, form control or form check, because every control comes from the field part and every action from the row-action part

Requirement: Constraint attributes derive from the declaration

The field part SHALL emit required (unless the field is optional or a checkbox), maxlength, min, max, pattern, inputmode, and autocomplete from the field's declaration and from nowhere else, so the browser's constraints and the server's rules cannot differ. Every mutation form SHALL carry novalidate, so the browser's constraint validation never blocks a submission and the refusal a person sees is the server's, rendered in submission mode; the attributes remain for assistive technology and for styling. The server SHALL validate regardless of the browser.

Scenario: The attribute follows the rule

  • WHEN a field declares a 500-character limit
  • THEN its control carries maxlength="500" and Parse refuses a longer value

Scenario: A cleared required field reaches the server

  • WHEN an operator clears a product's name on the record page and presses "Save changes"
  • THEN the request reaches the server, the 422 re-renders the form with "Enter a name." under the control, the control is marked invalid and focused, and no browser bubble stands in for it

Requirement: The library is closed on purpose

The library's stated goal, recorded in docs/design-system.md, is accessible and consistent forms by default; its customisation options are limited on purpose, and a form that cannot be expressed in the declaration is a change to the library or a recorded exception, never a hand-built form. The one escape hatch is the data-* and hx-* attribute map; it SHALL NOT override an attribute the part owns.

Scenario: The escape hatch cannot take over

  • WHEN a field's attribute map carries class or required
  • THEN the invariants test fails

Requirement: A batch form renders the caller's rows and one tray

A batch form is a declaration whose fields are the fields of one row of a table of records, and whose submission is a staged batch of changes to those records applied by one commit. For a batch form the form part SHALL render: the outer form element with the form's id, its data-form attribute and its hx-* attributes as for every form, the whole element being its default target; the staged batch as hidden fields the caller never writes (Binding.Staged: one indexed group per delta carrying its verb, its record key and the row's field values); the caller's body, a fragment the binding carries (Binding.Body), which is the table the caller renders from a template of its own; the form-level error slot; and, when the batch is non-empty or the binding asks for it, the tray as the commit region: one box at the body's width carrying the binding's message fragment (Binding.Message), the declared note field when the declaration has one, the commit with its in-flight indicator, and the way out. The caller's body SHALL render every control through the formField part with the row's key as its instance, so a field renders in the caller's cell with the same markup, label, wrapper id and constraint attributes it has in every family, and SHALL render every row action through the formRowAction part, which emits a button that posts the whole form to the declared path with its verb and its row key, so no template writes a form control or a posting control of its own. The body MAY lay one row's fields out as a dense row of its own beside the table, through the field part's dense option, in which case each field takes the dense family's grid column, the row aligns to the top under one label line, and a field's error renders under its control, as a dense form's do. The invariants SHALL refuse Columns, Wide, MessageAfter and the static control on a batch form. A 200 body and a 422 body of the same batch form SHALL be interchangeable in the same target.

Scenario: The body's controls are the declaration's

  • WHEN a batch form's body renders a row's Limit through the field part with that row's key as the instance
  • THEN the control carries the declaration's label, its constraint attributes and a wrapper id unique to that row, with the same markup a Dense or Stacked rendering of the field would carry

Scenario: A body's own dense row keeps its controls level under a refusal

  • WHEN a batch form's body renders a row's fields as a dense row of its own and that row's Limit is refused
  • THEN the Limit control carries the error's id in its aria-describedby and aria-invalid, the error renders under the Limit control inside its column, only that column grows, and the row's actions stay level with the controls

Scenario: A refused staging re-renders in place

  • WHEN a row action's request is refused
  • THEN the 422 body renders the same outer element, the same staged batch, every open row's submitted values and the field errors on the refused row, and swaps into the target the 200 body would have

Scenario: The tray is absent for an empty batch

  • WHEN a batch form renders with no staged delta and the binding does not ask for the tray
  • THEN no tray, no commit and no way out render, and the body renders at rest

Scenario: A row action posts the whole form

  • WHEN a row's Undo is activated while two other rows are open for editing
  • THEN the request carries the two open rows' values and the whole staged batch, and the response re-renders them

Requirement: A binding names the control that takes focus

A binding MAY name, in Binding.Autofocus, the control that carries autofocus on a successful render: a declared field, with its instance when the form renders rows, a row action by its act and row key, or the tray, whose box renders focusable. When the render is a refusal the first invalid control SHALL carry autofocus and the binding's name SHALL be ignored, as today. When the binding names nothing, no control SHALL carry autofocus on a successful render, as today. At most one control in a rendered form SHALL carry the attribute, and no script SHALL be needed for focus to move, because htmx focuses the first autofocus in swapped content and a browser honours it on a page load.

Scenario: A successful render focuses the named control

  • WHEN a handler renders a form with Binding.Autofocus naming the field limit on instance rule-12
  • THEN that row's Limit control carries autofocus and no other control does

Scenario: A refusal overrides the binding

  • WHEN a handler renders a refusal with Binding.Autofocus naming the tray and an error on a row's Limit
  • THEN that Limit control carries autofocus and the tray's box does not

Requirement: A dense row aligns to the top and keeps its errors under their controls

The form part SHALL render a Dense-family form's row with its columns aligned to the top (row g-2 align-items-start app-form-dense-row), every labelled column a one-line label over its control, so the controls share a line. A field's error SHALL render under its control inside its column, carrying the id the control's aria-describedby names, with is-invalid and aria-invalid="true" on the control, as in every other family; a refused column grows alone. A column with no label of its own, the commit and a checkbox, SHALL carry app-form-unlabeled, which the stylesheet pads by the label line's height from the breakpoint where the row lays out side by side, so it sits on the control line at rest and under a refusal.

Scenario: A refused dense row keeps its commit level with the controls

  • WHEN a sub-record form is refused with an error on one of its controls
  • THEN the error renders under that control inside its column, no error block renders after the row, and the commit sits on the control line