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.