> ## Documentation Index
> Fetch the complete documentation index at: https://sdlc-rstack.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Motion & Accessibility

> The Hub's motion budget — transitions over keyframes, reduced-motion, and the accessibility surface verified against the real DOM.

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):

```css theme={null}
--motion-fast: .16s;
--motion-base: .22s;
--motion-slow: .4s;
--motion-draw: .9s;
--motion-ease: ease;
--motion-ease-emphatic: cubic-bezier(.22, 1, .36, 1);
```

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:

```css theme={null}
/* #541: scroll reveal. Only .reveal-pending hides, and only script ever
   applies it — there is deliberately no rule on [data-reveal] itself, so a
   reader whose JS never runs sees every section. */
.reveal-pending { opacity: 0; transform: translateY(8px); }
.reveal-in { opacity: 1; transform: none; }
.reveal-pending, .reveal-in {
  transition: opacity var(--motion-slow) var(--motion-ease),
              transform var(--motion-slow) var(--motion-ease-emphatic);
}
```

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:

```css theme={null}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    scroll-behavior: auto !important;
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
}
```

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`):

```js theme={null}
export function desktopNavigationMarkup() {
  return `<nav id="primary-navigation" class="destination-nav"
    aria-label="Business Hub destinations">${navigationGroups('desktop')}</nav>`;
}
```

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:

```bash theme={null}
npm test -- dashboard-512-motion       # motion tokens + reduced-motion guard
npm test -- dashboard-541-motion       # Proof Rail draw, approval lifecycle, scroll reveal
npm run test:browser                   # real-Chromium journeys, incl. axe-core
```

<Info>
  `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`.
</Info>

<Warning>
  `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.
</Warning>

## Related

* [Overview & navigation](/business-hub/overview-and-navigation)
* [Data visualizations](/business-hub/data-visualizations)
* [Studio 3D](/business-hub/studio-3d)
* [Business Hub reference](/business-hub/overview-and-navigation)
