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

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

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

59 KiB

title, audience, summary
title audience summary
Design System
developer
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-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 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 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 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.


  1. 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. ↩︎