- 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
15 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Design System |
|
Bootstrap-first UI conventions, the custom HTMX primitives, and the WCAG 2.1 AA baseline for building the UI. |
Design System
This is a Bootstrap usage guide plus a custom-primitives reference — not a standalone design system. The member-console UI is Bootstrap-first: it uses Bootstrap 5.3.6 components and utilities directly, and supplements them only where Bootstrap genuinely falls short.
Status: M7c deliverable. Reused downstream by M8/M9 member-side UX.
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
| 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:
// Works for both HTMX partial swaps and hx-boost navigations.
w.Header().Set("HX-Trigger", `{"showSuccessToast": "Product created"}`)
role="status",aria-live="polite"— announced without interrupting, because success is not urgent.- The
.toast-containerlives outside any HTMX swap target, so the toast survives partial swaps andhx-boostfull-page navigations. An inlinealert-successin a POST response body would be discarded by the redirect target and never seen — that is why this primitive exists.
Existing templates are not migrated to this primitive in M7c; M7e owns the convention and timing for adopting it across flows.
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:
<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-livefor async regions. Content that appears without a navigation — toasts, and HTMX swap targets that surface validation or results — must be in anaria-liveregion so screen readers announce the change. Useassertiveonly for errors;politefor everything else.- Focus management. Modals trap and restore focus (Bootstrap handles this for
.modal). Do not remove focus outlines. Interactive controls must be reachable and operable by keyboard. - Semantic markup. Use real
<button>/<a>elements (not clickable<div>s), associate every form control with a<label>, and give icon-only controls anaria-label. - Audit. M7f runs the formal axe + Lighthouse a11y pass and keyboard-nav review across all operator screens; this section is the standing checklist that pass measures against.
5. Application shell
Both surfaces render one shell (chrome-conventions: "One shell for both
surfaces", "Session controls live in the account menu on both surfaces",
"The surface switch is a visible, mirrored rail entry"). The data is one
server.Shell value per page; the markup lives in
templates/partials/shell_topbar.html (both surfaces) 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 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.
| 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 throughpageHeader(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-sminside.table-responsive;align-middleonly 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>withsectionSummary; nothing else hides sections. - UI copy has no em dashes (the lone "—" empty-value marker is exempt);
destructive actions confirm through the modal (§3), never
hx-confirm. - A page arrives complete: every region that also exists as a partial is
composed on the server through
server.Include(an in-process request to the partial's route; integrations get it throughDeps), never fetched after load. Polling and event-driven refreshes render their current state first. A region that fails to compose renders an inline notice.
What holds it: member-console lint fails a template that renders an
<h1>, 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").