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.
1012 lines
59 KiB
Markdown
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.
|