The exploration directory held 245 tracked files, and one week's notebook had grown to 28M of captures, per-run CSS copies and model logs, so every change ended with an argument about what to prune. It is now a local lab notebook: git ignores it, its tracked files are untracked, and no tracked file cites a path under it, because another clone does not have it. Every live citation is rewritten so the fact stands in the sentence: the docs pages, AGENTS.md, four main specs, one Go comment, issues.md and milestones.md. The operator IA page gains the landed IA revisions it used to point at, and the UX rubric moves to docs/first-contact-rubric.md because the process requires it. Archived changes, status/archive, status/log and design/ keep their old paths as frozen history; the design/ ones are logged as an upstream issue. MAINTAINING.md and the status README describe the notebook and the harvest that replaces pruning.
19 KiB
chrome-conventions Specification
Purpose
Cross-cutting contract for page chrome outside forms (UX-13): location trails, row navigation, action weight, member navigation structure, and the identity-provider handoff.
Requirements
Requirement: One location trail
Every page except the surface's root SHALL carry exactly one location trail: a Bootstrap breadcrumb rendered by the pageHeader part above the title, whose first crumb is the surface's root ("Operator", linking to /operator, on the operator surface; "Home", linking to /, on the member surface), followed by the page's rail entry when the page sits below one, then real intermediate pages only, then the current page, unlinked with aria-current="page". A rail-entry page therefore reads "Operator / Products". The surface roots themselves (the operator overview at /operator, the member dashboard at /) SHALL carry none; error pages SHALL carry none and SHALL link out in their copy instead. The trail is what states the surface: the top bar SHALL NOT carry a surface label. Pages SHALL NOT render "← Back to {parent}" links or any other back affordance, and SHALL NOT vary the trail's position or separator. (Adopted by the maintainer 2026-09-01: below the lg breakpoint the rail is a drawer, so the trail is the only visible statement of where a page sits; every page header gets the same anatomy; the root crumb is the way home.)
Scenario: Trails are uniform
- WHEN a user views any page other than the surface root
- THEN the location trail is a breadcrumb above the title reading "{surface root} / {rail entry} / … / {current page}" with the current page unlinked
- AND no back link renders anywhere on the page
Scenario: A rail-entry page is rooted at the surface
- WHEN an operator views
/operator/products - THEN the trail reads "Operator / Products", with "Operator" linking to
/operator
Scenario: The root and an error page carry no trail
- WHEN a user views the surface root, or an error page
- THEN no trail renders, and an error page's copy links a way out
Requirement: Row navigation is the primary column's link
Every browse table's first column SHALL be the record's human-readable identifier (its name, or its reference number), rendered inside a <th scope="row"> cell — the row's semantic header, semibold via app.css so it stands out from body text without repeating the column headers' full weight (maintainer, 2026-08-30) — as a plain text link to the page that holds the record: the record's own detail page when it has one, or, when the record's home is a section of another page, that page with a fragment anchor on the section (a grant's home is its organization's composite, so a grants row links to /operator/organizations/{orgID}#plan-grants-panel); a record with neither renders it as plain text. Tables SHALL NOT render navigation as buttons ("View", "Manage"): a button in a table is a verb that changes or discloses something, and rows without such verbs carry no Actions column. Real verbs render in a trailing Actions column, at most two visible; a row that carries no verb while its siblings do SHALL render an empty Actions cell, never explanatory text in the verb's place. Whole rows SHALL NOT be click targets. A cell naming another record links to that record as plain text.
Scenario: A row navigates through its identifier
- WHEN a browse table lists records that have detail pages
- THEN each row's first-column identifier renders in a
<th scope="row">cell as a text link to its detail page - AND no "View" or "Manage" button renders anywhere in the table
Scenario: A record homed in a section links to that section
- WHEN a browse table lists records whose home is a section of another page (grants on the organization composite)
- THEN each row's first-column identifier links to that page with the section's fragment anchor
Scenario: Verbs stay buttons, navigation stays a link
- WHEN a row carries a real action (revoke, extend, delete)
- THEN the action renders as a verb-labeled control in the trailing Actions column, separate from the row's navigation link
Scenario: A verb-less row leaves its Actions cell empty
- WHEN a table carries an Actions column and a row has no action (a system-managed grant, an operator-root domain claim)
- THEN that row's Actions cell renders nothing, and the reason lives in the row's other columns
Requirement: One dominant primary action per screen
A screen SHALL present at most one button in the primary (filled) style; additional actions render in subordinate styles (outline or link). Two equal-weight primary buttons SHALL NOT appear on one screen.
Scenario: Detail pages demote secondary actions
- WHEN a user views a page with more than one available action
- THEN exactly one action carries the primary style
Requirement: The identity-provider handoff is marked subtly
The "Identity and Access" control, which renders inside the account menu on both surfaces, SHALL carry a subtle external-destination marker and a hover tooltip in plain words (no "identity provider" jargon) stating that it manages the account and opens in a new tab, so the externally-branded console that follows is expected rather than alarming; inline explanation copy SHALL NOT render beside the label (maintainer decision 2026-08-23: explanations do not belong in navigation, and nothing may widen the rail). The marker SHALL be a small monochrome inline icon that inherits the link's color and renders on the same line as the label — not an emoji or a character with emoji presentation (maintainer decision 2026-08-23: U+2197 rendered as an emoji and wrapped to its own line), and not separable from the label by line wrapping.
Scenario: Handoff is hinted, not explained inline
- WHEN a user opens the account menu and views the Identity and Access control
- THEN it carries a small monochrome external-destination icon on the same line as the label and a plain-words hover tooltip
- AND no inline explanation copy renders in the menu
- AND the marker is not an emoji or emoji-presentable character
Requirement: The sidebar has a fixed width
The application sidebar SHALL hold a fixed width on large screens; entry content SHALL NOT widen the rail — a label too long for the rail wraps instead.
Scenario: Long content cannot widen the sidebar
- WHEN a sidebar entry's content is wider than the rail
- THEN the content wraps within the fixed width and the sidebar's width is unchanged
Requirement: One shell for both surfaces
The member surface (dashboard, products, billing) and the operator surface SHALL render one application shell: the same top bar, the same rail geometry, the same account menu, and the same responsive behaviour, produced from one shared template rather than per-page copies. The top bar SHALL carry the brand (the configured deployment name, linking to / on both surfaces) as its left-most element on both surfaces at every width, and one control at its right end: the account menu at lg and up, the menu toggler below lg. The top bar SHALL NOT carry section links or a surface label on either surface at any width; the trail's root crumb states the surface. At lg and up the rail SHALL be visible without any interaction and SHALL carry task destinations only (Dashboard, Products, Billing on the member surface; the operator sections on the operator surface) plus the surface switch, and no session or account control. Below lg the rail SHALL be a drawer, opened from the top bar's toggler and closed by choosing an entry, by its close control, or by the backdrop, and it SHALL be the shell's only menu: the task destinations and the surface switch in one list, then the account menu's items as a second, separately named list at the drawer's foot. The two SHALL be told apart by distance first: the account group SHALL sit at the foot of the drawer whatever the surface's section count, and SHALL carry one divider and no other rule of its own. Navigation SHALL NOT be hidden behind a toggler at lg and up.
Scenario: The top bar names no surface
- WHEN an operator views any operator page at any viewport width
- THEN the top bar renders the brand and its one control, and no surface label and no Dashboard, Products, Billing, or Operator links
Scenario: The member top bar matches the operator top bar
- WHEN a member views the dashboard, products, or billing page
- THEN the top bar renders the brand and its one control in the same positions as on the operator surface
Scenario: One menu reaches the sections and the account
- WHEN an operator views an operator page below the
lgbreakpoint and opens the bar's toggler - THEN one drawer opens listing the eight operator sections and the surface switch, and, at its foot under a single divider, the account menu's items headed by the person's name and email
- AND the account group sits at the same place in the member surface's drawer, which lists three sections
- AND choosing a section navigates and closes the drawer, leaving the page scrollable
Scenario: Desktop navigation is never hidden
- WHEN a person views any shell page at
lgor wider - THEN the rail is visible without opening anything, it carries no account or session control, and no toggler renders
Requirement: Session controls live in the account menu on both surfaces
Both surfaces SHALL render exactly one account menu at a time, never two: at lg and up a dropdown at the right end of the top bar, triggered by a control labelled with the signed-in person's display name (the username when no display name is known); below lg a named group at the foot of the shell's one drawer, after the rail entries. Whichever renders SHALL contain, in order: a non-interactive header with the person's name and email; the "Identity and Access" control, linking to the identity provider's account console at the configured oidc-idp-account-url; a "Get help" control linking to the configured support-url, present only when that key resolves to a value; the "Sign out" control, last. The dropdown SHALL set Sign out apart with a divider. The drawer's group SHALL NOT draw a divider inside itself: it is separated from the rail's entries by one divider and by the drawer's remaining space, and it is named by the person's name and email, which SHALL be the group's accessible name so that assistive technology can tell it from the list of destinations above it. It SHALL contain nothing else, and no session or account control SHALL render anywhere outside it; help SHALL have no other home in the shell (maintainer, 2026-09-02: the bar has no room for a fourth item at narrow width). "Sign out" SHALL render in the menu's ordinary text colour, not the destructive colour, SHALL always be the last item, and SHALL be a control that posts at both widths. The menu SHALL be driven by declarative attributes so it works under the strict content security policy without page scripts. The items SHALL come from one template shared by both surfaces, so the two surfaces' menus cannot drift apart.
Scenario: The account menu holds exactly the session controls
- WHEN a signed-in person opens the account menu on either surface at either width
- THEN it lists the name-and-email header, Identity and Access, Get help when a support URL is configured, and Sign out, in that order, and nothing else
Scenario: The drawer's group is named and set apart by space
- WHEN a signed-in person opens the drawer below
lgon a viewport with room to spare - THEN the account items render as their own list at the drawer's foot, under one divider, headed by the person's name and email
- AND that name and email is the list's accessible name
- AND no divider renders between the account items themselves
Scenario: Only one account menu renders
- WHEN a signed-in person views any shell page below
lg - THEN the account items render in the drawer and no account trigger renders in the top bar
- WHEN the same person views it at
lgor wider - THEN the account items render in the top bar's dropdown and no account items render in the rail
Scenario: Get help appears only with a destination
- WHEN
support-urlresolves to no value - THEN the menu renders no "Get help" item
- WHEN it resolves to a URL
- THEN "Get help" opens that URL in a new tab from either surface
Scenario: Sign out is plain, last, posting, and everywhere
- WHEN a signed-in person views any shell page at any width
- THEN the only session-ending control is the "Sign out" item at the end of the account menu, rendered without destructive styling, and it posts rather than following a link
Scenario: The trigger names the person
- WHEN the identity provider supplied a display name and the person views a shell page at
lgor wider - THEN the account menu's trigger reads that name
- WHEN it supplied no display name
- THEN the trigger reads the username
Requirement: The surface switch is a visible, mirrored rail entry
The switch between the member surface and the operator surface SHALL be exactly one rail entry on each side, rendered last among the rail's entries and separated from the task entries by the rail's border-top grouping idiom: "Operator panel" on the member rail, shown only to people who hold the operator role, linking to /operator; "Member panel" on the operator rail, linking to /. The switch SHALL NOT be duplicated in the account menu or the top bar, and SHALL never carry the active state. Below lg, where one drawer carries both the rail entries and the account items, the switch SHALL render once among the rail entries, above the account group's divider, and never inside the account group.
Scenario: Operators see the switch on the member rail
- WHEN a person with the operator role views a member page
- THEN the member rail ends with a separated "Operator panel" entry linking to
/operator - WHEN a person without the operator role views a member page
- THEN no "Operator panel" entry renders
Scenario: The operator rail leads back
- WHEN an operator views any operator page
- THEN the operator rail ends with a separated "Member panel" entry linking to
/that is never marked active
Scenario: One place for the switch
- WHEN a person looks at the top bar, or opens the drawer below
lg - THEN exactly one switch entry renders, in the rail's list, and none renders in the top bar or in the account group
Requirement: The hypermedia engine is htmx 4 with explicit inheritance
The vendored hypermedia library (internal/embeds/static/htmx.min.js) SHALL be the htmx 4.x line, loaded without the htmx-2-compat extension and without the implicitInheritance config flag. Attribute inheritance SHALL be explicit: an attribute that must reach descendants carries the :inherited modifier (the shell's hx-boost and CSRF hx-headers, and container-level targets whose child controls declare none of their own), and an attribute without the modifier applies only to its own element. hx-disinherit, htmx 2 camelCase event names in first-party scripts, and any htmx-2-compat reference SHALL be lint failures.
Scenario: Inheritance is visible in the markup
- WHEN a control's request behavior depends on an ancestor's htmx attribute
- THEN that ancestor attribute carries
:inherited - AND no element relies on implicit inheritance or guards against it with
hx-disinherit
Scenario: The compat shim never ships
- WHEN
member-console lintruns - THEN a template or script referencing
htmx-2-compat, usinghx-disinherit, or listening for an htmx 2 camelCase event name fails the build
Requirement: Handlers that branch on HX-Request declare it with Vary
A handler whose response body or headers differ by the HX-Request request header SHALL set Vary: HX-Request on every response from that branch point, so a shared cache never serves the htmx variant of a URL to a full-page navigation or the reverse. Routes SHALL keep fragments at dedicated /partials/* paths rather than branching page URLs on HX-Request, so a history restore's re-fetch of a pushed URL receives the full page.
Scenario: A back navigation re-fetches and gets the full page
- WHEN a user filters or pages an operator list (which pushes the list URL) and later presses the browser back button
- THEN htmx re-fetches the previous URL and the server returns the full page
- AND the restored view renders inside the intact shell
Scenario: The error renderer varies
- WHEN
RenderErrorPageanswers an htmx request with plain text or a navigation request with the full error page - THEN both responses carry
Vary: HX-Request
Requirement: The shell meets AA contrast
The shared shell's text colours SHALL meet the WCAG AA contrast ratio against the surfaces they sit on: the rail's entry links (rest and active) against the rail background, the account trigger against the bar, and links and buttons inside card headers against the header background (card headers are white, and their links use the console's darker link colour, so the organization composite's tab links and "Issue grant" button pass), so no page inherits a contrast failure from the shell. A shell change SHALL be checked against the accessibility baseline before it is accepted.
Scenario: The rail, the bar, and card headers pass
- WHEN a Lighthouse accessibility snapshot runs on
/operator,/, and an organization composite - THEN no
color-contrastfailure names a rail link, the account trigger, or a card-header link or button
Requirement: The top bar holds the brand and one control
The top bar SHALL hold the brand and exactly one control, and nothing else,
ever: the menu toggler below the lg breakpoint, the account menu at lg
and up. No surface label, no search, no help button, no utility icons, and
never two menus at one width. The brand SHALL be the bar's left-most
element at every width and SHALL truncate with an ellipsis rather than wrap
when the deployment name is long; the account trigger SHALL keep its width
cap where it renders. (The bar was measured at 390px on 2026-09-02 carrying
three items — toggler, brand, account trigger — with 13px to spare. The
account trigger leaves the bar below lg with this change and the room
returns to the deployment name.)
Scenario: Nothing joins the bar
- WHEN a person views any shell page at 390px wide
- THEN the bar shows the brand at its left edge and the menu toggler at its right, the document does not scroll horizontally, and no other control sits in the bar
Scenario: The bar swaps its one control at the breakpoint
- WHEN a person views any shell page at
lgor wider - THEN the bar shows the brand at its left edge and the account menu's trigger at its right, and no toggler renders
Scenario: A long deployment name truncates
- WHEN the configured deployment name is longer than the space beside the bar's one control at 390px
- THEN the brand renders on one line with an ellipsis and the control stays visible