The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
348 lines
15 KiB
Go
348 lines
15 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package forms
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// The declaration's invariants (design D7; spec form-library "The registry
|
|
// holds every form and is checked"). These are the rules a declaration
|
|
// must satisfy for the two parts to render it accessibly and for Parse to
|
|
// read it honestly. They live in the package rather than in a test file so
|
|
// the package that owns a form can run them over its own registrations,
|
|
// and so a lane adding a form gets the same message wherever it runs them.
|
|
|
|
// ownedAttrs are the attributes the field part writes itself. The escape
|
|
// hatch may not carry one: a caller that could override them could render
|
|
// a control without its label wiring or with a constraint the server does
|
|
// not apply (lesson L§12, GOV.UK's Nunjucks API rules).
|
|
var ownedAttrs = map[string]bool{
|
|
"class": true, "id": true, "name": true, "type": true, "value": true,
|
|
"required": true, "maxlength": true, "min": true, "max": true, "step": true,
|
|
"pattern": true, "inputmode": true, "autocomplete": true, "rows": true,
|
|
"checked": true, "selected": true, "disabled": true, "autofocus": true,
|
|
"aria-describedby": true, "aria-invalid": true, "aria-label": true,
|
|
"for": true, "placeholder": true,
|
|
}
|
|
|
|
// CheckInvariants returns every rule spec breaks, as sentences naming the
|
|
// form and the field. An empty result is a declaration the library can
|
|
// render and read.
|
|
func CheckInvariants(spec FormSpec) []string {
|
|
var problems []string
|
|
add := func(format string, args ...any) {
|
|
problems = append(problems, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
if spec.Name == "" {
|
|
add("the form has no name; the name is its data-form id and its registry key")
|
|
}
|
|
if !spec.Kind.Valid() {
|
|
add("the form has no kind, or one the library does not offer")
|
|
}
|
|
if !spec.Family.Valid() {
|
|
add("the form has no layout family; it is stacked, dense, bar, table or rows, and there is no sixth")
|
|
} else if spec.Kind.Valid() && spec.Family != FamilyFor(spec.Kind) {
|
|
// Kind says what a form is for; family says how it is laid out,
|
|
// and the pairing is fixed (design D10 as corrected in round 4).
|
|
// A declaration names both so the registry table and the docs read
|
|
// the same way, but the choice of family is not the declaring
|
|
// file's to make: round 3 let two kinds pick Stacked and every
|
|
// list page and both settings pages rendered wrong.
|
|
add("the form is a %s form in the %s family; a %s form is laid out %s and the pairing is not the declaration's to choose", spec.Kind, spec.Family, spec.Kind, FamilyFor(spec.Kind))
|
|
}
|
|
if spec.Wide && spec.Family != Bar {
|
|
add("the form declares Wide outside the bar family; Wide drops a bar's width cap and means nothing elsewhere")
|
|
}
|
|
if spec.Family != Table {
|
|
if len(spec.Columns) > 0 {
|
|
add("the form declares Columns outside the table family; the middle columns are a table's")
|
|
}
|
|
if spec.LabelColumn != "" || spec.ControlColumn != "" {
|
|
add("the form names a label or control column outside the table family; the column headings are a table's")
|
|
}
|
|
}
|
|
// The Rows family's own two declarations (design D1's "Rows family
|
|
// contract"): the commit has a path of its own, because a row action
|
|
// re-renders the table's region and the commit applies the batch; and a
|
|
// field's placement says which of the family's three regions renders it.
|
|
if spec.Family != Rows {
|
|
if spec.CommitAction != "" {
|
|
add("the form declares CommitAction outside the rows family; only a batch form's commit posts somewhere other than the form's own path")
|
|
}
|
|
} else {
|
|
if spec.CommitAction == "" {
|
|
add("the batch form declares no CommitAction; the row actions post to Path and the commit needs a path of its own")
|
|
} else if !strings.HasPrefix(spec.CommitAction, "/") {
|
|
add("the form's CommitAction is not a route")
|
|
}
|
|
}
|
|
if spec.MessageAfter != "" {
|
|
if spec.Kind != KindPreview {
|
|
add("the form declares MessageAfter outside a preview; the message that follows a field is the preview idiom's")
|
|
}
|
|
if _, ok := spec.Field(spec.MessageAfter); !ok {
|
|
add("the form's MessageAfter names %q, which it does not declare as a field", spec.MessageAfter)
|
|
}
|
|
}
|
|
if !isFormMethod(spec.Method) {
|
|
add("the form has no method, or one the library does not send")
|
|
}
|
|
if !strings.HasPrefix(spec.Path, "/") {
|
|
add("the form has no path, or one that is not a route")
|
|
}
|
|
if spec.EditPath != "" && !strings.HasPrefix(spec.EditPath, "/") {
|
|
add("the form's record-side path is not a route")
|
|
}
|
|
if spec.EditMethod != "" && !isFormMethod(spec.EditMethod) {
|
|
add("the form's record-side method is one the library does not send")
|
|
}
|
|
if spec.Commit == "" {
|
|
add("the form has no commit label")
|
|
}
|
|
if spec.WayOut.Kind != WayOutNone && spec.WayOut.Label == "" {
|
|
add("the form declares a way out with no label")
|
|
}
|
|
if spec.WayOut.Kind == WayOutLink && spec.WayOut.URL == "" {
|
|
add("the form's way out is a link with no destination")
|
|
}
|
|
if spec.WayOut.Kind == WayOutClosePanel && spec.WayOut.PanelID == "" {
|
|
add("the form's way out closes a panel it does not name")
|
|
}
|
|
if spec.WayOut.Kind == WayOutDiscard && spec.WayOut.URL == "" {
|
|
add("the form's way out discards with no URL to re-fetch")
|
|
}
|
|
// A Confirm form's fields are supplied per trigger by
|
|
// confirm-action-modal.js's data-action-fields (one dialog serves many
|
|
// different mutations, each with its own hidden fields, injected into
|
|
// the rendered <form> at open time); the declaration itself carries
|
|
// none, so the rule that every other kind must declare at least one
|
|
// field does not apply here (design D4 names the kind; this is the
|
|
// library's own reading of what "declared" means for it).
|
|
if len(spec.Fields) == 0 && spec.Kind != KindConfirm {
|
|
add("the form declares no fields")
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for _, f := range spec.Fields {
|
|
if f.Name == "" {
|
|
add("a field has no name")
|
|
continue
|
|
}
|
|
if seen[f.Name] {
|
|
add("the form declares the field %q twice", f.Name)
|
|
}
|
|
if f.ShowIf.set() && !seen[f.ShowIf.Field] {
|
|
add("the field %q shows only when %q holds a value, but %q is not declared earlier in the field list; Parse reads ShowIf's condition from what it has already parsed", f.Name, f.ShowIf.Field, f.ShowIf.Field)
|
|
}
|
|
seen[f.Name] = true
|
|
|
|
if f.Label == "" {
|
|
add("the field %q has no label; every control carries one, visually hidden at most", f.Name)
|
|
}
|
|
if !f.Control.Valid() {
|
|
add("the field %q declares a control the library does not offer", f.Name)
|
|
}
|
|
if !f.Only.Valid() {
|
|
add("the field %q declares a side the library does not know", f.Name)
|
|
}
|
|
if !f.Placement.Valid() {
|
|
add("the field %q declares a placement the library does not know", f.Name)
|
|
} else if f.Placement != PlaceRow && spec.Family != Rows {
|
|
add("the field %q declares a placement outside the rows family; a delta line and a tray exist only under a batch form's body", f.Name)
|
|
}
|
|
if !f.Width.Valid() {
|
|
add("the field %q declares a width the library does not know", f.Name)
|
|
} else if f.Width != "" {
|
|
natural := f.Control.Fraction()
|
|
if _, ok := FieldFraction(f.Control, f.Width); !ok {
|
|
switch {
|
|
case natural < FractionSixth || natural > FractionHalf:
|
|
add("the field %q declares a width on a %s control, which stands on no rung to step from", f.Name, f.Control)
|
|
case f.Width == WidthNarrower:
|
|
add("the field %q steps narrower than a sixth, the ladder's bottom rung", f.Name)
|
|
default:
|
|
add("the field %q steps wider than a half, which would reach a full row", f.Name)
|
|
}
|
|
}
|
|
}
|
|
if f.Control == Checkbox {
|
|
// A checkbox declares a boolean whose absence is false. A
|
|
// length, a range or a pattern on one would be a rule the
|
|
// library can neither render nor apply, and "required" would
|
|
// mean "must be ticked", which the console has no field for
|
|
// (lesson L§21).
|
|
if f.MaxLen > 0 || f.Min != "" || f.Max != "" || f.Pattern != "" {
|
|
add("the field %q carries a rule a checkbox cannot have; a checkbox is present or absent", f.Name)
|
|
}
|
|
}
|
|
if f.Control == Static {
|
|
// A static row is the table's way of holding a key an
|
|
// operator cannot set here at all; in any other family it
|
|
// would be a sentence pretending to be a field (design D21).
|
|
if spec.Family != Table {
|
|
add("the field %q declares a static control outside the table family; a row that states a fact instead of offering a control exists only in a table", f.Name)
|
|
}
|
|
if f.Value == "" {
|
|
add("the field %q is static and says nothing; a static row's Value is the fact it states", f.Name)
|
|
}
|
|
}
|
|
if (f.Control == Select || f.Control == Radio) && len(f.Options) == 0 && !f.RuntimeOptions {
|
|
add("the field %q offers no options; declare them, or declare RuntimeOptions and supply them at render and at parse", f.Name)
|
|
}
|
|
if f.Control != Select && f.Control != Radio && (len(f.Options) > 0 || f.RuntimeOptions) {
|
|
add("the field %q declares options on a control that has none", f.Name)
|
|
}
|
|
if f.Pattern != "" && f.PatternHint == "" {
|
|
add("the field %q declares a pattern with no hint; a regular expression is not an error message", f.Name)
|
|
}
|
|
if f.Hint != "" && f.Notice != "" {
|
|
add("the field %q declares a hint and a notice; one visible line under a control is the budget, so put the explanation in one of them or in Help", f.Name)
|
|
}
|
|
// Copy earns its place (ui-vocabulary): a hint over the sources'
|
|
// own cap ("a single short sentence") is not one line any more, and
|
|
// a hint whose content words are all already in the label restates
|
|
// it rather than adding a fact (finding pattern 4.2, the audit's
|
|
// research). Both are the registry's own check, because a template
|
|
// review catches neither reliably.
|
|
if len(f.Hint) > maxHintLen {
|
|
add("the field %q hint is %d characters, over the %d-character cap; a hint is one short line, not a paragraph", f.Name, len(f.Hint), maxHintLen)
|
|
}
|
|
if f.Hint != "" && hintRestatesLabel(f.Hint, f.Label) {
|
|
add("the field %q hint restates its label; delete it unless it carries a fact the label, the control, and the page do not (ui-vocabulary \"Copy earns its place\")", f.Name)
|
|
}
|
|
// A dense row aligns its controls on one baseline
|
|
// (align-items-end), so a line under one control lifts that
|
|
// field's label above its neighbours' and the row reads as
|
|
// broken. The explanation goes behind the help icon instead
|
|
// (form-conventions "Dense form rows keep explanations behind a
|
|
// help icon"; maintainer, 2026-09-03, on the Extend tier row).
|
|
if spec.Family == Dense {
|
|
if f.Hint != "" {
|
|
add("the field %q carries a hint in a dense row; put the explanation in Help, which the icon after the label opens", f.Name)
|
|
}
|
|
if f.Notice != "" {
|
|
add("the field %q carries a notice in a dense row; put it in Help, which the icon after the label opens", f.Name)
|
|
}
|
|
}
|
|
if isSentence(f.Placeholder) {
|
|
add("the field %q placeholder reads as a sentence; a placeholder is a format example, and anything else is a hint", f.Name)
|
|
}
|
|
for key := range f.Attrs {
|
|
if !strings.HasPrefix(key, "data-") && !strings.HasPrefix(key, "hx-") {
|
|
add("the field %q escape hatch carries %q; the escape hatch admits data-* and hx-* keys only", f.Name, key)
|
|
continue
|
|
}
|
|
if ownedAttrs[key] {
|
|
add("the field %q escape hatch carries %q, which the part owns", f.Name, key)
|
|
}
|
|
}
|
|
for label, value := range map[string]string{
|
|
"label": f.Label, "hint": f.Hint, "placeholder": f.Placeholder,
|
|
"notice": f.Notice,
|
|
"help title": f.Help.Label, "help text": f.Help.Text,
|
|
"pattern hint": f.PatternHint,
|
|
} {
|
|
if strings.Contains(value, "—") {
|
|
add("the field %q %s uses an em dash; write it as a semicolon, colon, or comma", f.Name, label)
|
|
}
|
|
}
|
|
for _, o := range f.Options {
|
|
if strings.Contains(o.Label, "—") {
|
|
add("an option of the field %q uses an em dash in its label", f.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
for label, value := range map[string]string{
|
|
"commit label": spec.Commit, "record-side commit label": spec.CommitEdit,
|
|
"way out label": spec.WayOut.Label,
|
|
} {
|
|
if strings.Contains(value, "—") {
|
|
add("the form's %s uses an em dash; write it as a semicolon, colon, or comma", label)
|
|
}
|
|
}
|
|
|
|
return problems
|
|
}
|
|
|
|
// isFormMethod reports whether m is a method the library sends.
|
|
func isFormMethod(m string) bool {
|
|
switch m {
|
|
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isSentence reports whether a placeholder reads as prose rather than as a
|
|
// format example: it ends in a full stop, a question mark or an
|
|
// exclamation mark. "e.g. addon" is an example; "Enter the name." is a
|
|
// label the field already has (finding FA-35).
|
|
func isSentence(placeholder string) bool {
|
|
trimmed := strings.TrimSpace(placeholder)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
switch trimmed[len(trimmed)-1] {
|
|
case '.', '?', '!':
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// maxHintLen is the registry's own cap on a hint's length (ui-vocabulary
|
|
// "Copy earns its place"; the sources state it qualitatively, "a single
|
|
// short sentence" or "a few words only"; this is the repo's own number).
|
|
const maxHintLen = 100
|
|
|
|
// hintStopwords are dropped before comparing a hint's content words with its
|
|
// label's, so "Enter your email address" against "Email address" is caught
|
|
// as a restatement even though it adds a verb and a pronoun the label
|
|
// doesn't carry (ui-vocabulary "Copy earns its place"; the audit's research,
|
|
// 4.2 "Hint repeats the label").
|
|
var hintStopwords = map[string]bool{
|
|
"the": true, "a": true, "an": true, "of": true, "for": true, "to": true,
|
|
"in": true, "on": true, "and": true, "or": true, "is": true, "this": true,
|
|
"that": true, "your": true, "its": true, "enter": true, "choose": true,
|
|
"select": true,
|
|
}
|
|
|
|
// contentWords lowercases s, splits it on anything but letters and digits,
|
|
// and drops the stopwords, so what remains is what the string actually
|
|
// asserts.
|
|
func contentWords(s string) map[string]bool {
|
|
words := map[string]bool{}
|
|
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
|
return !('a' <= r && r <= 'z' || '0' <= r && r <= '9')
|
|
}) {
|
|
if w != "" && !hintStopwords[w] {
|
|
words[w] = true
|
|
}
|
|
}
|
|
return words
|
|
}
|
|
|
|
// hintRestatesLabel reports whether every content word of hint already
|
|
// appears in label, which is the mechanisable half of the deletion test: a
|
|
// hint that adds no word the label doesn't already carry adds no fact
|
|
// either (ui-vocabulary "Copy earns its place"). An empty hint or a hint
|
|
// with no content words at all (after stopwords) never matches: it is
|
|
// caught by nothing here, because there is nothing to compare.
|
|
func hintRestatesLabel(hint, label string) bool {
|
|
hintWords := contentWords(hint)
|
|
if len(hintWords) == 0 {
|
|
return false
|
|
}
|
|
labelWords := contentWords(label)
|
|
for w := range hintWords {
|
|
if !labelWords[w] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|