{
  "name": "AudioVisualizer",
  "type": "pattern",
  "description": "A bar meter that follows a live audio level through a requestAnimationFrame loop with zero React re-renders, asymmetric attack/release smoothing, and a post-speech linger",
  "export": "@gtmi/strix-react/molecules/audio-visualizer",
  "path": "@gtmi/strix-react/molecules/audio-visualizer",
  "props": [
    {
      "name": "isSpeaking",
      "type": "boolean",
      "default": "false",
      "description": "Whether audio is currently flowing. A falling edge is followed by lingerMs of continued animation"
    },
    {
      "name": "audioLevelRef",
      "type": "React.RefObject<number>",
      "description": "Live RMS level, read from .current on every frame and never through state. ~0.06 is loud speech"
    },
    {
      "name": "tone",
      "type": "'accent' | 'neutral' | 'destructive' | 'success'",
      "default": "'accent'",
      "description": "Semantic bar color, mapped to a fill-* token utility"
    },
    {
      "name": "size",
      "type": "number",
      "default": "56",
      "description": "Overall height in px. Every other geometry constant scales from it"
    },
    {
      "name": "barCount",
      "type": "number",
      "default": "18",
      "description": "Number of bars. Also widens the bell, which is barCount / 4.5"
    },
    {
      "name": "lingerMs",
      "type": "number",
      "default": "1500",
      "description": "How long the meter keeps animating after isSpeaking goes false"
    },
    {
      "name": "respectReducedMotion",
      "type": "boolean",
      "default": "true",
      "description": "Honour prefers-reduced-motion: reduce by parking the bars and never starting the frame loop"
    },
    {
      "name": "decorative",
      "type": "boolean",
      "default": "true",
      "description": "Hide the meter from assistive technology. False renders role=\"img\" with label as the accessible name"
    },
    {
      "name": "label",
      "type": "string",
      "default": "'Audio level'",
      "description": "Accessible name when decorative is false"
    }
  ],
  "parts": [
    {
      "name": "audio-visualizer",
      "description": "The svg root, via data-slot, carrying data-tone"
    },
    {
      "name": "audio-visualizer-bar",
      "description": "One bar, via data-slot, carrying data-index"
    }
  ],
  "ai_tags": [
    "audio",
    "visualizer",
    "level-meter",
    "waveform",
    "voice",
    "speech",
    "svg",
    "animation",
    "requestAnimationFrame",
    "pattern"
  ],
  "since": "0.2.0",
  "source": "cr-web-demo packages/ui/src/atoms/audio-visualizer.tsx",
  "tokens_used": [
    "--color-icon-accent",
    "--color-icon-primary",
    "--color-icon-destructive",
    "--color-icon-success",
    "--spacing-gap-300",
    "--spacing-gap-500",
    "--color-icon-waveform-agent"
  ],
  "notes": "ALGORITHM PORTED VERBATIM, because it is deliberate and correct. One requestAnimationFrame loop mutates `height` and `y` on the <rect> elements imperatively, with ZERO React re-renders per frame: every piece of animation state (per-bar heights, per-bar targets, the smoothed global level, the frame id, the linger timer, the active flag, the reduced-motion flag) lives in a ref and nothing in the loop can schedule a render. A test drives 300 frames through a stubbed requestAnimationFrame and asserts the render count is still 1. The global RMS is smoothed with ASYMMETRIC attack/release (ATTACK 0.28, RELEASE 0.07: alpha = target > current ? ATTACK : RELEASE), each bar is lerped towards its target with the same asymmetry, targets are regenerated every TARGET_UPDATE_MS = 180 under a jittered Gaussian envelope (bellTarget: bell = exp(-dist^2 / 2*SIGMA^2) with SIGMA = barCount / 4.5 and CENTER = (barCount - 1) / 2, jitter = 0.5 + random()*0.5, scaled by normalizedLevel = min(smoothedLevel / LEVEL_SCALE, 1) with LEVEL_SCALE = 0.06), and `isSpeaking` going false schedules a 1500ms post-speech linger rather than cutting out. The asymmetry is asserted numerically rather than as \"it changed\": the test measures the FRACTION OF THE REMAINING GAP covered over the same five frames in each direction — the only comparison not contaminated by the two phases starting from different distances — and pins each to 1 - (1 - alpha)^5, so setting RELEASE equal to ATTACK fails it. bellTarget is exported as a pure function and its shape is asserted directly (centre > shoulder > edge, symmetry about the centre, minHeight as an exact floor at level 0, linear in the level) with Math.random stubbed. IMPROVEMENT 1, prefers-reduced-motion — the important one: the source runs its loop unconditionally, forever, for every user, and an 18-bar meter animating continuously is precisely the case WCAG 2.3.3 exists for. Under a reduce preference every bar is parked at minHeight and requestAnimationFrame is never called at all (asserted with `not.toHaveBeenCalled`, not by inspecting a paused flag). The preference is tracked with a matchMedia('(prefers-reduced-motion: reduce)') change listener rather than sampled at mount, so flipping it at the OS level takes effect immediately in both directions — turning it on cancels the frame and parks, turning it off starts the loop — and both directions are tested by dispatching on a stubbed MediaQueryList. `respectReducedMotion={false}` is the escape hatch, for a meter whose motion is the content itself. Every matchMedia access is guarded, because jsdom and some hardened embeds do not implement it; with no matchMedia the component animates. IMPROVEMENT 2, idle stop: the source's loop runs for the lifetime of the component, burning a frame callback and 18 attribute writes forever on a silent page. This cancels the frame once the meter has GENUINELY come to rest — inactive, level decayed, and every bar within SETTLE_EPSILON (0.05px) of the floor — and restarts from the isSpeaking effect on the next activity. The epsilon exists because the release lerp is asymptotic, so \"exactly at rest\" is not a reachable state and a naive check would never fire; conversely stopping on !active alone would freeze the bars mid-decay at whatever height the linger expired on, so the test asserts that the loop is STILL running two frames after the linger expires and only stops several hundred frames later. IMPROVEMENT 3, token colors: the source picks between the hex #EF223A and `white` from an `isAgent` boolean. The hex cannot follow a theme and no-hardcoded-color would flag it; `white` measures 1.00:1 on a light page and only ever worked because cr-web-demo is dark-only; and `isAgent` is the consuming app's domain vocabulary, not a visual one. A semantic `tone` mapped to a fill-* token utility replaces all three: accent -> --color-icon-accent (5.03:1 light, 9.78:1 dark), neutral -> --color-icon-primary (19.37:1, 17.85:1), destructive -> --color-icon-destructive (7.09:1, 6.13:1), success -> --color-icon-success (6.00:1, 10.71:1), all against --color-surface-base and all clearing WCAG 1.4.11's 3:1 for a non-text graphic, proved with checkPairings over tokens DERIVED from the component's own cva output rather than named in the assertion. `neutral` is the honest translation of `white`: --color-icon-primary is #ffffff in dark, so the dark rendering is unchanged, and #000d25 in light instead of invisible. Critically, the class is set once by React and the loop only ever touches geometry: the source calls rect.setAttribute('fill', fill) for all 18 bars on every frame, ~1000 redundant writes a second for a value that changes only when a prop does, and a fill attribute would also beat the class. A test spies on setAttribute across 50 frames and asserts the set of attributes written is exactly {height, y}. IMPROVEMENT 4, accessibility: the source renders a bare <svg> with no role and no name, which is an accident rather than a decision. The decision here is aria-hidden=\"true\" BY DEFAULT, exposed as `decorative`. A level meter conveys nothing that is not already application state — whether the agent is speaking needs announcing from wherever that state lives, not from a graphic whose only content is a number changing sixty times a second — so hiding it removes a nameless image from the tree without removing information. `decorative={false}` switches to role=\"img\" with `label` ('Audio level' by default) for the rare case where the meter is the only indication that audio is live. There is deliberately NO live region and a test asserts none appears: an aria-live audio level is a screen reader talking over the audio it is measuring. IMPROVEMENT 5, sizing: `size` and `barCount` are props defaulting to the source's 56 and 18, with every one of the source's eight hardcoded geometry numbers derived by the exported audioVisualizerGeometry(size, barCount) from a single size/56 scale factor; at the defaults it reproduces the source exactly (216x56 box, 8px bars on a 12px pitch, 4..54 height range, sigma 4, centre 8.5). Cleanup cancels the frame, clears the linger timeout, and removes the matchMedia listener, each asserted separately on unmount.\n\nA FIFTH TONE, BECAUSE NEITHER SHIPPED TONE WAS FIGMA'S RED. `.agent-waiting` fills its bars `color/brand/accesible-red-dark-theme` #ef223a at 4.57:1 on the page. The composed screens drew `accent`, which resolves to --palette-primary-300 #9ec1fd — a BLUE at 10.61:1, a better ratio and a different hue. `destructive` is #d97e79, a muted salmon, so it is not Figma's red either. --color-icon-accent is deliberately NOT re-pointed: Spinner, Toast and six other components share it, and a speaking-agent waveform is not what \"the accent icon\" means. `neutral` needed nothing — --color-icon-primary is already #ffffff in dark, so the honest translation of the source's white was already exact. docs/FIGMA-EXCEPTIONS.md had listed this as a pending clean revert."
}
