Skip to main content
The Business Hub uses motion to make observed state changes legible — a value flips, a gate opens, work arrives — never as decoration on quiet data. That rule is enforced by a mechanism, not a convention someone has to remember: almost every animated rule in src/observability/dashboard/ui/styles.js is a CSS transition, not a @keyframes animation, because a transition only fires when a property changes on an element that already exists. A card that arrives already in its final state never animates; a card that flips under your eyes does. The same budget, plus a prefers-reduced-motion guard and the accessibility scaffolding (landmarks, focus trap, keyboard nav), is mirrored in the 3D Studio’s stylesheet.

How it works

The motion tokens

Every duration and easing curve in the Hub routes through custom properties defined once on :root (styles.js, top of the sheet):
The stylesheet’s own header comment states the rule the tokens exist to enforce: “motion exists to make an observed state change legible… Surfaces may transition on real state changes only; decorative loops on quiet data are prohibited… All durations/easings route through the --motion-* tokens below; never hardcode a transition duration.” --motion-draw is used specifically for stroke-based “drawing” effects — the Quality & Risk dials’ stroke-dasharray arcs, the Spend Center’s .bar-fill width, and the Proof Rail’s .proof-check checkmark all transition on --motion-draw with the emphatic easing curve, so a score or a passed check visibly fills in rather than snapping to its final state.

Transition, never keyframes — the one exception

The overwhelming majority of animated declarations in the sheet are transition: rules on real property changes: nav chevrons rotating (.destination-chevron), pill/state crossfades, the incoming page fade (.page.active { animation: page-enter ... } — deliberately a one-shot keyframe gated behind the .active class flip itself, not a loop), and the scroll-reveal pair:
The sheet does still use a handful of genuine @keyframes — but only for the one sanctioned case the header comment calls out: a continuous pulse that indicates an observed ongoing state, never a decorative loop. Examples: presence-pulse (a live person indicator), gate-pulse (a blocked gate’s red glow), ws-breathe/visor-breathe (workspace/session emphasis), and skeleton-shimmer (bound to the “no snapshot yet” first-load state, not a generic loading spinner). @starting-style is used once, for freshly-morphed feed rows (#516) to settle in on arrival — engines without @starting-style support simply skip that entry animation, no fallback needed.

The reduced-motion guard

One @media (prefers-reduced-motion: reduce) block at the end of the sheet freezes everything above it, including the sanctioned pulses:
The 3D Studio (studio3d/styles.css) carries the identical guard twice: once as a real @media (prefers-reduced-motion: reduce) block, and once as an explicit #studio-app[data-motion="reduced"] attribute selector with the same .001ms duration collapse — so the Studio can also be forced into reduced motion by the app itself (independent of the OS-level media query), not only by the browser/OS setting.

Landmarks and keyboard navigation

The Hub’s shell (ui/index.js) is built from real landmark elements: a <main id="main"> wrapping the topbar and page content, with role="group" on the scope controls and role="status" aria-live="polite" regions for scope/connection announcements. Desktop navigation renders as a real <nav> (navigation.js, desktopNavigationMarkup):
The mobile drawer is a focus-trapped dialog: role="dialog" aria-modal="true" aria-labelledby="mobile-nav-title", opened with focus moved to the first destination button, and a keydown handler (handleMobileNavigationKeydown) that closes on Escape and cycles Tab/Shift+Tab between the panel’s first and last focusable element (mobileNavigationFocusable) so keyboard focus can’t escape into the page behind it. Closing restores focus to whatever element opened the drawer. The run drawer (<aside id="drawer-panel" role="dialog" aria-modal="true"> in index.js) follows the same Escape-to-close pattern via client.js’s keydown handler.

Try it

The motion budget and accessibility surface are pinned by tests, not just convention:
tests/browser/dashboard-96.test.js drives a real headless Chromium via playwright-core and injects axe-core on every destination, asserting zero serious/critical violations except a documented, tracked color-contrast debt item. It also asserts the mobile nav opens on keyboard interaction and closes on Escape with role="dialog", and checks for critical horizontal overflow at 1440/1024/768/390px viewports. Both axe-core and playwright-core are runtime dependencies in package.json (not devDependencies) so browser verification is available on install, including to subagents — browsers themselves install separately via npx playwright-core install --only-shell chromium.
npm run test:browser skips cleanly (not a failure) when no browser is available in the environment. npm test does not run these — they’re isolated under tests/browser/ behind a dedicated script and CI job.