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

15 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.


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-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:

<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 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 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").