# World status

> The progress widget of a world generator: a card with a live WebGL terrain that folds into an orb of liquid colour.

The status of a tool that builds 3D worlds from a sentence. Open, a card lists the build’s steps beside a terrain that rises out of the sea while colour flows up its slopes; hidden, it folds into a gooey orb that keeps the percentage beside the composer. Send an idea and a new world is built, and the same idea always builds the same world.

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

Category: Feedback.

## Anatomy

1. **Title**: The phase in words: `Reading your idea…`, `Building your world…`, `Your world is ready`. A sheen runs along it until the world is ready.
2. **Hide**: Folds the card into the compact card, with `aria-expanded` on it; the focus moves to `Expand`, which replaces it. Narrow, the chevron alone.
3. **Idea**: The sentence the world is built from, in quotes, cut with an ellipsis when it runs long.
4. **Terrain**: A WebGL shader: the land rises out of the sea and colour flows up the mountain as the build goes. The idea’s words decide its shape, so the same idea builds the same world.
5. **Percentage**: A real `role="progressbar"`, 0 to 100, with a striped ball that rolls while it builds. The same element glides into the orb when the card folds.
6. **Step counter**: The step out of how many, with a dot per step; a screen reader hears “Step 2 of 3”.
7. **Current step**: One row of the ordered list of steps, marked `aria-current="step"`, beside a rail that fills down to it.
8. **Composer**: Where the next idea is typed: images to add, the level of detail and voice. Enter or the arrow sends it, and a new world starts.

## When to use

- A long generation (a 3D scene, a video, a big render) that runs for seconds to minutes, where a picture of the result taking shape says more than a bar.
- Work that goes through a few named steps, two to five, worth reading as they pass: `Raising the land`, `Carving the rivers`.
- A build the reader should be free to put aside: `Hide` folds the card into a small orb that keeps the percentage and the step, beside the composer.
- A creative tool where the next idea is typed while the last one finishes, so the status and the composer belong together.

## When not to use

- Short waits, under two or three seconds. A spinner or a pressed button’s own busy state is enough.
- Uploads, downloads or anything measured in bytes. A plain progress bar with sizes and time left reads faster.
- Several jobs at once. Use a list of rows, each with its own small progress, rather than one card per job.
- Pages that can’t spare WebGL: each instance holds two contexts. Without WebGL it falls back to still sketches, so don’t pick it for those.
- Steps with no real meaning behind them. Made-up stages read as theatre; show a percentage alone instead.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/world-status.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 { WorldStatus } from './world-status';

// The status card, its composer and a demo that plays while it is on screen.
// The steps, the ideas it types and the levels of detail are in worlds.ts.
export function BuildPanel() {
  return <WorldStatus duration={15000} />;
}
```

## States

- **Thinking**: Just after an idea is sent: the last world sinks back into the sea, at 0%, and no step is lit. The title’s sheen and the ball keep moving.
- **Building**: Held at 52%, in its second step: done steps get a check, the current one a filled mark.
- **Folded**: After `Hide`: the compact card beside the composer (above it when narrow), the percentage and the step in the orb’s two circles.
- **Ready**: Every step checked, 100%, and the flow of colour settled to a stop. Nothing moves.

## How it works

- The terrain is a grid of triangles that a vertex shader lifts into a mountain and two hills: soft cones placed by a seed (a hash of the prompt), roughened by ridged noise. Land below sea level is discarded, so the peaks rise out of the card as the build progresses.
- Its fragment shader draws a topographic model. Contour lines stay a pixel and a half wide on any slope, measured with screen-space derivatives. On the mountain, bands of blue, cream and orange are indexed by height minus time, so they flow up the slopes, up to a front of colour that climbs with the build.
- The orb is one fragment shader over the compact card. Two circles are melted together by a smooth minimum of their distances, which grows the gooey neck between them, and filled with noise warped twice by noise so it marbles, with a pale rim and a faint contour map behind.
- The fold is CSS. The card’s background is one rectangle clipped by an inset() clip-path that transitions between the two cards; the percentage and the step counter are the same elements in both and glide by transform. The terrain shrinks into the orb while the orb’s small circle comes out of the big one, on the same easing curve.
- One requestAnimationFrame loop advances the build and draws, only while the widget is near the screen and something moves. The canvases cap the pixel ratio at 2 and give their pixels back off screen. Without WebGL or JavaScript, CSS sketches stand in.
- Every colour is a light-dark() token on the root, so the widget follows the page’s color-scheme, or the colorScheme prop: a white card and composer edged with a hairline by day, dark ones at night, on the same light stage. The terrain keeps its own colours; the orb’s shader reads its rim, the inside of its small circle and its map lines from the tokens, again when the root’s unseen colour transition ends.

## API reference

- **steps** (`readonly string[]`, default `STEPS`): The steps of a build, in order: two to five short labels (three in worlds.ts). The card grows with them.
- **duration** (`number`, default `12000`): How long a world takes to build at the Balanced level of detail, in ms. Draft takes 0.7 of it, Detailed 1.4.
- **defaultOpen** (`boolean`, default `true`): Whether it starts open, as the card, or folded into the orb.
- **defaultState** (`'thinking' | 'building' | 'ready'`): Opens in this phase of the first build, held still there with the demo off until the reader sends an idea.
- **defaultProgress** (`number`): The percentage, 0 to 99, to hold the build at; it implies defaultState 'building'.
- **defaultValue** (`string`): An idea already typed in the composer. Turns the demo off.
- **defaultLevel** (`'draft' | 'balanced' | 'detailed'`): The level of detail picked in the composer at first ('balanced' when left out). Given, it turns the demo off.
- **onSend** (`(idea: string) => void`): Called with the idea when the reader sends one, as a new world starts building.
- **onOpenChange** (`(open: boolean) => void`): Called when the card folds into the orb (false) or opens out of it (true).
- **onLevelChange** (`(level: LevelId) => void`): Called when the reader picks another level of detail.
- **autoplay** (`boolean`, default `true`): Fold the card mid-build, then type and send the next idea, while visible and untouched.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a light stage of its own.

## Accessibility

- Hide and Expand are buttons with aria-expanded. The folded side is inert, and when the pressed toggle disappears, focus moves to the one that replaces it.
- The percentage is a progressbar and the counter reads “Step 2 of 3”. The current step has aria-current="step", and finished steps say they are done.
- A build the reader starts is announced in a polite live region, step by step and when the world is ready. The demo’s own builds stay quiet, and the demo never types into the field while the reader is in it.
- Under reduced motion there is no demo and no fold animation. A build still progresses, with its terrain and orb drawn as still frames at each step.

## Source

### world-status/world-status.tsx

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

'use client';

import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import {
  FOLD,
  createBuilder,
  progressAt,
  statusAt,
  type Builder,
  type Phase,
  type Status,
} from './builder';
import { Composer } from './composer';
import { CheckIcon, ChevronIcon } from './icons';
import { ORB } from './orb';
import { useAutoplay } from './use-autoplay';
import { FIRST_IDEA, IDEAS, LEVELS, STEPS, worldOf, type World } from './worlds';
import styles from './world-status.module.css';

/*
 * World status: the progress widget of a tool that builds 3D worlds from a sentence. Open, it is a
 * card that lists the build's steps beside a live terrain, rising out of the sea as the build
 * goes; hidden, it folds into an orb of liquid colour that holds the percentage, beside the
 * composer.
 *
 * - The terrain and the orb are WebGL shaders (terrain.ts, orb.ts). The build's clock and the one
 *   animation frame loop that draws them, only while something moves, are in builder.ts.
 * - The fold is CSS. The card's background is one rectangle clipped by an inset() clip-path that
 *   transitions from the card to the compact card; the percentage and the step counter are the
 *   same two elements in both states, gliding from the card's header into the orb; the terrain
 *   shrinks into the orb as the orb's small circle comes out of the big one.
 * - Sending an idea from the composer builds a new world: a moment of thought while the last one
 *   sinks, then the steps.
 * - While on screen and untouched, the demo plays itself: it folds the card mid-build, opens it
 *   again for the end, and once the world is ready, types the next idea and sends it.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the widget follows the
 *   page's color-scheme, or the one the colorScheme prop sets: a white card and composer, or dark
 *   ones. The terrain keeps its own colours; the orb's shader reads the colours that change from
 *   the computed style, again when the scheme changes (the root's unseen colour transition ends).
 * - defaultState (and defaultProgress) open it in one phase of a build, held still there with the
 *   demo off, until the reader sends an idea; defaultValue and defaultLevel fill the composer.
 */

/** How far along the first world is when the widget appears, as a share of its build. */
const START = 0.36;

const TITLES = {
  thinking: 'Reading your idea…',
  building: 'Building your world…',
  ready: 'Your world is ready',
};

/** The levels of detail, by id. */
export type LevelId = 'draft' | 'balanced' | 'detailed';

/** The status a widget opens on: the default's, or the phase and percentage it is given. */
function firstStatus(count: number, phase?: Phase, percent?: number): Status {
  if (phase === 'thinking') return { phase, percent: 0, step: -1 };
  if (phase === 'ready') return { phase, percent: 100, step: count };
  return statusAt(firstProgress(count, percent), count);
}
const firstProgress = (count: number, percent?: number) =>
  percent === undefined ? progressAt(START, count) : Math.min(0.99, Math.max(0, percent / 100));

export function WorldStatus({
  className = '',
  steps = STEPS,
  duration = 12000,
  defaultOpen = true,
  autoplay = true,
  colorScheme,
  defaultState,
  defaultProgress,
  defaultValue,
  defaultLevel,
  onSend,
  onOpenChange,
  onLevelChange,
}: {
  className?: string;
  /** The steps of a build, in order: two to five short labels. */
  steps?: readonly string[];
  /** How long a world takes to build at the Balanced level of detail, in ms. */
  duration?: number;
  /** Whether it starts open, as the card, or folded into the orb. */
  defaultOpen?: boolean;
  /** Play the demonstration while visible and untouched; false leaves it all to the reader. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens in this phase of the first build, held still there (and the demo off) until the reader
   * sends an idea: thinking, building (at defaultProgress) or ready.
   */
  defaultState?: Phase;
  /** With defaultState 'building' (which it implies), the percentage to hold the build at, 0–99. */
  defaultProgress?: number;
  /** An idea already typed in the composer (the demo off). */
  defaultValue?: string;
  /** The level of detail picked in the composer (the demo off); 'balanced' when left out. */
  defaultLevel?: LevelId;
  /** Called with the idea when the reader sends one, as a new world starts building. */
  onSend?: (idea: string) => void;
  /** Called when the card folds into the orb (false) or opens out of it (true). */
  onOpenChange?: (open: boolean) => void;
  /** Called when the reader picks another level of detail. */
  onLevelChange?: (level: LevelId) => void;
}) {
  const count = steps.length;
  const { ref: root, playing: unattended, take } = useAutoplay<HTMLDivElement>(8000);
  /** Opened in a given phase: the build is held there. */
  const phase0 = defaultState ?? (defaultProgress === undefined ? undefined : 'building');
  // The demonstration plays only when allowed, on screen and untouched, and not when the widget
  // opens in a given state.
  const playing =
    autoplay &&
    phase0 === undefined &&
    defaultValue === undefined &&
    defaultLevel === undefined &&
    unattended;
  const [open, setOpen] = useState(defaultOpen);
  const [world, setWorld] = useState<World>(() => worldOf(FIRST_IDEA));
  const [status, setStatus] = useState<Status>(() => firstStatus(count, phase0, defaultProgress));
  const [draft, setDraft] = useState(defaultValue ?? '');
  const [level, setLevel] = useState(
    () => LEVELS.find((one) => one.id === defaultLevel) ?? LEVELS[1],
  );
  const [images, setImages] = useState(0);
  const [listening, setListening] = useState(false);
  /** Near the screen with the tab visible: the CSS animations run only then. */
  const [live, setLive] = useState(false);
  /** No WebGL: still sketches stand in for the terrain and the orb. */
  const [flat, setFlat] = useState(false);
  const [message, setMessage] = useState('');
  const panelId = useId();
  const activityId = useId();

  const terrainBox = useRef<HTMLDivElement>(null);
  const orbBox = useRef<HTMLSpanElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const hideButton = useRef<HTMLButtonElement>(null);
  const expandButton = useRef<HTMLButtonElement>(null);
  const builder = useRef<Builder | null>(null);
  /** The latest props and state, for the builder's callbacks and the demo. */
  const latest = useRef({ steps, duration, level, draft, open, onSend, onOpenChange });
  /** A build the reader started: its steps are announced (the demo's are not). */
  const announced = useRef(false);
  /** The focus is on the part about to hide: it moves to the toggle that replaces it. */
  const moveFocus = useRef(false);
  const nextIdea = useRef(0);

  useEffect(() => {
    latest.current = { steps, duration, level, draft, open, onSend, onOpenChange };
  });

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  /** Builds a world from `text`: the reader's idea, or the demo's. */
  const send = (text: string, byReader: boolean) => {
    const prompt = text.trim();
    if (!prompt) return;
    const next = worldOf(prompt);
    const { duration: ms, level: detail } = latest.current;
    setWorld(next);
    setDraft('');
    setImages(0);
    announced.current = byReader;
    if (byReader) announce(`Building a new world: ${prompt}.`);
    if (byReader) latest.current.onSend?.(prompt);
    builder.current?.build({ seed: next.seed, ms: ms * detail.pace, lines: detail.lines });
  };

  /** Opens the card or folds it, by the reader's hand or the demo's. */
  const toggle = (next: boolean) => {
    if (next === latest.current.open) return;
    const hiding = next ? expandButton.current : panel.current;
    moveFocus.current = !!hiding?.contains(document.activeElement);
    setOpen(next);
    latest.current.onOpenChange?.(next);
  };

  // The builder, its canvases, and what it needs to know: whether the widget is near the screen,
  // and whether motion is reduced. Made once (again if the number of steps changes): later worlds
  // come in through build().
  useEffect(() => {
    const element = root.current;
    const terrainHost = terrainBox.current;
    const orbHost = orbBox.current;
    if (!element || !terrainHost || !orbHost) return;
    // Fresh canvases for each builder: a canvas whose WebGL context was released gets no other.
    const terrain = document.createElement('canvas');
    const orb = document.createElement('canvas');
    terrain.className = styles.canvas;
    orb.className = styles.canvas;
    terrainHost.prepend(terrain);
    orbHost.prepend(orb);

    const { duration: ms, level: detail } = latest.current;
    let lastStep = firstStatus(count, phase0, defaultProgress).step;
    const made = createBuilder({
      terrain,
      orb,
      steps: count,
      seed: worldOf(FIRST_IDEA).seed,
      duration: ms * detail.pace,
      lines: detail.lines,
      start: START,
      phase: phase0,
      progress: firstProgress(count, defaultProgress),
      hold: phase0 !== undefined,
      open,
      onChange: (next) => {
        setStatus(next);
        const stepped = next.step !== lastStep;
        lastStep = next.step;
        if (!announced.current || !stepped) return;
        if (next.phase === 'ready') announce('Your world is ready.');
        else if (next.phase === 'building') {
          const label = latest.current.steps[next.step].toLowerCase();
          announce(`Step ${next.step + 1} of ${count}: ${label}.`);
        }
      },
    });
    builder.current = made;
    setFlat(!made.supported);

    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => made.setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);

    // A change of colour scheme ends the root's unseen colour transition: the orb is recoloured.
    const onScheme = (event: TransitionEvent) => {
      if (event.target === element && event.propertyName === 'color') made.recolour();
    };
    element.addEventListener('transitionend', onScheme);

    let onScreen = false;
    const update = () => {
      const near = onScreen && !document.hidden;
      made.setNear(near);
      setLive(near);
    };
    // A little ahead of the screen, so the canvases are drawn before they show.
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { rootMargin: '120px 0px' },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);

    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
      element.removeEventListener('transitionend', onScheme);
      made.dispose();
      terrain.remove();
      orb.remove();
      builder.current = null;
    };
    // The first world only: `open` and the rest are read once, then follow through the builder.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [count]);

  useEffect(() => {
    builder.current?.setOpen(open);
    if (!moveFocus.current) return;
    moveFocus.current = false;
    (open ? hideButton : expandButton).current?.focus({ preventScroll: true });
  }, [open]);

  // The demo: while on screen and untouched, it folds the card into the orb mid-build, opens it
  // again for the end, and once the world is ready, types the next idea and sends it.
  useEffect(() => {
    if (!playing) return;
    let alive = true;
    let timer = 0;
    let typing = false;
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        timer = window.setTimeout(resolve, ms);
      });
    /** Checks `test` a few times a second until it passes, or the demo stops. */
    const until = async (test: () => boolean) => {
      while (alive && !test()) await wait(150);
    };
    const state = () => builder.current?.state() ?? { phase: 'ready', progress: 1 };
    const building = () => state().phase === 'building';
    /** The field is the reader's while it has the focus, and while it holds their words. */
    const fieldFree = () => {
      const active = document.activeElement;
      const focused = active instanceof HTMLInputElement && !!root.current?.contains(active);
      return !focused && !latest.current.draft;
    };

    (async () => {
      while (alive) {
        // A few seconds open, a few folded, then open for the end of the build.
        await until(() => state().phase !== 'thinking');
        await wait(3500);
        if (alive && building() && state().progress < 0.75) {
          toggle(false);
          await wait(3500);
        }
        if (!alive) return;
        toggle(true);
        await until(() => state().phase === 'ready');
        await wait(2600);
        await until(fieldFree);
        if (!alive) return;
        const idea = IDEAS[nextIdea.current++ % IDEAS.length];
        typing = true;
        for (let length = 1; length <= idea.length && alive; length++) {
          setDraft(idea.slice(0, length));
          await wait(35 + Math.random() * 45);
        }
        if (!alive) return;
        await wait(450);
        if (!alive) return;
        typing = false;
        send(idea, false);
      }
    })();

    return () => {
      alive = false;
      window.clearTimeout(timer);
      // An idea half typed by the demo goes when the reader takes over.
      if (typing) setDraft('');
    };
    // `send` and `toggle` read refs and set state only, so `playing` is all it depends on.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [playing]);

  const { phase, percent, step } = status;
  /** The counter's step, and how many of its dots are lit. */
  const shown = phase === 'thinking' ? 1 : Math.min(count, step + 1);
  const lit = phase === 'thinking' ? 0 : shown;
  /** The rail beside the steps, filled down to the current one. */
  const fill = Math.min(1, ((percent / 100) * count) / (count - 1));

  const geometry = {
    '--fold': `${FOLD}ms`,
    '--big-x': `${ORB.big.x}px`,
    '--big-r': `${ORB.big.radius}px`,
    '--small-x': `${ORB.small.x}px`,
    '--small-r': `${ORB.small.radius}px`,
    '--orb-y': `${ORB.big.y}px`,
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      style={geometry}
      role="group"
      aria-label="World builder"
      data-color-scheme={colorScheme}
      data-live={live || undefined}
      data-flat={flat || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.widget}>
        <div
          className={styles.frame}
          style={{ '--steps': count } as CSSProperties}
          data-open={open}
          data-phase={phase}
        >
          <div className={styles.cardBack} aria-hidden="true" />
          <div ref={terrainBox} className={styles.terrain} aria-hidden="true" data-part="terrain">
            <span className={styles.sketch} />
          </div>

          <div ref={panel} id={panelId} className={styles.panel} inert={!open} data-part="card">
            <div className={styles.head}>
              <p className={styles.title} data-part="title">
                <span className={styles.titleText}>{TITLES[phase]}</span>
              </p>
              <button
                ref={hideButton}
                type="button"
                className={styles.hide}
                data-part="hide"
                aria-expanded="true"
                aria-controls={panelId}
                onClick={() => toggle(false)}
              >
                <span className={styles.hideLabel}>Hide</span>
                <ChevronIcon />
              </button>
            </div>
            <p className={styles.caption} data-part="idea">
              “{world.prompt}”
            </p>
            <div className={styles.activity}>
              <p id={activityId} className={styles.activityLabel}>
                Activity
              </p>
              <ol
                className={styles.steps}
                data-part="steps"
                aria-labelledby={activityId}
                style={{ '--fill': fill } as CSSProperties}
              >
                {steps.map((label, index) => {
                  const state =
                    phase === 'ready' || index < step
                      ? 'done'
                      : index === step
                        ? 'current'
                        : 'next';
                  return (
                    <li
                      key={label}
                      className={styles.step}
                      data-part="step"
                      data-state={state}
                      aria-current={state === 'current' ? 'step' : undefined}
                    >
                      <span className={styles.mark} aria-hidden="true">
                        {state === 'done' ? <CheckIcon /> : index + 1}
                      </span>
                      <span className={styles.stepLabel}>
                        {label}
                        {state === 'current' && '…'}
                      </span>
                      {state === 'done' && <span className={styles.hidden}>, done</span>}
                    </li>
                  );
                })}
              </ol>
            </div>
          </div>

          <div
            className={styles.pill}
            data-part="progress"
            role="progressbar"
            aria-label="Build progress"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={percent}
          >
            <span className={styles.globe} aria-hidden="true" />
            {percent}%
          </div>
          <div className={styles.counter} data-part="counter">
            <span aria-hidden="true">
              {shown}/{count}
            </span>
            <span className={styles.hidden}>
              Step {shown} of {count}
            </span>
            <span className={styles.dots} aria-hidden="true">
              {steps.map((label, index) => (
                <span key={label} data-lit={index < lit || undefined} />
              ))}
            </span>
          </div>

          <button
            ref={expandButton}
            type="button"
            className={styles.mini}
            data-part="compact"
            inert={open}
            aria-expanded="false"
            aria-controls={panelId}
            onClick={() => toggle(true)}
          >
            <span ref={orbBox} className={styles.orb} aria-hidden="true" data-part="orb">
              <span className={styles.orbSketch} />
            </span>
            <span className={styles.typing} aria-hidden="true">
              <span />
              <span />
              <span />
            </span>
            <span className={styles.expand}>
              Expand<span className={styles.hidden}> the build status</span>
              <ChevronIcon />
            </span>
          </button>

          <Composer
            value={draft}
            onChange={setDraft}
            onSend={() => send(draft, true)}
            level={level}
            onLevel={(next) => {
              setLevel(next);
              onLevelChange?.(next.id as LevelId);
            }}
            images={images}
            onImages={setImages}
            listening={listening}
            onListen={() => setListening((now) => !now)}
          />
        </div>
      </div>

      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### world-status/world-status.module.css

```css
/* World status (world-status.tsx): a light stage with the widget in its middle, a card over the
   composer that folds into a compact card. Every size and place is a custom property on the
   frame, set for the narrow layout and again from 600px (a container query on the widget's own
   width), so the layout and the fold between its two states are plain CSS transitions.

   Colours are the tokens below, light-dark(light, dark): the widget 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). By day the card and the composer are white, edged with a hairline; at
   night they are dark. The stage is the same on both, and so is the terrain, a model in its own
   colours. The orb's rim, the inside of its small circle and the map behind it are tokens that
   its shader reads (orb.ts). */

.root {
  --stage: #fcfcfc;
  /* The card and the composer, their edge (a hairline by day), and what is raised on them: the
     percentage, Add images, a button under the pointer. */
  --card: #fff;
  --card-edge: rgb(0 0 0 / 0.1);
  --raised: #f1f1f3;
  /* The words: titles and values, then labels, then the next steps, captions and placeholder. */
  --ink: #18181b;
  --muted: #52525b;
  --faint: #71717a;
  /* The title's passing sheen while a world builds, and the composer's rule and divider. */
  --sheen: #a1a1aa;
  --line: rgb(0 0 0 / 0.09);
  /* The steps' rail, and the part of it that is done. */
  --rail: rgb(0 0 0 / 0.1);
  --rail-done: rgb(0 0 0 / 0.42);
  /* A step's mark: next (its ring), current, and done. */
  --mark-edge: #b8b8bf;
  --mark-current: #18181b;
  --mark-current-ink: #fff;
  --mark-done: #e4e4e7;
  --mark-done-ink: #3f3f46;
  /* The percentage in the orb, the striped ball beside it, and the counter's dots. */
  --pill-orb: #fff;
  --ball: #27272a;
  --ball-stripe: rgb(255 255 255 / 0.85);
  --dot: #b0b0b8;
  --dot-lit: #18181b;
  /* The orb, as its shader paints it: the faint map behind it, its rim, and the inside of its
     small circle, where the counter sits. The dark values are the shader's own numbers. */
  --orb-map: rgb(0 0 0 / 0.06);
  --orb-rim: color(srgb 0.8 0.79 0.9);
  --orb-hole: color(srgb 0.98 0.98 0.98);
  /* The still orb without WebGL: the inside of its small circle. */
  --sketch-hole: #fafafa;
  /* Send: on, and off (nothing to send); Voice input's ring while it listens. */
  --send: #18181b;
  --send-ink: #fff;
  --send-off: #e4e4e7;
  --send-off-ink: #a1a1aa;
  --listen-ring: rgb(0 0 0 / 0.3);
  /* The focus rings, and the composer's while its field has the focus. */
  --focus: #2f6fe4;
  --field-ring: rgb(47 111 228 / 0.75);
  --gap: 8px;
  --radius: 18px;
  --ease: cubic-bezier(0.2, 0, 0, 1);
  padding: clamp(24px, 6%, 52px) clamp(12px, 4%, 32px);
  border-radius: 18px;
  background:
    radial-gradient(rgb(0 0 0 / 0.07) 1px, transparent 1.5px) 0 0 / 16px 16px,
    var(--stage);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.06);
  color: var(--ink);
  /* A change of colour scheme runs this unseen transition, which tells the orb to read its colours
     again. Nothing moves, so it stays under reduced motion, even where a page turns every
     transition off (hence !important). */
  transition: color 1ms !important;
  font:
    400 14px/1.4 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --card: light-dark(#fff, #19191c);
    --card-edge: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0));
    --raised: light-dark(#f1f1f3, #29292e);
    --ink: light-dark(#18181b, #f4f4f5);
    --muted: light-dark(#52525b, #a1a1aa);
    --sheen: light-dark(#a1a1aa, #6f6f78);
    --line: light-dark(rgb(0 0 0 / 0.09), rgb(255 255 255 / 0.09));
    --rail: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1));
    --rail-done: light-dark(rgb(0 0 0 / 0.42), rgb(255 255 255 / 0.5));
    --mark-edge: light-dark(#b8b8bf, #4a4a52);
    --mark-current: light-dark(#18181b, #fff);
    --mark-current-ink: light-dark(#fff, #111);
    --mark-done: light-dark(#e4e4e7, #36363c);
    --mark-done-ink: light-dark(#3f3f46, #e4e4e7);
    --pill-orb: light-dark(#fff, #0f0f11);
    --ball: light-dark(#27272a, #fff);
    --ball-stripe: light-dark(rgb(255 255 255 / 0.85), rgb(0 0 0 / 0.78));
    --dot: light-dark(#b0b0b8, #55555d);
    --dot-lit: light-dark(#18181b, #fff);
    --orb-map: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.05));
    --orb-rim: light-dark(color(srgb 0.8 0.79 0.9), color(srgb 0.86 0.85 0.93));
    --orb-hole: light-dark(color(srgb 0.98 0.98 0.98), color(srgb 0.08 0.08 0.09));
    --sketch-hole: light-dark(#fafafa, #141416);
    --send: light-dark(#18181b, #fff);
    --send-ink: light-dark(#fff, #111);
    --send-off: light-dark(#e4e4e7, #3a3a40);
    --send-off-ink: light-dark(#a1a1aa, #85858d);
    --listen-ring: light-dark(rgb(0 0 0 / 0.3), rgb(255 255 255 / 0.35));
    --focus: light-dark(#2f6fe4, #9ec1ff);
    --field-ring: light-dark(rgb(47 111 228 / 0.75), rgb(158 193 255 / 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;
}
/* :where() keeps this reset at one class's weight, so the buttons' own colours (.send, .mic,
   .hide), written later, win. */
.root :where(button, input, select) {
  font: inherit;
  color: inherit;
}

.widget {
  container: widget / inline-size;
  width: min(100%, 640px);
  margin: 0 auto;
}

/* The places, narrow: the terrain under the title, the steps under it, the percentage and the
   counter on the "Activity" line, and the compact card over the composer's left end. */
.frame {
  --row: 24px;
  --card-h: calc(228px + var(--steps) * var(--row));
  --composer-h: 112px;
  --mini-w: 164px;
  --mini-h: 112px;
  --mini-top: calc(var(--card-h) - var(--mini-h));
  --activity-top: calc(var(--card-h) - 38px - var(--steps) * var(--row));
  /* The terrain's box: its top, its height, and its centre's distance from the left. */
  --terrain-top: 68px;
  --terrain-h: 116px;
  --terrain-x: 50cqw;
  /* How far the composer's left end moves aside for the compact card; it moves back once the
     card has mostly left its place, and aside at once to make room for it. */
  --shift: 0px;
  --slide: calc(var(--fold) * 0.6) var(--ease) calc(var(--fold) * 0.4);
  --pill-x: calc(100cqw - 90px);
  --pill-y: calc(var(--activity-top) + 13px);
  --counter-x: calc(100cqw - 31px);
  --counter-y: var(--pill-y);
  position: relative;
  height: calc(var(--card-h) + var(--gap) + var(--composer-h));
}
/* Folded: the percentage and the counter in the orb's two circles. */
.frame[data-open='false'] {
  --slide: calc(var(--fold) * 0.6) var(--ease);
  --pill-x: var(--big-x);
  --pill-y: calc(var(--mini-top) + var(--orb-y));
  --counter-x: var(--small-x);
}

/* The card's edge: the same rectangle a pixel bigger all round, clipped to the card or the compact
   card a pixel bigger, under the card's background. */
.frame::before {
  content: '';
  position: absolute;
  inset: -1px;
  background: var(--card-edge);
  clip-path: inset(0 0 calc(100% - var(--card-h) - 2px) 0 round calc(var(--radius) + 1px));
  transition: clip-path var(--fold) var(--ease);
}
.frame[data-open='false']::before {
  clip-path: inset(
    var(--mini-top) calc(100% - var(--mini-w) - 2px)
      calc(100% - var(--mini-top) - var(--mini-h) - 2px) 0 round calc(var(--radius) + 1px)
  );
}
/* The card's background: one rectangle, clipped to the card or to the compact card. */
.cardBack {
  position: absolute;
  inset: 0;
  background: var(--card);
  clip-path: inset(0 0 calc(100% - var(--card-h)) 0 round var(--radius));
  transition: clip-path var(--fold) var(--ease);
}
.frame[data-open='false'] .cardBack {
  clip-path: inset(
    var(--mini-top) calc(100% - var(--mini-w)) calc(100% - var(--mini-top) - var(--mini-h)) 0 round
      var(--radius)
  );
}

/* The terrain's box: folded, it shrinks into the orb's big circle and fades. It lies over the
   card's text (where they never meet), so the text needs no compositing layer of its own. */
.terrain {
  position: absolute;
  z-index: 2;
  top: var(--terrain-top);
  left: 0;
  right: 0;
  height: var(--terrain-h);
  pointer-events: none;
  transition:
    transform var(--fold) var(--ease),
    opacity calc(var(--fold) * 0.5) ease calc(var(--fold) * 0.2),
    visibility 0s;
}
.frame[data-open='false'] .terrain {
  opacity: 0;
  visibility: hidden;
  transform: translate(
      calc(var(--big-x) - var(--terrain-x)),
      calc(var(--mini-top) + var(--orb-y) - var(--terrain-top) - var(--terrain-h) / 2)
    )
    scale(0.16);
  transition:
    transform var(--fold) var(--ease),
    opacity calc(var(--fold) * 0.6) ease,
    visibility 0s var(--fold);
}
/* A canvas fades in once it has drawn its first frame. */
.canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 0.4s ease;
}
.canvas[data-drawn] {
  opacity: 1;
}

/* Without JavaScript or WebGL, a still sketch of a world stands in for the terrain and the orb. */
.sketch,
.orbSketch {
  display: none;
}
@media (scripting: none) {
  .sketch,
  .orbSketch {
    display: block;
  }
}
.root[data-flat] .sketch,
.root[data-flat] .orbSketch {
  display: block;
}
.sketch {
  position: absolute;
  top: 16%;
  bottom: 14%;
  left: 50%;
  width: min(300px, 82%);
  transform: translateX(-50%);
}
.sketch::before,
.sketch::after {
  content: '';
  position: absolute;
  bottom: 0;
}
.sketch::before {
  z-index: 1;
  left: 0;
  width: 58%;
  height: 100%;
  clip-path: polygon(
    48% 0,
    53% 6%,
    58% 15%,
    63% 26%,
    69% 38%,
    76% 51%,
    84% 64%,
    92% 77%,
    100% 90%,
    92% 97%,
    74% 100%,
    50% 98%,
    26% 100%,
    8% 97%,
    0 90%,
    8% 77%,
    16% 64%,
    24% 51%,
    31% 38%,
    37% 26%,
    42% 15%,
    45% 6%
  );
  background:
    repeating-linear-gradient(to bottom, transparent 0 11%, rgb(14 14 16 / 0.8) 11% 12.5%),
    linear-gradient(
      to bottom,
      #f7e3b3 0 20%,
      #6aa2ff 20% 46%,
      #f4c04e 46% 60%,
      #f08a33 60% 74%,
      #6aa2ff 74%
    );
}
.sketch::after {
  left: 46%;
  right: 0;
  bottom: 8%;
  height: 60%;
  clip-path: polygon(
    0 100%,
    8% 70%,
    16% 40%,
    24% 16%,
    32% 4%,
    38% 2%,
    44% 12%,
    50% 30%,
    56% 38%,
    62% 30%,
    70% 20%,
    78% 18%,
    86% 30%,
    94% 60%,
    100% 100%
  );
  background:
    repeating-linear-gradient(to bottom, transparent 0 15%, rgb(14 14 16 / 0.7) 15% 17.5%), #3a3a40;
}

/* The card's text and controls. */
.panel {
  position: absolute;
  z-index: 1;
  top: 0;
  left: 0;
  right: 0;
  height: var(--card-h);
  padding: 16px 12px 12px 16px;
  transition:
    opacity 0.28s ease calc(var(--fold) * 0.5),
    visibility 0s;
}
.frame[data-open='false'] .panel {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.14s ease,
    visibility 0s var(--fold);
}
.head {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 32px;
}
.title {
  flex: 1;
  min-width: 0;
  margin: 0;
  overflow: hidden;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
/* While a world builds, a sheen runs along the title. */
.titleText {
  background: linear-gradient(100deg, var(--ink) 0 42%, var(--sheen) 50%, var(--ink) 58% 100%) 0 0 /
    300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.root[data-live] .frame:not([data-phase='ready']) .titleText {
  animation: sheen 2.2s linear infinite;
}
@keyframes sheen {
  from {
    background-position: 100% 0;
  }
  to {
    background-position: 0 0;
  }
}

.hide {
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  gap: 4px;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: var(--muted);
  font-size: 13.5px;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.hide svg {
  width: 16px;
  height: 16px;
}
.hide:hover {
  background: var(--raised);
  color: var(--ink);
}
/* Narrow, the chevron alone. */
.hideLabel {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.caption {
  margin: 2px 0 0;
  overflow: hidden;
  font-size: 12.5px;
  color: var(--faint);
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* The steps, with a rail that fills down to the current one. */
.activity {
  position: absolute;
  top: var(--activity-top);
  left: 16px;
  right: 12px;
}
.activityLabel {
  margin: 0;
  font-size: 12px;
  line-height: 26px;
  color: var(--faint);
}
.steps {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
}
.steps::before,
.steps::after {
  content: '';
  position: absolute;
  top: calc(var(--row) / 2);
  bottom: calc(var(--row) / 2);
  left: 8.5px;
  width: 1px;
  background: var(--rail);
}
.steps::after {
  background: var(--rail-done);
  transform: scaleY(var(--fill));
  transform-origin: top;
  transition: transform 0.4s ease;
}
.step {
  display: flex;
  align-items: center;
  gap: 10px;
  height: var(--row);
}
.mark {
  position: relative;
  z-index: 1;
  display: grid;
  flex: none;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--card);
  box-shadow: inset 0 0 0 1px var(--mark-edge);
  font-size: 10.5px;
  font-weight: 600;
  color: var(--faint);
  transition:
    background-color 0.3s ease,
    color 0.3s ease,
    box-shadow 0.3s ease;
}
.mark svg {
  width: 12px;
  height: 12px;
}
.step[data-state='current'] .mark {
  background: var(--mark-current);
  box-shadow: none;
  color: var(--mark-current-ink);
}
.step[data-state='done'] .mark {
  background: var(--mark-done);
  box-shadow: none;
  color: var(--mark-done-ink);
}
.stepLabel {
  overflow: hidden;
  font-size: 13.5px;
  color: var(--faint);
  white-space: nowrap;
  text-overflow: ellipsis;
  transition: color 0.3s ease;
}
.step[data-state='current'] .stepLabel {
  color: var(--ink);
}
.step[data-state='done'] .stepLabel {
  color: var(--muted);
}

/* The percentage and the step counter: in the card's header, or in the orb. Each is placed by
   its centre, so it can glide from one place to the other with a transform. */
.pill,
.counter {
  position: absolute;
  z-index: 3;
  top: 0;
  left: 0;
  font-variant-numeric: tabular-nums;
  pointer-events: none;
  transition:
    transform var(--fold) var(--ease),
    background-color var(--fold) ease;
}
.pill {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 64px;
  height: 26px;
  margin: -13px 0 0 -32px;
  border-radius: 13px;
  background: var(--raised);
  font-size: 13px;
  font-weight: 600;
  transform: translate(var(--pill-x), var(--pill-y));
}
.frame[data-open='false'] .pill {
  background: var(--pill-orb);
}
/* A striped ball whose stripes roll while a world builds. */
.globe {
  position: relative;
  flex: none;
  width: 13px;
  height: 13px;
  overflow: hidden;
  border-radius: 50%;
  background: var(--ball);
}
.globe::before {
  content: '';
  position: absolute;
  top: -4px;
  left: 0;
  right: 0;
  height: 21px;
  background: repeating-linear-gradient(transparent 0 2.6px, var(--ball-stripe) 2.6px 4px);
}
.root[data-live] .frame:not([data-phase='ready']) .globe::before {
  animation: roll 1.4s linear infinite;
}
@keyframes roll {
  to {
    transform: translateY(4px);
  }
}
.counter {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  width: 36px;
  margin: -11px 0 0 -18px;
  font-size: 11.5px;
  font-weight: 600;
  line-height: 14px;
  transform: translate(var(--counter-x), var(--counter-y));
}
.dots {
  display: flex;
  gap: 3px;
}
.dots span {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--dot);
  transition: background-color 0.3s ease;
}
.dots span[data-lit] {
  background: var(--dot-lit);
}

/* The compact card: one button, the orb and "Expand". */
.mini {
  position: absolute;
  z-index: 2;
  top: var(--mini-top);
  left: 0;
  width: var(--mini-w);
  height: var(--mini-h);
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--muted);
  text-align: left;
  cursor: pointer;
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.14s ease,
    visibility 0s var(--fold);
}
.frame[data-open='false'] .mini {
  opacity: 1;
  visibility: visible;
  transition:
    opacity 0.3s ease calc(var(--fold) * 0.3),
    visibility 0s;
}
.orb {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  transform: scale(0.6);
  transform-origin: var(--big-x) var(--orb-y);
  transition: transform 0.2s ease;
}
.frame[data-open='false'] .orb {
  transform: none;
  transition: transform var(--fold) var(--ease);
}
/* The orb without WebGL: the two circles. */
.orbSketch {
  position: absolute;
  top: calc(var(--orb-y) - var(--big-r));
  left: calc(var(--big-x) - var(--big-r));
  width: calc(var(--big-r) * 2);
  height: calc(var(--big-r) * 2);
  border-radius: 50%;
  background: conic-gradient(from 200deg, #6aa2ff, #c8c5f2, #f7e3b3, #f4c04e, #f08a33, #6aa2ff);
  box-shadow: inset 0 0 0 3px #dcdaea;
}
.orbSketch::after {
  content: '';
  position: absolute;
  top: calc(var(--big-r) - var(--small-r));
  left: calc(var(--small-x) - var(--big-x) + var(--big-r) - var(--small-r));
  width: calc(var(--small-r) * 2);
  height: calc(var(--small-r) * 2);
  border-radius: 50%;
  background: var(--sketch-hole);
  box-shadow: inset 0 0 0 3px #c9c7dc;
}
.typing {
  position: absolute;
  top: 17px;
  left: 15px;
  display: flex;
  gap: 3px;
}
.typing span {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--muted);
}
.root[data-live] .frame:not([data-phase='ready']) .typing span {
  animation: blink 1.2s ease-in-out infinite;
}
.typing span:nth-child(2) {
  animation-delay: 0.15s;
}
.typing span:nth-child(3) {
  animation-delay: 0.3s;
}
@keyframes blink {
  0%,
  60%,
  100% {
    opacity: 0.35;
  }
  30% {
    opacity: 1;
  }
}
.expand {
  position: absolute;
  top: 9px;
  right: 10px;
  display: flex;
  align-items: center;
  gap: 2px;
  font-size: 13px;
  transition: color 0.2s ease;
}
.expand svg {
  width: 15px;
  height: 15px;
  transform: rotate(180deg);
}
.mini:hover .expand {
  color: var(--ink);
}

/* The composer: its left end moves aside for the compact card (wide only). */
.composer {
  position: absolute;
  z-index: 0;
  top: calc(var(--card-h) + var(--gap));
  left: 0;
  right: 0;
  height: var(--composer-h);
  margin: 0;
}
/* The composer's edge, as the card's: a pixel bigger all round, under its background. */
.composer::before {
  content: '';
  position: absolute;
  inset: -1px;
  background: var(--card-edge);
  clip-path: inset(0 0 0 var(--shift) round calc(var(--radius) + 1px));
  transition: clip-path var(--slide);
}
.composerBack {
  position: absolute;
  inset: 0;
  background: var(--card);
  clip-path: inset(0 0 0 var(--shift) round var(--radius));
  transition:
    clip-path var(--slide),
    box-shadow 0.2s ease;
}
.composer:has(.field:focus-visible) .composerBack {
  box-shadow: inset 0 0 0 1.5px var(--field-ring);
}
/* The field moves with the left end; it narrows at once as the card folds, and widens once it
   has opened again, so its text never runs past the composer. */
.field {
  position: absolute;
  top: 10px;
  left: 16px;
  right: calc(16px + var(--shift));
  height: 38px;
  padding: 0;
  border: 0;
  outline: none;
  background: transparent;
  font-size: 15px;
  transform: translateX(var(--shift));
  transition:
    transform var(--slide),
    right 0s var(--fold);
}
.frame[data-open='false'] .field {
  transition:
    transform var(--slide),
    right 0s;
}
.field::placeholder {
  color: var(--faint);
}
.rule {
  position: absolute;
  top: 56px;
  left: 16px;
  right: 16px;
  height: 1px;
  background: var(--line);
  clip-path: inset(0 0 0 var(--shift));
  transition: clip-path var(--slide);
}
.tools {
  position: absolute;
  bottom: 12px;
  left: 12px;
  right: 12px;
  display: flex;
  align-items: center;
  gap: 8px;
  height: 32px;
}
.attach {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 6px;
  height: 30px;
  padding: 0 12px 0 9px;
  border-radius: 15px;
  background: var(--raised);
  color: var(--muted);
  font-size: 13px;
  cursor: pointer;
  transform: translateX(var(--shift));
  transition:
    transform var(--slide),
    color 0.2s ease;
}
.attach:hover {
  color: var(--ink);
}
.attach svg {
  width: 15px;
  height: 15px;
}
.picker {
  position: relative;
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 5px;
  height: 30px;
  margin-left: auto;
  padding: 0 4px 0 6px;
  border-radius: 8px;
  font-size: 13px;
  white-space: nowrap;
}
.model {
  font-weight: 600;
}
.level,
.pickerChevron {
  color: var(--muted);
  transition: color 0.2s ease;
}
.picker:hover .level,
.picker:hover .pickerChevron {
  color: var(--ink);
}
.pickerChevron {
  width: 15px;
  height: 15px;
}
.select {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  /* 16px, so a phone does not zoom in when it opens. */
  font-size: 16px;
  cursor: pointer;
}
.divider {
  flex: none;
  width: 1px;
  height: 18px;
  background: var(--line);
}
.mic,
.send {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease;
}
.mic svg,
.send svg {
  width: 16px;
  height: 16px;
}
.mic {
  background: transparent;
  color: var(--muted);
}
.mic:hover,
.mic[aria-pressed='true'] {
  background: var(--raised);
  color: var(--ink);
}
/* Listening: a ring breathes round the button. */
.mic[aria-pressed='true']::after {
  content: '';
  position: absolute;
  inset: -3px;
  border: 1.5px solid var(--listen-ring);
  border-radius: 50%;
}
.root[data-live] .mic[aria-pressed='true']::after {
  animation: breathe 1.6s ease-in-out infinite;
}
@keyframes breathe {
  50% {
    opacity: 0.3;
    transform: scale(1.12);
  }
}
.send {
  background: var(--send);
  color: var(--send-ink);
}
.send:disabled {
  background: var(--send-off);
  color: var(--send-off-ink);
  cursor: default;
}

.hide:focus-visible,
.mini:focus-visible,
.mic:focus-visible,
.send:focus-visible,
.attach:has(:focus-visible),
.picker:has(:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
/* Its ring is on the light stage: a deeper blue. */
.mini:focus-visible {
  outline-color: #2f6fe4;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Wide: the terrain on the card's right, the steps at its bottom left, the percentage and the
   counter in its header; folded, the compact card beside the composer. */
@container widget (min-width: 600px) {
  .frame {
    --card-h: calc(150px + var(--steps) * var(--row));
    --mini-top: calc(var(--card-h) + var(--gap));
    --pill-x: calc(100cqw - 166px);
    --pill-y: 34px;
    --counter-x: calc(100cqw - 107px);
    --terrain-top: 0px;
    --terrain-h: var(--card-h);
    --terrain-x: 71cqw;
  }
  .frame[data-open='false'] {
    --shift: calc(var(--mini-w) + var(--gap));
  }
  .terrain {
    left: auto;
    width: 58%;
  }
  .sketch {
    left: 46%;
  }
  .panel {
    padding: 18px 12px 14px 20px;
  }
  .hide {
    width: 70px;
  }
  .hideLabel {
    position: static;
    width: auto;
    height: auto;
    overflow: visible;
    clip-path: none;
  }
  .caption {
    max-width: 40%;
  }
  .activity {
    top: auto;
    bottom: 14px;
    left: 20px;
    right: auto;
  }
}
/* The narrowest composers: the picker shows the level alone, then "Add images" its icon alone. */
@container widget (max-width: 379px) {
  .model {
    display: none;
  }
}
@container widget (max-width: 309px) {
  .attach {
    justify-content: center;
    width: 30px;
    padding: 0;
  }
  .attachLabel {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

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

### world-status/composer.tsx

```tsx
import { useId } from 'react';
import { ArrowUpIcon, ChevronIcon, MicIcon, PlusIcon } from './icons';
import { LEVELS, type Level } from './worlds';
import styles from './world-status.module.css';

/*
 * The chat composer under the status: a field for the idea, images to add (in this demo they are
 * counted, never read), the level of detail, a voice toggle (its pressed state only) and the send
 * button. Sending (the button, or Enter in the field) builds a world.
 *
 * The level picker is a native <select>, laid invisibly over its label: it opens the system's own
 * list, on every device, and the label shows what it holds.
 */

export function Composer({
  value,
  onChange,
  onSend,
  level,
  onLevel,
  images,
  onImages,
  listening,
  onListen,
}: {
  value: string;
  onChange: (value: string) => void;
  onSend: () => void;
  level: Level;
  onLevel: (level: Level) => void;
  /** How many images are added. */
  images: number;
  onImages: (count: number) => void;
  listening: boolean;
  onListen: () => void;
}) {
  const fieldId = useId();
  return (
    <form
      className={styles.composer}
      data-part="composer"
      onSubmit={(event) => {
        event.preventDefault();
        onSend();
      }}
    >
      <div className={styles.composerBack} aria-hidden="true" />
      <label htmlFor={fieldId} className={styles.hidden}>
        Describe a world to build
      </label>
      <input
        id={fieldId}
        className={styles.field}
        data-part="field"
        type="text"
        value={value}
        placeholder="Describe a world to build…"
        autoComplete="off"
        enterKeyHint="send"
        onChange={(event) => onChange(event.target.value)}
      />
      <span className={styles.rule} aria-hidden="true" />
      <div className={styles.tools}>
        <label className={styles.attach} data-part="attach">
          <input
            type="file"
            accept="image/*"
            multiple
            className={styles.hidden}
            onChange={(event) => {
              onImages(event.target.files?.length ?? 0);
              // The same files can be picked again.
              event.target.value = '';
            }}
          />
          <PlusIcon />
          <span className={styles.attachLabel}>
            {images ? `${images} image${images > 1 ? 's' : ''}` : 'Add images'}
          </span>
        </label>
        <div className={styles.picker} data-part="level">
          <span className={styles.model}>Orrery 2</span>
          <span className={styles.level}>{level.label}</span>
          <ChevronIcon className={styles.pickerChevron} />
          <select
            className={styles.select}
            aria-label="Level of detail"
            value={level.id}
            onChange={(event) =>
              onLevel(LEVELS.find((one) => one.id === event.target.value) ?? level)
            }
          >
            {LEVELS.map((one) => (
              <option key={one.id} value={one.id}>
                {one.label}
              </option>
            ))}
          </select>
        </div>
        <span className={styles.divider} aria-hidden="true" />
        <button
          type="button"
          className={styles.mic}
          data-part="voice"
          aria-label="Voice input"
          aria-pressed={listening}
          onClick={onListen}
        >
          <MicIcon />
        </button>
        <button
          type="submit"
          className={styles.send}
          data-part="send"
          aria-label="Build this world"
          disabled={!value.trim()}
        >
          <ArrowUpIcon />
        </button>
      </div>
    </form>
  );
}
```

### world-status/builder.ts

```tsx
import { createOrb, type Orb } from './orb';
import { createTerrain, type Terrain } from './terrain';

/*
 * The build behind the widget: a clock that turns time into progress, and the one animation frame
 * loop that draws the terrain and the orb from it.
 *
 * - A build thinks for a moment (the last world sinks back into the sea), then builds: its progress
 *   runs through the steps, each quick at first and slower as it ends, while the land rises and
 *   colour flows up the mountain. Then it is ready, and the flow settles to a stop.
 * - The loop runs only while the widget is near the screen (and the tab visible), and only while
 *   something moves: a build, the flow settling, or the card folding into the orb or opening out
 *   of it. At rest it does nothing.
 * - The canvases give their pixels back (they are sized to 0 × 0) when the widget leaves the
 *   screen, and get them back, redrawn, as it returns. On screen they keep them, so a fold costs
 *   no new buffers: the canvas that is not shown is simply not drawn.
 * - Under reduced motion the build still progresses, but the canvases are drawn as still frames,
 *   when a step changes, the card folds or they are resized.
 */

export type Phase = 'thinking' | 'building' | 'ready';

/** What the widget shows: the phase, the percentage, and the current step (-1 before the first). */
export type Status = { phase: Phase; percent: number; step: number };

/** The pause before a build, in ms. */
export const THINK = 1400;
/** How long the card takes to fold into the orb, or to open out of it, in ms. */
export const FOLD = 650;

/**
 * A build's progress (0 to 1) once a share `time` of its duration has passed: each step goes
 * quickly at first, then slows as it finishes.
 */
export function progressAt(time: number, steps: number) {
  if (time >= 1) return 1;
  const scaled = Math.max(0, time) * steps;
  const step = Math.floor(scaled);
  return (step + 1 - (1 - (scaled - step)) ** 2) / steps;
}

/** The status at `progress`, mid-build. */
export function statusAt(progress: number, steps: number): Status {
  return {
    phase: 'building',
    percent: Math.min(99, Math.floor(progress * 100)),
    step: Math.min(steps - 1, Math.floor(progress * steps)),
  };
}

const clamp = (value: number) => Math.min(1, Math.max(0, value));

/**
 * The fold's easing, the CSS's cubic-bezier(0.2, 0, 0, 1): the orb's small circle moves on the
 * same curve as the card and the counter, and meets the counter where they both land.
 */
function easeFold(t: number) {
  // The curve's x(s) = 0.6 s (1 - s)² + s³ is solved for s by Newton's method; then y(s).
  let s = t;
  for (let i = 0; i < 6; i++) {
    const slope = 0.6 * (1 - s) ** 2 - 1.2 * s * (1 - s) + 3 * s ** 2;
    if (slope < 1e-6) break;
    s = clamp(s - (0.6 * s * (1 - s) ** 2 + s ** 3 - t) / slope);
  }
  return 3 * s ** 2 - 2 * s ** 3;
}

/** How high the land has risen: from under the sea, so the summits break through first. */
const riseAt = (progress: number) => 0.15 + 0.85 * (1 - (1 - progress) ** 2);
/** How high colour has flowed up the mountain. */
const colourAt = (progress: number) => {
  const t = clamp((progress - 0.05) / 0.8);
  return t * t * (3 - 2 * t);
};

/** A canvas, what draws on it, its box in CSS px, and the size its pixels have now ('' if none). */
type View<Renderer extends Terrain | Orb> = {
  canvas: HTMLCanvasElement;
  renderer: Renderer | null;
  width: number;
  height: number;
  pixels: string;
};

type Options = {
  terrain: HTMLCanvasElement;
  orb: HTMLCanvasElement;
  steps: number;
  /** The first world's seed, how long it takes to build (ms), and its contour lines. */
  seed: number;
  duration: number;
  lines: number;
  /** How far along the first build is, as a share of its duration. */
  start: number;
  /** The first build's phase, and its progress (0 to 1) instead of `start`'s. */
  phase?: Phase;
  progress?: number;
  /** Holds the first build still where it is, until build() starts another. */
  hold?: boolean;
  open: boolean;
  /** Called when the phase or the percentage changes. */
  onChange: (status: Status) => void;
};

export function createBuilder(options: Options) {
  const { steps, onChange } = options;
  const view = <Renderer extends Terrain | Orb>(
    canvas: HTMLCanvasElement,
    renderer: Renderer | null,
  ): View<Renderer> => ({ canvas, renderer, width: 0, height: 0, pixels: '' });
  const terrain = view(options.terrain, createTerrain(options.terrain));
  const orb = view(options.orb, createOrb(options.orb));
  const views = [terrain, orb];

  let phase: Phase = options.phase ?? 'building';
  let elapsed = phase === 'thinking' ? THINK * 0.8 : options.start * options.duration;
  let duration = options.duration;
  let progress = phase === 'ready' ? 1 : (options.progress ?? progressAt(options.start, steps));
  /** Held still: nothing advances, not even the flow, until another build. */
  let held = !!options.hold;
  let seed = options.seed;
  let lines = options.lines;
  /** The next world, built once the thinking is over. */
  let next = { seed, lines };
  /** The last world as the thinking began: it sinks from there. */
  let sinking = { rise: 1, colour: 1 };
  /** How far the bands and the liquid have flowed (s), and how fast they flow (1 mid-build). */
  let flow = 0;
  let speed = phase === 'ready' ? 0 : 1;
  let open = options.open;
  let foldStart = -Infinity;
  let near = false;
  let reduced = false;
  /** A still frame is due: under reduced motion, nothing else is drawn. */
  let dirty = true;
  let frame = 0;
  let last = 0;

  const status = (): Status => {
    if (phase === 'thinking') return { phase, percent: 0, step: -1 };
    if (phase === 'ready') return { phase, percent: 100, step: steps };
    return statusAt(progress, steps);
  };
  let reported = status();

  const report = () => {
    const now = status();
    if (now.phase === reported.phase && now.percent === reported.percent) return;
    if (now.phase !== reported.phase || now.step !== reported.step) dirty = true;
    reported = now;
    onChange(now);
  };

  const advance = (ms: number) => {
    if (held) return;
    if (phase === 'thinking') {
      elapsed += ms;
      if (elapsed >= THINK) {
        phase = 'building';
        elapsed = 0;
        ({ seed, lines } = next);
      }
    } else if (phase === 'building') {
      elapsed += ms;
      progress = progressAt(elapsed / duration, steps);
      if (elapsed >= duration) phase = 'ready';
    }
    // The flow quickens while thinking, and settles to a stop once the world is ready.
    const target = phase === 'thinking' ? 1.8 : phase === 'building' ? 1 : 0;
    speed += (target - speed) * Math.min(1, ms / 500);
    if (phase === 'ready' && speed < 0.01) speed = 0;
    if (!reduced) flow += (ms / 1000) * speed;
  };

  /** How far the small circle has come out of the big one. */
  const splitAt = (now: number) => {
    const t = clamp((now - foldStart) / FOLD);
    return open ? 1 - easeFold(t) : easeFold(t);
  };

  /** Draws on a view, sizing its pixels first if its box changed or they were given back. */
  const paint = <Renderer extends Terrain | Orb>(
    target: View<Renderer>,
    draw: (renderer: Renderer) => boolean,
  ) => {
    const { renderer } = target;
    if (!renderer) return;
    const pixels = `${target.width}×${target.height}`;
    if (pixels !== target.pixels) {
      renderer.resize(target.width, target.height);
      target.pixels = pixels;
    }
    target.canvas.toggleAttribute('data-drawn', draw(renderer));
  };

  const release = (target: View<Terrain | Orb>) => {
    if (!target.pixels) return;
    target.renderer?.resize(0, 0);
    target.pixels = '';
  };

  const draw = (now: number) => {
    const folding = now - foldStart < FOLD;
    if (reduced && !dirty && !folding) return;
    dirty = false;
    const thinking = phase === 'thinking';
    const sink = 1 - clamp(elapsed / THINK) ** 2;
    if (open || folding) {
      paint(terrain, (renderer) =>
        renderer.draw({
          seed,
          rise: thinking ? sinking.rise * sink : riseAt(progress),
          colour: thinking ? sinking.colour : colourAt(progress),
          flow,
          lines,
        }),
      );
    }
    if (!open || folding) {
      paint(orb, (renderer) => renderer.draw({ seed, flow, split: splitAt(now) }));
    }
  };

  const tick = (now: number) => {
    frame = 0;
    // A long gap (a busy main thread, a tab in the background) counts as one short frame.
    advance(last ? Math.min(64, now - last) : 16);
    last = now;
    report();
    draw(now);
    const moving = (!held && (phase !== 'ready' || speed > 0)) || now - foldStart < FOLD;
    if (near && moving) frame = requestAnimationFrame(tick);
    else last = 0;
  };

  const wake = () => {
    if (!frame && near) frame = requestAnimationFrame(tick);
  };

  // Each canvas's box, for sizing its pixels: a resized canvas is redrawn at once, even at rest.
  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const resized = views.find((one) => one.canvas === entry.target);
      if (!resized) continue;
      resized.width = Math.round(entry.contentRect.width);
      resized.height = Math.round(entry.contentRect.height);
    }
    dirty = true;
    wake();
  });
  for (const one of views) observer.observe(one.canvas);

  return {
    /** Whether WebGL draws the terrain and the orb (else the widget shows still sketches). */
    supported: views.every((one) => one.renderer),
    /** The build's phase and progress (0 to 1). */
    state: () => ({ phase, progress }),
    /** Builds a new world: a moment of thought, then the build, in `ms`. */
    build(world: { seed: number; ms: number; lines: number }) {
      held = false;
      // A second idea while thinking replaces the first: the last world keeps sinking.
      if (phase !== 'thinking') {
        sinking = { rise: riseAt(progress), colour: colourAt(progress) };
        phase = 'thinking';
        elapsed = 0;
        progress = 0;
      }
      duration = world.ms;
      next = { seed: world.seed, lines: world.lines };
      dirty = true;
      report();
      wake();
    },
    /** Folds the card into the orb, or opens it out (at once under reduced motion). */
    setOpen(value: boolean) {
      if (value === open) return;
      open = value;
      foldStart = reduced ? -Infinity : performance.now();
      dirty = true;
      wake();
    },
    /** Near the screen with the tab visible, the build runs and draws; away, it pauses. */
    setNear(value: boolean) {
      near = value;
      if (near) {
        dirty = true;
        wake();
        return;
      }
      cancelAnimationFrame(frame);
      frame = 0;
      last = 0;
      release(terrain);
      release(orb);
    },
    setReduced(value: boolean) {
      reduced = value;
      dirty = true;
      wake();
    },
    /** The colour scheme changed: the orb reads its colours again, and is drawn in them. */
    recolour() {
      orb.renderer?.recolour();
      dirty = true;
      wake();
    },
    dispose() {
      cancelAnimationFrame(frame);
      observer.disconnect();
      for (const one of views) one.renderer?.dispose();
    },
  };
}

export type Builder = ReturnType<typeof createBuilder>;
```

### world-status/terrain.ts

```tsx
import { COLOURS, NOISE, createProgram, getContext, pixelRatio, uniformsOf } from './gl';
import { random } from './worlds';

/*
 * The terrain: a flat grid of triangles that a vertex shader lifts into a mountain and two hills,
 * drawn by a fragment shader as a topographic model.
 *
 * - The height of a point is a sum of soft cones (the mountain and the hills, placed by the world's
 *   seed) roughened by ridged noise. Below sea level the fragment shader discards the land, so the
 *   peaks stand as islands on the card, and as the build progresses the land rises out of the sea.
 * - Dark contour lines run at fixed heights, a pixel and a half wide whatever the slope (their
 *   width is measured with screen-space derivatives).
 * - On the mountain, colour flows up the slopes: bands of blue, cream and orange whose index is the
 *   height minus time, stirred by noise across the land and around the peak (the streaks). The
 *   colour fills the slopes up to a front that climbs with the build; above it and on the hills,
 *   the land stays grey.
 */

export type TerrainFrame = {
  /** The world's seed: where its mountain and hills stand. */
  seed: number;
  /** How far the land has risen out of the sea: 0 (under it) to 1. */
  rise: number;
  /** How high colour has flowed up the mountain: 0 (not at all) to 1 (over the summit). */
  colour: number;
  /** How far the bands have flowed, in seconds. */
  flow: number;
  /** Contour lines per unit of height. */
  lines: number;
};

/** A soft cone: its place on the grid (x, z), its radius and its height. */
type Cone = [x: number, z: number, radius: number, height: number];

/** A world's land: its mountain, two hills, how pointed they are, and offsets for its noise. */
type Shape = {
  peak: Cone;
  hills: [Cone, Cone];
  sharpness: number;
  detail: [number, number];
  phase: number;
};

/** The grid's extent, in world units: wider to the right and back, where the hills stand. */
const GRID = { left: -1.1, right: 1.7, back: -1.4, front: 0.9 };
const CELLS = 100;
/** The land under this height is sea. */
const SEA = 0.18;
/** The top of the highest summit, for the colour front. */
const SUMMIT = 1.05;

const VERTEX = /* glsl */ `
precision highp float;
attribute vec2 aPlace;
uniform mat4 uCamera;
uniform vec4 uPeak;
uniform vec4 uHillA;
uniform vec4 uHillB;
uniform float uSharpness;
uniform vec2 uDetail;
uniform float uRise;
varying float vHeight;
varying vec2 vPlace;
varying vec3 vNormal;
varying float vMountain;
${NOISE}
// Noise folded into sharp crests, four octaves.
float ridged(vec2 p) {
  float sum = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 4; i++) {
    sum += amplitude * (1.0 - abs(noise(p) * 2.0 - 1.0));
    p = p * 2.03 + vec2(1.7, 9.2);
    amplitude *= 0.5;
  }
  return sum;
}
float cone(vec2 p, vec4 shape) {
  vec2 d = (p - shape.xy) / shape.z;
  return shape.w * exp(-pow(dot(d, d), uSharpness));
}
// x: the height above the sea; y: the mountain's share of it.
vec2 land(vec2 p) {
  float mountain = cone(p, uPeak);
  float base = mountain + cone(p, uHillA) + cone(p, uHillB);
  float crests = ridged(p * 2.2 + uDetail) - 0.5;
  float height = (base + 0.3 * crests * (0.25 + base * 0.5)) * uRise - ${SEA.toFixed(2)};
  return vec2(height, mountain / max(base, 0.0001));
}
void main() {
  vec2 here = land(aPlace);
  // The normal, from the slopes on either side.
  float e = 0.012;
  float dx = land(aPlace + vec2(e, 0.0)).x - land(aPlace - vec2(e, 0.0)).x;
  float dz = land(aPlace + vec2(0.0, e)).x - land(aPlace - vec2(0.0, e)).x;
  vNormal = normalize(vec3(-dx, 2.0 * e, -dz));
  vHeight = here.x;
  vMountain = here.y;
  vPlace = aPlace;
  // The sea floor stays just under the surface: its triangles are discarded anyway.
  gl_Position = uCamera * vec4(aPlace.x, max(here.x, -0.02), aPlace.y, 1.0);
}
`;

const FRAGMENT = /* glsl */ `
#extension GL_OES_standard_derivatives : enable
precision highp float;
varying float vHeight;
varying vec2 vPlace;
varying vec3 vNormal;
varying float vMountain;
uniform vec2 uSummit;
uniform float uFlow;
uniform float uFront;
uniform float uLines;
uniform float uPhase;
${NOISE}
${COLOURS}
vec3 bands(float t) {
  t = fract(t);
  vec3 colour = mix(BLUE, CREAM, smoothstep(0.3, 0.36, t));
  colour = mix(colour, YELLOW, smoothstep(0.52, 0.58, t));
  colour = mix(colour, ORANGE, smoothstep(0.68, 0.74, t));
  return mix(colour, BLUE, smoothstep(0.86, 0.92, t));
}
void main() {
  float height = vHeight;
  // The shore: the land fades in over a pixel above the sea, for a smooth outline.
  float shore = smoothstep(0.0, fwidth(height) * 1.5 + 0.0005, height);
  if (shore <= 0.0) discard;

  float level = height * uLines;
  float line = 1.0 - smoothstep(0.5, 1.5, abs(fract(level + 0.5) - 0.5) / fwidth(level));

  // The bands climb with time; the streaks, fading out at the summit, run down the slopes.
  vec2 fromSummit = vPlace - uSummit;
  float streaks = smoothstep(0.02, 0.3, length(fromSummit));
  float band = height * 1.6 - uFlow * 0.12
    + noise(vPlace * 3.0 + uPhase) * 0.45
    + noise(normalize(fromSummit + 0.0001) * 2.5 + uPhase * 1.7) * 1.4 * streaks;
  vec3 colour = bands(band + uPhase);
  vec3 grey = mix(vec3(0.19, 0.19, 0.205), vec3(0.255, 0.255, 0.27), mod(floor(level), 2.0));
  float coloured = smoothstep(0.48, 0.56, vMountain) * smoothstep(uFront + 0.04, uFront - 0.04, height);

  float light = 0.62 + 0.38 * max(dot(normalize(vNormal), normalize(vec3(-0.6, 0.8, 0.55))), 0.0);
  vec3 surface = mix(mix(grey, colour, coloured) * light, vec3(0.06, 0.06, 0.07), line * 0.82);
  gl_FragColor = vec4(surface * shore, shore);
}
`;

const UNIFORMS = [
  'uCamera',
  'uPeak',
  'uHillA',
  'uHillB',
  'uSharpness',
  'uDetail',
  'uRise',
  'uSummit',
  'uFlow',
  'uFront',
  'uLines',
  'uPhase',
] as const;

/** Where a world's mountain and hills stand: the mountain in front, two hills behind it. */
function shapeOf(seed: number): Shape {
  const next = random(seed);
  const between = (min: number, max: number) => min + (max - min) * next();
  return {
    peak: [between(-0.4, -0.2), between(0.15, 0.32), between(0.5, 0.58), between(1.0, 1.12)],
    hills: [
      [between(0.5, 0.7), between(-0.52, -0.34), between(0.3, 0.37), between(0.58, 0.72)],
      [between(0.98, 1.15), between(-0.9, -0.66), between(0.3, 0.36), between(0.46, 0.58)],
    ],
    sharpness: between(0.78, 0.9),
    detail: [between(0, 40), between(0, 40)],
    phase: between(0, 10),
  };
}

/** The camera: up and to the right of the land, looking down at it at about 25°. */
function cameraFor(aspect: number) {
  const eye = [2.9, 2.1, 3.3];
  const target = [0.32, 0.3, -0.2];
  // Look-at: the camera's axes, in world units. The matrices below are lists of columns.
  const sub = (a: number[], b: number[]) => a.map((value, i) => value - b[i]);
  const normalise = (a: number[]) => a.map((value) => value / Math.hypot(...a));
  const cross = (a: number[], b: number[]) => [
    a[1] * b[2] - a[2] * b[1],
    a[2] * b[0] - a[0] * b[2],
    a[0] * b[1] - a[1] * b[0],
  ];
  const dot = (a: number[], b: number[]) => a.reduce((sum, value, i) => sum + value * b[i], 0);
  const z = normalise(sub(eye, target));
  const x = normalise(cross([0, 1, 0], z));
  const y = cross(z, x);
  const view = [
    [x[0], y[0], z[0], 0],
    [x[1], y[1], z[1], 0],
    [x[2], y[2], z[2], 0],
    [-dot(x, eye), -dot(y, eye), -dot(z, eye), 1],
  ];
  // Perspective: a narrow field (24° high, less in a wide, short box), for an almost isometric look.
  const field = 24 * Math.min(1, (1.8 / aspect) ** 0.25);
  const f = 1 / Math.tan((field * Math.PI) / 360);
  const near = 0.1;
  const far = 30;
  const projection = [
    [f / aspect, 0, 0, 0],
    [0, f, 0, 0],
    [0, 0, (far + near) / (near - far), -1],
    [0, 0, (2 * far * near) / (near - far), 0],
  ];
  // projection × view, column by column.
  const camera = new Float32Array(16);
  for (let column = 0; column < 4; column++) {
    for (let row = 0; row < 4; row++) {
      let sum = 0;
      for (let k = 0; k < 4; k++) sum += projection[k][row] * view[column][k];
      camera[column * 4 + row] = sum;
    }
  }
  return camera;
}

export type Terrain = {
  /** Sizes the canvas's pixels to a box of `width` × `height` CSS px; 0 × 0 gives them back. */
  resize: (width: number, height: number) => void;
  /** Draws a frame. False if there is nothing to draw on (no pixels, or WebGL is gone). */
  draw: (frame: TerrainFrame) => boolean;
  dispose: () => void;
};

/** The terrain on `canvas`, or null where WebGL is not available. */
export function createTerrain(canvas: HTMLCanvasElement): Terrain | null {
  const gl = getContext(canvas, { antialias: true, depth: true });
  if (!gl) return null;

  const setup = () => {
    const program = createProgram(gl, VERTEX, FRAGMENT);
    if (!program) return null;
    // The grid's corners, and two triangles per cell.
    const places = new Float32Array((CELLS + 1) ** 2 * 2);
    for (let row = 0, i = 0; row <= CELLS; row++) {
      for (let column = 0; column <= CELLS; column++) {
        places[i++] = GRID.left + ((GRID.right - GRID.left) * column) / CELLS;
        places[i++] = GRID.back + ((GRID.front - GRID.back) * row) / CELLS;
      }
    }
    const triangles = new Uint16Array(CELLS * CELLS * 6);
    for (let row = 0, i = 0; row < CELLS; row++) {
      for (let column = 0; column < CELLS; column++) {
        const a = row * (CELLS + 1) + column;
        const b = a + CELLS + 1;
        triangles.set([a, b, a + 1, a + 1, b, b + 1], i);
        i += 6;
      }
    }
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, places, gl.STATIC_DRAW);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, triangles, gl.STATIC_DRAW);
    const place = gl.getAttribLocation(program, 'aPlace');
    gl.enableVertexAttribArray(place);
    gl.vertexAttribPointer(place, 2, gl.FLOAT, false, 0, 0);
    gl.useProgram(program);
    gl.enable(gl.DEPTH_TEST);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    gl.clearColor(0, 0, 0, 0);
    return { uniforms: uniformsOf(gl, program, UNIFORMS), count: triangles.length };
  };

  let scene = setup();
  if (!scene) return null;
  let shaped = -1;
  let aspect = 0;

  // A lost context (the GPU reset, or the tab went to the background on a phone) comes back.
  const onLost = (event: Event) => {
    event.preventDefault();
    scene = null;
  };
  const onRestored = () => {
    scene = setup();
    shaped = -1;
    aspect = 0;
  };
  canvas.addEventListener('webglcontextlost', onLost);
  canvas.addEventListener('webglcontextrestored', onRestored);

  return {
    resize(width, height) {
      const ratio = pixelRatio();
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
    },
    draw(frame) {
      if (!scene || !canvas.width || !canvas.height) return false;
      const { uniforms } = scene;
      if (canvas.width / canvas.height !== aspect) {
        aspect = canvas.width / canvas.height;
        gl.uniformMatrix4fv(uniforms.uCamera, false, cameraFor(aspect));
      }
      if (frame.seed !== shaped) {
        shaped = frame.seed;
        const shape = shapeOf(frame.seed);
        gl.uniform4fv(uniforms.uPeak, shape.peak);
        gl.uniform4fv(uniforms.uHillA, shape.hills[0]);
        gl.uniform4fv(uniforms.uHillB, shape.hills[1]);
        gl.uniform1f(uniforms.uSharpness, shape.sharpness);
        gl.uniform2fv(uniforms.uDetail, shape.detail);
        gl.uniform2f(uniforms.uSummit, shape.peak[0], shape.peak[1]);
        gl.uniform1f(uniforms.uPhase, shape.phase);
      }
      gl.uniform1f(uniforms.uRise, frame.rise);
      // From just under the shore to just over the summit.
      gl.uniform1f(uniforms.uFront, -0.08 + (SUMMIT + 0.2) * frame.colour);
      gl.uniform1f(uniforms.uFlow, frame.flow);
      gl.uniform1f(uniforms.uLines, frame.lines);
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
      gl.drawElements(gl.TRIANGLES, scene.count, gl.UNSIGNED_SHORT, 0);
      return true;
    },
    dispose() {
      canvas.removeEventListener('webglcontextlost', onLost);
      canvas.removeEventListener('webglcontextrestored', onRestored);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    },
  };
}
```

### world-status/orb.ts

```tsx
import { COLOURS, NOISE, createProgram, getContext, pixelRatio, uniformsOf } from './gl';
import { random } from './worlds';

/*
 * The orb: the folded status, drawn by one fragment shader over the whole compact card.
 *
 * - The shape is two circles melted together: the distance to each is blended with a smooth
 *   minimum, which grows a neck between them where they are close (a metaball). The big circle
 *   holds the percentage, the small one the step counter.
 * - Inside, a liquid: noise whose coordinates are pushed around by more noise (domain warping),
 *   twice, then mapped to the world's colours, so it marbles and swirls as time flows.
 * - A pale rim runs along the outline; the small circle is a ring, dark inside for its counter
 *   (light, by day).
 * - Behind the orb, faint contour lines: a map of a world.
 * - `split` slides the small circle out of the big one as the card folds, the neck stretching
 *   between them.
 * - The rim, the inside of the small circle and the map's lines follow the colour scheme: they are
 *   CSS tokens, read from the computed style through a hidden probe inside the canvas, at first
 *   and whenever `recolour` is called. The liquid's colours are the world's own, on both.
 */

/** The two circles, in CSS px from the compact card's top left corner. */
export const ORB = {
  big: { x: 50, y: 66, radius: 35 },
  small: { x: 113, y: 66, radius: 21 },
} as const;

export type OrbFrame = {
  /** The world's seed, for the map behind the orb. */
  seed: number;
  /** How far the liquid has flowed, in seconds. */
  flow: number;
  /** How far the small circle has come out of the big one: 0 (inside it) to 1. */
  split: number;
};

const VERTEX = /* glsl */ `
attribute vec2 aCorner;
void main() {
  gl_Position = vec4(aCorner, 0.0, 1.0);
}
`;

const FRAGMENT = /* glsl */ `
#extension GL_OES_standard_derivatives : enable
precision highp float;
uniform vec2 uSize;
uniform float uRatio;
uniform vec3 uBig;
uniform vec3 uSmall;
uniform float uSplit;
uniform float uFlow;
uniform vec2 uMap;
uniform vec4 uMapInk;
uniform vec3 uRim;
uniform vec3 uHole;
${NOISE}
${COLOURS}
float smoothMin(float a, float b, float k) {
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}
vec3 liquid(float t) {
  t = fract(t);
  vec3 colour = mix(BLUE, LILAC, smoothstep(0.16, 0.24, t));
  colour = mix(colour, CREAM, smoothstep(0.3, 0.38, t));
  colour = mix(colour, YELLOW, smoothstep(0.44, 0.5, t));
  colour = mix(colour, ORANGE, smoothstep(0.6, 0.66, t));
  return mix(colour, BLUE, smoothstep(0.84, 0.92, t));
}
void main() {
  // This pixel, in CSS px from the top left corner.
  vec2 p = vec2(gl_FragCoord.x, uSize.y * uRatio - gl_FragCoord.y) / uRatio;
  float edge = 0.75;

  float height = fbm(p / 60.0 + uMap);
  float level = height * 9.0;
  float map = 1.0 - smoothstep(0.4, 1.4, abs(fract(level + 0.5) - 0.5) / fwidth(level));
  vec4 colour = uMapInk * map;

  vec2 small = mix(uBig.xy, uSmall.xy, uSplit);
  float smallRadius = mix(uBig.z * 0.45, uSmall.z, uSplit);
  // The signed distance to the outline: negative inside.
  float toEdge = smoothMin(length(p - uBig.xy) - uBig.z, length(p - small) - smallRadius, 26.0);
  float inside = 1.0 - smoothstep(-edge, edge, toEdge);

  vec2 q = (p - uBig.xy) / 42.0;
  float t = uFlow;
  vec2 warp = vec2(
    fbm(q + vec2(0.0, t * 0.09)),
    fbm(q + vec2(5.2, 1.3) - vec2(t * 0.07, 0.0))
  );
  vec2 swirl = vec2(
    fbm(q + 2.2 * warp + vec2(1.7, 9.2) + t * 0.05),
    fbm(q + 2.2 * warp + vec2(8.3, 2.8))
  );
  vec3 orb = liquid(fbm(q + 2.4 * swirl) * 1.6 + warp.x * 0.6 - t * 0.03 + 0.4);

  float rim = smoothstep(-3.0 - edge, -3.0 + edge, toEdge);
  orb = mix(orb, uRim, rim * 0.85);
  float hole = 1.0 - smoothstep(-edge, edge, length(p - small) - (smallRadius - 4.0));
  orb = mix(orb, uHole, hole * smoothstep(0.3, 0.9, uSplit));

  gl_FragColor = mix(colour, vec4(orb, 1.0), inside);
}
`;

const UNIFORMS = [
  'uSize',
  'uRatio',
  'uBig',
  'uSmall',
  'uSplit',
  'uFlow',
  'uMap',
  'uMapInk',
  'uRim',
  'uHole',
] as const;

/** The colours that follow the scheme, and the CSS tokens they come from. */
const TOKENS = { map: '--orb-map', rim: '--orb-rim', hole: '--orb-hole' } as const;
type Colours = Record<keyof typeof TOKENS, number[]>;

/** The numbers in a computed colour. */
const numbers = (colour: string) => (colour.match(/[\d.]+/g) ?? []).map(Number);

/**
 * A token as red, green, blue and alpha, from 0 to 1, as the CSS resolves it here (light-dark()
 * follows the probe's color-scheme). An rgb() colour computes to rgb() or rgba(), read as it is;
 * any other (color(srgb …), say, which may compute to lab()) is read through color-mix() in sRGB.
 */
function readColour(probe: HTMLElement, style: CSSStyleDeclaration, token: string) {
  probe.style.color = `var(${token})`;
  const plain = style.color;
  if (plain.startsWith('rgb')) {
    const [red = 0, green = 0, blue = 0, alpha = 1] = numbers(plain);
    return [red / 255, green / 255, blue / 255, alpha];
  }
  probe.style.color = `color-mix(in srgb, var(${token}) 100%, transparent)`;
  const [red = 0, green = 0, blue = 0, alpha = 1] = numbers(style.color);
  return [red, green, blue, alpha];
}

/** The colours, read through the probe's colour. */
function readColours(probe: HTMLElement) {
  const style = getComputedStyle(probe);
  const colours = {} as Colours;
  for (const [key, token] of Object.entries(TOKENS)) {
    colours[key as keyof Colours] = readColour(probe, style, token);
  }
  return colours;
}

export type Orb = {
  /** Sizes the canvas's pixels to a box of `width` × `height` CSS px; 0 × 0 gives them back. */
  resize: (width: number, height: number) => void;
  /** Draws a frame. False if there is nothing to draw on (no pixels, or WebGL is gone). */
  draw: (frame: OrbFrame) => boolean;
  /** Reads the colours again, for the next frame: the colour scheme changed. */
  recolour: () => void;
  dispose: () => void;
};

/** The orb on `canvas`, or null where WebGL is not available. */
export function createOrb(canvas: HTMLCanvasElement): Orb | null {
  // The edges are smoothed by the shader: no multisampling, no depth.
  const gl = getContext(canvas, { antialias: false, depth: false });
  if (!gl) return null;

  const setup = () => {
    const program = createProgram(gl, VERTEX, FRAGMENT);
    if (!program) return null;
    // One triangle that covers the canvas.
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const corner = gl.getAttribLocation(program, 'aCorner');
    gl.enableVertexAttribArray(corner);
    gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
    gl.useProgram(program);
    const uniforms = uniformsOf(gl, program, UNIFORMS);
    gl.uniform3f(uniforms.uBig, ORB.big.x, ORB.big.y, ORB.big.radius);
    gl.uniform3f(uniforms.uSmall, ORB.small.x, ORB.small.y, ORB.small.radius);
    return uniforms;
  };

  let uniforms = setup();
  if (!uniforms) return null;
  let ratio = 1;
  /** The size, the map and the colours change rarely: they are sent again only when they do. */
  let stale = true;
  let mapped = -1;
  // The colours' probe: hidden, inside the canvas (fallback content, never drawn).
  const probe = document.createElement('span');
  probe.hidden = true;
  canvas.append(probe);
  let colours = readColours(probe);
  let tinted = false;

  const onLost = (event: Event) => {
    event.preventDefault();
    uniforms = null;
  };
  const onRestored = () => {
    uniforms = setup();
    stale = true;
    mapped = -1;
    tinted = false;
  };
  canvas.addEventListener('webglcontextlost', onLost);
  canvas.addEventListener('webglcontextrestored', onRestored);

  return {
    resize(width, height) {
      ratio = pixelRatio();
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      stale = true;
    },
    draw(frame) {
      if (!uniforms || !canvas.width || !canvas.height) return false;
      if (stale) {
        stale = false;
        gl.uniform2f(uniforms.uSize, canvas.width / ratio, canvas.height / ratio);
        gl.uniform1f(uniforms.uRatio, ratio);
      }
      if (frame.seed !== mapped) {
        mapped = frame.seed;
        const next = random(frame.seed);
        gl.uniform2f(uniforms.uMap, next() * 40, next() * 40);
      }
      if (!tinted) {
        tinted = true;
        // The map's lines are blended premultiplied, as the canvas is.
        const [red, green, blue, alpha] = colours.map;
        gl.uniform4f(uniforms.uMapInk, red * alpha, green * alpha, blue * alpha, alpha);
        gl.uniform3f(uniforms.uRim, colours.rim[0], colours.rim[1], colours.rim[2]);
        gl.uniform3f(uniforms.uHole, colours.hole[0], colours.hole[1], colours.hole[2]);
      }
      gl.uniform1f(uniforms.uSplit, frame.split);
      gl.uniform1f(uniforms.uFlow, frame.flow);
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      return true;
    },
    recolour() {
      colours = readColours(probe);
      tinted = false;
    },
    dispose() {
      canvas.removeEventListener('webglcontextlost', onLost);
      canvas.removeEventListener('webglcontextrestored', onRestored);
      probe.remove();
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    },
  };
}
```

### world-status/gl.ts

```tsx
/*
 * What the terrain and the orb share: a WebGL context with standard derivatives (for crisp contour
 * lines at any zoom), a program from two shaders, the GLSL noise both are made of, and the world's
 * colours.
 */

/** Pixels per CSS pixel: the screen's, capped at 2 (sharp enough, for half the memory of 3×). */
export const pixelRatio = () => Math.min(2, window.devicePixelRatio || 1);

/** A WebGL context, or null where WebGL or the derivatives extension is missing. */
export function getContext(canvas: HTMLCanvasElement, options: WebGLContextAttributes) {
  const gl = canvas.getContext('webgl', {
    alpha: true,
    premultipliedAlpha: true,
    powerPreference: 'low-power',
    ...options,
  });
  if (!gl || !gl.getExtension('OES_standard_derivatives')) return null;
  return gl;
}

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
  console.error(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
  return null;
}

/** A program from a vertex and a fragment shader, or null if either fails. */
export function createProgram(gl: WebGLRenderingContext, vertex: string, fragment: string) {
  const program = gl.createProgram();
  const shaders = [
    compile(gl, gl.VERTEX_SHADER, vertex),
    compile(gl, gl.FRAGMENT_SHADER, fragment),
  ];
  if (!program || !shaders[0] || !shaders[1]) return null;
  for (const shader of shaders) if (shader) gl.attachShader(program, shader);
  gl.linkProgram(program);
  for (const shader of shaders) if (shader) gl.deleteShader(shader);
  if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
  console.error(gl.getProgramInfoLog(program));
  gl.deleteProgram(program);
  return null;
}

/** Every uniform of a program, by name. */
export function uniformsOf<Name extends string>(
  gl: WebGLRenderingContext,
  program: WebGLProgram,
  names: readonly Name[],
) {
  return Object.fromEntries(
    names.map((name) => [name, gl.getUniformLocation(program, name)]),
  ) as Record<Name, WebGLUniformLocation | null>;
}

/**
 * Value noise: a random value at each corner of a unit grid, blended smoothly in between (quintic
 * easing, so its slopes are smooth too). `fbm` stacks three octaves of it.
 */
export const NOISE = /* glsl */ `
float hash(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * 0.1031);
  q += dot(q, q.yzx + 33.33);
  return fract((q.x + q.y) * q.z);
}
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  float a = hash(i);
  float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0));
  float d = hash(i + vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
  float sum = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 3; i++) {
    sum += amplitude * noise(p);
    p = p * 2.02 + vec2(3.1, 7.7);
    amplitude *= 0.5;
  }
  return sum / 0.875;
}
`;

/** The world's colours, as 0–1 RGB. */
export const COLOURS = /* glsl */ `
const vec3 BLUE = vec3(0.4, 0.62, 1.0);
const vec3 LILAC = vec3(0.78, 0.77, 0.95);
const vec3 CREAM = vec3(0.97, 0.89, 0.7);
const vec3 YELLOW = vec3(0.98, 0.77, 0.3);
const vec3 ORANGE = vec3(0.95, 0.51, 0.18);
`;
```

### world-status/worlds.ts

```tsx
/*
 * The demo's content: the steps of a build, the ideas it types in turn, and the levels of detail
 * the composer offers. A world is a prompt and a seed: the seed, a hash of the prompt, decides where
 * its mountain and hills stand, so the same idea always builds the same world.
 */

export type World = { prompt: string; seed: number };

/** The steps a build goes through, in order. */
export const STEPS = ['Raising the land', 'Carving the rivers', 'Naming every peak'] as const;

/** The world on screen before anything is typed, and the ideas the demo types after it. */
export const FIRST_IDEA = 'Snowy peaks over a warm valley';
export const IDEAS = [
  'A canyon city at dusk',
  'Glass islands on a quiet sea',
  'Terraced hills after the rain',
  'A volcano wrapped in fog',
];

export type Level = { id: string; label: string; pace: number; lines: number };

/** How much detail a world gets: how long it takes, and how many contour lines it has. */
export const LEVELS: readonly Level[] = [
  { id: 'draft', label: 'Draft', pace: 0.7, lines: 6 },
  { id: 'balanced', label: 'Balanced', pace: 1, lines: 9 },
  { id: 'detailed', label: 'Detailed', pace: 1.4, lines: 13 },
];

/** A 32-bit hash of a text (FNV-1a), the same for the same words in any case. */
export function seedOf(text: string) {
  let hash = 0x811c9dc5;
  for (const char of text.trim().toLowerCase()) {
    hash ^= char.codePointAt(0) ?? 0;
    hash = Math.imul(hash, 0x01000193);
  }
  return hash >>> 0;
}

export const worldOf = (prompt: string): World => ({ prompt, seed: seedOf(prompt) });

/** A small seeded random generator (mulberry32): the same seed, the same numbers. */
export function random(seed: number) {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
```

### world-status/icons.tsx

```tsx
import type { SVGProps } from 'react';

/*
 * The widget's icons, drawn on a 16 × 16 grid with 1.5px strokes in `currentColor`, so they take
 * the colour of the text around them.
 */

type IconProps = SVGProps<SVGSVGElement>;

function Icon({ children, ...props }: IconProps) {
  return (
    <svg
      viewBox="0 0 16 16"
      width="16"
      height="16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      {children}
    </svg>
  );
}

/** Points down; the CSS turns it up where it opens something. */
export function ChevronIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
    </Icon>
  );
}

export function PlusIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M8 3.5v9M3.5 8h9" />
    </Icon>
  );
}

export function MicIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <rect x="5.75" y="1.75" width="4.5" height="8" rx="2.25" />
      <path d="M3.5 7.75a4.5 4.5 0 0 0 9 0M8 12.25v2" />
    </Icon>
  );
}

export function ArrowUpIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M8 13V3.25M3.75 7.5 8 3.25l4.25 4.25" />
    </Icon>
  );
}

export function CheckIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="m4 8.25 2.5 2.5L12 5.25" />
    </Icon>
  );
}
```

### world-status/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.
 */
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 };
}
```