Files
member-console/internal/forms/invariants.go
T
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
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.
2026-09-19 19:46:09 -05:00

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
}