// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo // Package forms is the console's form library (spec form-library; design // D1 to D14 of the forms-library change). Every form on the member and // operator surfaces, and on an integration-owned page, is one declared // value: a FormSpec with a name, a kind, a layout family, a method, a // path, an ordered list of Field values, a commit label and a way out. // One part renders the declaration, one Parse reads a request through it, // and one registry indexes every declaration so tests can hold the whole // console to the same rules. // // The package sits below internal/server and below the integrations' web // packages so both can import it without a cycle; it knows nothing about // handlers, databases or the domain. Each form is declared in the package // that serves it, in a *_forms.go file beside the handler, the way // anatomy.go builds page headers per page, and registers itself with // Register. The registry is the index, not the home. // // The option set is small on purpose (Primer's side of the tradeoff): // accessible and consistent forms by default, customisation limited so a // caller cannot render a control without its label, its hint wiring or its // error slot. A form that cannot be expressed here 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, which cannot override an // attribute the part owns. package forms // Control is the closed set of controls the library renders and reads. A // form that needs a control this set lacks needs a change to the library, // with its rendering and its reading added together, not an improvised // template (lessons L§12, L§26). type Control string const ( Text Control = "text" Textarea Control = "textarea" Number Control = "number" Email Control = "email" URL Control = "url" Date Control = "date" DateTime Control = "datetime" Select Control = "select" Checkbox Control = "checkbox" Radio Control = "radio" // Hidden carries an identifier a sub-record form needs (a parent id). // A hidden field is parsed only because it is declared, and the // handler must still check it against the route: the route is the // authority, the field is a convenience (finding FA-10). Hidden Control = "hidden" // Static is a row that states a fact instead of offering a control: it // renders Field.Value as muted text where the control would be, emits // no name, and is never parsed. It exists for the Table family, whose // rows are the whole settings surface and must therefore hold the keys // an operator cannot set here at all (a secret key, set in the // environment): one table, one Save, no second table below it // (design D21). Static Control = "static" ) // InputType is the type attribute the control renders with, for the // controls that are elements. Empty for the three that are not. func (c Control) InputType() string { switch c { case Text: return "text" case Number: return "number" case Email: return "email" case URL: return "url" case Date: return "date" case DateTime: return "datetime-local" case Checkbox: return "checkbox" case Radio: return "radio" case Hidden: return "hidden" } return "" } // Valid reports whether c is one of the declared controls. func (c Control) Valid() bool { switch c { case Text, Textarea, Number, Email, URL, Date, DateTime, Select, Checkbox, Radio, Hidden, Static: return true } return false } // Kind decides a form's outcome contract (design D9) and what the capture // utility does with it (design D16): create, edit, sub-record and settings // forms are submitted empty for the refusal capture; search, preview and // confirm forms are never submitted, because they navigate or mutate. type Kind string const ( KindCreate Kind = "create" KindEdit Kind = "edit" KindSubRecord Kind = "sub-record" KindSettings Kind = "settings" KindSearch Kind = "search" KindPreview Kind = "preview" KindConfirm Kind = "confirm" // KindBatch is a table of records edited in place: the declaration's // fields are one row's, the caller's table is the form's body, and a // staged batch of changes is applied by one commit in a tray under the // table (design D1, staged-rule-changes). It is never submitted empty // by the capture utility, because its commit applies a batch that an // empty submission does not have. KindBatch Kind = "batch" ) // Valid reports whether k is one of the declared kinds. func (k Kind) Valid() bool { switch k { case KindCreate, KindEdit, KindSubRecord, KindSettings, KindSearch, KindPreview, KindConfirm, KindBatch: return true } return false } // Submittable reports whether the capture utility submits a form of this // kind empty to photograph its refusal (design D16). func (k Kind) Submittable() bool { switch k { case KindCreate, KindEdit, KindSubRecord, KindSettings: return true } return false } // ServerValidated reports whether the form's refusal is the server's, and // only the server's. The part emits `novalidate` on these, so pressing the // commit with an empty required field reaches the handler and swaps back // the 422 with the message under the control, rather than stopping at the // browser's transient bubble, which cannot be captured, cannot be styled, // disappears on the next keystroke, and says nothing the server's own // message does not (GOV.UK Frontend does the same). The constraint // attributes stay on the controls for assistive technology and for // :invalid styling; they are a convenience, never the control. // // A Search form keeps the browser's validation, because it navigates // rather than refusing, and a Confirm form has nothing to validate. func (k Kind) ServerValidated() bool { switch k { case KindCreate, KindEdit, KindSubRecord, KindSettings, KindPreview, KindBatch: return true } return false } // Family is the layout, of which there are five and no sixth (design D10, // D20, D21; D1 of staged-rule-changes added the fifth). Kind says what a // form is for; family says how it is laid out, and the pairing is fixed: // the declaration names both so the registry table and the docs read the // same way, but the choice is not the declaring file's to make (FamilyFor, // and the invariants that hold every declaration to it). // // - Stacked: one control per row, labels above, in a section box capped // at a readable width; create pages, edit forms, previews, the confirm // modal. // - Dense: one row of small controls with the commit at its end; // sub-record panels only. // - Bar: one input group with a leading magnifier, the control, the // outline commit and the way out; searches only. Round 3 forced a // search into Stacked and every list page rendered a narrow input with // its button wrapped onto the next line (maintainer, 2026-09-04). // - Table: one table row per field, the label and its hint in the first // column, the page's own pre-rendered cells next, the control last; // settings only. Round 3 forced settings into Stacked and both // settings pages lost the effective value, the source and the status // the table had carried. // - Rows: the caller's own table of records as the form's body, the // declaration's controls rendered into the caller's cells through the // field part, and one tray under the table at the body's width holding // the staged batch and the commit; batch forms only. A Table's rows are // fields, one per row; a Rows body's rows are records, each carrying // several of the declaration's fields. type Family string const ( Stacked Family = "stacked" Dense Family = "dense" Bar Family = "bar" Table Family = "table" Rows Family = "rows" ) // Valid reports whether f is one of the five families. func (f Family) Valid() bool { switch f { case Stacked, Dense, Bar, Table, Rows: return true } return false } // FamilyFor is the family a kind is laid out in. The pairing is fixed, and // CheckInvariants refuses any declaration that names a different one // (design D10 as corrected in round 4). func FamilyFor(k Kind) Family { switch k { case KindSearch: return Bar case KindSettings: return Table case KindSubRecord: return Dense case KindBatch: return Rows } return Stacked } // Placement is where in a Rows form a field renders (design D1's "Rows // family contract"). It is meaningless in every other family, and the // invariants refuse anything but the zero value outside a batch form. type Placement string const ( // PlaceRow is the zero value: a field of one record's row, rendered by // the caller's own body through FormView.RowField. PlaceRow Placement = "" // PlaceDelta renders the field once per staged delta, on that delta's // tray line, with the delta's index as its instance: a decision whose // consequence is in view only once the change is staged (the rule // change's reduction policy). PlaceDelta Placement = "delta" // PlaceTray renders the field once in the tray, before the commit: the // batch's own field rather than any record's (the note). PlaceTray Placement = "tray" ) // Valid reports whether p is one of the declared placements. func (p Placement) Valid() bool { switch p { case PlaceRow, PlaceDelta, PlaceTray: return true } return false } // Side names the render side a field exists on. A field that exists on one // side only declares that side and a reason, and the part renders the // reason as one short line at the field on the side where it exists // (design D8; spec form-conventions "Create and edit forms agree or // disclose their difference"). type Side string const ( // BothSides is the zero value: the field renders in every mode. BothSides Side = "" // CreateOnly renders on the create page (unbound mode) only. CreateOnly Side = "create" // EditOnly renders on the edit form (record-bound mode) only. EditOnly Side = "edit" ) // Valid reports whether s is one of the declared sides. func (s Side) Valid() bool { switch s { case BothSides, CreateOnly, EditOnly: return true } return false } // Fraction is a field's column in a dense row: one of five fractions of // the twelve-column grid that divide it evenly, held as an ordered ladder // from FractionSixth to FractionFull (design D1). The zero value, // FractionNone, is not a rung on the ladder: it names a control with // nothing to step from (a hidden field's no column; a checkbox's, a radio // group's and a static value's own content). type Fraction int const ( FractionNone Fraction = iota FractionSixth FractionQuarter FractionThird FractionHalf FractionFull ) // Fraction is the control's natural rung: the lowest fraction that holds // its widest ordinary value at a laptop width, where one twelfth of the // content column is about 80px (design D2). Checkbox, Radio and Static are // their own content, not a fraction of the row; Hidden takes no column at // all, which is why both resolve to the zero value here and are handled by // control alone wherever that distinction matters. func (c Control) Fraction() Fraction { switch c { case Number, Date: return FractionSixth case DateTime: return FractionQuarter case Select, Text, Email, URL: return FractionThird case Textarea: return FractionFull } return FractionNone } // Width steps a field's column one rung from its control's natural // fraction (design D2); ignored in every family but Dense. The zero value // means the control's own rung, and no declaration writes any other // constant for it: a declaration sets Width only where the field's values // are shorter or longer than its control's usual ones. type Width string const ( // WidthNarrower moves the field's column one rung down the ladder. WidthNarrower Width = "narrower" // WidthWider moves the field's column one rung up the ladder. WidthWider Width = "wider" ) // Valid reports whether w is one of the declared widths, the zero value // included. func (w Width) Valid() bool { switch w { case "", WidthNarrower, WidthWider: return true } return false } // FieldFraction resolves a field's rung from its control's natural // fraction and its declared Width, and reports whether the result stays on // the ladder (design D2). ok is false when c has nothing to step from // (hidden, checkbox, radio, static and textarea all stand on no rung for // this purpose, a textarea's full row being the control's own rather than // a step), when a narrower step would leave the ladder below a sixth, or // when a wider step would reach a full row. func FieldFraction(c Control, w Width) (fraction Fraction, ok bool) { natural := c.Fraction() if w == "" { return natural, true } if natural < FractionSixth || natural > FractionHalf { return natural, false } return stepFraction(natural, w) } // stepFraction moves natural one rung by w and reports whether the result // stays on the ladder: a narrower step below a sixth or a wider step above // a half leaves it (design D2). Split out from FieldFraction so the // ladder's two edges are each one comparison, testable on their own. func stepFraction(natural Fraction, w Width) (Fraction, bool) { switch w { case WidthNarrower: if natural <= FractionSixth { return natural, false } return natural - 1, true case WidthWider: if natural >= FractionHalf { return natural, false } return natural + 1, true } return natural, false } // ColClass is the dense row's grid class for this fraction (design D3, // D5): below the extra-large breakpoint every field takes its own line, // and the fractions apply from 1200px, where the content column beside // the sidebar is wide enough for them. The zero value renders no class; a // content column (checkbox, radio, static) is col-12 col-xl-auto instead, // decided by control rather than by fraction. func (fr Fraction) ColClass() string { switch fr { case FractionSixth: return "col-12 col-xl-2" case FractionQuarter: return "col-12 col-xl-3" case FractionThird: return "col-12 col-xl-4" case FractionHalf: return "col-12 col-xl-6" case FractionFull: return "col-12" } return "" } // WayOutKind names how a form is left without submitting it. type WayOutKind string const ( // WayOutNone is an always-open edit form: nothing is left by // cancelling, so no control is offered (finding FA-19). WayOutNone WayOutKind = "" // WayOutLink navigates away, typically a create page back to its list. WayOutLink WayOutKind = "link" // WayOutClosePanel collapses the sub-record panel the form sits in. WayOutClosePanel WayOutKind = "close-panel" // WayOutDismissModal closes the confirm modal the form sits in. WayOutDismissModal WayOutKind = "dismiss-modal" // WayOutDiscard re-fetches the form's own resting state with a GET to // URL and swaps it into the same target the form itself posts to, // carving that one region out of the GET's full-page response with the // same selector (design D10's preview idiom: a preview writes nothing, // so "Discard" has nothing to undo, only a pending choice to drop). Use // this over WayOutLink when the form renders more than once on its // page, so discarding one instance leaves its siblings alone. WayOutDiscard WayOutKind = "discard" ) // WayOut is a form's way out. It renders btn-outline-secondary after the // commit; solid btn-secondary is not in the console's palette (design D12). type WayOut struct { Kind WayOutKind Label string // URL is the destination for WayOutLink, and the GET source for // WayOutDiscard. URL string // PanelID is the collapse element the WayOutClosePanel button closes. PanelID string // HXTarget and HXSwap turn a WayOutLink into a scoped htmx GET instead // of a plain navigation: the response at URL is swapped into HXTarget // with HXSwap (default "innerHTML") rather than the browser navigating. // For a form whose container is itself an htmx-swapped region, not a // routable page (the workspace create panel's "Back" to the list, the // member plan-move preview's "Keep current plan"), a plain // would fall through to the page's own hx-boost and swap the wrong // region. Both empty renders a plain , which is what a create // page's Cancel to its own list needs. HXTarget string HXSwap string } // NoWayOut is the always-open edit form's way out: none. func NoWayOut() WayOut { return WayOut{} } // LinkOut leaves the form by navigating, the create page's Cancel. func LinkOut(label, url string) WayOut { return WayOut{Kind: WayOutLink, Label: label, URL: url} } // ScopedLinkOut leaves the form by a scoped htmx GET whose response swaps // into target, for a way out that stays inside the htmx-swapped region the // form itself occupies rather than navigating the page. func ScopedLinkOut(label, url, target string) WayOut { return WayOut{Kind: WayOutLink, Label: label, URL: url, HXTarget: target, HXSwap: "innerHTML"} } // ClosePanel leaves the form by collapsing the panel it sits in. func ClosePanel(label, panelID string) WayOut { return WayOut{Kind: WayOutClosePanel, Label: label, PanelID: panelID} } // DismissModal leaves the form by closing the modal it sits in. func DismissModal(label string) WayOut { return WayOut{Kind: WayOutDismissModal, Label: label} } // Discard leaves a preview form by re-fetching its resting state with a GET // to url and swapping the same target the form posts to, carving that one // region out of the GET's full-page response (design D10's preview idiom). func Discard(label, url string) WayOut { return WayOut{Kind: WayOutDiscard, Label: label, URL: url} } // Option is one entry of a select or a radio group. type Option struct { Value string Label string Disabled bool } // ChooseOption is the first option of a select whose value must be chosen: // disabled, selected, empty-valued, so the form cannot submit a value the // person never chose (spec form-conventions "A select that needs a choice // opens with a placeholder option"). thing completes "Choose ...", so pass // "an entitlement set" or "a product". func ChooseOption(thing string) Option { return Option{Value: "", Label: "Choose " + thing, Disabled: true} } // NoneOption is the first option of an optional select: an empty value the // person may deliberately choose, which is how a select unsets a column. func NoneOption() Option { return Option{Value: "", Label: "None"} } // HelpIcon is the click-opened popover the shared helpIcon part renders // after a field's label, as its sibling and never inside the label // (findings FA-37, FA-50). Its field names match the part's pipeline, so a // forms.HelpIcon is passed to {{ template "helpIcon" ... }} directly. type HelpIcon struct { Label string Text string } // Help builds a field's help popover. func Help(label, text string) HelpIcon { return HelpIcon{Label: label, Text: text} } // Set reports whether the field carries help. func (h HelpIcon) Set() bool { return h.Text != "" } // Field is one control's whole declaration: what it submits, what it says, // how it is read, and where it exists. Nothing about a field lives in a // template, and nothing a template would need is left undeclared. type Field struct { // Name is the submitted name. It is stable and never prefixed: the // data-form id namespaces the DOM ids instead. Name string // Label is the visible label. Every control has one; in a dense row // with no room it renders visually hidden, never absent (FA-51). Label string // HideLabel renders the label visually hidden, which the part honours // in a dense row and in a bar only, the two families with no room for // one. An aria-label never substitutes for a label. HideLabel bool // Control is the control this field renders and is read as. Control Control // Options are a select's or a radio group's entries. A form whose // options are loaded per request declares RuntimeOptions instead and // supplies them at render and at parse. Options []Option // RuntimeOptions declares that this select's or radio group's options // come from the request (an entitlement-set picker, a product picker), // so the invariants accept an empty Options and Parse is given the set // through ParseWith. RuntimeOptions bool // Hint is one short line under the control. It survives an error // (GOV.UK's side of the split; findings FA-48, FA-49). A field carries // a Hint or a Notice, never both: one visible line under a control is // the whole budget, and a second line reads as clutter (maintainer, // 2026-09-03: "Can we remove all additional obvious text that doesn't // need to be there?"). A dense row carries neither; its explanation // goes behind the help icon. Hint string // Notice is the one-sidedness a person would otherwise be surprised // by, as one line under the control: a create-only value that cannot // be changed later, say. It is not the default for a one-sided field. // Most differences are self-explanatory once the field is simply // absent from the other side, and saying so out loud is the // superfluous text the maintainer struck (2026-09-03). Notice string // Help is the click popover after the label. Help HelpIcon // Placeholder is a format example only, never a label, a state or an // instruction (finding FA-35). The invariants refuse a sentence. Placeholder string // Optional marks the label with the "(optional)" suffix and lets an // empty value through. On a select, requiredness is carried by the // option set instead: an empty value is accepted exactly when an // option with an empty value exists, so a select's Optional only // governs the label's marker. On a radio group, by contrast, Optional // governs the required attribute and Parse's requiredness check // directly, like every other control except Select: a radio group has // no "choose nothing" member the way a Select's enabled empty option // does, so there is no equivalent convention available to carry the // fact (fieldAttrs and the Select/Radio membership check in parse.go // both give Radio its own case for this reason). Optional bool // MaxLen caps the value's length; it renders as maxlength and Parse // refuses a longer value, so the browser's rule and the server's rule // cannot differ. MaxLen int // Min and Max bound a number, a date or a date-time. They render as // the min and max attributes and Parse applies them. Min, Max string // Step is a Number field's granularity ("0.01" for a currency amount), // rendered as the step attribute. Parse does not apply it: a step // mismatch is cosmetic (the constraint attribute is a convenience, // never the control, design D2), and the amounts this library reads // are already integer-cents or otherwise range-checked by the // handler's own domain rule. Step string // Pattern is a regular expression the value must match, rendered as // the pattern attribute and applied by Parse. PatternHint is the // refusal's message, because a regular expression is not an error // message. Pattern string PatternHint string // Inputmode is the virtual keyboard hint (numeric, decimal, email). Inputmode string // Autocomplete is a real autofill token (name, email, url, // organization) or "off" for keys, search boxes and one-time values. // The part emits it from here and from nowhere else, and never writes // it on the form tag (finding FA-52). Autocomplete string // Rows is a textarea's height in lines; 3 when unset. Rows int // Value is a checkbox's submitted value when it is ticked ("public", // "true"). "true" when unset. A checkbox's absence always reads as // false, and the invariants refuse a required checkbox. On a Static // field it is instead the sentence the row states where its control // would be, and nothing is submitted at all. Value string // Width steps the field's column one rung from its control's natural // fraction (design D2); ignored when stacked. The zero value is the // control's own rung. Width Width // Only names the side this field exists on. It carries no // justification string: no surveyed framework does (Django's // add_fieldsets and Filament's hiddenOn declare the side and nothing // else), and a reason nothing renders and nothing reads is noise in // the registry (maintainer, 2026-09-04). Why a field is one-sided is // a Go comment beside the declaration, like any other comment; what a // person needs to be told is a Notice. Only Side // Slot renders an empty container after the control, with the id // "-", for a region a scoped htmx request swaps into // (the product name's duplicate warning). The render supplies its // first content through Binding.Slots. Slot string // ShowIf makes this field's rendering and parsing conditional on // another field's bound value (the entitlement-set rule form's Limit // and "Per unit" fields depend on the selected resource key's kind; // design D2, entitlement-set-management "the kind-dependent shape // SHALL be a declared field set"). The zero value // always shows. The condition reads Values, not the database: a // handler that derives visibility from data the library cannot see // (a resource key's kind) writes that derived value into a Values // entry itself, typically a Hidden field the render fills in, so the // same submitted state governs the next render and the next parse // (see ShowIf's doc for the trust model). ShowIf ShowIf // Placement is where a Rows form renders this field: with the record's // row (the zero value, rendered by the caller's body), on a staged // delta's tray line, or once in the tray before the commit. Ignored, // and refused by the invariants, in every other family. Placement Placement // Attrs is the one escape hatch: data-* and hx-* keys only, and never // a key the part owns. Anything else fails the invariants. Attrs map[string]string } // ShowIf names another declared field and the raw values it must carry for // this field to render and be parsed. It is the library's answer to "the // field set depends on a value" (design D2): a small, general mechanism, // not a rule form's private hack, so any future form with the same shape // (a picker whose choice reveals different fields) can declare it too. // // The condition is checked against Values, which Parse builds field by // field in declaration order: a field referenced by another field's ShowIf // must therefore be declared earlier in Fields. The referenced field need // not itself be visible to the operator; a Hidden field the render sets // from server-derived data (never from user choice) is the common case, // and the trust model follows from that: whatever governs visibility is // exactly what was rendered, so it is self-consistent across a refused // resubmission, but it is never authoritative for a domain decision (the // entitlement-set rule's actual type is still re-derived from the // database, never read back from this field) because the value did // round-trip through the client. type ShowIf struct { // Field is the name of the field this condition reads. Field string // Equals is the set of that field's raw values for which this field // is shown; empty Field means always shown. Equals []string } // set reports whether the condition is declared at all. func (s ShowIf) set() bool { return s.Field != "" } // matches reports whether raw is one of the condition's accepted values. func (s ShowIf) matches(raw string) bool { for _, v := range s.Equals { if v == raw { return true } } return false } // Shown reports whether f renders and is parsed, given the values built so // far. A field with no ShowIf is always shown. func (f Field) Shown(values Values) bool { if !f.ShowIf.set() { return true } return f.ShowIf.matches(values.Raw(f.ShowIf.Field)) } // On returns a copy of f declared for one side, so a shared field value // can be included by reference and still exist on one side only // (design D3, D8). func (f Field) On(side Side) Field { f.Only = side return f } // WithHint returns a copy of f carrying hint, for the rare case where one // shared field value needs a different line in one form. A different rule // is a different field with a different name, never a loosened copy. func (f Field) WithHint(hint string) Field { f.Hint = hint return f } // RendersOn reports whether the field renders in the given mode. A // submission re-renders the side it was submitted from, which the binding // carries. func (f Field) RendersOn(side Side) bool { return f.Only == BothSides || f.Only == side } // CheckboxValue is the value a ticked checkbox submits. func (f Field) CheckboxValue() string { if f.Value != "" { return f.Value } return "true" } // FormSpec is one form. It is declared once, rendered in three modes, and // parsed through in the handler, so a field the handler reads and no // template renders cannot exist. type FormSpec struct { // Name is the data-form id and the registry key, unique across the // console, e.g. "operator.product". Name string // Kind decides the outcome contract and the capture behaviour. Kind Kind // Family decides the layout. Family Family // Method and Path are the route the form submits to. For a form whose // create and edit sides are different routes, Path is the create // route and EditMethod and EditPath are the record's. Method string Path string // EditMethod and EditPath are the record-bound side's route when it // differs from Method and Path (a create form posts to the // collection, an edit form puts to the record). Empty when the form // has one route. Both are route patterns; the render is given the // concrete URL. EditMethod string EditPath string // Fields is the ordered field list; the order is the render order. Fields []Field // Commit is the commit button's label. CommitEdit is the record // side's label when it differs ("Create product" against "Save // changes"); empty means both sides commit under one label. Commit string CommitEdit string // WayOut is how the form is left without submitting it, on the create // side. The record side never carries one: an always-open edit form // leaves nothing by cancelling, so the part renders none there // whatever the declaration says (spec form-conventions "A form's way // out follows its container"; finding FA-19). WayOut WayOut // Target is the swap target; "this" (the form's own outer element) // when unset. Swap is the swap strategy; "outerHTML" when unset. Target string Swap string // Wide drops the Bar family's width cap, so the bar spans its content // column instead of stopping at 30rem: for a search that is its page's // primary control and whose width should say so (the landing surface's // lookup). Bar only. Wide bool // Columns are the Table family's middle columns, in order: the headings // of the cells the page supplies per row through Binding.Cells (for // integration settings, "Effective value" and "Source"). The badges // those cells hold belong to the page, not to the library, which is why // they arrive pre-rendered. Table only. Columns []string // LabelColumn and ControlColumn are the Table family's first and last // column headings; "Field" and "Value" when unset. Table only. LabelColumn string ControlColumn string // CommitAction is the path the Rows family's commit posts to, which is // not the path its row actions post to: every row action, Undo and the // way out re-render the form's own region from Path, while the commit // applies the batch and re-renders whatever the page's commit renders // (design D2). Batch forms only, and required on one. CommitAction string // MessageAfter names the field the render's Message follows, for a // preview whose message is the consequence of one control and reads // wrong above it (the org-type card; maintainer, 2026-09-04: "The // following text appears above the form element instead of below // it"). Empty puts the message before the fields, which is right for a // preview whose narrative introduces the choice. Preview only. MessageAfter string } // Bind renders this declaration against one binding. It is Render written // as a method, so a caller that must hold the view before it can render the // body it hands back (the Rows family: the body fragment calls RowField on // the view) reads as one expression. func (s FormSpec) Bind(b Binding) FormView { return Render(s, b) } // Field returns the declared field with this name, and whether it exists. func (s FormSpec) Field(name string) (Field, bool) { for _, f := range s.Fields { if f.Name == name { return f, true } } return Field{}, false } // target is the effective hx-target. func (s FormSpec) target() string { if s.Target != "" { return s.Target } return "this" } // swap is the effective hx-swap. func (s FormSpec) swap() string { if s.Swap != "" { return s.Swap } return "outerHTML" }