Files
member-console/docs/operator-ux-conventions.md
T
cgalo5758 16a15560c8 Add reconciled tier removal flow
Preview affected orgs by position source and require keep or migrate for
default-sourced positions. Commit deletion, renumbering, and holder
reconciliation atomically while preserving other-source delivery.
2026-07-12 21:09:43 -05:00

17 KiB
Raw Blame History

title, audience, summary
title audience summary
Operator UX Conventions
developer
The convention layer atop the design-system primitives: when and how to apply HTMX request shapes, feedback, confirmation modals, and validation when building operator forms and actions.

Operator UX conventions

This is the convention layer that sits on top of the design-system primitives. It says when and how to use each primitive — not what they look like. Primitive definitions live in design-system.md; this doc decides which one a new form or action picks up.

Status: M7e deliverable. Cited by 7f's audit as the convention-drift baseline.


1. Scope & non-goals

In scope:

  • HTMX request shape on mutation surfaces (forms, destructive actions)
  • Success and error feedback patterns
  • Confirmation modal coverage rules
  • Server-side validation rendering
  • HTTP status-code discipline for mutation handlers

Out of scope (covered elsewhere):

  • Component primitives (modal markup, toast markup, badge styles) — see design-system.md §23
  • IA / routes / breadcrumbs — see operator-ia.md and the operator-panel-navigation spec
  • A11y audit (keyboard nav, ARIA correctness, contrast checks) — M7f
  • Spacing, typography, color tokens — design-system.md §1

2. HTMX request shape

Every mutation form on the operator surface uses HTMX, not native <form method="POST">. The native path hit a brittle gorilla-CSRF Origin-check failure mode for at least one user submission; HTMX routes through the X-CSRF-Token header on body's hx-headers which is the same path every other form uses successfully (see status/operator-ux.md "Composite expansion landed decisions").

The triad every form sets:

<form hx-post="/partials/operator/<capability>/<action>"
      hx-target="#<swap-target-id>"
      hx-swap="innerHTML">
  • hx-post / hx-put / hx-delete — never use bare method="POST"
  • hx-target — CSS selector for the element to swap (default page-shell target is #operator-main; in-page partials use their own container ID)
  • hx-swapinnerHTML for content-area swaps; outerHTML only when replacing the wrapper itself (rare)

For redirects: handler sets HX-Redirect: /path header (HTMX intercepts and does a full-page navigation). Do not call http.Redirect — HTMX will swap the redirect response body into the target.


3. Success feedback

Rule: mutation success fires a toast. Page-load durable notices use a banner.

Pattern When
HX-Trigger: {"showSuccessToast": "<message>"} response header A mutation handler returns success
<div class="alert alert-info"> rendered in the response body The page itself is reporting a durable in-progress state (e.g., "Backfill in progress")

The toast container lives outside the swap target so it survives hx-boost navigations and partial swaps (see design-system.md §3 "Success feedback"). The toast driver is internal/embeds/static/success-toast.js.

Standard message framing:

Action Toast message
Created <Resource> created
Updated Changes saved
Deleted <Resource> deleted
Revoked Grant revoked
Backfilled Backfill complete (<N> orgs processed)

Keep it short. Operators read toasts in <1s of glance time — every extra word is friction.

Anti-pattern: rendering <div class="alert alert-success"> inside the response body of a mutation. That pattern pre-dates showSuccessToast and got mixed into the codebase before the toast primitive was wired. Migrate to toast on next touch.


4. Error feedback

internal/embeds/static/error-handler.js is the single funnel for HTTP error feedback. It listens for htmx:beforeSwap, intercepts:

  • 403 (CSRF expired) → red toast + preserve user input (no swap)
  • 4xx with body → allow swap (server returns rendered error context) + backup toast
  • 5xx → red toast + preserve user input (no swap)
  • network failure → red toast + preserve user input

Handler responsibility: return proper HTTP status codes. Never return a 200 with an .alert-danger banner for a failure — the error handler keys off status, not body content.

Failure type Status to return
Validation failure 422 + form partial with FieldErrors populated (see §6)
Auth failure 401 / 403
Resource not found 404
Conflict (e.g., concurrent edit) 409
Server bug 500 (let the error handler show a generic toast; details go to logs)

Database write failures never render err.Error(). Raw driver text ("duplicate key value violates unique constraint … SQLSTATE 23505") is not operator-facing copy — it leaks schema internals and reads as a crash. The contract for a failed write:

  1. Constraint violation → translate with web.FieldErrorsFromDB(err, web.ConstraintMessages{…}) (internal/web/dberrors.go) and route the result through the page's 422 + FieldErrors path (§6/§8). Name the constraints the form can plausibly hit in a map next to the handler — constraint names live beside the form they belong to, not in a global registry — each mapped to the form field to flag and a friendly, actionable message. Violations without a named entry fall back to a per-SQLSTATE-class message on the form-level "" key. Surfaces without FieldErrors machinery (member partials, formless actions like revoke/delete buttons) render the translated message through their existing banner/toast shape instead — friendly text, never driver text.
  2. Everything else (ok == false) → slog.Error the real error and render a generic message ("Failed to update the product. Details are in the server logs."). The operator can't act on driver text; the log line is where the details belong.

member-console lint enforces this with the raw-error-render rule: any line under internal/server that passes err.Error() into a render* helper, fireErrorToast/fireSuccessToast, or http.Error is flagged (lines containing slog. are exempt; _test.go files are skipped).


5. Confirmation modals

The rule: require the modal for actions that (a) delete data, or (b) revoke user-visible access via the composite path (the revoke handler: decree + end_conferral).

Not required for: grant issuance, grant extend, create, update, save. These are forward-only — mistakes are revocable. Adding friction to high-frequency operator actions doesn't earn its weight.

Special case, retired: the org-type backfill's confirmation modal is gone with the action itself. High blast-radius mass mutations now follow the org-type default-change pattern instead: the selection immediately renders a server-side read-only preview (classified affected-population counts, named where a disposition is required), and the commit button lives inside that preview — the operator cannot reach the mutation without having seen its projection. Prefer this preview-then-commit shape over a modal for any future mass mutation: a modal interrupts with a question; the preview answers it. Tier drag-to-reorder and tier removal follow the same shape: the action renders the preview and the commit lives inside it — pending state is server-rendered, never browser-only, so no swap can silently discard it. Affected-population lists inside previews render count-first: the bucket line carries only the count and one sentence of consequence; the org names live behind a show all N <details> disclosure (.org-disclosure-list — one org per row, scrollable), so a hundred-org preview reads as one line until the operator asks for names.

Trigger contract (from internal/embeds/static/confirm-action-modal.js):

<button type="button"
        data-bs-toggle="modal"
        data-bs-target="#confirmActionModal"
        data-action-url="/partials/operator/<resource>/<id>"
        data-action-method="delete"
        data-action-target="#<swap-target-id>"
        data-action-title="Delete plan ladder"
        data-action-body="Delete '<resource-name>'? This cannot be undone."
        data-action-confirm-label="Delete ladder"
        data-action-style="danger">
    Delete
</button>
Attribute Required Default Notes
data-action-url yes endpoint hit on confirm
data-action-method yes post post or delete
data-action-target yes HTMX swap target
data-action-title no Confirm modal heading
data-action-body no empty body copy; name the resource being affected
data-action-confirm-label no Confirm submit-button label
data-action-style no danger danger / primary / warning
data-action-fields no JSON map of hidden form fields

Failed requests leave the modal open so the operator sees the error in the swap target and can retry. Successful requests close the modal automatically.

Body copy convention: name the affected resource. "Delete 'Standard tier'?" beats "Are you sure you want to delete this?" by a wide margin — operators routinely manage many similarly-named entities.


6. Server-side validation

The rule: every form that mutates revalidates server-side and renders field-level errors. HTML5 required / type= stays as belt-and-suspenders (good UX for the happy path) but isn't trusted as the validation contract.

Infrastructure (to be built in M7e Slice C):

  • internal/web/formerrors.goFieldErrors map[string]string keyed by form input name
  • A form-field template partial that renders is-invalid + <div class="invalid-feedback"> from the map
  • Handler pattern: validate → on fail, return 422 with the form partial + populated FieldErrors

Field-level error rendering:

<div class="mb-2">
    <label class="form-label" for="reason">Reason</label>
    <input type="text" id="reason" name="reason"
           class="form-control {{ if index .FieldErrors `reason` }}is-invalid{{ end }}"
           value="{{ .Form.Reason }}"
           aria-invalid="{{ if index .FieldErrors `reason` }}true{{ else }}false{{ end }}"
           required>
    {{ with index .FieldErrors `reason` }}
    <div class="invalid-feedback">{{ . }}</div>
    {{ end }}
</div>

Keep messages concrete and actionable: "Reason is required" not "Invalid input."


7. Optimistic updates

Rule: don't.

Every mutation waits for server confirmation before the UI updates. HTMX's request-response cycle is the contract.

Single exception: internal/embeds/static/grant-toggle.js — a toggle switch for grant active/inactive that flips immediately and reconciles on server response. Justified because the toggle is high-frequency and the misfire cost is low (server reconciles). New optimistic updates should not be added without explicit precedent + writeup.


8. Mutation status codes

Outcome Status Body
Success 200 partial HTML for the swap target (plus HX-Trigger: showSuccessToast header)
Success + redirect 200 (HTMX) empty body + HX-Redirect: /path header
Validation failure 422 form partial with FieldErrors populated
CSRF expired 403 error-handler.js handles the toast
Not found 404 error-handler funnel
Conflict 409 error-handler funnel
Server error 500 error-handler funnel

The 422 distinction matters: error-handler.js lets 4xx-with-body swap (so the operator sees the form re-rendered with field errors) while 403/5xx do not (the original input must be preserved). 422 is the validation-failure signal that opts into the swap path.


9. Anti-patterns

Don't Do instead
<form method="POST" action="..."> hx-post (CSRF Origin-check is brittle on native POST)
<div class="alert alert-success"> in a mutation response HX-Trigger: {"showSuccessToast": "..."} header
Return 200 + .alert-danger for a failure Return proper status code (422 / 4xx / 5xx); let error-handler.js funnel it
hx-confirm="Are you sure?" The confirm-action-modal contract
<span class="badge bg-info"> <span class="badge text-bg-info"> (bare bg-* fails WCAG AA contrast against Bootstrap defaults)
Inline <script> in templates Put JS in internal/embeds/static/ and reference via <script defer src> (CSP script-src 'self' blocks inline)
http.Redirect(w, r, ...) from an HTMX handler w.Header().Set("HX-Redirect", "/path")
Validation in the client only (HTML5 required alone) Server-side FieldErrors + the field partial; keep required as belt-and-suspenders
for _, g := range grants { if g.Status != "active" { continue } … } to build an "active grants" list Use a query that joins to active provisions (ListDeliveringGrantsByOrgID / ListGrantsWithDeliveryByOrgID) — see §9a
hx-get="/operator/foo/{{ .ID }}" — interpolated URL string in any hx-* or data-action-url attribute hx-get="{{ routeURL "/operator/foo/{id}" .ID }}"member-console lint cross-checks the pattern against mux.HandleFunc registrations. Helper is web.RouteURL in internal/web/route_url.go; register on each template FuncMap as "routeURL": web.RouteURL.
"Failed to save: " + err.Error() in a render/toast/http.Error call web.FieldErrorsFromDB → 422 + FieldErrors for constraint violations; slog.Error + generic message otherwise (§4; enforced by the raw-error-render lint rule)

9a. Grant lifecycle ≠ delivery state

core.grants.status is a lifecycle field with three values — active, expired, revoked — and it only changes through explicit revocation or time-based expiry. status='active' means "this commitment has never been formally retracted"; it does not mean "this grant is currently delivering entitlements to a pool."

Operational delivery lives one layer down, on pool_provisions.status and pool_provision_ladders.status — and the schema's GiST exclusion constraint guarantees at most one active provision per (pool, ladder). A grant whose provision was ended by a later conferral (supersession, extend-as-replace, or end_conferral from another flow) stays grants.status='active' (audit ledger), but it stops delivering.

These two facts get confused in code that filters grants by status='active' and treats the result as "what this org is getting right now." The result is a UI lie: 5+ rows shown as "active" for a product that the data model guarantees is delivered by exactly one of them.

Rule. Any operator or member surface that answers "what is this org/pool getting right now?" MUST use a query that joins to pool_provisions (and pool_provision_ladders when ladder-scoped):

  • Members (Sources panel, Plans page, anything user-facing): use ListDeliveringGrantsByOrgID — INNER JOIN on pool_provisions.status='active'. Returns only live grants. Audit history is noise here.
  • Operators (per-org composite, audit-shaped views): use ListGrantsWithDeliveryByOrgID — LEFT JOIN, returns every grant tagged with delivery_state ∈ {live, superseded, inactive}. Render the column. Gate destructive actions on live. History is the point.

Don't filter on grants.status for the "currently active" question. The status field answers a different question. If you're tempted to write a third such query, name it after what it actually returns (ListGrantsCurrentlyDeliveringTo…, not ListActiveGrants…) so the trap doesn't reappear.


10. Future

  • Cascade-preview pattern — for catalog edits at criticality ≥4 (product edit, entitlement-set edit, tier reorder), surface "this change will affect: N products, M orgs, K members" before confirmation. Deferred to M11 (audit log & observability; renumbered from M10 on 2026-07-03) because the projected-change set is audit-log adjacent.
  • Convention-drift guard — automated lint that flags bg-info without text-, native method="POST", etc. M7f.1 delivered member-console lint covering dead routes, dead swap targets, stale ?tab=, and interpolated URL literals; additional §9 rules can be added there as static-detectable patterns emerge.
  • Real-time validation — on-blur server validation pings vs. on-submit. Not pursued unless operator feedback demands it.

See also