Console UX Rules (UXR catalog — BINDING)
Status: Binding (ratified 2026-07-11) Scope: all console UI under src/Web/StellaOps.Web/** Source sprint: docs/implplan/SPRINT_20260711_018_FE_ux_rules_material_and_remediation.md Guard spec: src/Web/StellaOps.Web/src/app/core/design/ux-rules-guard.spec.ts
Distilled from the 2026-07-11 36-finding UX audit (families A–F) and ratified by the approved before/after design review. Every rule below is binding for any agent or human touching console UI. Four rules (R1, R11, R12, R14) are machine-enforced by the guard spec; the rest are enforced by review and by the UXR-5/6 population sweeps. Format per rule: Rule (binding statement) · Why (human-perception grounding) · Material (reusable token/primitive that implements it) · Guard (machine enforcement, if any).
All contrast ratios below are computed WCAG relative-luminance ratios (alpha-blended over the actual theme background), not eyeballed.
UXR-R1 — Never white text on a brand or status token fill
- Rule: a CSS rule block must never set
colorto#fff/#ffffff/whitewhile filling its background with the brand gold (--color-brand-primary) or a status token. Use the status-bg/-texttoken pair, or--color-text-on-brand(#101828, defined in both themes) when the fill really must be the brand gold. - Why: white on the gold
#F2C14Ecomputes ≈1.7:1 — illegible for anyone, invisible in sunlight; dark ink on the same gold is 10.57:1. - Material:
--color-text-on-brand: #101828(both theme blocks insrc/styles/tokens/_colors.scss); status-bg/-text/-bordertriplets per status family. Exemplar fix: thestella-tableactive pager number. - Guard: YES —
ux-rules-guard.spec.tscheck (a). What actually landed (deviation from the original spec, deliberate): the fill matcher covers the base status tokens (--color-status-warningetc.) and-text/-bordervariants, toleratingvar(--x, fallback)syntax — only the pale-bgfills are sanctioned as backgrounds. The original-text|-border-only regex missed 6 of the 14 audit seeds.
UXR-R2 — Focus must be visible in both themes
- Rule: never
outline: none(orbox-shadow: noneon focus) without an equally visible replacement ring. Use the focus tokens; do not invent per-component rings. - Why: keyboard users are blind without a focus indicator; a sub-3:1 ring is functionally absent on tinted surfaces.
- Material: light theme ring = night-blue
rgba(23, 74, 103, 0.9)at 2px (--color-focus-ringintokens/_colors.scss,--focus-ring-color/--focus-ring-widthin_forms.scss); dark theme stays amber (rgba(242, 193, 78, 0.48)/ formsrgba(245, 184, 74, 0.5)). Computed: light ring 6.95:1 on#FFF9EF(was 1.22:1); dark rings 3.37–3.41:1 — all ≥3:1. - Guard: none (token-level fix carries the population). Enforced by review.
UXR-R3 — Dark-theme warning is the orange ramp + an icon; gold is reserved
- Rule: in the dark theme, warning states use the distinct orange ramp and pair the color with an icon (⚠ / SVG). The brand gold is reserved for brand, primary action, and selection — never for warnings.
- Why: gold-warning vs gold-selection were the same hue in dark — a warning that looks like a selection is a missed warning; an icon also survives color-vision deficiency.
- Material: dark warning tokens in
tokens/_colors.scss: base#E08A3C, bgrgba(224, 122, 47, 0.18), border#C97F45, text#F0B583. Computed on the blended dark bg: text 8.05:1 (≥4.5 ✓), base 5.44:1, border 4.57:1 (≥3 ✓). Icon slot:stella-chip[icon]. - Guard: none. Deliberate non-change: the light-theme warning ramp was left as-is (the collision is acute only in dark); revisit after dark ships. Also ratified: the primary button keeps its per-theme identity (blue light / gold dark) — the rule bans white text on gold (R1), not the gold itself.
UXR-R4 — Type floor: base 14px; readable floor 12px; below 12px is ornament only
- Rule: body/data text uses
--font-size-base(0.875rem = 14px). Prefer--font-size-13(0.8125rem = 13px) for dense readable text.--font-size-sm(12px) is the readable floor — legal for captions, table meta, and timestamps.--font-size-xs(11px) and below are ornament only: count badges, letter glyphs, decorative micro-chrome — never sentences, values, or anything the user must read to act. - Why: sub-12px text drops below comfortable reading acuity at normal viewing distance; operators read this console for hours.
- Material:
src/styles/tokens/_typography.scss(base bumped 13px → 14px in UXR-1;--font-size-13added for dense surfaces). - Decision (ratified 2026-07-12, sprint 20260711_018): the floor was set at 12px, not the originally-drafted 12.5px — it aligns with the scale’s own
--font-size-smstep and avoids churning ~1,285 legitimate 12px caption sites. The 11px tier (Tier 2, ~975 readable sites) was converted to 13px in UXR-6 wave 5. To tighten later: raise the floor here and sweep--font-size-smreadable uses. - Guard: none (the E1/F2 population sweeps drained hardcoded sub-12px readable sites; 18 SVG/fixed-geometry chart labels remain flagged for layout redesign — see the microtext inventory’s Flagged section).
UXR-R5 — Interactive targets ≥ 32px
- Rule: every interactive element offers at least a 32×32px effective hit area. Small icon buttons extend their target with the mixin instead of inflating padding.
- Why: Fitts’s law — sub-32px targets force slow, error-prone precision pointing; misses on destructive icons are worse than slow hits.
- Material:
@mixin hit-area($min: 32px)insrc/styles/_mixins.scss— a transparent centered::afteroverlay (max(100%, $min)square); visual size and layout untouched. - Guard: none (F3 population sweep).
UXR-R6 — Clickable non-button elements get the keyboard triad via stellaClickableRow
- Rule: a
<tr>/<div>(or any non-button element) carrying a(click)handler must also carrystellaClickableRow— which suppliesrole(defaultbutton, overriderole="link"for navigation),tabindex="0", Enter/Space activation (Space’s scroll suppressed), thesx-clickable-rowcursor + focus-ring styling, and adisabledguard (keyboard suppression,tabindex=-1,aria-disabled). Do not hand-roll the triad. - Why: a click-only row simply does not exist for keyboard and assistive-tech users.
- Material:
src/app/shared/directives/clickable-row.directive.ts+ the.sx-clickable-rowblock insrc/styles/_interactions.scss. Activation synthesises a native host click, so the consumer’s(click)stays the single source of behavior; keys on interactive children are left to the child (no double activation). - Guard: none (C4 population sweep). What landed: the
disabledguard covers the keyboard path + ARIA + tab order only — guarding pointer clicks stays with the consumer’s own handler (a directive cannot unbind a template(click); documented in the directive JSDoc).
UXR-R7 — Enumerable match keys get stella-suggest (or a picker) — never bare free text
- Rule: any input matched against a known key set — environment names, tenants, roles, subjects, image refs, package ids, policy/pack ids — uses
<stella-suggest>fed by a real read (or a full picker). Never a bare free-text input. - Why: an exact-match typo in free text fails silently — the miss renders as “no data” and the operator trusts it.
- Material:
src/app/shared/components/stella-suggest/stella-suggest.component.ts(exported from the shared components barrel):[options]static or[loadOptions]async (Promise/Observable),[allowFreeText](default true), always-on exact-match indicator with an explicitdata-match-state, full ARIA combobox pattern. Honesty contract (UXD-8, carried fromtriage-audit-bundle-new): a FAILED suggestion read rendersload-failed— match state unknown — and is never rendered as the honestemptystate. - Guard: none (D family population sweep).
UXR-R8 — One filled primary per view; table rows never carry filled primaries
- Rule: at most one filled primary button per view; everything else is secondary/ghost. Rows in tables and lists never carry filled primary buttons (row actions are ghost/text buttons).
- Why: emphasis is relative — twenty “primary” buttons means no primary; a filled button in every row turns a data surface into a wall of noise.
- Material: existing button variants (
shared/components/button); no new primitive needed. - Guard: none (F1 population sweep + review).
UXR-R9 — No affordance without a handler
- Rule: nothing may promise behavior it doesn’t have — no
cursor: pointer, hover highlight, or chevron on an element with no handler; conversely, remove dead interactive-looking markup rather than leaving it inert. - Why: a broken promise trains operators to stop trying affordances that do work.
- Material: exemplar fixes from UXR-3: the Doctor
check-resultheader now has a real toggle (button, rotating chevron,aria-expanded/aria-controls); dead components with unreachable render branches (navigation-menu,sidebar-nav-group,app-dropdown,stella-form-fieldflat duplicate, orphan flatcompare-view) were deleted with zero-usage proofs. - Guard: none (review).
UXR-R10 — Hover-intent consistency (150ms enter / 300ms leave)
- Rule: if one navigation level opens on hover, its children do too, with the same hover-intent timings: 150ms enter delay, 300ms leave delay. Keyboard/chevron toggles must keep working alongside hover.
- Why: mixed hover/click levels in one menu make the menu feel broken; intent delays stop flicker on diagonal mouse travel.
- Material:
app-sidebar.component.ts—SectionHoverOpenDelayMs = 150/SectionHoverCloseDelayMs = 300, same rule as the collapsed-rail flyout. Sidebar submenus now open on hover-intent; the chevron toggle closes a hover-transient open without writing the collapse preference. What landed::focus-withinhover-parity for submenus was skipped (focusin on chevron mousedown would self-cancel the chevron click) — the keyboard path stays on the chevron toggle. - Guard: none (review).
UXR-R11 — Angular Material is FROZEN; the house idiom is canonical
- Rule: no new
@angular/materialimports or<mat-*>selectors. New surfaces use the house idioms: standalone components, OnPush, signals, design tokens, thestella-*kit (seesrc/Web/StellaOps.Web/AGENTS.md“Canonical UI Kit”). The F5 migration (UXR-6) drains the existing Material call-sites; the allowlist shrinks monotonically and never grows. - Why: two design systems on one screen means two focus styles, two densities, two theme pipelines — every Material island breaks the rules above in its own way.
- Guard: YES —
ux-rules-guard.spec.tscheck (b), frozen to the file baseline captured 2026-07-11 (concentrated incompare/,evidence-thread/,timeline/,secret-detection/,shared/proof-spine).
UXR-R12 — Route changes move focus to the main landmark without stealing query-filter focus
- Rule: every routed shell owns one focusable
<main>landmark carryingstellaRouteFocus. A route path change moves focus to that landmark withpreventScroll; a query-only change does not move focus, because filter bars synchronize state through the URL while the operator is typing. - Why: after a route replaces the focused link/button, browser focus otherwise falls back to the document body. Keyboard and screen-reader users then have no reliable announcement or starting point for the new page. Treating query-string synchronization as a page change would create the opposite failure by stealing focus from active filters.
- Material:
shared/directives/route-focus.directive.ts, adopted by the authenticatedAppShellComponentmain landmark and the root component’s unauthenticated/full-page landmarks. The landmark uses the existing--color-focus-ring: computed on its resolved surface, 6.48:1 light and 3.33:1 dark. - Guard: YES —
ux-rules-guard.spec.tsasserts both routed shell templates keep the shared directive on their main landmarks. Directive and shell specs prove initial focus, path-change focus, and query-only focus preservation.
UXR-R13 — Async feedback announces once, at the urgency it actually has
- Rule: routine success/info/warning feedback uses one
role="status"/aria-live="polite"region; only errors userole="alert"/aria-live="assertive". Never nest an alert inside a second live region. Mark decorative status iconsaria-hidden, make the message atomic, and disable entrance/timer motion underprefers-reduced-motion: reduce. - Why: nested live regions can announce one message twice, while marking every success toast as an alert interrupts screen-reader speech as if the operation failed. Motion-only timers and slide-ins remain distracting for people who ask the operating system to reduce motion.
- Material: the global
ToastContainerComponent; it is mounted byAppComponenton every route. Its existing semantic text and icon colors remain token-driven and pass arithmetic contrast in both themes: primary text 15.30:1/15.36:1, secondary text 7.65:1/10.20:1, and status icons at least 5.72:1/6.15:1 (light/dark minima). - Guard: no source-wide guard; the adjacent toast rendering spec asserts single polite vs assertive announcements, atomicity, decorative-icon hiding, and reduced-motion CSS.
UXR-R14 — The routed shell owns the single main landmark
- Rule: the application shell owns the document’s one
<main>landmark. Routed feature templates and their child panels usesection,article, ordiv; they never add another<main>inside the shell. Full-page and unauthenticated routes remain covered by the root shell’s own main landmark. - Why: nested or duplicate main landmarks make screen-reader landmark navigation ambiguous and violate the HTML document outline. An operator asking to jump to “main” must land on the routed page once, not choose among a shell main and an internal layout panel.
- Material: the authenticated
AppShellComponentand rootAppComponentmain landmarks plus UXR-R12 focus management. The 2026-07-12 population sweep removed nested main tags from 15 feature templates without changing their layout classes./signin,/signout,/auth/callback,/auth/silent-refresh,/logged-out, and/setup-wizard/*are true full-page routes: the root main remains, while app-brand, command-palette, and keyboard-shortcut chrome is not rendered behind them and therefore cannot enter the focus order. - Guard: YES — the guard rejects
<main>insrc/app/features/**; only the routed shells may own the landmark.
UXR-R15 — Page-level tabs use the canonical tabs and preserve addressable state
- Rule: a routed workspace’s primary in-page tabs use
<stella-page-tabs>. When panes are not represented by child routes, seturlParam="tab"(or another explicit query key); child-route tab shells keep their route-based state instead. Local mode toggles inside a panel are not page tabs and do not inherit this URL requirement. - Why: button strips with visual-only active classes omit tab roles, arrow-key behavior, and a shareable/reloadable view. Operators lose context when they refresh, paste a link, or return to an investigation.
- Material:
shared/components/stella-page-tabs/stella-page-tabs.component.tssupplies the ARIA tablist/tabpanel contract, roving tabindex, arrow/Home/End keys, disabled semantics, and optional query-param synchronization. Every tab’saria-controlstargets the same stable, currently existing panel id; changing the active tab never invalidates the association. The deployment monitor at/releases/deployments/:id, the Advisory & VEX Sources catalog at/ops/integrations/advisory-vex-sources?view=health, and the routed notification inbox at/notificationsare retained exemplars. The retired/security/intelligence?section=...bookmarks translate into the catalog’sview/categorystate. - Guard: none; review must distinguish page tabs from local mode switches.
UXR-R16 — Routed authoring drafts cannot disappear on navigation
- Rule: a routed multi-field or multi-step authoring surface that holds an unsaved local draft implements
HasPendingChangesand addscanDeactivate: [pendingChangesGuard]. Route/query prefill is the pristine baseline, successful submission clears the dirty state, and server-saved step flows do not prompt for work that is already durable. - Why: browser Back, a sidebar click, or a copied deep link can otherwise discard minutes of release, promotion, bundle, hotfix, or key-reference work without warning.
- Material:
core/guards/pending-changes.guard.tsand the shared confirm dialog. The covered live wizard routes are/releases/versions/new,/releases/hotfixes/new,/releases/promotions/create,/releases/bundles/create,/releases/bundles/:bundleId/organizer, and/console-admin/crypto-control/:kekId/rotate, in addition to the five previously guarded editors. - Guard: no source-wide heuristic; route specs assert the retained authoring routes opt in and component specs assert pristine/dirty/submitted behavior.
UXR-R17 — Theme-sensitive readable and status styling uses semantic tokens
- Rule: foreground/background pairs on a routed surface use the semantic design tokens. A fixed literal is acceptable only when it is intentionally theme-invariant and has arithmetic contrast proof against both resolved theme surfaces. Status styling uses the existing
-bg/-textpair; primary controls use the button tokens. - Why: a literal that passes in one theme can invert into unreadable text in the other. The KEK rotation wizard demonstrated both failure directions:
#555was 7.12:1 light but 2.54:1 dark;#888was 3.38:1 light but 5.34:1 dark. - Material:
tokens/_colors.scss. After tokenization, the wizard’s lowest readable pair is the muted text at 4.90:1 light / 6.17:1 dark; secondary text is 7.65:1 / 10.20:1, action text 9.06:1 / 11.27:1, primary-button text 8.96:1 / 10.57:1, and status text/background pairs are at least 6.61:1 / 8.06:1. - Guard: none; legacy literal colors require contextual resolution and arithmetic review.
UXR-R18 — Search and filter inputs have a persistent accessible name
- Rule: placeholder text is a hint, never the only accessible name. Every search or filter input uses a visible
<label>/stella-form-fieldwhere the layout permits, or an explicitaria-labelwhen the compact control’s purpose is already visually established by its surrounding toolbar. - Why: placeholders disappear after entry and are not a dependable programmatic label. An operator navigating by form controls otherwise hears only “edit text” with no indication of what the field filters.
- Material: the shared
stella-filter-barderives its accessible name fromsearchPlaceholder; compact routed searches on/ops/scriptsand/ops/operations/doctoruse explicit labels. - Guard: none; whether a compact label is sufficiently contextual requires rendered review.
UXR-R19 — Popup menus expose one complete relationship and keyboard contract
- Rule: a menu trigger uses
aria-haspopup="menu",aria-expanded, andaria-controlstargeting the menu’s stable id. The popup usesrole="menu"; actions usemenuitem, selected single-choice settings usemenuitemradioplusaria-checked, and separators are semantic. Arrow Up/Down and Home/End move among enabled items. Escape dismisses and returns focus to the trigger; Tab dismisses without trapping or reversing normal browser focus movement. Do not label a button strip as a menu unless the whole interaction contract is present. - Why: role-only menus advertise keyboard behavior screen-reader and keyboard users then cannot perform. Closing a popup while leaving focus on a removed descendant drops focus to
<body>, so the operator loses their place in the persistent header. - Material:
shared/directives/menu-focus.directive.ts(stellaMenuFocus) andshared/accessibility/focus-return.ts. The user menu is the reference adopter. Non-menu header popovers such as Notifications reuse the same focus-return helper and keep a stable trigger/panelaria-controlsrelationship without falsely claiming menu semantics. - Guard: none; a source heuristic cannot prove focus order or distinguish a menu from a dialog. The directive, user-menu, and topbar rendering specs exercise the behavior.
UXR-R20 — Controls live at the scope they actually affect
- Rule: persistent global chrome contains only controls with stable, cross-route meaning. Tenant remains global. Region and Environment are one progressive-disclosure Scope control. A filter, view, or mode that changes one page, table, or workflow lives beside that content and preserves its state in the URL when the state is useful to reload, share, or revisit. A control with no routed consumer is removed, never retained as anticipatory chrome.
- Why: a page-specific control in global chrome implies that it affects the whole product. When it silently changes only one table—or nothing at all—the operator cannot predict the blast radius, cannot explain an empty result, and loses trust in every other header control. Keeping the account cluster pinned also prevents low-priority context from displacing logout, notifications, or identity at narrower desktop widths.
- Material:
ContextChipsComponentis the canonical compact Scope disclosure;stella-filter-chip,stella-filter-multi,stella-view-mode-switcher, andstella-page-tabsare the page-level materials. The integration Status and advisory Category filters are reference URL-synchronized adopters. Content width is a user Display preference, not data scope, and therefore lives in the user menu. - Guard: none. Whether a backend contract and routed consumer give a control global meaning requires source/OpenAPI review. Adjacent specs assert the current header taxonomy and page-local adopters.
UXR-R21 — A loading layer yields as soon as its destination is usable
- Rule: a splash, blocking loader, or full-surface skeleton must be removed as soon as the destination surface is rendered and interactive. It may wait for a route or authentication gate that genuinely prevents use; it must not obscure an already-restored authenticated shell while a secondary refresh or background request continues.
- Why: hiding a usable interface behind a non-interactive loading layer creates a false wait, makes performance feel substantially worse, and prevents visual automation from observing the state a user can already operate.
- Material:
AppComponentdismisses the inline#stella-splashwhen either the first route resolves or the restored authenticated session makes the application shell ready. Route-local pending data continues to use the existing loading and skeleton primitives inside the page. - Guard: none; source scanning cannot prove when a runtime destination becomes usable. The root component spec and authenticated Playwright timing check cover the contract.
UXR-R22 — One primary accent; every other color is a semantic role
- Rule: the console’s only decorative emphasis color is
--color-accent-primary(theme-adaptive: night navy#174A67light / brand gold#F2C14Edark; ink pair--color-accent-contrast). Tokens that read as “the accent” (--color-action-link,--color-card-heading,--color-btn-primary-bg,--color-page-eyebrow) DERIVE from it — never fork them back to literals. Everything else is a role, not decoration: gold--color-brand-*= brand identity (fills/active/selection; never light-theme text), purple =--color-status-excepted/--color-evidence-attestationsemantics only, cyan = terminal syntax only, status/severity ramps = state. Pages must not invent their own accent (hardcoded purples, cyan eyebrows). - Why: the 2026-07-16 design re-audit (backlog #10) found the gold/navy/purple roulette is what holds 35 surfaces at 3★ and caps the best at 4★ — with no single accent, no surface can read as designed. Gold cannot be the light-theme text accent (≈1.6:1 on the cream surfaces), which is why the accent is a theme-adaptive pair rather than one hex.
- Material: the canonical accent block in
src/styles/tokens/_colors.scss(all three theme blocks), with the accent contract documented inline. - Guard: YES —
core/design/accessibility-contrast.spec.ts“#10 canonical-accent contract”: derivation equality per theme + WCAG AA for accent text on every content surface and ink on the accent fill.
UXR-R23 — Every page opens with the canonical purpose-header
- Rule: pages open with
<stella-page-header>(componentContextHeaderComponent,shared/ui/context-header/;app-context-headeris the legacy alias — flip tags on touch): eyebrow (area/tenant context in--color-page-eyebrow) + one h1 (sized by--font-size-page-title) + a one-line purpose subtitle. Extra structured header content (verdict strips, snapshot lines) projects into[header-context]; actions into[header-actions]. Do not hand-roll header copy blocks or bare<h1>+<p>intros. - Why: same audit, same backlog item — four+ header dialects (different eyebrow colors, h1 scales 1.25–1.75rem, ad-hoc layouts) deny the console a recognizable opening move; the purpose line is what makes a surface self-explanatory in the first second.
- Material: the canonicalized header component; adopters: dashboard-v3, exposure, the compliance cockpits (NIS2/CRA/assurance), inventory, deadletter, offline-kit, pack-registry, registry-admin.
- Guard: partial — the component’s own spec pins the eyebrow/title tokens and slots; adoption is enforced by review and the #10 migration recipe (
docs/implplan/_evidence/20260716_design_uplift/design-system-foundation.md).
UXR-R24 — One Refresh rendering
- Rule: every refresh affordance is
<stella-refresh-button>: neutral secondary rendering, circular-arrows icon + the visible constant label “Refresh”,[busy]while in flight (disabled +aria-busy), placed FIRST in the page-header actions slot for page scope (section toolbars: right end, same component). Never a filled primary, never icon-only, never a bare link, and never a dead registration (a Refresh that renders nowhere). - Why: the audit counted 8+ Refresh dialects across 54 files (including a filled-primary Refresh and one registered through PageActionService on a route with no outlet — invisible). A safe, idempotent action must look identical everywhere or it reads as different features.
- Material:
shared/components/stella-refresh-button/. - Guard: YES (component-level) — its spec bans primary/brand fills at source level and pins the busy contract. Population sweep is routed via the #10 recipe.
The guard spec — how enforcement works
src/Web/StellaOps.Web/src/app/core/design/ux-rules-guard.spec.ts scans all of src/app (.ts/.scss/.html) and runs six checks (~8s; run it with npx vitest run --config vitest.codex.config.ts src/app/core/design/ux-rules-guard.spec.ts):
- New white-on-token violation (R1) — fails with the offending file + the rule pointer.
- New Material usage (R11) — same.
- White-on-token baseline honesty — a baseline entry that no longer violates (fixed or deleted) FAILS the spec until the entry is removed, so the allowlist can only shrink.
- Material baseline honesty — same for the Material freeze list.
- Route-focus coverage (R12) — every routed shell template keeps
stellaRouteFocuson its main landmark. - Single-main coverage (R14) — feature templates cannot introduce a nested/duplicate main landmark inside the routed shell.
Protocol when you fix or delete a baselined file: remove its line from WHITE_ON_TOKEN_BASELINE / MATERIAL_BASELINE in the same change. Never add an entry.
Related
src/Web/StellaOps.Web/AGENTS.md— module conventions (UI kit, no-mocks, error copy, etc.); carries the short inline version of the six most load-bearing rules above.docs/agent-playbooks/frontend-implementer.md— build/test recipes.- Token ground truth:
src/Web/StellaOps.Web/src/styles/tokens/_colors.scss,tokens/_typography.scss,_forms.scss,_mixins.scss,_interactions.scss.
