Files
member-console/docs/design-system.md
T
cgalo5758 9114271cc6 Right-align every Actions column and hide its header
Spread the Rules table's treatment to every table that carries verbs
(record-table-actions, archived 2026-09-19): the table is align-middle,
the Actions header and cells are text-end, every button in an Actions
cell carries ms-1, and the header's word is for assistive technology
only (a visually-hidden span; Primer: row actions do not require a
visible column header). The two integrations tables and the member
domains list, which headed their controls with an empty cell, take the
hidden word too. A new anatomy-lint rule, actions-column, refuses a
table that drifts from any of it.

Decisions on the way: a row tint marks the row in play (the current
workspace, a staged change), never a record's status; the member catalog
lists only what can be bought, so a Listed product without an active,
synced, recurring price is left out instead of shown with "Not available
for purchase"; two Actions cells that carried text in a verb's place are
empty (the Placements column and the pending panel already say why); and
four record tables gain their width floor.

Specs: page-anatomy "Tables share one density" modified, ui-quality-gate
gains "Lint refuses an Actions column without its treatment",
member-product-discovery's Extras bucket and truthful-copy requirements
modified. docs/design-system.md §6 states the treatment and its reasons.
2026-09-19 22:31:45 -05:00

1012 lines
59 KiB
Markdown

---
title: "Design System"
audience: [developer]
summary: "Bootstrap-first UI conventions, the custom HTMX primitives, and the WCAG 2.1 AA baseline for building the UI."
---
# Design System
This is a **Bootstrap usage guide plus a custom-primitives reference** — not a
standalone design system. The member-console UI is Bootstrap-first: it uses
[Bootstrap 5.3.6](https://getbootstrap.com/) components and utilities directly,
and supplements them only where Bootstrap genuinely falls short.
Status: M7c deliverable. Reused downstream by M8/M9 member-side UX.
**Sources**: a rule added to this document names the published design
system it follows (e.g. the Buttons table, §2, cites GOV.UK Design System
and IBM Carbon) or says why this document departs from one.
---
## 1. Foundation
### Bootstrap-first
There is **no custom token layer and no parallel utility system**. Spacing,
typography, colors, breakpoints, and layout all come from Bootstrap. Reach for a
Bootstrap utility class or component before writing CSS.
### Role of `app.css`
`internal/embeds/static/app.css` is **supplemental only**. A rule belongs there
only when Bootstrap genuinely cannot express it: the HTMX loading-indicator
hook, toast positioning, `<meter>` styling, the shell and rail (§5), the
page-anatomy parts (§6), and the icons the parts use. The contract comment
at the top of the file is the authoritative list of what it holds; this
page does not repeat it. Keep the file short; if it starts to grow a token
system, that is a signal to stop.
### Bootstrap CSS variable convention
Do **not** hardcode color values in `app.css` or templates. Reference Bootstrap's
CSS variables so the UI stays in sync if Bootstrap's defaults change:
```css
/* no */ background: #0d6efd;
/* yes */ background: var(--bs-primary);
```
Common ones: `--bs-primary`, `--bs-secondary`, `--bs-body-color`,
`--bs-border-color`, `--bs-success`, `--bs-danger`.
### Future theming hook
Bootstrap 5's CSS-variable architecture is the intended seam for per-deployment
theming (see the "Custom theming (two-tier)" backlog item in
`status/milestones.md`). Tier 1 = operator overrides a handful of `--bs-*`
variables; Tier 2 = unrestricted custom CSS. Nothing is built yet — but because
`app.css` references `--bs-*` instead of hex values, that feature stays a small
server-side addition rather than a refactor.
---
## 2. Approved component palette
### Buttons
Weight follows the button's role, not the page (design D19, round 4). Record
creation has a page of its own (design D20), so a list page carries exactly
one filled control, the "New ..." link that starts its work, and the create
page it leads to carries exactly one, its commit. Sub-record forms (a price,
a tier, a rule, Issue grant, Extend) stay closed panels on the record's own
page, so at most one of those is normally open.
| Variant | Use for | Notes |
|---|---|---|
| `btn-primary` | A form's commit: create, save, apply, issue, extend, commit a reorder; and a list page's "New ..." link to the create page | Exactly one per form; exactly one per list page |
| `btn-outline-secondary` | Navigation, neutral sub-views, and panel openers | The default weight. A page-header action is always this |
| `btn-danger` | A destructive commit | Inside the confirm modal only (§3) |
| `btn-outline-danger` | Opening the destructive confirmation | Pair with the confirm modal (§3) |
| `btn-link` | Tertiary inline actions | |
`btn-outline-primary` is not a header weight. 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; there is no third emphasis between them. A page
header's action (§6 "Page anatomy") never renders filled.
**Panel openers.** A control that opens a collapsed panel ("Add price",
"Add tier", "Add rule", "Issue grant", "Extend tier") is
`btn btn-outline-secondary btn-sm` and carries a leading plus glyph, the
shared `openerIcon` part, that rotates 45 degrees into a cross while the
control reports `aria-expanded="true"` (`.app-opener-icon` in `app.css`,
with a short transition suppressed under `prefers-reduced-motion`). The
glyph is the whole open/closed signal: the opener keeps its weight and
colour while its panel is open. The earlier rule that painted an expanded
collapse trigger with its variant's active palette is retired (maintainer,
2026-09-03: it "looks pressable as opposed to untoggleable").
GOV.UK Design System and IBM Carbon both name the single-primary-per-page
rule this table follows; Material Design and Carbon both flip an opener's
icon rather than its weight:
- GOV.UK Design System, *Button*: "Use a default button for the main call to
action on a page. 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 "use a tertiary button for page headers if there is a primary
button in the content section."
- Material Design, *Buttons: FAB* (speed dial): the FAB's plus rotates into a
close glyph when the dial opens. IBM Carbon, *Accordion*: the item's icon
flips while the section is expanded. The console adapts both to its panel
openers.
**Retired — do not use:**
| Variant | Color | Contrast on white | Why |
|---|---|---|---|
| `btn-outline-info` | `#0dcaf0` cyan | ~1.8:1 | Fails WCAG 2.1 AA (needs 4.5:1) |
| `btn-outline-warning` | `#ffc107` yellow | ~1.28:1 | Fails WCAG 2.1 AA (needs 4.5:1) |
Both outline variants render unreadable text against the app's white
background. Replace them with `btn-outline-secondary`. (The *solid*
`btn-info` / `btn-warning` and the `text-bg-*` badge variants are
unaffected; only the outline button variants are retired.)
### Badges
Use `text-bg-*` badge variants (`text-bg-secondary`, `text-bg-success`, etc.)
rather than bare `bg-*` where contrast matters — `text-bg-*` pairs the
background with a contrast-checked foreground.
### Alerts
`alert-success` / `alert-danger` / `alert-warning` are fine for **in-page,
persistent** messaging (e.g. validation summaries rendered into a swap target).
For **transient** success/error feedback that must survive an HTMX swap or
navigation, use the toast primitives (§3) instead.
### Forms
Every form on either surface is declared, rendered and validated by the
forms library; §8 is its page. Two component-palette rules stay here
because they are Bootstrap usage decisions the library's Checkbox control
follows rather than makes: a switch (`form-switch`, `role="switch"`)
implies its toggle takes effect immediately, so a control committed by the
form's own Save button is always a plain checkbox (`form-check` alone, no
`role`); and a boolean with two named states is one checkbox on its own
line, never a pair of radios, with the label naming the ticked state and
the help icon after it saying what both states mean (design D6, round 4;
worked example in §8's registered-forms table, `operator.product`'s
Visibility field).
### Inline code
A bare `<code>` renders in the body colour on a faint chip
(`--bs-tertiary-bg`, small padding and radius), keeping the mono font,
instead of Bootstrap's default pink `<code>` colour: an identifier (a key,
a route) is not an error, and the unstyled pink read as one (design D16).
`pre code` is exempt — a code block keeps its own background.
### Card headers
Card headers are white (`--bs-body-bg`), not Bootstrap's default tinted
`.card-header` background. Links and `btn-outline-primary` buttons inside a
card header use `--bs-link-hover-color` (6.1:1 on white) rather than the
default outline-primary colour, which clears a contrast failure the tinted
header produced (4.24:1) without a one-off override at each call site
(design D16).
### Flush lists
A short roll of rows that are not records of one kind (a checklist's steps,
a status roll) renders as `ul.list-group.list-group-flush` inside a card
body, each row `li.list-group-item.px-0.py-2`: the setup checklist's steps
and the overview's System providers are the two consumers, and they are
identical. Records of one kind are a table instead. A flush list wraps its
rows at narrow width where a table would scroll.
---
## 3. Custom primitives
### Confirm modal
A single shared, accessible confirmation dialog replaces htmx's native
`hx-confirm` on both surfaces. Implemented by
`static/confirm-action-modal.js`; the modal markup
(`shell_confirm_modal.html`, `#confirmActionModal`) lives in the operator
layout and in every member page shell. `hx-confirm` is banned outright;
`member-console lint` fails any use. Member flows that confirm through it:
the plan cancel, domain release and cancel-verification, FedWiki archive
and delete-permanently.
Trigger contract — `data-*` attributes on any trigger element:
| Attribute | Required | Meaning |
|---|---|---|
| `data-bs-toggle="modal" data-bs-target="#confirmActionModal"` | yes | Opens the modal |
| `data-action-url` | yes | Endpoint to call |
| `data-action-method` | yes | `post` or `delete` |
| `data-action-target` | yes | CSS selector for the HTMX swap target; a `closest <selector>` value resolves against the trigger element (the modal's form is outside the trigger's ancestry) |
| `data-action-swap` | no | HTMX swap style (default `innerHTML`) |
| `data-action-title` | no | Modal title (default `Confirm`) |
| `data-action-body` | no | Modal body text/HTML |
| `data-action-confirm-label` | no | Submit button label (default `Confirm`) |
| `data-action-style` | no | `danger` \| `primary` \| `warning` (default `danger`) |
| `data-action-fields` | no | JSON object of hidden form fields |
Behavior: a successful (2xx/3xx) response closes the modal; a failed request
leaves it open so the operator can read the error in the swap target and retry.
### Error toast
Transient error feedback. Markup (`#errorToast`) lives in each layout's
`.toast-container`; behavior in `static/error-handler.js`. It is shown
automatically by the `htmx:response:error` handler (HTTP 4xx/5xx; silent for
422, whose body lands inline per the validation contract) and the
`htmx:error` handler (network failures, timeouts), and can be shown manually
via `showErrorToast(message)`. Whether an error body swaps is not decided in
script: see §7 "Error responses".
- `role="alert"`, `aria-live="assertive"` — interrupts the screen reader, because
an error needs immediate attention.
### Success toast
Transient success feedback. Markup (`#successToast`) lives in each layout's
`.toast-container`; behavior in `static/success-toast.js`.
Triggered from the server via an `HX-Trigger` response header:
```go
// Works for both HTMX partial swaps and hx-boost navigations.
w.Header().Set("HX-Trigger", `{"showSuccessToast": "Product created"}`)
```
- `role="status"`, `aria-live="polite"` — announced without interrupting,
because success is not urgent.
- The `.toast-container` lives outside any HTMX swap target, so the toast
survives partial swaps and `hx-boost` full-page navigations.
An inline `alert-success` in a POST response body would be discarded by the
redirect target and never seen — that is why this primitive exists.
Existing templates are **not** migrated to this primitive in M7c; M7e owns the
convention and timing for adopting it across flows.
A create-then-land landing (`?flash=created`; design D20, round 2: "a
success toast, not a banner") is a fresh top-level navigation, not an
htmx-driven exchange, so no `HX-Trigger` header can reach it. Its confirmation
instead rides `OperatorPageData.FlashSuccess`: `operator.html` pre-renders
the message into `#successToast` and marks it `data-show-on-load`;
`success-toast.js`'s load-time init shows it once and removes the marker.
The `?flash=missing` bounce on a list page is unrelated — it is an error, not
a confirmation, and stays an in-page `alert-danger`.
### Loading states
HTMX request indicators use the `.htmx-indicator` hook (styled in `app.css`).
Place a `.htmx-indicator` element inside the triggering control and let HTMX
toggle it via `.htmx-request`. For destructive forms, also use
`hx-disable` to disable the submit button during the request (see the
confirm modal form).
### Empty states
Empty collections render through the `emptyState` part (§6): build a
`server.EmptyStateParams` in Go and `{{ template "emptyState" ... }}` in
the template — never a hand-rolled centered block (lint-caught). The
part's blocked branch names a missing prerequisite with a link to create
it; the plain branch is centered muted text with an optional note. Keep
the copy specific ("No plan ladders yet.") rather than generic ("No
data").
### Disabled controls
A disabled control's reason renders through the `disabledControl` part
(§6): build a `server.DisabledControl` (`NewDisabledControl` for the
operator surface's default, `.AsVisible()` for the member surface's) and
`{{ template "disabledControl" ... }}` — never a hand-rolled
`<span title="...">` around a disabled button. The reason always lives in
the DOM next to the control, referenced by the button's
`aria-describedby`, so a screen reader announces it regardless of
presentation; which presentation is the surface's density choice: a
tooltip on a focusable wrapper (operator, maintainer decision 2026-08-23)
or ordinary `<small>` text (member). Both variants wrap the button in
`span.disabled-control`, which carries the not-allowed cursor: a disabled
button receives no pointer events of its own and so cannot show a cursor
by itself.
---
## 4. WCAG 2.1 AA baseline
- **Contrast.** Text and meaningful UI must meet 4.5:1 (3:1 for large text /
components). The two retired outline button variants (§2) failed this; check
any new color use against it.
- **`aria-live` for async regions.** Content that appears without a navigation —
toasts, and HTMX swap targets that surface validation or results — must be in
an `aria-live` region so screen readers announce the change. Use `assertive`
only for errors; `polite` for everything else.
- **Focus management.** Modals trap and restore focus (Bootstrap handles this for
`.modal`). Do not remove focus outlines. Interactive controls must be
reachable and operable by keyboard.
- **Semantic markup.** Use real `<button>`/`<a>` elements (not clickable
`<div>`s), associate every form control with a `<label>`, and give icon-only
controls an `aria-label`.
- **Audit.** M7f runs the formal axe + Lighthouse a11y pass and keyboard-nav
review across all operator screens; this section is the standing checklist
that pass measures against.
---
## 5. Application shell
Both surfaces render one shell (chrome-conventions: "One shell for both
surfaces", "Session controls live in the account menu on both surfaces",
"The surface switch is a visible, mirrored rail entry"). The data is one
`server.Shell` value per page; the markup lives in
`templates/partials/shell_topbar.html` (both surfaces),
`templates/partials/shell_rail_member.html` (member rail) and
`templates/partials/shell_rail_account.html` (the account items in the
drawer, both surfaces); the operator rail stays in `operator.html`. The convention, and the reasoning behind it, came
from the August 2026 shell survey; the survey is a frozen record, this
section is the rule.
### Top bar
The bar holds the brand and exactly one control, and nothing else, ever
(design D16, round 2; `mobile-shell-menu` D2):
- The brand (the configured deployment name, linking to `/` on both
surfaces) is the left-most element at every width. It truncates with an
ellipsis instead of wrapping when a deployment name is long
(`max-width: 55vw` beside the account trigger; uncapped below `lg`, where
only the toggler shares the line).
- One control at the right end: the account menu at `lg` and up, the menu
toggler below `lg`.
DOM order is brand, account menu, toggler at both widths; the display
utilities decide which of the last two renders (`d-none d-lg-flex` on the
account list, `d-lg-none` on the toggler) and `ms-auto` on each puts the
one that renders at the right end. The bar carried three items until
September 2026 — toggler, brand, account trigger — and was measured at
390px with 13px to spare; the account trigger then left the bar below `lg`
for the drawer, which is where the room for a long deployment name came
from.
Neither surface names itself here: the operator surface's "Operator" label
left the top bar for the trail's root crumb (below, "Trail"). Help lives in
the account menu ("Get help"), not a bar button — a "?" bar button was
proposed and withdrawn in round 1's review.
### Account menu
Two homes, one menu, one order. At `lg` and up it is a Bootstrap dropdown
in the top bar whose trigger is a `<button>` labelled with the person's
display name (`Shell.Label()`: name, else username, else "Account"), so
`hx-boost` never treats it as a link. Below `lg`, where the rail's drawer
is the shell's one menu, the same items render inside it as a second,
separately named list at the drawer's foot, from
`partials/shell_rail_account.html`, so the two surfaces cannot drift
(`mobile-shell-menu` D4). Exactly one of the two renders at any width. The
drawer's group is set apart by distance, one hairline and its heading, and
draws no divider inside itself: the dropdown's divider before Sign out does
not travel (D8; the drawer has room the dropdown does not, and rules of
equal weight read as peers). Its heading is the person's name and email,
which is also the list's accessible name, so a screen reader can tell the
group from the destinations above it.
Items, in order: a
non-interactive header (name, email); "Identity and Access" with the
external-link icon nowrap-bound to the label and the plain-words tooltip,
`target="_blank"`, `hx-boost="false"`; "Get help" to the configured
`support-url` (`target="_blank"`, `hx-boost="false"`), rendered only when
that key resolves to a value; a divider; "Sign out", a `<button>` that
posts to `/logout` (`hx-post`, `hx-swap="none"`) and is declared as an
action trigger, since it is a single-intent control with nothing to fill
in. It posts because sign-out changes state: a link would be a GET any
page on any origin could send the browser to, and a GET at `/logout` now
leads back to the console with the session intact. The handler answers the
htmx request with `HX-Redirect` to the identity provider. Sign out is plain
text, never the destructive colour (that colour is for destructive data
actions), and always last. Nothing else goes in this menu, and no session
or account control renders anywhere else.
### Rail
Task destinations only: Dashboard, Products, Billing on the member surface;
the eight operator sections on the operator surface. Fixed at 15rem
(`.app-sidebar`); entry content wraps rather than widening it; no children,
no headed groups. The rail ends with the surface switch, the one separated
non-section entry (`.app-rail-switch`, the border-top idiom): "Operator
panel" on the member rail for people who hold the operator role, "Member
panel" on the operator rail (design D25, round 2 — was "Member dashboard").
It is never marked active and is not duplicated in the account menu or the
top bar; below `lg`, where the drawer carries the account items too, the
switch stays a rail entry above the account block's divider. Moving the
switch into the account menu instead was rejected because an operator uses
it daily: GitLab moved its equivalent admin switch into the user dropdown
and opened an issue about people no longer finding it.
### Trail
Every page except the surface's own root carries a location trail (a
Bootstrap breadcrumb): the root crumb is the surface itself ("Operator" →
`/operator` on the operator surface, "Home" → `/` on the member surface),
then the rail entry when the page sits below one, then any real
intermediate pages, then the current page unlinked. Rail-entry pages
therefore read "Operator / Products". The two surface roots — the operator
overview, the member dashboard — carry no trail at all (design D18, round
2). Mechanism: the `pageHeader` part (§6) renders the root crumb from a
per-template-set `surfaceRoot` template function, so no page constructor
names it; `PageHeader.NoTrail` suppresses the whole trail on the two roots.
### Below `lg`
The rail element is a Bootstrap `offcanvas-lg offcanvas-start` (`#app-rail`):
a static rail at `lg` and up, a left drawer below, opened by the top bar's
toggler and closed by its own close button, the backdrop, or choosing an
entry. Below `lg` that drawer is the shell's one menu, in two groups: the
task entries and the surface switch in the rail's list, then the account
items in their own list, pushed to the drawer's foot by an auto margin so
both surfaces put it in the same place whatever their section count. The
account group's wrapper is `d-lg-none`, so the static rail above the
breakpoint carries task destinations and the switch only; the drawer's
padding sits on `.offcanvas-body`, so the two lists share one inset. One
drawer rather than two is what keeps the section list single-sourced per
surface (`mobile-shell-menu` D1); two lists rather than one is what keeps
the account items from being announced as destinations (D3, D8). `static/shell.js` closes the drawer on an entry click, because
Bootstrap's `data-bs-dismiss` cancels an anchor's navigation and a boosted
navigation on the operator surface would otherwise leave Bootstrap's scroll
lock on `<body>`. Navigation is never hidden behind a toggler at `lg` and
up: NN/g's study of 179 participants over six sites found that hiding the
primary navigation cut its discoverability almost in half, that the hidden
desktop menu was used in 27% of cases, and that desktop tasks took 39%
longer.
### Labels
"Sign out" (matching the identity provider's "Sign in" and "Sign out"
screens), "Identity and Access", "Get help", "Operator panel", "Member
panel" (ui-vocabulary: "Shell controls use canonical labels"). The
`/logout` route name is not copy. Where two labels name what looks like
one concept, a renaming pass checks the collision against the concept's
model card before it unifies the words, because the two words may be
carrying a model distinction the interface has failed to convey.
---
## 6. Page anatomy
Every page on both surfaces is built from the same parts (spec
`page-anatomy`; enforced by `member-console lint`, spec `ui-quality-gate`).
The anatomy is parts and lint rather than a section of written rules
because a convention written as prose is re-read differently by every
contributor and drifts, while a convention that ships as a shared part
with a lint rule behind it is applied the same way every time.
The parts are `define`s under `templates/partials/ui_*.html`, parsed into
every template set by `web.ParseUIPartials`, and they take values built in
Go (`internal/server/anatomy.go`), usually as methods on the page's data, so
a template never composes a title, a badge class, or an empty state itself.
The Domains and Integrations pages are the reference implementations. A
change that introduces a part sweeps both surfaces for every hand-rolled
instance of the idiom it replaces before it lands, because reading a page
only against its neighbours misses the other places that idiom already
exists.
| Part | Renders | Value |
|---|---|---|
| `pageHeader` | the page title, `h1.h2`, with the location trail above it (§5 "Trail": a Bootstrap breadcrumb rooted at the surface, then the rail entry and any real intermediate pages, then the current page unlinked; suppressed only on the surface's own root), an optional one-sentence lead below it, and one right-hand slot holding a muted count **or** one action control, always `btn-outline-secondary` and never filled (design D19); the only `<h1>` on a page. A record whose name the identity provider owns marks that field's own `<dt>` label with the `managedGlyph` part instead of marking the title (the person page today; design D13, round 5) | `server.PageHeader`; `Check()` aborts the render when both a count and an action are set |
| `sectionHeader` | an in-page section title, `h2.h5`, with an optional muted count or one action, titled outside the box its content sits in. The action is a filled link when `Link.Filled` marks it as the section's one call to action (a list's "New ..." link to the create page), an outline-secondary link otherwise, and an outline-secondary opener carrying the `openerIcon` plus glyph when `Link.Toggle` names a collapsed panel (§2 "Buttons") | `server.SectionHeader` |
| `openerIcon` | the panel opener's leading plus glyph: an inline SVG, `aria-hidden`, that rotates into a cross while its button reports `aria-expanded="true"` | no value; used inside an opener's markup |
| `chevronIcon` | the linked card's trailing chevron: an inline SVG, `aria-hidden`, in the muted colour, the mark that a card navigates ("A linked card", below) | no value; used inside a linked card's body |
| `managedGlyph` | the identity-provider glyph a field's own `<dt>` label carries when the provider, not the console, owns that field: an inline SVG, `aria-hidden`, plus visually-hidden text "Managed by the identity provider" and a matching title tooltip on the wrapping span (the person page's Name and Email labels today; design D13, round 5) | no value; used inside a label's markup |
| `sectionSummary` | the same header as the `<summary>` of a native `<details>`, for a secondary section that opens on demand; the count rides in parentheses | `server.SectionHeader` |
| `readout` | one headline count or amount: a small semibold uppercase muted label (the eyebrow, the console's one uppercase text) over an `fs-3` semibold value, with an optional small muted caption. A value that could not be loaded renders the em dash marker with its title and the caption "Count unavailable", never a zero; a value that needs an operator renders in the danger colour. The part renders no anchor and its label is never a link: when a readout navigates, its box is the link ("A linked card", below). The overview's tiles, the Stripe page's delivery queue and the Products page's catalog overview are its consumers | `server.Readout` |
| `statusBadge` | a status badge from one state-to-style map: title-case label, one tone per state (`success`, `secondary`, `warning`, `danger`, `info`, `light` with a border), optional tooltip; the only `.badge` | `server.StatusBadge(state)`; an unknown state renders raw and `secondary`, and `TestStatusBadgeMapIsComplete` names it |
| `emptyState` | the one empty-collection idiom: a warning alert naming the missing prerequisite when blocked, otherwise centered muted text with an optional note and outline action; no borders, no boxes, no bare "No X found." The branches and copy are `empty-state-guidance`'s | `server.EmptyStateParams` |
| `disabledControl` | a disabled button plus the reason it is disabled, always in the DOM and referenced by `aria-describedby`: a tooltip on a focusable wrapper (operator) or ordinary `<small>` text (member, the `Visible` variant); no hand-rolled `<span title="...">` | `server.DisabledControl` (`NewDisabledControl`, `.AsVisible()`) |
| `helpIcon` | a help icon: a button that opens a Bootstrap popover on click, titled with the field or column it explains, with a close button in its header; Escape and a click outside the popover or its trigger also close it (`help-init.js`); no hand-rolled `<span title="...">` gloss (design D23) | `server.HelpIcon` (`helpIcon(label, text string)`) |
| the list scaffold | search, facet pills, paging, and the searched-to-nothing state for lists of records (`listControls`, `listPager`, `listNoMatch` in `operator_list_controls.html`, spec `operator-list-scale`) | `server.ListNav` |
Help is disclosed by click-opened popovers, one pattern everywhere (design
D23): a help icon opens on click, not hover, titled with the field or
column it explains, with a close button in its header; clicking outside it
or pressing Escape also closes it. Hover tooltips remain only for the
disabled-control part's reason on a control (§3 "Disabled controls") — a
reason a control is unavailable, not help about what a field means.
Rules the parts carry, stated once:
- One title size (`h1.h2`), one section size (`h2.h5`); heading levels
descend one at a time. Every page, detail and edit pages included, is
titled through `pageHeader` (a detail page's title is the instance's
name, never a card title), and the location trail is the header's: above
the title, outside any box, nowhere else (§5 "Trail"); no page renders a
"← Back to" link.
- A page's kind decides whether its sections are boxed. A rail-entry page
renders its lists bare under their headers (Organizations, People,
Grants, Integrations, Domains); a record page and a surface root box each
section under its header (the organization composite, the member
dashboard, the operator overview), the empty state included.
- One readout size (`fs-3`), one step under the page title and above the
section title, so no number on a page outranks its own title: a headline
count or amount renders through `readout`, never through `display-*` or
a page's own `fs-*`. The readout's uppercase eyebrow is the only
uppercase text on the console; section titles and table headers stay in
sentence case.
- A linked card (the overview's tiles) is an `a.card` in the body colour
without underline, marked two ways: the `chevronIcon` glyph at its
trailing edge (an inline SVG in the muted colour, hidden from assistive
technology; a chevron says "go into", where the external-destination
arrow says "leaves the console") and the tertiary background on hover
(`a.card:hover`, the token `table-hover` uses), with no elevation and no
motion. Its label is the readout's label, not a link, and nothing inside
it is a link. A boxed ledger on a record page or a root (the composite's
members and tier changes, the overview's activity) sits in a `card-body`
with the list scaffold's pager under its table.
- Record tables are `table table-hover table-sm` inside `.table-responsive`;
each row's primary cell (the identifier, chrome-conventions row
navigation) is a `<th scope="row">`, semibold (app.css) and announced as the
row's header;
`align-middle` only where a row carries a control taller than its text.
A record table also carries `.table-record` (`app.css`, `min-width:
44rem`; a table narrow by nature — few columns, short content — carries
`.table-record-sm`, `min-width: 28rem`, instead of tuning the shared
class), and identifier (`<code>`), money, and date cells carry
`text-nowrap`, so `.table-responsive` scrolls the table at narrow width
rather than squeezing its cells (design D9, ACC-5). A record table
holding rows of several kinds names each group with a row whose label is
`small fw-semibold text-body-secondary`, with `.75rem` of air above it
and `.25rem` below and no line, because a 2px divider and a bold word
give the least important structural unit the heaviest ink on the page. A
row tint inside a record table marks the row in play, the one the person
is on or is changing (`table-active` on the current workspace,
`table-light` on a row whose change is staged); a record's own status is
never a tint, a badge names it, because `table-hover`'s wash is nearly
the same grey as `table-light` and a second tinted surface for a state
reads as the hover continuing. A table whose rows carry verbs heads them in a
trailing column named Actions (chrome-conventions) and takes the Rules
table's treatment, Bootstrap utilities and no CSS (maintainer,
2026-09-18: "Actions right aligned is definitely staying"): the table is
`align-middle`, because a `btn-sm` is taller than a line of table text
and a top-aligned row reads as two levels; the Actions header and every
Actions cell are `text-end`, so every row ends at the table's edge, a
scan down that edge finds a verb in the same place in every row, and a
cell with no verb is simply empty there; every button in an Actions
cell carries `ms-1`, so the gap between two buttons is a fixed 0.25rem
rather than the source's whitespace (the forms library emits `ms-1` on
every `formRowAction` already); and the header's word is for assistive
technology only, `<span class="visually-hidden">Actions</span>`, because
the verbs name themselves and a visible word over them explains the
interface (Primer's DataTable guideline: row actions "don't require a
visible column header", and its example hides the word the same way;
maintainer, 2026-09-19). The `actions-column` lint rule holds every
Actions table to it.
- Identifiers (keys, operation names, resource refs) render as `<code>` in
the muted small style, never as badges; badges are for states. A boolean check marks only the
exception: an unverified email gets a warning badge, a verified one
shows nothing. An attribute boolean renders as plain Yes/No text (the
lone em-dash marker means unavailable, never No).
- Badges render only as `text-bg-<tone>` (the part's markup), so
Bootstrap computes black or white text for WCAG contrast per tone; the
legacy `bg-<tone>` badge idiom (white text on warning yellow fails
contrast) is banned and lint-caught.
- A secondary section opens on demand through a native `<details>` with
`sectionSummary`; nothing else hides sections.
- UI copy has no em dashes (the lone "—" empty-value marker is exempt);
destructive actions confirm through the modal (§3), never `hx-confirm`.
- A page arrives complete: every region that also exists as a partial is
composed on the server through `server.Include` (an in-process request to
the partial's route; integrations get it through `Deps`), never fetched
after load. Polling and event-driven refreshes render their current state
first. A region that fails to compose renders an inline notice.
What holds it: `member-console lint` fails a template that renders an
`<h1>`, an `<h2>` (`raw-section-title`, everywhere but
`ui_section_header.html` and the shell's confirm dialog), a `.badge`, or a
centered empty block outside the parts, an
`hx-confirm`, a load-triggered fetch, an em dash in copy, a heading skip, a page body without the header,
or an Actions column without its treatment (`actions-column`: the table's
`align-middle`, `text-end` on the header and on every cell holding a
control, the header's word visually hidden, `ms-1` on every button in such
a cell, Actions as the last column)
(`internal/lint/anatomy.go`). The rebuild allowlist
(`internal/lint/anatomy_allowlist.txt`) emptied when the 10k.2 sweep
finished rebuilding every template; it stays empty — a new violation
fails lint outright. `make screens`
resets the application database to the demo state and captures every route at desktop and phone width into contact sheets under
`test/screens/out/` and compares them with the accepted baseline; a change
touching templates or `app.css` is not done until its sheet has been
reviewed against the neighbouring pages (`first-contact-ux-process.md`,
"Definition of done").
---
## 7. Hypermedia (htmx 4)
The vendored library is htmx 4 (`internal/embeds/static/htmx.min.js`), loaded
without the `htmx-2-compat` shim and without the `implicitInheritance` config
flag; lint fails any reference to either.
### Explicit inheritance
An htmx attribute applies only to its own element unless it carries the
`:inherited` modifier. Mark inheritance only where a descendant genuinely
relies on it — today that is the page shells (`hx-boost:inherited` and the
CSRF `hx-headers:inherited` on `<body>`) and nothing else, because every
in-page control declares its own `hx-get`/`hx-target`/`hx-swap` set. A link
that must not be boosted overrides with its own `hx-boost="false"`.
`hx-disinherit` no longer exists and is lint-caught.
### Error responses
htmx 4 swaps every response except 204/304. The contract:
- **422** is the validation signal: the body is the form re-rendered with
`FieldErrors`, and it swaps inline by default behavior — no script, no toast.
- **403 and 5xx** are suppressed declaratively by the `"noSwap": [204, 304,
403, "5xx"]` list in each page's `htmx-config` meta tag, so failed requests
preserve the operator's input; `error-handler.js` reports them as toasts.
- A page that needs different per-status routing uses `hx-status` on the
element, never a script that steers swaps.
### Progressive enhancement (recorded decision, 10k.3)
| Form category | Without JS | Decision |
|---|---|---|
| Mutation forms (hx-post/put/patch/delete), both surfaces | Fail: CSRF needs the `X-CSRF-Token` header; a native top-level POST arrives with `Origin: null` under the no-referrer policy and is rejected | Require htmx. No native `action` fallback; one that always 403s is dishonest. |
| Operator list controls (search, facets, paging) | Work: native GET `action`/`method` present, CSRF-free | True progressive enhancement, kept. |
| Operator lookup (`operator.lookup.search`, the `Search` kind, §8) | Work: native `method="get"` `action`, no CSRF header | True progressive enhancement, kept: a lookup writes nothing, so it never needed the mutation path (`form-conventions`). |
| Timezone offset attachment (`grant-valid-until-tz.js`) | Server falls back to its own zone | Optional enhancement, kept. |
| Stripe checkout | `HX-Redirect` handoff requires htmx (see `billing.go`) | Require htmx, recorded in code. |
A new form names its category; a different answer needs a recorded decision
here, not an undocumented `action` attribute (`form-conventions`).
### Server-side headers
A handler whose response differs by the `HX-Request` request header sets
`Vary: HX-Request` at the branch. Fragments live at `/partials/*` routes;
page URLs always return full pages to htmx, which is what makes htmx 4's
re-fetching history restores (no localStorage cache) correct here by
construction.
What holds it: `member-console lint` fails `hx-disinherit`, htmx 2 camelCase
event names in first-party scripts under `static/`, and any `htmx-2-compat`
reference (`internal/lint/anatomy.go`).
---
## 8. Forms
Every form on the member and operator surfaces, and on an integration-owned
page, is one declared value: a `forms.FormSpec` (spec `form-library`;
design D1 to D14 and D20 to D23 of the forms-library change). The package is
`internal/forms`, below `internal/server` and the integrations' `web`
packages, so both can import it without a cycle; it knows nothing about
handlers, databases or the domain. One part (`ui_form.html`,
`ui_form_field.html`) renders every declaration in one of three modes, one
`Parse` reads a request through it, and one registry (`forms.Register`,
`forms.All`) indexes every declaration so `internal/server/forms_routes_test.go`
and `internal/forms/invariants_test.go` can hold the whole console to the
same rules; `internal/server/forms_doc_test.go` holds this page's table to
the registry.
**The library is closed on purpose.** Its goal is accessible and
consistent forms by default; its customisation options are limited on
purpose, in Primer's words for the same tradeoff GitHub's design system
makes. A form that cannot be expressed in the declaration is a change to
the library, or a recorded exception, never a hand-built form
(`raw-form-control`, `form-without-part`, lint-caught). The one escape
hatch is a `data-*`/`hx-*` attribute map on a field; it cannot override an
attribute the part owns (`class`, `id`, `required`, and so on).
### The declaration
A form is declared once, in a `*_forms.go` file beside the handler that
serves it (the way `anatomy.go` builds page headers per page), and
registers itself with `forms.Register` at that package's init, e.g.
`internal/server/operator_product_forms.go`'s `operator.product` or
`internal/integrations/fedwiki/web/forms.go`'s `fedwiki.site.create`. The
registry is the index, not the home: each declaration is still read and
maintained where its handler lives. A `Field` written by more than one
form to the same column is declared once, in `internal/forms/shared.go`
(`GrantDescription`, `GrantValidUntil`, `RecordName`, `RecordDescription`),
and included by reference; a variant is a new field with a new name, never
a copy with loosened rules.
### Kinds and families
Every declaration names a **kind**, which decides its outcome contract
and what the capture utility does with it: `Create`, `Edit`, `SubRecord`,
`Settings` and `Preview` are refused with a 422 the capture also submits
empty to photograph; `Search` navigates by a native GET and is never
submitted; `Confirm` guards a mutation fired from outside the form (the
shared confirm modal) and carries no fields of its own, because one
dialog serves whichever trigger opened it; `Batch` is a table of records
edited in place, refused with a 422 like the rest but never submitted
empty, because its commit applies a staged batch that an empty submission
does not have.
Every declaration also names a **layout family**, of which there are five
and no sixth:
| Family | Layout | Used by |
|---|---|---|
| **Stacked** | One control per row, labels above, in a section box that ends where the fields end (below) | `Create`, `Edit`, `Preview`, `Confirm` |
| **Dense** | One row (`row g-2 align-items-start app-form-dense-row`), labels above, `-sm` controls, the commit at the row's end at `btn-sm`, and every field error under its control; the commit and a checkbox carry `app-form-unlabeled`, the label line's height, so they sit on the control line. A field's column is full, half, third, quarter or sixth; the control's natural width picks its rung, and a declaration steps it one rung only where the field's values are unusual; nothing is sized in rems | `SubRecord` (Add price, Add tier, Issue grant, Extend) |
| **Bar** | One `input-group`: a leading magnifier, the control with its label visually hidden, the commit as `btn-outline-secondary`, and Clear beside it when a query is active. No box, capped at `30rem` unless the declaration is `Wide` | `Search` (the list controls, the landing surface's lookup) |
| **Table** | One row per field: the label and its hint in the label column, the page's own pre-rendered cells (`FormSpec.Columns`, `Binding.Cells`) next, the `-sm` control last, and one commit below the table. No box | `Settings` (each integration's settings page) |
| **Rows** | The caller's own table of records as the body (`FormView.Body`), its cells holding the declaration's `-sm` controls through the field part (`FormView.RowField`) and its rows' actions through the row-action part (`FormView.RowAction`); the open editors and the staged batch ride as hidden inputs; one tray under the body, at the body's width, carries the batch's lines with their own controls, the tray's fields and the commit. No box around the body. A surface earns this family when its editable fields fit the row; a record with many properties is edited on its own page or panel instead, and an expanded row is for reading more rather than for editing (PatternFly, Cloudscape, Carbon) | `Batch` (the entitlement set's rules) |
Each control's own rung:
- Number, date: a sixth
- Date-time: a quarter
- Select, text, email, URL: a third
- Textarea: the full row
- Checkbox, radio, static: their own content (`col-12 col-xl-auto`)
- Hidden: no column
The declared field order is the reading order and the tab order, and where
the reading order allows it, fields sharing a line are placed so their
fractions fill it, with the commit on the last line. Below the extra-large
breakpoint every field takes its own line at full width, with the commit
on the line after; the fractions apply from 1200px, where the content
column beside the sidebar is wide enough for them.
**The pairing is fixed**, and the invariants refuse any other: `Search` is
Bar, `Settings` is Table, `SubRecord` is Dense, `Batch` is Rows, and
`Create`, `Edit`, `Preview` and `Confirm` are Stacked. Kind says what a form is for; family
says how it is laid out. A declaration names both so this page's table and
the registry read the same way, but the choice of family is not the
declaring file's to make: the first cut let a search and a settings page
pick Stacked, and every list page rendered a narrow input with its button
wrapped onto the next line while both settings pages lost the effective
value, the source and the status their tables had carried (maintainer,
2026-09-04).
The create page and the edit form of one entity render the **same
declaration**, in two modes; they differ only by the fields each side
declares, so they cannot drift apart. Side-by-side fields belong only in
the dense family: a multi-column stacked form makes people misread which
fields are required (Baymard, cited in the forms audit's research). Only
the optional fields are marked, following GOV.UK and Polaris; the
published systems disagree here, because Material, Primer and USWDS mark
the required ones with an asterisk instead, so this rule follows two
systems rather than a consensus.
A table's control shows **the value in force**: the stored override when
one exists, the effective value otherwise. Because every row arrives on
every save, the handler compares each submitted value with the value its
control was rendered with and treats an equal one as no change. The table
never renders the "(optional)" marker, and neither does a bar: every
setting is optional by nature, and a bar's one field is too. A settings
text control's placeholder is its type's format example
(`https://host.example`, `1h30m`, `10`, `a, b`; a bare string carries none),
shown only once the control is empty (typed-config-keys design D4).
**A table's control sets a value and does nothing else.** Its select
offers only real values, so there is no `NoneOption` in a table; a boolean
is a checkbox, ticked from the value in force, with the label column
carrying its label and the control column holding the input alone. A text
control emptied against a value that was there is refused, not read as an
instruction. Removing what a row stores is an action on that row: the
integration settings page renders Clear after an overridden row's Source
badge, a declared action trigger on
`DELETE /operator/integrations/{integrationKey}/settings/{key}` with no
confirm modal, because the same override can be set again from the same
row. The first cut made the empty choice do that job, and it did not: a
row whose source was Default came back unchanged after picking None and
saving, and a boolean key was a three-way select (maintainer, 2026-09-05:
"Picking None maybe shouldn't even be possible and it basically just gets
ignored on save").
A dense form's field errors render **under their controls**, as every
family's do. The row aligns its columns to the top under one label line,
so a refused column grows alone; the commit and a checkbox column carry
the label line's height (`app-form-unlabeled`) and sit on the control
line at rest and under a refusal. The family still refuses hints, which
would put a second line under some controls and not others.
A `Preview` form (the tier reorder and removal previews, the org-type
default-change card, `member.plan.move`) is Stacked, rendered inside an
`alert alert-secondary` box, its explanation (`Binding.Message`) in the
same box, its commit labelled to match what it does ("Apply change") and
its way out "Discard": nothing was written, so there is nothing to undo,
only a pending, server-rendered choice to drop (`forms.Discard`, a scoped
GET that re-fetches the resting state).
**A preview at rest shows only its fields.** With no message there is
nothing to apply and nothing to discard, so the part renders no commit row
at all: the org-type card at rest is its "Change default to" select alone,
and choosing a candidate is what brings the classification, the
disposition radios, Apply change and Discard. `FormSpec.MessageAfter`
names the field the message follows, for a message that is the consequence
of one control and reads wrong above it; empty puts the message before the
fields, which is right for a preview whose narrative introduces the
choice. A commit disabled behind a precondition
(`FormView.CommitState`) renders with the classes the enabled commit would
have carried, so it and the way out beside it are the same height.
### The section box hugs its fields
The `form` part owns the section box a Stacked form renders in, not the
page: a page that declares a form writes no `<div class="card">` of its
own. The box is capped at a readable measure (`max-width: 40rem` on
`.app-form-box`, a block element with no auto margins), so it ends where
the fields end and stays left-aligned under its section header rather
than running the width of the content column (maintainer, 2026-09-04:
"Hugging definitely"; centering is a sign-in-page idiom, wrong on a page
whose trail, title and section headers sit on the left edge). A dense
row gets no box of its own, because the panel its page already opened is
one; a bar's box is the list header it sits in; a settings table is its
own shape and takes none.
### The outcome contract
- A mutation refused for any reason answers **422**, with the same form
re-rendered in submission mode: every submitted value carried back
(including checkbox and radio state), each field's error under its
control, the form-level error in the slot every form renders (empty
when unused, `role="alert"`), and `autofocus` on the first errored
control. The 200 body and the 422 body of one form share the outer
element, so either swaps into the same target.
- A mutation that **navigates** (a create page landing on the record, a
settings save returning to a page) answers **303** with `?flash=`
carrying the toast key. A mutation that **stays** answers the region's
re-render with an `HX-Trigger` success toast. An inline `alert-success`
in the response body is retired; so is an `HX-Redirect` on a refusal
(integration settings used both before this change).
- Every mutation form carries `hx-disable="find button[type=submit]"`
and the commit shows its `htmx-indicator` spinner while the request is
in flight, so a double submit is impossible.
- Every mutation form (every kind but `Search`, which navigates, and
`Confirm`, which has nothing to validate) carries `novalidate`: the
browser's own constraint validation never blocks a submission, so the
server's refusal, rendered under the control, is the one refusal a
person sees. Before this was added, a cleared required field's cleared
submission never reached the server at all; the only feedback was the
browser's transient, unstyleable bubble (maintainer, 2026-09-03: "I
cleared a name, pressed Save, and saw nothing"). The constraint
attributes (`required`, `maxlength`, `min`, `max`, `pattern`) stay on
every control regardless, for assistive technology and `:invalid`
styling; they are derived from the declaration and from nowhere else,
so the browser's rule and the server's rule cannot differ.
### The commit and the way out
The commit is `btn-primary`, natural width, never stretched to its
column, first in the commit row, at the row's left; `btn-sm` in a dense
row. A destructive commit renders `btn-danger` only inside the confirm
modal (`shell.confirm`'s default); outside it the trigger that opens the
modal is `btn-outline-danger` (§2 "Buttons"). A bar's button is
`btn-outline-secondary`, always: a search is not a commit, so the list's
own "New …" link stays the page's one filled control, and the landing
surface's "Look up" takes the same weight as every other search rather
than an exception of its own.
The way out follows the commit as `btn-outline-secondary`, and exists
exactly where there is somewhere to go: a create page's Cancel links to
its list; a sub-record panel's Cancel collapses the panel; a confirm
modal's Cancel dismisses it; a preview's Discard re-fetches the resting
state; an always-open edit form carries **no** way out, because nothing
is left by cancelling it. Solid `btn-secondary` is not in the palette
anywhere.
### Labels, help, hints and errors
Every control carries a `<label for>`; in a dense row with no room for
one the label is visually hidden, never absent, and an `aria-label`
never substitutes 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, the way every surveyed system places
it (the "Forms" note in §2 states the same rule for switches versus
checkboxes). The help icon (§6) renders after the label as its sibling,
always, never inside the `<label>` element: a button inside a label
toggles the labelled control on every click that opens the popover,
which is not what a help click should do.
A field's hint carries an id its control's `aria-describedby` names; its
error, when the submission is refused, carries its own id, also named,
with `aria-invalid="true"` on the control; the hint stays visible beside
the error, because GOV.UK's own side of this split is: don't take away
the instruction just because the person got it wrong. A dense field
carries no hint at all: a line under one control in an
`align-items-end` row lifts that field's label above its neighbours' and
the row reads as broken (the maintainer's Extend tier screenshot,
2026-09-03); a dense field's explanation goes behind its help icon
instead. Wrapper ids are `form-<spec name>-<field name>`, stable across
a field's three modes.
`autocomplete` is declared per field: a real token (`name`, `email`,
`url`, `organization`) where one applies, `off` for keys, search boxes
and one-time values; the form tag itself carries none, and a template
never writes the attribute by hand.
### Selects
A select whose value must be chosen opens with a first option that is
disabled, selected, and reads "Choose a …" (`forms.ChooseOption`), so a
form cannot submit a value the person never chose. An optional select
opens with a first option "None" (`forms.NoneOption`), the deliberate
empty choice that clears the column. A submitted value outside the
option set is refused, never silently defaulted.
### One-sided fields
A field with `Only: Create` renders on the create page only; a field
with `Only: Edit` renders on the edit form only. **Nothing about the
side renders**: a line such as "Available after creation" tells the
person nothing they cannot already see from the field's simple absence
on the other side (maintainer, 2026-09-03, on the first cut's reason
lines: "We also don't need to add superfluous stuff like that"), and the
declaration itself carries no justification string either; a recorded
reason nobody reads is noise in the registry (maintainer, 2026-09-04).
Why a field is one-sided is an ordinary Go comment beside its
declaration, the way Django's `add_fieldsets` and Filament's `hiddenOn`
declare the side and nothing else.
A field may carry a `Notice`, one line under the control, only where the
difference would surprise a person: a value that can be set at creation
and never changed again, say. A field carries at most one visible line
under its control: its `Hint`, or its `Notice`, never both. This is the
mechanism behind form-conventions' "Create and edit forms agree or
disclose their difference": the disclosure is structural (the field
either renders or it does not), not printed.
### The copy rule
Every hint, notice, help text and caption survives the deletion test:
delete it, and ask whether the person loses a fact they could not infer
from the label, the control, and the page; if they lose nothing, it does
not exist (GOV.UK: "Do not use help text to explain the interface. If
you have to do that, you've made your service too complicated"; Polaris:
"What's the most you can take away before things fall apart?"). The
default for a field is **no hint**. A hint, when one is needed, is one
short line that never restates the label and never explains the
interface's own state ("Available after creation"); a field carries at
most one visible line under its control.
Copy must not contain: `Available after`, `Once created`, `After you
save`, `This field`, `This is the`, `Here you can`, `allows you to`, `is
used to`, `you can`, `lets you`, `Please`, `simply`, `just`, `in order
to`, `Note that`, `It's worth noting`, `Keep in mind`. The lint rule
`filler-copy` fails on these, case-insensitively on word boundaries, in
template text, in an attribute value, and in a Go string literal under
`internal/server`, `internal/web` or an integration's `web` package
(tests excluded). The registry's own invariants separately refuse a hint
over 100 characters (the sources state the cap qualitatively, "a single
short sentence"; this is the repo's own number) and a hint whose content
words, once stopwords are dropped, are all already present in its label
(a label "Email address" with hint "Enter your email address" is the
canonical failure).
**The counter-rule stands**, so the lint does not eat facts: a side
effect, a data-preservation guarantee, a prerequisite, or a cost is a
fact, not filler, and stays. `GrantDescription`'s help, "Kept with the
grant's history.", is the sources' own example of a hint that passes: it
states a data-preservation fact the label "Description" does not carry.
When a hint is needed to explain what a control does at all, the finding
is against the control, not the copy; that is a review question, not
something a lint can catch.
### Registered forms
`internal/server/forms_doc_test.go` checks this table against
`forms.All()`, the way `internal/config/config_reference_test.go` checks
`docs/environment-reference.md` against every Viper key: every registered
form's name, kind and family must appear here, and a form the test finds
here but the registry no longer carries fails as stale.
| Form | Kind | Family | One-sided fields |
|---|---|---|---|
| `discourse.mapping.create` | Sub-record | Dense | |
| `fedwiki.site.create` | Create | Stacked | |
| `member.plan.move` | Preview | Stacked | |
| `member.workspace.create` | Create | Stacked | |
| `operator.enrollment.grant.extend` | Sub-record | Dense | |
| `operator.enrollment.grant.issue` | Sub-record | Dense | |
| `operator.entitlement-set` | Create[^edit] | Stacked | `is_active` (edit) |
| `operator.entitlement-set.rules` | Batch | Rows | |
| `operator.integration.settings` | Settings | Table | |
| `operator.list.controls` | Search | Bar | |
| `operator.lookup.search` | Search | Bar | |
| `operator.org-type.default` | Preview | Stacked | |
| `operator.plan-ladder` | Create[^edit] | Stacked | |
| `operator.plan-ladder.tier.add` | Sub-record | Dense | |
| `operator.plan-ladder.tier.remove.commit` | Preview | Stacked | |
| `operator.plan-ladder.tier.reorder.commit` | Preview | Stacked | |
| `operator.product` | Create[^edit] | Stacked | `description`, `features`, `is_active` (edit) |
| `operator.product.price.add` | Sub-record | Dense | |
| `shell.confirm` | Confirm | Stacked | |
[^edit]: Declared `Create`, with an `EditPath` and `CommitEdit`: one
declaration serves the record's create page (unbound mode) and its
edit form (record-bound mode, rendered `Edit` on that side); design D5.
`operator.product`'s Visibility field is the worked example for a
boolean with two named states (§2 "Forms"): one
`<input type="checkbox" name="visibility" value="public">` labelled
"Public", with help reading "A public product is sold in the catalog. A
private product is only issued by an operator as a grant." Unticked is
Private, and it is the default, because an unticked checkbox submits
nothing and the handler reads the absence as the off state.
`shell.confirm` is the one declaration with no static fields at all
(§3 "Confirm modal"): `confirm-action-modal.js` rewrites its endpoint,
its message and its commit's class from the trigger's own `data-action-*`
attributes on every open, and appends the trigger's own hidden fields
directly, since one dialog serves whichever mutation opened it.