- 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
322 lines
15 KiB
Markdown
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").
|
|
|