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.
59 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Design System |
|
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 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:
/* 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:
// 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-containerlives outside any HTMX swap target, so the toast survives partial swaps andhx-boostfull-page navigations. An inlinealert-successin 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-livefor async regions. Content that appears without a navigation — toasts, and HTMX swap targets that surface validation or results — must be in anaria-liveregion so screen readers announce the change. Useassertiveonly for errors;politefor 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 anaria-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: 55vwbeside the account trigger; uncapped belowlg, where only the toggler shares the line). - One control at the right end: the account menu at
lgand up, the menu toggler belowlg.
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 defines 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 throughpageHeader(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 throughreadout, never throughdisplay-*or a page's ownfs-*. 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.cardin the body colour without underline, marked two ways: thechevronIconglyph 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 tokentable-hoveruses), 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 acard-bodywith the list scaffold's pager under its table. - Record tables are
table table-hover table-sminside.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-middleonly 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 carrytext-nowrap, so.table-responsivescrolls 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 issmall fw-semibold text-body-secondary, with.75remof air above it and.25rembelow 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-activeon the current workspace,table-lighton a row whose change is staged); a record's own status is never a tint, a badge names it, becausetable-hover's wash is nearly the same grey astable-lightand 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 isalign-middle, because abtn-smis taller than a line of table text and a top-aligned row reads as two levels; the Actions header and every Actions cell aretext-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 carriesms-1, so the gap between two buttons is a fixed 0.25rem rather than the source's whitespace (the forms library emitsms-1on everyformRowActionalready); 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). Theactions-columnlint 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 legacybg-<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>withsectionSummary; 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 throughDeps), 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'shtmx-configmeta tag, so failed requests preserve the operator's input;error-handler.jsreports them as toasts. - A page that needs different per-status routing uses
hx-statuson 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"), andautofocuson 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 anHX-Triggersuccess toast. An inlinealert-successin the response body is retired; so is anHX-Redirecton a refusal (integration settings used both before this change). - Every mutation form carries
hx-disable="find button[type=submit]"and the commit shows itshtmx-indicatorspinner while the request is in flight, so a double submit is impossible. - Every mutation form (every kind but
Search, which navigates, andConfirm, which has nothing to validate) carriesnovalidate: 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:invalidstyling; 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 |
Create1 | 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 |
Create1 | 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 |
Create1 | Stacked | description, features, is_active (edit) |
operator.product.price.add |
Sub-record | Dense | |
shell.confirm |
Confirm | Stacked |
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.
-
Declared
Create, with anEditPathandCommitEdit: one declaration serves the record's create page (unbound mode) and its edit form (record-bound mode, renderedEditon that side); design D5. ↩︎