# Onboarding tour

> A welcome sheet whose laptop zooms into each part of the system it introduces, blurring between steps.

The welcome window of a made-up desktop system: a laptop at the top, a title, two lines, page dots and a wide Continue button. Each step points the laptop’s camera at what it introduces: the menu bar’s control panel, the widgets, a search launcher and the controls’ settings. The frame swells and blurs on the way, and the texts blur out and back in.

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

Category: Navigation.

## Anatomy

1. **Window**: The welcome sheet, over a drawn desktop. Every size in it is a share of its width, so it keeps its proportions.
2. **Laptop**: The picture of the system. Each step frames a part of its screen by a translation and a scale, with a slight swell on the way, in 500 ms.
3. **Back**: Goes to the step before, like the left arrow key. Faint and inert on the first step.
4. **Close**: Ends the tour at once: calls `onFinish` and starts over from the first step.
5. **Page dots**: One dot per step; the current one stretches into a pill and the others slide aside.
6. **Title**: One line naming what the step shows. On a change it blurs out and in through a glow copy.
7. **Lines**: The subtitle, always two lines, muted. It blurs with the title and the screen.
8. **Continue**: Half the window wide. Moves on; on the last step it reads Get Started and finishes the tour.

## When to use

- A first run after a redesign, where a few new places on one screen each deserve a sentence.
- Three to six steps that all point at parts of the same picture: the camera moves, the screen stays.
- A welcome sheet the reader can leave at any time: Close ends it, Back and the arrow keys go over it again.
- Product launches and what’s-new moments, where the picture carries the message and the copy stays to two lines.

## When not to use

- Teaching a task step by step in the live interface. Use coach marks anchored to the real controls.
- Anything the reader must read or agree to before going on: a tour can be closed at once. Use a dialog.
- More than about six steps, or steps with long explanations. Link to a help page instead.
- Features spread over several screens: one picture can’t frame them. Use a short series of pages.
- A single new thing to point out. A badge or a one-line tip next to it is enough.

## Installation

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

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

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

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

## Usage

```tsx
import { OnboardingTour } from './onboarding-tour';

export function Welcome({ onDone }: { onDone: () => void }) {
  return <OnboardingTour autoplay={false} onFinish={onDone} />;
}
```

## States

- **Welcome**: The first step: the whole laptop, and Back faint because there is nowhere to go back to.
- **Zoomed on the menu bar**: The laptop scaled 1.78× about the menu bar’s right end, its control panel open.
- **Zoomed on the widgets**: The same scale about the menu bar’s left end, where two widgets sit: a month and a set of gauges.
- **Zoomed on search**: A gentler 1.4× on the middle of the screen, where the launcher opens.
- **Last step**: Back to the whole laptop; the button reads Get Started, which calls `onFinish` and starts over.
- **Light and dark**: Set by `colorScheme`, or the page’s color-scheme without it: the window, texts, dots and desktop change; the laptop is a picture and stays the same.

## How it works

- Each step has a framing for the laptop: a scale and a point that stays put. A step change plays one Web Animations transform from wherever the laptop is to the new framing, fast at first and settling slowly, with a slight swell halfway.
- The screen blurs during the change without a blur filter: a veil painted with soft gradients, showing the screen out of focus, covers it within a fifth of the change and clears last, and the screen’s content changes while it is covered.
- The title, the lines and the button’s label blur out and in the same way. Each text has a twin drawn as a soft glow (a text shadow on transparent letters), and the two cross-fade around the swap.
- The laptop’s screen is HTML and CSS: a wallpaper of SVG gradients, a menu bar, a control panel, widgets, a launcher with an app grid, and a settings window. Every part is always there and each step shows its own by opacity, so a change only repaints.
- The page dots are fixed-size boxes clipped to a dot or to the current pill, sliding on a springy curve. A timer swaps the content while the blur hides it, so nothing waits on an animation’s end.
- The window, its texts and controls, and the desktop drawn around it are light-dark() tokens (the desktop’s SVG gradient stops read them), so it follows the page’s color-scheme, or the colorScheme prop: a white window on a pale lilac desktop by day, a dark one on deep blue at night. The laptop and the system on its screen are a picture and look the same on both.

## API reference

- **defaultStep** (`number`, default `0`): The step to open on, from 0.
- **onStepChange** (`(step: number) => void`): Called with the new step, from 0, each time it changes.
- **onFinish** (`() => void`): Called when the reader gets started on the last step, or closes the tour.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer presses Continue through the steps, goes back once, gets started and starts over.
- **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.

## Accessibility

- Back, Close the tour and Continue are real buttons with visible focus rings. With focus in the window, the Left and Right arrow keys change the step.
- Each step change the reader makes is announced in a polite live region (step number, title and both lines); the demo’s changes stay silent.
- The laptop, its screen, the wallpaper, the dots and the glow copies of the texts are hidden from screen readers.
- Under reduced motion the step changes at once and the demo never plays. Keyboard focus inside the window also stops the demo.

## Source

### onboarding-tour/onboarding-tour.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
} from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import { Screen } from './screen';
import { ORIGIN, STEPS, framingVars } from './steps';
import { useAutoplay } from './use-autoplay';
import { Backdrop } from './wallpaper';
import styles from './onboarding-tour.module.css';

/*
 * Onboarding tour: the welcome sheet of a desktop system. A laptop at the top shows the system's
 * screen; under it, a title, two lines, page dots and a wide Continue button. Each step points
 * the laptop's camera at the part it introduces: the menu bar's control panel, the widgets, the
 * search launcher, the controls' settings.
 *
 * - A step's framing is a translation and a scale of the laptop, as CSS variables (steps.ts).
 *   The laptop is one layer of a fixed size; once it settles, the browser draws it again at its
 *   new scale, so it stays sharp zoomed in.
 * - A step change plays one transform animation on the laptop, from wherever it is to the new
 *   framing, with a slight swell on the way. Its screen blurs meanwhile: a veil fades in over it
 *   and out again, the screen as it looks out of focus, painted with soft gradients (a light
 *   patch where the scene's panel is). No blur filter is drawn or animated. The screen's content
 *   changes while the veil hides it.
 * - The title, the lines and the button's label blur out and in the same way: each text has a
 *   copy drawn as a soft glow (a text shadow on transparent letters), and the two cross-fade.
 * - Nothing waits on an animation's end: a timer swaps the content halfway. Under reduced motion
 *   a step changes at once.
 * - While on screen and untouched, a ghost pointer presses Continue through the steps, goes back
 *   once, gets started and starts over.
 * - Light or dark: the window, its texts and controls, and the desktop around it are light-dark()
 *   tokens in the CSS, so they follow the page's color-scheme, or the one the colorScheme prop
 *   sets. The laptop and the system on its screen are a picture, the same on both.
 */

/** How long a step change takes, and when its content is swapped, in ms. */
const DURATION = 500;
const SWAP = 200;
/** How much the laptop swells halfway through a step change. */
const SWELL = 0.045;
/** The dots: a dot's size, the gap between dots and the current one's length, in px. */
const DOT = 4;
const GAP = 4.8;
const PILL = 18;

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const opacityOf = (element: Element) => Number(getComputedStyle(element).opacity);

/** The laptop's translation (px) and scale, read from its transform as it shows now. */
function transformOf(element: Element) {
  const matrix = new DOMMatrixReadOnly(getComputedStyle(element).transform);
  return { x: matrix.e, y: matrix.f, k: matrix.a };
}

/** The ways the tour moves: on, back, or done (it starts over from the first step). */
type Move = 'next' | 'back' | 'finish';

export function OnboardingTour({
  defaultStep = 0,
  onStepChange,
  onFinish,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The step to open on, from 0. */
  defaultStep?: number;
  /** Called with the new step, from 0, each time it changes. */
  onStepChange?: (step: number) => void;
  /** Called when the reader gets started on the last step, or closes the tour. */
  onFinish?: () => void;
  /** While on screen and untouched, a ghost pointer presses through the steps. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const last = STEPS.length - 1;
  const first = Math.min(last, Math.max(0, Math.round(defaultStep)));
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(8000);
  /** The step the tour is on, and the one its screen and texts show (the same, but mid-change). */
  const [step, setStep] = useState(first);
  const [shown, setShown] = useState(first);
  const [message, setMessage] = useState('');
  /** Keyboard focus is in the tour: the ghost leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const stage = useRef<HTMLDivElement>(null);
  const sheet = useRef<HTMLElement>(null);
  const laptop = useRef<HTMLDivElement>(null);
  const blur = useRef<HTMLDivElement>(null);
  const copy = useRef<HTMLDivElement>(null);
  const label = useRef<HTMLSpanElement>(null);
  const backButton = useRef<HTMLButtonElement>(null);
  const continueButton = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const stopGhost = useRef<(() => void) | null>(null);
  const swap = useRef(0);
  const running = useRef<Animation[]>([]);

  useEffect(() => () => window.clearTimeout(swap.current), []);

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

  /** Plays the change to `next`: the laptop's move and swell, the blur, the texts' cross-fade. */
  const play = (next: number, relabel: boolean) => {
    const frame = sheet.current;
    const body = laptop.current;
    const veil = blur.current;
    const texts = copy.current;
    if (!frame || !body || !veil || !texts) return;
    // Where everything is now, mid-change included, before the running animations let go.
    const from = transformOf(body);
    const fading = [...texts.querySelectorAll<HTMLElement>('[data-text]')].filter(
      (element) => relabel || !label.current?.contains(element),
    );
    const opacities = new Map(fading.map((element) => [element, opacityOf(element)]));
    const veiled = opacityOf(veil);
    running.current.forEach((animation) => animation.cancel());

    // The new framing, in px for the window's width now.
    const width = frame.offsetWidth;
    const { scale, x, y } = STEPS[next].framing;
    const to = {
      x: (x - ORIGIN.x) * (1 - scale) * width,
      y: (y - ORIGIN.y) * (1 - scale) * width,
      k: scale,
    };
    // The swell scales about the middle of the laptop's area.
    const corner = { x: ORIGIN.x * width, y: ORIGIN.y * width };
    const middle = { x: width / 2, y: width * 0.3 };
    const frames: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const t = i / 16;
      const swell = 1 + SWELL * Math.sin(Math.PI * t);
      const tx = from.x + (to.x - from.x) * t;
      const ty = from.y + (to.y - from.y) * t;
      const k = from.k + (to.k - from.k) * t;
      frames.push({
        transform: `translate(${middle.x - corner.x + swell * (corner.x + tx - middle.x)}px, ${
          middle.y - corner.y + swell * (corner.y + ty - middle.y)
        }px) scale(${swell * k})`,
      });
    }
    // The blur comes on within a fifth of the change and holds while the content swaps (at 40%);
    // the new texts come back into focus last. Each segment eases on its own.
    const ease = (keyframes: Keyframe[]) =>
      keyframes.map((keyframe) => ({ easing: 'ease-in-out', ...keyframe }));
    const animations = [
      // Quick off the mark, with a long settle: a third of the way in a fifth of the time.
      body.animate(frames, { duration: DURATION, easing: 'cubic-bezier(0.45, 0.6, 0.15, 1)' }),
      veil.animate(
        ease([
          { opacity: veiled },
          { opacity: 1, offset: 0.18 },
          { opacity: 1, offset: 0.64 },
          { opacity: 0.35, offset: 0.8 },
          { opacity: 0, offset: 0.94 },
          { opacity: 0 },
        ]),
        DURATION,
      ),
      ...fading.map((element) => {
        const now = opacities.get(element) ?? 0;
        // The letters fade under their glow, which then fades into the new letters.
        const keyframes: Keyframe[] =
          element.dataset.text === 'glow'
            ? [
                { opacity: now },
                { opacity: 1, offset: 0.16 },
                { opacity: 0.5, offset: 0.38 },
                { opacity: 0.5, offset: 0.42 },
                { opacity: 1, offset: 0.6 },
                { opacity: 0.6, offset: 0.8 },
                { opacity: 0, offset: 0.97 },
                { opacity: 0 },
              ]
            : [
                { opacity: now },
                { opacity: 0, offset: 0.2 },
                { opacity: 0, offset: 0.56 },
                { opacity: 0.45, offset: 0.8 },
                { opacity: 1, offset: 0.95 },
                { opacity: 1 },
              ];
        return element.animate(ease(keyframes), DURATION);
      }),
    ];
    running.current = animations;
  };

  /** Goes to `next`. The reader's moves are announced; the demo's are not. */
  const go = (next: number, move: Move, byReader: boolean) => {
    window.clearTimeout(swap.current);
    const relabel = (next === last) !== (shown === last);
    setStep(next);
    onStepChange?.(next);
    if (byReader) {
      const { title, lines } = STEPS[next];
      const where = `Step ${next + 1} of ${STEPS.length}: ${title}. ${lines.join(' ')}`;
      announce(move === 'finish' ? `Tour finished, starting over. ${where}` : where);
    }
    if (reducedMotion()) {
      setShown(next);
      return;
    }
    play(next, relabel);
    swap.current = window.setTimeout(() => setShown(next), SWAP);
  };

  const act = (move: Move, byReader: boolean) => {
    if (move === 'back') {
      if (step > 0) go(step - 1, move, byReader);
    } else if (move === 'next' && step < last) go(step + 1, move, byReader);
    else {
      onFinish?.();
      go(0, 'finish', byReader);
    }
  };

  /** The demo's presses: the same moves, quietly. */
  const demoAct = useEffectEvent((move: Move) => act(move, false));
  const demoStep = useEffectEvent(() => step);

  /** The reader takes over: the ghost stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopGhost.current?.();
    take();
  };

  const demo = autoplay && playing && !keyboard;
  useEffect(() => {
    const area = stage.current;
    const arrow = pointer.current;
    if (!demo || !area || !arrow) return;
    const stop = runDemo(area, arrow, async ({ wait, moveTo, press }) => {
      const button = () => continueButton.current;
      await wait(1100);
      if (demoStep() !== 0) {
        demoAct('finish');
        await wait(1500);
      }
      for (let round = 0; ; round += 1) {
        await moveTo(button(), { x: 0.7, y: 0.72 });
        for (let i = 0; i < last; i += 1) {
          await wait(round === 0 && i === 0 ? 1000 : 1700);
          await press(() => demoAct('next'));
          await wait(520);
          // A small shift, as a resting hand makes.
          await moveTo(button(), { x: 0.66 + (i % 2) * 0.05, y: 0.66 + (i % 3) * 0.06 });
        }
        // On the last step: back once, on again, and get started.
        await wait(1300);
        await moveTo(backButton.current);
        await wait(250);
        await press(() => demoAct('back'));
        await wait(1500);
        await moveTo(button(), { x: 0.68, y: 0.7 });
        await wait(300);
        await press(() => demoAct('next'));
        await wait(2000);
        await press(() => demoAct('finish'));
        await wait(1900);
      }
    });
    stopGhost.current = stop;
    return stop;
  }, [demo, last]);

  const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
    if (event.key === 'ArrowRight' && step < last) act('next', true);
    else if (event.key === 'ArrowLeft' && step > 0) act('back', true);
    else return;
    event.preventDefault();
  };
  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };

  const { title, lines, scene } = STEPS[shown];
  const action = shown === last ? 'Get Started' : 'Continue';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div ref={stage} className={styles.stage} data-part="stage">
        <div className={styles.backdrop} aria-hidden="true">
          <Backdrop />
        </div>

        <section
          ref={sheet}
          className={styles.window}
          data-part="window"
          aria-label="Welcome tour"
          aria-roledescription="tour"
          onKeyDown={onKeyDown}
        >
          <div
            ref={laptop}
            className={styles.laptop}
            data-part="laptop"
            style={framingVars(STEPS[step].framing) as CSSProperties}
            aria-hidden="true"
          >
            <div className={styles.lid}>
              <div className={styles.screen} data-part="screen">
                <Screen scene={scene} />
                <div ref={blur} className={styles.veil} data-scene={scene} />
              </div>
              <span className={styles.notch} />
            </div>
            <span className={styles.base} />
          </div>
          <div className={styles.shade} aria-hidden="true" />

          <button
            ref={backButton}
            type="button"
            className={`${styles.corner} ${styles.back}`}
            data-part="back"
            aria-label="Back"
            aria-disabled={step === 0 || undefined}
            onClick={() => act('back', true)}
          >
            <svg viewBox="0 0 12 12" aria-hidden="true">
              <path d="M7.4 2.6 4 6l3.4 3.4" />
            </svg>
          </button>
          <button
            type="button"
            className={`${styles.corner} ${styles.close}`}
            data-part="close"
            aria-label="Close the tour"
            onClick={() => act('finish', true)}
          >
            <svg viewBox="0 0 12 12" aria-hidden="true">
              <path d="m3.4 3.4 5.2 5.2m0-5.2L3.4 8.6" />
            </svg>
          </button>

          <div ref={copy} className={styles.copy}>
            <span className={styles.dots} data-part="dots" aria-hidden="true">
              {STEPS.map((item, i) => (
                <span
                  key={item.title}
                  className={styles.dot}
                  data-part="dot"
                  data-on={i === step || undefined}
                  style={
                    { '--x': `${i * (DOT + GAP) + (i > step ? PILL - DOT : 0)}px` } as CSSProperties
                  }
                />
              ))}
            </span>
            <h3 className={styles.title} data-part="title">
              <span data-text="">{title}</span>
              <span data-text="glow" aria-hidden="true">
                {title}
              </span>
            </h3>
            <p className={styles.lines} data-part="lines">
              <span data-text="">
                {lines[0]}
                <br />
                {lines[1]}
              </span>
              <span data-text="glow" aria-hidden="true">
                {lines[0]}
                <br />
                {lines[1]}
              </span>
            </p>
            <button
              ref={continueButton}
              type="button"
              className={styles.continue}
              data-part="continue"
              onClick={() => act('next', true)}
            >
              <span ref={label} className={styles.label}>
                <span data-text="">{action}</span>
                <span data-text="glow" aria-hidden="true">
                  {action}
                </span>
              </span>
            </button>
          </div>
        </section>

        <DemoPointer ref={pointer} />
        <p className={styles.status} aria-live="polite">
          {message}
        </p>
      </div>
    </div>
  );
}
```

### onboarding-tour/onboarding-tour.module.css

```css
/* Onboarding tour (onboarding-tour.tsx). The window's size follows the stage's width (the root is
   a size container) as --w, and everything in it is a share of --w, so it keeps its proportions;
   below about 470px of stage the texts keep a readable size and the window grows a little taller.

   Memory and layout. The stage is one layout boundary of a known size: a change inside it (a new
   title) lays out the stage alone. What moves has a layer of its own, of a fixed size: the
   laptop (scaled, never resized), the veil over its screen, the texts and their glows, the
   dots and the pointer. The drawings never change.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). The laptop is an object and its screen a
   picture of the system (screen.module.css, wallpaper.tsx): they look the same on both. */

.root {
  /* The desktop around the window (under its drawing), and the window. */
  --surface: #d9d0fb;
  --surface-2: #fff;
  /* The title and the lines; their glow copies, the same colours out of focus. */
  --ink: #1b1c24;
  --ink-muted: #5c5f6e;
  --ink-glow: rgb(27 28 36 / 0.9);
  --ink-muted-glow: rgb(92 95 110 / 0.95);
  /* The window's hairline edge, and its shadows on the desktop, far and near. */
  --line: rgb(30 30 80 / 0.08);
  --shadow: rgb(40 30 150 / 0.2);
  --shadow-near: rgb(40 30 150 / 0.12);
  /* Back and close: at rest, hovered, and Back on the first step; on light, a pale disc under
     them, unseen on the window but there when the zoomed screen slides under them. */
  --control: rgb(20 22 40 / 0.5);
  --control-hover: rgb(20 22 40 / 0.82);
  --control-off: rgb(20 22 40 / 0.2);
  --control-fill: rgb(255 255 255 / 0.8);
  /* The page dots, and the current one. */
  --dot: #8e909c;
  --dot-on: #1b1c24;
  /* Continue's blue, top and bottom: at rest, hovered, pressed. Darker on light, where its label
     needs it; its label, and its shadow. */
  --accent: #0b72ea;
  --accent-end: #0866dc;
  --accent-hover: #0a6be2;
  --accent-hover-end: #075fd0;
  --accent-press: #075fd0;
  --accent-press-end: #0554c0;
  --accent-ink: #fff;
  --accent-shadow: rgb(8 50 150 / 0.25);
  --focus: #0a66d8;
  /* The desktop's drawing (wallpaper.tsx): its sky from the top, the band of light, the low
     ground from its top, the glow in the corner and the rise on the right. */
  --desk-1: #d2c6fb;
  --desk-2: #dccffc;
  --desk-3: #e2dcfc;
  --desk-4: #d8e0fb;
  --desk-5: #d0daf9;
  --desk-6: #c9d3f7;
  --desk-band-1: #f3f1ff;
  --desk-band-2: #ebeaff;
  --desk-band-3: #e2e5ff;
  --desk-low-1: #c2d5fa;
  --desk-low-2: #cad8f9;
  --desk-low-3: #d0d7f7;
  --desk-glow: #eef1ff;
  --desk-rise: #bccff8;
  container-type: inline-size;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#d9d0fb, #2410d4);
    --surface-2: light-dark(#fff, #16192b);
    --ink: light-dark(#1b1c24, #f1f1f5);
    --ink-muted: light-dark(#5c5f6e, #bcbec9);
    --ink-glow: light-dark(rgb(27 28 36 / 0.9), rgb(241 241 245 / 0.9));
    --ink-muted-glow: light-dark(rgb(92 95 110 / 0.95), rgb(188 190 201 / 0.95));
    --line: light-dark(rgb(30 30 80 / 0.08), rgb(255 255 255 / 0.08));
    --shadow: light-dark(rgb(40 30 150 / 0.2), rgb(10 0 80 / 0.42));
    --shadow-near: light-dark(rgb(40 30 150 / 0.12), rgb(10 0 80 / 0.3));
    --control: light-dark(rgb(20 22 40 / 0.5), rgb(255 255 255 / 0.32));
    --control-hover: light-dark(rgb(20 22 40 / 0.82), rgb(255 255 255 / 0.75));
    --control-off: light-dark(rgb(20 22 40 / 0.2), rgb(255 255 255 / 0.2));
    --control-fill: light-dark(rgb(255 255 255 / 0.8), transparent);
    --dot: light-dark(#8e909c, #555869);
    --dot-on: light-dark(#1b1c24, #e9eaf0);
    --accent: light-dark(#0b72ea, #0ba3ff);
    --accent-end: light-dark(#0866dc, #0a8afa);
    --accent-hover: light-dark(#0a6be2, #26adff);
    --accent-hover-end: light-dark(#075fd0, #1894fb);
    --accent-press: light-dark(#075fd0, #0896f2);
    --accent-press-end: light-dark(#0554c0, #087de6);
    --accent-shadow: light-dark(rgb(8 50 150 / 0.25), rgb(0 0 30 / 0.25));
    --focus: light-dark(#0a66d8, #8cc2ff);
    --desk-1: light-dark(#d2c6fb, #3a02dc);
    --desk-2: light-dark(#dccffc, #4c22e2);
    --desk-3: light-dark(#e2dcfc, #1c10ca);
    --desk-4: light-dark(#d8e0fb, #0a30c8);
    --desk-5: light-dark(#d0daf9, #0420be);
    --desk-6: light-dark(#c9d3f7, #0204b4);
    --desk-band-1: light-dark(#f3f1ff, #8aa0f7);
    --desk-band-2: light-dark(#ebeaff, #5d78ee);
    --desk-band-3: light-dark(#e2e5ff, #3a58e4);
    --desk-low-1: light-dark(#c2d5fa, #0d5edc);
    --desk-low-2: light-dark(#cad8f9, #0634c6);
    --desk-low-3: light-dark(#d0d7f7, #0208b6);
    --desk-glow: light-dark(#eef1ff, #7d93e4);
    --desk-rise: light-dark(#bccff8, #1c5ad8);
  }
}
/* 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;
}

.stage {
  /* The window's width, and the margin around it. */
  --w: max(min(58cqw, 460px), min(100cqw - 32px, 300px));
  --m: clamp(16px, 6.75cqw, 56px);
  position: relative;
  display: grid;
  place-items: center;
  height: calc(var(--w) * 0.7396 + 88px + var(--m) * 2);
  overflow: hidden;
  overflow: clip;
  contain: size layout;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.06);
  font-family: -apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}

.backdrop {
  position: absolute;
  inset: 0;
}

/* The window: rounded, over the desktop. It clips the laptop as it zooms. */
.window {
  position: relative;
  width: var(--w);
  height: calc(var(--w) * 0.7396 + 88px);
  overflow: hidden;
  border-radius: calc(var(--w) * 0.055);
  background: var(--surface-2);
  box-shadow:
    0 calc(var(--w) * 0.05) calc(var(--w) * 0.12) var(--shadow),
    0 2px 6px var(--shadow-near);
}
/* Its hairline edge, over everything in it. */
.window::after {
  content: '';
  position: absolute;
  z-index: 4;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px var(--line);
  pointer-events: none;
}

/* The laptop. --u is a hundredth of the window's width; the transform frames the step (--tx and
   --ty in shares of the window's width, --k the scale) from the laptop's top left corner. */
.laptop {
  --u: calc(var(--w) * 0.01);
  position: absolute;
  top: calc(var(--w) * 0.0565);
  left: calc(var(--w) * 0.0603);
  width: calc(var(--u) * 87.94);
  height: calc(var(--u) * 51.65);
  transform: translate(calc(var(--w) * var(--tx)), calc(var(--w) * var(--ty))) scale(var(--k));
  transform-origin: 0 0;
  will-change: transform;
}
.lid {
  position: absolute;
  top: 0;
  left: calc(var(--u) * 5.28);
  width: calc(var(--u) * 77.38);
  height: calc(var(--u) * 49.65);
  border-radius: calc(var(--u) * 1.7) calc(var(--u) * 1.7) 0 0;
  background: #05060a;
  box-shadow: inset 0 0 0 calc(var(--u) * 0.24) #3b3e4c;
}
.screen {
  position: absolute;
  inset: calc(var(--u) * 1.26) calc(var(--u) * 1.25) calc(var(--u) * 0.5);
  overflow: hidden;
  border-radius: calc(var(--u) * 0.8) calc(var(--u) * 0.8) 0 0;
  background: #1a0cc4;
}
.notch {
  position: absolute;
  top: calc(var(--u) * 1.25);
  left: 50%;
  width: calc(var(--u) * 8.3);
  height: calc(var(--u) * 1.64);
  margin-left: calc(var(--u) * -4.15);
  border-radius: 0 0 calc(var(--u) * 0.9) calc(var(--u) * 0.9);
  background: #05060a;
}
/* The base: a light bar with a scoop in the middle of its front edge. */
.base {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: calc(var(--u) * 2);
  border-radius: calc(var(--u) * 0.3) calc(var(--u) * 0.3) calc(var(--u) * 1.4)
    calc(var(--u) * 1.4) / calc(var(--u) * 0.3) calc(var(--u) * 0.3) calc(var(--u) * 1.2)
    calc(var(--u) * 1.2);
  background: linear-gradient(#a6a9b4, #8d909c 50%, #666975);
}
.base::after {
  content: '';
  position: absolute;
  top: 0;
  left: 50%;
  width: calc(var(--u) * 11);
  height: calc(var(--u) * 0.6);
  margin-left: calc(var(--u) * -5.5);
  border-radius: 0 0 calc(var(--u) * 0.8) calc(var(--u) * 0.8);
  background: rgb(70 72 84 / 0.55);
}

/* The screen out of focus, for the step changes: the wallpaper's colours as soft gradients, and a
   light patch where the scene's panel is (--patch). It only fades in and out. */
.veil {
  --patch: linear-gradient(transparent, transparent);
  position: absolute;
  inset: 0;
  background:
    var(--patch), radial-gradient(34% 30% at 64% 33%, rgb(140 80 246 / 0.55), transparent),
    radial-gradient(48% 16% at 32% 49%, rgb(112 128 246 / 0.75), transparent),
    radial-gradient(16% 34% at 50% 100%, rgb(14 10 160 / 0.9), transparent),
    radial-gradient(44% 40% at 10% 96%, rgb(150 162 236 / 0.95), transparent 75%),
    radial-gradient(42% 66% at 98% 88%, rgb(176 186 242 / 0.95), transparent 75%),
    linear-gradient(#2412d4, #4524e4 28%, #5530ea 37%, #3a34dc 48%, #2c38d4 62%, #2026c2);
  opacity: 0;
  will-change: opacity;
  pointer-events: none;
}
.veil[data-scene='menu-bar'] {
  --patch: radial-gradient(14% 22% at 90% 17%, rgb(190 192 255 / 0.55), transparent);
}
.veil[data-scene='widgets'] {
  --patch: radial-gradient(16% 16% at 13% 13%, rgb(160 150 255 / 0.5), transparent);
}
.veil[data-scene='search'] {
  --patch: radial-gradient(34% 34% at 49% 66%, rgb(70 60 200 / 0.65), transparent);
}
.veil[data-scene='controls'] {
  --patch:
    radial-gradient(14% 22% at 90% 17%, rgb(190 192 255 / 0.5), transparent),
    radial-gradient(26% 44% at 52% 46%, rgb(36 26 150 / 0.65), transparent);
}

/* The window's lower part: the laptop's screen fades into it when it zooms in. */
.shade {
  position: absolute;
  z-index: 1;
  top: calc(var(--w) * 0.573);
  right: 0;
  bottom: 0;
  left: 0;
  background: linear-gradient(transparent, var(--surface-2) calc(var(--w) * 0.15));
  pointer-events: none;
}

/* Back and close, small and faint in the top corners. */
.corner {
  position: absolute;
  z-index: 3;
  top: max(2px, calc(var(--w) * 0.041 - 12px));
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--control-fill);
  color: var(--control);
  cursor: pointer;
  transition: color 0.15s ease;
}
.corner:hover {
  color: var(--control-hover);
}
.corner[aria-disabled] {
  color: var(--control-off);
  cursor: default;
}
.corner:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}
.corner svg {
  display: block;
  width: 11px;
  height: 11px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.back {
  left: max(2px, calc(var(--w) * 0.041 - 12px));
}
.close {
  right: max(2px, calc(var(--w) * 0.041 - 12px));
}

/* The dots, the title, the lines and the button, from the bottom up. */
.copy {
  position: absolute;
  z-index: 2;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  height: calc(var(--w) * 0.17 + 88px);
  padding: 0 calc(var(--w) * 0.06) clamp(13px, calc(var(--w) * 0.0457), 21px);
  text-align: center;
}

.dots {
  position: relative;
  flex: none;
  width: 53.2px;
  height: 4px;
  margin-bottom: clamp(10px, calc(var(--w) * 0.027), 12.5px);
  will-change: transform;
}
/* Each dot is the current one's length, clipped to a dot unless it is current. */
.dot {
  position: absolute;
  top: 0;
  left: 0;
  width: 18px;
  height: 4px;
  border-radius: 99px;
  background: var(--dot);
  clip-path: inset(0 14px 0 0 round 99px);
  transform: translateX(var(--x));
  transition:
    transform 0.46s cubic-bezier(0.3, 1.25, 0.5, 1),
    clip-path 0.46s cubic-bezier(0.3, 1.25, 0.5, 1),
    background-color 0.3s ease;
}
.dot[data-on] {
  background: var(--dot-on);
  clip-path: inset(0 0 0 0 round 99px);
}

/* A text and its glow, stacked: the glow is the same text as a soft shadow, for the blur. */
.title,
.lines,
.label {
  display: grid;
}
.title > *,
.lines > *,
.label > * {
  grid-area: 1 / 1;
  will-change: opacity;
}
.title > [data-text='glow'],
.lines > [data-text='glow'],
.label > [data-text='glow'] {
  color: transparent;
  opacity: 0;
}

.title {
  margin: 0 0 clamp(3px, calc(var(--w) * 0.0098), 4.5px);
  font-size: clamp(15px, calc(var(--w) * 0.0435), 20px);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.005em;
  color: var(--ink);
  white-space: nowrap;
}
.title > [data-text='glow'] {
  text-shadow: 0 0 8px var(--ink-glow);
}
.lines {
  margin: 0 0 clamp(10px, calc(var(--w) * 0.0315), 14.5px);
  font-size: 10.8px;
  line-height: 1.39;
  color: var(--ink-muted);
  white-space: nowrap;
}
.lines > [data-text='glow'] {
  text-shadow: 0 0 6px var(--ink-muted-glow);
}

.continue {
  flex: none;
  display: grid;
  place-items: center;
  width: calc(var(--w) * 0.502);
  height: clamp(28px, calc(var(--w) * 0.0744), 34px);
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 99px;
  background: linear-gradient(var(--accent), var(--accent-end));
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.16),
    0 1px 2px var(--accent-shadow);
  color: var(--accent-ink);
  font: inherit;
  font-size: 11px;
  font-weight: 500;
  cursor: pointer;
}
.continue:hover {
  background: linear-gradient(var(--accent-hover), var(--accent-hover-end));
}
.continue:active {
  background: linear-gradient(var(--accent-press), var(--accent-press-end));
}
.continue:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.label > [data-text='glow'] {
  text-shadow: 0 0 5px rgb(255 255 255 / 0.95);
}

/* Read by screen readers only, on a layer of its own (a new message paints nothing else). */
.status {
  position: absolute;
  will-change: opacity;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .dot,
  .corner {
    transition: none;
  }
}
```

### onboarding-tour/steps.ts

```tsx
/*
 * The tour's steps: what each one says, and where the laptop's camera points. A framing zooms the
 * laptop by `scale` about a point that stays put, given as shares of the window's width (the
 * window is about as tall as it is wide, so one unit serves both axes).
 */

/** What the laptop's screen shows on a step (screen.tsx draws each layer). */
export type Scene = 'desktop' | 'menu-bar' | 'widgets' | 'search' | 'controls';

export type Framing = {
  /** How much the laptop is zoomed: 1 shows it whole. */
  scale: number;
  /** The point that stays put while it zooms, in shares of the window's width. */
  x: number;
  y: number;
};

export type TourStep = {
  title: string;
  /** The subtitle, as its two lines. */
  lines: [string, string];
  scene: Scene;
  framing: Framing;
};

export const STEPS: TourStep[] = [
  {
    title: 'Welcome to Lumen OS 7',
    lines: ['Introducing a new design made', 'of Prism Glass.'],
    scene: 'desktop',
    framing: { scale: 1, x: 0.5, y: 0.3 },
  },
  {
    title: 'New Menu Bar',
    lines: ['Get around your desktop easily with', 'a sleek, adaptive menu bar.'],
    scene: 'menu-bar',
    // The control panel, under the menu bar's right end.
    framing: { scale: 1.78, x: 0.884, y: 0 },
  },
  {
    title: 'Personalized Home Screen',
    lines: ['Make your space truly yours by', 'adding and arranging widgets.'],
    scene: 'widgets',
    // The widgets, under the menu bar's left end.
    framing: { scale: 1.78, x: 0.123, y: 0 },
  },
  {
    title: 'All New Quick Search',
    lines: ['Find anything in an instant with', 'capable, intuitive search tools.'],
    scene: 'search',
    // The launcher, in the middle of the screen.
    framing: { scale: 1.4, x: 0.5, y: 0.62 },
  },
  {
    title: 'Customize Menu Bar Controls',
    lines: ['Reorder and resize controls', "for a menu bar that's made for you."],
    scene: 'controls',
    framing: { scale: 1, x: 0.5, y: 0.3 },
  },
];

/** The laptop's top left corner in the window, in shares of the window's width. */
export const ORIGIN = { x: 0.0603, y: 0.0565 };

/**
 * The transform that frames a step, as CSS variables: a translation in shares of the window's
 * width, and a scale. The laptop scales from its top left corner, so zooming about a point that
 * stays put moves it by (point − corner) × (1 − scale).
 */
export function framingVars({ scale, x, y }: Framing) {
  return {
    '--tx': ((x - ORIGIN.x) * (1 - scale)).toFixed(4),
    '--ty': ((y - ORIGIN.y) * (1 - scale)).toFixed(4),
    '--k': scale.toFixed(4),
  };
}
```

### onboarding-tour/screen.tsx

```tsx
import type { CSSProperties } from 'react';
import type { Scene } from './steps';
import { Wallpaper } from './wallpaper';
import styles from './screen.module.css';

/*
 * What the laptop's screen shows: the wallpaper, and over it the desktop's parts that the tour
 * introduces (the menu bar, its control panel, two widgets, the search launcher, the controls'
 * settings and the empty slots they can be dragged into). Every part is always there and the
 * scene picks which ones show, by their opacity: changing a step only repaints the screen, and
 * never adds or removes a drawing. Sizes are in hundredths of the screen's width (the
 * stylesheet's --s), so the screen scales as one picture.
 */

/** The parts each scene shows. */
const PARTS: Record<Scene, string> = {
  desktop: '',
  'menu-bar': 'menu panel',
  widgets: 'menu widgets',
  search: 'menu search',
  controls: 'menu panel settings',
};

/** A tile's look: its two colours (top to bottom) and the simple glyph drawn on it. */
type Glyph = 'grid' | 'lines' | 'fold' | 'pin' | 'note' | 'ring' | 'wave' | 'half' | 'plus' | 'dot';
type App = { name: string; from: string; to: string; glyph: Glyph; ink?: string };

/** The launcher's apps, row by row: plain tiles with generic glyphs. */
const APPS: App[] = [
  { name: 'Files', from: '#7cc8ff', to: '#2f84f4', glyph: 'grid' },
  { name: 'Notes', from: '#fde89a', to: '#f0c341', glyph: 'lines', ink: '#8a6a10' },
  { name: 'Mail', from: '#f4f6fb', to: '#cfd5e6', glyph: 'fold', ink: '#3d63d8' },
  { name: 'Maps', from: '#9be890', to: '#3fba59', glyph: 'pin' },
  { name: 'Music', from: '#ffd979', to: '#f2a12a', glyph: 'note' },
  { name: 'Clock', from: '#34363f', to: '#15161c', glyph: 'ring' },
  { name: 'Stocks', from: '#294d39', to: '#10241a', glyph: 'wave', ink: '#7ee08f' },
  { name: 'Terminal', from: '#33343a', to: '#121317', glyph: 'lines', ink: '#c9ccd6' },
  { name: 'Photos', from: '#f6f7fb', to: '#d7dcea', glyph: 'dot', ink: '#f2a12a' },
  { name: 'Books', from: '#cfd2dc', to: '#9ea3b3', glyph: 'half', ink: '#4a4f60' },
  { name: 'Home', from: '#5c98fa', to: '#2b5fd8', glyph: 'fold' },
  { name: 'Calendar', from: '#f6f7fa', to: '#dcdfe8', glyph: 'dot', ink: '#ef4b3c' },
  { name: 'Weather', from: '#eef4ff', to: '#b6cbf2', glyph: 'ring', ink: '#3d74e6' },
  { name: 'Camera', from: '#e9ebf0', to: '#c2c6d1', glyph: 'ring', ink: '#30333d' },
  { name: 'Contacts', from: '#86abff', to: '#3d6cf0', glyph: 'pin' },
  { name: 'Podcasts', from: '#3a3c46', to: '#17181e', glyph: 'wave', ink: '#e0c3ff' },
  { name: 'Wallet', from: '#a8862a', to: '#5d4710', glyph: 'lines', ink: '#f3dc96' },
  { name: 'Reminders', from: '#ff9468', to: '#ee522c', glyph: 'plus' },
  { name: 'Preview', from: '#f3e6c8', to: '#d8bf91', glyph: 'half', ink: '#7a5d26' },
  { name: 'Settings', from: '#a3a8b5', to: '#6a6f7e', glyph: 'ring', ink: '#eef0f5' },
  { name: 'Board', from: '#f3f4f7', to: '#cfd2db', glyph: 'grid', ink: '#30333d' },
];

/** The control panel's tiles with an icon and two lines. */
const TOGGLES = [
  { name: 'Network', state: 'Home', glyph: 'wave' },
  { name: 'Devices', state: 'On', glyph: 'dot' },
  { name: 'Focus', state: 'Off', glyph: 'half' },
  { name: 'Mirror', state: 'Off', glyph: 'fold', plain: true },
] as const;

/** The settings' sidebar, and the controls listed on its page. */
const SECTIONS = [
  'General',
  'Appearance',
  'Display',
  'Wallpaper',
  'Menu Bar',
  'Controls',
  'Widgets',
  'Search',
  'Sound',
  'Focus',
  'Battery',
  'Network',
  'Privacy',
  'Users',
];
const CONTROLS = [
  'Network',
  'Devices',
  'Focus',
  'Mirror',
  'Display',
  'Sound',
  'Timer',
  'Notes',
  'Battery',
  'Clock',
  'Keyboard',
  'Camera',
];
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];

const tileStyle = ({ from, to, ink }: App) =>
  ({ '--from': from, '--to': to, '--glyph': ink ?? '#fff' }) as CSSProperties;

export function Screen({ scene }: { scene: Scene }) {
  return (
    <div className={styles.screen} data-parts={PARTS[scene]}>
      <Wallpaper />

      {/* The menu bar: a mark and the menus on the left, status glyphs and the time on the right. */}
      <div className={`${styles.part} ${styles.menu}`}>
        <span className={styles.mark} />
        <b>Appearance</b>
        <span>Edit</span>
        <span>View</span>
        <span>Window</span>
        <span>Help</span>
        <span className={styles.status}>
          <i className={styles.statusRing} />
          <i className={styles.statusWave} />
          <i className={styles.statusSearch} />
          <i className={styles.statusBattery} />
          <i className={styles.statusSwitch} />
          <span>Mon Jun 30</span>
          <span>9:41</span>
        </span>
      </div>

      {/* The control panel, under the menu bar's right end. */}
      <div className={`${styles.part} ${styles.panel}`}>
        {TOGGLES.map(({ name, state, glyph, ...rest }) => (
          <span key={name} className={styles.tile}>
            <i
              className={`${styles.badge} ${'plain' in rest ? styles.badgePlain : ''}`}
              data-glyph={glyph}
            />
            <span className={styles.tileText}>
              <b>{name}</b>
              <span>{state}</span>
            </span>
          </span>
        ))}
        <span className={styles.row}>
          <i className={styles.knob} />
          <span className={`${styles.tile} ${styles.square}`}>
            <i className={styles.minor} />
          </span>
          <span className={`${styles.tile} ${styles.tileStack}`}>
            <b>Display</b>
            <span className={styles.slider}>
              <i />
            </span>
          </span>
        </span>
        <span className={`${styles.tile} ${styles.wide}`}>
          <b>Sound</b>
          <span className={styles.slider} data-long="">
            <i />
          </span>
          <i className={styles.output} />
        </span>
        <span className={styles.edit}>Edit Controls</span>
      </div>

      {/* Two widgets on the desktop: a month, and three gauges and a reading. */}
      <div className={`${styles.part} ${styles.widgets}`}>
        <span className={styles.widget}>
          <b className={styles.month}>June</b>
          <span className={styles.days}>
            {WEEKDAYS.map((day, i) => (
              <span key={`head-${i}`} data-head="">
                {day}
              </span>
            ))}
            {Array.from({ length: 30 }, (_, i) => (
              <span key={i} data-today={i === 29 || undefined}>
                {i + 1}
              </span>
            ))}
          </span>
        </span>
        <span className={`${styles.widget} ${styles.gauges}`}>
          {[
            { value: 54, label: '54' },
            { value: 0, label: '19°' },
            { value: 70, label: '70' },
            { value: 48, label: '6k' },
          ].map(({ value, label }) =>
            value ? (
              <span
                key={label}
                className={styles.gauge}
                style={{ '--p': `${value}%` } as CSSProperties}
              >
                {label}
              </span>
            ) : (
              <span key={label} className={styles.reading}>
                <i />
                {label}
              </span>
            ),
          )}
        </span>
      </div>

      {/* The search launcher, showing its apps. */}
      <div className={`${styles.part} ${styles.search}`}>
        <span className={styles.searchHead}>
          <i className={styles.back} />
          Applications
        </span>
        <span className={styles.apps}>
          {APPS.map((app) => (
            <span key={app.name} className={styles.app}>
              <i className={styles.appIcon} data-glyph={app.glyph} style={tileStyle(app)} />
              <span>{app.name}</span>
            </span>
          ))}
        </span>
      </div>

      {/* The settings for the menu bar's controls, and the empty slots beside them. */}
      <div className={`${styles.part} ${styles.settings}`}>
        <span className={styles.sidebar}>
          <span className={styles.find}>
            <i />
            Search
          </span>
          {SECTIONS.map((name) => (
            <span key={name}>
              <i />
              {name}
            </span>
          ))}
        </span>
        <span className={styles.page}>
          <span className={styles.controls}>
            {CONTROLS.map((name) => (
              <span key={name}>
                <i />
                {name}
              </span>
            ))}
          </span>
          <span className={styles.single}>
            <i />
            Now Playing
          </span>
          <span className={styles.trio}>
            {['Spaces', 'Stage', 'Hot Corners'].map((name) => (
              <span key={name}>
                <i />
                {name}
              </span>
            ))}
          </span>
          <span className={styles.choices}>
            {['Always', 'When Active', 'Never'].map((name) => (
              <span key={name}>
                <i />
                {name}
              </span>
            ))}
          </span>
          <span className={styles.footer}>
            <i />
          </span>
        </span>
      </div>
      <div className={`${styles.part} ${styles.slots}`}>
        {Array.from({ length: 16 }, (_, i) => (
          <i key={i} />
        ))}
      </div>
    </div>
  );
}
```

### onboarding-tour/screen.module.css

```css
/* The laptop's screen (screen.tsx). Every size is a multiple of --s, a hundredth of the screen's
   width, which the laptop sets (onboarding-tour.module.css): the screen is one picture that
   scales as a whole. Its parts are all there at once; the scene shows some by their opacity. */

.screen {
  --s: calc(var(--u, 4.6px) * 0.7488);
  position: absolute;
  inset: 0;
  overflow: hidden;
  color: #fff;
  font-family: -apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', sans-serif;
  line-height: 1;
  white-space: nowrap;
}
.screen b {
  font-weight: 600;
}

.part {
  position: absolute;
  opacity: 0;
}
.screen[data-parts~='menu'] .menu,
.screen[data-parts~='panel'] .panel,
.screen[data-parts~='widgets'] .widgets,
.screen[data-parts~='search'] .search,
.screen[data-parts~='settings'] .settings,
.screen[data-parts~='settings'] .slots {
  opacity: 1;
}

/* The menu bar. The lid's notch covers its middle. */
.menu {
  top: 0;
  right: 0;
  left: 0;
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 1.7);
  height: calc(var(--s) * 2.6);
  padding: 0 calc(var(--s) * 1.5);
  font-size: calc(var(--s) * 0.98);
  font-weight: 500;
  color: rgb(255 255 255 / 0.84);
}
.menu b {
  font-weight: 700;
}
.mark {
  width: calc(var(--s) * 0.95);
  height: calc(var(--s) * 0.95);
  border-radius: calc(var(--s) * 0.28);
  background: #fff;
  transform: rotate(45deg) scale(0.86);
}
.status {
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 1.25);
  margin-left: auto;
}
.status i {
  position: relative;
  display: block;
}
.statusSearch {
  width: calc(var(--s) * 0.8);
  height: calc(var(--s) * 0.8);
  border: calc(var(--s) * 0.15) solid currentColor;
  border-radius: 50%;
}
.statusSearch::after {
  content: '';
  position: absolute;
  right: calc(var(--s) * -0.42);
  bottom: calc(var(--s) * -0.3);
  width: calc(var(--s) * 0.42);
  height: calc(var(--s) * 0.15);
  border-radius: 9px;
  background: currentColor;
  transform: rotate(45deg);
}
.statusRing {
  width: calc(var(--s) * 0.9);
  height: calc(var(--s) * 0.9);
  border: calc(var(--s) * 0.15) solid currentColor;
  border-radius: 50%;
  background: radial-gradient(circle, currentColor 0 30%, transparent 34%);
}
.statusWave {
  width: calc(var(--s) * 1.2);
  height: calc(var(--s) * 0.6);
  border: calc(var(--s) * 0.17) solid currentColor;
  border-bottom: 0;
  border-radius: 99px 99px 0 0;
}
.statusBattery {
  width: calc(var(--s) * 1.9);
  height: calc(var(--s) * 0.9);
  border: calc(var(--s) * 0.12) solid rgb(255 255 255 / 0.6);
  border-radius: calc(var(--s) * 0.28);
  background: linear-gradient(90deg, #fff 70%, transparent 0) calc(var(--s) * 0.1) 50% /
    calc(100% - var(--s) * 0.2) calc(100% - var(--s) * 0.2) no-repeat;
}
.statusSwitch {
  width: calc(var(--s) * 1.25);
  height: calc(var(--s) * 0.95);
  background:
    linear-gradient(#fff 0 0) 0 0 / 100% 38% no-repeat,
    linear-gradient(#fff 0 0) 0 100% / 100% 38% no-repeat;
  border-radius: calc(var(--s) * 0.2);
}

/* The control panel. */
.panel {
  top: calc(var(--s) * 3.1);
  right: calc(var(--s) * 1);
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: calc(var(--s) * 0.6);
  width: calc(var(--s) * 16.4);
}
.tile {
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 0.55);
  height: calc(var(--s) * 3.75);
  padding: 0 calc(var(--s) * 0.62);
  border-radius: calc(var(--s) * 1.7);
  background: rgb(255 255 255 / 0.16);
  box-shadow: inset 0 0 0 calc(var(--s) * 0.08) rgb(255 255 255 / 0.22);
}
.tileText {
  display: flex;
  flex-direction: column;
  gap: calc(var(--s) * 0.32);
  font-size: calc(var(--s) * 0.9);
}
.tileText span {
  font-size: calc(var(--s) * 0.74);
  color: rgb(255 255 255 / 0.62);
}
.badge {
  position: relative;
  flex: none;
  width: calc(var(--s) * 2.2);
  height: calc(var(--s) * 2.2);
  border-radius: 50%;
  background: #f5f5ff;
  color: #2c2bc4;
}
.badgePlain {
  width: calc(var(--s) * 1.6);
  margin: 0 calc(var(--s) * 0.5);
  background: none;
  color: #fff;
}
.badge::before {
  content: '';
  position: absolute;
  inset: 32%;
  border-radius: 50%;
  background: currentColor;
}
.badge[data-glyph='wave']::before {
  inset: 30% 24% 40%;
  border: calc(var(--s) * 0.24) solid currentColor;
  border-bottom: 0;
  border-radius: 99px 99px 0 0;
  background: none;
}
.badge[data-glyph='half']::before {
  background: linear-gradient(90deg, currentColor 50%, transparent 0);
  box-shadow: inset 0 0 0 calc(var(--s) * 0.2) currentColor;
}
.badge[data-glyph='fold']::before {
  inset: 18% 4%;
  border: calc(var(--s) * 0.2) solid currentColor;
  border-radius: calc(var(--s) * 0.25);
  background: none;
}
.row {
  display: grid;
  grid-template-columns: calc(var(--s) * 3.75) calc(var(--s) * 3.75) 1fr;
  grid-column: 1 / -1;
  gap: calc(var(--s) * 0.6);
}
.knob {
  position: relative;
  display: block;
  height: calc(var(--s) * 3.75);
  border-radius: 50%;
  background: #f7f7ff;
  box-shadow: 0 0 calc(var(--s) * 0.8) rgb(255 255 255 / 0.45);
}
.knob::before {
  content: '';
  position: absolute;
  inset: 34%;
  border-radius: 50%;
  background: linear-gradient(90deg, #1c1e33 50%, transparent 0);
  box-shadow: inset 0 0 0 calc(var(--s) * 0.18) #1c1e33;
}
.square {
  justify-content: center;
  padding: 0;
}
.minor {
  display: block;
  width: calc(var(--s) * 1.2);
  height: calc(var(--s) * 0.8);
  border: calc(var(--s) * 0.16) solid rgb(255 255 255 / 0.85);
  border-radius: calc(var(--s) * 0.2);
}
.tileStack {
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  gap: calc(var(--s) * 0.55);
  padding: 0 calc(var(--s) * 0.8);
  font-size: calc(var(--s) * 0.82);
}
.slider {
  position: relative;
  display: block;
  height: calc(var(--s) * 0.62);
  border-radius: 99px;
  background: rgb(255 255 255 / 0.22);
}
.slider i {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 62%;
  border-radius: inherit;
  background: #fff;
}
.slider[data-long] i {
  width: 74%;
}
.wide {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-column: 1 / -1;
  align-content: center;
  gap: calc(var(--s) * 0.55) calc(var(--s) * 0.8);
  height: calc(var(--s) * 3.5);
  padding: 0 calc(var(--s) * 0.9);
  font-size: calc(var(--s) * 0.82);
}
.wide b {
  grid-column: 1 / -1;
}
.output {
  display: block;
  width: calc(var(--s) * 1.3);
  height: calc(var(--s) * 1.3);
  margin-top: calc(var(--s) * -0.34);
  border-radius: 50%;
  background: radial-gradient(circle, #2c2bc4 0 26%, #f5f5ff 30%);
}
.edit {
  grid-column: 1 / -1;
  justify-self: center;
  margin-top: calc(var(--s) * 0.35);
  padding: calc(var(--s) * 0.45) calc(var(--s) * 1.3);
  border-radius: 99px;
  background: rgb(255 255 255 / 0.12);
  font-size: calc(var(--s) * 0.72);
  color: rgb(255 255 255 / 0.8);
}

/* The widgets. */
.widgets {
  top: calc(var(--s) * 4.1);
  left: calc(var(--s) * 1.75);
  display: flex;
  gap: calc(var(--s) * 1.4);
}
.widget {
  width: calc(var(--s) * 8.4);
  height: calc(var(--s) * 8.5);
  padding: calc(var(--s) * 0.75) calc(var(--s) * 0.7);
  border-radius: calc(var(--s) * 1.7);
  background: rgb(18 10 92 / 0.45);
  box-shadow: inset 0 0 0 calc(var(--s) * 0.08) rgb(255 255 255 / 0.14);
}
.month {
  display: block;
  padding-left: calc(var(--s) * 0.12);
  font-size: calc(var(--s) * 0.6);
  text-transform: uppercase;
  color: rgb(255 255 255 / 0.62);
}
.days {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  row-gap: calc(var(--s) * 0.22);
  margin-top: calc(var(--s) * 0.45);
  font-size: calc(var(--s) * 0.48);
  font-weight: 500;
  text-align: center;
  color: rgb(255 255 255 / 0.82);
}
.days span {
  display: grid;
  place-items: center;
  justify-self: center;
  width: calc(var(--s) * 0.92);
  height: calc(var(--s) * 0.92);
  border-radius: 50%;
}
/* The weekdays' initials, above the dates. */
.days [data-head] {
  color: rgb(255 255 255 / 0.5);
}
.days [data-today] {
  background: #fff;
  color: #1d1b8c;
}
.gauges {
  display: grid;
  grid-template-columns: 1fr 1fr;
  place-items: center;
  width: calc(var(--s) * 8.9);
  padding: calc(var(--s) * 0.5);
}
.gauge {
  position: relative;
  display: grid;
  place-items: center;
  width: calc(var(--s) * 3.1);
  height: calc(var(--s) * 3.1);
  font-size: calc(var(--s) * 0.62);
  font-weight: 600;
}
/* A full track and its value from the top, clockwise, cut into a ring. */
.gauge::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(#fff 0 var(--p), rgb(255 255 255 / 0.2) 0);
  -webkit-mask: radial-gradient(farthest-side, transparent 72%, #000 75%);
  mask: radial-gradient(farthest-side, transparent 72%, #000 75%);
}
/* A plain reading: a small glyph over a large number. */
.reading {
  display: grid;
  place-items: center;
  gap: calc(var(--s) * 0.3);
  font-size: calc(var(--s) * 0.95);
  font-weight: 600;
}
.reading i {
  display: block;
  width: calc(var(--s) * 0.7);
  height: calc(var(--s) * 0.7);
  border-radius: 50%;
  box-shadow: 0 0 0 calc(var(--s) * 0.14) rgb(255 255 255 / 0.85);
}

/* The search launcher. */
.search {
  top: calc(var(--s) * 23.2);
  left: calc(var(--s) * 21.95);
  width: calc(var(--s) * 56.1);
  height: calc(var(--s) * 37.8);
  border-radius: calc(var(--s) * 2.6);
  background: rgb(16 12 104 / 0.82);
  box-shadow:
    inset 0 0 0 calc(var(--s) * 0.1) rgb(255 255 255 / 0.16),
    0 calc(var(--s) * 0.8) calc(var(--s) * 2.4) rgb(6 2 60 / 0.35);
}
.searchHead {
  position: absolute;
  top: calc(var(--s) * 1.45);
  left: calc(var(--s) * 2.1);
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 0.9);
  font-size: calc(var(--s) * 1.02);
  font-weight: 600;
  color: rgb(255 255 255 / 0.9);
}
.back {
  display: block;
  width: calc(var(--s) * 0.6);
  height: calc(var(--s) * 0.6);
  border-bottom: calc(var(--s) * 0.16) solid currentColor;
  border-left: calc(var(--s) * 0.16) solid currentColor;
  transform: rotate(45deg);
}
.apps {
  position: absolute;
  top: calc(var(--s) * 4.55);
  left: calc(var(--s) * 1.3);
  display: grid;
  grid-template-columns: repeat(7, calc(var(--s) * 7.8));
  grid-auto-rows: calc(var(--s) * 7);
}
.app {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(var(--s) * 0.5);
  font-size: calc(var(--s) * 0.6);
  color: rgb(255 255 255 / 0.72);
}
.appIcon {
  position: relative;
  display: block;
  width: calc(var(--s) * 4.1);
  height: calc(var(--s) * 4.1);
  border-radius: calc(var(--s) * 1);
  background: linear-gradient(var(--from), var(--to));
  box-shadow:
    inset 0 0 0 calc(var(--s) * 0.06) rgb(255 255 255 / 0.25),
    0 calc(var(--s) * 0.15) calc(var(--s) * 0.4) rgb(0 0 40 / 0.25);
}
.appIcon::before,
.appIcon::after {
  content: '';
  position: absolute;
}
.appIcon[data-glyph='grid']::before {
  inset: 26%;
  background:
    linear-gradient(var(--glyph) 0 0) 0 0 / 42% 42%,
    linear-gradient(var(--glyph) 0 0) 100% 0 / 42% 42%,
    linear-gradient(var(--glyph) 0 0) 0 100% / 42% 42%,
    linear-gradient(var(--glyph) 0 0) 100% 100% / 42% 42%;
  background-repeat: no-repeat;
  opacity: 0.92;
}
.appIcon[data-glyph='lines']::before {
  inset: 26% 24%;
  background: repeating-linear-gradient(
    var(--glyph) 0 calc(var(--s) * 0.26),
    transparent 0 calc(var(--s) * 0.6)
  );
}
.appIcon[data-glyph='fold']::before {
  inset: 28% 20%;
  border: calc(var(--s) * 0.22) solid var(--glyph);
  border-radius: calc(var(--s) * 0.25);
}
.appIcon[data-glyph='fold']::after {
  top: 22%;
  left: 36%;
  width: 28%;
  height: 28%;
  border-right: calc(var(--s) * 0.22) solid var(--glyph);
  border-bottom: calc(var(--s) * 0.22) solid var(--glyph);
  transform: rotate(45deg);
}
.appIcon[data-glyph='pin']::before {
  inset: 24% 30% 36%;
  border-radius: 50%;
  background: radial-gradient(circle, var(--to) 0 30%, var(--glyph) 34%);
}
.appIcon[data-glyph='pin']::after {
  bottom: 22%;
  left: 47%;
  width: 6%;
  height: 18%;
  background: var(--glyph);
}
.appIcon[data-glyph='note']::before {
  bottom: 24%;
  left: 28%;
  width: 26%;
  height: 26%;
  border-radius: 50%;
  background: var(--glyph);
}
.appIcon[data-glyph='note']::after {
  top: 24%;
  left: 48%;
  width: 26%;
  height: 40%;
  border-top: calc(var(--s) * 0.3) solid var(--glyph);
  border-left: calc(var(--s) * 0.24) solid var(--glyph);
}
.appIcon[data-glyph='ring']::before {
  inset: 22%;
  border: calc(var(--s) * 0.28) solid var(--glyph);
  border-radius: 50%;
}
.appIcon[data-glyph='ring']::after {
  top: 32%;
  left: 48%;
  width: 6%;
  height: 20%;
  background: var(--glyph);
}
.appIcon[data-glyph='wave']::before {
  inset: 34% 18% 30%;
  border-top: calc(var(--s) * 0.26) solid var(--glyph);
  border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
.appIcon[data-glyph='half']::before {
  inset: 24%;
  border-radius: 50%;
  background: linear-gradient(90deg, var(--glyph) 50%, transparent 0);
  box-shadow: inset 0 0 0 calc(var(--s) * 0.22) var(--glyph);
}
.appIcon[data-glyph='plus']::before {
  inset: 26%;
  background:
    linear-gradient(var(--glyph) 0 0) center / 100% 18% no-repeat,
    linear-gradient(var(--glyph) 0 0) center / 18% 100% no-repeat;
}
.appIcon[data-glyph='dot']::before {
  inset: 30%;
  border-radius: 50%;
  background: var(--glyph);
}

/* The controls' settings, in the middle of the screen: a sidebar with a search field, and a page
   of controls, a few options and a footer. */
.settings {
  top: calc(var(--s) * 4.2);
  left: calc(var(--s) * 29.6);
  display: flex;
  width: calc(var(--s) * 46.5);
  height: calc(var(--s) * 51.5);
  border-radius: calc(var(--s) * 1.8);
  background: rgb(14 8 86 / 0.6);
  box-shadow: inset 0 0 0 calc(var(--s) * 0.1) rgb(255 255 255 / 0.14);
}
.sidebar {
  display: grid;
  grid-auto-rows: 1fr;
  width: calc(var(--s) * 12);
  padding: calc(var(--s) * 1.6) calc(var(--s) * 0.8);
  font-size: calc(var(--s) * 0.62);
  color: rgb(255 255 255 / 0.8);
}
.sidebar span {
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 0.6);
  padding: 0 calc(var(--s) * 0.5);
  border-radius: calc(var(--s) * 0.5);
}
.sidebar i {
  display: block;
  width: calc(var(--s) * 0.95);
  height: calc(var(--s) * 0.95);
  border-radius: 50%;
  background: rgb(255 255 255 / 0.88);
}
.sidebar .find {
  margin-bottom: calc(var(--s) * 0.5);
  background: rgb(255 255 255 / 0.16);
  color: rgb(255 255 255 / 0.55);
}
.find i {
  background: none;
  box-shadow: inset 0 0 0 calc(var(--s) * 0.16) rgb(255 255 255 / 0.6);
}
.page {
  display: flex;
  flex: 1;
  flex-direction: column;
  justify-content: space-between;
  padding: calc(var(--s) * 2.7) calc(var(--s) * 1.6) calc(var(--s) * 1.5);
  font-size: calc(var(--s) * 0.58);
  color: rgb(255 255 255 / 0.78);
}
.controls {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: calc(var(--s) * 4) calc(var(--s) * 0.8);
}
.controls span,
.trio span {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(var(--s) * 0.55);
}
.controls i,
.trio i,
.single i {
  display: block;
  width: calc(var(--s) * 1.4);
  height: calc(var(--s) * 1.4);
  border-radius: 50%;
  background: rgb(255 255 255 / 0.85);
}
.single {
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 0.7);
  padding-left: calc(var(--s) * 1.4);
}
.trio {
  display: flex;
  justify-content: space-around;
}
.choices {
  display: flex;
  justify-content: space-around;
}
.choices span {
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 0.55);
}
.choices i {
  display: block;
  width: calc(var(--s) * 1.25);
  height: calc(var(--s) * 1.25);
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 calc(var(--s) * 0.5) rgb(255 255 255 / 0.5);
}
/* The page's footer: a rule, and a small round button at its end. */
.footer {
  display: flex;
  align-items: center;
  gap: calc(var(--s) * 1.2);
}
.footer::before {
  content: '';
  flex: 1;
  height: calc(var(--s) * 0.1);
  background: rgb(255 255 255 / 0.2);
}
.footer i {
  display: block;
  width: calc(var(--s) * 1.6);
  height: calc(var(--s) * 1.6);
  border-radius: 50%;
  background: rgb(255 255 255 / 0.22);
}

/* While the controls are edited: empty slots under the control panel, to drop controls into. */
.slots {
  top: calc(var(--s) * 22.4);
  right: calc(var(--s) * 1);
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: calc(var(--s) * 1);
  width: calc(var(--s) * 16.4);
}
.slots i {
  display: block;
  aspect-ratio: 1;
  border-radius: calc(var(--s) * 0.9);
  background: rgb(255 255 255 / 0.07);
  box-shadow: inset 0 0 0 calc(var(--s) * 0.08) rgb(255 255 255 / 0.12);
}
```

### onboarding-tour/wallpaper.tsx

```tsx
import { useId, type CSSProperties } from 'react';

/*
 * The two drawings behind everything: the laptop's wallpaper, folds of blue and lavender under a
 * violet sky with one bright edge of light, and the desktop around the window, the same colours
 * spread wide and soft. Both are made of gradients only (no blur filter, so they cost one paint),
 * and never change: an SVG whose style changes is laid out again. The wallpaper is a picture and
 * looks the same on both colour schemes; the desktop takes its colours from the stylesheet's
 * tokens (pale on light, deep on dark), which change only with the scheme.
 */

const FILL: CSSProperties = {
  position: 'absolute',
  inset: 0,
  display: 'block',
  width: '100%',
  height: '100%',
};

const CLOUD =
  'M0 466C44 440 94 430 142 440C198 454 232 500 248 538C292 522 352 534 388 578C412 606 424 630 428 645H0Z';
const SLOPE = 'M602 645C654 560 702 502 770 444C850 374 926 306 1000 204V645Z';

/** A shape whose edge is softened by wide, faint strokes of its colour, drawn under its fill. */
function Soft({ d, fill, edge }: { d: string; fill: string; edge: string }) {
  return (
    <>
      {[30, 19, 9].map((width) => (
        <path
          key={width}
          d={d}
          fill="none"
          stroke={edge}
          strokeOpacity="0.13"
          strokeWidth={width}
          strokeLinejoin="round"
        />
      ))}
      <path d={d} fill={fill} />
    </>
  );
}

/** The laptop's wallpaper, 1000 × 645 (the screen's proportions), cropped to fill its box. */
export function Wallpaper() {
  const id = useId().replace(/[^\w-]/g, '');
  const ref = (name: string) => `url(#${id}${name})`;
  return (
    <svg viewBox="0 0 1000 645" preserveAspectRatio="xMidYMid slice" style={FILL}>
      <defs>
        <linearGradient id={`${id}sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#170cc8" />
          <stop offset="0.18" stopColor="#2210d6" />
          <stop offset="0.3" stopColor="#4a22e6" />
          <stop offset="0.38" stopColor="#6630ee" />
          <stop offset="0.48" stopColor="#3020d8" />
          <stop offset="1" stopColor="#1c16c2" />
        </linearGradient>
        <radialGradient id={`${id}glow`} cx="0.6" cy="0.37" r="0.45">
          <stop offset="0" stopColor="#8a46f6" stopOpacity="0.55" />
          <stop offset="1" stopColor="#8a46f6" stopOpacity="0" />
        </radialGradient>
        <linearGradient
          id={`${id}hill`}
          x1="0"
          y1="200"
          x2="0"
          y2="300"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#2a20c0" stopOpacity="0" />
          <stop offset="0.35" stopColor="#2a1fbc" stopOpacity="0.9" />
          <stop offset="1" stopColor="#1d16b2" />
        </linearGradient>
        <linearGradient id={`${id}sheet`} x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stopColor="#3f4eea" />
          <stop offset="0.24" stopColor="#6577f4" />
          <stop offset="0.46" stopColor="#5262ee" />
          <stop offset="0.64" stopColor="#2a2ccc" />
          <stop offset="1" stopColor="#1c16bc" />
        </linearGradient>
        <linearGradient id={`${id}body`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#3446e2" />
          <stop offset="0.35" stopColor="#2430ce" />
          <stop offset="1" stopColor="#1418b6" />
        </linearGradient>
        <linearGradient id={`${id}edge`} x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stopColor="#eef1ff" stopOpacity="0" />
          <stop offset="0.1" stopColor="#eef1ff" stopOpacity="0.75" />
          <stop offset="0.55" stopColor="#eef1ff" stopOpacity="0.9" />
          <stop offset="0.85" stopColor="#eef1ff" stopOpacity="0.45" />
          <stop offset="1" stopColor="#eef1ff" stopOpacity="0" />
        </linearGradient>
        <radialGradient id={`${id}dark`}>
          <stop offset="0" stopColor="#0b0898" />
          <stop offset="0.55" stopColor="#0e0aa0" stopOpacity="0.7" />
          <stop offset="1" stopColor="#120ca8" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={`${id}cloud`} x1="0" y1="0" x2="0.4" y2="1">
          <stop offset="0" stopColor="#97a3ea" />
          <stop offset="1" stopColor="#6c79d8" />
        </linearGradient>
        <linearGradient id={`${id}slope`} x1="0" y1="1" x2="1" y2="0">
          <stop offset="0" stopColor="#7a88e0" />
          <stop offset="0.5" stopColor="#97a2ea" />
          <stop offset="1" stopColor="#b3bcf2" />
        </linearGradient>
      </defs>
      <rect width="1000" height="645" fill={ref('sky')} />
      <rect width="1000" height="645" fill={ref('glow')} />
      {/* Far hills, darker than the sky, their tops soft. */}
      <path
        d="M0 258C70 246 130 240 190 242C255 244 300 222 350 212C400 204 440 230 482 250C560 266 720 262 1000 240V340H0Z"
        fill={ref('hill')}
      />
      {/* The wave: a light sheet folding down to a bright edge, its body below. */}
      <path
        d="M0 262C160 258 330 268 480 290C640 300 820 270 1000 236V250C930 300 840 330 760 360C640 400 540 410 480 406C400 392 330 368 260 342C180 312 100 282 0 266Z"
        fill={ref('sheet')}
      />
      <path
        d="M0 266C100 282 180 312 260 342C330 368 400 392 480 406C540 410 640 400 760 360C840 330 930 300 1000 250V645H0Z"
        fill={ref('body')}
      />
      <ellipse cx="50" cy="360" rx="200" ry="80" fill={ref('dark')} />
      <ellipse cx="500" cy="660" rx="160" ry="250" fill={ref('dark')} />
      <ellipse cx="500" cy="700" rx="110" ry="200" fill={ref('dark')} />
      {/* The wave's edge of light: a soft glow with a faint line in it. */}
      {[
        { width: 16, opacity: 0.1 },
        { width: 8, opacity: 0.18 },
        { width: 2.4, opacity: 0.55 },
      ].map(({ width, opacity }) => (
        <path
          key={width}
          d="M10 266C100 282 180 312 260 342C330 368 400 392 480 406C540 410 640 400 760 360C840 330 930 300 1000 250"
          fill="none"
          stroke={ref('edge')}
          strokeOpacity={opacity}
          strokeWidth={width}
          strokeLinecap="round"
        />
      ))}
      {/* Soft lavender clouds on the left, and the long slope on the right. */}
      <Soft d={CLOUD} fill={ref('cloud')} edge="#8693e2" />
      <path
        d="M0 560C40 530 92 524 138 540C180 556 208 590 222 645H0Z"
        fill="#a3aeed"
        fillOpacity="0.3"
      />
      <Soft d={SLOPE} fill={ref('slope')} edge="#8f9be6" />
      <path
        d="M770 444C850 374 926 306 1000 204"
        fill="none"
        stroke="#d9defb"
        strokeOpacity="0.16"
        strokeWidth="10"
      />
    </svg>
  );
}

/** A gradient stop of the desktop, in one of its colours (onboarding-tour.module.css, --desk-*). */
const tone = (name: string | number): CSSProperties => ({ stopColor: `var(--desk-${name})` });

/** The desktop around the window: wide, soft bands of violet and blue with one streak of light. */
export function Backdrop() {
  const id = useId().replace(/[^\w-]/g, '');
  const ref = (name: string) => `url(#${id}${name})`;
  return (
    <svg viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice" style={FILL}>
      <defs>
        <linearGradient id={`${id}base`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" style={tone(1)} />
          <stop offset="0.14" style={tone(2)} />
          <stop offset="0.3" style={tone(3)} />
          <stop offset="0.52" style={tone(4)} />
          <stop offset="0.78" style={tone(5)} />
          <stop offset="1" style={tone(6)} />
        </linearGradient>
        <linearGradient id={`${id}band`} x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" style={tone('band-1')} />
          <stop offset="0.35" style={tone('band-2')} stopOpacity="0.75" />
          <stop offset="1" style={tone('band-3')} stopOpacity="0" />
        </linearGradient>
        <linearGradient id={`${id}low`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" style={tone('low-1')} />
          <stop offset="0.6" style={tone('low-2')} />
          <stop offset="1" style={tone('low-3')} />
        </linearGradient>
        <linearGradient id={`${id}streak`} x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stopColor="#fff" stopOpacity="0.9" />
          <stop offset="0.3" stopColor="#fff" stopOpacity="0.55" />
          <stop offset="0.6" stopColor="#fff" stopOpacity="0" />
        </linearGradient>
        <radialGradient id={`${id}glow`} cx="0.97" cy="0.98" r="0.32">
          <stop offset="0" style={tone('glow')} />
          <stop offset="1" style={tone('glow')} stopOpacity="0" />
        </radialGradient>
        <radialGradient id={`${id}rise`} cx="0.98" cy="0.62" r="0.22">
          <stop offset="0" style={tone('rise')} stopOpacity="0.9" />
          <stop offset="1" style={tone('rise')} stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect width="1600" height="1000" fill={ref('base')} />
      <path d="M0 470C340 500 700 640 1100 820C1260 892 1420 950 1600 1000H0Z" fill={ref('low')} />
      <path
        d="M0 410C330 430 680 560 1060 760L1040 800C670 610 330 500 0 520Z"
        fill={ref('band')}
      />
      <path d="M0 452C300 468 610 580 930 744" fill="none" stroke={ref('streak')} strokeWidth="5" />
      <rect width="1600" height="1000" fill={ref('rise')} />
      <rect width="1600" height="1000" fill={ref('glow')} />
    </svg>
  );
}
```

### onboarding-tour/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a control and press it, calling the component's own handler as a click
 * would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress
 * at once: its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * Nothing the pointer does needs a layout: it glides by an animation the compositor plays, and
 * the press scales a box that keeps a transform at rest (a box that gains or loses one is laid
 * out again) and rings a thin circle around the tip. The arrow is an SVG drawing whose style
 * never changes, inside HTML boxes.
 */

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

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

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

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

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

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.94 };

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

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

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    // The arrow dips towards its tip, and a ring shows around it for a moment.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    pointer.children[1]?.animate(
      [
        { opacity: 0, transform: 'scale(0.7)' },
        { opacity: 1, transform: 'scale(1)', offset: 0.25 },
        { opacity: 1, transform: 'scale(1)', offset: 0.6 },
        { opacity: 0, transform: 'scale(1.06)' },
      ],
      { duration: 520, easing: 'ease-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

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

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

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.3))',
  willChange: 'transform',
};

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

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/** The ring around the tip while the pointer presses: a thin grey circle, hidden at rest. */
const RING = 30;
const RING_STYLE: CSSProperties = {
  position: 'absolute',
  top: TIP.y * 1.1 - RING,
  left: TIP.x * 1.1 - RING,
  width: RING * 2,
  height: RING * 2,
  borderRadius: '50%',
  boxShadow: 'inset 0 0 0 1.5px rgb(150 154 170 / 0.75)',
  opacity: 0,
  transform: 'scale(1)',
  willChange: 'transform, opacity',
};

/** The arrow the demo moves around. It is decorative, and invisible while no demo plays. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            fill="#111216"
            stroke="#fff"
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span style={RING_STYLE} />
    </span>
  );
}
```

### onboarding-tour/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 };
}
```