Platform Usability Standards

Platform Usability Standards

Living reference for all UI development. All developers and AI agents must follow these standards when creating or reviewing UI code.

Color System

Every UI component uses CSS custom properties for all color roles. These properties are set by the branding system via buildBrandingStyleTag() and fall back to defaults in globals.css.

Variable Purpose Example
--dpf-bg Page background background: var(--dpf-bg)
--dpf-surface-1 Cards, panels, inputs background: var(--dpf-surface-1)
--dpf-surface-2 Secondary surfaces background: var(--dpf-surface-2)
--dpf-text Primary text color: var(--dpf-text)
--dpf-accent Interactive elements, links color: var(--dpf-accent)
--dpf-muted Secondary text, placeholders color: var(--dpf-muted)
--dpf-border Borders, dividers border-color: var(--dpf-border)
--dpf-font-body Body font family font-family: var(--dpf-font-body)
--dpf-font-heading Heading font family font-family: var(--dpf-font-heading)

Contrast Requirements

All color pairs must meet WCAG 2.2 Level AA minimum contrast ratios:

Element Type Minimum Ratio Standard
Body text on any background 4.5:1 WCAG 2.2 AA
Secondary/muted text on any background 4.5:1 WCAG 2.2 AA
Interactive text (links, buttons) on background 4.5:1 WCAG 2.2 AA
UI components (borders, focus rings) on background 3:1 WCAG 2.2 AA
Status indicators on background 3:1 WCAG 2.2 AA

Enforcement points:

Form Elements

All <input>, <select>, <textarea> elements receive a baseline via @layer components in globals.css:

Prohibited Patterns

These patterns are NOT allowed in component code:

Pattern Replacement
text-white text-[var(--dpf-text)]
text-black text-[var(--dpf-text)]
bg-white bg-[var(--dpf-surface-1)]
bg-black bg-[var(--dpf-bg)]
color: "#ffffff" color: "var(--dpf-text)"
background: "#000000" background: "var(--dpf-bg)"
Any hardcoded hex for bg/text/border/accent/muted Use the corresponding var(--dpf-*)
Hand-rolled animate-spin / animate-pulse loading indicator ui/Spinner, ui/Skeleton, ui/ProgressBar, or ui/InlineBusy (see Async Activity & Loading States)

Allowed Hex Usage

Literal hex values are permitted ONLY for:

  1. Status colors referenced from ThemeTokens.states (success, warning, error, info)
  2. SVG brand marks and third-party logos (Google, Apple, etc.)
  3. Third-party component overrides where CSS variables cannot be injected

Component Checklist

Before submitting a component, verify:

Async Activity & Loading States

Every asynchronous action MUST show a visible, consistent activity indicator. A state change with no motion — a button whose only feedback is swapped text, a panel that sits blank while data loads — reads as “nothing is happening” and is a defect. Use the shared primitives in apps/web/components/ui/; never hand-roll animate-spin / animate-pulse.

Which indicator, when (converged from Nielsen Norman Group, Shopify Polaris, GitHub Primer, Vercel Geist, IBM Carbon):

Situation Indicator Primitive
< ~0.5s (or LCP < 800ms) Nothing — a placeholder that flashes is perceptually worse than empty space
A short/unknown-duration action you triggered (button submit, inline fetch, ~0.5–10s) Spinner + status label Spinner, or InlineBusy inside a button
Async data filling a known layout (panels, cards, lists, tables) Skeleton (shape-of-content + shimmer) — best perceived performance Skeleton, SkeletonText
Determinate work where the total is known (uploads, multi-step, builds) Progress bar ProgressBar
Never Skeleton and spinner together — pick one

Refreshing existing content: keep the current content visible and dimmed (opacity) with the region marked aria-busy, and show the spinner on the trigger. Don’t blank out good content to show a placeholder.

Accessibility (required):

Rollout: the primitives are the canonical replacement for the hand-rolled indicators catalogued across the portal; migrate opportunistically and when touching a surface. A CI ratchet — scripts/check-no-hand-rolled-loading.mjs, run by the repo guard loop — freezes the remaining sites at a per-file baseline and fails any new animate-spin/animate-pulse outside components/ui/ (migrate a surface, then --update to retighten). Semantic status dots (e.g. a pulsing health indicator) are state, not loading, and are out of this pattern — but still inherit the reduced-motion guard.

Progressive Disclosure

Choose the disclosure construct by the relationship between its summary and content. Do not hand-roll a new expand/collapse dialect inside a feature.

Need Canonical construct
Preview the first rows of one long list CollapsibleList
Reveal subordinate detail for one record among peer records ExpandableCard
Hide one short, secondary piece of prose or advanced help Native <details>
Preserve a large detail workspace while the list remains visible Purpose-built drawer
Support linking, history, or a full record workflow Dedicated detail route

ExpandableCard follows the WAI-ARIA accordion/disclosure contract: a native button is the only control inside the heading; the button exposes aria-expanded and aria-controls; the panel is labelled by the trigger; and a visible chevron communicates state. Enter and Space work through native button semantics. Opening inline detail does not move focus. The same summary trigger closes it, so a separate remote “Close” action must not be added.

Render record identity and summary metadata once. Loading and recoverable error states belong inside the opened panel while its summary remains stable. Lists of peer records should normally allow one open item at a time unless research shows that operators need side-by-side comparison.

Readability & Plain Language

Business-facing copy must be understandable by the people who run a business, not only the people who build the platform. The platform — and its own marketing — holds business copy to a high-school reading level, measured with the Flesch–Kincaid tests that word processors like Microsoft Word report. Plain language is a precondition for mass adoption, and a marketing requirement when reaching a non-technical audience.

Audience tiers

Reading level is tiered by audience — match the copy to the reader. Do not flatten everything to one level:

Audience / surface Target Flesch–Kincaid grade Why
Marketing & external (storefront, campaigns, landing copy) High school ≤ 9 Reaches the widest audience; the basis for mass acceptance
A specific archetype / business page High school ≤ 9 Operators read these to run the business
Reseller / partner / integrator material College ≤ 13 Partners and MSPs want robust detail and fit
Architecture & standards (TAK, GAID, system design) Highest no cap Precision for architects and standards reviewers outranks simplicity

Two metrics, both Flesch–Kincaid:

Enforcement points

Coworker rule

Every coworker that writes customer-facing copy (marketing-specialist and peers) must:

Standards Referenced

Operator identity and personalization

When greeting or addressing the operator in portal UI:

  1. Prefer User.displayName / first name when present and human-looking.
  2. Fall back to a derived first name from email local-part only when it is not a opaque id.
  3. Never show a raw email as the primary greeting when a display name exists.
  4. Do not invent personalization knobs the operator cannot see or control; keep defaults progressive (one warm greeting, not a form).

This is the operator-personalization pattern (displayName precedence → email fallback → human-name derivation) used by dashboard greeting work (BI-IMP-0FFB8D25).

Cockpit tiles, attention cards, and coworker handoffs that open an operator work queue must use a stable filtered-link contract so the same URL works from email, chat, MCP, and progressive-disclosure UI (BI-IMP-5DE1139F / IP-E254E).

  1. Canonical query params — encode filters as stable, documented query keys (e.g. ?status=open&workType=bug&epic=EP-…). Prefer closed enums already used by the backlog/MCP surface; do not invent one-off keys per page.
  2. Deterministic section anchors — when the page has bands (NEEDS-YOU, queue, detail), use stable #section-id anchors that match id= on the real DOM (not screenshot coordinates).
  3. Server-readable filters — the list must apply the same filters from the URL on first paint (SSR or first client read of searchParams). Do not require a click-only path that loses the deep link.
  4. Graceful no-JS — the URL alone must land on a useful view; optional client-side highlight/scroll is enhancement only.
  5. No coordinate deep links — never deep-link via x/y click targets or ephemeral row indexes; use semantic ids (BI-*, FB-*, WC-*).

When adding a new operator queue, document its query keys next to the route (or in the page’s report-kit FilterBar) so cockpit cards and record_execution_evidence links can reuse them.