Files
member-console/docs/design-system.md
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

322 lines
15 KiB
Markdown

---
title: "Design System"
audience: [developer]
summary: "Bootstrap-first UI conventions, the custom HTMX primitives, and the WCAG 2.1 AA baseline for building the UI."
---
# Design System
This is a **Bootstrap usage guide plus a custom-primitives reference** — not a
standalone design system. The member-console UI is Bootstrap-first: it uses
[Bootstrap 5.3.6](https://getbootstrap.com/) components and utilities directly,
and supplements them only where Bootstrap genuinely falls short.
Status: M7c deliverable. Reused downstream by M8/M9 member-side UX.
---
## 1. Foundation
### Bootstrap-first
There is **no custom token layer and no parallel utility system**. Spacing,
typography, colors, breakpoints, and layout all come from Bootstrap. Reach for a
Bootstrap utility class or component before writing CSS.
### Role of `app.css`
`internal/embeds/static/app.css` is **supplemental only**. A rule belongs there
only when Bootstrap genuinely cannot express it: the HTMX loading-indicator
hook, toast positioning, `<meter>` styling, the shell and rail (§5), the
page-anatomy parts (§6), and the icons the parts use. The contract comment
at the top of the file is the authoritative list of what it holds; this
page does not repeat it. Keep the file short; if it starts to grow a token
system, that is a signal to stop.
### Bootstrap CSS variable convention
Do **not** hardcode color values in `app.css` or templates. Reference Bootstrap's
CSS variables so the UI stays in sync if Bootstrap's defaults change:
```css
/* no */ background: #0d6efd;
/* yes */ background: var(--bs-primary);
```
Common ones: `--bs-primary`, `--bs-secondary`, `--bs-body-color`,
`--bs-border-color`, `--bs-success`, `--bs-danger`.
### Future theming hook
Bootstrap 5's CSS-variable architecture is the intended seam for per-deployment
theming (see the "Custom theming (two-tier)" backlog item in
`status/milestones.md`). Tier 1 = operator overrides a handful of `--bs-*`
variables; Tier 2 = unrestricted custom CSS. Nothing is built yet — but because
`app.css` references `--bs-*` instead of hex values, that feature stays a small
server-side addition rather than a refactor.
---
## 2. Approved component palette
### Buttons
| Variant | Use for | Notes |
|---|---|---|
| `btn-primary` | The single primary action on a screen/form | |
| `btn-outline-primary` | Emphasis secondary actions | |
| `btn-outline-secondary` | Neutral secondary actions (navigate, view, sub-views) | Default for "show me more" buttons |
| `btn-danger` / `btn-outline-danger` | Destructive actions | Pair with the confirm modal (§3) |
| `btn-link` | Tertiary / inline actions | |
**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 neutral uses with `btn-outline-secondary` and emphasis uses
with `btn-outline-primary`. (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.
---
## 3. Custom primitives
### Confirm modal
A single shared, accessible confirmation dialog replaces htmx's native
`hx-confirm` on the operator surface. Implemented by
`static/confirm-action-modal.js`; the modal markup lives in the operator
layout (`#confirmActionModal`). The member surface does not carry the modal
yet, so its four `hx-confirm` uses are allowlisted in
`internal/lint/anatomy_allowlist.txt` until the 10k.2 sweep adds it to the
member shell; `member-console lint` fails any new `hx-confirm`.
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 |
| `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:beforeSwap` handler for 4xx/5xx/network failures, and
can be shown manually via `showErrorToast(message)`.
- `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.
### 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-disabled-elt` to disable the submit button during the request (see the
confirm modal form).
### Empty states
Empty collections render a centered, muted message rather than a bare blank
area:
```html
<div class="text-center py-4">
<p class="text-muted mb-0">No plan ladders found.</p>
</div>
```
Keep the copy specific ("No plan ladders found") rather than generic ("No
data").
---
## 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) and
`templates/partials/shell_rail_member.html` (member rail); 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
- Left: the brand (the configured deployment name), linking to `/` on both
surfaces. On the operator surface a muted, non-interactive "Operator"
label follows it, naming the space.
- Below `lg`: a toggler at the left edge opens the rail as a drawer.
- Right: the account menu, and nothing else. The top bar carries no section
links on either surface; the rail does.
### Account menu
A Bootstrap dropdown 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. 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"`; a divider; "Sign out" to `/logout`
with `hx-boost="false"`. 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
dashboard" on the operator rail. It is never marked active and is not
duplicated in the account menu or the top bar.
### 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. `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 hidden-navigation findings; see the survey).
### Labels
"Sign out" (matching the identity provider's "Sign in" and "Sign out"
screens), "Identity and Access", "Operator panel", "Member dashboard",
"Operator" (ui-vocabulary: "Shell controls use canonical labels"). The
`/logout` route name is not copy.
---
## 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 parts are `define`s under `templates/partials/ui_*.html`, parsed into
every template set by `web.ParseUIPartials`, and they take values built in
Go (`internal/server/anatomy.go`), usually as methods on the page's data, so
a template never composes a title, a badge class, or an empty state itself.
The Domains and Integrations pages are the reference implementations.
| Part | Renders | Value |
|---|---|---|
| `pageHeader` | the page title, `h1.h2`, with an optional back link above it ("← Back to {parent}", chrome-conventions), an optional one-sentence lead below it, and one right-hand slot holding a muted count **or** one action control (the page's single `btn-primary`, or `btn-outline-secondary`); the only `<h1>` on a page | `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 outline action, titled outside the box its content sits in | `server.SectionHeader` |
| `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` |
| `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` |
| 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` |
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 back affordance is the header's:
above the title, outside any box, nowhere else.
- Record tables are `table table-hover table-sm` inside `.table-responsive`;
`align-middle` only where a row carries a control taller than its text.
- Identifiers (keys, operation names, resource refs) render as `<code>` in
the muted small style, never as badges; badges are for states.
- 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>`, 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, or a page body without the header
(`internal/lint/anatomy.go`); templates not yet rebuilt are listed in
`internal/lint/anatomy_allowlist.txt`, which only shrinks. `make screens`
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").