// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package forms import ( "encoding/json" "fmt" "html" "html/template" "sort" "strings" ) // Rendering (design D5, D9, D11, D12; spec form-library "One declaration // renders create, edit and the refused submission"). One declaration, one // render, three inputs: unbound (a create page, defaults only), bound to a // record (an edit form: values, no errors), bound to a submission (a 422: // the raw submitted strings, every field's error, the form-level error). // The create page and the edit form of one entity therefore cannot drift, // because there is one field list and the mode is the only difference // (lessons L§1, L§2; finding FA-12). // // Everything the two parts render is computed here, in Go: ids, the // described-by wiring, the constraint attributes, the option list's // selected entry, the commit row. A template never composes an attribute, // so no caller can render a control without its label or its error slot // (lessons L§9, L§10). // Mode is which of the three inputs a render carries. type Mode string const ( // ModeUnbound is a create page: defaults only, no values, no errors. ModeUnbound Mode = "unbound" // ModeRecord is an edit form bound to a record: the record's values // and no errors. An edit form never opens showing errors (L§6, L§7). ModeRecord Mode = "record" // ModeSubmission is a 422: the raw submitted strings and every error. ModeSubmission Mode = "submission" ) // Binding is one render's input. type Binding struct { // Mode is which of the three inputs this is. Mode Mode // Side is the side being rendered, which decides whether a one-sided // field appears. Empty renders every declared field, which is right // for a form that has only one side (a sub-record panel, a settings // form, a search form). Side Side // Action is the concrete URL the form submits to, with any route // placeholders already filled. Empty falls back to the declaration's // Path (or EditPath on the edit side). Action string // Method overrides the declaration's method for this render. Empty // falls back to the declaration's Method (or EditMethod on the edit // side). Method string // Values are the record's or the submission's values. Values Values // Errors are the submission's refusals; nil in the other two modes. Errors *Errors // Options carry the option sets of the fields whose options are // loaded per request. The same map is passed to ParseWith, so what // the control offered and what the handler accepts are one list. Options map[string][]Option // Slots carry the first content of a field's declared slot region // (the product name's duplicate warning at page load). Slots map[string]template.HTML // Cells carry a Table-family row's middle cells, keyed by field name, // in FormSpec.Columns order. The page pre-renders them because what // they hold is the page's (a source badge, a Not set badge, an // effective value in a ), not the library's (design D21). A // field with no entry renders empty cells; a family other than Table // ignores them. Cells map[string][]template.HTML // ControlFooters carry a Table-family row's own content under its // control, in the same cell, keyed by field name; page-owned for the // same reason Cells is (design D9, typed-config-keys): the integration // settings page's Clear action and Pending restart badge describe the // stored override, not the running value, so they render here rather // than in one of Cells' columns. A field with no entry renders nothing // extra; a family other than Table ignores it. ControlFooters map[string]template.HTML // Instance disambiguates a form rendered more than once on one page: a // form repeated once per record on a listing (the org-type // default-change form, once per type's card). It is suffixed onto // every id the render computes, so a page with several instances // carries no duplicate DOM id; data-form stays the declaration's own // name, so the capture and coverage tooling still see one form. // Wrapper ids stay stable within one instance across its three modes // (design D11), which is what the contract is for. Empty for a form // that renders once. Instance string // Target overrides the declaration's Target for this render, for a // form whose swap target is per-instance (the org-type card's id // carries the type). Empty falls back to the declaration's Target. Target string // WayOut overrides the declaration's WayOut for this render, for a // way out that carries a per-render URL (a preview's Discard re-fetches // the page it sits on). Nil falls back to the declaration's WayOut. WayOut *WayOut // Message is one block of prose rendered inside the section box, above // the fields: a preview's explanation of what it is about to do, which // belongs to the whole form rather than to one field (design D10's // preview idiom), or the lead a stacked form in a panel opens with // (Issue grant's, grant-resumption D21). Empty for a form with nothing // to say beyond its fields. Message template.HTML // HXSelect and HXSelectOOB narrow a Search form's request to one // embedded list panel (operator-list-scale's ListNav.Target/SyncSelect): // HXSelect carves the panel's own markup out of the GET response, // HXSelectOOB refreshes sibling panels from the same response. Both // empty for a standalone list's search, which sets Boosted instead // (form-conventions, "Every mutation form's no-JS behavior is a // recorded decision"). HXSelect string HXSelectOOB string // Body is the caller's own fragment the Rows family renders in place of // a section box: the table of records whose cells hold the // declaration's controls (spec form-library "A batch form renders the // caller's rows and one tray"). A body fragment usually needs the view // it will sit inside, because its cells call FormView.RowField, so the // common order is Bind, render the fragment with the view in scope, set // FormView.Body, render the form part. Setting it here instead is the // same thing for a body that needs nothing from the view. Refused on // any other family. Body template.HTML // Staged is the batch the tray shows, in the order the tray lists it; // the index is the delta's identity in the hidden fields the render // writes and ParseBatch reads back. Refused outside the Rows family. Staged []StagedDelta // Rows are the open editors, keyed by the record key the caller passes // to RowField as the instance. A record with no entry renders at rest, // which is the caller's own markup and none of the library's business. // Refused outside the Rows family. Rows map[string]RowBinding // Autofocus names the control that takes focus on a successful render // (design D9; spec form-library "A binding names the control that takes // focus"). A refusal ignores it and focuses the first invalid control // instead, as every other family does. Autofocus Focus // CommitTarget is the hx-target of the Rows family's commit, which // applies the batch and therefore usually re-renders more than the form // region a row action re-renders. Empty falls back to the form's own // target. CommitTarget string // CommitSwap is the hx-swap of that commit; "outerHTML" when empty, as // it is for every other control of the form. A commit whose target is a // container the page keeps (an operator body whose response is its // contents rather than a wrapper) sets "innerHTML" here, because // replacing the container itself would leave the next request with no // target to swap into. CommitSwap string // Tray renders the tray with no staged delta, for a batch form whose // tray says something at rest. Empty batch and no Tray renders no tray, // no commit and no way out. Tray bool // Boosted renders a Search form with no explicit htmx attributes at // all, relying entirely on the page's own hx-boost inheritance for // both the scripted behaviour and the native no-JS fallback: the right // choice for a standalone list's own search, whose target is the page // itself and not a selector this declaration could name. An embedded // list sets HXSelect/HXSelectOOB instead, never this. Boosted bool } // ConfirmTrigger is a destructive act a batch form's row offers // (FormView.RowConfirm): a button that opens the shared confirm dialog // (shell_confirm_modal.html, confirm-action-modal.js), which posts the act // itself. The dialog commits it, not this form, so the act carries none of // the form's fields. type ConfirmTrigger struct { // Label is the button's word ("Revoke"). Label string // Title, Body and ConfirmLabel are the dialog's title, body and commit. Title string Body string ConfirmLabel string // URL is where the dialog posts, Target its hx-target. URL string Target string // Fields are posted with the act as hidden fields. Fields map[string]string } // confirmAttrs are the attributes that make a button open the shared confirm // dialog for t: the dialog's words, the route it posts and the hidden fields // it posts with, the last as the JSON object the dialog's script reads. Built // here and escaped here, so no template composes one. func confirmAttrs(t ConfirmTrigger) template.HTMLAttr { fields := "{}" if len(t.Fields) > 0 { b, err := json.Marshal(t.Fields) if err != nil { panic(fmt.Sprintf("forms: the confirm trigger %q's fields could not be written: %v", t.Label, err)) } fields = string(b) } attrs := []string{`data-bs-toggle="modal"`, `data-bs-target="#confirmActionModal"`} for _, kv := range [][2]string{ {"data-action-url", t.URL}, {"data-action-method", "post"}, {"data-action-target", t.Target}, {"data-action-title", t.Title}, {"data-action-body", t.Body}, {"data-action-confirm-label", t.ConfirmLabel}, {"data-action-fields", fields}, } { attrs = append(attrs, fmt.Sprintf(`%s="%s"`, kv[0], html.EscapeString(kv[1]))) } return template.HTMLAttr(strings.Join(attrs, " ")) } // StagedDelta is one staged change to one record: what the tray lists, what // the hidden fields carry, and what ParseBatch reads back (design D1's // "Rows family contract", D3). Verb and Key are opaque to the library: it // writes them out, reads them back, and never interprets either. type StagedDelta struct { // Verb is the caller's word for what this delta does ("add", "edit", // "remove"). Verb string // Key is the record key: a record's identifier, or the caller's own // name for a record that does not exist yet. Key string // Values are the delta's field values, keyed by the declaration's bare // field names. A field the tray renders as a control (PlaceDelta) // renders from here; every other declared field present here rides as a // hidden input, so the whole batch survives every request. Values Values // Line is the delta's narrative on its tray line, rendered by the // caller: the library knows verbs and keys, not what a change means. Line template.HTML // Error is a refusal on this delta, rendered on its line with the rest // of the batch intact (design D3: every request re-validates the batch). Error string // Options are this delta's runtime option sets, keyed by field name, // for a control whose choices depend on the record (the rule change's // policy select offers a dormant stored value as its own option). // A field with no entry here falls back to Binding.Options. Options map[string][]Option } // RowBinding is one open editor: the values its controls carry and the // refusals on them, keyed by the declaration's bare field names. type RowBinding struct { Values Values Errors *Errors // Options are this row's runtime option sets, keyed by field name, for a // control whose choices depend on the record (the rule row's policy // select offers a dormant stored value as its own option). A field with // no entry here falls back to Binding.Options. Options map[string][]Option // Action is the concrete URL this row's own commit posts to, the // declaration's CommitAction filled in for the record, on a form that // commits row by row (FormSpec.RowCommit). Required there, and refused // on a form that applies a staged batch, whose commit is the tray's. Action string } // Focus names the control a successful render gives focus to (design D9). // The zero value focuses nothing, which is what every render did before // this existed. type Focus struct { // Field is the declared field's name. Field string // Instance is the row key the field renders under, or "staged." for // a delta's own control. Empty names a field the library renders once // (a tray field). Instance string // Tray focuses the tray's message region instead of a control; the // region renders tabindex="-1" so it can take focus at all. Tray bool // Act and Key name a posting control instead of a control that carries // a value: the row's own Edit after a Cancel, say, so focus returns to // the button the person pressed to open the editor rather than falling // to the top of the swapped region. Act string Key string } // HiddenInput is one name and value the Rows family writes out so a request // carries the whole page state: the open editors' keys and the staged // batch. The part writes them from here and composes no name of its own. type HiddenInput struct { Name string Value string } // RowActionView is one control of a batch form: a row's Edit or Remove, a // delta's Undo, the commit, the way out, a row's own commit on a form that // commits row by row, and a row's destructive act. Every posting one of them // posts the whole form, so each request carries every open editor's values // and the whole staged batch (design D2); the destructive act opens the // shared confirm dialog, which posts the act itself. type RowActionView struct { Act string Key string Label string Variant string // Classes are the button's classes, decided by the variant. Classes string // Indicator renders the in-flight spinner inside the button; a commit // carries one, a row action does not. Indicator bool // Opener renders the shared opener glyph before the label and reports // aria-expanded, for a control that owns a panel (FormView.RowOpener); // Expanded is that attribute's value. Opener bool Expanded bool // HX carries the control's behaviour: hx-post, hx-vals, hx-include, // hx-target and hx-swap on a posting control, or the confirm dialog's // data-* attributes on a destructive act, built here so no template // composes a control by hand. HX template.HTMLAttr } // DeltaView is one staged delta's tray line. type DeltaView struct { Index int Verb string Key string Line template.HTML Error string ErrorID string // Fields are the delta's own controls (Placement PlaceDelta), named // "staged..". Fields []FieldView // Undo drops this delta from the batch. Undo RowActionView } // OptionView is one rendered option. type OptionView struct { Value string Label string Disabled bool Selected bool // ShowsHint renders data-shows-hint on the option. ShowsHint bool // Hint renders data-hint on the option: the line it shows in place of the // declared hint. It is set only for an option that carries ShowsHint. Hint string } // RadioView is one rendered radio in a group. type RadioView struct { ID string Value string Label string Checked bool Attrs template.HTMLAttr } // FieldView is one rendered field: everything the formField part writes. type FieldView struct { Name string Label string Control Control // InputType is the type attribute for the controls that are inputs. InputType string // LabelHidden renders the label visually hidden, for a dense row or a // bar with no room for it. The label is never absent (finding FA-51). LabelHidden bool // LabelNarrowOnly renders the label d-lg-none: a Rows body's editing // cells stack below lg, where the column headers that label them are // off to the side, and hide their own labels again from lg up where the // headers do the work (design D11). Set by the label-hidden option of // FormView.RowField, and false everywhere else. LabelNarrowOnly bool // LabelAbove renders a checkbox's label above the box, as every other // field's label sits, with its help icon after it and nothing beside // the box (grant-resumption D19). Set for a checkbox in the Stacked and // Dense families; a Rows cell keeps its label beside the box, and the // Table family places the label in its own column. LabelAbove bool // Optional renders the "(optional)" suffix after the label. A bar and // a table never do: their fields are optional by nature, so the marker // states nothing (design D20, D21). Optional bool // WrapperID is the stable per-field id, "form--", so // inline validation can be added later without rewriting the DOM // (lessons L§18, L§19). WrapperID string // ControlID is what the label points at. ControlID string // ColClass is the dense row's grid column; empty in every other family. // Small renders the -sm control a dense row and a table row use. ColClass string Small bool Value string // Checked is a checkbox's state; CheckboxValue is what it submits. Checked bool CheckboxValue string Placeholder string Rows int Options []OptionView Radios []RadioView Help HelpIcon Hint string HintID string // HintHidden renders the hint with the hidden attribute: a // HintOnChoice field whose chosen option does not show it. HintHidden bool // HintDefault is the declared hint of a HintOnChoice field one of whose // options carries its own hint, rendered as data-hint-default so // hint-on-choice.js can restore it; empty otherwise. HintDefault string Error string ErrorID string // Cells are the Table family's middle cells for this row, in the // declaration's Columns order, pre-rendered by the page // (Binding.Cells). Empty in every other family. Cells []template.HTML // ControlFooter is the Table family's own content under this row's // control, in the control's own cell, pre-rendered by the page // (Binding.ControlFooters). Empty in every other family, and for a // field with nothing to show there. ControlFooter template.HTML // Notice is the declared line under the control for a one-sidedness a // person would be surprised by, and NoticeID is what the control's // aria-describedby names it by. A one-sided field says nothing about // its side unless it declares a Notice. Notice string NoticeID string // SlotID and SlotHTML are the field's declared live region. SlotID string SlotHTML template.HTML // Attrs carries every attribute derived from the declaration: // required, maxlength, min, max, pattern, inputmode, autocomplete, // aria-describedby, aria-invalid, autofocus, and the data-* and hx-* // escape hatch. Built here so a template cannot write one by hand and // the browser's constraints and the server's rules cannot differ. Attrs template.HTMLAttr } // Invalid reports whether the field was refused. func (f FieldView) Invalid() bool { return f.Error != "" } // HasErrors reports whether this render carries any refusal: a field error // or the form-level error. A page uses it to force a collapsed sub-record // panel open, so a refused submission's errors are never hidden behind a // closed panel (design D9). func (v FormView) HasErrors() bool { if v.FormError != "" { return true } for _, f := range v.Fields { if f.Error != "" { return true } } // A batch form's refusals mostly live where its controls do: on an open // editor's field or on a staged delta's line, neither of which is in // Fields (the body renders the one, the tray the other). for _, d := range v.Deltas { if d.Error != "" { return true } } for _, rb := range v.binding.Rows { if rb.Errors.Any() { return true } } return false } // WayOutView is the rendered way out. type WayOutView struct { Kind WayOutKind Label string URL string PanelID string HXTarget string HXSwap string } // CommitLinkView is a commit that is a link out of the console // (FormView.CommitLink). type CommitLinkView struct { Label string URL string } // Shown reports whether the form offers a way out at all. func (w WayOutView) Shown() bool { return w.Kind != WayOutNone && w.Label != "" } // DisabledControlView is the shape the shared disabledControl part reads // (internal/server.DisabledControl carries the same fields; internal/forms // cannot import internal/server, which imports it, so this is the same // shape declared on this side of the boundary). Set on FormView.CommitState // to gate a preview's commit on a precondition the declaration cannot check // itself (design D10; org-types "The commit waits for dispositions"). type DisabledControlView struct { ID string Label string Classes string Reason string Visible bool } // FormView is the whole rendered form: what the form part writes. type FormView struct { Name string // ID is the outer element's id, "form-". ID string Kind Kind // Dense, Bar, Table and Rows report the layout family, so the part can // pick its shape; all four false is Stacked (design D10, D20, D21, and // D1 of staged-rule-changes). Dense bool Bar bool Table bool Rows bool // Wide drops the bar's width cap (FormSpec.Wide). Bar only. Wide bool // Columns, LabelColumn and ControlColumn are the Table family's // headings: the label column first, the declaration's Columns next, // the control column last. Columns []string LabelColumn string ControlColumn string Fields []FieldView Commit string WayOut WayOutView // FormError is the refusal that belongs to no field. Its slot renders // on every form and is empty when unused, so a refusal without a // field name always has somewhere to land (findings FA-22, FA-26). FormError string FormErrorID string // Native reports a search form, which renders a real method and // action and keeps a true no-JavaScript fallback (spec // form-conventions "Every mutation form's no-JS behavior is a // recorded decision"). Native bool // Method and Action are the native attributes, set when Native. Method string Action string // NoValidate emits the form's `novalidate` attribute, so the refusal a // person sees is the server's, rendered under the control, and not // the browser's transient bubble (maintainer, 2026-09-03: pressing // Save on a cleared name produced no feedback at all). NoValidate bool // Box names the section box the part renders around the form (spec // form-library, "The form part owns the container"): "card" for a // create page or an edit form, "alert" for the preview idiom's muted // box (design D10), and empty for a dense sub-record row, whose panel // is already its box, for a bar, which sits in a list's header, and // for a settings table, which is its own shape. A stacked box is capped at the // readable measure and hugs its fields, so the box ends where the // fields end rather than running the width of the content column // (maintainer, 2026-09-04: "Hugging definitely"). Box string // HX carries the htmx attributes: the verb and its path, the target, // the swap, and hx-disable on the commit so a double submit is // impossible (finding FA-20). HX template.HTMLAttr // Target is the resolved hx-target (the declaration's, or Binding. // Target), for a WayOutDiscard button to reuse as its own hx-target // and hx-select: it re-fetches the page that owns this target and // carves the same region back out. Target string // CommitState, when non-nil, replaces the ordinary submit button with // the shared disabled-control idiom: a preview whose commit waits on a // precondition the declaration cannot itself check (design D10; // org-types "The commit waits for dispositions"). Nil renders the // ordinary enabled commit. Its Classes are the render's, never the // caller's: the part reads them through CommitControl, which overwrites // whatever was set with the classes the enabled commit would carry, so // a disabled commit and the way out beside it are the same height in // every state (design D23). CommitState *DisabledControlView // CommitLink, when non-nil, replaces the ordinary submit button with a // link that leaves the console, at the weight and size the submit would // have: a preview whose next step is a page another service hosts (a // payment the member approves on Stripe's invoice page). It opens in a // new tab, so the console stays where the member left it. Nil renders // the ordinary commit; CommitState, when also set, wins. CommitLink *CommitLinkView // CommitDescribedBy is the id of an element outside the form that // states a fact about what committing does, such as who bills the // charge. The part writes it as aria-describedby on the enabled submit // button and on a CommitLink, never on a CommitState, whose disabled // control is described by its own reason. Empty writes nothing. CommitDescribedBy string // CommitHidden renders no commit row at all, neither the commit nor // the way out: a Preview form at rest has nothing to apply and nothing // to discard, so it shows only its fields until a choice produces a // message (design D23; maintainer, 2026-09-04: Discard with nothing to // discard). CommitHidden bool // WayOutOnly renders the commit row with the way out alone and no // commit, for a Preview form whose change cannot be applied: the act // is certain to refuse it, or the preview could not read its outcome. // It wins over CommitState and CommitLink; CommitHidden wins over it, // because that suppresses the whole row. WayOutOnly bool // Message is prose rendered inside the section box (Binding.Message's // doc), before the fields, or after the field MessageAfter names. Message template.HTML // MessageAfter is the field name the message follows // (FormSpec.MessageAfter). Empty puts the message before the fields. MessageAfter string // MessageID is the message region's id. Every other kind renders the // region only when Message is set; a Confirm form renders it always, // empty at rest (app.css collapses an empty one), because // confirm-action-modal.js writes each trigger's body text into it after // the page has already loaded (design D4's Confirm kind; task 5.6). MessageID string // Danger reports a Confirm form's commit, which renders btn-danger by // default rather than btn-primary (design D12: "A destructive commit // renders btn-danger only inside the confirm modal"). The JS // (confirm-action-modal.js) still reassigns the class per trigger on // every open, as it does today; this is only the rendered default. Danger bool // The Rows family's own view (design D1's "Rows family contract"). Body // is the caller's table, settable after Bind because the fragment that // produces it calls RowField on this view; everything else the library // builds. Body template.HTML // Hiddens are the open editors' keys and the staged batch, as hidden // inputs the part writes and ParseBatch reads back. Hiddens []HiddenInput // Deltas are the tray's lines, one per staged delta. Deltas []DeltaView // Tray reports whether the tray renders at all: an empty batch with no // Binding.Tray renders no tray, no commit and no way out, and a form // that commits row by row never renders one. Tray bool // TrayFocus renders the tray's message region focusable and focused, // for the render that follows a staging or an undo (design D9). TrayFocus bool // Apply and Discard are the tray's commit and way out, emitted through // the same row-action part as every other posting control of the form. Apply RowActionView Discard RowActionView // spec and binding are what RowField and RowAction read; focus is the // one control this render gives autofocus to, resolved once so at most // one control can carry it however many rows the body renders. spec FormSpec binding Binding formID string focus focusTarget } // focusTarget is the resolved answer to "which control carries autofocus": // a field under an instance, the tray's message region, or nothing. type focusTarget struct { field string instance string act string key string action bool tray bool set bool } // matches reports whether this field, under this instance, is the one. func (t focusTarget) matches(instance, field string) bool { return t.set && !t.tray && !t.action && t.instance == instance && t.field == field } // matchesAction reports whether this posting control is the one. func (t focusTarget) matchesAction(act, key string) bool { return t.set && t.action && t.act == act && t.key == key } // CommitClasses are the commit button's classes: filled by default, // btn-danger inside the confirm modal, outline in a bar (a search is not // a commit, so every bar's button is outline, the landing surface's "Look // up" included; design D20), and btn-sm only in a dense row. func (v FormView) CommitClasses() string { classes := "btn btn-primary" switch { case v.Danger: classes = "btn btn-danger" case v.Bar: classes = "btn btn-outline-secondary" } if v.Dense { classes += " btn-sm" } return classes } // CommitControl is the disabled commit the part renders in place of the // submit button, with the classes the enabled commit would have carried // (design D23): the caller declares the id, the label and the reason; the // weight and the size are the family's, so Apply change and Discard are // the same height whether the commit is live or waiting. func (v FormView) CommitControl() *DisabledControlView { if v.CommitState == nil { return nil } control := *v.CommitState control.Classes = v.CommitClasses() return &control } // Render builds the view the two parts write, from one declaration and // one binding. func Render(spec FormSpec, b Binding) FormView { checkRowsBinding(spec, b) side := resolveSide(spec, b) // formID disambiguates a form rendered more than once on one page // (Binding.Instance); data-form stays spec.Name so the capture and // coverage tooling see one declaration regardless of how many // instances render. formID := spec.Name if b.Instance != "" { formID = formID + "-" + b.Instance } target := spec.target() if b.Target != "" { target = b.Target } view := newFormView(spec, b, side, formID, target) view.HX = formHX(spec.Kind, side.method, side.action, target, spec.swap(), b) view.spec = spec view.binding = b view.formID = formID if view.Rows { view.focus = resolveRowsFocus(spec, b) buildRows(&view, spec, b, side.action, target) } renderFields(&view, spec, b, side.side) return view } // renderedSide is what the binding's side makes of the declaration: the // route, the kind, the commit and the way out the view renders. type renderedSide struct { side Side method, action string kind Kind commit string wayOut WayOutView } // resolveSide applies the binding's side and overrides to the declaration: // the method and action, the kind the view renders, the commit label and the // way out. On the edit side a create form renders as an edit form, takes the // record-side commit label when one is declared, and has no way out. func resolveSide(spec FormSpec, b Binding) renderedSide { s := renderedSide{side: b.Side, method: spec.Method, action: spec.Path, kind: spec.Kind, commit: spec.Commit} if s.side == EditOnly && spec.EditPath != "" { s.action = spec.EditPath if spec.EditMethod != "" { s.method = spec.EditMethod } } if b.Method != "" { s.method = b.Method } if b.Action != "" { s.action = b.Action } // One declaration serves a record's create page and its edit form, so // the rendered kind follows the side: the capture utility and the // sheet then name what is on screen rather than what the declaration // was filed under. declaredWayOut := spec.WayOut if b.WayOut != nil { declaredWayOut = *b.WayOut } s.wayOut = WayOutView(declaredWayOut) if s.side == EditOnly { if s.kind == KindCreate { s.kind = KindEdit } if spec.CommitEdit != "" { s.commit = spec.CommitEdit } s.wayOut = WayOutView{} } return s } // newFormView is the view before its fields: the form's id with its // instance, the target, the family booleans, the table headings' defaults, // the preview at rest and the confirm's message id. func newFormView(spec FormSpec, b Binding, side renderedSide, formID, target string) FormView { view := FormView{ Name: spec.Name, ID: "form-" + formID, Kind: side.kind, Dense: spec.Family == Dense, Bar: spec.Family == Bar, Table: spec.Family == Table, Rows: spec.Family == Rows, Wide: spec.Wide, Columns: spec.Columns, LabelColumn: spec.LabelColumn, ControlColumn: spec.ControlColumn, Commit: side.commit, WayOut: side.wayOut, FormError: b.Errors.FormError(), FormErrorID: "form-" + formID + "-error", Method: side.method, Action: side.action, NoValidate: side.kind.ServerValidated(), Box: boxFor(spec.Family, side.kind, side.wayOut.Kind), Target: target, Message: b.Message, MessageAfter: spec.MessageAfter, Danger: spec.Kind == KindConfirm, } if view.Table && view.LabelColumn == "" { view.LabelColumn = "Field" } if view.Table && view.ControlColumn == "" { view.ControlColumn = "Value" } // A preview at rest has nothing to apply and nothing to discard, so it // renders no commit row at all: the org-type card at rest is its // "New default" select alone, and choosing a candidate is what // brings Apply change and Discard (design D23). if spec.Kind == KindPreview && b.Message == "" { view.CommitHidden = true } if spec.Kind == KindConfirm { view.MessageID = "form-" + formID + "-message" } view.Native = spec.Kind == KindSearch return view } // formHX is the form element's htmx attributes: a search pushes its URL // and may select from the answer; every other kind disables its commit and // shows its indicator while the request runs. func formHX(kind Kind, method, action, target, swap string, b Binding) template.HTMLAttr { if kind != KindSearch { return template.HTMLAttr(fmt.Sprintf( `%s=%q hx-target=%q hx-swap=%q hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator"`, hxVerb(method), action, target, swap)) } if b.Boosted { return "" } hx := fmt.Sprintf(`hx-get=%q hx-target=%q hx-swap=%q hx-push-url="true"`, action, target, swap) if b.HXSelect != "" { hx += fmt.Sprintf(` hx-select=%q`, b.HXSelect) } if b.HXSelectOOB != "" { hx += fmt.Sprintf(` hx-select-oob=%q`, b.HXSelectOOB) } return template.HTMLAttr(hx) } // renderFields appends the fields the library itself writes, each one // libraryRenders keeps: in a Rows form the tray's own, autofocused where the // binding's focus names one; in a table one row per field with one cell per // declared column; and outside a Rows form, on a submission, the first // refused field autofocused. func renderFields(view *FormView, spec FormSpec, b Binding, side Side) { autofocused := b.Mode != ModeSubmission for _, f := range spec.Fields { if !libraryRenders(view.Rows, f, side, b.Values) { continue } fv := renderField(view.formID, spec.Family, f, b) switch { case view.Rows: if view.focus.matches("", f.Name) { fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`) } view.Fields = append(view.Fields, fv) continue case view.Table: fv.Cells = padCells(fv.Cells, len(spec.Columns)) } if !autofocused && fv.Invalid() { fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`) autofocused = true } view.Fields = append(view.Fields, fv) } } // libraryRenders reports whether the form part itself writes f: a one-sided // field renders only when the binding names its side or names no side at // all, a Rows form's body renders a row's // fields and its tray a delta's (Fields is, in that family, the tray's own // fields alone), and a ShowIf field renders only when its condition holds. func libraryRenders(rows bool, f Field, side Side, values Values) bool { if side != BothSides && !f.RendersOn(side) { return false } if rows && f.Placement != PlaceTray { return false } return f.Shown(values) } // padCells gives a table row exactly one cell per declared column, so a // page that supplied too few (or none at all) still renders a table whose // rows line up with its headings. func padCells(cells []template.HTML, columns int) []template.HTML { for len(cells) < columns { cells = append(cells, "") } return cells[:columns] } // boxFor decides the section box the part renders around the form. A // dense row's box is the panel its page already opened, a bar's is the // list header it sits in, and a table is its own shape; a stacked form // owns its own, so a page writes no card of its own and every stacked // form is the same width on every surface. A preview whose way out closes // the modal sits in the modal, which is its box. func boxFor(family Family, kind Kind, wayOut WayOutKind) string { if family != Stacked { return "" } if kind == KindPreview && wayOut == WayOutDismissModal { return "" } switch kind { case KindPreview: return "alert" case KindConfirm: return "" } return "card" } // hxVerb maps an HTTP method to its htmx attribute. func hxVerb(method string) string { switch strings.ToUpper(method) { case "GET": return "hx-get" case "PUT": return "hx-put" case "PATCH": return "hx-patch" case "DELETE": return "hx-delete" } return "hx-post" } // denseColClass is a field's grid column in a dense row (design D2): a // hidden field takes no column, because the template renders it with no // wrapper at all; a checkbox, radio or static field takes its own content's // width; every other control takes a fraction of the row from its rung and // its declared Width. func denseColClass(f Field) string { switch f.Control { case Hidden: return "" case Checkbox, Radio, Static: return "col-12 col-xl-auto" } fraction, _ := FieldFraction(f.Control, f.Width) return fraction.ColClass() } // renderField builds one field's view. formID is spec.Name, suffixed with // Binding.Instance when the form renders more than once on one page; // family decides the column class, the control size, whether a hidden // label is honoured, whether the "(optional)" marker renders at all, and // where the error goes. func renderField(formID string, family Family, f Field, b Binding) FieldView { return renderFieldAs(formID, "", f.Name, family, f, b) } // renderFieldAs is renderField with the two things a Rows form changes: the // suffix that makes a wrapper id unique per row or per delta, and the // submitted name, which carries the row or the delta it belongs to. Every // id the view computes derives from the wrapper, so one suffix moves the // control's id, its hint's, its notice's, its error's, its slot's and each // radio's together, and the described-by wiring follows. func renderFieldAs(formID, suffix, name string, family Family, f Field, b Binding) FieldView { wrapper := "form-" + formID + "-" + f.Name + suffix fv := fieldBase(wrapper, name, family, f, b) bindFieldValue(&fv, f, b) if b.Errors != nil { fv.Error = b.Errors.Get(f.Name) } opts := f.Options if runtime, ok := b.Options[f.Name]; ok { opts = runtime } switch f.Control { case Select: fv.Options = selectOptions(opts, fv.Value, b.Mode != ModeUnbound && b.Values.Present(f.Name)) case Radio: fv.Radios = radioOptions(opts, wrapper, fv.Value) } if f.HintOnChoice { fv.Hint = choiceHint(f.Hint, fv.Options) fv.HintHidden = true for _, o := range fv.Options { if o.Selected && o.ShowsHint { fv.HintHidden = false } if o.Hint != "" { fv.HintDefault = f.Hint } } } else { for i := range fv.Options { fv.Options[i].ShowsHint = false fv.Options[i].Hint = "" } } describedBy := describeField(&fv, wrapper, f, b) fv.Attrs = template.HTMLAttr(strings.Join(fieldAttrs(f, fv, opts, describedBy), " ")) return fv } // fieldBase is the part of a field's view the declaration and the family // decide, before any value is bound. func fieldBase(wrapper, name string, family Family, f Field, b Binding) FieldView { dense := family == Dense fv := FieldView{ Name: name, Label: f.Label, Control: f.Control, InputType: f.Control.InputType(), // A bar and a dense row have no room for a visible label; a // stacked form and a table always do, so a declaration that asks // to hide one there is ignored rather than obeyed (form- // conventions, "Its input SHALL carry a label, visually hidden // where the row has no room"). LabelHidden: (dense || family == Bar) && f.HideLabel, // A checkbox's label sits above the box wherever labels sit above // controls (grant-resumption D19); the Rows family's in-cell boxes // keep theirs beside, and the Table family places its own. LabelAbove: f.Control == Checkbox && (family == Stacked || dense), // Neither a bar nor a table renders "(optional)": a bar's one // field is optional by nature and its label is hidden anyway, and // every setting is optional by nature, so the marker on every row // said nothing (maintainer, 2026-09-04: "kinda useless"; design // D20, D21). Optional: f.Optional && family != Bar && family != Table, WrapperID: wrapper, ControlID: wrapper + "-control", Placeholder: f.Placeholder, Rows: f.Rows, Help: f.Help, Hint: f.Hint, Notice: f.Notice, CheckboxValue: f.CheckboxValue(), } if family == Table { fv.Cells = b.Cells[f.Name] fv.ControlFooter = b.ControlFooters[f.Name] } if fv.Rows == 0 { fv.Rows = 3 } if dense { fv.ColClass = denseColClass(f) } // A table row is one line tall; small controls fit it and full-size // ones would set every row's height from the control alone. A Rows // body's controls sit in the caller's table cells, where the same // holds, and its tray's fields sit beside small buttons. fv.Small = dense || family == Table || family == Rows return fv } // bindFieldValue carries the binding's value into the view. An unbound // render carries none; the other two carry the record's or the // submission's, and a refused field keeps its raw text so nothing an // operator typed is thrown away (finding FA-23). A Hidden field is the // exception: it holds what the handler derived for this render, never a // person's input, and a ShowIf that reads it was already decided from the // bound value above. Rendering it empty would submit a marker the parse // then contradicts, dropping the very field this render showed (the site // create form's custom-domain box). A static row states a fact rather than // carrying one: its text is the declaration's, never the binding's. func bindFieldValue(fv *FieldView, f Field, b Binding) { if b.Mode != ModeUnbound || f.Control == Hidden { fv.Value = b.Values.Raw(f.Name) fv.Checked = f.Control == Checkbox && fv.Value == f.CheckboxValue() } if f.Control == Static { fv.Value = f.Value } } // selectOptions marks the chosen option. present distinguishes "bound to // an explicit value" (Values.Set was called, even with "") from "nothing // chosen yet": a select can offer more than one empty-valued option at // once (a disabled "Choose a ..." placeholder alongside an enabled "None" // that deliberately clears the column), and the value alone cannot tell // them apart. With nothing chosen the browser selects the first option, so // marking it keeps the markup honest about what will be submitted (design // D14). func selectOptions(opts []Option, value string, present bool) []OptionView { var out []OptionView chosen := false for _, o := range opts { selected := present && o.Value == value chosen = chosen || selected v := OptionView{Value: o.Value, Label: o.Label, Disabled: o.Disabled, Selected: selected, ShowsHint: o.ShowsHint} if o.ShowsHint { v.Hint = o.Hint } out = append(out, v) } if !chosen && len(out) > 0 && !present { out[0].Selected = true } return out } // radioOptions is one radio per option, each with its own id. func radioOptions(opts []Option, wrapper, value string) []RadioView { var out []RadioView for i, o := range opts { out = append(out, RadioView{ID: fmt.Sprintf("%s-%d", wrapper, i), Value: o.Value, Label: o.Label, Checked: o.Value == value}) } return out } // choiceHint is a HintOnChoice field's hint: the chosen option's own hint // when it shows one, the declared hint when another option the select offers // shows it, and "" when none does, so the field renders no line. func choiceHint(hint string, opts []OptionView) string { shown := false for _, o := range opts { if o.Selected && o.ShowsHint && o.Hint != "" { return o.Hint } shown = shown || o.ShowsHint } if shown { return hint } return "" } // describeField sets the ids of the lines under a control and returns them // in described-by order: a hint that shows keeps its id and stays visible // beside the error (a hint that waits for a choice is named only while it // shows), and the error carries its own id, so a screen reader hears why a // control was refused and not only that it was (findings FA-48, FA-49). func describeField(fv *FieldView, wrapper string, f Field, b Binding) []string { var describedBy []string if fv.Hint != "" { fv.HintID = wrapper + "-hint" if !fv.HintHidden { describedBy = append(describedBy, fv.HintID) } } if fv.Notice != "" { fv.NoticeID = wrapper + "-notice" describedBy = append(describedBy, fv.NoticeID) } if fv.Error != "" { fv.ErrorID = wrapper + "-error" describedBy = append(describedBy, fv.ErrorID) } if f.Slot != "" { fv.SlotID = wrapper + "-" + f.Slot fv.SlotHTML = b.Slots[f.Name] describedBy = append(describedBy, fv.SlotID) } return describedBy } // fieldAttrs builds the attribute list the control carries, in one place, // from the declaration and from nowhere else (spec form-library // "Constraint attributes derive from the declaration"). func fieldAttrs(f Field, fv FieldView, opts []Option, describedBy []string) []string { var attrs []string add := func(key, value string) { attrs = append(attrs, fmt.Sprintf(`%s="%s"`, key, html.EscapeString(value))) } switch f.Control { case Checkbox, Hidden, Static: // A checkbox is never required: its absence is false, and the // invariants refuse the combination. A hidden field carries no // constraint the browser could enforce, and a static row carries // no control at all. case Select: // A select's requiredness is carried by its option set: an empty // value is submittable exactly when an option offers one, so the // attribute follows the same fact the server checks. if !optionExists(opts, "") { attrs = append(attrs, "required") } case Radio: // A radio group has no "choose nothing" member the way a select's // enabled empty option does — there is no convention for an empty // radio button — so its requiredness is the field's own Optional // flag, like every other control (parse.go mirrors this). if !f.Optional { attrs = append(attrs, "required") } default: if !f.Optional { attrs = append(attrs, "required") } if f.MaxLen > 0 { add("maxlength", fmt.Sprint(f.MaxLen)) } if f.Min != "" { add("min", f.Min) } if f.Max != "" { add("max", f.Max) } if f.Step != "" { add("step", f.Step) } if f.Pattern != "" { add("pattern", f.Pattern) } if f.Inputmode != "" { add("inputmode", f.Inputmode) } } if f.Autocomplete != "" && f.Control != Checkbox && f.Control != Hidden && f.Control != Static { add("autocomplete", f.Autocomplete) } if f.HintOnChoice && fv.HintID != "" { add("data-hint-toggle", fv.HintID) } if len(describedBy) > 0 { add("aria-describedby", strings.Join(describedBy, " ")) } if fv.Error != "" { add("aria-invalid", "true") } // The escape hatch, last, in a stable order so the rendered markup is // the same on every run (the capture utility compares pixels). if len(f.Attrs) > 0 { keys := make([]string, 0, len(f.Attrs)) for k := range f.Attrs { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { add(k, f.Attrs[k]) } } return attrs } // The Rows family (design D1's "Rows family contract"; spec form-library "A // batch form renders the caller's rows and one tray"). The library owns the // form element, the hidden page state, the tray and every posting control; // the caller owns the table between them and renders each cell's control // through RowField and each row's actions through RowAction, so a batch // form's markup is still the declaration's everywhere a control appears. // checkRowsBinding refuses a binding that carries the Rows family's own // input on a form of another family. It panics rather than rendering // something half right: a body, a batch or an open editor on a stacked form // is a programming error, caught the first time the page renders, the way // two forms registering under one name are caught at boot. func checkRowsBinding(spec FormSpec, b Binding) { if spec.Family == Rows { checkRowCommitBinding(spec, b) return } switch { case b.Body != "": panic(fmt.Sprintf("forms: %s is a %s form and its binding carries a Body; a body fragment belongs to the rows family", spec.Name, spec.Family)) case len(b.Staged) > 0: panic(fmt.Sprintf("forms: %s is a %s form and its binding carries Staged; a staged batch belongs to the rows family", spec.Name, spec.Family)) case len(b.Rows) > 0: panic(fmt.Sprintf("forms: %s is a %s form and its binding carries Rows; open editors belong to the rows family", spec.Name, spec.Family)) } } // checkRowCommitBinding holds a batch form's binding to the way the form // commits (FormSpec.RowCommit). A form that commits row by row stages // nothing, has no tray, edits one row at a time and needs that row's own // commit URL; a form that applies a staged batch commits from its tray, so // a row's own commit URL means nothing there. Like checkRowsBinding it // panics, because each of these is a programming error the first render // catches. func checkRowCommitBinding(spec FormSpec, b Binding) { if !spec.RowCommit { for key, rb := range b.Rows { if rb.Action != "" { panic(fmt.Sprintf("forms: %s applies a staged batch and its row %q carries an Action; a row's own commit belongs to a form that commits row by row", spec.Name, key)) } } return } switch { case len(b.Staged) > 0: panic(fmt.Sprintf("forms: %s commits row by row and its binding carries Staged; such a form stages nothing", spec.Name)) case b.Tray: panic(fmt.Sprintf("forms: %s commits row by row and its binding asks for the tray; such a form has none", spec.Name)) case len(b.Rows) > 1: panic(fmt.Sprintf("forms: %s commits row by row and its binding opens %d rows; one row edits at a time", spec.Name, len(b.Rows))) } for key, rb := range b.Rows { if rb.Action == "" { panic(fmt.Sprintf("forms: %s commits row by row and its open row %q carries no Action for its commit", spec.Name, key)) } } } // rowsRefused reports whether this render is a refusal. A batch form's // refusals land in three places, because its state does: the form level and // its tray fields on Binding.Errors, an open editor's fields on its own // RowBinding, and a delta's on its line. func rowsRefused(b Binding) bool { if b.Errors.Any() { return true } for _, rb := range b.Rows { if rb.Errors.Any() { return true } } for _, d := range b.Staged { if d.Error != "" { return true } } return false } // resolveRowsFocus decides the one control that carries autofocus. A // refusal focuses the first invalid control and ignores what the binding // asked for, as every other family does; a successful render honours the // binding; neither one focuses anything it was not told to (spec // form-library "A binding names the control that takes focus"). // // "First" is the open editors in key order, each read in the declaration's // field order, then the tray's own fields: a map has no order of its own, so // the render picks one and keeps it, and the same refusal focuses the same // control on every run. func resolveRowsFocus(spec FormSpec, b Binding) focusTarget { if rowsRefused(b) { for _, instance := range sortedRowKeys(b.Rows) { rb := b.Rows[instance] for _, f := range spec.Fields { if f.Placement != PlaceRow || !f.Shown(rb.Values) { continue } if rb.Errors.Get(f.Name) != "" { return focusTarget{field: f.Name, instance: instance, set: true} } } } for _, f := range spec.Fields { if f.Placement == PlaceTray && b.Errors.Get(f.Name) != "" { return focusTarget{field: f.Name, set: true} } } return focusTarget{} } if b.Autofocus.Tray { return focusTarget{tray: true, set: true} } if b.Autofocus.Act != "" { return focusTarget{act: b.Autofocus.Act, key: b.Autofocus.Key, action: true, set: true} } if b.Autofocus.Field != "" { return focusTarget{field: b.Autofocus.Field, instance: b.Autofocus.Instance, set: true} } return focusTarget{} } // sortedRowKeys orders the open editors, so the hidden inputs, the focus // rule and the tests all read one order rather than the map's. func sortedRowKeys(rows map[string]RowBinding) []string { keys := make([]string, 0, len(rows)) for k := range rows { keys = append(keys, k) } sort.Strings(keys) return keys } // stagedPrefix is a delta's name and instance prefix. The index is the // delta's identity across a request: the render writes it, ParseBatch reads // it back, and nothing else numbers a batch. func stagedPrefix(i int) string { return fmt.Sprintf("staged.%d", i) } // buildRows fills the view's Rows-family parts: the hidden page state, the // tray's lines, and the three controls the tray carries. func buildRows(view *FormView, spec FormSpec, b Binding, action, target string) { view.Body = b.Body // Every posting control here is a