# Session review

> A research team’s session screen: the interview’s recording with a scrub preview, a transcript lit as it is spoken, and an assistant to question it.

Review a recorded user interview without leaving the screen. Play, scrub with a preview of any moment, change the speed or go full screen; the transcript lights the phrase being said and keeps its line in view, and every timestamp seeks. Tabs hold the summary, comments and insights, and an assistant panel answers questions about the recording, streaming its replies with timestamps that jump to the moment. The recording is illustrated: no photo of anyone.

A React component by Bruno Delorence: https://brunodelorence.com/components/session-review. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Dashboards.

## Anatomy

1. **Sidebar**: The app’s mark, an icon nav with the current section marked, and the account. Its first button widens it to show labels; under 640 px it becomes a bar across the top.
2. **Breadcrumbs**: The study and the session, the last one marked as the current page.
3. **Header**: The session’s title over a two-line description with See more, then the More menu and Share.
4. **Facts**: Date, products, company and participant, as a definition list.
5. **Player**: The illustrated recording with the researcher picture-in-picture. Pressing the picture plays or pauses; the bar under it has volume, both clocks, the scrub bar, speed, cast and full screen.
6. **Tabs**: Summary, Transcript, Comments and Insights, as a tab list the arrow keys walk.
7. **Transcript**: One line per turn: its timestamp, the words and who said them. The line being spoken is kept at the top, its stamp blue and its current four words marked.
8. **Assistant**: Ask the recording a question: three suggestions, then answers that stream in with timestamp chips. It folds into a rail or closes.

## When to use

- A research team reviewing a recorded interview: watching it, reading along, and pulling out what matters.
- A recording whose words matter as much as its picture, so the transcript follows the time and every line seeks back to it.
- Notes that point at moments: summary, comments and insights each carry a timestamp that plays from there.
- Asking questions of one recording, with answers that cite the moments they come from.

## When not to use

- A video to simply watch. Use a plain player; the tabs and the assistant only get in the way.
- Comparing many sessions across a study. Use a table of sessions with tags and filters, and open this from a row.
- A live call. This reviews a recording after the fact; a call needs its own controls and presence.
- Editing the recording into clips or a reel. Use a timeline editor with in and out points.
- A general chat assistant. This one only answers about the session on screen; use a chat thread for the rest.

## Installation

Install it with the shadcn CLI, into components/session-review of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/session-review.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { SessionReview } from './session-review';

export function SessionPage() {
  return (
    <SessionReview
      autoplay={false}
      defaultTime={128}
      onSeek={(time) => console.log('seek', time)}
    />
  );
}
```

## States

- **Transcript**: Opens at 02:08, paused: the current line at the top of its box, its phrase marked.
- **Summary**: A paragraph, then key moments, each with the timestamp it plays from.
- **Comments**: The team’s notes, each pinned to a time; the field posts a new one at the current time.
- **Insights**: Cards by kind (pain point, behaviour, opportunity) with the quote behind them.
- **Scrub preview**: Hovering the scrub bar: a small frame of that moment over its time and the total, and the part ahead tinted.
- **Not following**: After the transcript is scrolled by hand, it stops following; `Follow 02:08` brings the line back.
- **Answered**: A suggestion asked and answered: the reply with three timestamp chips that seek the recording.
- **Share open**: The link, with the current time as its start unless unchecked, and Copy link.
- **More menu**: Reviewed (a checkbox item), Download transcript and Add a comment.
- **Sidebar labels**: The sidebar widened over the screen with its labels; a press outside or Escape folds it.
- **Assistant folded**: `assistant="folded"`: a rail with the assistant’s name; the recording takes the room.
- **Assistant closed**: `assistant="closed"`: gone, with Ask Sorrel in the header to bring it back.
- **Under 1100 px**: At 900 px the screen is one column: the assistant moves under the recording and the tabs.
- **Under 640 px**: At 600 px the sidebar turns into a bar across the top, and the player drops volume, cast and the time left.
- **Dark**: `colorScheme="dark"`, or a dark page: near-black, the recording unchanged.

## How it works

- Nothing plays: the time is one number the screen owns. A timer adds a quarter of a second, times the speed, four times a second while playing, and holds while the screen is off-screen or the tab hidden. The scrub bar, both clocks, which figure talks and the transcript’s current line and phrase all read that number.
- The picture is two inline SVG scenes, the participant in her room and the researcher’s picture-in-picture against a brick wall. While playing, each figure breathes and nods by CSS transforms on its groups, and the one speaking moves its mouth; pausing pauses every animation at once through animation-play-state.
- The scrub bar is a role="slider" driven by pointer events with pointer capture: hovering shows a preview (a small copy of the scene, leaning as she did at that moment, with the hovered time over the total), pressing or dragging seeks. The played and hovered spans are full-width bars scaled from the left, so nothing is laid out again as time moves.
- The transcript lights the line being spoken and, inside it, the phrase being said, four words at a time, from how far time has gone between this line and the next. Each seek or new line scrolls its line to the top of the box; scrolling by hand lets go until the Follow button brings it back.
- The assistant’s answers are canned (data.ts): an answer is text and timestamps, split into tokens and revealed one every 38 ms; timestamps become chips that seek. The composer sends on Enter, adds the current time from its plus button, fakes a dictation from the microphone and has a menu for short answers or a new chat. The panel folds into a rail or closes, and the header brings it back.
- Layout is by container queries on the root, so it answers to its own width: the full screen from 1100 px, 945 px tall with the tab’s content scrolling inside; under that the assistant moves below the recording; under 640 px the sidebar becomes a bar across the top. The player’s controls answer to the player’s own width, dropping the volume, casting and remaining time when narrow.
- While on screen and untouched, a demo pointer plays the recording, previews and seeks on the scrub bar, jumps from a timestamp, asks the assistant and follows a timestamp in its answer, then walks the tabs and starts over. Any real input hands control to the reader.
- Every colour is a light-dark() token on the root, so the screen follows the page’s color-scheme or the colorScheme prop; the recording keeps its own colours in both.

## API reference

- **session** (`Session`, default `SESSION`): The session on screen: study, title, description, facts, length, the two people and the transcript (data.ts has the type and an invented interview).
- **defaultTime** (`number`, default `128`): Where the recording opens, in seconds.
- **defaultTab** (`'summary' | 'transcript' | 'comments' | 'insights'`, default `'transcript'`): The tab that opens.
- **assistant** (`'open' | 'folded' | 'closed'`, default `'open'`): The assistant’s panel at first: open, folded into a rail, or closed.
- **onSeek** (`(time: number) => void`): Called whenever the recording seeks, with the new time in seconds.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer plays, previews and seeks, jumps from a timestamp, asks the assistant and walks the tabs. Real input hands control to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s system setting; without it, the page’s color-scheme.
- **defaultState** (`{ preview?: number; menu?: 'more' | 'share'; asked?: string; unfollowed?: boolean; labels?: boolean }`): Opens in a given state: a scrub preview (a share of the length), the More menu or Share open, a suggestion answered in full, the transcript let go of the time, or the sidebar’s labels shown. Giving one keeps the demo pointer off.
- **className** (`string`): Extra class for the root.

## Accessibility

- Landmarks and headings: the sidebar is an aside with a nav, the screen a main with an h1, the breadcrumb marks the current page, and the assistant is a labelled section with an h2.
- The scrub bar is a role="slider" with its value as text ("02:08 of 08:24"): arrow keys step 5 s, Page Up and Down 30 s, Home and End go to either end. The volume is a native range input; play, mute, speed, casting and full screen are named buttons, with aria-pressed where they toggle.
- The tabs are a tablist with arrow keys, Home and End. The transcript is a focusable ordered list; the current line has aria-current="time", and every timestamp is a button named "Play from 02:34".
- The kebab menu and the composer’s settings are role="menu" with menuitem and menuitemcheckbox items: arrow keys move, Escape closes and returns to the button, a press outside closes them. Share opens a labelled dialog that Escape closes.
- The assistant’s conversation is a role="log" (aria-live polite, aria-busy while streaming); the toast and the player’s status (playing, paused, sought) are polite live regions that speak on change, not on every tick.
- Every control shows a :focus-visible ring. Reduced motion stills the figures, streams answers at once, scrolls the transcript without smoothing and turns the demo off. The scenes, avatars and the demo pointer are aria-hidden.

## Source

### session-review/session-review.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  useSyncExternalStore,
  type KeyboardEvent,
} from 'react';
import { Assistant, tokenize, type Message } from './assistant';
import {
  COMMENTS,
  INSIGHTS,
  REPLIES,
  SESSION,
  START,
  SUGGESTIONS,
  SUMMARY,
  clock,
  replyTo,
  segmentAt,
  type Comment,
  type Session,
} from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { AppMark, Icon, type IconName } from './icons';
import { Player } from './player';
import { Face } from './scene';
import { useAutoplay } from './use-autoplay';
import styles from './session-review.module.css';

/*
 * Session review: the screen a research team watches an interview on. A thin icon sidebar; the
 * study's breadcrumbs, the session's title, description and facts, with Share and a menu; the
 * recording (illustrated, with the researcher's picture-in-picture and a hover preview on the
 * scrub bar); tabs for the summary, the transcript, comments and insights; and an assistant
 * that answers questions about the recording.
 *
 * - One number is the time. A timer advances it while playing (four ticks a second, times the
 *   speed; it holds while the screen is off-screen or the tab hidden), and everything reads it:
 *   the scrub bar, the clocks, which figure speaks, the transcript's current line and phrase.
 * - The transcript lights the line being spoken and, inside it, the phrase (four words at a time)
 *   being said, and keeps the line at the top of its box as time goes on. Scrolling it by hand
 *   lets go of that until "Follow" brings it back. Every timestamp, in the transcript, the
 *   summary, comments, insights and the assistant's answers, seeks the recording.
 * - The assistant streams canned answers token by token (data.ts) and can be folded into a rail
 *   or closed; a button in the header brings it back.
 * - Layout is by container queries on the root: the full screen from 1100 px (945 px tall, the
 *   tab's content scrolling inside), the assistant under the recording below that, and under
 *   640 px the sidebar turns into a bar across the top.
 * - While on screen and untouched, a demo pointer plays the recording, previews and seeks on the
 *   scrub bar, jumps from a timestamp, asks the assistant and walks the tabs. Any real input
 *   hands control to the reader.
 */

type Tab = 'summary' | 'transcript' | 'comments' | 'insights';
type Scheme = 'light' | 'dark' | 'system';
type PanelMode = 'open' | 'folded' | 'closed';

/** A state to open in, for docs and tests; giving one keeps the demo pointer off. */
export type SessionReviewState = {
  /** The scrub bar's hover preview, as a share of the length (0–1). */
  preview?: number;
  /** A header popover open: the More menu, or Share. */
  menu?: 'more' | 'share';
  /** A suggestion already asked and answered in full: 'ideas', 'needs' or 'opportunities'. */
  asked?: string;
  /** The transcript let go of the time (as after scrolling it by hand), with Follow showing. */
  unfollowed?: boolean;
  /** The sidebar widened to show its labels. */
  labels?: boolean;
};

const TABS: { id: Tab; label: string }[] = [
  { id: 'summary', label: 'Summary' },
  { id: 'transcript', label: 'Transcript' },
  { id: 'comments', label: 'Comments' },
  { id: 'insights', label: 'Insights' },
];

const NAV: { id: string; label: string; icon: IconName }[] = [
  { id: 'search', label: 'Search', icon: 'search' },
  { id: 'home', label: 'Home', icon: 'home' },
  { id: 'studies', label: 'Studies', icon: 'studies' },
  { id: 'calendar', label: 'Calendar', icon: 'calendar' },
  { id: 'files', label: 'Files', icon: 'folder' },
  { id: 'people', label: 'Participants', icon: 'people' },
];

/** The assistant's name, and the app's. */
const ASSISTANT = 'Sorrel AI';
/** How often the clock ticks while playing, in ms. */
const TICK = 250;
/** How many words a lit phrase holds. */
const PHRASE = 4;
/** How long an answer takes per token, in ms. */
const STREAM = 38;

function useReducedMotion() {
  return useSyncExternalStore(
    (change) => {
      const query = window.matchMedia('(prefers-reduced-motion: reduce)');
      query.addEventListener('change', change);
      return () => query.removeEventListener('change', change);
    },
    () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
    () => false,
  );
}

export function SessionReview({
  session = SESSION,
  defaultTime = START,
  defaultTab = 'transcript',
  assistant = 'open',
  onSeek,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The session on screen (data.ts has the type and the invented one). */
  session?: Session;
  /** Where the recording opens, in seconds. */
  defaultTime?: number;
  /** The tab that opens. */
  defaultTab?: Tab;
  /** The assistant's panel at first: open, folded into a rail, or closed. */
  assistant?: PanelMode;
  /** Called whenever the recording seeks, with the new time in seconds. */
  onSeek?: (time: number) => void;
  /** While on screen and untouched, a demo pointer shows the main interactions. */
  autoplay?: boolean;
  colorScheme?: Scheme;
  /** Opens in a given state (a preview, a menu, an answer…), with the demo pointer off. */
  defaultState?: SessionReviewState;
  className?: string;
}) {
  const { ref: root, playing: demoCan, take } = useAutoplay<HTMLDivElement>(6000);
  const reduced = useReducedMotion();
  const duration = session.duration;

  // ——— The recording's clock ———
  const [time, setTime] = useState(() => Math.min(Math.max(defaultTime, 0), duration));
  const [playing, setPlaying] = useState(false);
  const [speed, setSpeed] = useState(1);
  const [preview, setPreview] = useState<number | null>(defaultState?.preview ?? null);
  const onScreen = useRef(true);
  /** What the player last did, for screen readers: played, paused, sought. */
  const [status, setStatus] = useState('');

  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new IntersectionObserver(([entry]) => {
      onScreen.current = entry.isIntersecting;
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [root]);

  useEffect(() => {
    if (!playing) return;
    const timer = window.setInterval(() => {
      if (!onScreen.current || document.hidden) return;
      setTime((now) => {
        const next = now + (TICK / 1000) * speed;
        if (next >= duration) {
          setPlaying(false);
          return duration;
        }
        return next;
      });
    }, TICK);
    return () => window.clearInterval(timer);
  }, [playing, speed, duration]);

  const seek = (to: number) => {
    const next = Math.min(Math.max(to, 0), duration);
    setTime(next);
    setFollow(true);
    setJump((n) => n + 1);
    setStatus(`At ${clock(next)}`);
    onSeek?.(next);
  };
  const toggle = () => {
    if (!playing && time >= duration) setTime(0);
    setStatus(playing ? `Paused at ${clock(time)}` : 'Playing');
    setPlaying(!playing);
  };

  const transcript = session.transcript;
  const current = segmentAt(transcript, time);
  const speaker = transcript[current]?.speaker ?? 'guest';

  // ——— Header, menus, the sidebar's labels ———
  const [tab, setTab] = useState<Tab>(defaultTab);
  const [more, setMore] = useState(defaultState?.menu === 'more');
  const [share, setShare] = useState(defaultState?.menu === 'share');
  /** A menu that opens with the screen (defaultState) takes no focus: only one the reader opens. */
  const quiet = useRef(defaultState?.menu !== undefined);
  const [shareAtTime, setShareAtTime] = useState(true);
  const [copied, setCopied] = useState(false);
  const [reviewed, setReviewed] = useState(false);
  const [described, setDescribed] = useState(false);
  const [wideNav, setWideNav] = useState(defaultState?.labels ?? false);
  const [toast, setToast] = useState<{ key: number; text: string } | null>(null);
  const moreButton = useRef<HTMLButtonElement>(null);
  const shareButton = useRef<HTMLButtonElement>(null);
  const moreMenu = useRef<HTMLDivElement>(null);
  const sharePop = useRef<HTMLDivElement>(null);

  const say = (text: string) => setToast({ key: Date.now(), text });
  useEffect(() => {
    if (!toast) return;
    const timer = window.setTimeout(() => setToast(null), 2600);
    return () => window.clearTimeout(timer);
  }, [toast]);

  useEffect(() => {
    if (!more && !share) return;
    const outside = (event: PointerEvent) => {
      const target = event.target as Node;
      if (
        moreMenu.current?.contains(target) ||
        sharePop.current?.contains(target) ||
        moreButton.current?.contains(target) ||
        shareButton.current?.contains(target)
      )
        return;
      setMore(false);
      setShare(false);
    };
    document.addEventListener('pointerdown', outside);
    return () => document.removeEventListener('pointerdown', outside);
  }, [more, share]);
  useEffect(() => {
    if (more && !quiet.current)
      moreMenu.current?.querySelector<HTMLElement>('[role^="menuitem"]')?.focus();
  }, [more]);
  useEffect(() => {
    if (share && !quiet.current)
      sharePop.current?.querySelector<HTMLElement>('input[type="checkbox"]')?.focus();
  }, [share]);
  useEffect(() => {
    if (!copied) return;
    const timer = window.setTimeout(() => setCopied(false), 1800);
    return () => window.clearTimeout(timer);
  }, [copied]);

  const link = `https://sessions.example/s/u2-design${shareAtTime ? `?t=${Math.floor(time)}` : ''}`;

  // ——— The transcript follows the time ———
  const [follow, setFollow] = useState(!defaultState?.unfollowed);
  /** Bumped by every seek, so seeking within the current line still brings it back to the top. */
  const [jump, setJump] = useState(0);
  const list = useRef<HTMLOListElement>(null);
  const first = useRef(true);
  useEffect(() => {
    if (tab !== 'transcript' || !follow) return;
    const box = list.current;
    const line = box?.querySelector<HTMLElement>(`[data-index="${current}"]`);
    if (!box || !line) return;
    box.scrollTo({
      top: line.offsetTop - box.offsetTop,
      behavior: first.current || reduced ? 'auto' : 'smooth',
    });
    first.current = false;
  }, [current, follow, tab, reduced, jump]);
  useEffect(() => {
    first.current = true;
  }, [tab]);
  const letGo = () => setFollow(false);

  // ——— Comments ———
  const [comments, setComments] = useState<Comment[]>(COMMENTS);
  const [note, setNote] = useState('');

  // ——— The assistant ———
  const [panel, setPanel] = useState<PanelMode>(assistant);
  const [messages, setMessages] = useState<Message[]>(() => {
    const asked = SUGGESTIONS.find((item) => item.id === defaultState?.asked);
    if (!asked) return [];
    const tokens = tokenize(REPLIES[asked.id]);
    return [
      { id: 1, role: 'user', text: asked.label },
      { id: 2, role: 'assistant', tokens, shown: tokens.length },
    ];
  });
  const nextId = useRef(3);
  const last = messages.at(-1);
  const streaming = last?.role === 'assistant' && last.shown < last.tokens.length;

  const ask = (text: string, suggestion?: string, brief?: boolean) => {
    if (streaming) return;
    let parts = suggestion ? REPLIES[suggestion] : replyTo(text);
    if (brief) {
      const stamp = parts.findIndex((part) => typeof part !== 'string');
      parts = [...parts.slice(0, stamp + 1), '.'];
    }
    const tokens = tokenize(parts);
    setMessages((list) => [
      ...list,
      { id: nextId.current++, role: 'user', text },
      { id: nextId.current++, role: 'assistant', tokens, shown: reduced ? tokens.length : 0 },
    ]);
  };
  useEffect(() => {
    if (!streaming) return;
    const timer = window.setInterval(() => {
      setMessages((list) =>
        list.map((message, index) =>
          index === list.length - 1 && message.role === 'assistant'
            ? { ...message, shown: Math.min(message.tokens.length, message.shown + 1) }
            : message,
        ),
      );
    }, STREAM);
    return () => window.clearInterval(timer);
  }, [streaming]);

  // ——— The demo ———
  const pointer = useRef<HTMLSpanElement>(null);
  const demo = autoplay && !defaultState && demoCan && !reduced;
  const demoPreview = useEffectEvent((share: number | null) => setPreview(share));
  const demoReset = useEffectEvent(() => {
    setMessages([]);
    setPlaying(false);
    setPreview(null);
    setTab('transcript');
    setPanel('open');
    setTime(Math.min(Math.max(defaultTime, 0), duration));
    setFollow(true);
  });
  const demoStop = useEffectEvent(() => setPreview(null));

  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    const middle = (element: HTMLElement, dx = 0, dy = 0): Point => {
      const rect = stage.getBoundingClientRect();
      const scale = rect.width / stage.offsetWidth || 1;
      const box = element.getBoundingClientRect();
      return {
        x: (box.left + box.width * (0.5 + dx) - rect.left) / scale,
        y: (box.top + box.height * (0.5 + dy) - rect.top) / scale,
      };
    };
    const shown = (element: HTMLElement) => {
      const box = element.getBoundingClientRect();
      const scroller = element.closest('[data-scroll]')?.getBoundingClientRect();
      if (!box.width) return false;
      return !scroller || (box.top >= scroller.top && box.bottom <= scroller.bottom);
    };

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const to = async (selector: string, ms = 650, dx = 0, dy = 0) => {
        const element = find(selector);
        if (!element || !shown(element)) return null;
        glyph('arrow');
        await glide(middle(element, dx, dy), ms);
        return element;
      };
      const press = async (selector: string, ms = 650) => {
        const element = await to(selector, ms);
        if (!element) return false;
        glyph('hand');
        await wait(200);
        element.click();
        return true;
      };
      for (;;) {
        await wait(600);
        // Play.
        if (await press('[data-part="play"]', 900)) await wait(2600);
        // Preview a later moment on the scrub bar, then seek there.
        const bar = find('[data-part="scrub"]');
        if (bar) {
          glyph('hand');
          await glide(middle(bar, 0.32), 900);
          demoPreview(0.82);
          await wait(1300);
          await glide(middle(bar, 0.12), 700);
          demoPreview(0.62);
          await wait(900);
          bar.dispatchEvent(new CustomEvent('demo-seek', { detail: 0.62 }));
          demoPreview(null);
          await wait(2200);
        }
        // Jump back to a moment from its timestamp.
        if (await press('[data-part="timestamp"][data-at="345"]', 900)) await wait(2200);
        // Ask the assistant; follow a timestamp in its answer.
        if (await press('[data-part="suggestion"][data-id="ideas"]', 1000)) {
          await wait(4200);
          if (await press('[data-part="reply-time"]', 700)) await wait(2000);
        }
        // The other tabs.
        for (const id of ['insights', 'summary']) {
          if (await press(`[data-part="tab"][data-id="${id}"]`, 800)) await wait(1700);
        }
        if (await press('[data-part="tab"][data-id="transcript"]', 700)) await wait(1200);
        if (await press('[data-part="play"]', 900)) await wait(900);
        await to('[data-part="title"]', 1000);
        await wait(800);
        demoReset();
        await wait(1500);
      }
    });
    return () => {
      stop();
      demoStop();
    };
  }, [demo, root]);

  // The demo seeks through an event on the scrub bar, so it shares the reader's path.
  useEffect(() => {
    const bar = root.current?.querySelector('[data-part="scrub"]');
    if (!bar) return;
    const handle = (event: Event) => {
      const share = (event as CustomEvent<number>).detail;
      setTime(share * duration);
      setFollow(true);
    };
    bar.addEventListener('demo-seek', handle);
    return () => bar.removeEventListener('demo-seek', handle);
  }, [root, duration]);

  // ——— Pieces ———
  const stamp = (at: number, extra?: string) => (
    <button
      type="button"
      className={`${styles.stamp} ${extra ?? ''}`}
      data-part="timestamp"
      data-at={at}
      onClick={() => seek(at)}
      aria-label={`Play from ${clock(at)}`}
    >
      {clock(at)}
    </button>
  );

  const onTabKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const index = TABS.findIndex((item) => item.id === tab);
    let next = index;
    if (event.key === 'ArrowRight') next = (index + 1) % TABS.length;
    else if (event.key === 'ArrowLeft') next = (index - 1 + TABS.length) % TABS.length;
    else if (event.key === 'Home') next = 0;
    else if (event.key === 'End') next = TABS.length - 1;
    else return;
    event.preventDefault();
    setTab(TABS[next].id);
    event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
  };

  const segmentEnd = transcript[current + 1]?.at ?? duration;

  const renderLine = (index: number) => {
    const line = transcript[index];
    if (index !== current) return line.text;
    const words = line.text.split(' ');
    const share = Math.min(0.999, Math.max(0, (time - line.at) / (segmentEnd - line.at)));
    const start = Math.floor((share * words.length) / PHRASE) * PHRASE;
    const end = Math.min(words.length, start + PHRASE);
    const before = words.slice(0, start).join(' ');
    const after = words.slice(end).join(' ');
    return (
      <>
        {before && `${before} `}
        <mark className={styles.phrase}>{words.slice(start, end).join(' ')}</mark>
        {after && ` ${after}`}
      </>
    );
  };

  const navButton = (item: { id: string; label: string; icon: IconName }) => (
    <li key={item.id}>
      <button
        type="button"
        className={styles.navItem}
        aria-current={item.id === 'studies' ? 'page' : undefined}
        title={wideNav ? undefined : item.label}
        onClick={() => {
          setWideNav(false);
          if (item.id !== 'studies') say(`${item.label} is not part of this demo`);
        }}
      >
        <Icon name={item.icon} className={styles.navIcon} />
        <span className={styles.navLabel}>{item.label}</span>
      </button>
    </li>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="session-review"
      data-color-scheme={colorScheme}
      data-panel={panel}
      data-wide-nav={wideNav || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onWheelCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.shell}>
        <aside
          className={styles.sidebar}
          data-part="sidebar"
          aria-label="Main"
          onKeyDown={(event) => {
            if (event.key === 'Escape' && wideNav) setWideNav(false);
          }}
        >
          <span className={styles.brand} title="Studio">
            <AppMark className={styles.brandMark} />
          </span>
          <nav className={styles.nav} aria-label="Sections">
            <ul>
              <li>
                <button
                  type="button"
                  className={`${styles.navItem} ${styles.navToggle}`}
                  aria-expanded={wideNav}
                  aria-label={wideNav ? 'Hide labels' : 'Show labels'}
                  title={wideNav ? undefined : 'Show labels'}
                  onClick={() => setWideNav(!wideNav)}
                >
                  <Icon name="panel" className={styles.navIcon} />
                  <span className={styles.navLabel}>Hide labels</span>
                </button>
              </li>
              {NAV.map(navButton)}
            </ul>
          </nav>
          <div className={styles.sideFoot}>
            <ul>{navButton({ id: 'settings', label: 'Settings', icon: 'settings' })}</ul>
            <button
              type="button"
              className={styles.me}
              aria-label="Your account, Kai Moreno, online"
              title="Kai Moreno"
              onClick={() => say('Signed in as Kai Moreno')}
            >
              <Face className={styles.meFace} skin="#b98463" hair="#231a16" shirt="#f4f4f2" short />
              <span className={styles.online} />
            </button>
          </div>
        </aside>
        {wideNav && (
          <button
            type="button"
            className={styles.scrim}
            aria-label="Hide labels"
            tabIndex={-1}
            onClick={() => setWideNav(false)}
          />
        )}

        <main className={styles.main} data-part="main">
          <nav className={styles.crumbs} aria-label="Breadcrumb" data-part="breadcrumbs">
            <ol>
              <li>
                <a href="#studies" onClick={(event) => event.preventDefault()}>
                  Studies
                </a>
                <Icon name="chevron" className={styles.crumbChevron} />
              </li>
              <li>
                <a href="#study" onClick={(event) => event.preventDefault()}>
                  {session.study}
                </a>
                <Icon name="chevron" className={styles.crumbChevron} />
              </li>
              <li>
                <span aria-current="page" className={styles.crumbCurrent}>
                  {session.label} · {session.guest.name}
                </span>
              </li>
            </ol>
          </nav>

          <header className={styles.header} data-part="header">
            <span className={styles.kind} aria-hidden="true">
              <span>
                <Icon name="video" />
              </span>
            </span>
            <div className={styles.heading}>
              <h1 className={styles.title} data-part="title">
                {session.title}
              </h1>
              <p
                className={styles.description}
                data-open={described || undefined}
                id="sr-description"
              >
                <span className={styles.descriptionText}>{session.description}</span>{' '}
                <button
                  type="button"
                  className={styles.seeMore}
                  aria-expanded={described}
                  aria-controls="sr-description"
                  onClick={() => setDescribed(!described)}
                >
                  {described ? 'See less' : 'See more'}
                </button>
              </p>
            </div>
            <div className={styles.actions}>
              {panel === 'closed' && (
                <button
                  type="button"
                  className={styles.askButton}
                  onClick={() => setPanel('open')}
                  data-part="assistant-open"
                >
                  <Icon name="sparkle" />
                  <span>Ask {ASSISTANT.split(' ')[0]}</span>
                </button>
              )}
              <div className={styles.popWrap}>
                <button
                  ref={moreButton}
                  type="button"
                  className={styles.kebab}
                  aria-label="More actions"
                  aria-haspopup="menu"
                  aria-expanded={more}
                  onClick={() => {
                    quiet.current = false;
                    setShare(false);
                    setMore(!more);
                  }}
                >
                  <Icon name="more" />
                </button>
                {more && (
                  <div
                    ref={moreMenu}
                    className={`${styles.menu} ${styles.moreMenu}`}
                    role="menu"
                    aria-label="More actions"
                    onKeyDown={(event) => {
                      const items = [
                        ...(moreMenu.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ??
                          []),
                      ];
                      const index = items.indexOf(document.activeElement as HTMLElement);
                      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
                        event.preventDefault();
                        const step = event.key === 'ArrowDown' ? 1 : -1;
                        items[(index + step + items.length) % items.length]?.focus();
                      } else if (event.key === 'Escape') {
                        setMore(false);
                        moreButton.current?.focus();
                      } else if (event.key === 'Tab') setMore(false);
                    }}
                  >
                    <button
                      type="button"
                      role="menuitemcheckbox"
                      aria-checked={reviewed}
                      className={styles.menuItem}
                      onClick={() => {
                        setReviewed(!reviewed);
                        setMore(false);
                        say(reviewed ? 'Marked as not reviewed' : 'Marked as reviewed');
                      }}
                    >
                      <Icon name="check" className={styles.menuCheck} />
                      Reviewed
                    </button>
                    <button
                      type="button"
                      role="menuitem"
                      className={styles.menuItem}
                      onClick={() => {
                        setMore(false);
                        say('Transcript ready to download');
                      }}
                    >
                      <Icon name="download" className={styles.menuIcon} />
                      Download transcript
                    </button>
                    <button
                      type="button"
                      role="menuitem"
                      className={styles.menuItem}
                      onClick={() => {
                        setMore(false);
                        setTab('comments');
                      }}
                    >
                      <Icon name="pencil" className={styles.menuIcon} />
                      Add a comment
                    </button>
                  </div>
                )}
              </div>
              <div className={styles.popWrap}>
                <button
                  ref={shareButton}
                  type="button"
                  className={styles.share}
                  aria-haspopup="dialog"
                  aria-expanded={share}
                  data-part="share"
                  onClick={() => {
                    quiet.current = false;
                    setMore(false);
                    setShare(!share);
                  }}
                >
                  Share
                </button>
                {share && (
                  <div
                    ref={sharePop}
                    className={styles.sharePop}
                    role="dialog"
                    aria-label="Share this session"
                    onKeyDown={(event) => {
                      if (event.key === 'Escape') {
                        setShare(false);
                        shareButton.current?.focus();
                      }
                    }}
                  >
                    <p className={styles.shareTitle}>Share this session</p>
                    <input
                      className={styles.shareLink}
                      readOnly
                      value={link}
                      aria-label="Link"
                      onFocus={(event) => event.currentTarget.select()}
                    />
                    <label className={styles.shareCheck}>
                      <input
                        type="checkbox"
                        checked={shareAtTime}
                        onChange={(event) => setShareAtTime(event.target.checked)}
                      />
                      Start at {clock(time)}
                    </label>
                    <button
                      type="button"
                      className={styles.copy}
                      onClick={() => {
                        void navigator.clipboard?.writeText(link).catch(() => {});
                        setCopied(true);
                      }}
                    >
                      <Icon name={copied ? 'check' : 'link'} />
                      {copied ? 'Copied' : 'Copy link'}
                    </button>
                  </div>
                )}
              </div>
            </div>
          </header>

          <dl className={styles.meta} data-part="meta">
            <div>
              <dt>Date</dt>
              <dd>{session.date}</dd>
            </div>
            <div>
              <dt>Products</dt>
              <dd>{session.products}</dd>
            </div>
            <div>
              <dt>Company</dt>
              <dd>{session.company}</dd>
            </div>
            <div>
              <dt>User</dt>
              <dd>{session.guest.name}</dd>
            </div>
          </dl>

          <div className={styles.body}>
            <div className={styles.column}>
              <Player
                time={time}
                duration={duration}
                playing={playing}
                speaker={speaker}
                preview={preview}
                speed={speed}
                onSpeed={setSpeed}
                onToggle={toggle}
                onSeek={seek}
                onPreview={setPreview}
              />

              <div className={styles.tabsRow}>
                <div
                  className={styles.tabs}
                  role="tablist"
                  aria-label="Session"
                  data-part="tabs"
                  onKeyDown={onTabKey}
                >
                  {TABS.map((item) => (
                    <button
                      key={item.id}
                      type="button"
                      role="tab"
                      id={`sr-tab-${item.id}`}
                      aria-selected={tab === item.id}
                      aria-controls={`sr-panel-${item.id}`}
                      tabIndex={tab === item.id ? 0 : -1}
                      className={styles.tab}
                      data-part="tab"
                      data-id={item.id}
                      onClick={() => setTab(item.id)}
                    >
                      {item.label}
                    </button>
                  ))}
                </div>
                {tab === 'transcript' && !follow && (
                  <button
                    type="button"
                    className={styles.follow}
                    onClick={() => setFollow(true)}
                    data-part="follow"
                  >
                    Follow {clock(time)}
                  </button>
                )}
              </div>

              <div
                className={styles.tabPanel}
                role="tabpanel"
                id={`sr-panel-${tab}`}
                aria-labelledby={`sr-tab-${tab}`}
              >
                {tab === 'transcript' && (
                  <ol
                    ref={list}
                    className={styles.transcript}
                    data-part="transcript"
                    data-scroll
                    tabIndex={0}
                    aria-label="Transcript"
                    onWheel={letGo}
                    onTouchMove={letGo}
                    onKeyDown={(event) => {
                      if (
                        event.target === event.currentTarget &&
                        /^(Arrow(Up|Down)|Page(Up|Down)|Home|End| )$/.test(event.key)
                      )
                        letGo();
                    }}
                  >
                    {transcript.map((line, index) => {
                      const who = line.speaker === 'host' ? session.host : session.guest;
                      return (
                        <li
                          key={line.at}
                          className={styles.line}
                          data-index={index}
                          data-current={index === current || undefined}
                          aria-current={index === current ? 'time' : undefined}
                        >
                          {stamp(line.at)}
                          <p className={styles.lineText}>{renderLine(index)}</p>
                          <span className={styles.who} title={who.name}>
                            {line.speaker === 'host' ? (
                              <span className={styles.initials}>{who.initials}</span>
                            ) : (
                              <Face className={styles.whoFace} />
                            )}
                            <span className={styles.srOnly}>{who.name}</span>
                          </span>
                        </li>
                      );
                    })}
                  </ol>
                )}

                {tab === 'summary' && (
                  <div className={styles.scrollBox} data-scroll data-part="summary">
                    <p className={styles.summary}>{SUMMARY.text}</p>
                    <h3 className={styles.subhead}>Key moments</h3>
                    <ul className={styles.moments}>
                      {SUMMARY.moments.map((moment) => (
                        <li key={moment.at} className={styles.moment}>
                          {stamp(moment.at)}
                          <span>{moment.text}</span>
                        </li>
                      ))}
                    </ul>
                  </div>
                )}

                {tab === 'comments' && (
                  <div className={styles.scrollBox} data-scroll data-part="comments">
                    <ul className={styles.comments}>
                      {comments.map((comment) => (
                        <li key={comment.id} className={styles.comment}>
                          <span className={styles.initials}>{comment.who.initials}</span>
                          <div>
                            <p className={styles.commentHead}>
                              <strong>{comment.who.name}</strong>
                              <span>{comment.ago}</span>
                              {stamp(comment.at, styles.stampChip)}
                            </p>
                            <p className={styles.commentText}>{comment.text}</p>
                          </div>
                        </li>
                      ))}
                    </ul>
                    <form
                      className={styles.noteForm}
                      onSubmit={(event) => {
                        event.preventDefault();
                        if (!note.trim()) return;
                        setComments((list) => [
                          ...list,
                          {
                            id: `c${Date.now()}`,
                            who: { name: 'Kai Moreno', initials: 'KM', role: 'Designer' },
                            at: Math.floor(time),
                            text: note.trim(),
                            ago: 'Just now',
                          },
                        ]);
                        setNote('');
                      }}
                    >
                      <input
                        className={styles.noteField}
                        value={note}
                        onChange={(event) => setNote(event.target.value)}
                        placeholder={`Comment at ${clock(time)}`}
                        aria-label={`Comment at ${clock(time)}`}
                      />
                      <button type="submit" className={styles.noteSend} disabled={!note.trim()}>
                        Post
                      </button>
                    </form>
                  </div>
                )}

                {tab === 'insights' && (
                  <div className={styles.scrollBox} data-scroll data-part="insights">
                    <ul className={styles.insights}>
                      {INSIGHTS.map((insight) => (
                        <li key={insight.id} className={styles.insight} data-kind={insight.kind}>
                          <span className={styles.insightKind}>{insight.kind}</span>
                          <p className={styles.insightTitle}>{insight.title}</p>
                          <p className={styles.insightQuote}>
                            <Icon name="quote" className={styles.quoteIcon} />
                            {insight.quote}
                          </p>
                          {stamp(insight.at, styles.stampChip)}
                        </li>
                      ))}
                    </ul>
                  </div>
                )}
              </div>
            </div>

            {panel === 'open' && (
              <Assistant
                name={ASSISTANT}
                time={time}
                messages={messages}
                streaming={streaming}
                onAsk={ask}
                onSeek={seek}
                onClear={() => setMessages([])}
                onCollapse={() => setPanel('folded')}
                onClose={() => setPanel('closed')}
              />
            )}
            {panel === 'folded' && (
              <div className={styles.rail} data-part="assistant-rail">
                <button
                  type="button"
                  className={styles.railButton}
                  aria-label={`Open ${ASSISTANT}`}
                  title={`Open ${ASSISTANT}`}
                  onClick={() => setPanel('open')}
                >
                  <AppMark className={styles.railMark} />
                  <span className={styles.railLabel}>{ASSISTANT}</span>
                </button>
              </div>
            )}
          </div>
        </main>
      </div>

      <div className={styles.toast} role="status" aria-live="polite">
        {toast && (
          <span key={toast.key} className={styles.toastText}>
            {toast.text}
          </span>
        )}
      </div>
      <p className={styles.srOnly} aria-live="polite">
        {status}
      </p>

      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}
```

### session-review/player.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { clock } from './data';
import { Icon, PlayerGlyph } from './icons';
import { GuestScene, HostScene } from './scene';
import styles from './session-review.module.css';

/*
 * The recording's player. Nothing plays: the time is a number the screen owns and advances on
 * its own clock, and the picture is the illustrated scene of scene.tsx, which moves while
 * `playing`. The controls are real ones:
 *
 * - The scrub bar is a role="slider": hovering it shows a preview of that moment (a small copy of
 *   the scene, leaning as she did then) with its time over the total; pressing or dragging seeks,
 *   and the arrow keys step 5 s, Page Up / Down 30 s, Home and End to either end.
 * - The volume is a native range input with its speaker as a mute button; the speed button steps
 *   through 1×, 1.5×, 2× and 0.5×; casting is a toggle; full screen is the browser's own.
 * - A press on the picture plays or pauses, and a large play button shows while paused.
 */

const SPEEDS = [1, 1.5, 2, 0.5];

export function Player({
  time,
  duration,
  playing,
  speaker,
  preview,
  speed,
  onSpeed,
  onToggle,
  onSeek,
  onPreview,
}: {
  time: number;
  duration: number;
  playing: boolean;
  /** Who is speaking now: their figure's mouth moves. */
  speaker: 'host' | 'guest';
  /** The hovered share of the scrub bar (0–1), or null; the demo can set it too. */
  preview: number | null;
  /** The playback rate; the speed button steps it. */
  speed: number;
  onSpeed: (speed: number) => void;
  onToggle: () => void;
  onSeek: (time: number) => void;
  onPreview: (share: number | null) => void;
}) {
  const player = useRef<HTMLDivElement>(null);
  const bar = useRef<HTMLDivElement>(null);
  const [volume, setVolume] = useState(0.75);
  const [muted, setMuted] = useState(false);
  const [cast, setCast] = useState(false);
  const [full, setFull] = useState(false);
  const dragging = useRef(false);

  useEffect(() => {
    const change = () => setFull(document.fullscreenElement === player.current);
    document.addEventListener('fullscreenchange', change);
    return () => document.removeEventListener('fullscreenchange', change);
  }, []);

  const shareAt = (clientX: number) => {
    const box = bar.current?.getBoundingClientRect();
    if (!box || !box.width) return 0;
    return Math.min(1, Math.max(0, (clientX - box.left) / box.width));
  };

  const onBarDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    dragging.current = true;
    event.currentTarget.setPointerCapture(event.pointerId);
    const share = shareAt(event.clientX);
    onSeek(share * duration);
    onPreview(share);
  };
  const onBarMove = (event: PointerEvent<HTMLDivElement>) => {
    const share = shareAt(event.clientX);
    onPreview(share);
    if (dragging.current) onSeek(share * duration);
  };
  const onBarUp = () => {
    dragging.current = false;
  };
  const onBarKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const step: Record<string, number> = {
      ArrowRight: 5,
      ArrowUp: 5,
      ArrowLeft: -5,
      ArrowDown: -5,
      PageUp: 30,
      PageDown: -30,
    };
    if (event.key in step) onSeek(time + step[event.key]);
    else if (event.key === 'Home') onSeek(0);
    else if (event.key === 'End') onSeek(duration);
    else return;
    event.preventDefault();
  };

  const played = time / duration;
  const hoverTime = preview === null ? null : preview * duration;
  const level = muted ? 0 : volume;

  return (
    <div
      ref={player}
      className={styles.player}
      data-part="player"
      data-playing={playing || undefined}
      data-full={full || undefined}
    >
      <div className={styles.picture} onClick={onToggle} data-part="picture">
        <GuestScene speaking={playing && speaker === 'guest'} className={styles.mainScene} />
        <div className={styles.pip} data-part="pip">
          <HostScene speaking={playing && speaker === 'host'} />
        </div>
        <span className={styles.bigPlay} aria-hidden="true">
          <PlayerGlyph name="play" />
        </span>
        {cast && <span className={styles.castNote}>Casting to Living room</span>}
      </div>

      {hoverTime !== null && preview !== null && (
        <div
          className={styles.preview}
          style={{ ['--at' as string]: preview }}
          data-part="preview"
          aria-hidden="true"
        >
          <span className={styles.previewFrame}>
            <GuestScene lean={Math.sin(hoverTime / 23) * 0.8} />
            <span className={styles.previewPip}>
              <HostScene />
            </span>
          </span>
          <span className={styles.previewTime}>
            {clock(hoverTime)}
            <span className={styles.previewSep} />
            <span>{clock(duration)}</span>
          </span>
        </div>
      )}

      <div className={styles.controls} data-part="controls">
        <button
          type="button"
          className={styles.ctrl}
          data-part="play"
          aria-label={playing ? 'Pause' : 'Play'}
          onClick={onToggle}
        >
          <PlayerGlyph name={playing ? 'pause' : 'play'} className={styles.ctrlGlyph} />
        </button>
        <button
          type="button"
          className={`${styles.ctrl} ${styles.volumeButton}`}
          aria-label={muted ? 'Unmute' : 'Mute'}
          aria-pressed={muted}
          onClick={() => setMuted(!muted)}
        >
          <PlayerGlyph name={level === 0 ? 'muted' : 'volume'} className={styles.ctrlGlyph} />
        </button>
        <input
          type="range"
          className={styles.volume}
          min={0}
          max={100}
          value={Math.round(level * 100)}
          aria-label="Volume"
          style={{ ['--v' as string]: `${level * 100}%` }}
          onChange={(event) => {
            const next = Number(event.target.value) / 100;
            setVolume(next);
            setMuted(next === 0);
          }}
        />
        <span className={styles.timeNow}>{clock(time)}</span>
        <div
          ref={bar}
          className={styles.scrub}
          role="slider"
          tabIndex={0}
          aria-label="Seek"
          aria-valuemin={0}
          aria-valuemax={Math.round(duration)}
          aria-valuenow={Math.round(time)}
          aria-valuetext={`${clock(time)} of ${clock(duration)}`}
          data-part="scrub"
          onPointerDown={onBarDown}
          onPointerMove={onBarMove}
          onPointerUp={onBarUp}
          onPointerCancel={onBarUp}
          onPointerLeave={() => {
            if (!dragging.current) onPreview(null);
          }}
          onKeyDown={onBarKey}
        >
          <span className={styles.scrubTrack}>
            {preview !== null && preview > played && (
              <span className={styles.scrubAhead} style={{ transform: `scaleX(${preview})` }} />
            )}
            <span className={styles.scrubFill} style={{ transform: `scaleX(${played})` }} />
          </span>
          {preview !== null && (
            <span className={styles.scrubLine} style={{ left: `${preview * 100}%` }} />
          )}
        </div>
        <span className={styles.timeLeft}>-{clock(duration - time)}</span>
        <button
          type="button"
          className={`${styles.ctrl} ${styles.speed}`}
          aria-label={`Playback speed, ${speed}×`}
          onClick={() => onSpeed(SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length])}
          data-part="speed"
        >
          {speed}×
        </button>
        <button
          type="button"
          className={`${styles.ctrl} ${styles.castButton}`}
          aria-label="Cast to a screen"
          aria-pressed={cast}
          onClick={() => setCast(!cast)}
        >
          <Icon name="cast" className={styles.ctrlIcon} />
        </button>
        <button
          type="button"
          className={styles.ctrl}
          aria-label={full ? 'Exit full screen' : 'Full screen'}
          onClick={() => {
            if (document.fullscreenElement) void document.exitFullscreen();
            else void player.current?.requestFullscreen?.().catch(() => {});
          }}
        >
          <Icon name="expand" className={styles.ctrlIcon} />
        </button>
      </div>
    </div>
  );
}
```

### session-review/assistant.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useRef, useState } from 'react';
import { SUGGESTIONS, clock, type ReplyPart } from './data';
import { AppMark, Icon } from './icons';
import styles from './session-review.module.css';

/*
 * The assistant's panel: ask the recording a question. Empty, it shows its mark, a headline and
 * three suggestions above the composer; once asked, the conversation takes the room. Answers are
 * canned (data.ts) and stream in token by token; every timestamp in an answer is a chip that
 * seeks the recording there.
 *
 * - The composer is a textarea: Enter sends, Shift+Enter breaks the line. The plus button adds
 *   the current time as a reference, the sliders button opens a menu (a shorter answer, a new
 *   chat), and the microphone fakes a dictation into the field.
 * - The two buttons at the top fold the panel into a rail, or close it; the screen keeps the rest.
 */

export type Message =
  | { id: number; role: 'user'; text: string }
  | { id: number; role: 'assistant'; tokens: ReplyPart[]; shown: number };

/** Splits an answer into the tokens it streams in: words with their spaces, and timestamps. */
export function tokenize(parts: ReplyPart[]): ReplyPart[] {
  return parts.flatMap<ReplyPart>((part) =>
    typeof part === 'string' ? (part.match(/\s*\S+\s*/g) ?? [part]) : [part],
  );
}

export function Assistant({
  name,
  time,
  messages,
  streaming,
  onAsk,
  onSeek,
  onClear,
  onCollapse,
  onClose,
}: {
  name: string;
  time: number;
  messages: Message[];
  streaming: boolean;
  /** A question, typed or picked (a suggestion's id comes with its label); `brief` asks for a
   *  shorter answer. */
  onAsk: (text: string, suggestion?: string, brief?: boolean) => void;
  onSeek: (time: number) => void;
  onClear: () => void;
  onCollapse: () => void;
  onClose: () => void;
}) {
  const [draft, setDraft] = useState('');
  const [menu, setMenu] = useState(false);
  const [listening, setListening] = useState(false);
  const [brief, setBrief] = useState(false);
  const field = useRef<HTMLTextAreaElement>(null);
  const log = useRef<HTMLDivElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);
  const menuButton = useRef<HTMLButtonElement>(null);

  // The conversation keeps its newest line in view as it streams.
  const last = messages.at(-1);
  const progress = last?.role === 'assistant' ? last.shown : messages.length;
  useEffect(() => {
    const element = log.current;
    if (element) element.scrollTop = element.scrollHeight;
  }, [messages.length, progress]);

  // The fake dictation: listens a moment, then writes a question in.
  useEffect(() => {
    if (!listening) return;
    const timer = window.setTimeout(() => {
      setDraft('What frustrates her most about research?');
      setListening(false);
      field.current?.focus();
    }, 1600);
    return () => window.clearTimeout(timer);
  }, [listening]);

  // The menu closes on a press outside it, or Escape.
  useEffect(() => {
    if (!menu) return;
    const outside = (event: globalThis.PointerEvent) => {
      const target = event.target as Node;
      if (!menuRef.current?.contains(target) && !menuButton.current?.contains(target))
        setMenu(false);
    };
    document.addEventListener('pointerdown', outside);
    menuRef.current?.querySelector<HTMLElement>('[role^="menuitem"]')?.focus();
    return () => document.removeEventListener('pointerdown', outside);
  }, [menu]);

  const send = () => {
    const text = draft.trim();
    if (!text || streaming) return;
    onAsk(text, undefined, brief);
    setDraft('');
  };

  const empty = messages.length === 0;

  return (
    <section className={styles.assistant} data-part="assistant" aria-label={name}>
      <div className={styles.assistantTools}>
        <button
          type="button"
          className={styles.toolButton}
          aria-label={`Fold ${name}`}
          title="Fold"
          onClick={onCollapse}
          data-part="assistant-fold"
        >
          <Icon name="minimize" />
        </button>
        <button
          type="button"
          className={styles.toolButton}
          aria-label={`Close ${name}`}
          title="Close"
          onClick={onClose}
          data-part="assistant-close"
        >
          <Icon name="close" />
        </button>
      </div>

      {empty ? (
        <div className={styles.hero}>
          <span className={styles.heroMark}>
            <AppMark />
          </span>
          <p className={styles.wordmark}>{name}</p>
          <h2 className={styles.heroTitle}>
            Solve your doubts in <span>seconds</span>
          </h2>
          <p className={styles.heroText}>
            Chat with the recording and get
            <br /> insights at the moment.
          </p>
        </div>
      ) : (
        <div
          ref={log}
          className={styles.log}
          data-part="log"
          role="log"
          aria-live="polite"
          aria-busy={streaming}
        >
          {messages.map((message) =>
            message.role === 'user' ? (
              <p key={message.id} className={styles.ask}>
                {message.text}
              </p>
            ) : (
              <div key={message.id} className={styles.answer}>
                <span className={styles.answerMark} aria-hidden="true">
                  <AppMark />
                </span>
                <p>
                  {message.tokens.slice(0, message.shown).map((token, index) =>
                    typeof token === 'string' ? (
                      token
                    ) : (
                      <button
                        key={index}
                        type="button"
                        className={styles.timeChip}
                        data-part="reply-time"
                        onClick={() => onSeek(token.at)}
                        aria-label={`Play from ${clock(token.at)}`}
                      >
                        {clock(token.at)}
                      </button>
                    ),
                  )}
                  {message.shown < message.tokens.length && (
                    <span className={styles.caret} aria-hidden="true" />
                  )}
                </p>
              </div>
            ),
          )}
        </div>
      )}

      {empty && (
        <ul className={styles.suggestions} aria-label="Suggestions">
          {SUGGESTIONS.map((item) => (
            <li key={item.id}>
              <button
                type="button"
                className={styles.suggestion}
                data-part="suggestion"
                data-id={item.id}
                onClick={() => onAsk(item.label, item.id, brief)}
              >
                <Icon name={item.icon} className={styles.suggestionIcon} />
                {item.label}
              </button>
            </li>
          ))}
        </ul>
      )}

      <form
        className={styles.composer}
        data-part="composer"
        data-listening={listening || undefined}
        onSubmit={(event) => {
          event.preventDefault();
          send();
        }}
      >
        <textarea
          ref={field}
          className={styles.composerField}
          value={draft}
          rows={2}
          placeholder={listening ? 'Listening…' : `Ask ${name}`}
          aria-label={`Ask ${name}`}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={(event) => {
            if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
              event.preventDefault();
              send();
            }
          }}
        />
        <div className={styles.composerBar}>
          <button
            type="button"
            className={styles.composerButton}
            aria-label={`Add the current time, ${clock(time)}`}
            title="Add the current time"
            onClick={() => {
              setDraft(
                (text) => `${text}${text && !text.endsWith(' ') ? ' ' : ''}at ${clock(time)} `,
              );
              field.current?.focus();
            }}
          >
            <Icon name="plus" />
          </button>
          <div className={styles.composerMenuWrap}>
            <button
              ref={menuButton}
              type="button"
              className={styles.composerButton}
              aria-label="Answer settings"
              aria-haspopup="menu"
              aria-expanded={menu}
              onClick={() => setMenu(!menu)}
            >
              <Icon name="sliders" />
            </button>
            {menu && (
              <div
                ref={menuRef}
                className={`${styles.menu} ${styles.composerMenu}`}
                role="menu"
                aria-label="Answer settings"
                onKeyDown={(event) => {
                  const items = [
                    ...(menuRef.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? []),
                  ];
                  const index = items.indexOf(document.activeElement as HTMLElement);
                  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
                    event.preventDefault();
                    const step = event.key === 'ArrowDown' ? 1 : -1;
                    items[(index + step + items.length) % items.length]?.focus();
                  } else if (event.key === 'Escape') {
                    setMenu(false);
                    menuButton.current?.focus();
                  } else if (event.key === 'Tab') setMenu(false);
                }}
              >
                <button
                  type="button"
                  role="menuitemcheckbox"
                  aria-checked={brief}
                  className={styles.menuItem}
                  onClick={() => {
                    setBrief(!brief);
                    setMenu(false);
                  }}
                >
                  <Icon name="check" className={styles.menuCheck} />
                  Short answers
                </button>
                <button
                  type="button"
                  role="menuitem"
                  className={styles.menuItem}
                  disabled={empty}
                  onClick={() => {
                    onClear();
                    setMenu(false);
                    field.current?.focus();
                  }}
                >
                  <Icon name="pencil" className={styles.menuIcon} />
                  New chat
                </button>
              </div>
            )}
          </div>
          <button
            type="button"
            className={`${styles.composerButton} ${styles.mic}`}
            aria-label={listening ? 'Stop dictation' : 'Dictate'}
            aria-pressed={listening}
            onClick={() => setListening(!listening)}
          >
            <Icon name="mic" />
          </button>
          <button
            type="submit"
            className={styles.send}
            aria-label="Send"
            disabled={!draft.trim() || streaming}
            data-part="send"
          >
            <Icon name="send" />
          </button>
        </div>
      </form>
    </section>
  );
}
```

### session-review/scene.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useId } from 'react';
import styles from './session-review.module.css';

/*
 * The recording's pictures, all illustrated: no photo of anyone. The participant sits in a bright
 * room by a window; the researcher's tile shows him against a brick wall, a plant beside him.
 * Each figure breathes and sways while the recording plays, and its mouth moves while it is the
 * one speaking (data-speaking); the stylesheet holds every animation, paused with the recording
 * (data-playing on the player) and dropped under reduced motion. Gradient ids come from useId, so
 * any number of copies (the hover preview, the thumbnails) can share a page.
 */

/** The participant, in her room. `lean` (−1 to 1) tilts her, for the preview of another moment. */
export function GuestScene({
  speaking = false,
  lean = 0,
  className,
}: {
  speaking?: boolean;
  lean?: number;
  className?: string;
}) {
  const id = useId().replace(/:/g, '');
  const g = (name: string) => `${id}-${name}`;
  return (
    <svg
      className={`${styles.scene} ${className ?? ''}`}
      viewBox="0 0 1600 900"
      preserveAspectRatio="xMidYMid slice"
      data-speaking={speaking || undefined}
      aria-hidden="true"
      focusable="false"
    >
      <defs>
        <linearGradient id={g('room')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#7d6f5c" />
          <stop offset="0.12" stopColor="#b9ab97" />
          <stop offset="0.36" stopColor="#e7ded2" />
          <stop offset="0.62" stopColor="#f3ece3" />
          <stop offset="1" stopColor="#f8f3ec" />
        </linearGradient>
        <radialGradient id={g('window')} cx="0.78" cy="0.42" r="0.5">
          <stop offset="0" stopColor="#fffdf9" />
          <stop offset="0.6" stopColor="#f8f1e7" stopOpacity="0.9" />
          <stop offset="1" stopColor="#f3e9dc" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={g('beam')} x1="0" x2="1" y1="0" y2="0.4">
          <stop offset="0" stopColor="#c29a6f" stopOpacity="0" />
          <stop offset="0.35" stopColor="#c7a079" />
          <stop offset="1" stopColor="#a87d55" />
        </linearGradient>
        <linearGradient id={g('skin')} x1="0.2" x2="0.85" y1="0.1" y2="0.9">
          <stop offset="0" stopColor="#efc4a2" />
          <stop offset="0.55" stopColor="#dda582" />
          <stop offset="1" stopColor="#bf8463" />
        </linearGradient>
        <linearGradient id={g('shirt')} x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stopColor="#efe2de" />
          <stop offset="0.5" stopColor="#f8eeeb" />
          <stop offset="1" stopColor="#e6d7d2" />
        </linearGradient>
        <linearGradient id={g('hair')} x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stopColor="#2b2220" />
          <stop offset="0.6" stopColor="#1d1715" />
          <stop offset="1" stopColor="#3a2d27" />
        </linearGradient>
        <linearGradient id={g('sofa')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#ece9e5" />
          <stop offset="1" stopColor="#cfcac4" />
        </linearGradient>
      </defs>

      <rect width="1600" height="900" fill={`url(#${g('room')})`} />
      {/* The window: soft light, two mullions, a shelf with a vase. */}
      <rect x="960" width="640" height="720" fill={`url(#${g('window')})`} />
      <rect x="1175" y="80" width="12" height="600" fill="#d9c7ae" opacity="0.55" />
      <rect x="1420" y="40" width="10" height="640" fill="#d9c7ae" opacity="0.4" />
      <rect x="1090" y="430" width="420" height="10" fill="#d6c3a8" opacity="0.5" />
      <path d="M1290 430c-8-40 6-70 12-96 6 26 20 56 12 96z" fill="#cdbfae" opacity="0.6" />
      <path
        d="M700 0h900v150C1330 110 1010 70 760 44z"
        fill={`url(#${g('beam')})`}
        opacity="0.85"
      />
      <path d="M0 0h70v900H0z" fill="#5f5243" opacity="0.5" />
      <rect x="160" y="0" width="40" height="900" fill="#efe6d8" opacity="0.35" />

      {/* The sofa she leans on, and the laptop's edge. */}
      <path d="M1080 650c80-30 380-40 520-20v270h-560z" fill={`url(#${g('sofa')})`} />
      <path d="M1180 700c120-18 300-20 420-6" stroke="#dcd7d1" strokeWidth="14" fill="none" />
      <path d="M1040 900l70-170h360l-40 170z" fill="#b9b8b6" opacity="0.55" />

      <g
        className={styles.figure}
        style={{ transform: `rotate(${lean * 3}deg) translateX(${lean * 30}px)` }}
      >
        <g className={styles.breath}>
          {/* Hair behind */}
          <path
            d="M500 210c-70 90-80 240-60 380 14 90-10 160-40 230h460c-20-80-30-180-10-300 20-130-10-260-110-320-80-48-180-40-240 10z"
            fill={`url(#${g('hair')})`}
          />
          {/* Body */}
          <path
            d="M330 900c10-120 40-220 130-270 70-40 150-44 200-40 80 6 170 20 240 70 90 64 120 150 140 240z"
            fill={`url(#${g('shirt')})`}
          />
          <path d="M600 600c20 40 70 58 120 50" stroke="#e1cfc9" strokeWidth="10" fill="none" />
          <path
            d="M760 640c60 40 110 120 150 260h-90c-20-90-40-170-70-230z"
            fill="#e9dbd6"
            opacity="0.7"
          />
          {/* Neck */}
          <path
            d="M600 470c4 60 0 110-12 140 40 26 100 30 140 6-16-40-22-90-18-140z"
            fill="#cf9876"
          />
          <g className={styles.head}>
            {/* Face */}
            <ellipse
              cx="660"
              cy="370"
              rx="132"
              ry="168"
              transform="rotate(-14 660 370)"
              fill={`url(#${g('skin')})`}
            />
            <ellipse cx="540" cy="390" rx="22" ry="38" fill="#cf9572" />
            <circle cx="548" cy="452" r="7" fill="#f2efe9" />
            {/* Eyes, smiling and looking down; brows */}
            <path
              d="M598 352c14 10 30 12 44 4"
              stroke="#3a2620"
              strokeWidth="7"
              fill="none"
              strokeLinecap="round"
            />
            <path
              d="M700 330c14 10 30 10 42 0"
              stroke="#3a2620"
              strokeWidth="7"
              fill="none"
              strokeLinecap="round"
            />
            <path
              d="M590 316c20-12 42-14 60-8"
              stroke="#2c201c"
              strokeWidth="8"
              fill="none"
              strokeLinecap="round"
            />
            <path
              d="M694 292c18-10 38-12 56-4"
              stroke="#2c201c"
              strokeWidth="8"
              fill="none"
              strokeLinecap="round"
            />
            <path
              d="M676 360c4 26 2 44-8 56 10 6 22 4 30-2"
              stroke="#b97a5a"
              strokeWidth="6"
              fill="none"
              strokeLinecap="round"
            />
            <ellipse cx="610" cy="420" rx="26" ry="14" fill="#e59b86" opacity="0.35" />
            <ellipse cx="750" cy="392" rx="24" ry="13" fill="#e59b86" opacity="0.35" />
            {/* Mouth: a smile, opening as she speaks */}
            <g className={styles.mouth} style={{ transformOrigin: '676px 462px' }}>
              <path d="M636 452c22 26 64 26 86-6c-24 6-62 10-86 6z" fill="#8a3f36" />
              <path
                d="M644 456c22 8 50 6 70-6"
                stroke="#fbf3ef"
                strokeWidth="7"
                fill="none"
                strokeLinecap="round"
              />
            </g>
            {/* Hair in front: a parting, falling to the side */}
            <path
              d="M520 330c-6-110 70-190 170-196 90-6 160 50 186 130-40-50-110-74-170-58 20 10 30 26 34 40-60-30-140-20-190 40-14 20-24 40-30 44z"
              fill={`url(#${g('hair')})`}
            />
            <path
              d="M842 250c40 70 50 160 30 250-10 50-40 90-70 110 30-80 46-190 40-360z"
              fill={`url(#${g('hair')})`}
            />
          </g>
        </g>
      </g>
    </svg>
  );
}

/** The researcher, against a brick wall with a plant: the picture-in-picture tile. */
export function HostScene({
  speaking = false,
  className,
}: {
  speaking?: boolean;
  className?: string;
}) {
  const id = useId().replace(/:/g, '');
  const g = (name: string) => `${id}-${name}`;
  return (
    <svg
      className={`${styles.scene} ${className ?? ''}`}
      viewBox="0 0 480 316"
      preserveAspectRatio="xMidYMid slice"
      data-speaking={speaking || undefined}
      aria-hidden="true"
      focusable="false"
    >
      <defs>
        <pattern id={g('bricks')} width="60" height="28" patternUnits="userSpaceOnUse">
          <rect width="60" height="28" fill="#ece7e1" />
          <path d="M0 13.5h60M0 27.5h60M15 0v13.5M45 14v14" stroke="#d3cbc2" strokeWidth="2.5" />
        </pattern>
        <pattern id={g('plaid')} width="36" height="36" patternUnits="userSpaceOnUse">
          <rect width="36" height="36" fill="#3b3d47" />
          <path d="M0 9h36M0 27h36" stroke="#e7e4df" strokeWidth="4" opacity="0.8" />
          <path d="M9 0v36M27 0v36" stroke="#e7e4df" strokeWidth="4" opacity="0.65" />
          <path d="M0 18h36M18 0v36" stroke="#8e2f35" strokeWidth="3" opacity="0.8" />
        </pattern>
        <linearGradient id={g('skin')} x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stopColor="#d9a17c" />
          <stop offset="1" stopColor="#b47a58" />
        </linearGradient>
      </defs>
      <rect width="480" height="316" fill={`url(#${g('bricks')})`} />
      <rect width="480" height="316" fill="#f4efe8" opacity="0.25" />
      {/* A framed picture and a plant */}
      <rect x="40" y="0" width="120" height="58" fill="#2f2d2b" />
      <rect x="50" y="0" width="100" height="48" fill="#d7d2ca" />
      <path d="M58 40l24-22 18 14 14-10 30 18z" fill="#8f8a83" />
      <g fill="#3f7a4a">
        <path d="M410 316c-6-60-30-120-70-170 40 30 70 90 82 170z" />
        <path d="M430 316c4-70 20-130 50-176-10 60-22 110-28 176z" />
        <path d="M400 230c-30-40-70-60-110-62 40 20 74 44 102 82z" opacity="0.85" />
        <path d="M446 220c10-50 30-84 34-90v60c-12 6-24 18-34 30z" opacity="0.8" />
      </g>
      <g className={styles.figure}>
        <g className={styles.breath}>
          {/* Shoulders: a plaid shirt over a dark tee */}
          <path
            d="M90 316c6-70 40-110 100-124 30-8 70-8 100 0 60 14 96 56 102 124z"
            fill={`url(#${g('plaid')})`}
          />
          <path d="M205 196c10 30 26 46 35 46s25-16 35-46z" fill="#22232a" />
          <path d="M215 160v36c10 14 40 14 50 0v-36z" fill="#b57b59" />
          <g className={styles.head}>
            <ellipse cx="240" cy="124" rx="52" ry="64" fill={`url(#${g('skin')})`} />
            <ellipse cx="188" cy="128" rx="9" ry="15" fill="#bf8562" />
            <ellipse cx="292" cy="128" rx="9" ry="15" fill="#bf8562" />
            {/* Hair, beard */}
            <path
              d="M188 104c-4-40 22-66 54-66 34 0 58 24 52 66-6-22-26-34-52-34s-46 12-54 34z"
              fill="#1f1a18"
            />
            <path
              d="M190 132c2 46 22 72 50 74 28-2 48-28 50-74-8 18-18 26-30 28-12-8-28-8-40 0-12-2-22-10-30-28z"
              fill="#2a211e"
            />
            <g className={styles.mouth} style={{ transformOrigin: '240px 162px' }}>
              <ellipse cx="240" cy="162" rx="12" ry="5" fill="#6d3530" />
            </g>
            {/* Glasses */}
            <g fill="none" stroke="#1d1d22" strokeWidth="4">
              <rect x="200" y="104" width="34" height="24" rx="7" />
              <rect x="246" y="104" width="34" height="24" rx="7" />
              <path d="M234 112h12M200 110l-10-4M280 110l10-4" />
            </g>
            <circle cx="217" cy="117" r="3.5" fill="#1d1d22" />
            <circle cx="263" cy="117" r="3.5" fill="#1d1d22" />
          </g>
        </g>
      </g>
    </svg>
  );
}

/** A small illustrated face in a circle: the participant's avatar, or the signed-in reader's. */
export function Face({
  hair = '#1e1816',
  skin = '#d8a07c',
  shirt = '#f1e6e2',
  short = false,
  className,
}: {
  hair?: string;
  skin?: string;
  shirt?: string;
  short?: boolean;
  className?: string;
}) {
  return (
    <svg className={className} viewBox="0 0 40 40" aria-hidden="true" focusable="false">
      <rect width="40" height="40" fill="#e9e5df" />
      {!short && <path d="M9 18c0-10 5-14 11-14s11 4 11 14v18H9z" fill={hair} />}
      <path d="M6 40c1-7 6-11 14-11s13 4 14 11z" fill={shirt} />
      <path d="M17 26h6v5c-2 1.4-4 1.4-6 0z" fill={skin} opacity="0.85" />
      <ellipse cx="20" cy="19" rx="7.4" ry="9" fill={skin} />
      <path
        d={
          short
            ? 'M12.4 17c-.6-6 3-9.4 7.6-9.4s8.3 3.4 7.6 9.4c-1.6-3.4-4.4-5-7.6-5s-6 1.6-7.6 5z'
            : 'M12.4 18c0-6.4 3.4-9.6 7.6-9.6s7.6 3.2 7.6 9c-3.4-1-6.4-3-8-5.6-1.4 3-4 5.2-7.2 6.2z'
        }
        fill={hair}
      />
      <path d="M17 20.6h.01M23 20.6h.01" stroke="#2b1d18" strokeWidth="1.8" strokeLinecap="round" />
      <path
        d="M17.6 24c1.4 1.2 3.4 1.2 4.8 0"
        stroke="#7b3b33"
        strokeWidth="1.1"
        fill="none"
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### session-review/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The session on screen: an invented research interview, its transcript, summary, comments and
 * insights, and the assistant's canned answers. Times are in seconds from the start of the
 * recording; every timestamp in the screen is a button that seeks to it.
 */

export type Speaker = 'host' | 'guest';

export type Segment = { at: number; speaker: Speaker; text: string };

export type Person = { name: string; initials: string; role: string };

export type Session = {
  /** The study it belongs to, and the participant's label in it. */
  study: string;
  label: string;
  title: string;
  description: string;
  date: string;
  products: string;
  company: string;
  /** Length of the recording, in seconds. */
  duration: number;
  host: Person;
  guest: Person;
  transcript: Segment[];
};

export const SESSION: Session = {
  study: 'Design process discovery',
  label: 'User 2',
  title: 'Insights in the design process',
  description:
    'To explore how experienced product designers navigate the full product development lifecycle, from the first research questions to the handoff with engineering, and where the process breaks down when deadlines get close.',
  date: '14/05/2026',
  products: 'Payments +2',
  company: 'Fieldline',
  duration: 504,
  host: { name: 'Owen Reyes', initials: 'OR', role: 'Researcher' },
  guest: { name: 'Nora Vale', initials: 'NV', role: 'Design lead' },
  transcript: [
    {
      at: 4,
      speaker: 'host',
      text: 'Thanks for joining, Nora. Before we start, is it okay if we record this session for the team?',
    },
    { at: 12, speaker: 'guest', text: 'Of course, go ahead. Happy to help.' },
    {
      at: 21,
      speaker: 'host',
      text: 'Great. Could you give me a quick picture of what you work on day to day?',
    },
    {
      at: 30,
      speaker: 'guest',
      text: 'I lead design for the payments team — mostly checkout, invoicing and the merchant dashboard. Four product designers and one researcher.',
    },
    { at: 49, speaker: 'host', text: 'And how long have you been in that role?' },
    {
      at: 56,
      speaker: 'guest',
      text: 'A bit over two years. Before that I was at an agency, so it was a big shift to own a product end to end.',
    },
    { at: 82, speaker: 'host', text: 'What changed the most when you moved in-house?' },
    {
      at: 91,
      speaker: 'guest',
      text: 'Depth. At the agency we shipped and left. Now I live with every decision for years, so I care a lot more about why we build something before how.',
    },
    {
      at: 128,
      speaker: 'host',
      text: 'You’ve mentioned you’ve worked across different stages of the product — could you tell me a bit more about your role in discovery versus delivery?',
    },
    {
      at: 154,
      speaker: 'guest',
      text: 'Sure! In most of my projects, I’ve been involved from the research phase — talking to users, mapping pain points, identifying opportunities — all the way to designing and refining solutions with engineers. I love that balance between strategy and craft.',
    },
    {
      at: 186,
      speaker: 'host',
      text: 'That’s great. How do you typically prioritize what to design next when there’s a lot on the roadmap?',
    },
    {
      at: 200,
      speaker: 'guest',
      text: 'I try to align closely with product and engineering leads to understand impact and feasibility, but I also keep a running list of the small paper cuts users mention. Those rarely make the roadmap on their own.',
    },
    { at: 238, speaker: 'host', text: 'How do those paper cuts get in, then?' },
    {
      at: 246,
      speaker: 'guest',
      text: 'We bundle them. Every quarter we pick a theme, say refunds, and fix five or six small things at once. It’s easier to argue for one clear story than for twenty tiny tickets.',
    },
    { at: 284, speaker: 'host', text: 'Where does research fit when timelines get tight?' },
    {
      at: 292,
      speaker: 'guest',
      text: 'Honestly, it’s the first thing to get squeezed. We’ve started doing lighter checks — five-minute calls with merchants, or an hour with the support tickets — instead of skipping it altogether.',
    },
    {
      at: 336,
      speaker: 'host',
      text: 'What helps the team stay in sync with engineering along the way?',
    },
    {
      at: 345,
      speaker: 'guest',
      text: 'A weekly design review that engineers actually attend. We look at work in progress, not polished screens, so problems surface while they’re still cheap to fix.',
    },
    {
      at: 388,
      speaker: 'host',
      text: 'If you could change one thing about how your team works, what would it be?',
    },
    {
      at: 397,
      speaker: 'guest',
      text: 'More time with users after launch. We celebrate shipping, then move on — but the most useful feedback shows up three weeks later, when people stop being polite.',
    },
    {
      at: 444,
      speaker: 'host',
      text: 'That’s a great note to end on. Anything you’d like to add?',
    },
    {
      at: 451,
      speaker: 'guest',
      text: 'Just that I’d love to see what comes out of these sessions. It’s rare that someone asks how we actually work.',
    },
    {
      at: 472,
      speaker: 'host',
      text: 'We’ll share the summary with you. Thanks again, Nora.',
    },
  ],
};

/** Where the recording opens: the question about discovery and delivery. */
export const START = 128;

export const SUMMARY = {
  text: 'Nora leads design for a payments team of five. She is involved from research to delivery and values the balance between strategy and craft, but research is the first thing to go when timelines tighten. Small usability issues reach the roadmap only when bundled into a quarterly theme, and she wants more contact with users after launch.',
  moments: [
    { at: 91, text: 'Moving in-house shifted her focus from how to why.' },
    { at: 154, text: 'Works across discovery and delivery; enjoys both.' },
    { at: 246, text: 'Bundles paper cuts into one quarterly theme.' },
    { at: 292, text: 'Replaces skipped research with five-minute checks.' },
    { at: 397, text: 'The best feedback arrives weeks after launch.' },
  ],
};

export type Comment = { id: string; who: Person; at: number; text: string; ago: string };

export const COMMENTS: Comment[] = [
  {
    id: 'c1',
    who: { name: 'Ines Park', initials: 'IP', role: 'Product manager' },
    at: 200,
    text: 'The paper-cut list is something we could share across teams. Worth a follow-up?',
    ago: '2 h ago',
  },
  {
    id: 'c2',
    who: { name: 'Theo Marsh', initials: 'TM', role: 'Engineer' },
    at: 345,
    text: 'Same experience on our side: reviewing work in progress saved us a sprint last quarter.',
    ago: 'Yesterday',
  },
  {
    id: 'c3',
    who: { name: 'Owen Reyes', initials: 'OR', role: 'Researcher' },
    at: 397,
    text: 'Clip this for the readout. It sums up the whole study in one sentence.',
    ago: 'Yesterday',
  },
];

export type Insight = { id: string; kind: string; title: string; at: number; quote: string };

export const INSIGHTS: Insight[] = [
  {
    id: 'i1',
    kind: 'Pain point',
    title: 'Research is the first thing cut under pressure',
    at: 292,
    quote: 'Honestly, it’s the first thing to get squeezed.',
  },
  {
    id: 'i2',
    kind: 'Behaviour',
    title: 'Small fixes ship as a quarterly theme',
    at: 246,
    quote: 'It’s easier to argue for one clear story than for twenty tiny tickets.',
  },
  {
    id: 'i3',
    kind: 'Opportunity',
    title: 'Structured check-ins weeks after launch',
    at: 397,
    quote: 'The most useful feedback shows up three weeks later.',
  },
  {
    id: 'i4',
    kind: 'Behaviour',
    title: 'Engineers review work in progress',
    at: 345,
    quote: 'Problems surface while they’re still cheap to fix.',
  },
];

/** A reply is text and timestamps: a timestamp is drawn as a chip that seeks. */
export type ReplyPart = string | { at: number };

export const SUGGESTIONS: { id: string; label: string; icon: 'bulb' | 'needs' | 'bolt' }[] = [
  { id: 'ideas', label: 'Find ideas', icon: 'bulb' },
  { id: 'needs', label: 'Spot user needs', icon: 'needs' },
  { id: 'opportunities', label: 'Seek opportunities', icon: 'bolt' },
];

export const REPLIES: Record<string, ReplyPart[]> = {
  ideas: [
    'Three ideas stand out in this session. First, bundle small usability fixes into one quarterly theme, as Nora’s team does with refunds ',
    { at: 246 },
    '. Second, keep a five-minute research check even when the timeline is tight ',
    { at: 292 },
    '. Third, plan a check-in with users three weeks after launch, when the honest feedback arrives ',
    { at: 397 },
    '.',
  ],
  needs: [
    'Nora needs a way to keep research alive under deadline pressure ',
    { at: 292 },
    ', a place where small user complaints are collected before they are lost ',
    { at: 200 },
    ', and more time with users once a feature has shipped ',
    { at: 397 },
    '.',
  ],
  opportunities: [
    'The clearest opportunity is after launch: no one on her team owns the weeks when real feedback appears ',
    { at: 397 },
    '. A shared paper-cut list across teams is a second one ',
    { at: 200 },
    ', and the weekly review with engineers could become a template for other squads ',
    { at: 345 },
    '.',
  ],
};

/** The answer to a question typed in: where the recording speaks to it most. */
export function replyTo(question: string): ReplyPart[] {
  const q = question.toLowerCase();
  if (/engineer|handoff|sync|review/.test(q))
    return [
      'Nora keeps in sync with engineering through a weekly review of work in progress ',
      { at: 345 },
      '. She also checks feasibility with engineering leads before prioritizing ',
      { at: 200 },
      '.',
    ];
  if (/research|user|interview|feedback/.test(q))
    return [
      'Research comes up twice. It is the first thing cut when time is short ',
      { at: 292 },
      ', and Nora says the most useful feedback arrives weeks after launch ',
      { at: 397 },
      '.',
    ];
  return [
    `About “${question.trim()}”: the closest moment is when Nora describes her role from discovery to delivery `,
    { at: 154 },
    '. She returns to it when she talks about prioritizing the roadmap ',
    { at: 200 },
    '.',
  ];
}

/** 128 → "02:08". */
export function clock(seconds: number) {
  const whole = Math.max(0, Math.floor(seconds));
  return `${String(Math.floor(whole / 60)).padStart(2, '0')}:${String(whole % 60).padStart(2, '0')}`;
}

/** The index of the segment being spoken at `time` (the last one that started). */
export function segmentAt(transcript: Segment[], time: number) {
  let index = 0;
  for (let i = 0; i < transcript.length; i += 1) if (transcript[i].at <= time) index = i;
  return index;
}
```

### session-review/icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The screen's icons: one 24-unit grid, drawn as strokes in currentColor, sized by the stylesheet
 * (the lab sizes an unsized SVG to 16 px). The player's glyphs (play, pause, the speaker) are
 * filled, as a player draws them. The app's mark and the assistant's are filled shapes of their
 * own.
 */

export type IconName =
  | 'panel'
  | 'search'
  | 'home'
  | 'studies'
  | 'calendar'
  | 'folder'
  | 'people'
  | 'settings'
  | 'chevron'
  | 'more'
  | 'video'
  | 'cast'
  | 'expand'
  | 'minimize'
  | 'close'
  | 'bulb'
  | 'needs'
  | 'bolt'
  | 'plus'
  | 'sliders'
  | 'mic'
  | 'send'
  | 'link'
  | 'check'
  | 'sparkle'
  | 'download'
  | 'pencil'
  | 'trash'
  | 'menu'
  | 'quote';

/** A gear: eight teeth around a ring, drawn once. */
const GEAR = (() => {
  const points: string[] = [];
  const teeth = 8;
  for (let i = 0; i < teeth * 4; i += 1) {
    const angle = (i / (teeth * 4)) * Math.PI * 2 - Math.PI / 2 + Math.PI / (teeth * 4);
    const radius = i % 4 < 2 ? 9.6 : 7.4;
    points.push(
      `${(12 + Math.cos(angle) * radius).toFixed(2)} ${(12 + Math.sin(angle) * radius).toFixed(2)}`,
    );
  }
  return `M${points.join('L')}Z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z`;
})();

const LINE: Record<IconName, string> = {
  panel:
    'M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5zM9.5 4v16',
  search: 'M10.8 18.1a7.3 7.3 0 1 0 0-14.6 7.3 7.3 0 0 0 0 14.6zM20.5 20.5l-4.6-4.6',
  home: 'M4 10.2 12 4l8 6.2v8.3a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5z',
  studies:
    'M18 10.5V6.2L14.8 3H7.5A1.5 1.5 0 0 0 6 4.5v15A1.5 1.5 0 0 0 7.5 21h4M14.5 3v3.5H18M9 8.5h3.5M9 12h5M16.5 19.2a2.7 2.7 0 1 0 0-5.4 2.7 2.7 0 0 0 0 5.4zM18.5 18.8l2 2',
  calendar:
    'M5.5 5.5h13A1.5 1.5 0 0 1 20 7v11.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5V7a1.5 1.5 0 0 1 1.5-1.5zM8 3.5v4M16 3.5v4M4 10.5h16',
  folder:
    'M3.5 7A1.5 1.5 0 0 1 5 5.5h4.2l2 2.2H19A1.5 1.5 0 0 1 20.5 9.2v9.3A1.5 1.5 0 0 1 19 20H5a1.5 1.5 0 0 1-1.5-1.5zM11.8 16.5a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2zM13.7 16.1l1.8 1.8',
  people:
    'M9.5 11.5a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2zM3.5 19.8c.6-3.4 3-5.3 6-5.3s5.4 1.9 6 5.3M15.5 4.6a3.6 3.6 0 0 1 0 6.8M17.6 14.8c1.6.7 2.6 2.4 2.9 5',
  settings: GEAR,
  chevron: 'M9.5 6.5 15 12l-5.5 5.5',
  more: 'M12 5.5h.01M12 12h.01M12 18.5h.01',
  video:
    'M4.5 6.5h9A1.5 1.5 0 0 1 15 8v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 3 16V8a1.5 1.5 0 0 1 1.5-1.5zM15 10.6l5.2-3v8.8L15 13.4',
  cast: 'M7.5 17.5H5.5A1.5 1.5 0 0 1 4 16V6a1.5 1.5 0 0 1 1.5-1.5h13A1.5 1.5 0 0 1 20 6v10a1.5 1.5 0 0 1-1.5 1.5h-2M12 14.5l4.2 5.5H7.8z',
  expand: 'M14.5 4.5h5v5M19.5 4.5 13.5 10.5M9.5 19.5h-5v-5M4.5 19.5l6-6',
  minimize: 'M19.5 4.5 14 10M14 5.5V10h4.5M4.5 19.5 10 14M10 18.5V14H5.5',
  close: 'M6 6l12 12M18 6 6 18',
  bulb: 'M9.5 17h5M10 20h4M12 3.8a5.4 5.4 0 0 0-3.3 9.7c.6.5.8 1.1.8 1.8V17h5v-1.7c0-.7.3-1.3.8-1.8A5.4 5.4 0 0 0 12 3.8zM12 1v.01M3.5 4.5l.01.01M20.5 4.5l-.01.01M1.5 11h.01M22.5 11h-.01',
  needs: 'M12 3.2 19.6 7.6v8.8L12 20.8l-7.6-4.4V7.6zM12 8.6v6.8M8.6 12h6.8',
  bolt: 'M13 2.8 4.8 13.5H12l-1 7.7 8.2-10.7H12z',
  plus: 'M12 5v14M5 12h14',
  sliders:
    'M4 8.5h9.5M4 15.5h3.5M17.5 8.5h2.5M13.5 15.5H20M15.5 10.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM9.5 17.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4z',
  mic: 'M12 3.5a2.8 2.8 0 0 0-2.8 2.8v5.4a2.8 2.8 0 0 0 5.6 0V6.3A2.8 2.8 0 0 0 12 3.5zM6.5 11.5a5.5 5.5 0 0 0 11 0M12 17v3.5',
  send: 'M20.5 3.5 10.2 13.8M20.5 3.5 14 20.5l-3.8-6.7-6.7-3.8z',
  link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
  check: 'M5 12.5l4.5 4.5L19 7.5',
  sparkle:
    'M12 3.5c.5 4.2 2.3 6 6.5 6.5-4.2.5-6 2.3-6.5 6.5-.5-4.2-2.3-6-6.5-6.5 4.2-.5 6-2.3 6.5-6.5zM18.5 15.5c.2 1.6.9 2.3 2.5 2.5-1.6.2-2.3.9-2.5 2.5-.2-1.6-.9-2.3-2.5-2.5 1.6-.2 2.3-.9 2.5-2.5z',
  download: 'M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14',
  pencil:
    'M15.5 5.5l3 3M4.5 19.5l1-4.2L16.2 4.6a1.5 1.5 0 0 1 2.1 0l1.1 1.1a1.5 1.5 0 0 1 0 2.1L8.7 18.5z',
  trash:
    'M4.5 7h15M9.5 7V4.5h5V7M6.5 7l.9 12a1.5 1.5 0 0 0 1.5 1.5h6.2a1.5 1.5 0 0 0 1.5-1.5l.9-12',
  menu: 'M4 7h16M4 12h16M4 17h16',
  quote:
    'M9.5 7.5H6.2A1.7 1.7 0 0 0 4.5 9.2v3.1A1.7 1.7 0 0 0 6.2 14H9v.5c0 1.6-1 2.8-2.5 3.2M19.5 7.5h-3.3a1.7 1.7 0 0 0-1.7 1.7v3.1a1.7 1.7 0 0 0 1.7 1.7H19v.5c0 1.6-1 2.8-2.5 3.2',
};

export function Icon({ name, className }: { name: IconName; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={name === 'more' ? 3.2 : 1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      <path d={LINE[name]} />
    </svg>
  );
}

/** The player's filled glyphs. */
export function PlayerGlyph({
  name,
  className,
}: {
  name: 'play' | 'pause' | 'volume' | 'muted';
  className?: string;
}) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      {name === 'play' && (
        <path
          fill="currentColor"
          d="M7 4.6v14.8a1 1 0 0 0 1.5.9l12-7.4a1 1 0 0 0 0-1.8l-12-7.4A1 1 0 0 0 7 4.6z"
        />
      )}
      {name === 'pause' && (
        <path
          fill="currentColor"
          d="M6.5 4h2.8c.6 0 1 .4 1 1v14c0 .6-.4 1-1 1H6.5c-.6 0-1-.4-1-1V5c0-.6.4-1 1-1zm8.2 0h2.8c.6 0 1 .4 1 1v14c0 .6-.4 1-1 1h-2.8c-.6 0-1-.4-1-1V5c0-.6.4-1 1-1z"
        />
      )}
      {(name === 'volume' || name === 'muted') && (
        <path
          fill="currentColor"
          d="M3 9.2v5.6c0 .6.4 1 1 1h3.2l4.6 4c.6.5 1.7.1 1.7-.8V5c0-.9-1-1.3-1.7-.8l-4.6 4H4c-.6 0-1 .4-1 1z"
        />
      )}
      {name === 'volume' && (
        <path
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          d="M16.5 9a4.2 4.2 0 0 1 0 6M19.2 6.4a8 8 0 0 1 0 11.2"
        />
      )}
      {name === 'muted' && (
        <path
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          d="M16.5 9.5l5 5M21.5 9.5l-5 5"
        />
      )}
    </svg>
  );
}

/**
 * The app's mark: a sprout of three leaves over two ripples, as if floating on water. Filled in
 * currentColor.
 */
export function AppMark({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 32 28" aria-hidden="true" focusable="false">
      <path
        fill="currentColor"
        d="M16 1.5c2.6 2.4 3.6 5 3 7.7-.4 1.8-1.5 3.3-3 4.6-1.5-1.3-2.6-2.8-3-4.6-.6-2.7.4-5.3 3-7.7zM4.2 6.6c3.6-.4 6.6.6 8.4 2.9.9 1.2 1.4 2.6 1.6 4.1-2.8.4-5.6-.2-7.4-1.8-1.5-1.3-2.4-3-2.6-5.2zm23.6 0c-.2 2.2-1.1 3.9-2.6 5.2-1.8 1.6-4.6 2.2-7.4 1.8.2-1.5.7-2.9 1.6-4.1 1.8-2.3 4.8-3.3 8.4-2.9z"
      />
      <path
        fill="currentColor"
        d="M1.6 17.4c2.3-1.4 4.6-1.4 6.9 0 2.5 1.5 5 1.5 7.5 0s5-1.5 7.5 0c2.3 1.4 4.6 1.4 6.9 0v3.4c-2.3 1.4-4.6 1.4-6.9 0-2.5-1.5-5-1.5-7.5 0s-5 1.5-7.5 0c-2.3-1.4-4.6-1.4-6.9 0z"
      />
      <path
        fill="currentColor"
        d="M6.4 24.2c1.9-1.1 3.8-1.1 5.6 0 2.7 1.6 5.3 1.6 8 0 1.8-1.1 3.7-1.1 5.6 0v2.9c-1.9-1.1-3.8-1.1-5.6 0-2.7 1.6-5.3 1.6-8 0-1.8-1.1-3.7-1.1-5.6 0z"
      />
    </svg>
  );
}
```

### session-review/demo-pointer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The arrow and the
 * hand are HTML boxes that swap by their visibility: a change of style inside an SVG would lay
 * the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (session-review.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over the screen. It
 * is decorative: what it does shows on the screen itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### session-review/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```

### session-review/session-review.module.css

```css
/* Session review (session-review.tsx): a white research app with a pale icon sidebar, the
   recording in a rounded frame with white controls, a segmented tab bar, a transcript lit in pale
   blue where it is being spoken, and a pale grey assistant panel with a black send button. At
   night near-black, the recording unchanged.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the screen takes the
   color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme).

   Layout is by container queries on the root: the full screen from 1100 px (945 px tall; the
   tab's content scrolls inside), the assistant under the recording below that, the sidebar a bar
   across the top under 640 px. The player answers to its own width for its controls.

   Motion. The figures breathe, nod and talk by transforms on their SVG groups, paused with the
   recording; scrub previews, menus and the toast fade in. Reduced motion stills it all. */

.root {
  --bg: #fff;
  --side: #fafafa;
  --panel: #fafafa;
  --field: #fff;
  --line: #efefef;
  --line-2: #e2e2e2;
  --line-3: #ececec;
  --ink: #0f1112;
  --ink-2: #282a2b;
  --ink-3: #3f4142;
  --muted: #4f5253;
  --muted-2: #85878a;
  --faint: #95979a;
  --side-icon: #8f9295;
  --side-icon-on: #3d4145;
  --nav-on: #f1f1f1;
  --tab-bg: #fafafa;
  --tab-on: #fff;
  --chip: #f1f3f4;
  --chip-ink: #5a5c5e;
  --blue: #1b6dc1;
  --link: #1667c4;
  --phrase: #ebf6fa;
  --phrase-ink: #1f5fa6;
  --kind: #e2336b;
  --kind-bg: #ffeef2;
  --seconds: #96989a;
  --send: #161b23;
  --send-ink: #fff;
  --menu: #fff;
  --toast: #1b1d21;
  --toast-ink: #fff;
  --scroll: rgb(0 0 0 / 0.38);
  --shadow: rgb(16 18 24 / 0.06);
  --shadow-2: rgb(16 18 24 / 0.12);
  --scrim: rgb(20 22 26 / 0.12);
  --online: #20b26b;
  --focus: #1b6dc1;
  --kind-pain: #c4314b;
  --kind-pain-bg: #fdecef;
  --kind-op: #157a4f;
  --kind-op-bg: #e5f6ed;
  --kind-be: #5a46c4;
  --kind-be-bg: #eeebfc;
  /* The demo's pointer looks like the system's, on both schemes. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);

  position: relative;
  container: session / inline-size;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 12px;
  background: var(--bg);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family:
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  font-size: 13px;
  line-height: 20px;
  font-feature-settings: 'cv11' 1;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  text-align: left;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --bg: light-dark(#fff, #111214);
    --side: light-dark(#fafafa, #161719);
    --panel: light-dark(#fafafa, #161719);
    --field: light-dark(#fff, #1d1e21);
    --line: light-dark(#efefef, #25262a);
    --line-2: light-dark(#e2e2e2, #303136);
    --line-3: light-dark(#ececec, #2a2b2f);
    --ink: light-dark(#0f1112, #f2f3f4);
    --ink-2: light-dark(#282a2b, #d8dadc);
    --ink-3: light-dark(#3f4142, #b9bcbf);
    --muted: light-dark(#4f5253, #a3a6aa);
    --muted-2: light-dark(#85878a, #84878b);
    --faint: light-dark(#95979a, #6f7277);
    --side-icon: light-dark(#8f9295, #7d8085);
    --side-icon-on: light-dark(#3d4145, #e7e8ea);
    --nav-on: light-dark(#f1f1f1, #232428);
    --tab-bg: light-dark(#fafafa, #18191b);
    --tab-on: light-dark(#fff, #2b2c30);
    --chip: light-dark(#f1f3f4, #26272b);
    --chip-ink: light-dark(#5a5c5e, #b5b8bc);
    --blue: light-dark(#1b6dc1, #73adf2);
    --link: light-dark(#1667c4, #73adf2);
    --phrase: light-dark(#ebf6fa, #17304b);
    --phrase-ink: light-dark(#1f5fa6, #a8cdf8);
    --kind: light-dark(#e2336b, #ff7aa1);
    --kind-bg: light-dark(#ffeef2, #3a1a25);
    --seconds: light-dark(#96989a, #6f7276);
    --send: light-dark(#161b23, #f2f3f4);
    --send-ink: light-dark(#fff, #111214);
    --menu: light-dark(#fff, #1f2023);
    --toast: light-dark(#1b1d21, #2a2b30);
    --scroll: light-dark(rgb(0 0 0 / 0.38), rgb(255 255 255 / 0.3));
    --shadow: light-dark(rgb(16 18 24 / 0.06), rgb(0 0 0 / 0.4));
    --shadow-2: light-dark(rgb(16 18 24 / 0.12), rgb(0 0 0 / 0.55));
    --scrim: light-dark(rgb(20 22 26 / 0.12), rgb(0 0 0 / 0.45));
    --online: light-dark(#20b26b, #3ccf86);
    --focus: light-dark(#1b6dc1, #73adf2);
    --kind-pain: light-dark(#c4314b, #ff8a9c);
    --kind-pain-bg: light-dark(#fdecef, #3a1a21);
    --kind-op: light-dark(#157a4f, #6fd8a6);
    --kind-op-bg: light-dark(#e5f6ed, #15301f);
    --kind-be: light-dark(#5a46c4, #b3a6ff);
    --kind-be-bg: light-dark(#eeebfc, #241f42);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.root :where(button, input, textarea) {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
}
.root :where(button) {
  margin: 0;
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
}
.root :where(button:disabled) {
  cursor: default;
}
.root :where(h1, h2, h3, p, ul, ol, dl, dd, figure) {
  margin: 0;
}
.root :where(ul, ol) {
  padding: 0;
  list-style: none;
}
.root :where(svg) {
  display: block;
  flex: none;
}
.root :where(button, a, input, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.root :where(a) {
  color: inherit;
  text-decoration: none;
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ——— The shell: a sidebar and the screen ——— */

.shell {
  display: grid;
  grid-template-columns: 62px minmax(0, 1fr);
  min-height: 600px;
}
@container session (min-width: 1100px) {
  .shell {
    height: 945px;
  }
}

/* ——— Sidebar ——— */

.sidebar {
  position: relative;
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 62px;
  padding: 22px 0 19px;
  border-right: 1px solid var(--line);
  border-radius: 12px 0 0 12px;
  background: var(--side);
  transition:
    width 0.22s cubic-bezier(0.3, 0, 0.2, 1),
    margin 0.22s cubic-bezier(0.3, 0, 0.2, 1),
    box-shadow 0.22s ease;
}
.root[data-wide-nav] .sidebar {
  width: 200px;
  margin-right: -138px;
  align-items: stretch;
  padding-inline: 12px;
  box-shadow: 8px 0 24px var(--shadow-2);
}
.brand {
  display: grid;
  place-items: center;
  height: 23px;
  color: var(--ink);
  overflow: visible;
}
.brandMark {
  width: 30px;
  height: 26px;
}
.nav {
  margin-top: 16px;
}
.nav ul,
.sideFoot ul {
  display: flex;
  flex-direction: column;
  align-items: inherit;
  gap: 2.5px;
}
.navToggle {
  margin-bottom: -3.5px;
}
.nav {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.root[data-wide-nav] .nav,
.root[data-wide-nav] .nav ul,
.root[data-wide-nav] .sideFoot,
.root[data-wide-nav] .sideFoot ul {
  align-items: stretch;
}
.navItem {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 36px;
  height: 36px;
  border-radius: 8px;
  color: var(--side-icon);
  white-space: nowrap;
  transition:
    background-color 0.15s ease,
    color 0.15s ease;
}
.navItem:hover {
  background: var(--nav-on);
  color: var(--side-icon-on);
}
.navItem[aria-current='page'] {
  background: var(--nav-on);
  color: var(--side-icon-on);
}
.navIcon {
  width: 21px;
  height: 21px;
}
.navLabel {
  display: none;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-2);
}
.root[data-wide-nav] .navItem {
  justify-content: flex-start;
  width: 100%;
  padding: 0 10px;
}
.root[data-wide-nav] .navLabel {
  display: inline;
}
.sideFoot {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  margin-top: auto;
}
.me {
  position: relative;
  width: 35px;
  height: 35px;
  border-radius: 50%;
}
.root[data-wide-nav] .me {
  margin-left: 0;
}
.meFace {
  width: 35px;
  height: 35px;
  border-radius: 50%;
  box-shadow: 0 0 0 1px var(--line);
}
.online {
  position: absolute;
  right: -1px;
  bottom: -1px;
  width: 12px;
  height: 12px;
  border: 2px solid var(--side);
  border-radius: 50%;
  background: var(--online);
}
.scrim {
  position: absolute;
  inset: 0;
  z-index: 5;
  background: var(--scrim);
  cursor: default;
  animation: sr-fade 0.2s ease;
}

/* ——— Main ——— */

.main {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  padding: 22px 23px 23px;
}

.crumbs ol {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}
.crumbs li {
  display: flex;
  align-items: center;
  min-width: 0;
}
.crumbs a,
.crumbCurrent {
  display: block;
  height: 28px;
  padding: 0 8px;
  overflow: hidden;
  border-radius: 8px;
  color: var(--muted);
  font-weight: 500;
  line-height: 28px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.crumbs a:hover {
  color: var(--ink);
}
.crumbCurrent {
  background: var(--side);
  color: var(--ink);
}
.crumbChevron {
  width: 14px;
  height: 14px;
  margin: 0 4px;
  color: var(--faint);
}

.header {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  margin-top: 14px;
}
.kind {
  display: grid;
  flex: none;
  place-items: center;
  width: 50px;
  height: 50px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--field);
  box-shadow:
    0 1px 2px var(--shadow),
    0 4px 10px var(--shadow);
}
.kind > span {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 9px;
  background: var(--kind-bg);
  color: var(--kind);
}
.kind svg {
  width: 24px;
  height: 24px;
}
.heading {
  flex: 1;
  min-width: 0;
  padding-top: 3px;
}
.title {
  font-size: 18.5px;
  font-weight: 600;
  line-height: 24px;
  letter-spacing: -0.005em;
}
.description {
  display: flex;
  align-items: baseline;
  gap: 9px;
  margin-top: 3px;
  color: var(--ink-2);
}
.descriptionText {
  min-width: 0;
  max-width: 588px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.description[data-open] {
  display: block;
  max-width: 720px;
}
.description[data-open] .descriptionText {
  max-width: none;
  white-space: normal;
}
.seeMore {
  flex: none;
  color: var(--link);
  font-weight: 500;
}
.seeMore:hover {
  text-decoration: underline;
}

.actions {
  display: flex;
  flex: none;
  align-items: center;
  gap: 12px;
}
.popWrap {
  position: relative;
}
.kebab {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  color: var(--faint);
}
.kebab:hover,
.kebab[aria-expanded='true'] {
  background: var(--nav-on);
  color: var(--ink);
}
.kebab svg {
  width: 18px;
  height: 18px;
}
.share,
.askButton {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 38px;
  min-width: 65px;
  justify-content: center;
  padding: 0 13.5px;
  border: 1px solid var(--line-2);
  border-radius: 10px;
  background: var(--field);
  box-shadow: 0 1px 2px var(--shadow);
  font-size: 13px;
  font-weight: 600;
}
.share:hover,
.askButton:hover,
.share[aria-expanded='true'] {
  border-color: var(--faint);
}
.askButton svg {
  width: 16px;
  height: 16px;
  color: var(--blue);
}

.meta {
  display: flex;
  flex-wrap: wrap;
  gap: 2px 16px;
  margin-top: 16px;
}
.meta > div {
  display: flex;
  gap: 6px;
}
.meta dt {
  font-weight: 500;
}
.meta dd {
  color: var(--ink-3);
}

/* ——— Body: the recording and its tabs, the assistant ——— */

.body {
  display: grid;
  flex: 1;
  grid-template-columns: minmax(0, 1fr) 424px;
  gap: 22px;
  min-height: 0;
  margin-top: 27px;
}
.root[data-panel='folded'] .body {
  grid-template-columns: minmax(0, 1fr) 52px;
}
.root[data-panel='closed'] .body {
  grid-template-columns: minmax(0, 1fr);
}
.column {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
}

/* ——— Player ——— */

.player {
  --scrub-left: 193px;
  --scrub-right: 185px;
  position: relative;
  container: player / inline-size;
  flex: none;
  width: 100%;
  aspect-ratio: 16 / 9;
  max-height: 437px;
  overflow: hidden;
  border-radius: 12px;
  background: #d9d2c8;
  color: #fff;
  user-select: none;
  -webkit-user-select: none;
}
.player[data-full] {
  max-height: none;
  border-radius: 0;
}
.picture {
  position: absolute;
  inset: 0;
  cursor: pointer;
}
.scene {
  display: block;
  width: 100%;
  height: 100%;
}
.mainScene {
  position: absolute;
  inset: 0;
}
.pip {
  position: absolute;
  bottom: 60px;
  left: 22px;
  width: 172px;
  height: 113px;
  overflow: hidden;
  border-radius: 6px;
  box-shadow:
    0 0 0 1px rgb(255 255 255 / 0.25),
    0 4px 14px rgb(0 0 0 / 0.18);
}
.bigPlay {
  position: absolute;
  top: 50%;
  left: 50%;
  display: grid;
  place-items: center;
  width: 64px;
  height: 64px;
  margin: -32px 0 0 -32px;
  border-radius: 50%;
  background: rgb(0 0 0 / 0.32);
  backdrop-filter: blur(8px);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease;
}
.bigPlay svg {
  width: 26px;
  height: 26px;
  margin-left: 3px;
}
.player[data-playing] .bigPlay {
  opacity: 0;
  transform: scale(0.85);
}
.castNote {
  position: absolute;
  top: 14px;
  left: 50%;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgb(0 0 0 / 0.45);
  font-size: 12px;
  font-weight: 500;
  transform: translateX(-50%);
  animation: sr-fade 0.2s ease;
}

.controls {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  align-items: center;
  height: 74px;
  padding: 0 31px 0 30px;
  background: linear-gradient(rgb(0 0 0 / 0), rgb(0 0 0 / 0.16));
  font-size: 12px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
}
.ctrl {
  display: grid;
  flex: none;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 6px;
  color: #fff;
  filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.25));
  transition: background-color 0.15s ease;
}
.ctrl:hover {
  background: rgb(255 255 255 / 0.16);
}
.ctrl:focus-visible,
.scrub:focus-visible,
.volume:focus-visible {
  outline-color: #fff;
}
.ctrlGlyph {
  width: 16px;
  height: 16px;
}
.volumeButton {
  margin-left: 5px;
}
.volumeButton .ctrlGlyph {
  width: 18px;
  height: 18px;
}
.ctrlIcon {
  width: 20px;
  height: 20px;
}
.volume {
  flex: none;
  width: 38px;
  height: 16px;
  margin: 0 0 0 1px;
  background: transparent;
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
}
.volume::-webkit-slider-runnable-track {
  height: 3px;
  border-radius: 2px;
  background: linear-gradient(90deg, #fff var(--v), rgb(255 255 255 / 0.4) var(--v));
}
.volume::-moz-range-track {
  height: 3px;
  border-radius: 2px;
  background: linear-gradient(90deg, #fff var(--v), rgb(255 255 255 / 0.4) var(--v));
}
.volume::-webkit-slider-thumb {
  width: 10px;
  height: 10px;
  margin-top: -3.5px;
  border: 0;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);
  -webkit-appearance: none;
}
.volume::-moz-range-thumb {
  width: 10px;
  height: 10px;
  border: 0;
  border-radius: 50%;
  background: #fff;
}
.timeNow {
  flex: none;
  margin-left: 22px;
}
.scrub {
  position: relative;
  flex: 1;
  min-width: 40px;
  height: 22px;
  margin-left: 7px;
  border-radius: 4px;
  cursor: grab;
  touch-action: none;
}
.scrub:active {
  cursor: grabbing;
}
.scrubTrack {
  position: absolute;
  top: 9px;
  right: 0;
  left: 0;
  height: 4px;
  overflow: hidden;
  border-radius: 2px;
  background: rgb(255 255 255 / 0.32);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
}
.scrubFill,
.scrubAhead {
  position: absolute;
  inset: 0;
  transform-origin: 0 50%;
}
.scrubFill {
  background: #fff;
}
.scrubAhead {
  background: rgb(255 255 255 / 0.32);
}
.scrubLine {
  position: absolute;
  top: -22px;
  bottom: -2px;
  width: 1px;
  margin-left: -0.5px;
  background: rgb(255 255 255 / 0.45);
  pointer-events: none;
}
.timeLeft {
  flex: none;
  margin-left: 9px;
}
.speed {
  margin-left: 16px;
  font-size: 12px;
}
.castButton {
  margin-left: 4px;
}
.castButton[aria-pressed='true'] {
  background: rgb(255 255 255 / 0.22);
}
.castButton + .ctrl {
  margin-left: 6px;
}

.preview {
  position: absolute;
  bottom: 60px;
  left: clamp(
    80px,
    calc(var(--scrub-left) + (100% - var(--scrub-left) - var(--scrub-right)) * var(--at)),
    calc(100% - 80px)
  );
  display: flex;
  flex-direction: column;
  align-items: center;
  pointer-events: none;
  transform: translateX(-50%);
  animation: sr-fade 0.15s ease;
}
.previewFrame {
  position: relative;
  display: block;
  width: 148px;
  height: 84px;
  overflow: hidden;
  border: 2px solid rgb(255 255 255 / 0.92);
  border-radius: 7px;
  box-shadow: 0 6px 16px rgb(0 0 0 / 0.22);
}
.previewPip {
  position: absolute;
  bottom: 3px;
  left: 3px;
  width: 26px;
  height: 18px;
  overflow: hidden;
  border-radius: 3px;
}
.previewTime {
  display: flex;
  align-items: center;
  height: 14px;
  margin-top: 10px;
  font-size: 11px;
  font-weight: 600;
  line-height: 14px;
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.35);
}
.previewTime > span:last-child {
  opacity: 0.75;
}
.previewSep {
  width: 1px;
  height: 11px;
  margin: 0 5px;
  background: rgb(255 255 255 / 0.55);
}

/* The figures: breathing, nodding, talking; held while paused. */
.breath,
.head {
  transform-box: fill-box;
  transform-origin: 50% 100%;
}
.breath {
  animation: sr-breathe 4.8s ease-in-out infinite alternate;
}
.head {
  transform-origin: 40% 85%;
  animation: sr-nod 3.4s ease-in-out infinite alternate;
}
.scene[data-speaking] .mouth {
  animation: sr-talk 0.36s ease-in-out infinite alternate;
}
.player:not([data-playing]) .scene *,
.preview .scene * {
  animation-play-state: paused;
}
.preview .scene * {
  animation: none;
}

/* ——— Tabs ——— */

.tabsRow {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 16px;
}
.tabs {
  display: inline-flex;
  gap: 4px;
  max-width: 100%;
  padding: 3px;
  overflow-x: auto;
  border: 1px solid var(--line-3);
  border-radius: 10px;
  background: var(--tab-bg);
  scrollbar-width: none;
}
.tab {
  flex: none;
  height: 32px;
  padding: 0 11.25px;
  border-radius: 7px;
  color: var(--muted);
  font-size: 13px;
  font-weight: 500;
  transition:
    background-color 0.15s ease,
    color 0.15s ease,
    box-shadow 0.15s ease;
}
.tab:hover {
  color: var(--ink);
}
.tab[aria-selected='true'] {
  background: var(--tab-on);
  box-shadow:
    0 0 0 1px var(--line-3),
    0 1px 2px var(--shadow);
  color: var(--ink);
  font-weight: 600;
  letter-spacing: -0.01em;
}
.follow {
  flex: none;
  height: 30px;
  padding: 0 12px;
  border-radius: 999px;
  background: var(--phrase);
  color: var(--phrase-ink);
  font-size: 12px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  animation: sr-fade 0.2s ease;
}

.tabPanel {
  position: relative;
  flex: 1;
  min-height: 0;
  margin-top: 15px;
}
.transcript,
.scrollBox {
  max-height: 420px;
  margin-right: -11px;
  padding-right: 11px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-color: var(--scroll) transparent;
  scrollbar-width: thin;
}
@container session (min-width: 1100px) {
  .transcript,
  .scrollBox {
    height: 100%;
    max-height: none;
  }
}
.transcript:focus-visible,
.scrollBox:focus-visible {
  outline-offset: -2px;
}
.line {
  display: grid;
  grid-template-columns: 56px minmax(0, 1fr);
  padding-bottom: 7px;
}
.stamp {
  justify-self: start;
  height: 22.5px;
  padding-top: 2px;
  border-radius: 4px;
  color: var(--muted-2);
  font-size: 11.5px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  line-height: 22.5px;
  transition: color 0.15s ease;
}
.stamp:hover,
.line[data-current] .stamp {
  color: var(--blue);
}
.lineText {
  font-size: 15px;
  line-height: 22.5px;
  letter-spacing: -0.01em;
}
.phrase {
  margin: -2px -3px;
  padding: 2px 3px;
  border-radius: 4px;
  background: var(--phrase);
  color: var(--phrase-ink);
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  transition: background-color 0.2s ease;
}
.who {
  grid-column: 2;
  width: 22px;
  height: 22px;
  margin-top: 7px;
}
.initials {
  display: grid;
  flex: none;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--chip);
  color: var(--chip-ink);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.02em;
}
.whoFace {
  width: 22px;
  height: 22px;
  border-radius: 50%;
}

/* Summary, comments, insights */
.summary {
  max-width: 720px;
  font-size: 15px;
  line-height: 22.5px;
  letter-spacing: -0.01em;
}
.subhead {
  margin-top: 20px;
  color: var(--muted);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.moments {
  margin-top: 6px;
}
.moment {
  display: grid;
  grid-template-columns: 56px minmax(0, 1fr);
  padding: 6px 0;
  border-bottom: 1px solid var(--line);
  font-size: 14px;
  line-height: 22.5px;
}
.comments {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.comment {
  display: grid;
  grid-template-columns: 22px minmax(0, 1fr);
  gap: 12px;
}
.commentHead {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 8px;
  line-height: 22px;
}
.commentHead span {
  color: var(--muted-2);
  font-size: 12px;
}
.commentText {
  margin-top: 2px;
  color: var(--ink-2);
  font-size: 14px;
  line-height: 21px;
}
.stampChip {
  height: 22px;
  padding: 0 7px;
  background: var(--phrase);
  color: var(--phrase-ink);
  line-height: 22px;
}
.noteForm {
  display: flex;
  gap: 8px;
  margin-top: 18px;
  padding-bottom: 4px;
}
.noteField {
  flex: 1;
  min-width: 0;
  height: 36px;
  padding: 0 12px;
  border: 1px solid var(--line-2);
  border-radius: 9px;
  background: var(--field);
  font-size: 13px;
}
.noteField::placeholder {
  color: var(--muted-2);
}
.noteSend {
  height: 36px;
  padding: 0 14px;
  border-radius: 9px;
  background: var(--send);
  color: var(--send-ink);
  font-weight: 600;
}
.noteSend:disabled {
  opacity: 0.4;
}
.insights {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: 12px;
}
.insight {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel);
}
.insightKind {
  padding: 0 8px;
  border-radius: 999px;
  background: var(--kind-be-bg);
  color: var(--kind-be);
  font-size: 11.5px;
  font-weight: 600;
  line-height: 20px;
}
.insight[data-kind='Pain point'] .insightKind {
  background: var(--kind-pain-bg);
  color: var(--kind-pain);
}
.insight[data-kind='Opportunity'] .insightKind {
  background: var(--kind-op-bg);
  color: var(--kind-op);
}
.insightTitle {
  font-size: 14px;
  font-weight: 600;
  line-height: 20px;
}
.insightQuote {
  display: flex;
  gap: 6px;
  color: var(--ink-3);
  font-size: 13px;
  line-height: 19px;
}
.quoteIcon {
  width: 14px;
  height: 14px;
  margin-top: 2px;
  color: var(--faint);
}

/* ——— Assistant ——— */

.assistant {
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: 0 14px 14px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 16px;
  background: var(--panel);
  animation: sr-fade 0.2s ease;
}
.assistantTools {
  position: absolute;
  top: 14.5px;
  right: 14px;
  z-index: 1;
  display: flex;
  gap: 8px;
}
.toolButton {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border: 1px solid var(--line-2);
  border-radius: 8px;
  background: var(--field);
  color: var(--muted);
  box-shadow: 0 1px 2px var(--shadow);
}
.toolButton:hover {
  color: var(--ink);
}
.toolButton svg {
  width: 17px;
  height: 17px;
}
.hero {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 69px;
  text-align: center;
}
.heroMark {
  display: grid;
  place-items: center;
  width: 58px;
  height: 58px;
  border: 1px solid var(--line-2);
  border-radius: 10px;
  background: var(--field);
  color: var(--ink);
  box-shadow: 0 1px 3px var(--shadow);
}
.heroMark svg {
  width: 44px;
  height: 38px;
}
.wordmark {
  margin-top: 12.5px;
  color: var(--ink);
  font-family: Lustria, Georgia, serif;
  font-size: 21px;
  line-height: 26px;
  letter-spacing: -0.03em;
}
.heroTitle {
  margin-top: 8.5px;
  font-family: Jakarta, Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 22px;
  font-weight: 500;
  line-height: 28px;
  letter-spacing: -0.003em;
}
.heroTitle span {
  color: var(--seconds);
}
.heroText {
  margin-top: 7px;
  color: var(--muted);
  font-size: 13px;
  line-height: 18.5px;
}
.log {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 14px;
  min-height: 120px;
  margin: 0 -14px;
  padding: 64px 14px 14px;
  overflow-y: auto;
  scrollbar-color: var(--scroll) transparent;
  scrollbar-width: thin;
}
.ask {
  align-self: flex-end;
  max-width: 85%;
  padding: 8px 12px;
  border-radius: 12px 12px 4px 12px;
  background: var(--chip);
  color: var(--ink);
  font-size: 13.5px;
  animation: sr-rise 0.2s ease;
}
.answer {
  display: grid;
  grid-template-columns: 24px minmax(0, 1fr);
  gap: 10px;
  color: var(--ink-2);
  font-size: 13.5px;
  line-height: 21px;
}
.answerMark {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border: 1px solid var(--line-2);
  border-radius: 7px;
  background: var(--field);
  color: var(--ink);
}
.answerMark svg {
  width: 15px;
  height: 13px;
}
.timeChip {
  display: inline-block;
  height: 19px;
  padding: 0 6px;
  border-radius: 5px;
  background: var(--phrase);
  color: var(--phrase-ink);
  font-size: 11.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: 19px;
  vertical-align: 1px;
  animation: sr-fade 0.2s ease;
}
.timeChip:hover {
  background: var(--blue);
  color: #fff;
}
.caret {
  display: inline-block;
  width: 7px;
  height: 13px;
  margin-left: 2px;
  border-radius: 1px;
  background: var(--ink);
  vertical-align: -1px;
  animation: sr-blink 0.8s steps(2) infinite;
}
.suggestions {
  display: flex;
  flex-direction: column;
  margin-top: auto;
  margin-bottom: 13px;
  padding-top: 16px;
}
.suggestion {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  height: 34px;
  padding: 0;
  border-radius: 8px;
  color: var(--ink-2);
  font-size: 13.25px;
  transition: color 0.15s ease;
}
.suggestion:hover {
  color: var(--blue);
}
.suggestionIcon {
  width: 22px;
  height: 22px;
  color: var(--faint);
}
.suggestion:hover .suggestionIcon {
  color: var(--blue);
}
.composer {
  display: flex;
  flex: none;
  flex-direction: column;
  height: 104px;
  border: 1px solid var(--line-2);
  border-radius: 10px;
  background: var(--field);
  transition:
    border-color 0.15s ease,
    box-shadow 0.15s ease;
}
.composer:focus-within {
  border-color: var(--faint);
}
.composerField {
  flex: 1;
  min-height: 0;
  padding: 11.5px 10px 0;
  border: 0;
  background: transparent;
  font-size: 14px;
  line-height: 20px;
  resize: none;
}
.composerField::placeholder {
  color: var(--muted);
}
.composerField:focus-visible {
  outline: none;
}
.composerBar {
  display: flex;
  align-items: center;
  height: 32px;
  margin: 0 12px 7px 10px;
}
.composerButton {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 7px;
  color: var(--muted);
}
.composerButton:hover {
  background: var(--nav-on);
  color: var(--ink);
}
.composerButton svg {
  width: 18px;
  height: 18px;
}
.composerMenuWrap {
  position: relative;
  margin-left: 5px;
}
.mic {
  margin-left: auto;
}
.mic svg {
  width: 21px;
  height: 21px;
}
.mic[aria-pressed='true'] {
  background: var(--kind-bg);
  color: var(--kind);
  animation: sr-pulse 1s ease-in-out infinite;
}
.send {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  margin-left: 11px;
  border-radius: 50%;
  background: var(--send);
  color: var(--send-ink);
  transition: transform 0.15s ease;
}
.send:not(:disabled):hover {
  transform: scale(1.06);
}
.send svg {
  width: 15px;
  height: 15px;
  margin: 1px 1px 0 0;
}

.rail {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-top: 12px;
  border: 1px solid var(--line);
  border-radius: 16px;
  background: var(--panel);
  animation: sr-fade 0.2s ease;
}
.railButton {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 6px;
  border-radius: 10px;
  color: var(--ink);
}
.railButton:hover {
  background: var(--nav-on);
}
.railMark {
  width: 24px;
  height: 21px;
}
.railLabel {
  color: var(--muted);
  font-size: 12px;
  font-weight: 500;
  writing-mode: vertical-rl;
}

/* ——— Menus, the share popover, the toast ——— */

.menu,
.sharePop {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 20;
  min-width: 200px;
  padding: 4px;
  border: 1px solid var(--line-2);
  border-radius: 10px;
  background: var(--menu);
  box-shadow:
    0 8px 24px var(--shadow-2),
    0 1px 2px var(--shadow);
  animation: sr-pop 0.14s ease;
}
.composerMenu {
  top: auto;
  right: auto;
  bottom: calc(100% + 6px);
  left: -6px;
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  height: 32px;
  padding: 0 10px;
  border-radius: 6px;
  white-space: nowrap;
}
.menuItem:hover,
.menuItem:focus-visible {
  background: var(--nav-on);
  outline: none;
}
.menuItem:disabled {
  opacity: 0.45;
}
.menuIcon,
.menuCheck {
  width: 16px;
  height: 16px;
  color: var(--muted);
}
.menuItem[aria-checked='false'] .menuCheck {
  visibility: hidden;
}
.sharePop {
  width: 300px;
  padding: 14px;
}
.shareTitle {
  font-size: 14px;
  font-weight: 600;
}
.shareLink {
  width: 100%;
  height: 34px;
  margin-top: 10px;
  padding: 0 10px;
  border: 1px solid var(--line-2);
  border-radius: 8px;
  background: var(--side);
  color: var(--ink-3);
  font-size: 12.5px;
}
.shareCheck {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  color: var(--ink-2);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
}
.shareCheck input {
  width: 15px;
  height: 15px;
  margin: 0;
  accent-color: var(--blue);
}
.copy {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 100%;
  height: 34px;
  margin-top: 12px;
  border-radius: 8px;
  background: var(--send);
  color: var(--send-ink);
  font-weight: 600;
}
.copy svg {
  width: 15px;
  height: 15px;
}

.toast {
  position: absolute;
  bottom: 20px;
  left: 50%;
  z-index: 30;
  transform: translateX(-50%);
  pointer-events: none;
}
.toastText {
  display: block;
  padding: 8px 14px;
  border-radius: 10px;
  background: var(--toast);
  color: var(--toast-ink);
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
  box-shadow: 0 8px 24px var(--shadow-2);
  animation: sr-rise 0.2s ease;
}

/* ——— The demo's pointer ——— */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 50;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

/* ——— Narrower screens ——— */

@container session (max-width: 1099.98px) {
  .body {
    grid-template-columns: minmax(0, 1fr);
    gap: 18px;
  }
  .root[data-panel='folded'] .body {
    grid-template-columns: minmax(0, 1fr);
  }
  .assistant {
    min-height: 600px;
  }
  .rail {
    flex-direction: row;
    padding: 6px 8px;
  }
  .railButton {
    flex-direction: row;
  }
  .railLabel {
    writing-mode: horizontal-tb;
  }
}
@container session (max-width: 760px) {
  .descriptionText {
    max-width: none;
  }
}
@container session (max-width: 639.98px) {
  .shell {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto 1fr;
  }
  .sidebar {
    flex-direction: row;
    width: auto;
    height: 52px;
    padding: 0 12px 0 14px;
    border-right: 0;
    border-bottom: 1px solid var(--line);
    border-radius: 12px 12px 0 0;
  }
  .nav {
    flex: 1;
    min-width: 0;
    margin: 0 0 0 10px;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .nav ul,
  .sideFoot ul {
    flex-direction: row;
  }
  .navToggle {
    display: none;
  }
  .sideFoot {
    flex-direction: row;
    gap: 8px;
    margin: 0 0 0 6px;
  }
  .me,
  .meFace {
    width: 30px;
    height: 30px;
  }
  .scrim {
    display: none;
  }
  .main {
    padding: 14px 14px 16px;
  }
  .header {
    flex-wrap: wrap;
    gap: 10px 12px;
    margin-top: 8px;
  }
  .kind {
    display: none;
  }
  .heading {
    flex-basis: 100%;
    order: 2;
    padding-top: 0;
  }
  .actions {
    order: 1;
    margin-left: auto;
  }
  .title {
    font-size: 17px;
  }
  .meta {
    margin-top: 10px;
  }
  .body {
    margin-top: 18px;
  }
  .lineText,
  .summary {
    font-size: 14px;
    line-height: 21px;
  }
  .line {
    grid-template-columns: 48px minmax(0, 1fr);
  }
  .stamp {
    height: 21px;
    line-height: 21px;
  }
  .sharePop {
    width: min(300px, calc(100cqw - 28px));
  }
  .assistant {
    min-height: 540px;
  }
}

/* The player's controls fit its own width. */
@container player (max-width: 620px) {
  .controls {
    height: 56px;
    padding: 0 10px;
  }
  .volume,
  .castButton,
  .timeLeft {
    display: none;
  }
  .timeNow {
    margin-left: 6px;
  }
  .speed {
    margin-left: 6px;
  }
  .castButton + .ctrl {
    margin-left: 0;
  }
  .pip {
    bottom: 50px;
    left: 12px;
    width: 26%;
    height: auto;
    aspect-ratio: 172 / 113;
  }
  .player {
    --scrub-left: 100px;
    --scrub-right: 74px;
  }
  .preview {
    bottom: 44px;
  }
  .previewFrame {
    width: 120px;
    height: 68px;
  }
}
@container player (max-width: 380px) {
  .speed {
    display: none;
  }
  .player {
    --scrub-left: 100px;
    --scrub-right: 40px;
  }
}

@keyframes sr-fade {
  from {
    opacity: 0;
  }
}
@keyframes sr-pop {
  from {
    opacity: 0;
    transform: translateY(-4px) scale(0.98);
  }
}
@keyframes sr-rise {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
}
@keyframes sr-blink {
  to {
    opacity: 0;
  }
}
@keyframes sr-pulse {
  50% {
    box-shadow: 0 0 0 4px var(--kind-bg);
  }
}
@keyframes sr-breathe {
  to {
    transform: translateY(5px) scale(1.006);
  }
}
@keyframes sr-nod {
  from {
    transform: rotate(-1.6deg);
  }
  to {
    transform: rotate(1.4deg) translateY(3px);
  }
}
@keyframes sr-talk {
  from {
    transform: scaleY(0.45);
  }
  to {
    transform: scaleY(1.1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation: none !important;
    transition: none !important;
  }
}
```