feat: add c2-video-grid, c2-video-tile and c2-audio-visualizer
Adds three call-screen media components from the roadmap and removes their rows:
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, aMediaStreamanalysed 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) ororb(swells and glows).muteddraws a flat, grey meter. - No source:
stateanimates an agent:idle,listening,thinkingorspeaking. - Speaking:
speaking-changefires when the level crossesspeaking-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;
labelmakes it an image of that name.
c2-video-tile (@c2n/video-tile, Data display)
- Video:
streamplays 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. localmirrors the video and adds "(You)".kind="screen"fits the share inside the tile and never mirrors it.
- No video: a
c2-avatar(initials oravatar-src) shows while there is no live video track, or withvideo-off. - Name pill: a 3-bar visualizer of the stream or of
level, or a struck-through mic whenmuted. - Speaking:
speakingdraws the accent outline. Withdetect-speaking, the tile's meter decides instead.speaking-changefires either way. - Badges:
hand-raised,pinnedandconnection(fair/poorsignal bars), plus abadgeslot and anactionsslot 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,pinnedandspeakingreflect, so a grid sees them when a framework sets them as properties.
c2-video-grid (@c2n/video-grid, Layout)
- Layouts:
gridpicks the column count that makes tiles largest (exported asfitTiles), centres the last row and refits on resize.spotlightputs the others in a filmstrip below;sidebarputs 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-changefires when it changes. - Overflow:
max-tilesfolds the rest into a "+N more" button with avatars, which firesoverflow-clickwith 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
slotattributes.
- Size: with no height set, the grid takes its height from its width at 16:9.
--c2-video-grid--heightsets one.
Verification
- Build chain: the full build, the theme,
build:tools,size:addandui:buildall ran. - Checks:
lint,format:check,docs:check,test:type-check,check:bundle-size,check:example-theme,check:dogfoodand thevalidateCLI 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
captureStreamcamera and an oscillator microphone, so no devices are needed.
- The fake media are a canvas
Screenshot review (both themes: docs, galleries, landing cards)
- Grid gallery:
docs:checkrejected 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: contentsisland, which::slottedcannot size. The grid now passes its tile size down as inherited properties thatc2-video-tilesizes 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
- 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-speakingtile reports throughspeaking-change. - A tile muted while its meter heard speech kept the outline. It is now cleared.
- 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.
- A grid that was detached and re-attached stopped refitting. It now observes resizes again.
- A visualizer whose stream was set later kept its idle look. It now redraws.
- A cross-origin media element would have been silenced. It is now left alone.
- The inner
<video>was muted only as an attribute. It is now muted as a property too. - A second raised hand was not announced. It is now cleared and re-announced.
- A gap of 0, or one written in
rem, was misread. The grid now reads the gap the stage resolved. - 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
innerHTMLorunsafeHTML; - 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-visualizerdraws a level as centre-weighted bars, a wave SVG, or a glowing orb from aMediaStream, a playing same-origin media element, or a level from 0 to 1. With no source,stateanimates an agent (idle,listening,thinking,speaking),speaking-changefires over a 300 ms held threshold, and it draws only while on screen at 4 fps under reduced motion. It absorbs the plannedc2-voice-orb(#166) as its orb variant.c2-video-tileshows 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, plusbadgeandactionsslots. It is named as a group ("Ana Ruiz, muted, hand raised") and a raised hand is announced again on every raise.c2-video-gridlays 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
captureStreamcamera 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
AudioContexttries to resume when a source connects. AwebAudioRuns()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.