Files
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

29 KiB

form-conventions Specification

Purpose

Cross-cutting presentation contract for forms, badges, and table cells across operator and member surfaces (UX-15): one convention per question, everywhere.

Requirements

Requirement: Required is the default and only optional is marked

Forms SHALL treat required as the default: only optional fields carry an "(optional)" suffix on their label, and required fields carry no marker. Forms SHALL NOT state the convention in copy: the line "All fields are required unless marked optional." is retired (maintainer, 2026-09-02: it taught nothing the suffix does not, and it sat on forms with no optional field at all). No surface SHALL use a required-asterisk convention.

Scenario: A mixed form marks only the optional fields

  • WHEN a user views a form with both required and optional fields
  • THEN optional field labels end in "(optional)"
  • AND required field labels carry no requiredness marker
  • AND no sentence states the convention

Requirement: Badge colors carry stated, consistent semantics

Status badge colors SHALL carry one meaning each across the application: the success (green) treatment only for live/delivering/healthy states, the warning (amber) treatment only for states needing operator attention, and the neutral treatment for informational states. Color SHALL never be the only carrier of meaning: every badge's text states the state. A badge SHALL NOT reuse the success or warning treatment for a state outside its meaning (e.g. an amber "Yes" beside amber warnings, or a green state that is not live).

Scenario: Green means live everywhere

  • WHEN a user sees a green badge anywhere in the application
  • THEN the badged state is a live/delivering/healthy state
  • AND the badge text names the state

Requirement: Placeholders are format examples only

Input placeholders SHALL be used only to show a format example of the expected value. State text (such as "no value") and instructions SHALL NOT be carried in placeholders; state renders as help text or the empty-value marker, instructions as labels or help text. A search bar is the one exception: its label is visually hidden and the box itself is where every surveyed search component states what it finds, so a search bar's placeholder SHALL repeat its label's text (the landing lookup: "Find anything by its name, key, or number"; a list's search: what that list is searched by).

Scenario: No state text in placeholders

  • WHEN a user views a form input showing that no value is set
  • THEN that fact is conveyed outside the placeholder
  • AND any placeholder present shows only a format example

Scenario: A search bar says what it finds

  • WHEN an operator opens the landing surface
  • THEN the lookup's box shows "Find anything by its name, key, or number" until they type, and a screen reader hears the same text as the control's label

Requirement: Create and edit forms agree or disclose their difference

The create and edit forms for one object SHALL be one declaration (form-library) rendered in two modes, so they expose the same field set by construction. A field that exists on one side only SHALL declare that side in the declaration; nothing about the side renders, because a line such as "Available after creation" tells the person nothing they cannot see (maintainer, 2026-09-03), and the declaration carries no justification string either (maintainer, 2026-09-04). A field MAY carry a notice, one line under the control, only where the difference would surprise: a value that can be set at creation and never changed. A field SHALL NOT exist on one form and be silently absent from the other.

Scenario: A value that cannot change later says so

  • WHEN a field can be set at creation and never changed
  • THEN the create page renders its declared notice, one line, under the control

Scenario: An edit-only field says nothing about its side

  • WHEN a field exists only on the edit form
  • THEN the edit form renders the field with its hint at most, and no line about creation

Scenario: A difference is a declaration, not a second form

  • WHEN an entity's create page lacks a field its edit form has
  • THEN that field carries the edit side in the one declaration both render, and no second template exists

Requirement: Dense form rows keep explanations behind a help icon

A compact multi-column form row SHALL NOT hold visible explanatory copy beyond field labels: a field in such a row whose meaning needs explaining carries a small monochrome question-mark help icon after its label, rendered through the shared helpIcon part, whose explanation opens as a popover on click (Requirement: Help is disclosed by a click-opened popover). The icon follows the same rules as the external-destination marker (chrome-conventions): an inline SVG inheriting the text color, never an emoji or emoji-presentable character, and never separable from its label by line wrapping. A short single-line hint under a field in a stacked (vertical) form remains allowed; multi-sentence explanations prefer the help icon there too.

Scenario: A dense row explains through the icon, not visible copy

  • WHEN a user views a compact multi-column form row containing a field that needs explanation
  • THEN the explanation is available from a question-mark help icon's popover at that field
  • AND no multi-line explanatory copy renders inside the row

Requirement: One empty-cell marker

Tables SHALL render an absent value as the single em-dash marker "—" (the exempt unavailable-value marker), identically across surfaces; per-table variants ("n/a", blank, "none") SHALL NOT be used for absent values.

Scenario: Absent values render identically

  • WHEN a user views tables on different surfaces with absent values
  • THEN every absent value renders as "—"

Requirement: Error responses are designed as swap content

Because htmx 4 swaps every response except 204 and 304, a refused mutation SHALL answer 422 with the same form re-rendered in submission mode: every submitted value carried back, including checkbox and radio states; each field error under its control; the form-level error in the slot every form renders; autofocus on the first errored control; and the hint surviving beside the error. The 422 body and the 200 body SHALL share the form's outer element so either swaps into the same target, with no client script steering the swap. Errors SHALL appear only in the response to a submission, and then all of them; a form opened to edit a record SHALL show none. Every mutation form SHALL carry novalidate, so the browser's constraint validation never blocks the submission with a bubble of its own and the server's refusal is the one refusal a person sees (GOV.UK Frontend does the same). A refusal SHALL NOT answer 200, and SHALL NOT answer with an HX-Redirect. Statuses whose bodies are not designed as swap content (403 and 5xx) SHALL be suppressed declaratively through the noSwap list in the page's htmx-config meta tag, not through JavaScript, and SHALL surface as toasts from the htmx:response:error and htmx:error listeners. A page needing per-status behavior that differs from the global default SHALL use hx-status on the element rather than adding script.

Scenario: Validation lands inline without script

  • WHEN a form submission returns 422 with the re-rendered form
  • THEN htmx swaps it into the form's target by default behavior, every typed value is still there, and focus lands on the first errored control
  • AND no toast appears and no JavaScript altered the swap decision

Scenario: A failure without a field name has a home

  • WHEN a mutation is refused for a reason that belongs to no field
  • THEN the response is 422 and the form-level slot carries the reason

Scenario: A server error never replaces the panel

  • WHEN a request returns a 5xx response
  • THEN the target's content is unchanged (the status is in the declared noSwap list)
  • AND an error toast reports it

Requirement: Every mutation form's no-JS behavior is a recorded decision

Each category of form SHALL have a recorded decision on what happens without JavaScript, kept in docs/design-system.md, and a form's declared kind names its category. Mutation forms (POST/PUT/PATCH/DELETE) require the htmx request path by decision: the CSRF contract needs the X-CSRF-Token header, a native top-level POST arrives with Origin: null under the no-referrer policy and is rejected, and a native fallback that always fails is not offered. Search forms (list controls and the operator lookup) SHALL be declared as the search kind, render a native GET action, and keep a true native fallback. A form that needs a different answer needs a recorded decision, not an undocumented action attribute.

Scenario: List controls degrade, mutations do not pretend to

  • WHEN JavaScript is unavailable
  • THEN operator list search, facet, paging and lookup controls work as native GET navigations
  • AND mutation forms render without a native action fallback rather than offering one that would be rejected

Requirement: Disabled controls carry their reason in the DOM

A control rendered disabled because a precondition is unmet SHALL carry its reason in the document and reference it with aria-describedby, on both surfaces, through one shared part. Whether the reason is visible text (the member surface) or a tooltip on a focusable wrapper (the operator surface, where dense action rows do not stack notes between buttons) is the surface's density choice; the keyboard path and the described-by relation exist in both cases. Pages SHALL NOT hand-roll a title on a wrapper as the only reason. This is the one place a hover tooltip remains in the console; help uses popovers. In both variants the control SHALL sit in a wrapper that carries the not-allowed cursor, because a disabled button receives no pointer events of its own and cannot show a cursor or a tooltip by itself.

Scenario: A keyboard user reaches the reason

  • WHEN a disabled control's wrapper receives focus on the operator surface
  • THEN the reason is announced through aria-describedby and shown as the tooltip

Scenario: A pointer over a disabled control sees the deny cursor

  • WHEN a pointer rests on a disabled control rendered through the part, on either surface
  • THEN the cursor is the not-allowed cursor

Scenario: Both surfaces use the part

  • WHEN a disabled control with a reason renders on either surface
  • THEN it renders through the shared part, and the reason is present in the DOM whether or not it is visible

Requirement: Help is disclosed by a click-opened popover

Every help icon in the console, on a form field, a column header, a section header, or a badge, SHALL be a button that opens a Bootstrap popover on click, titled with the thing it explains, with a close button in the popover's header; pressing Escape or clicking outside SHALL also close it. On a form field the icon SHALL render after the label as its sibling, never inside the <label> element. Help SHALL NOT be carried in a bare title attribute or a hover-only tooltip, because hover does not exist on touch and a title is unreachable by keyboard. One shared part (helpIcon) and one initializer render and wire every instance, so pages do not hand-roll the pattern.

Scenario: Help opens on click and closes three ways

  • WHEN a person activates a help icon by click or keyboard
  • THEN a popover opens titled with the field, column, or section it explains, carrying a close button
  • WHEN the person presses Escape, clicks the close button, or clicks outside the popover
  • THEN it closes

Scenario: No help hides in a title attribute

  • WHEN any operator or member page is inspected
  • THEN no help text is carried only by a title attribute on a glyph

Requirement: Button weight follows the button's role

Button weight SHALL follow the button's role, never a count per page. btn-primary, filled, is a form's commit (create, save, apply, issue, extend, commit a reorder), exactly one per form, and the one link that starts a list page's work, its "New …" link to the record's create page. btn-outline-secondary is navigation, neutral sub-views, panel openers, a form's way out, and every search bar's button, because a search navigates rather than committing and the list's own "New …" link is the page's one filled control; the landing surface's lookup takes the same weight rather than an exception of its own (maintainer, 2026-09-04). btn-outline-danger opens the confirm modal in place, and btn-danger is the destructive commit inside it; a destructive commit SHALL NOT render filled danger outside the modal. btn-link is a tertiary inline action. btn-outline-primary SHALL NOT be used for a header action: a header action either starts the page's work, in which case it is filled, or it navigates, in which case it is outline-secondary. Page headers SHALL NOT carry a filled button. Solid btn-secondary SHALL NOT be used. The rule follows the published systems' intent (GOV.UK Design System, Button: "Avoid using multiple default buttons on a single page"; IBM Carbon, Button usage: "Primary button should only appear once per screen (not including the application header, modal dialog, or side panel)", and a lower-emphasis button in a page header when the content has a primary action): a list page carries one filled control, its "New …" link, and the create page it leads to carries one, its commit.

Scenario: Every form's commit is filled

  • WHEN a user views any open form (a create page's form, an edit form, a sub-record panel, Issue grant, Extend, the integration settings form, the org-type change)
  • THEN its commit button is btn-primary and it is the only btn-primary in that form
  • WHEN an operator views the products, entitlement sets, or plan ladders list
  • THEN the list section header's "New …" link renders btn-primary and no other control on the page is filled

Scenario: Openers and navigation are outline

  • WHEN a user views a section header's "Add …" or "Issue grant" opener, or a page header's action
  • THEN it renders btn-outline-secondary, never btn-primary and never btn-outline-primary

Scenario: Danger stays in the modal

  • WHEN a user views a tier's removal control on the ladder page
  • THEN the control renders btn-outline-danger and opens the confirm modal, and the filled btn-danger commit exists only inside it

Requirement: A panel opener states its open state in its glyph

A control that opens a collapsed panel SHALL carry a leading plus glyph, rendered through one shared part as an inline SVG marked aria-hidden="true" and focusable="false", sized between 12px and 14px, that rotates 45 degrees into a cross while the control reports aria-expanded="true", with a short transition suppressed under prefers-reduced-motion. The opener SHALL NOT change weight, colour, or fill while its panel is open: a painted-in trigger reads as a pressable button rather than a control that is already toggled (maintainer, 2026-09-03: it "looks pressable as opposed to untoggleable"), so the earlier rule that gave an expanded collapse trigger its variant's active palette is retired. The rotation follows Material Design's speed-dial FAB, whose plus rotates into a close glyph when the dial opens, and IBM Carbon's accordion, which flips its icon while a section is expanded.

Scenario: The glyph flips, the button does not

  • WHEN an operator opens a sub-record panel from its section header's opener
  • THEN the opener's plus glyph rotates into a cross
  • AND the opener keeps the same weight and colour it had while the panel was closed

Scenario: The glyph is decorative

  • WHEN a screen reader reaches a panel opener
  • THEN it announces the opener's label and its expanded state, and the glyph itself is not announced

Requirement: Forms use the layout family their kind names

Every form SHALL render in the layout family its kind names, and the declaration SHALL name that family rather than choosing one. Stacked: one control per row, labels above, in a section box that ends where the fields end (a readable width, left-aligned under its section header, never centered; maintainer, 2026-09-04: "Hugging definitely"), so the box carries no empty width beside the controls; used by create pages, edit forms, preview forms and the confirm modal. Dense: one row with labels above, small controls, each field's width one of five fractions of the row (full, half, third, quarter, sixth) that its control decides (a number and a date a sixth, a date-time a quarter, a select and a text control a third, a textarea on a line of its own, a checkbox at the width of its box and label), stepped one rung narrower or wider only where the field's values are shorter or longer than its control's usual ones, never sized in rems; where the reading order allows, the fields that share a line placed so their fractions fill it, with the commit on the last line at its natural width; no hint under any control and no error inside any column (a dense field's explanation is its help icon and its error renders below the row, so every label in the row sits at the same height); below the extra-large breakpoint every field takes its own line at full width, with the commit on the line after; used by sub-record panels only. Bar: one input group with a leading magnifier, the control, an outline commit and, when a query is active, the way out in the same group, at a search box's width unless the declaration is wide; used by search forms only. Table: one row per field, the label and its hint in the label column, the page's own cells in the declared columns, the small control in the control column, and one commit below the table; used by settings forms only. The create form and the edit form of one entity SHALL use the same family. No fifth family exists; a form that fits none needs a recorded decision. Side-by-side fields belong only in the dense family, because a multi-column form makes users misread which fields are required (Baymard, cited in the audit's research).

Scenario: Create and edit look the same

  • WHEN an operator compares a product's create page with the record page's edit form
  • THEN both render stacked, one control per row, at the same width, with the same labels, hints and help

Scenario: Sub-record panels are dense

  • WHEN an operator opens Add price, Add tier, Add rule, Issue grant, or Extend
  • THEN the form renders one dense row with small controls and a small commit at its end

Scenario: A dense field's width is a fraction its control decides

  • WHEN an operator opens Issue grant on a desktop
  • THEN the product select and the description each take a third of the row, the quantity a sixth and the date-time a quarter, every column edge lies on the twelve-column grid, and no column is sized in rems or is any fraction but full, half, third, quarter or sixth

Scenario: Fields that share a line fill it

  • WHEN an operator opens Issue grant on a desktop
  • THEN the first line is filled exactly by the product select, the quantity, the date-time and the reason, and the description and the commit share the second

Scenario: A list's search is a bar, not a stack

  • WHEN an operator views any governed list
  • THEN its search renders as one input group, the magnifier, the control and the outline button on one line, with no box and no button wrapped onto a line of its own

Scenario: A settings page is a table

  • WHEN an operator opens an integration's settings page
  • THEN every declared key is a row of one table carrying its effective value and its winning source in their own columns, and the page's single commit sits below it

Requirement: A settings control shows the value in force

A settings row's control SHALL be bound to the value in force: the stored override when one exists, the effective value otherwise. Every settings control SHALL set a value and SHALL do nothing else: its select offers only real values, a boolean is a checkbox, and a text control emptied against a value that was there SHALL be refused rather than read as a request to remove the override, which is the row's own action (design D21 round 5). The effective value SHALL NOT be restated in a hint, because the row's own Effective column carries it. Because every row arrives on every save, the handler SHALL compare each submitted value with the value its control was rendered with and treat an equal value as no change. Neither a table nor a bar SHALL render the "(optional)" marker: every setting is optional by nature, and a bar's one field is too, so the marker states nothing (maintainer, 2026-09-04: "kinda useless").

Scenario: The control says what is running

  • WHEN an operator opens a settings page for a key with no stored override
  • THEN its control shows the effective value, and the control offers no way to submit nothing

Scenario: Saving an untouched page changes nothing

  • WHEN an operator presses Save without editing any row
  • THEN no override is written or removed, and the page lands with its saved toast

Requirement: A form's way out follows its container

A form SHALL offer a way out when there is somewhere to go and none when there is not: a create page carries a Cancel link to its list; a sub-record panel carries a Cancel that closes the panel; a confirm modal carries its Cancel; an always-open edit form carries none. The way out SHALL render btn-outline-secondary after the commit; solid btn-secondary SHALL NOT be used anywhere.

Scenario: An edit form has no Cancel

  • WHEN an operator views a record page's edit form
  • THEN it carries one commit and no Cancel, Reset, or Discard control

Scenario: A panel closes from its Cancel

  • WHEN an operator opens a sub-record panel and presses its Cancel
  • THEN the panel collapses and nothing is submitted

Requirement: The commit's size, width, order and in-flight state are fixed

A form's commit SHALL render at full size on a page or an always-open form and at btn-sm in a dense row; at its natural width, never stretched to its column; first in the commit row, at the row's left, with the way out after it. Every mutation form SHALL lock its commit while the request is in flight and show an in-flight indicator in it. A destructive commit SHALL render btn-danger only inside the confirm modal; outside it, the trigger that opens the modal renders btn-outline-danger.

Scenario: A commit never stretches

  • WHEN a dense row's commit sits in a grid column
  • THEN it renders at its natural width

Scenario: A double submit is impossible

  • WHEN an operator presses a commit and the request is in flight
  • THEN the commit is disabled and shows its indicator until the response swaps

Requirement: Every control has a label, and its hint and error are described

Every form control SHALL have a <label for>; in a dense row with no room the label is visually hidden, never absent, and an aria-label SHALL NOT substitute for it. A label sits above its control, except a checkbox's, which sits beside the box, after it, because that label names the state the box turns on (every surveyed system places it so; the design system's boolean rule of 2026-09-03). A field's hint SHALL carry an id referenced by the control's aria-describedby; a field's error SHALL carry an id also referenced, with aria-invalid="true" on the control while the error shows; the hint SHALL remain when the error shows. The help icon SHALL render after the label as its sibling, never inside the <label>.

Scenario: A screen reader hears the hint and the error

  • WHEN a control with a hint is refused
  • THEN the control's aria-describedby names both the hint and the error, aria-invalid is true, and the hint is still visible

Scenario: No button inside a label

  • WHEN a field with help renders
  • THEN the help icon's button is a sibling of the label, not its child

Requirement: A select that needs a choice opens with a placeholder option

A select whose value must be chosen SHALL open with a first option that is disabled and selected and reads "Choose a …", so a form cannot submit a value the person did not choose. A select whose empty value clears a column SHALL open with a first option "None". A select in the Table family SHALL offer only real values: nothing on a table row is cleared by submitting the row, so an empty option there would be a choice that does nothing (design D21 round 5). A submitted value outside the options SHALL be refused.

Scenario: A mapping cannot default silently

  • WHEN an operator opens the Discourse mapping form without choosing an entitlement
  • THEN the select shows "Choose an entitlement" disabled and submitting refuses the field

Requirement: A checkbox is never required

A checkbox declares a boolean whose absence is false; the library SHALL refuse a checkbox declared as required, because "required" on a checkbox would mean "must be ticked" and the console has no such field.

Scenario: Absence is false

  • WHEN a form with a Public checkbox is submitted unticked
  • THEN the handler reads Public as false and reports no error

Requirement: Autocomplete is declared per field

The autocomplete attribute SHALL be emitted by the field part from the field's declared token: a real token where one applies (name, email, url, organization), off for keys, search and one-time values, and nothing on the form tag. A template SHALL NOT write the attribute by hand or repeat it.

Scenario: A person's email autofills

  • WHEN a member form asks for an email address
  • THEN its control carries autocomplete="email" and the form tag carries no autocomplete

Requirement: Success is reported by one of two mechanisms

A mutation that navigates (creation landing on the record; a settings save returning to its page) SHALL answer through the one navigating-success helper: an htmx submit gets HX-Redirect to the destination with a 200 and an empty body, and a native submit gets a 303 to the same destination. The destination carries the flash query parameter that the landing page renders as a success toast. A bare 303 SHALL NOT be the answer to an htmx submit, because htmx follows it and swaps the destination's full page into the form's target. A mutation that stays SHALL answer the region's re-render with the HX-Trigger success toast. A mutation SHALL NOT render an alert-success inside its response body or on the next page from a query parameter.

Scenario: A staying mutation toasts

  • WHEN an operator saves an edit form
  • THEN the form re-renders in place and a success toast fires; no banner appears in the body

Scenario: An htmx submit that navigates

  • WHEN an operator saves the settings form and the save is accepted
  • THEN the response is a 200 with an empty body and an HX-Redirect header naming the settings page with its flash key, and the browser navigates there; the page never renders inside the form's target

Scenario: A native submit that navigates

  • WHEN the same form is submitted without JavaScript
  • THEN the response is a 303 to the same destination

Requirement: A create flow's container follows its size

Record creation (a product, a set, a ladder) SHALL be its own page, because the record's page continues the work after landing. A sub-record with up to four fields that benefits from page context SHALL be a closed panel on its parent's page. A modal SHALL be used for confirmations only. A side panel is the named escape hatch for a sub-record form that outgrows a row, and is not built until one does. A drawer is not adopted; no surveyed source publishes a rule for it.

Scenario: A new sub-record form is a panel

  • WHEN a change adds a sub-record form with three fields to a record page
  • THEN it renders as a closed dense panel opened from the section header, not as a page or a modal

Requirement: A dense row's errors render under their controls

A refused dense form SHALL render each field's error under that field's control, inside its column, as every other family does. The row SHALL align its columns to the top under one label line, so a refused column grows alone and the commit stays level with the controls; a column with no label of its own (the commit, a checkbox) SHALL sit on the control line. The control SHALL keep its invalid styling and aria-invalid="true", and the error SHALL carry the id the control's aria-describedby names.

Scenario: The commit stays level with the controls

  • WHEN an operator stages a new rule from the add form without entering a limit
  • THEN the reason renders under the Limit control, in its column, and "Stage change" and "Cancel" sit at the same height as the controls