# Agent screen

> A small live view of the computer an agent works on, which opens into a window where you can teach it a task.

Watch an agent at work on its own desktop: its cursor glides to a shop’s search field, types a flavour, adds it, then jots a line in its notes. Hover the view for an Open button; open it and the screen fills a window over the page, where “Teach a task” starts a recording with a red timer by the agent’s name, until you press End. Before the screen is there, the view shows a spinner and “Connecting to agent’s screen”.

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

Category: AI.

## Anatomy

1. **View**: The agent’s screen in its own proportions, 340 px wide at most. Hovering or focusing it lifts its shadow.
2. **Desktop**: Drawn in code and sized in `cqw`, so it reads the same in the small view and the big window.
3. **Agent cursor**: Glides, clicks and types while the agent works; it stops off screen, in a hidden tab and under reduced motion.
4. **Open button**: The whole view is the button; on hover a veil comes over it and this pill rises into its middle in 150 ms.
5. **Name**: “<name>’s screen”, cut with an ellipsis when it is too long for the view.
6. **Status switch**: Flips between Working and Loading. It moves to the right when the card is under 360 px wide. Hide it with `showStatusSwitch={false}`.

## When to use

- An agent that works on a computer of its own, where people want to glance at what it is doing without leaving the page.
- A dashboard of agents: one small live view per agent, its name under it, each opening to a full window.
- Teaching an agent by showing it: `Teach a task` records the reader’s own clicks on the agent’s screen.
- A remote screen that takes a moment to connect, where the spinner and “Connecting to…” say it is on its way.

## When not to use

- Reporting what an agent did in words or steps. Use a progress list or a log, which can be read and searched.
- Real remote control with keyboard and clipboard. Use a full remote-desktop client.
- Several screens to watch at once in detail: the view is 340 px at most and the window opens one at a time.
- An agent that only calls tools or APIs: there is no screen to show, so a picture of one would mislead.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { AgentScreen } from './agent-screen';

export function AgentPanel({ agent }: { agent: AgentSession }) {
  return (
    <AgentScreen
      name={agent.name}
      status={agent.connected ? 'working' : 'loading'}
      showStatusSwitch={false}
      onRecordingChange={(recording, seconds) => {
        if (!recording) agent.saveDemonstration(seconds);
      }}
      autoplay={false}
    />
  );
}
```

## States

- **Working**: The agent’s desktop, here held still with `live={false}`; live, its cursor works through a task in a loop.
- **Hovered**: A veil and the Open pill, also on keyboard focus. Clicking anywhere on the view opens it.
- **Connecting**: Black, a spinner that keeps turning, and “Connecting to agent’s screen”. It cannot be opened.
- **Open**: The window over a dimmed page (drawn in place here); the reader’s pointer is drawn as the agent’s arrow.
- **Recording**: The agent stands still and its arrow goes; a red timer counts on and End replaces Teach a task.

## How it works

- The desktop is drawn in code: a soft gradient wallpaper, a menu bar, a notes window behind a browser on a shop’s flavours, two widgets and a dock. Places are shares of the screen and sizes are cqw (the screen is a size container), so the 340 px view and the 960 px window show the same picture.
- The agent plays its task as a script of awaited steps: glide, click, type a character every 55 to 95 ms, wait. A click replays a 280 ms press of the arrow and a ripple; the search dims the flavours that do not match; a click on the notes brings that window to the front.
- Its cursor is a layer as big as the screen, moved by a transform in percent of it, so every glide (380 to 950 ms, by distance) is one compositor transition whatever the screen’s size; the 30 px arrow keeps its size, as a real pointer does.
- The agent only works while the view is on screen, the tab is visible and motion is welcome; stopped, it starts its task over when it runs again, gliding on from where its cursor was.
- On hover or focus, a veil (rgb(17 19 24 / 0.18)) comes over the view and the Open button rises 4 px as it fades in, both in 150 ms, while the view’s shadow lifts.
- The window is a <dialog> opened with showModal(): it sits in the top layer, keeps focus inside, gives it back on close, and Escape, the collapse button or a click on the dimmed page close it. The page dims in 180 ms and the window pops in from a 0.95 scale in 240 ms; it closes at once.
- The big screen is as large as fits: 960 px at most, 90% of the viewport’s width, and the viewport’s height less 150 px, in the screen’s own proportions.
- Over it, the reader’s pointer is drawn as the agent’s arrow (cursor: none and a layer that follows pointermove). While a task is recorded, the agent stands still, its arrow goes, and each of the reader’s clicks rings out in red; the timer reads its start time every 250 ms, so it never drifts, and its dot pulses every 1.1 s.
- While on screen and untouched, a demo pointer hovers the view after a whole task of the agent’s, then flips the switch to Loading and back; any input of the reader’s hands it over and undoes a loading state the demo left.
- Every colour is a light-dark() token on the root; the desktop keeps its own colours, as a picture of another screen does. Shadows that differ between the schemes are written once with all their layers, the layers a scheme does not use being transparent.

## API reference

- **name** (`string`, default `'Agent'`): Whose screen it is: the window’s title, and “<name>’s screen” under the view.
- **status** (`'working' | 'loading'`): Whether the screen is there or still connecting, when the parent controls it. A connecting view cannot be opened.
- **defaultStatus** (`'working' | 'loading'`, default `'working'`): The status to start with, when it is not controlled.
- **onStatusChange** (`(status: AgentStatus) => void`): Called when the switch, or the demo, asks for the other status.
- **showStatusSwitch** (`boolean`, default `true`): Whether the Working / Loading switch shows at the foot of the card.
- **connectingLabel** (`string`, default `“Connecting to <name>’s screen”`): What the view says while it connects.
- **live** (`boolean`, default `true`): Whether the agent works on its desktop: its cursor moves, clicks and types.
- **defaultState** (`AgentScreenState`): A state to open in: hover (the view drawn hovered until the reader’s first input), open (the window open) or recording (the window recording, this many seconds in). Setting it turns the demo off.
- **windowInline** (`boolean`, default `false`): Draws the open window in the page’s flow, non-modal, instead of over the page.
- **onOpenChange** (`(open: boolean) => void`): Called when the big window opens or closes.
- **onRecordingChange** (`(recording: boolean, seconds: number) => void`): Called when a recording starts (true, 0) and when it ends (false, its length in seconds); closing the window ends it.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers the view and flips it to loading and back.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s system setting. Without it, the page’s color-scheme decides.
- **className** (`string`): Classes for the root.

## Accessibility

- The view is opened by a real button, “Open agent’s screen”, with aria-haspopup="dialog"; its Open label shows on focus as on hover.
- The window is a modal <dialog> named “Agent’s screen”: focus moves into it, stays there, and returns to the Open button when it closes; Escape closes it.
- Teach a task, End and Collapse are buttons; the timer is labelled “Recording, 00:04”, and a polite live region says when a recording starts and ends (with its length) and when the view connects.
- The Working / Loading switch is a group of two toggle buttons with aria-pressed.
- The desktop and both arrows are decorative and hidden from assistive technology.
- Under reduced motion the agent does not work, the demo does not play, and the view, window and buttons change at once.

## Source

### agent-screen/agent-screen.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { CursorArt, INITIAL, Scene, useAgent } from './scene';
import { useAutoplay } from './use-autoplay';
import styles from './agent-screen.module.css';

/*
 * Agent screen: a small live view of the computer an agent works on, with its name under it.
 * The agent's cursor glides about its desktop, clicks and types (scene.tsx). Hover the view and a
 * dark veil comes over it with an "Open" button; open it and the screen fills a window over the
 * page, where the reader can teach the agent a task: "Teach a task" starts a recording, with a red
 * timer by the agent's name and an "End" button in place of it.
 *
 * - The window is a <dialog> opened with showModal(): it sits in the top layer, keeps focus inside,
 *   and Escape closes it; so do the collapse button and a click on the dimmed page. It opens with
 *   the page dimming in 180 ms and the window popping in from 0.95 in 240 ms, and closes at once.
 * - Over the big screen the reader's own pointer is drawn as the agent's arrow; while a task is
 *   recorded the agent stands still, its arrow goes, and each of the reader's clicks rings out.
 * - Before the agent's screen is there, the view shows a spinner and "Connecting to agent's
 *   screen". A small switch under the view flips between the two states ("Working", "Loading").
 * - The agent only works while the view is on screen, the tab is visible and motion is welcome.
 *   While the demo is untouched, a pointer hovers the view and flips it to loading and back.
 * - defaultState opens it hovered, open or recording, held still (no demo); windowInline draws
 *   the open window in the page's flow instead of over it, for previews and docs.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets. The desktop itself keeps its own colours.
 */

export type AgentStatus = 'working' | 'loading';

/** A state to open in, held still (no demo): the view hovered, the window open, or recording. */
export type AgentScreenState = {
  /** Draws the view as hovered (its veil and Open button) until the reader's first input. */
  hover?: boolean;
  /** Opens the window. */
  open?: boolean;
  /** Opens the window recording a task, this many seconds in (the clock runs on from there). */
  recording?: number;
};

/** Seconds as 00:00. */
const clock = (seconds: number) =>
  `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;

export function AgentScreen({
  name = 'Agent',
  status,
  defaultStatus = 'working',
  onStatusChange,
  showStatusSwitch = true,
  connectingLabel,
  live = true,
  onOpenChange,
  onRecordingChange,
  autoplay = true,
  colorScheme,
  defaultState,
  windowInline = false,
  className = '',
}: {
  /** Whose screen it is: the window's title, and "<name>'s screen" under the view. */
  name?: string;
  /** Whether the screen is there ('working') or still connecting ('loading'); controlled. */
  status?: AgentStatus;
  /** The status to start with, when it is not controlled. */
  defaultStatus?: AgentStatus;
  /** Called when the switch (or the demo) asks for the other status. */
  onStatusChange?: (status: AgentStatus) => void;
  /** Whether the Working / Loading switch shows under the view. */
  showStatusSwitch?: boolean;
  /** What the view says while it connects; by default "Connecting to <name>'s screen". */
  connectingLabel?: string;
  /** Whether the agent works on its desktop (its cursor moves, clicks and types). */
  live?: boolean;
  /** Called when the big window opens or closes. */
  onOpenChange?: (open: boolean) => void;
  /** Called when a recording starts (false… true) or ends, with its length in seconds. */
  onRecordingChange?: (recording: boolean, seconds: number) => void;
  /** While on screen and untouched, a demo pointer hovers the view and flips its status. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** A state to open in (hovered, open, recording); setting it turns the demo off. */
  defaultState?: AgentScreenState;
  /** Draws the open window in place of the view, in the page's flow, not over the page. */
  windowInline?: boolean;
  className?: string;
}) {
  const { ref: root, take, visible, reduced, playing } = useAutoplay<HTMLDivElement>(8000);
  const pointer = useRef<HTMLSpanElement>(null);
  const dialog = useRef<HTMLDialogElement>(null);
  const card = useRef<HTMLDivElement>(null);

  const [ownStatus, setOwnStatus] = useState<AgentStatus>(defaultStatus);
  const current = status ?? ownStatus;
  const [open, setOpen] = useState(!!defaultState?.open || defaultState?.recording !== undefined);
  /** When the recording started (ms), or null. */
  const [startedAt, setStartedAt] = useState<number | null>(() =>
    defaultState?.recording !== undefined ? Date.now() - defaultState.recording * 1000 : null,
  );
  const [seconds, setSeconds] = useState(defaultState?.recording ?? 0);
  /** The hover drawn by defaultState, until the reader's first input. */
  const [held, setHeld] = useState(!!defaultState?.hover);
  const [announce, setAnnounce] = useState('');
  const recording = startedAt !== null;

  const agent = useAgent(live && visible && !reduced && current === 'working' && !recording);

  const changeStatus = (next: AgentStatus) => {
    if (next === current) return;
    if (status === undefined) setOwnStatus(next);
    onStatusChange?.(next);
    setAnnounce(next === 'loading' ? `Connecting to ${name.toLowerCase()}'s screen` : '');
  };

  // The window follows `open`: showModal() puts it in the top layer, close() gives focus back.
  useEffect(() => {
    const element = dialog.current;
    if (!element) return;
    if (open && !element.open) {
      if (windowInline) element.show();
      else element.showModal();
    } else if (!open && element.open) element.close();
  }, [open, windowInline]);

  // A connecting screen cannot be open.
  useEffect(() => {
    if (current === 'loading') setOpen(false);
  }, [current]);

  // The recording's clock, read from its start so it never drifts.
  useEffect(() => {
    if (startedAt === null) return;
    const tick = () => setSeconds(Math.floor((Date.now() - startedAt) / 1000));
    tick();
    const timer = window.setInterval(tick, 250);
    return () => window.clearInterval(timer);
  }, [startedAt]);

  const startRecording = () => {
    setSeconds(0);
    setStartedAt(Date.now());
    setAnnounce('Recording a task');
    onRecordingChange?.(true, 0);
  };
  const endRecording = () => {
    if (startedAt === null) return;
    const length = Math.floor((Date.now() - startedAt) / 1000);
    setStartedAt(null);
    setAnnounce(`Recording ended after ${clock(length)}`);
    onRecordingChange?.(false, length);
  };

  const show = (next: boolean) => {
    if (next === open) return;
    if (!next) endRecording();
    setOpen(next);
    onOpenChange?.(next);
  };

  // The demo: a pointer hovers the view, then flips it to loading and back.
  /** Whether the demo, not the reader, left the view connecting. */
  const demoLoading = useRef(false);
  const demoAct = useEffectEvent((next: AgentStatus) => {
    demoLoading.current = next === 'loading';
    changeStatus(next);
  });
  /** The reader's first input takes over, and undoes the demo's loading. */
  const interrupt = () => {
    take();
    setHeld(false);
    if (demoLoading.current) {
      demoLoading.current = false;
      changeStatus('working');
    }
  };
  const demo = autoplay && !defaultState && playing && !open && status === undefined;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    let hovered: HTMLElement | null = null;
    /** Stands in for :hover, as the demo's pointer is not the reader's. */
    const hover = (element: HTMLElement | null) => {
      hovered?.removeAttribute('data-hover');
      hovered = element;
      hovered?.setAttribute('data-hover', '');
    };
    const visit = async (
      { moveTo }: Demo,
      element: HTMLElement | null,
      at?: { x: number; y: number },
    ) => {
      hover(null);
      await moveTo(element, at);
      hover(element);
    };
    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, press } = steps;
      for (;;) {
        demoAct('working');
        // A whole task of the agent's first, then the hover and the loading state.
        await wait(15500);
        await visit(steps, card.current, { x: 0.66, y: 0.62 });
        await wait(1900);
        await visit(steps, find('[data-status="loading"]'), { x: 0.5, y: 0.6 });
        await wait(350);
        await press(() => demoAct('loading'));
        await wait(2600);
        await visit(steps, find('[data-status="working"]'), { x: 0.5, y: 0.6 });
        await wait(350);
        await press(() => demoAct('working'));
        await wait(700);
        await visit(steps, stage, { x: 0.9, y: 0.92 });
        hover(null);
        await wait(3500);
      }
    });
    return () => {
      stop();
      hover(null);
    };
  }, [demo, root]);

  // Over the big screen the reader's pointer is drawn as the agent's arrow.
  const reader = useRef<HTMLSpanElement>(null);
  const [rings, setRings] = useState<{ id: number; x: number; y: number }[]>([]);
  const follow = (event: ReactPointerEvent<HTMLDivElement>) => {
    const arrow = reader.current;
    if (!arrow) return;
    if (event.pointerType !== 'mouse') {
      arrow.style.opacity = '0';
      return;
    }
    const box = event.currentTarget.getBoundingClientRect();
    arrow.style.transform = `translate(${event.clientX - box.left}px, ${event.clientY - box.top}px)`;
    arrow.style.opacity = '1';
  };
  const leave = () => {
    if (reader.current) reader.current.style.opacity = '0';
  };
  const ring = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!recording) return;
    const box = event.currentTarget.getBoundingClientRect();
    setRings((list) => [
      ...list.slice(-4),
      { id: event.timeStamp, x: event.clientX - box.left, y: event.clientY - box.top },
    ]);
  };

  const owner = `${name}'s screen`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-inline={windowInline || undefined}
      onPointerDownCapture={interrupt}
      onKeyDownCapture={interrupt}
    >
      <div className={styles.frame}>
        <div className={styles.tile}>
          <div
            ref={card}
            className={styles.card}
            data-status={current}
            data-part="view"
            data-hover={held || undefined}
          >
            {current === 'working' ? (
              <>
                <Scene state={reduced ? INITIAL : agent} cursor={!recording} />
                <button
                  type="button"
                  className={styles.opener}
                  aria-haspopup="dialog"
                  aria-label={`Open ${owner.toLowerCase()}`}
                  onClick={() => show(true)}
                >
                  <span className={styles.pill} data-part="open">
                    <ExpandIcon />
                    Open
                  </span>
                </button>
              </>
            ) : (
              <div className={styles.connecting} data-part="connecting">
                <Spinner />
                <span className={styles.connectingText}>
                  {connectingLabel ?? `Connecting to ${owner.toLowerCase()}`}
                </span>
              </div>
            )}
          </div>
          <div className={styles.label} data-part="label">
            {owner}
          </div>
        </div>
      </div>

      {showStatusSwitch && (
        <div
          className={styles.switch}
          role="group"
          aria-label="Screen state"
          data-part="status-switch"
        >
          {(['working', 'loading'] as const).map((value) => (
            <button
              key={value}
              type="button"
              className={styles.option}
              data-status={value}
              aria-pressed={current === value}
              onClick={() => changeStatus(value)}
            >
              {value === 'working' ? 'Working' : 'Loading'}
            </button>
          ))}
        </div>
      )}

      <span className={styles.srOnly} aria-live="polite">
        {announce}
      </span>

      <dialog ref={dialog} className={styles.dialog} aria-label={owner} onClose={() => show(false)}>
        {open && (
          <>
            <div className={styles.backdrop} onClick={() => show(false)} />
            <div className={styles.panel} data-part="window">
              <div className={styles.head} data-part="window-head">
                <div className={styles.title}>
                  <span className={styles.name}>{name}</span>
                  {recording && (
                    <span
                      className={styles.timer}
                      data-part="timer"
                      aria-label={`Recording, ${clock(seconds)}`}
                    >
                      <span className={styles.dot} />
                      {clock(seconds)}
                    </span>
                  )}
                </div>
                <div className={styles.actions}>
                  {recording ? (
                    <button
                      type="button"
                      className={styles.end}
                      data-part="end"
                      onClick={endRecording}
                    >
                      <span className={styles.stop} />
                      End
                    </button>
                  ) : (
                    <button
                      type="button"
                      className={styles.teach}
                      data-part="teach"
                      onClick={startRecording}
                    >
                      <RecordIcon />
                      Teach a task
                    </button>
                  )}
                  <button
                    type="button"
                    className={styles.collapse}
                    data-part="collapse"
                    aria-label="Collapse"
                    onClick={() => show(false)}
                  >
                    <CollapseIcon />
                  </button>
                </div>
              </div>
              <div
                className={styles.stage}
                data-part="stage"
                onPointerMove={follow}
                onPointerEnter={follow}
                onPointerLeave={leave}
                onPointerDown={ring}
              >
                <Scene state={reduced ? INITIAL : agent} cursor={!recording} />
                {rings.map((r) => (
                  <span
                    key={r.id}
                    className={styles.ring}
                    style={{ left: r.x, top: r.y }}
                    onAnimationEnd={() => setRings((list) => list.filter((x) => x.id !== r.id))}
                  />
                ))}
                <span ref={reader} className={styles.reader} aria-hidden="true">
                  <CursorArt className={styles.readerArrow} />
                </span>
              </div>
            </div>
          </>
        )}
      </dialog>

      <DemoPointer ref={pointer} />
    </div>
  );
}

/* The icons, drawn on a 24-unit grid. */

const STROKE = {
  viewBox: '0 0 24 24',
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 2,
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
  'aria-hidden': true,
} as const;

/** Two arrows pointing out to opposite corners. */
function ExpandIcon() {
  return (
    <svg width="14" height="14" {...STROKE}>
      <path d="M14.5 3.5h6v6M20.5 3.5l-6.8 6.8M9.5 20.5h-6v-6M3.5 20.5l6.8-6.8" />
    </svg>
  );
}

/** Two arrows pointing in from opposite corners. */
function CollapseIcon() {
  return (
    <svg width="15" height="15" {...STROKE}>
      <path d="M4.5 13.8h5.7v5.7M3.2 20.8l7-7M19.5 10.2h-5.7V4.5M20.8 3.2l-7 7" />
    </svg>
  );
}

/** A ring with a dot: record. */
function RecordIcon() {
  return (
    <svg width="15" height="15" {...STROKE}>
      <circle cx="12" cy="12" r="8.8" />
      <circle cx="12" cy="12" r="3.6" fill="currentColor" stroke="none" />
    </svg>
  );
}

/** A faint ring with a bright arc, a little over a quarter of it, that turns. */
function Spinner() {
  const length = 2 * Math.PI * 12;
  return (
    <svg className={styles.spinner} width="26" height="26" aria-hidden="true">
      <circle cx="13" cy="13" r="12" fill="none" stroke="rgb(255 255 255 / 0.18)" strokeWidth="2" />
      <circle
        cx="13"
        cy="13"
        r="12"
        fill="none"
        stroke="#fff"
        strokeWidth="2"
        strokeLinecap="round"
        strokeDasharray={`${length * 0.28} ${length * 0.72}`}
      />
    </svg>
  );
}
```

### agent-screen/agent-screen.module.css

```css
/* Agent screen (agent-screen.tsx): a soft grey card; in its middle the agent's view, 340 px wide
   at most, its name under it, and the Working / Loading switch at the card's foot. Opened, the
   view fills a window over the page (a <dialog> in the top layer).

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). Shadows that differ between the schemes are
   written once with every layer, the layers a scheme does not use being transparent. The dialog
   is the root's child in the document, so it inherits the same tokens from the top layer.

   Motion. The view rises in once (380 ms); its veil and Open button fade in on hover (150 ms);
   the window pops in (240 ms) over a page that dims (180 ms) and closes at once. Under reduced
   motion all of it is instant. */

.root {
  /* The card, the view's own ground, and the ink in three strengths. */
  --canvas: oklch(96.1% 0.002 247.84);
  --inset: oklch(97.9% 0.002 247.839);
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  --line: oklch(94.6% 0.003 264.542);
  /* The switch's track, the raised surfaces (its thumb, the window, the buttons), their hover. */
  --field: oklch(96.1% 0.001 286.375);
  --surface: oklch(100% 0 0);
  --hover: oklch(97% 0.002 247.839);
  /* The Open button, and the recording's red with its tint. */
  --accent: oklch(55.6% 0.187 255.617);
  --accent-ink: oklch(49.5% 0.18 257);
  --red: oklch(62.1% 0.192 23.042);
  --red-tint: oklch(95.6% 0.017 17.462);
  /* A button's edge and lift: a soft halo by day, a short drop at night. */
  --button-edge: oklch(91.2% 0.005 258.326);
  --button-halo: #0000000a;
  --button-drop: transparent;
  /* The view's edge and lift: long and soft by day, short and dark at night; and on hover. */
  --card-edge: oklch(94.6% 0.003 264.542);
  --card-1: #0000000a;
  --card-2: #00000008;
  --card-3: #00000005;
  --card-4: #00000003;
  --card-night-1: transparent;
  --card-night-2: transparent;
  --raised-edge: oklch(94.6% 0.003 264.542);
  --raised-night: transparent;
  /* The window's edge, its long soft shadow by day, its single deep one at night; the dimmed page. */
  --overlay-edge: oklch(94.6% 0.003 264.542);
  --overlay-1: #0000000d;
  --overlay-2: #0000000a;
  --overlay-3: #00000008;
  --overlay-4: #00000005;
  --overlay-night: transparent;
  --dim: rgb(0 0 0 / 0.6);
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);

  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  container-type: inline-size;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --accent: light-dark(oklch(55.6% 0.187 255.617), oklch(68% 0.173 253.301));
    --accent-ink: light-dark(oklch(49.5% 0.18 257), oklch(78.8% 0.113 248.33));
    --red: light-dark(oklch(62.1% 0.192 23.042), oklch(66.6% 0.18 21.433));
    --red-tint: light-dark(oklch(95.6% 0.017 17.462), oklch(66.6% 0.18 21.433 / 0.14));
    --button-edge: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --button-halo: light-dark(#0000000a, transparent);
    --button-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
    --card-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --card-1: light-dark(#0000000a, transparent);
    --card-2: light-dark(#00000008, transparent);
    --card-3: light-dark(#00000005, transparent);
    --card-4: light-dark(#00000003, transparent);
    --card-night-1: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --card-night-2: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --raised-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.13));
    --raised-night: light-dark(transparent, oklch(0% 0 0 / 0.22));
    --overlay-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.15));
    --overlay-1: light-dark(#0000000d, transparent);
    --overlay-2: light-dark(#0000000a, transparent);
    --overlay-3: light-dark(#00000008, transparent);
    --overlay-4: light-dark(#00000005, transparent);
    --overlay-night: light-dark(transparent, oklch(0% 0 0 / 0.34));
    --dim: light-dark(rgb(0 0 0 / 0.6), rgb(0 0 0 / 0.75));
  }
}

/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.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;
}

/* A bare reset, weaker than any class below. */
:where(.root) button {
  margin: 0;
  border: 0;
  padding: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  cursor: pointer;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

/* The column: 480 px at most, and the view 340 px in its middle. */
.frame {
  width: 100%;
  max-width: 480px;
}
.tile {
  width: 100%;
  max-width: 340px;
  margin-inline: auto;
}

/* The view: the agent's screen, in its own proportions. */
.card {
  position: relative;
  aspect-ratio: 2964 / 1856;
  overflow: hidden;
  border-radius: 14px;
  background: var(--inset);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 17.54px 23.39px var(--card-1),
    0 9.4px 12.5px var(--card-2),
    0 5.25px 7px var(--card-3),
    0 2.79px 3.72px -2px var(--card-4),
    0 1.16px 1.5px var(--card-4),
    0 1px 2px var(--card-night-1),
    0 2px 6px var(--card-night-2);
  transition: box-shadow 0.15s cubic-bezier(0.4, 0, 0.2, 1);
  animation: rise 380ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.card[data-status='working']:is(:hover, [data-hover], :focus-within) {
  box-shadow:
    0 0 0 1px var(--raised-edge),
    0 17.54px 23.39px var(--card-1),
    0 9.4px 12.5px var(--card-2),
    0 5.25px 7px var(--card-3),
    0 2.79px 3.72px -2px var(--card-4),
    0 1.16px 1.5px var(--card-4),
    0 2px 10px var(--raised-night),
    0 0 0 transparent;
}

/* Over the view, the button that opens it: a veil, and the Open pill rising into its middle. */
.opener {
  position: absolute;
  inset: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  border-radius: inherit;
  background: rgb(17 19 24 / 0);
  transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.root .opener:focus-visible {
  outline-offset: -2px;
}
.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 27px;
  padding: 0 12px;
  border-radius: 999px;
  background: var(--accent);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.14);
  color: #fff;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  opacity: 0;
  transform: translateY(4px);
  transition:
    opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1),
    transform 0.15s cubic-bezier(0.4, 0, 0.2, 1),
    background-color 0.15s cubic-bezier(0, 0, 0.2, 1);
}
.pill svg {
  flex: none;
}
.card:is(:hover, [data-hover], :focus-within) .opener {
  background: rgb(17 19 24 / 0.18);
}
.card:is(:hover, [data-hover], :focus-within) .pill {
  opacity: 1;
  transform: translateY(0);
}
.opener:active .pill {
  transform: scale(0.96);
}
@media (hover: hover) {
  .pill:hover {
    background: var(--accent-ink);
  }
}

/* Connecting: black, a spinner, and a line under it. */
.connecting {
  position: absolute;
  inset: 0;
  background: #000;
}
.spinner {
  position: absolute;
  left: 50%;
  top: 50%;
  display: block;
  margin: -13px 0 0 -13px;
  animation: spin 1.1s linear infinite;
}
.connectingText {
  position: absolute;
  inset-inline: 0;
  top: calc(50% + 28px);
  color: rgb(255 255 255 / 0.7);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.5;
  text-align: center;
}

.label {
  overflow: hidden;
  margin-top: 10px;
  padding: 0 2px;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.5;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The Working / Loading switch, at the card's foot. */
.switch {
  position: absolute;
  bottom: 10px;
  left: 50%;
  display: flex;
  padding: 2px;
  border-radius: 999px;
  background: var(--field);
  transform: translateX(-50%);
}
.option {
  padding: 2px 8px;
  border-radius: 999px;
  color: var(--ink-3);
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1.5;
  transition:
    background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1),
    color 0.15s cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.option:is(:hover, [data-hover]) {
  color: var(--ink-2);
}
.option[aria-pressed='true'] {
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 0 4px var(--button-halo),
    0 1px 2px var(--button-drop);
  color: var(--ink);
}
/* Too narrow for the switch to sit centred beside the name: it moves to the right. */
@container (max-width: 359px) {
  .switch {
    left: auto;
    right: 12px;
    transform: none;
  }
}

/* The window: a <dialog> as big as the viewport, holding the dimmed page and the panel. */
.dialog {
  position: fixed;
  inset: 0;
  box-sizing: border-box;
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
  margin: 0;
  padding: 16px;
  border: 0;
  background: transparent;
  color: var(--ink);
  overflow: hidden;
}
.dialog[open] {
  display: flex;
  align-items: center;
  justify-content: center;
}
.dialog::backdrop {
  background: transparent;
}
@media (min-width: 640px) {
  .dialog {
    padding: 24px;
  }
}
.backdrop {
  position: absolute;
  inset: 0;
  background: var(--dim);
  animation: appear 180ms ease-out both;
}
.panel {
  position: relative;
  display: flex;
  flex-direction: column;
  max-height: 100%;
  overflow: hidden;
  padding: 0 8px 8px;
  border-radius: 16px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--overlay-edge),
    0 25px 50px var(--overlay-1),
    0 12px 24px var(--overlay-2),
    0 6px 12px var(--overlay-3),
    0 3px 6px var(--overlay-4),
    0 1.5px 3px var(--overlay-4),
    0 8px 28px var(--overlay-night);
  animation: pop 240ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.head {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 44px;
  padding: 0 6px;
}
.title {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.name {
  overflow: hidden;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.5;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.timer {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 6px;
  padding: 2px 8px 2px 6px;
  border-radius: 999px;
  background: var(--red-tint);
  color: var(--red);
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
}
.dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--red);
  animation: pulse 1.1s ease-in-out infinite;
}
.actions {
  display: flex;
  flex: none;
  align-items: center;
  gap: 6px;
}
.teach,
.end {
  display: inline-flex;
  align-items: center;
  height: 27px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  user-select: none;
}
.teach {
  gap: 4px;
  padding: 0 12px 0 6px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 0 4px var(--button-halo),
    0 1px 2px var(--button-drop);
  transition:
    transform 0.15s cubic-bezier(0, 0, 0.2, 1),
    background-color 0.15s cubic-bezier(0, 0, 0.2, 1);
}
.teach:hover {
  background: var(--inset);
}
.end {
  gap: 6px;
  padding: 0 12px 0 10px;
  background: var(--red);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.14);
  color: #fff;
  transition:
    transform 0.15s cubic-bezier(0, 0, 0.2, 1),
    filter 0.15s cubic-bezier(0, 0, 0.2, 1);
}
.end:hover {
  filter: brightness(0.95);
}
.teach:active,
.end:active {
  transform: scale(0.96);
}
.stop {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  background: #fff;
}
.collapse {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 8px;
  color: var(--ink-3);
  transition:
    color 0.1s cubic-bezier(0.4, 0, 0.2, 1),
    background-color 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapse:hover {
  background: var(--hover);
  color: var(--ink);
}

/* The big screen: as big as fits, 960 px at most, in the screen's proportions. */
.stage {
  position: relative;
  width: min(960px, 90vw, 100vw - 48px, (100dvh - 150px) * 2964 / 1856);
  min-height: 0;
  aspect-ratio: 2964 / 1856;
  overflow: hidden;
  border-radius: 8px;
  background: var(--inset);
  cursor: none;
}
.reader {
  position: absolute;
  left: -5px;
  top: -5px;
  z-index: 30;
  pointer-events: none;
  opacity: 0;
}
.readerArrow {
  display: block;
  fill: #111318;
  stroke: #fff;
  stroke-width: 1.4;
  stroke-linejoin: round;
  filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.35));
}
.ring {
  position: absolute;
  z-index: 25;
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border-radius: 50%;
  background: rgb(240 70 80 / 0.25);
  box-shadow: 0 0 0 2px var(--red);
  pointer-events: none;
  animation: ring 520ms ease-out forwards;
}

/* windowInline: the open window drawn in place of the view, in the page's flow (previews, docs):
   no dimmed page, and the view and its switch out of the way while it is open. */
.root[data-inline] .dialog {
  position: relative;
  inset: auto;
  height: auto;
  padding: 0;
  overflow: visible;
}
.root[data-inline] .dialog[open] {
  display: block;
}
.root[data-inline] .backdrop,
.root[data-inline]:has(.dialog[open]) > :is(.frame, .switch) {
  display: none;
}
.root[data-inline] .stage {
  width: 100%;
}

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

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}
@keyframes appear {
  from {
    opacity: 0;
  }
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
@keyframes pulse {
  0%,
  100% {
    opacity: 0.35;
    transform: scale(0.8);
  }
  50% {
    opacity: 1;
    transform: scale(1);
  }
}
@keyframes ring {
  from {
    opacity: 1;
    transform: scale(0.3);
  }
  to {
    opacity: 0;
    transform: scale(1.5);
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .dialog * {
    animation: none !important;
    transition: none !important;
  }
  .dot {
    opacity: 1;
  }
}
```

### agent-screen/scene.tsx

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

'use client';

import { useEffect, useRef, useState, type CSSProperties } from 'react';
import styles from './scene.module.css';

/*
 * The agent's desktop, drawn in code: a soft wallpaper, a menu bar, a notes window behind a
 * browser that shows a small shop's flavours, two widgets and a dock. Everything is placed in
 * shares of the screen and sized in cqw, so the same drawing reads the same in the 340 px tile
 * and in the 960 px window.
 *
 * The agent works on it in a loop (useAgent): its cursor glides to the shop's search field,
 * clicks, types a flavour and sends it, adds the flavour that is left, then brings the notes to
 * the front and writes a line there. The cursor is a full-screen layer moved by a transform in
 * percent of the screen, so a glide is one compositor transition, whatever the screen's size.
 */

/** A point on the screen, in percent of its width (x) and height (y). */
type Point = { x: number; y: number };

/** The windows, in percent of the screen. */
const BROWSER = { x: 5, y: 29, w: 55, h: 59 };
const NOTES = { x: 25, y: 9, w: 56, h: 51 };

/** A point given in percent of a window, in percent of the screen. */
const inWindow = (box: typeof BROWSER, x: number, y: number): Point => ({
  x: box.x + (box.w * x) / 100,
  y: box.y + (box.h * y) / 100,
});

/** Where the cursor rests between tasks: over the shop's last flavour. */
const REST: Point = { x: 43.5, y: 55.4 };
/** The shop's search field, the first flavour's Add button, and an empty line of the notes. */
const SEARCH = inWindow(BROWSER, 74, 21);
const ADD = inWindow(BROWSER, 27.6, 57);
const NOTE_LINE = { x: 64, y: inWindow(NOTES, 0, 50).y };

const FLAVOURS = [
  { name: 'Pistachio', price: '$4.50', color: '#b8d98c' },
  { name: 'Strawberry', price: '$4.00', color: '#f5a9bd' },
  { name: 'Mango sorbet', price: '$4.25', color: '#ffc768' },
  { name: 'Dark chocolate', price: '$4.50', color: '#8c5c47' },
  { name: 'Blueberry', price: '$4.25', color: '#a3b5f5' },
  { name: 'Vanilla bean', price: '$3.75', color: '#f2e4c2' },
];
const QUERY = 'pistachio';
const NOTE = 'Pistachio on the board for Saturday';

export type AgentState = {
  /** The cursor: where it is, and how long its glide there takes (ms). */
  at: Point;
  glide: number;
  /** Bumped on each click, to replay the press and the ripple. */
  clicks: number;
  /** The window in front, and the field the caret is in. */
  front: 'browser' | 'notes';
  field: 'none' | 'search' | 'notes';
  query: string;
  searched: boolean;
  added: boolean;
  note: string;
};

export const INITIAL: AgentState = {
  at: REST,
  glide: 0,
  clicks: 0,
  front: 'browser',
  field: 'none',
  query: '',
  searched: false,
  added: false,
  note: '',
};

/** Glides take longer the farther they go, from 380 to 950 ms. */
const glideTime = (from: Point, to: Point) =>
  Math.round(Math.min(950, Math.max(380, Math.hypot(to.x - from.x, (to.y - from.y) * 0.63) * 15)));

/**
 * The agent at work: while `running`, it plays its task in a loop. Stopping it (off screen, a
 * hidden tab, the reader teaching) ends the step in progress; it starts over from the top when it
 * runs again, gliding back from wherever its cursor was.
 */
export function useAgent(running: boolean) {
  const [state, setState] = useState<AgentState>(INITIAL);
  /** Where the cursor is, kept across runs, so a new run glides on from there. */
  const cursorAt = useRef(INITIAL.at);

  useEffect(() => {
    if (!running) return;
    let alive = true;
    let timer = 0;
    const set = (patch: Partial<AgentState> | ((s: AgentState) => Partial<AgentState>)) =>
      setState((s) => ({ ...s, ...(typeof patch === 'function' ? patch(s) : patch) }));
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        if (!alive) return reject(new Error('stopped'));
        timer = window.setTimeout(() => (alive ? resolve() : reject(new Error('stopped'))), ms);
      });
    const move = async (to: Point) => {
      const glide = glideTime(cursorAt.current, to);
      cursorAt.current = to;
      set({ at: to, glide });
      await wait(glide + 60);
    };
    const click = async (patch: Partial<AgentState> = {}) => {
      set((s) => ({ ...patch, clicks: s.clicks + 1 }));
      await wait(260);
    };
    const type = async (key: 'query' | 'note', text: string, every: number) => {
      for (let i = 1; i <= text.length; i += 1) {
        set({ [key]: text.slice(0, i) });
        await wait(every + (i % 3 === 0 ? 40 : 0));
      }
    };

    (async () => {
      for (;;) {
        set({
          front: 'browser',
          field: 'none',
          query: '',
          searched: false,
          added: false,
          note: '',
        });
        await move(REST);
        await wait(1800);
        await move(SEARCH);
        await click({ field: 'search' });
        await wait(200);
        await type('query', QUERY, 95);
        await wait(450);
        set({ searched: true });
        await wait(800);
        await move(ADD);
        await wait(150);
        await click({ added: true, field: 'none' });
        await wait(900);
        await move(NOTE_LINE);
        await click({ front: 'notes', field: 'notes' });
        await wait(250);
        await type('note', NOTE, 55);
        await wait(1500);
        set({ field: 'none' });
        await move(REST);
        await wait(2400);
      }
    })().catch(() => {});

    return () => {
      alive = false;
      window.clearTimeout(timer);
    };
  }, [running]);

  return state;
}

/** The desktop, and the agent's cursor on it when `cursor` is set. */
export function Scene({ state, cursor = true }: { state: AgentState; cursor?: boolean }) {
  const matches = (name: string) =>
    !state.searched || name.toLowerCase().includes(state.query.toLowerCase());
  return (
    <div className={styles.scene} aria-hidden="true" data-part="desktop">
      <div className={styles.wallpaper} />

      <div className={styles.menubar}>
        <span className={styles.logo} />
        <b>Browser</b>
        <span>File</span>
        <span>Edit</span>
        <span>View</span>
        <span>Window</span>
        <span className={styles.clock}>Sat 9:41</span>
      </div>

      {/* The notes, behind the browser until the agent clicks on them. */}
      <div
        className={styles.window}
        style={box(NOTES, state.front === 'notes' ? 3 : 1)}
        data-front={state.front === 'notes' || undefined}
      >
        <div className={styles.titlebar} style={{ height: '10%' }}>
          <Dots />
        </div>
        <div className={styles.notes}>
          <div className={styles.folders}>
            <span className={styles.folderHead}>Folders</span>
            <span className={styles.folder} data-on="">
              Shop
            </span>
            <span className={styles.folder}>Suppliers</span>
            <span className={styles.folder}>Ideas</span>
          </div>
          <div className={styles.page}>
            <span className={styles.noteTitle} style={{ top: '6%' }}>
              Saturday
            </span>
            <span className={styles.noteLine} style={{ top: '18%' }}>
              Open at 10, close at 6
            </span>
            <span className={styles.noteLine} style={{ top: '26%' }}>
              Restock waffle cones
            </span>
            <span className={styles.noteLine} style={{ top: '42%' }}>
              {state.note}
              {state.field === 'notes' && <span className={styles.caret} />}
            </span>
          </div>
        </div>
      </div>

      {/* The browser, on the shop's flavours. */}
      <div
        className={styles.window}
        style={box(BROWSER, 2)}
        data-front={state.front === 'browser' || undefined}
      >
        <div className={styles.titlebar} style={{ height: '11%' }}>
          <Dots />
          <span className={styles.address}>scoops.example/flavours</span>
        </div>
        <div className={styles.shop}>
          <span className={styles.shopTitle}>Flavours</span>
          <span className={styles.search} data-focus={state.field === 'search' || undefined}>
            <svg viewBox="0 0 16 16" className={styles.lens}>
              <circle cx="7" cy="7" r="4.6" />
              <path d="M10.4 10.4 14 14" />
            </svg>
            {state.query || <span className={styles.placeholder}>Search flavours</span>}
            {state.field === 'search' && <span className={styles.caret} />}
          </span>
          <div className={styles.grid}>
            {FLAVOURS.map((flavour, index) => (
              <div
                key={flavour.name}
                className={styles.tile}
                data-dim={!matches(flavour.name) || undefined}
                data-hit={(state.searched && matches(flavour.name)) || undefined}
              >
                <span className={styles.scoop} style={{ background: flavour.color }} />
                <span className={styles.flavour}>{flavour.name}</span>
                <span className={styles.price}>{flavour.price}</span>
                <span className={styles.add} data-added={(index === 0 && state.added) || undefined}>
                  {index === 0 && state.added ? 'Added' : 'Add'}
                </span>
              </div>
            ))}
          </div>
        </div>
      </div>

      <div className={styles.widgets}>
        <div className={styles.widget}>
          <span className={styles.widgetHead}>Saturday</span>
          <span className={styles.widgetBig}>12</span>
          <span className={styles.widgetText}>Churn the batch · 9:00</span>
        </div>
        <div className={styles.widget}>
          <span className={styles.widgetHead}>Weather</span>
          <span className={styles.widgetBig}>24°</span>
          <span className={styles.widgetText}>Sunny all afternoon</span>
        </div>
      </div>

      <div className={styles.dock}>
        {DOCK.map((colors, index) => (
          <span
            key={index}
            className={styles.app}
            style={{ background: `linear-gradient(160deg, ${colors[0]}, ${colors[1]})` }}
          >
            <span className={styles.glyph} data-shape={index % 4} />
          </span>
        ))}
      </div>

      {cursor && <AgentCursor state={state} />}
    </div>
  );
}

/** The dock's apps: plain tiles in two-colour gradients, each with a simple white mark. */
const DOCK: [string, string][] = [
  ['#7fb2ff', '#4f7cf0'],
  ['#ffd36e', '#f5a524'],
  ['#8be0b4', '#33b37a'],
  ['#ff9e9e', '#f0566a'],
  ['#c7a8ff', '#8a63f0'],
  ['#9ee7f0', '#3fb4cf'],
  ['#ffb98a', '#f07a3c'],
  ['#d9dde4', '#9aa3b2'],
  ['#f6a8de', '#d65cb0'],
];

/** A window's place on the screen, and its stacking. */
const box = (b: typeof BROWSER, z: number): CSSProperties => ({
  left: `${b.x}%`,
  top: `${b.y}%`,
  width: `${b.w}%`,
  height: `${b.h}%`,
  zIndex: z,
});

function Dots() {
  return (
    <span className={styles.dots}>
      <span />
      <span />
      <span />
    </span>
  );
}

/** The agent's cursor: a layer as big as the screen, moved in percent of it. */
function AgentCursor({ state }: { state: AgentState }) {
  return (
    <div
      className={styles.cursorLayer}
      style={
        {
          transform: `translate(${state.at.x}%, ${state.at.y}%)`,
          '--glide': `${state.glide}ms`,
        } as CSSProperties
      }
    >
      {state.clicks > 0 && <span key={`r${state.clicks}`} className={styles.ripple} />}
      <span
        key={`c${state.clicks}`}
        className={styles.press}
        data-part="cursor"
        data-pressed={state.clicks > 0 || undefined}
      >
        <CursorArt />
      </span>
    </div>
  );
}

/** A dark arrow with a white edge, its tip 5 px in from the top left of its 30 px box. */
export function CursorArt({ className }: { className?: string }) {
  return (
    <svg className={className ?? styles.arrow} width="30" height="30" viewBox="0 0 24 24">
      <path d="M4.6 4.3 19.9 10.5c.5.2.5.9-.1 1.1l-6.2 1.7a1.9 1.9 0 0 0-1.3 1.3l-1.7 6.2c-.2.6-.9.6-1.1.1L3.6 5.4c-.2-.6.4-1.3 1-1.1Z" />
    </svg>
  );
}
```

### agent-screen/scene.module.css

```css
/* The agent's desktop (scene.tsx). It fills the box it is given and is drawn in shares of it:
   places in percent, sizes in cqw (the screen is the container), so the 340 px tile and the 960 px
   window show the same picture. It is a picture of another computer's screen, so its colours are
   its own and stay the same in both schemes. Only the agent's cursor keeps a fixed size, 30 px, as
   a real pointer does. */

.scene {
  position: absolute;
  inset: 0;
  overflow: hidden;
  container-type: inline-size;
  color: #1d1f24;
  font-family: var(--sans, system-ui, sans-serif);
  letter-spacing: 0;
  line-height: 1.25;
  white-space: nowrap;
  user-select: none;
}
.scene span {
  display: block;
}

.wallpaper {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(60% 75% at 12% 92%, #ff8fb1 0%, #ff8fb100 70%),
    radial-gradient(55% 70% at 88% 12%, #84c2ff 0%, #84c2ff00 70%),
    radial-gradient(55% 65% at 85% 95%, #ffbf7d 0%, #ffbf7d00 72%),
    radial-gradient(45% 55% at 40% 10%, #b39dff 0%, #b39dff00 70%),
    linear-gradient(135deg, #9a8cff 0%, #ee9fcb 48%, #ffc89c 100%);
}

/* The menu bar along the top. */
.menubar {
  position: absolute;
  inset: 0 0 auto;
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 1.7cqw;
  height: 2.2cqw;
  padding: 0 1.4cqw;
  background: rgb(255 255 255 / 0.3);
  font-size: 1.05cqw;
  color: rgb(20 22 28 / 0.86);
}
.menubar b {
  font-weight: 650;
}
.menubar .logo {
  width: 1.1cqw;
  height: 1.1cqw;
  border-radius: 0.3cqw;
  background: rgb(20 22 28 / 0.78);
}
.menubar .clock {
  margin-left: auto;
  font-weight: 500;
}

/* A window: white, rounded, lifted off the wallpaper; the one in front a little more. */
.window {
  position: absolute;
  overflow: hidden;
  border-radius: 1cqw;
  background: #fff;
  box-shadow:
    0 0 0 0.08cqw rgb(0 0 0 / 0.1),
    0 0.6cqw 2cqw rgb(30 20 60 / 0.22);
}
.window[data-front] {
  box-shadow:
    0 0 0 0.08cqw rgb(0 0 0 / 0.12),
    0 1cqw 3.2cqw rgb(30 20 60 / 0.32);
}
.titlebar {
  position: absolute;
  inset: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 1cqw;
  padding: 0 1.1cqw;
  background: #f3f3f6;
  box-shadow: inset 0 -0.08cqw #e2e3e9;
}
.dots {
  display: flex !important;
  gap: 0.5cqw;
}
.dots span {
  width: 0.85cqw;
  height: 0.85cqw;
  border-radius: 50%;
  background: #d3d5dc;
}
.window[data-front] .dots span {
  background: #b9bcc6;
}
.address {
  flex: 1;
  height: 58%;
  margin-right: 6%;
  border-radius: 0.6cqw;
  background: #e7e8ed;
  color: #6b6f7a;
  font-size: 0.95cqw;
  line-height: 2.2cqw;
  text-align: center;
}

/* The notes: folders on the left, the page on the right. */
.notes {
  position: absolute;
  inset: 10% 0 0;
  display: flex;
}
.folders {
  display: flex;
  flex-direction: column;
  gap: 0.35cqw;
  width: 28%;
  padding: 1.1cqw 0.8cqw;
  background: #f6f6f8;
  box-shadow: inset -0.08cqw 0 #e8e9ee;
  font-size: 0.95cqw;
  color: #4a4e58;
}
.folderHead {
  margin-bottom: 0.2cqw;
  font-size: 0.8cqw;
  font-weight: 600;
  color: #9a9eaa;
}
.folder {
  padding: 0.3cqw 0.5cqw;
  border-radius: 0.4cqw;
}
.folder[data-on] {
  background: #fde7a8;
  color: #1d1f24;
}
.page {
  position: relative;
  flex: 1;
}
.noteTitle,
.noteLine {
  position: absolute;
  left: 6%;
}
.noteTitle {
  font-size: 1.55cqw;
  font-weight: 650;
}
.noteLine {
  display: flex !important;
  align-items: center;
  min-height: 1.4cqw;
  font-size: 1.1cqw;
  color: #3a3d45;
}

/* The shop, in the browser. */
.shopTitle {
  position: absolute;
  left: 5%;
  top: 15.5%;
  font-size: 1.75cqw;
  font-weight: 650;
}
.shop {
  position: absolute;
  inset: 0;
}
.search {
  position: absolute;
  left: 56%;
  top: 16.5%;
  display: flex !important;
  align-items: center;
  gap: 0.5cqw;
  box-sizing: border-box;
  width: 39%;
  height: 9%;
  padding: 0 1cqw;
  border-radius: 99px;
  background: #f1f2f5;
  box-shadow: inset 0 0 0 0.08cqw #e0e2e8;
  font-size: 1.05cqw;
  transition: box-shadow 0.15s ease;
}
.search[data-focus] {
  background: #fff;
  box-shadow:
    inset 0 0 0 0.1cqw #4f7cf0,
    0 0 0 0.3cqw rgb(79 124 240 / 0.22);
}
.lens {
  flex: none;
  width: 1.1cqw;
  height: 1.1cqw;
  fill: none;
  stroke: #8a8e99;
  stroke-width: 1.8;
  stroke-linecap: round;
}
.placeholder {
  color: #9a9ea9;
}
.grid {
  position: absolute;
  inset: 31% 5% 4%;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: repeat(2, 1fr);
  gap: 1.4cqw;
}
.tile {
  position: relative;
  border-radius: 0.8cqw;
  background: #fafafb;
  box-shadow: inset 0 0 0 0.08cqw #e9eaef;
  transition:
    opacity 0.25s ease,
    box-shadow 0.25s ease;
}
.tile[data-dim] {
  opacity: 0.3;
}
.tile[data-hit] {
  box-shadow: inset 0 0 0 0.15cqw #4f7cf0;
}
.scoop {
  position: absolute;
  left: 0.9cqw;
  top: 0.9cqw;
  width: 4.2cqw;
  height: 4.2cqw;
  border-radius: 50%;
  box-shadow: inset 0 -0.4cqw 0 rgb(0 0 0 / 0.08);
}
.flavour {
  position: absolute;
  left: 0.9cqw;
  bottom: 3.1cqw;
  font-size: 1.05cqw;
  font-weight: 600;
}
.price {
  position: absolute;
  left: 0.9cqw;
  bottom: 1.1cqw;
  font-size: 0.95cqw;
  color: #7a7e88;
}
.add {
  position: absolute;
  right: 0.8cqw;
  bottom: 0.8cqw;
  height: 2cqw;
  padding: 0 1cqw;
  border-radius: 99px;
  background: #1d1f24;
  color: #fff;
  font-size: 0.9cqw;
  font-weight: 600;
  line-height: 2cqw;
  transition: background-color 0.2s ease;
}
.add[data-added] {
  background: #2fae74;
}

/* The widgets on the right. */
.widgets {
  position: absolute;
  left: 81%;
  top: 7%;
  display: flex;
  flex-direction: column;
  gap: 1cqw;
  width: 17%;
}
.widget {
  padding: 1cqw 1.1cqw;
  border-radius: 1.2cqw;
  background: rgb(255 255 255 / 0.62);
  box-shadow: 0 0.3cqw 1.2cqw rgb(30 20 60 / 0.12);
}
.widgetHead {
  font-size: 0.85cqw;
  font-weight: 600;
  color: #e0465e;
}
.widget + .widget .widgetHead {
  color: #3f74e0;
}
.widgetBig {
  font-size: 2.6cqw;
  font-weight: 600;
  line-height: 1.15;
}
.widgetText {
  overflow: hidden;
  font-size: 0.85cqw;
  color: #4a4e58;
  text-overflow: ellipsis;
}

/* The dock. */
.dock {
  position: absolute;
  left: 50%;
  top: 89.5%;
  display: flex;
  gap: 0.75cqw;
  height: 8%;
  padding: 0.6cqw;
  box-sizing: border-box;
  border-radius: 1.6cqw;
  background: rgb(255 255 255 / 0.36);
  box-shadow: inset 0 0 0 0.08cqw rgb(255 255 255 / 0.5);
  transform: translateX(-50%);
}
.app {
  position: relative;
  height: 100%;
  aspect-ratio: 1;
  border-radius: 1.1cqw;
  box-shadow: inset 0 -0.15cqw rgb(0 0 0 / 0.1);
}
.glyph {
  position: absolute;
  inset: 30%;
  background: rgb(255 255 255 / 0.92);
}
.glyph[data-shape='0'] {
  border-radius: 50%;
}
.glyph[data-shape='1'] {
  border-radius: 0.3cqw;
}
.glyph[data-shape='2'] {
  inset: 32% 24%;
  border-radius: 99px;
}
.glyph[data-shape='3'] {
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

/* The caret where the agent types. */
.caret {
  display: inline-block !important;
  width: max(1px, 0.1cqw);
  height: 1.2em;
  margin-left: 0.1cqw;
  background: #4f7cf0;
  vertical-align: middle;
  animation: blink 1s steps(1) infinite;
}
@keyframes blink {
  50% {
    opacity: 0;
  }
}

/* The agent's cursor: a layer the screen's size, moved in percent of it, so the arrow at its top
   left lands on the point. The arrow's tip sits 5 px into its box, hence the box's offset. */
.cursorLayer {
  position: absolute;
  inset: 0;
  z-index: 10;
  pointer-events: none;
  transition: transform var(--glide, 0ms) cubic-bezier(0.45, 0, 0.2, 1);
  will-change: transform;
}
.press {
  position: absolute;
  left: -5px;
  top: -5px;
  width: 30px;
  height: 30px;
  transform-origin: 5px 5px;
}
.press[data-pressed] {
  animation: press 280ms ease-in-out;
}
@keyframes press {
  35% {
    transform: scale(0.82);
  }
}
.arrow {
  display: block;
  fill: #111318;
  stroke: #fff;
  stroke-width: 1.4;
  stroke-linejoin: round;
  filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.35));
}
.ripple {
  position: absolute;
  left: -13px;
  top: -13px;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: rgb(79 124 240 / 0.28);
  box-shadow: 0 0 0 2px rgb(255 255 255 / 0.85);
  opacity: 0;
  animation: ripple 520ms ease-out;
}
@keyframes ripple {
  from {
    opacity: 1;
    transform: scale(0.3);
  }
  to {
    opacity: 0;
    transform: scale(1.4);
  }
}

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

### agent-screen/demo-pointer.tsx

```tsx
'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, glide
 * the pointer onto a control, press it and call the component's own handler, as a click would.
 * 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.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

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

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

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

/** Where the arrow's tip is, in its 22 × 22 box: the point the pointer acts on. */
const TIP: Point = { x: 3.3, y: 2.2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** 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;
  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.86, y: stage.offsetHeight * 0.96 };

  /** 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);
      }),
    );

  /** Glides to `to`, in the stage's own px. */
  const glide = (to: Point) => {
    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.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          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,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen px to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return glide({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).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. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x}px ${TIP.y}px`,
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's colours, like its shadow's, are the stage's tokens (tool-chips.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the controls. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <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>
  );
}
```

### agent-screen/use-autoplay.ts

```tsx
'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. `visible` and `reduced` are there for any other
 * work that should only run on screen, or only with motion.
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```