feat: add c2-video-grid, c2-video-tile and c2-audio-visualizer

Merged
CO
15h ago
+4107-22

Adds three call-screen media components from the roadmap and removes their rows:

  • #128 c2-video-grid
  • #127 c2-video-tile
  • #129 c2-audio-visualizer, which absorbs #166 c2-voice-orb

None of them connects to a call. Streams, mute state and the active speaker come from the app's SDK (LiveKit, Daily, plain WebRTC), and the components only draw them. All three went through the design-mock step first, and the design doc and mock screenshots were shared before building.

c2-audio-visualizer (@c2n/audio-visualizer, Data display)

  • Sources: the first one set wins:
    • stream, a MediaStream analysed through one shared Web Audio context;
    • media, a playing <audio>/<video>; a cross-origin element without CORS is left alone, because routing it would silence it;
    • level, a number from 0 to 1 that SDKs already report.
  • Looks: variant="bars" (centre-weighted), wave (an SVG path) or orb (swells and glows). muted draws a flat, grey meter.
  • No source: state animates an agent: idle, listening, thinking or speaking.
  • Speaking: speaking-change fires when the level crosses speaking-threshold, held for 300 ms, and :state(speaking) matches meanwhile.
    • It still ends when the visualizer is off screen (a slower read loop), muted or removed.
  • Motion: it draws only while it is on screen and the page is visible. Under reduced motion it is a steady meter at 4 frames a second.
  • Accessibility: decorative by default; label makes it an image of that name.

c2-video-tile (@c2n/video-tile, Data display)

  • Video:
    • stream plays in an inner <video> that is always muted, so the app's own audio playback never echoes.
    • An SDK's own <video> goes in the default slot instead.
    • local mirrors the video and adds "(You)". kind="screen" fits the share inside the tile and never mirrors it.
  • No video: a c2-avatar (initials or avatar-src) shows while there is no live video track, or with video-off.
  • Name pill: a 3-bar visualizer of the stream or of level, or a struck-through mic when muted.
  • Speaking: speaking draws the accent outline. With detect-speaking, the tile's meter decides instead. speaking-change fires either way.
  • Badges: hand-raised, pinned and connection (fair/poor signal bars), plus a badge slot and an actions slot that shows on hover and focus.
  • Accessibility:
    • The tile is a group named "Ana Ruiz, muted, hand raised". Speaking is left out of the name because it changes too often.
    • A raised hand is announced, every time.
  • Reflected properties: name, kind, pinned and speaking reflect, so a grid sees them when a framework sets them as properties.

c2-video-grid (@c2n/video-grid, Layout)

  • Layouts:
    • grid picks the column count that makes tiles largest (exported as fitTiles), centres the last row and refits on resize.
    • spotlight puts the others in a filmstrip below; sidebar puts them in a column at the inline end (left in RTL).
  • Featured tile: a child marked featured, else a screen share, else a pinned tile, else the first. It never follows the active speaker by itself. featured-change fires when it changes.
  • Overflow: max-tiles folds the rest into a "+N more" button with avatars, which fires overflow-click with the hidden elements. Pinned and speaking tiles are never folded.
  • Routing:
    • Tiles are routed by manual slot assignment, so a moved tile keeps its element and its playing video.
    • A server-rendered (named-mode) root falls back to slot attributes.
  • Size: with no height set, the grid takes its height from its width at 16:9. --c2-video-grid--height sets one.

Verification

  • Build chain: the full build, the theme, build:tools, size:add and ui:build all ran.
  • Checks: lint, format:check, docs:check, test:type-check, check:bundle-size, check:example-theme, check:dogfood and the validate CLI all pass.
  • Browser tests: the three suites pass in Chromium (6 + 7 + 6 tests, plus a regression test for each review fix), repeated 2×. The umbrella suite passes too. Firefox and WebKit run in CI.
    • The fake media are a canvas captureStream camera and an oscillator microphone, so no devices are needed.

Screenshot review (both themes: docs, galleries, landing cards)

  • Grid gallery: docs:check rejected a Default card that set a variable, so the grid now sizes from its width when no height is set.
  • Hydration mismatch: the grid is server-rendered without its children, and its first client render drew the "+N more" tile. It now keeps the server's output until after its first update.
  • Tiles invisible in the docs: Astro wraps each tile in a display: contents island, which ::slotted cannot size. The grid now passes its tile size down as inherited properties that c2-video-tile sizes itself from, and reads a wrapped tile's state through the wrapper. A regression test wraps tiles the same way.
  • Small tiles: the name pill covered the avatar, so the avatar now centres above the pill.
  • Thinking orb: showed a pinwheel seam; it is now blurred.
  • Demo name: the docs tile read "You (You)" and now uses a real name.

/code-review: 10 findings, all fixed with tests

  1. The grid read only tile attributes, so tile state set as properties (React, Vue, Angular) was ignored. It now reads properties as well, the tile reflects its state, and a detect-speaking tile reports through speaking-change.
  2. A tile muted while its meter heard speech kept the outline. It is now cleared.
  3. The visualizer's speaking state stuck when its loop stopped (off screen, muted, removed). It is now ended, and an unseen source is still read.
  4. A grid that was detached and re-attached stopped refitting. It now observes resizes again.
  5. A visualizer whose stream was set later kept its idle look. It now redraws.
  6. A cross-origin media element would have been silenced. It is now left alone.
  7. The inner <video> was muted only as an attribute. It is now muted as a property too.
  8. A second raised hand was not announced. It is now cleared and re-announced.
  9. A gap of 0, or one written in rem, was misread. The grid now reads the gap the stage resolved.
  10. The surface default in the JSDoc did not match the SCSS. They now match.

The three smaller points were fixed too:

  • a source no longer connects twice;
  • the grid watches only its children, not the subtree;
  • the gesture listeners are removed once the audio context runs.

/security-review

No findings at the reporting threshold:

  • all rendering goes through Lit bindings, with no innerHTML or unsafeHTML;
  • the components never request the camera or microphone;
  • audio analysis stays in the page.

Below the threshold: the docs demos do not stop the tracks they open, so the browser's recording dot stays on until the page is closed.


Generated by Claude Code


Summary by cubic

Adds the three call-screen media components from the roadmap — c2-audio-visualizer, c2-video-tile, and c2-video-grid — and removes their roadmap rows. None connects to a call: the app's SDK supplies streams, mute state, and the active speaker, and the components only draw them.

Components

  • c2-audio-visualizer draws a level as centre-weighted bars, a wave SVG, or a glowing orb from a MediaStream, a playing same-origin media element, or a level from 0 to 1. With no source, state animates an agent (idle, listening, thinking, speaking), speaking-change fires over a 300 ms held threshold, and it draws only while on screen at 4 fps under reduced motion. It absorbs the planned c2-voice-orb (#166) as its orb variant.
  • c2-video-tile shows a participant's video (an always-muted inner <video>, or the SDK's own element in the default slot) or an avatar, with a name pill with a live meter, a struck-through mic when muted, a speaking outline, and badges for a raised hand, pin, and weak connection, plus badge and actions slots. It is named as a group ("Ana Ruiz, muted, hand raised") and a raised hand is announced again on every raise.
  • c2-video-grid lays tiles out as a grid, spotlight, or sidebar, sizing from its width at 16:9 when no height is set. It picks a featured tile, folds extra tiles into a "+N more" button (pinned and speaking tiles never fold), and routes children by manual slot assignment so a moved tile keeps its element and playing video.

Verification

  • The /code-review found 10 issues, all fixed with tests; /security-review found nothing at threshold.
  • Build, lint, format, docs, type-check, bundle-size, and browser suites pass; tests use a canvas captureStream camera and an oscillator microphone, so no devices are needed.
  • The CI runner has no audio device and Firefox's autoplay policy kept those oscillator streams silent, so the Playwright config gives Firefox a null audio backend and lets Web Audio run there. Analyser nodes are routed through a shared muted gain node to the speakers, since a dead-end analyser is never processed, and the shared AudioContext tries to resume when a source connects. A webAudioRuns() fixture skips the stream tests where Web Audio still cannot run, and a silence failure reports whether the tone's context and the visualizer's analyser run, and the level each reads.
  • A side fix pins the clock in the month-planner picker test, a toggletip test waits for its live region before tabbing, and an in-view test lets the outer watcher's own enter fire before counting, so all pass year-round and on loaded runners.