--- title: "Design System" audience: [developer] summary: "Bootstrap-first UI conventions, the custom HTMX primitives, and the WCAG 2.1 AA baseline for building the UI." --- # Design System This is a **Bootstrap usage guide plus a custom-primitives reference** — not a standalone design system. The member-console UI is Bootstrap-first: it uses [Bootstrap 5.3.6](https://getbootstrap.com/) components and utilities directly, and supplements them only where Bootstrap genuinely falls short. Status: M7c deliverable. Reused downstream by M8/M9 member-side UX. **Sources**: a rule added to this document names the published design system it follows (e.g. the Buttons table, §2, cites GOV.UK Design System and IBM Carbon) or says why this document departs from one. --- ## 1. Foundation ### Bootstrap-first There is **no custom token layer and no parallel utility system**. Spacing, typography, colors, breakpoints, and layout all come from Bootstrap. Reach for a Bootstrap utility class or component before writing CSS. ### Role of `app.css` `internal/embeds/static/app.css` is **supplemental only**. A rule belongs there only when Bootstrap genuinely cannot express it: the HTMX loading-indicator hook, toast positioning, `` styling, the shell and rail (§5), the page-anatomy parts (§6), and the icons the parts use. The contract comment at the top of the file is the authoritative list of what it holds; this page does not repeat it. Keep the file short; if it starts to grow a token system, that is a signal to stop. ### Bootstrap CSS variable convention Do **not** hardcode color values in `app.css` or templates. Reference Bootstrap's CSS variables so the UI stays in sync if Bootstrap's defaults change: ```css /* no */ background: #0d6efd; /* yes */ background: var(--bs-primary); ``` Common ones: `--bs-primary`, `--bs-secondary`, `--bs-body-color`, `--bs-border-color`, `--bs-success`, `--bs-danger`. ### Future theming hook Bootstrap 5's CSS-variable architecture is the intended seam for per-deployment theming (see the "Custom theming (two-tier)" backlog item in `status/milestones.md`). Tier 1 = operator overrides a handful of `--bs-*` variables; Tier 2 = unrestricted custom CSS. Nothing is built yet — but because `app.css` references `--bs-*` instead of hex values, that feature stays a small server-side addition rather than a refactor. --- ## 2. Approved component palette ### Buttons Weight follows the button's role, not the page (design D19, round 4). Record creation has a page of its own (design D20), so a list page carries exactly one filled control, the "New ..." link that starts its work, and the create page it leads to carries exactly one, its commit. Sub-record forms (a price, a tier, a rule, Issue grant, Extend) stay closed panels on the record's own page, so at most one of those is normally open. | Variant | Use for | Notes | |---|---|---| | `btn-primary` | A form's commit: create, save, apply, issue, extend, commit a reorder; and a list page's "New ..." link to the create page | Exactly one per form; exactly one per list page | | `btn-outline-secondary` | Navigation, neutral sub-views, and panel openers | The default weight. A page-header action is always this | | `btn-danger` | A destructive commit | Inside the confirm modal only (§3) | | `btn-outline-danger` | Opening the destructive confirmation | Pair with the confirm modal (§3) | | `btn-link` | Tertiary inline actions | | `btn-outline-primary` is not a header weight. A header action either starts the page's work, in which case it is filled, or it navigates, in which case it is outline-secondary; there is no third emphasis between them. A page header's action (§6 "Page anatomy") never renders filled. **Panel openers.** A control that opens a collapsed panel ("Add price", "Add tier", "Add rule", "Issue grant", "Extend tier") is `btn btn-outline-secondary btn-sm` and carries a leading plus glyph, the shared `openerIcon` part, that rotates 45 degrees into a cross while the control reports `aria-expanded="true"` (`.app-opener-icon` in `app.css`, with a short transition suppressed under `prefers-reduced-motion`). The glyph is the whole open/closed signal: the opener keeps its weight and colour while its panel is open. The earlier rule that painted an expanded collapse trigger with its variant's active palette is retired (maintainer, 2026-09-03: it "looks pressable as opposed to untoggleable"). GOV.UK Design System and IBM Carbon both name the single-primary-per-page rule this table follows; Material Design and Carbon both flip an opener's icon rather than its weight: - GOV.UK Design System, *Button*: "Use a default button for the main call to action on a page. Avoid using multiple default buttons on a single page." - IBM Carbon, *Button usage*: "Primary button should only appear once per screen (not including the application header, modal dialog, or side panel)" and "use a tertiary button for page headers if there is a primary button in the content section." - Material Design, *Buttons: FAB* (speed dial): the FAB's plus rotates into a close glyph when the dial opens. IBM Carbon, *Accordion*: the item's icon flips while the section is expanded. The console adapts both to its panel openers. **Retired — do not use:** | Variant | Color | Contrast on white | Why | |---|---|---|---| | `btn-outline-info` | `#0dcaf0` cyan | ~1.8:1 | Fails WCAG 2.1 AA (needs 4.5:1) | | `btn-outline-warning` | `#ffc107` yellow | ~1.28:1 | Fails WCAG 2.1 AA (needs 4.5:1) | Both outline variants render unreadable text against the app's white background. Replace them with `btn-outline-secondary`. (The *solid* `btn-info` / `btn-warning` and the `text-bg-*` badge variants are unaffected; only the outline button variants are retired.) ### Badges Use `text-bg-*` badge variants (`text-bg-secondary`, `text-bg-success`, etc.) rather than bare `bg-*` where contrast matters — `text-bg-*` pairs the background with a contrast-checked foreground. ### Alerts `alert-success` / `alert-danger` / `alert-warning` are fine for **in-page, persistent** messaging (e.g. validation summaries rendered into a swap target). For **transient** success/error feedback that must survive an HTMX swap or navigation, use the toast primitives (§3) instead. ### Forms Every form on either surface is declared, rendered and validated by the forms library; §8 is its page. Two component-palette rules stay here because they are Bootstrap usage decisions the library's Checkbox control follows rather than makes: a switch (`form-switch`, `role="switch"`) implies its toggle takes effect immediately, so a control committed by the form's own Save button is always a plain checkbox (`form-check` alone, no `role`); and a boolean with two named states is one checkbox on its own line, never a pair of radios, with the label naming the ticked state and the help icon after it saying what both states mean (design D6, round 4; worked example in §8's registered-forms table, `operator.product`'s Visibility field). ### Inline code A bare `` 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 `` 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 ` value resolves against the trigger element (the modal's form is outside the trigger's ancestry) | | `data-action-swap` | no | HTMX swap style (default `innerHTML`) | | `data-action-title` | no | Modal title (default `Confirm`) | | `data-action-body` | no | Modal body text/HTML | | `data-action-confirm-label` | no | Submit button label (default `Confirm`) | | `data-action-style` | no | `danger` \| `primary` \| `warning` (default `danger`) | | `data-action-fields` | no | JSON object of hidden form fields | Behavior: a successful (2xx/3xx) response closes the modal; a failed request leaves it open so the operator can read the error in the swap target and retry. ### Error toast Transient error feedback. Markup (`#errorToast`) lives in each layout's `.toast-container`; behavior in `static/error-handler.js`. It is shown automatically by the `htmx:response:error` handler (HTTP 4xx/5xx; silent for 422, whose body lands inline per the validation contract) and the `htmx:error` handler (network failures, timeouts), and can be shown manually via `showErrorToast(message)`. Whether an error body swaps is not decided in script: see §7 "Error responses". - `role="alert"`, `aria-live="assertive"` — interrupts the screen reader, because an error needs immediate attention. ### Success toast Transient success feedback. Markup (`#successToast`) lives in each layout's `.toast-container`; behavior in `static/success-toast.js`. Triggered from the server via an `HX-Trigger` response header: ```go // Works for both HTMX partial swaps and hx-boost navigations. w.Header().Set("HX-Trigger", `{"showSuccessToast": "Product created"}`) ``` - `role="status"`, `aria-live="polite"` — announced without interrupting, because success is not urgent. - The `.toast-container` lives outside any HTMX swap target, so the toast survives partial swaps and `hx-boost` full-page navigations. An inline `alert-success` in a POST response body would be discarded by the redirect target and never seen — that is why this primitive exists. Existing templates are **not** migrated to this primitive in M7c; M7e owns the convention and timing for adopting it across flows. A create-then-land landing (`?flash=created`; design D20, round 2: "a success toast, not a banner") is a fresh top-level navigation, not an htmx-driven exchange, so no `HX-Trigger` header can reach it. Its confirmation instead rides `OperatorPageData.FlashSuccess`: `operator.html` pre-renders the message into `#successToast` and marks it `data-show-on-load`; `success-toast.js`'s load-time init shows it once and removes the marker. The `?flash=missing` bounce on a list page is unrelated — it is an error, not a confirmation, and stays an in-page `alert-danger`. ### Loading states HTMX request indicators use the `.htmx-indicator` hook (styled in `app.css`). Place a `.htmx-indicator` element inside the triggering control and let HTMX toggle it via `.htmx-request`. For destructive forms, also use `hx-disable` to disable the submit button during the request (see the confirm modal form). ### Empty states Empty collections render through the `emptyState` part (§6): build a `server.EmptyStateParams` in Go and `{{ template "emptyState" ... }}` in the template — never a hand-rolled centered block (lint-caught). The part's blocked branch names a missing prerequisite with a link to create it; the plain branch is centered muted text with an optional note. Keep the copy specific ("No plan ladders yet.") rather than generic ("No data"). ### Disabled controls A disabled control's reason renders through the `disabledControl` part (§6): build a `server.DisabledControl` (`NewDisabledControl` for the operator surface's default, `.AsVisible()` for the member surface's) and `{{ template "disabledControl" ... }}` — never a hand-rolled `` 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 `` 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 `