# Listing cards

> Six listing cards (a recipe, rentals, homes for sale) with hand-painted photos that swipe like a carousel.

A board of six cards of different kinds: a recipe with its timings and ingredients, a flat to rent, a house for sale, and three photo cards with their details on a soft shade. Every photo is painted in SVG. The photos swipe, step with arrows or keys, and follow their dots. Cards lift under the pointer, the heart saves the flat, and the guest house can be reserved.

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

Category: Media.

## Anatomy

1. **Card**: White on the grey stage, a dark grey at night, softly shadowed. It lifts 5 units under the pointer, by its transform alone.
2. **Photos**: A small carousel, inset on the white cards and edge to edge on the photo ones. Drag or swipe it, or use the arrow keys.
3. **Badge**: A pill on the photo: what makes the listing stand out. White, plain or frosted glass, by card.
4. **Heart**: Saves the flat: a toggle button that swaps a white glyph for a red one, with a short pop.
5. **Arrows**: Show on hover and on focus, and step through the photos, wrapping around at either end.
6. **Dots**: Where you are in the photos. Dots more than two away from the one shown shrink.
7. **Text on a shade**: On the photo cards, the words sit on a gradient drawn for each painting, so they read on it.
8. **Action**: The card’s one button. Reserve swaps its label for Reserved in place, so the card never reflows.

## When to use

- A grid of things to browse and compare at a glance: homes, stays, recipes, products with photos.
- Items whose photos sell them, where a few pictures per card save a trip to the detail page.
- One action per card that can happen right there: save, reserve, start, view details.
- Mixed kinds on one board, each card shaped by what it lists but drawn from one unit.

## When not to use

- Long lists to scan and sort by a figure. Use a table or a dense list with columns.
- Items without good pictures: an empty photo box weighs more than a line of text.
- Several actions on each item. Put them on the detail page, or in a menu on a row.
- One item on its own page. Use a full gallery and the details laid out in full.

## Installation

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

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

export function Listings() {
  return (
    <ListingCards
      autoplay={false}
      onLikeChange={(liked) => console.log('saved', liked)}
      onAction={(action) => console.log(action)}
    />
  );
}
```

## States

- **At rest**: The first photo, the dots under it; the arrows wait for a hover or the focus.
- **Hovered**: The card rises 5 units in 400 ms and a deeper shadow fades in; the arrows show.
- **Photo turned**: On the last of four photos: the dot far from it shrinks, and a screen reader hears “Photo 4 of 4”.
- **Saved**: The heart turns red with a 460 ms pop; `onLikeChange` hears true.
- **Pressed**: A button dips to 97% while held, and comes back in 150 ms.
- **Reserved**: Reserve Apartment becomes Reserved with a green check; pressing again cancels.
- **Dark**: The stage turns near-black and the white cards a dark grey a step above it; the photos stay as painted.

## How it works

- Every photo is a scene painted in SVG and drawn once, in a hidden sprite. Each slide shows a scene through a use element, whole or as a closer crop set by its viewBox. Leaves, grass and grains of rice come from a seeded random generator, so the server and the browser draw the same picture.
- Each set of photos is a small carousel. A drag or swipe moves the shown slide and its neighbours with the hand, and past a fifth of the width, or after a flick, it moves on. The arrows wrap around, the arrow keys step up to either end, and the dots shrink when they are far from the photo shown.
- Each card’s photo box paints on one fixed layer of its own. The slides inside it are absolutely placed boxes that glide frame by frame, so a turn never makes a layer wider than the photos and never repaints the page around it.
- Every size is a multiple of one unit, set from the stage’s width by a container query, so the board scales as one drawing. Below 680px it turns to two columns, and below 440px to one.
- The heart and the Reserve button each hold both of their states, drawn in place, and swap them by visibility. The heart pops with the Web Animations API, and a card’s deeper hover shadow fades in by its opacity.
- Every interface colour is a light-dark() token on the root, so the cards follow the page’s color-scheme, or the colorScheme prop; the paintings, and what is drawn over them (badges, arrows, the heart, the price pill), stay as they are.

## API reference

- **defaultLiked** (`boolean`, default `false`): Whether the flat starts saved.
- **defaultReserved** (`boolean`): Whether the guest house opens already reserved. Setting it to true turns the demo off.
- **onLikeChange** (`(liked: boolean) => void`): Called when the reader saves the flat or takes it off their list.
- **onAction** (`(action: ListingAction) => void`): Called when the reader presses a card’s button: start-cooking, view-details, reserve or cancel-reservation.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer flips through photos and presses buttons.
- **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 stage of its own.

## Accessibility

- Each card is an article named by its title. Each set of photos is a group described as a carousel, its slides are labelled “2 of 4”, and every drawing is labelled with what it shows.
- The Previous photo and Next photo buttons show on focus as well as on hover, and the arrow keys step through the photos. Vertical swipes still scroll the page (touch-action: pan-y).
- The heart and Reserve are real toggle buttons with aria-pressed. Saving, reserving and turning a photo are announced in a polite live region; the demo’s own actions stay silent.
- Keyboard focus anywhere on the stage stops the demo. Under reduced motion, turns jump and the demo never plays.

## Source

### listing-cards/listing-cards.tsx

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

'use client';

import { useEffect, useId, useRef, useState, type FocusEvent } from 'react';
import { SceneSprite, SpriteContext } from './art';
import type { CarouselHandle } from './carousel';
import { CottageCard, FlatCard, GuestHouseCard, HouseCard, ListingCard, RecipeCard } from './cards';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './listing-cards.module.css';

/*
 * Listing cards: six cards of different kinds on one board, a recipe, a flat to rent, a house for
 * sale, and three photo cards with their text on a shade.
 *
 * - The photos are painted in SVG (art.tsx): each scene is drawn once in a hidden sprite, and a
 *   slide shows it whole or as a closer crop, through its viewBox.
 * - Each card's photos are a small carousel (carousel.tsx): drag or swipe them, use the arrows
 *   that show on hover and on focus, or the arrow keys; the dots follow, and shrink when far from
 *   the photo shown.
 * - A card lifts under the pointer: it moves by its transform, and its deeper shadow fades in by
 *   its opacity, so nothing is painted again as it moves.
 * - The heart saves the flat and the guest house can be reserved: each swaps between two glyphs
 *   or two labels drawn in place, by their visibility.
 * - The board scales with its box as one drawing: every size is a multiple of one unit, set from
 *   the stage's width by a container query. Narrow, it turns to two columns, then one.
 * - While on screen and untouched, a demo pointer flips through photos, saves the flat, reserves
 *   the guest house and presses a button; its next round undoes what the last one did. It lifts
 *   the cards it visits only where the screen has hover: on a phone a lift would only cost memory.
 * - Light or dark: the stage and the cards' colours are light-dark() tokens in the CSS, so the
 *   scheme is the page's color-scheme, or the one the colorScheme prop sets. The paintings, and
 *   what lies on them, stay as they are.
 */

/** What a card's button asks for. */
export type ListingAction = 'start-cooking' | 'view-details' | 'reserve' | 'cancel-reservation';

/** The cards whose photos turn, which the demo visits. */
type CardName = 'recipe' | 'flat' | 'house' | 'cottage' | 'guest';

export function ListingCards({
  className = '',
  autoplay = true,
  defaultLiked = false,
  defaultReserved = false,
  onLikeChange,
  onAction,
  colorScheme,
}: {
  /** Extra class for the root, a stage of its own. */
  className?: string;
  /** While on screen and untouched, a demo pointer flips through photos and presses buttons. */
  autoplay?: boolean;
  /** Whether the flat starts saved. */
  defaultLiked?: boolean;
  /** Whether the guest house starts reserved; true turns the demo off, so it stays so. */
  defaultReserved?: boolean;
  /** Called when the reader saves the flat or takes it off their list. */
  onLikeChange?: (liked: boolean) => void;
  /** Called when the reader presses a card's button. */
  onAction?: (action: ListingAction) => void;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  // The prefix of the drawings' ids, unique to this board.
  const sprite = `scene${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
  const [liked, setLiked] = useState(defaultLiked);
  const [reserved, setReserved] = useState(defaultReserved);
  const [message, setMessage] = useState('');
  /** Keyboard focus is on the stage: the demo leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);

  const cards = useRef<Partial<Record<CardName, HTMLElement | null>>>({});
  const photos = useRef<Partial<Record<CardName, CarouselHandle | null>>>({});
  const heart = useRef<HTMLButtonElement>(null);
  const reserve = useRef<HTMLButtonElement>(null);
  const cook = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  // The latest state, for the demo's script, which outlives renders.
  const state = useRef({ liked, reserved });
  state.current = { liked, reserved };

  /** Says `text` to screen readers, again if it was the last thing said. */
  const say = (text: string) => setMessage((last) => (last === text ? `${text}\u00a0` : text));

  /** The heart pops when the flat is saved. */
  const pop = () => {
    const box = heart.current?.firstElementChild;
    if (!box || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    box.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(1.32)', offset: 0.35 },
        { transform: 'scale(0.9)', offset: 0.7 },
        { transform: 'scale(1)' },
      ],
      { duration: 460, easing: 'ease-out' },
    );
  };

  const toggleLike = (byReader: boolean) => {
    const next = !state.current.liked;
    state.current.liked = next;
    setLiked(next);
    if (next) pop();
    if (!byReader) return;
    onLikeChange?.(next);
    say(next ? 'Business Bay saved.' : 'Business Bay removed from your saved places.');
  };

  const toggleReserve = (byReader: boolean) => {
    const next = !state.current.reserved;
    state.current.reserved = next;
    setReserved(next);
    if (!byReader) return;
    onAction?.(next ? 'reserve' : 'cancel-reservation');
    say(next ? 'The Hills Guest House reserved.' : 'Reservation cancelled.');
  };

  const turned = (index: number, count: number) => say(`Photo ${index + 1} of ${count}.`);

  /** The reader takes over (a press, a key, a mouse over the board): the demo stops at once and
   * waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    take();
  };

  const demo = autoplay && !defaultReserved && playing && !keyboard;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    /**
     * The card the pointer is over: its arrows show, and it lifts as a hover would, where the
     * screen has hover at all (on a phone, a lift would only cost memory).
     */
    let over: HTMLElement | null = null;
    /** The photos the pointer holds, let go if the demo stops. */
    let held: CarouselHandle | null = null;
    const lifts = window.matchMedia('(hover: hover)').matches;
    const hover = (name: CardName | null) => {
      const card = name ? (cards.current[name] ?? null) : null;
      if (over === card) return;
      if (over) {
        delete over.dataset.hover;
        delete over.dataset.arrows;
      }
      over = card;
      if (!card) return;
      card.dataset.arrows = '';
      if (lifts) card.dataset.hover = '';
    };
    /** Presses `button` as a finger would: it dips while `action` runs. */
    const tap = async ({ press, wait }: Demo, button: HTMLElement | null, action: () => void) => {
      await press(() => {
        button?.setAttribute('data-press', '');
        action();
      });
      await wait(60);
      button?.removeAttribute('data-press');
    };
    /** Drags a card's photos aside to the next one, or back towards the first. */
    const swipe = async ({ moveTo, drag, wait }: Demo, name: CardName) => {
      const handle = photos.current[name];
      const box = handle?.element();
      if (!handle || !box) return;
      hover(name);
      const forward = handle.index() === 0;
      const from = forward ? 0.74 : 0.26;
      const to = forward ? 0.3 : 0.7;
      await moveTo(box, { x: from, y: 0.42 });
      await wait(160);
      const rect = box.getBoundingClientRect();
      held = handle;
      await drag(
        { x: rect.left + rect.width * to, y: rect.top + rect.height * 0.45 },
        620,
        (share) => handle.hold((to - from) * share),
      );
      handle.release();
      held = null;
    };
    const stop = runDemo(
      stage,
      arrow,
      async (steps) => {
        const { moveTo, wait } = steps;
        await wait(900);
        for (;;) {
          // The flat: another photo, then the heart.
          await swipe(steps, 'flat');
          await wait(650);
          await moveTo(heart.current);
          await tap(steps, null, () => toggleLike(false));
          await wait(900);
          // The house: its next photo, or back to the first, by the arrow.
          hover('house');
          const house = photos.current.house;
          const box = house?.element();
          const forward = (house?.index() ?? 0) === 0;
          const button = box?.querySelector<HTMLElement>(
            `[aria-label="${forward ? 'Next photo' : 'Previous photo'}"]`,
          );
          await moveTo(button);
          await tap(steps, null, () => house?.go(forward ? 1 : 0));
          await wait(1100);
          // The guest house: another photo, then reserve it, or cancel.
          await swipe(steps, 'guest');
          await wait(600);
          await moveTo(reserve.current, { x: 0.5, y: 0.55 });
          await tap(steps, reserve.current, () => toggleReserve(false));
          await wait(1300);
          // The recipe: another photo, then its button.
          await swipe(steps, 'recipe');
          await wait(600);
          await moveTo(cook.current, { x: 0.5, y: 0.55 });
          await tap(steps, cook.current, () => {});
          await wait(900);
          // The cottage, and a rest away from the cards.
          await swipe(steps, 'cottage');
          await wait(700);
          hover(null);
          await moveTo(stage, { x: 0.5, y: 0.97 });
          await wait(2200);
        }
      },
      () => {
        held?.release();
        held = null;
        hover(null);
      },
    );
    stopDemo.current = stop;
    return stop;
    // The script reads the latest state through refs; it restarts only when the demo does.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  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 cardRef = (name: CardName) => (element: HTMLElement | null) => {
    cards.current[name] = element;
  };
  const photosRef = (name: CardName) => (handle: CarouselHandle | null) => {
    photos.current[name] = handle;
  };

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onPointerMove={(event) => {
        // A mouse over the board hovers its cards: the demo makes way.
        if (event.pointerType === 'mouse') readerActs();
      }}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.scene}>
        <SceneSprite prefix={sprite} />
        <SpriteContext value={sprite}>
          <div className={styles.grid}>
            <RecipeCard
              cardRef={cardRef('recipe')}
              photosRef={photosRef('recipe')}
              onTurn={turned}
              onCook={() => onAction?.('start-cooking')}
              buttonRef={cook}
            />
            <FlatCard
              cardRef={cardRef('flat')}
              photosRef={photosRef('flat')}
              onTurn={turned}
              liked={liked}
              onLike={() => toggleLike(true)}
              heartRef={heart}
            />
            <HouseCard
              cardRef={cardRef('house')}
              photosRef={photosRef('house')}
              onTurn={turned}
              onDetails={() => onAction?.('view-details')}
            />
            <CottageCard
              cardRef={cardRef('cottage')}
              photosRef={photosRef('cottage')}
              onTurn={turned}
            />
            <ListingCard />
            <GuestHouseCard
              cardRef={cardRef('guest')}
              photosRef={photosRef('guest')}
              onTurn={turned}
              reserved={reserved}
              onReserve={() => toggleReserve(true)}
              reserveRef={reserve}
            />
          </div>
        </SpriteContext>
        <p className={styles.hidden} aria-live="polite">
          {message}
        </p>
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer ref={pointer} className={styles.pointer} />
      </div>
    </div>
  );
}
```

### listing-cards/listing-cards.module.css

```css
/* Listing cards (listing-cards.tsx): six cards on a light grey stage, three by two; at night, on
   a near-black stage, the three white cards turn a dark grey a step above it.

   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 photos are paintings (art.tsx) and stay as
   they are, and so does what lies on them, drawn against the pictures rather than the page: the
   badges, arrows, dots and heart, and the photo cards' shades, words and buttons.

   Every size is a multiple of --u, one unit of the cards' design, so the whole board scales with
   its box as one drawing: the stage is a size container, and --u follows its width (up to 1px).
   Narrow, the board turns to two columns, then one, and --u grows again so the text stays legible.

   Memory. A phone keeps a page's pixels in tiles the width of the screen, and a tile painted
   again gets a second buffer. So the board is one layout boundary of a set size (.scene), each
   card's photos paint on a fixed layer of their own (their slides move inside it, frame by
   frame, without becoming layers), and the cards lift by transform alone; the heart and the
   button swap glyphs by visibility, and no drawing ever changes inside an SVG. */

.stage {
  /* The stage, and the cards on it. */
  --surface: #f2f2f2;
  --surface-2: #fff;
  /* The words on the cards: titles and prices, bold figures and names, the flat's price, the
     ingredients, and the flat's counts of rooms. */
  --ink: #1c1c1e;
  --ink-2: #2c2c2e;
  --ink-price: #2a2a2c;
  --ink-chip: #3a3a3c;
  --ink-rooms: #707073;
  /* The muted words, and the line icons. By day the greys differ by a hair, as they were drawn;
     at night they share one. */
  --ink-muted: #a3a3a6;
  --muted-facts: #9d9da0;
  --muted-rooms: #a0a0a3;
  --muted-text: #a2a2a5;
  --muted-recipe: #a6a6a8;
  --muted-byline: #ababae;
  --icon: #8e8e93;
  /* The ingredients' chips. */
  --chip: #f1f1f2;
  /* The stage's edge; the cards' own at night; the rules between the facts, four greys by day. */
  --line: rgb(0 0 0 / 0.06);
  --card-edge: transparent;
  --rule: #efefef;
  --rule-2: #f0f0f0;
  --rule-3: #f3f3f3;
  --rule-4: #ededed;
  /* The cards' shadows, near to far, and the deeper one a lifted card gains; the photo cards'. */
  --shadow-near: rgb(0 0 0 / 0.08);
  --shadow: rgb(0 0 0 / 0.07);
  --shadow-far: rgb(0 0 0 / 0.05);
  --shadow-lift: rgb(0 0 0 / 0.14);
  --photo-shadow: rgb(0 0 0 / 0.17);
  --photo-shadow-far: rgb(0 0 0 / 0.36);
  /* Start Cooking and View Details: a near-black button by day, a light one at night. */
  --accent: #474747;
  --accent-2: #1f1f1f;
  --accent-ink: #fff;
  --accent-highlight: rgb(255 255 255 / 0.12);
  --accent-shadow: rgb(0 0 0 / 0.18);
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #0a84ff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family:
    Satoshi,
    'General Sans',
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#f2f2f2, #121214);
    --surface-2: light-dark(#fff, #1f1f22);
    --ink: light-dark(#1c1c1e, #ececef);
    --ink-2: light-dark(#2c2c2e, #dadade);
    --ink-price: light-dark(#2a2a2c, #ececef);
    --ink-chip: light-dark(#3a3a3c, #c7c7cc);
    --ink-rooms: light-dark(#707073, #b1b1b6);
    --ink-muted: light-dark(#a3a3a6, #929298);
    --muted-facts: light-dark(#9d9da0, #929298);
    --muted-rooms: light-dark(#a0a0a3, #929298);
    --muted-text: light-dark(#a2a2a5, #929298);
    --muted-recipe: light-dark(#a6a6a8, #929298);
    --muted-byline: light-dark(#ababae, #929298);
    --icon: light-dark(#8e8e93, #8e8e95);
    --chip: light-dark(#f1f1f2, #2a2a2e);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --card-edge: light-dark(transparent, rgb(255 255 255 / 0.06));
    --rule: light-dark(#efefef, #2e2e32);
    --rule-2: light-dark(#f0f0f0, #2e2e32);
    --rule-3: light-dark(#f3f3f3, #2e2e32);
    --rule-4: light-dark(#ededed, #2e2e32);
    --shadow-near: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
    --shadow: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.4));
    --shadow-far: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --shadow-lift: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.5));
    --photo-shadow: light-dark(rgb(0 0 0 / 0.17), rgb(0 0 0 / 0.45));
    --photo-shadow-far: light-dark(rgb(0 0 0 / 0.36), rgb(0 0 0 / 0.7));
    --accent: light-dark(#474747, #f4f4f6);
    --accent-2: light-dark(#1f1f1f, #d4d4d9);
    --accent-ink: light-dark(#fff, #1c1c1e);
    --accent-highlight: light-dark(rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.7));
    --accent-shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.4));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}
.stage :where(h3, p, ul, dl, dd) {
  margin: 0;
  padding: 0;
}
.stage :where(ul) {
  list-style: none;
}
.stage :where(button) {
  margin: 0;
  padding: 0;
  border: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.stage :where(b) {
  font-weight: 500;
}

/* The board: three columns by two rows, centred in a box of a set height. */
.scene {
  --u: min(1px, 100cqi / 941);
  position: relative;
  height: calc(712 * var(--u));
  contain: size layout;
}
.grid {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: calc(224 * var(--u)) calc(232 * var(--u)) calc(224 * var(--u));
  grid-template-rows: calc(300 * var(--u)) calc(288 * var(--u));
  gap: calc(33.5 * var(--u)) calc(47 * var(--u));
  place-content: center;
  place-items: center;
  /* Room below for the photo cards' long shadows. */
  padding-bottom: calc(22 * var(--u));
}

/* A card: white (a dark grey at night, with a faint edge), rounded, softly shadowed. It lifts
   under the pointer; the deeper shadow it gains fades in with its own opacity rather than being
   painted again at every step. */
.card {
  position: relative;
  border-radius: calc(14 * var(--u));
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 calc(2 * var(--u)) calc(3 * var(--u)) var(--shadow-near),
    0 calc(7 * var(--u)) calc(12 * var(--u)) calc(-2 * var(--u)) var(--shadow),
    0 calc(14 * var(--u)) calc(28 * var(--u)) var(--shadow-far);
  transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.card::before {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 calc(16 * var(--u)) calc(30 * var(--u)) var(--shadow-lift);
  opacity: 0;
  transition: opacity 0.4s ease;
  content: '';
  pointer-events: none;
}
@media (hover: hover) {
  .card:hover {
    transform: translateY(calc(-5 * var(--u)));
  }
  .card:hover::before {
    opacity: 1;
  }
}
.card[data-hover] {
  transform: translateY(calc(-5 * var(--u)));
}
.card[data-hover]::before {
  opacity: 1;
}
.recipe,
.house {
  width: calc(224 * var(--u));
  height: calc(300 * var(--u));
}
.flat {
  width: calc(232 * var(--u));
  height: calc(300 * var(--u));
}
.photo {
  height: calc(288 * var(--u));
  background: #2a2f26;
  /* Lit from the upper left: the shadow falls to the right and below. */
  box-shadow:
    calc(7 * var(--u)) calc(8 * var(--u)) calc(16 * var(--u)) var(--photo-shadow),
    calc(26 * var(--u)) calc(30 * var(--u)) calc(60 * var(--u)) calc(-8 * var(--u))
      var(--photo-shadow-far);
}
.cottage {
  width: calc(223 * var(--u));
}
.listing {
  width: calc(230 * var(--u));
}
.guest {
  width: calc(222 * var(--u));
}

/* Photos: a clipped box, its row of slides, the arrows and the dots. */
.media {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  /* One layer for the photos and all that lies on them: a turn repaints it, not the page. */
  will-change: transform;
  touch-action: pan-y;
  -webkit-user-select: none;
  user-select: none;
}
.media svg {
  -webkit-user-drag: none;
}
/* A photo that does not turn: no layer of its own. */
.still {
  overflow: hidden;
}
.inset {
  margin: calc(4.5 * var(--u)) calc(4.5 * var(--u)) 0;
  border-radius: calc(10.5 * var(--u));
  background: #1b1b1b;
}
.recipe .inset {
  height: calc(146 * var(--u));
}
.flat .inset {
  height: calc(200.5 * var(--u));
}
.house .inset {
  height: calc(153 * var(--u));
}
.full {
  position: absolute;
  inset: 0;
  border-radius: inherit;
}
.cottage .dots {
  bottom: calc(91 * var(--u));
}
.guest .dots {
  bottom: calc(119 * var(--u));
  gap: calc(2.4 * var(--u));
}
.guest .dot {
  width: calc(3.6 * var(--u));
  height: calc(3.6 * var(--u));
}
.slides {
  position: absolute;
  inset: 0;
}
/* Before any turn, the first slide shows and the others wait, hidden, off to the right. */
.slide {
  position: absolute;
  inset: 0;
  visibility: hidden;
  transform: translateX(200%);
}
.slide:first-child {
  visibility: visible;
  transform: none;
}

.arrow {
  position: absolute;
  z-index: 3;
  top: 50%;
  display: grid;
  place-items: center;
  width: calc(20 * var(--u));
  height: calc(20 * var(--u));
  margin-top: calc(-10 * var(--u));
  border-radius: 50%;
  background: rgb(255 255 255 / 0.92);
  box-shadow: 0 calc(1 * var(--u)) calc(4 * var(--u)) rgb(0 0 0 / 0.18);
  color: #1c1c1e;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.arrow svg {
  width: calc(10 * var(--u));
  height: calc(10 * var(--u));
}
.back {
  left: calc(7 * var(--u));
}
.forward {
  right: calc(7 * var(--u));
}
.full .arrow {
  top: 34%;
}
@media (hover: hover) {
  .media:hover .arrow {
    opacity: 1;
  }
}
.card[data-arrows] .arrow,
.arrow:focus-visible {
  opacity: 1;
}
.arrow:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.dots {
  position: absolute;
  z-index: 2;
  left: 50%;
  bottom: calc(8.5 * var(--u));
  display: flex;
  gap: calc(2.8 * var(--u));
  transform: translateX(-50%);
  pointer-events: none;
}
.dot {
  width: calc(4.2 * var(--u));
  height: calc(4.2 * var(--u));
  border-radius: 50%;
  background: #fff;
  opacity: 0.5;
  transition:
    opacity 0.3s ease,
    transform 0.3s ease;
}
.dot[data-on] {
  opacity: 1;
}
.dot[data-far] {
  transform: scale(0.62);
}

/* Badges on the photos. */
.badge {
  position: absolute;
  z-index: 2;
  top: calc(6.6 * var(--u));
  left: calc(6.6 * var(--u));
  display: inline-flex;
  align-items: center;
  gap: calc(3.2 * var(--u));
  height: calc(17.3 * var(--u));
  padding: 0 calc(6 * var(--u)) 0 calc(5 * var(--u));
  border-radius: 999px;
  background: #fff;
  color: #3d3d3f;
  font-size: calc(7.6 * var(--u));
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  pointer-events: none;
}
.badge svg {
  width: calc(8.4 * var(--u));
  height: calc(8.4 * var(--u));
}
.plain {
  height: calc(19.3 * var(--u));
  padding: 0 calc(8 * var(--u));
  color: #333;
  font-size: calc(9.3 * var(--u));
}
.glass {
  top: calc(13.7 * var(--u));
  left: calc(11.4 * var(--u));
  height: calc(15.4 * var(--u));
  padding: 0 calc(6.6 * var(--u));
  background: rgb(98 104 96 / 0.72);
  color: #fff;
  font-size: calc(8.4 * var(--u));
}
.listing .badge {
  top: calc(13.4 * var(--u));
  left: calc(12.6 * var(--u));
}

/* The heart: a white glyph, and a red one shown when saved. */
.heart {
  position: absolute;
  z-index: 3;
  top: calc(6 * var(--u));
  right: calc(6 * var(--u));
  display: grid;
  place-items: center;
  width: calc(20 * var(--u));
  height: calc(20 * var(--u));
  border-radius: 50%;
  background: transparent;
}
.heart:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.heartBox {
  position: relative;
  display: block;
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
  transform: scale(1);
  filter: drop-shadow(0 calc(0.5 * var(--u)) calc(1.5 * var(--u)) rgb(0 0 0 / 0.25));
}
.heartOff,
.heartOn {
  position: absolute;
  inset: 0;
  display: block;
}
.heartOff svg,
.heartOn svg {
  display: block;
  width: 100%;
  height: 100%;
}
.heartOn,
.heart[aria-pressed='true'] .heartOff {
  visibility: hidden;
}
.heart[aria-pressed='true'] .heartOn {
  visibility: visible;
}

/* Dark buttons, as on the recipe and the house (light ones at night). */
.dark {
  display: grid;
  place-items: center;
  width: 100%;
  border-radius: 999px;
  background: linear-gradient(var(--accent), var(--accent-2));
  box-shadow:
    inset 0 calc(0.6 * var(--u)) 0 var(--accent-highlight),
    0 calc(1 * var(--u)) calc(2 * var(--u)) var(--accent-shadow);
  color: var(--accent-ink);
  font-weight: 500;
  transition: transform 0.15s ease;
}
.dark:active,
.reserve:active,
.dark[data-press],
.reserve[data-press] {
  transform: scale(0.97);
}
.dark:focus-visible,
.reserve:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The recipe. */
.recipeBody {
  padding: calc(9.6 * var(--u)) calc(9.6 * var(--u)) 0;
}
.recipeTitle {
  font-size: calc(12.4 * var(--u));
  font-weight: 700;
  line-height: calc(16 * var(--u));
  letter-spacing: -0.05em;
  white-space: nowrap;
}
.recipeText {
  display: -webkit-box;
  height: calc(21.6 * var(--u));
  margin-top: calc(2.4 * var(--u));
  overflow: hidden;
  color: var(--muted-recipe);
  font-size: calc(8.5 * var(--u));
  line-height: calc(10.8 * var(--u));
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
}
.facts {
  display: flex;
  align-items: center;
  height: calc(24.5 * var(--u));
  margin-top: calc(7.6 * var(--u));
  border-top: 1px solid var(--rule-3);
  border-bottom: 1px solid var(--rule-4);
  color: var(--muted-facts);
  font-size: calc(8.6 * var(--u));
  white-space: nowrap;
}
.facts li {
  display: flex;
  align-items: center;
  gap: calc(2.6 * var(--u));
}
.facts li + li {
  padding-left: calc(13.5 * var(--u));
  border-left: 1px solid var(--rule-2);
}
.facts li:first-child {
  width: calc(54.4 * var(--u));
}
.facts li:nth-child(2) {
  width: calc(77 * var(--u));
}
.facts b {
  color: var(--ink-2);
}
.facts svg {
  width: calc(8.6 * var(--u));
  height: calc(8.6 * var(--u));
  color: var(--icon);
}
.chips {
  display: flex;
  gap: calc(3.4 * var(--u));
  margin-top: calc(7 * var(--u));
}
.chips li {
  display: grid;
  place-items: center;
  height: calc(15 * var(--u));
  padding: 0 calc(5.6 * var(--u));
  border-radius: 999px;
  background: var(--chip);
  color: var(--ink-chip);
  font-size: calc(7.9 * var(--u));
  white-space: nowrap;
}
.recipeButton {
  height: calc(26 * var(--u));
  margin-top: calc(9 * var(--u));
  font-size: calc(8.6 * var(--u));
}

/* The flat. */
.flatBody {
  padding: calc(8.7 * var(--u)) calc(10.6 * var(--u)) 0;
}
.flatHead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  white-space: nowrap;
}
.flatTitle {
  font-size: calc(14 * var(--u));
  font-weight: 500;
  line-height: calc(19 * var(--u));
  letter-spacing: -0.01em;
}
.flatPrice {
  color: var(--ink-price);
  font-size: calc(14 * var(--u));
  font-weight: 700;
  letter-spacing: -0.01em;
}
.flatPrice span {
  color: var(--ink-muted);
  font-size: calc(8 * var(--u));
  font-weight: 400;
}
.flatText {
  margin-top: calc(3.4 * var(--u));
  overflow: hidden;
  color: var(--muted-text);
  font-size: calc(12.2 * var(--u));
  line-height: calc(15 * var(--u));
  white-space: nowrap;
  text-overflow: ellipsis;
}
.rooms {
  display: flex;
  align-items: center;
  justify-content: space-between;
  white-space: nowrap;
}
.flat .rooms {
  height: calc(32 * var(--u));
  margin-top: calc(11 * var(--u));
  border-top: 1px solid var(--rule);
  color: var(--muted-rooms);
  font-size: calc(11.2 * var(--u));
}
.rooms li {
  display: flex;
  align-items: center;
  gap: calc(2.8 * var(--u));
}
.rooms b {
  font-weight: 700;
}
.flat .rooms b {
  color: var(--ink-rooms);
}
.rooms svg {
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
}

/* The house for sale. */
.houseBody {
  padding: calc(12 * var(--u)) calc(10 * var(--u)) 0;
}
.housePrice {
  color: var(--ink-muted);
  font-size: calc(8.2 * var(--u));
  line-height: calc(17 * var(--u));
  white-space: nowrap;
}
.housePrice b {
  margin-right: calc(1.6 * var(--u));
  color: var(--ink);
  font-size: calc(12 * var(--u));
  font-weight: 700;
  letter-spacing: -0.01em;
}
.houseOwner {
  margin-top: calc(-0.4 * var(--u));
  overflow: hidden;
  color: var(--muted-byline);
  font-size: calc(8.8 * var(--u));
  line-height: calc(11 * var(--u));
  white-space: nowrap;
  text-overflow: ellipsis;
}
.houseOwner b {
  color: var(--ink-2);
}
.houseFacts {
  display: flex;
  align-items: center;
  height: calc(25.6 * var(--u));
  margin-top: calc(7.6 * var(--u));
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
  color: var(--ink-muted);
  font-size: calc(9.2 * var(--u));
  white-space: nowrap;
}
.houseFacts li {
  display: flex;
  align-items: center;
  gap: calc(2.8 * var(--u));
}
.houseFacts li + li {
  margin-left: calc(9 * var(--u));
  padding-left: calc(9.6 * var(--u));
  border-left: 1px solid var(--rule-2);
}
.houseFacts b {
  color: var(--ink-2);
}
.houseFacts svg {
  width: calc(9 * var(--u));
  height: calc(9 * var(--u));
  color: var(--icon);
}
.byline {
  display: flex;
  justify-content: space-between;
  padding-right: calc(7.5 * var(--u));
  margin-top: calc(7.4 * var(--u));
  color: var(--muted-byline);
  font-size: calc(9 * var(--u));
  line-height: calc(11 * var(--u));
  white-space: nowrap;
}
.byline u {
  color: var(--ink-2);
  font-weight: 500;
  text-decoration-thickness: 1px;
  text-underline-offset: calc(2 * var(--u));
}
.houseButton {
  height: calc(27 * var(--u));
  margin-top: calc(12 * var(--u));
  font-size: calc(9.2 * var(--u));
}

/* Cards that are photos from edge to edge: a shade under their text, white type. */
.shade {
  position: absolute;
  z-index: 1;
  inset: 0;
  pointer-events: none;
}
.lawnShade {
  background: linear-gradient(
    rgb(79 99 36 / 0) 52%,
    rgb(84 103 38 / 0.55) 70%,
    rgb(79 97 34 / 0.92) 100%
  );
}
.fieldShade {
  background: linear-gradient(rgb(32 40 28 / 0) 58%, rgb(31 38 27 / 0.92) 70%, #232b20 100%);
}
.duskShade {
  background:
    radial-gradient(70% 34% at 0% 100%, rgb(150 136 150 / 0.75), rgb(150 136 150 / 0) 100%),
    radial-gradient(40% 50% at 104% 86%, rgb(160 152 170 / 0.6), rgb(160 152 170 / 0) 100%),
    linear-gradient(rgb(36 36 44 / 0) 50%, rgb(38 38 46 / 0.84) 68%, rgb(42 42 52 / 0.96) 100%);
}
.cottageText,
.listingText,
.guestText {
  position: absolute;
  z-index: 2;
  right: 0;
  bottom: 0;
  left: 0;
  color: #fff;
  white-space: nowrap;
}
.cottageText {
  padding: 0 calc(12.4 * var(--u)) calc(1 * var(--u)) calc(12 * var(--u));
}
.cottageHead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
}
.cottageHead h3 {
  font-size: calc(13.6 * var(--u));
  font-weight: 500;
  letter-spacing: -0.01em;
}
.cottageHead p {
  font-size: calc(12.7 * var(--u));
  font-weight: 700;
}
.cottageAddress {
  margin-top: calc(3 * var(--u));
  color: rgb(255 255 255 / 0.9);
  font-size: calc(11 * var(--u));
  line-height: calc(14 * var(--u));
}
.cottage .rooms {
  height: calc(38 * var(--u));
  margin-top: calc(7 * var(--u));
  border-top: 1px solid rgb(255 255 255 / 0.2);
  font-size: calc(11.2 * var(--u));
}

.listingText {
  padding: 0 calc(12 * var(--u)) calc(11 * var(--u));
}
.listingText h3 {
  font-size: calc(14.6 * var(--u));
  font-weight: 700;
  letter-spacing: -0.01em;
}
.listingRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: calc(4.6 * var(--u));
  padding-bottom: calc(10.6 * var(--u));
  border-bottom: 1px solid rgb(255 255 255 / 0.12);
  font-size: calc(9.4 * var(--u));
  line-height: calc(11 * var(--u));
}
.listingRow p {
  color: rgb(255 255 255 / 0.92);
}
.listingRow dl {
  display: flex;
  margin: 0;
  text-align: center;
}
.listingRow dl > div {
  padding: 0 calc(11 * var(--u));
}
.listingRow dl > div + div {
  padding-right: 0;
  border-left: 1px solid rgb(255 255 255 / 0.18);
}
.listingRow dd {
  margin: 0;
}
.listingBy {
  margin-top: calc(10 * var(--u));
  color: rgb(255 255 255 / 0.5);
  font-size: calc(9 * var(--u));
}
.listingBy u {
  color: #fff;
  text-decoration-thickness: 1px;
  text-underline-offset: calc(2 * var(--u));
}

.guestText {
  padding: 0 calc(11.4 * var(--u)) calc(11.6 * var(--u)) calc(10.6 * var(--u));
}
.guestText h3 {
  font-size: calc(14.4 * var(--u));
  font-weight: 500;
  letter-spacing: -0.015em;
}
.guestAddress {
  display: flex;
  align-items: center;
  gap: calc(3 * var(--u));
  margin-top: calc(5 * var(--u));
  color: rgb(255 255 255 / 0.9);
  font-size: calc(9.4 * var(--u));
  line-height: calc(12 * var(--u));
}
.guestAddress svg {
  width: calc(10 * var(--u));
  height: calc(10 * var(--u));
}
.roomsSmall {
  justify-content: flex-start;
  gap: calc(8 * var(--u));
  height: calc(22 * var(--u));
  margin-top: calc(7 * var(--u));
  color: rgb(255 255 255 / 0.85);
  font-size: calc(7.6 * var(--u));
}
.roomsSmall svg {
  width: calc(9 * var(--u));
  height: calc(9 * var(--u));
}
.roomsSmall b {
  color: #fff;
}
.guestActions {
  display: flex;
  gap: calc(4.4 * var(--u));
  margin-top: calc(4 * var(--u));
}
.rate {
  display: grid;
  place-items: center;
  width: calc(45.7 * var(--u));
  height: calc(25 * var(--u));
  border-radius: 999px;
  background: rgb(255 255 255 / 0.22);
  font-size: calc(9.6 * var(--u));
  font-weight: 500;
}
.reserve {
  position: relative;
  flex: 1;
  height: calc(25 * var(--u));
  border-radius: 999px;
  background: #fff;
  box-shadow: 0 calc(1 * var(--u)) calc(3 * var(--u)) rgb(0 0 0 / 0.15);
  color: #2c2c2e;
  font-size: calc(9.6 * var(--u));
  font-weight: 500;
  transition: transform 0.15s ease;
}
.reserveIdle,
.reserveDone {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: calc(3 * var(--u));
}
.reserveDone svg {
  width: calc(10 * var(--u));
  height: calc(10 * var(--u));
  color: #1f9d55;
}
.reserveDone,
.reserve[aria-pressed='true'] .reserveIdle {
  visibility: hidden;
}
.reserve[aria-pressed='true'] .reserveDone {
  visibility: visible;
}

/* Two columns, then one, each at a size of its own. */
@container (max-width: 679px) {
  .scene {
    --u: min(1.12px, 100cqi / 572);
    height: calc(1040 * var(--u));
  }
  .grid {
    grid-template-columns: repeat(2, calc(232 * var(--u)));
    grid-template-rows: calc(300 * var(--u)) calc(300 * var(--u)) calc(288 * var(--u));
    gap: calc(28 * var(--u)) calc(36 * var(--u));
  }
}
@container (max-width: 439px) {
  .scene {
    --u: min(1.3px, 100cqi / 272);
    height: calc(1960 * var(--u));
  }
  .grid {
    grid-template-columns: calc(232 * var(--u));
    grid-template-rows: repeat(3, calc(300 * var(--u))) repeat(3, calc(288 * var(--u)));
    gap: calc(24 * var(--u));
  }
}

/* The demo's pointer: an arrow, or a closed hand while it drags. It moves over the whole stage,
   in a box of its own, contained, so that nothing in it can ever lay out the page around it. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}
.pointer [data-glyph='hand'],
.pointer[data-grab] [data-glyph='arrow'] {
  visibility: hidden;
}
.pointer[data-grab] [data-glyph='hand'] {
  visibility: visible;
}

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

@media (prefers-reduced-motion: reduce) {
  .card,
  .card::before,
  .arrow,
  .dot,
  .dark,
  .reserve {
    transition: none;
  }
}
```

### listing-cards/cards.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';
import { Photo } from './art';
import { Carousel, type CarouselHandle } from './carousel';
import {
  AreaIcon,
  BathIcon,
  BedIcon,
  CheckIcon,
  ChefIcon,
  ClockIcon,
  ExpandIcon,
  HeartIcon,
  HouseIcon,
  MapIcon,
  PeopleIcon,
  StarIcon,
} from './icons';
import styles from './listing-cards.module.css';

/*
 * The six cards. Three sit on white (a dark grey at night) with their photos inset: a recipe, a
 * flat to rent, a house for sale. Three are photos from edge to edge, their text on a shade: a cottage, the house again,
 * a guest house. Their photos are carousels (carousel.tsx); the drawings are in art.tsx.
 */

/** What every card hears from the stage. */
type CardProps = {
  /** The card's root, which the demo lifts as a hover would. */
  cardRef?: Ref<HTMLElement>;
  /** Its photos, which the demo drives. */
  photosRef?: Ref<CarouselHandle>;
  /** The reader moved to photo `index`. */
  onTurn: (index: number, count: number) => void;
  /** Extra class for the card's root (the docs draw a card on its own with the stage's). */
  className?: string;
  /** Style for the card's root, such as its unit, --u, when drawn on its own. */
  style?: CSSProperties;
};

/** Where a card drawn on its own takes its class and style. */
type Own = Pick<CardProps, 'className' | 'style'>;

/** Beds, baths and floor area, as the cards list them. */
function Rooms({ beds, baths, small }: { beds: number; baths: number; small?: boolean }) {
  return (
    <ul className={`${styles.rooms} ${small ? styles.roomsSmall : ''}`} data-part="rooms">
      <li>
        <BedIcon />
        Beds: <b>{beds}</b>
      </li>
      <li>
        <BathIcon />
        Baths: <b>{baths}</b>
      </li>
      <li>
        <AreaIcon />
        Sqft: <b>1150</b>
      </li>
    </ul>
  );
}

export function RecipeCard({
  className = '',
  style,
  cardRef,
  photosRef,
  onTurn,
  onCook,
  buttonRef,
}: CardProps & { onCook: () => void; buttonRef?: Ref<HTMLButtonElement> }) {
  const title = 'Lebanese Lemon Garlic Chicken';
  return (
    <article
      ref={cardRef}
      className={`${styles.card} ${styles.recipe} ${className}`}
      style={style}
      aria-label={title}
      data-part="card"
    >
      <Carousel
        ref={photosRef}
        className={styles.inset}
        label={`Photos of ${title}`}
        onTurn={(index) => onTurn(index, 4)}
        slides={[
          <Photo
            scene="dish"
            key="dish"
            label="Chicken and spiced rice in a copper pot, with two small bowls"
          />,
          <Photo
            scene="overhead"
            key="overhead"
            label="The dish from above, with curry, yogurt sauce and lemons"
          />,
          <Photo
            scene="dish"
            key="close"
            view="44 26 128 87"
            label="A closer look at the chicken and rice"
          />,
          <Photo
            scene="overhead"
            key="closeup"
            view="40 26 104 71"
            label="Spiced rice and chicken, up close"
          />,
        ]}
      >
        <span className={styles.badge} data-part="badge">
          <StarIcon />
          Hot choice
        </span>
      </Carousel>
      <div className={styles.recipeBody} data-part="body">
        <h3 className={styles.recipeTitle}>{title}</h3>
        <p className={styles.recipeText}>
          Juicy chicken roasted with lemon, garlic and sumac, served over warm, fragrant rice.
        </p>
        <ul className={styles.facts}>
          <li>
            <ClockIcon />
            <b>20</b> mins
          </li>
          <li>
            <PeopleIcon />
            <b>4</b> servings
          </li>
          <li>
            <ChefIcon />
            <b>Medium</b>
          </li>
        </ul>
        <ul className={styles.chips} aria-label="Ingredients" data-part="chips">
          <li>Yogurt</li>
          <li>Olive oil</li>
          <li aria-label="and 8 more">+8</li>
        </ul>
        <button
          ref={buttonRef}
          type="button"
          className={`${styles.dark} ${styles.recipeButton}`}
          data-part="action"
          onClick={onCook}
        >
          Start Cooking
        </button>
      </div>
    </article>
  );
}

export function FlatCard({
  className = '',
  style,
  cardRef,
  photosRef,
  onTurn,
  liked,
  onLike,
  heartRef,
}: CardProps & {
  liked: boolean;
  onLike: () => void;
  heartRef?: Ref<HTMLButtonElement>;
}) {
  return (
    <article
      ref={cardRef}
      className={`${styles.card} ${styles.flat} ${className}`}
      style={style}
      aria-label="Business Bay"
      data-part="card"
    >
      <Carousel
        ref={photosRef}
        className={styles.inset}
        label="Photos of Business Bay"
        onTurn={(index) => onTurn(index, 4)}
        slides={[
          <Photo
            scene="living"
            key="living"
            label="A sunlit living room with a caramel sofa and two armchairs"
          />,
          <Photo
            scene="bedroom"
            key="bedroom"
            label="A bedroom with linen sheets and a sheer curtain"
          />,
          <Photo
            scene="living"
            key="window"
            view="52 46 112 101"
            label="The armchairs by the window"
          />,
          <Photo
            scene="bedroom"
            key="bed"
            view="34 70 120 108"
            label="The bed, its cushions and throw"
          />,
        ]}
      >
        <span className={`${styles.badge} ${styles.plain}`} data-part="badge">
          Guests Favorite
        </span>
        <button
          ref={heartRef}
          type="button"
          className={styles.heart}
          data-part="heart"
          aria-label="Save Business Bay"
          aria-pressed={liked}
          onClick={onLike}
        >
          <span className={styles.heartBox}>
            <span className={styles.heartOff}>
              <HeartIcon color="#fff" />
            </span>
            <span className={styles.heartOn}>
              <HeartIcon color="#ff3b5c" />
            </span>
          </span>
        </button>
      </Carousel>
      <div className={styles.flatBody} data-part="body">
        <div className={styles.flatHead}>
          <h3 className={styles.flatTitle}>Business Bay</h3>
          <p className={styles.flatPrice} data-part="price">
            $3,679 <span>/m</span>
          </p>
        </div>
        <p className={styles.flatText}>Bright, calm, spacious, central...</p>
        <Rooms beds={2} baths={2} />
      </div>
    </article>
  );
}

export function HouseCard({
  className = '',
  style,
  cardRef,
  photosRef,
  onTurn,
  onDetails,
}: CardProps & { onDetails: () => void }) {
  return (
    <article
      ref={cardRef}
      className={`${styles.card} ${styles.house} ${className}`}
      style={style}
      aria-label="House for $250,000"
      data-part="card"
    >
      <Carousel
        ref={photosRef}
        className={styles.inset}
        label="Photos of the house"
        onTurn={(index) => onTurn(index, 4)}
        slides={[
          <Photo
            scene="farm"
            key="farm"
            view="17.8 47.7 265.4 188.3"
            label="A white farmhouse in tall grass, smoke rising from its chimney"
          />,
          <Photo
            scene="kitchen"
            key="kitchen"
            label="The kitchen, a table by a window onto the field"
          />,
          <Photo
            scene="farm"
            key="front"
            view="124 96 130 92"
            label="The front of the house, its door and windows"
          />,
          <Photo
            scene="kitchen"
            key="table"
            view="10 30 140 99"
            label="The table and its jug of wildflowers"
          />,
        ]}
      >
        <span className={styles.badge} data-part="badge">
          <StarIcon />
          Prime Pick
        </span>
      </Carousel>
      <div className={styles.houseBody} data-part="body">
        <p className={styles.housePrice}>
          <b>$250,000</b> List price
        </p>
        <p className={styles.houseOwner}>
          <b>Corentin Vallois</b> - Mila lindenbergerstr, 1063 AG
        </p>
        <ul className={styles.houseFacts}>
          <li>
            <ExpandIcon />
            <b>29 m²</b> Living
          </li>
          <li>
            <HouseIcon />
            <b>2</b> Rooms
          </li>
        </ul>
        <p className={styles.byline}>
          <span>
            By <u>Idris Malouf</u>
          </span>
          <span>2 days ago</span>
        </p>
        <button
          type="button"
          className={`${styles.dark} ${styles.houseButton}`}
          data-part="action"
          onClick={onDetails}
        >
          View Details
        </button>
      </div>
    </article>
  );
}

export function CottageCard({ className = '', style, cardRef, photosRef, onTurn }: CardProps) {
  return (
    <article
      ref={cardRef}
      className={`${styles.card} ${styles.photo} ${styles.cottage} ${className}`}
      style={style}
      aria-label="Highland Huts"
      data-part="card"
    >
      <Carousel
        ref={photosRef}
        className={styles.full}
        label="Photos of Highland Huts"
        onTurn={(index) => onTurn(index, 4)}
        slides={[
          <Photo
            scene="cottage"
            key="cottage"
            label="A white cottage with arched windows on a mown lawn"
          />,
          <Photo
            scene="salon"
            key="salon"
            label="Inside: tall arched windows onto the lawn, a cane chair"
          />,
          <Photo
            scene="cottage"
            key="windows"
            view="40 40 140 181"
            label="The arched windows and the roof"
          />,
          <Photo
            scene="salon"
            key="chair"
            view="30 60 160 207"
            label="The cane chair and a potted palm"
          />,
        ]}
      >
        <span className={`${styles.shade} ${styles.lawnShade}`} data-part="shade" />
        <span className={`${styles.badge} ${styles.glass}`} data-part="badge">
          Newly listed
        </span>
        <div className={styles.cottageText} data-part="text">
          <div className={styles.cottageHead}>
            <h3>Highland Huts</h3>
            <p>$200k</p>
          </div>
          <p className={styles.cottageAddress}>235 Fernhill Ave, Los...</p>
          <Rooms beds={2} baths={1} />
        </div>
      </Carousel>
    </article>
  );
}

export function ListingCard({ className = '', style }: Own = {}) {
  return (
    <article
      className={`${styles.card} ${styles.photo} ${styles.listing} ${className}`}
      style={style}
      aria-label="Listed at $250,000"
      data-part="card"
    >
      <div className={`${styles.still} ${styles.full}`} data-part="photos">
        <Photo
          scene="farm"
          view="35 0 229 288"
          label="A white farmhouse in tall grass under a pale sky, birds overhead"
        />
        <span className={`${styles.shade} ${styles.fieldShade}`} data-part="shade" />
        <span className={styles.badge} data-part="badge">
          <StarIcon />
          Prime Pick
        </span>
        <div className={styles.listingText} data-part="text">
          <h3>List: $250,000</h3>
          <div className={styles.listingRow}>
            <p>
              Mila lindenhofstr...
              <br />
              1063 AG Corentin Vallois
            </p>
            <dl>
              <div>
                <dt>29 m²</dt>
                <dd>Living</dd>
              </div>
              <div>
                <dt>2</dt>
                <dd>Rooms</dd>
              </div>
            </dl>
          </div>
          <p className={styles.listingBy}>
            By <u>Idris Malouf</u> 2 days ago
          </p>
        </div>
      </div>
    </article>
  );
}

export function GuestHouseCard({
  className = '',
  style,
  cardRef,
  photosRef,
  onTurn,
  reserved,
  onReserve,
  reserveRef,
}: CardProps & {
  reserved: boolean;
  onReserve: () => void;
  reserveRef?: Ref<HTMLButtonElement>;
}) {
  return (
    <article
      ref={cardRef}
      className={`${styles.card} ${styles.photo} ${styles.guest} ${className}`}
      style={style}
      aria-label="The Hills Guest House"
      data-part="card"
    >
      <Carousel
        ref={photosRef}
        className={styles.full}
        label="Photos of The Hills Guest House"
        onTurn={(index) => onTurn(index, 3)}
        slides={[
          <Photo
            scene="stoneHouse"
            key="house"
            label="A stone guest house under ivy at dusk, its windows lit"
          />,
          <Photo
            scene="lounge"
            key="lounge"
            label="Inside in the evening: stone walls, a reading lamp, a leather chair"
          />,
          <Photo
            scene="stoneHouse"
            key="ivy"
            view="40 0 150 195"
            label="The lit windows under the ivy"
          />,
        ]}
      >
        <span className={`${styles.shade} ${styles.duskShade}`} data-part="shade" />
        <div className={styles.guestText} data-part="text">
          <h3>The Hills Guest House</h3>
          <p className={styles.guestAddress}>
            <MapIcon />
            58 Ashbrook Road, Ellisley, B17 0LA
          </p>
          <Rooms beds={4} baths={2} small />
          <div className={styles.guestActions}>
            <span className={styles.rate} data-part="price">
              $620
            </span>
            <button
              ref={reserveRef}
              type="button"
              className={styles.reserve}
              data-part="action"
              aria-pressed={reserved}
              onClick={onReserve}
            >
              <span className={styles.reserveIdle}>Reserve Apartment</span>
              <span className={styles.reserveDone}>
                <CheckIcon />
                Reserved
              </span>
            </button>
          </div>
        </div>
      </Carousel>
    </article>
  );
}
```

### listing-cards/carousel.tsx

```tsx
'use client';

import {
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
  type Ref,
} from 'react';
import { ChevronIcon } from './icons';
import styles from './listing-cards.module.css';

/*
 * A card's photos: slides that slide sideways. A finger or a mouse drags them and lets go to the
 * next photo or back; the arrows (on hover, or on focus) step through them and wrap around, and
 * the arrow keys step up to either end; the dots say where you are, and shrink when far from the
 * photo shown, as many apps do.
 *
 * Each slide is a box the size of the photos, stacked on the others and placed by its transform:
 * the one shown, its neighbours one width off to either side, the rest hidden. A drag moves the
 * shown one and its neighbours with the hand; a turn glides the two slides it swaps, frame by
 * frame. The photos' box paints on one layer of its own (see the stylesheet), and the slides
 * move inside it without becoming layers themselves: on a phone, a layer for each moving slide
 * would also lift the badges and texts drawn over it onto layers of their own.
 */

/** Drives the carousel as a hand would; the demo uses it. */
export type CarouselHandle = {
  /** The slide shown, from 0. */
  index: () => number;
  /** How many slides there are. */
  count: () => number;
  /** Shows slide `index`. */
  go: (index: number) => void;
  /** Holds the row dragged by `share` of its width (negative: towards the next slide). */
  hold: (share: number) => void;
  /** Lets the row go: past a fifth of its width it moves on, otherwise it slides back. */
  release: () => void;
  /** The photos' box. */
  element: () => HTMLDivElement | null;
};

/** How far a press moves before it drags, in px; up or down first, the page scrolls. */
const DRAG_START = 6;
/** The share of the width past which a drag moves on to the next slide. */
const TURN = 0.2;
/** A flick this fast (px per ms) moves on however short it is. */
const FLICK = 0.45;
/** How long a turn glides, in ms. */
const GLIDE = 450;

/** Fast, then settling: an ease-out with a long tail. */
const easeOut = (t: number) => 1 - (1 - t) ** 3.6;

type Press = {
  pointerId: number;
  x: number;
  y: number;
  time: number;
  /** The width of the photos on the screen, which a scaled stage makes smaller. */
  width: number;
  dragging: boolean;
};

export function Carousel({
  label,
  slides,
  dots = true,
  className = '',
  onTurn,
  ref,
  children,
}: {
  /** What the photos show, for screen readers: "Photos of Business Bay". */
  label: string;
  /** The slides, each a drawing with its own label. */
  slides: ReactNode[];
  /** Shows the dots under the photos. */
  dots?: boolean;
  /** Extra class for the photos' box. */
  className?: string;
  /** Called when the reader moves to another slide. */
  onTurn?: (index: number) => void;
  /** Drives the carousel as a hand would. */
  ref?: Ref<CarouselHandle>;
  /** What lies on the photos: a badge, a heart, a shade, the card's text. */
  children?: ReactNode;
}) {
  const [index, setIndex] = useState(0);
  const box = useRef<HTMLDivElement>(null);
  const boxes = useRef<(HTMLDivElement | null)[]>([]);
  const press = useRef<Press | null>(null);
  const shown = useRef(0);
  const count = slides.length;

  /** Where each slide is, in widths off the photos' box, and the glide in progress. */
  const offsets = useRef<number[]>(slides.map((_, i) => (i === 0 ? 0 : 2)));
  const frame = useRef(0);

  /** Places slide `i` `offset` widths off the photos' box: visible next to it, hidden beyond. */
  const put = (i: number, offset: number) => {
    const slide = boxes.current[i];
    if (!slide) return;
    const near = Math.abs(offset) < 1.5;
    offsets.current[i] = near ? offset : Math.sign(offset) * 2;
    slide.style.visibility = near ? 'visible' : 'hidden';
    slide.style.transform = `translateX(${(offsets.current[i] * 100).toFixed(3)}%)`;
  };

  /** Glides slides to their places (`to`, by slide), frame by frame, from where they are. */
  const glide = (to: Map<number, number>) => {
    cancelAnimationFrame(frame.current);
    const from = new Map([...to.keys()].map((i) => [i, offsets.current[i]]));
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      to.forEach((offset, i) => put(i, offset));
      return;
    }
    const start = performance.now();
    const tick = (now: number) => {
      // A frame's time can come before the clock read when the glide began: clamp.
      const t = Math.min(1, Math.max(0, (now - start) / GLIDE));
      const e = easeOut(t);
      to.forEach((offset, i) => {
        const begin = from.get(i) ?? offset;
        put(i, begin + (offset - begin) * e);
      });
      frame.current = t < 1 ? requestAnimationFrame(tick) : 0;
    };
    frame.current = requestAnimationFrame(tick);
  };
  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  /** Draws the slides dragged by `share` of the width, with the hand. */
  const drawDrag = (share: number) => {
    cancelAnimationFrame(frame.current);
    // Past either end it gives less and less, like a rubber band.
    const atEnd = (share > 0 && shown.current === 0) || (share < 0 && shown.current === count - 1);
    const shift = atEnd ? share * 0.3 : share;
    for (let i = 0; i < count; i += 1) put(i, i - shown.current + shift);
  };

  /**
   * Shows slide `next` (within the ends): it slides in from the side `direction` points to (the
   * way the count goes, unless a wrap says otherwise), and the one shown slides out the other way.
   */
  const show = (next: number, byReader: boolean, direction = Math.sign(next - shown.current)) => {
    const target = Math.min(count - 1, Math.max(0, next));
    const from = shown.current;
    const way = direction || 1;
    // A slide coming from further than a neighbour starts beside the box.
    if (target !== from && Math.abs(offsets.current[target]) > 1) put(target, way);
    const moving = new Map([[target, 0]]);
    if (from !== target) moving.set(from, -way);
    // The others go straight to their places, out of sight.
    for (let i = 0; i < count; i += 1) if (!moving.has(i)) put(i, i - target);
    glide(moving);
    if (target === from) return;
    shown.current = target;
    setIndex(target);
    if (byReader) onTurn?.(target);
  };

  /** Lets a drag of `share` go: it moves on past TURN, or after a flick. */
  const letGo = (share: number, speed: number, byReader: boolean) => {
    if (share < -TURN || speed < -FLICK) show(shown.current + 1, byReader);
    else if (share > TURN || speed > FLICK) show(shown.current - 1, byReader);
    else show(shown.current, byReader);
  };

  const held = useRef(0);
  useImperativeHandle(ref, () => ({
    index: () => shown.current,
    count: () => count,
    go: (next) => show(next, false),
    hold: (share) => {
      held.current = share;
      drawDrag(share);
    },
    release: () => {
      letGo(held.current, 0, false);
      held.current = 0;
    },
    element: () => box.current,
  }));

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || count < 2) return;
    // Buttons on the photos keep their presses.
    if ((event.target as Element).closest('button')) return;
    const element = box.current;
    if (!element) return;
    press.current = {
      pointerId: event.pointerId,
      x: event.clientX,
      y: event.clientY,
      time: event.timeStamp,
      width: element.getBoundingClientRect().width || 1,
      dragging: false,
    };
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    if (!current || current.pointerId !== event.pointerId) return;
    const dx = event.clientX - current.x;
    if (!current.dragging) {
      const dy = event.clientY - current.y;
      if (Math.abs(dy) > DRAG_START && Math.abs(dy) > Math.abs(dx)) {
        press.current = null;
        return;
      }
      if (Math.abs(dx) < DRAG_START) return;
      current.dragging = true;
      event.currentTarget.setPointerCapture(event.pointerId);
    }
    drawDrag(dx / current.width);
  };

  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
    const current = press.current;
    if (!current || current.pointerId !== event.pointerId) return;
    press.current = null;
    if (!current.dragging) return;
    const dx = event.clientX - current.x;
    const speed = dx / Math.max(1, event.timeStamp - current.time);
    letGo(dx / current.width, speed, true);
  };

  const onPointerCancel = () => {
    if (!press.current) return;
    press.current = null;
    show(shown.current, false);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
    if (!step) return;
    event.preventDefault();
    show(shown.current + step, true);
  };

  return (
    <div
      ref={box}
      className={`${styles.media} ${className}`}
      data-part="photos"
      role="group"
      aria-roledescription="carousel"
      aria-label={label}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerCancel}
      onKeyDown={onKeyDown}
    >
      <div className={styles.slides}>
        {slides.map((slide, i) => (
          <div
            key={i}
            ref={(element) => {
              boxes.current[i] = element;
            }}
            className={styles.slide}
            role="group"
            aria-roledescription="slide"
            aria-label={`${i + 1} of ${count}`}
            aria-hidden={i !== index}
          >
            {slide}
          </div>
        ))}
      </div>
      {children}
      {count > 1 && (
        <>
          <button
            type="button"
            className={`${styles.arrow} ${styles.back}`}
            data-part="arrow"
            aria-label="Previous photo"
            onClick={() => show((shown.current - 1 + count) % count, true, -1)}
          >
            <ChevronIcon back />
          </button>
          <button
            type="button"
            className={`${styles.arrow} ${styles.forward}`}
            data-part="arrow"
            aria-label="Next photo"
            onClick={() => show((shown.current + 1) % count, true, 1)}
          >
            <ChevronIcon />
          </button>
        </>
      )}
      {dots && count > 1 && (
        <div className={styles.dots} aria-hidden="true" data-part="dots">
          {slides.map((_, i) => (
            <span
              key={i}
              className={styles.dot}
              data-part="dot"
              data-on={i === index || undefined}
              data-far={Math.abs(i - index) > 2 || undefined}
            />
          ))}
        </div>
      )}
    </div>
  );
}
```

### listing-cards/art.tsx

```tsx
'use client';

import { createContext, memo, useContext, type ReactNode } from 'react';

/*
 * The photos of the cards, painted in SVG: a dish on a dark table, a sunlit living room, a
 * farmhouse in tall grass, a cottage on a lawn, a stone guest house at dusk, and a second view of
 * each. Every scene is drawn once in its own units; a slide shows it whole or a closer crop of it
 * (its viewBox), and fills its box the way a photo would (slice).
 *
 * Each scene is drawn once, in a hidden sprite (SceneSprite), and every slide shows it through a
 * <use>, so a scene seen on two slides costs its markup once. Leaves, grass and rice are many small
 * shapes scattered by a seeded random generator, so the server and the browser draw the very same
 * picture, and merged into one path per colour. The drawings never change once drawn: a carousel
 * moves the boxes around them.
 */

/** Names a scene's gradients and patterns, unique on the page. */
type Ids = { id: (name: string) => string; url: (name: string) => string };

/** A seeded random number generator: the same seed gives the same numbers everywhere. */
function seeded(seed: number) {
  let value = seed % 2147483647 || 1;
  return () => {
    value = (value * 16807) % 2147483647;
    return (value - 1) / 2147483646;
  };
}

const round = (value: number) => Math.round(value * 10) / 10;

type Canopy = {
  seed: number;
  /** The ellipse the leaves fill. */
  cx: number;
  cy: number;
  rx: number;
  ry: number;
  count: number;
  /** The leaves' radius, smallest and largest. */
  size: readonly [number, number];
  /** From the shadowed colour to the lit one. */
  colors: readonly string[];
  /** Where the light comes from, as a direction: up and to the left by default. */
  light?: readonly [number, number];
};

/**
 * Foliage: many round leaves in an ellipse, coloured by how much they face the light. Each leaf is
 * a dot (a stroke of no length with round caps), and the dots of one colour and one size share a
 * path, the shadowed colours first and the lit ones over them.
 */
function Leaves({ seed, cx, cy, rx, ry, count, size, colors, light = [-0.55, -0.85] }: Canopy) {
  const random = seeded(seed);
  const SIZES = 4;
  const dots: string[] = Array.from({ length: colors.length * SIZES }, () => '');
  for (let i = 0; i < count; i += 1) {
    const angle = random() * Math.PI * 2;
    const reach = Math.sqrt(random());
    const x = cx + Math.cos(angle) * reach * rx;
    const y = cy + Math.sin(angle) * reach * ry;
    const grade = Math.floor(random() * SIZES);
    const facing = ((x - cx) / rx) * -light[0] + ((y - cy) / ry) * -light[1];
    const lit = 0.5 - facing * 0.42 + (random() - 0.5) * 0.4;
    const shade = Math.min(colors.length - 1, Math.max(0, Math.floor(lit * colors.length)));
    dots[shade * SIZES + grade] += `M${round(x)} ${round(y)}h0`;
  }
  return (
    <g fill="none" strokeLinecap="round">
      {dots.map((d, i) =>
        d ? (
          <path
            key={i}
            d={d}
            stroke={colors[Math.floor(i / SIZES)]}
            strokeWidth={round(2 * (size[0] + ((size[1] - size[0]) * (i % SIZES)) / (SIZES - 1)))}
          />
        ) : null,
      )}
    </g>
  );
}

/**
 * Grass: short blades leaning a little either way, rooted along a band, lighter towards their
 * tops; the blades of one colour share a path.
 */
function Blades({
  seed,
  x0,
  x1,
  top,
  depth,
  count,
  height,
  colors,
  width = 0.7,
}: {
  seed: number;
  /** The band the blades grow from: its left and right ends, its top and its depth. */
  x0: number;
  x1: number;
  top: number;
  depth: number;
  count: number;
  /** The blades' height, shortest and tallest. */
  height: readonly [number, number];
  /** From the darkest blade to the lightest. */
  colors: readonly string[];
  width?: number;
}) {
  const random = seeded(seed);
  const blades: string[] = colors.map(() => '');
  for (let i = 0; i < count; i += 1) {
    const x = x0 + random() * (x1 - x0);
    const y = top + random() * depth;
    const h = height[0] + random() * (height[1] - height[0]);
    const lean = (random() - 0.5) * h * 0.5;
    const lit = 1 - (y - top) / depth + (random() - 0.5) * 0.7;
    const shade = Math.min(colors.length - 1, Math.max(0, Math.floor(lit * colors.length)));
    blades[shade] +=
      `M${round(x)} ${round(y)}q${round(lean * 0.3)} ${round(-h * 0.5)} ${round(lean)} ${round(-h)}`;
  }
  return (
    <g fill="none" strokeWidth={width} strokeLinecap="round">
      {blades.map((d, shade) => (d ? <path key={shade} d={d} stroke={colors[shade]} /> : null))}
    </g>
  );
}

/** A soft cloud: puffs with feathered edges, a grey belly under them. */
function Cloud({
  puffs,
  fill,
  belly,
}: {
  puffs: readonly (readonly [number, number, number])[];
  fill: string;
  belly: string;
}) {
  return (
    <g>
      {puffs.map(([cx, cy, r], i) => (
        <circle key={`b${i}`} cx={cx + r * 0.1} cy={cy + r * 0.38} r={r * 0.92} fill={belly} />
      ))}
      {puffs.map(([cx, cy, r], i) => (
        <circle key={i} cx={cx} cy={cy} r={r} fill={fill} />
      ))}
    </g>
  );
}

/** A small bird in flight, as two strokes, at (x, y). */
function Bird({ x, y, s = 1, tilt = 0 }: { x: number; y: number; s?: number; tilt?: number }) {
  return (
    <path
      d={`M${round(x - 2.6 * s)} ${round(y - 0.3 * s + tilt)}q${round(1.4 * s)} ${round(-1.2 * s)} ${round(2.6 * s)} ${round(0.5 * s)}q${round(1.1 * s)} ${round(-1.6 * s)} ${round(2.7 * s)} ${round(-1 * s - tilt)}`}
      fill="none"
      stroke="#2a2d2b"
      strokeWidth={round(0.8 * s)}
      strokeLinecap="round"
    />
  );
}

/* ------------------------------------------------------------------------------------------ */
/* A dish of chicken and spiced rice in a copper pot, two small bowls, on burlap, in the dark.  */
/* 215 × 146.                                                                                   */

const RICE = ['#a8481a', '#c8622a', '#de8234', '#eaa040', '#f2b84e', '#f8cc66', '#fde08e'];

/** Grains of rice: short strokes at every angle, lighter towards the top of the heap. */
function Rice({
  seed,
  count,
  box = [37, 38, 110, 34],
  even = false,
}: {
  seed: number;
  count: number;
  /** Where the grains lie: x, y, width and height. */
  box?: readonly [number, number, number, number];
  /** Seen from above: lit evenly, rather than lighter towards the top. */
  even?: boolean;
}) {
  const random = seeded(seed);
  const strokes: string[] = RICE.map(() => '');
  const [left, top, width, height] = box;
  for (let i = 0; i < count; i += 1) {
    const x = left + random() * width;
    const y = top + random() * height;
    const lit = (even ? 0.75 : 1.1 - (y - 38) / 34) + (random() - 0.5) * 0.9;
    const shade = Math.min(RICE.length - 1, Math.max(0, Math.floor(lit * (RICE.length - 1))));
    const angle = random() * Math.PI;
    const dx = Math.cos(angle) * 1.1;
    const dy = Math.sin(angle) * 1.1;
    strokes[shade] += `M${round(x - dx)} ${round(y - dy)}l${round(2 * dx)} ${round(2 * dy)}`;
  }
  return (
    <g strokeWidth="0.9" strokeLinecap="round">
      {strokes.map((d, shade) => (
        <path key={shade} d={d} stroke={RICE[shade]} />
      ))}
    </g>
  );
}

/** Specks of spice on a piece of chicken. */
const SPICE = ['#7a2608', '#9a3a10', '#f6c08a'];

function drawDish({ id, url }: Ids) {
  const heap = 'M36 64c1-12 18-22 46-24 10-1 18 1 24-1 18-1 34 9 41 24-28 10-84 10-111 1z';
  const bowl = (cx: number, cy: number, rx: number, depth: number) =>
    `M${cx - rx} ${cy}c1 ${round(depth * 0.66)} ${round(rx * 0.34)} ${depth} ${round(rx * 0.72)} ${depth}h${round(rx * 0.56)}c${round(rx * 0.38)} 0 ${round(rx * 0.71 - 1)} ${round(-depth * 0.34)} ${round(rx * 0.72)} ${-depth}z`;
  const pieces = [
    'M45 56c0-5 4-9 9-11 3-1 5 1 8 0 4-2 9-2 12 1 3 3 3 7 0 10-3 2-5 2-7 4-4 3-12 3-17 1-3-1-5-3-5-5z',
    'M103 51c-3-4-2-10 1-14 2-3 6-5 10-4 3 0 4-2 7-1 4 2 5 7 3 11-1 3-3 4-3 7-1 3-5 5-9 5-4 0-7-2-9-4z',
    'M89 62c-1-5 1-10 5-13 3-2 6-1 9-3 4-1 9 1 11 5 2 3 1 6 2 9 1 4-2 8-7 10-4 1-6 0-9 1-5 0-10-4-11-9z',
  ];
  return (
    <>
      <defs>
        <radialGradient id={id('bg')} cx="0.48" cy="0.45" r="0.7">
          <stop offset="0" stopColor="#160c07" />
          <stop offset="0.5" stopColor="#040303" />
          <stop offset="1" stopColor="#000" />
        </radialGradient>
        <linearGradient id={id('copper')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#1a0802" />
          <stop offset="0.12" stopColor="#5a2a10" />
          <stop offset="0.25" stopColor="#b66e3a" />
          <stop offset="0.36" stopColor="#6e3216" />
          <stop offset="0.7" stopColor="#3e1806" />
          <stop offset="1" stopColor="#100502" />
        </linearGradient>
        <linearGradient id={id('deep')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#000" stopOpacity="0" />
          <stop offset="1" stopColor="#000" stopOpacity="0.55" />
        </linearGradient>
        <pattern id={id('hammer')} width="4" height="3" patternUnits="userSpaceOnUse">
          <ellipse cx="2" cy="1.5" rx="1.6" ry="1" fill="#000" opacity="0.1" />
          <ellipse cx="1.4" cy="1" rx="0.6" ry="0.35" fill="#f2b27a" opacity="0.12" />
        </pattern>
        <clipPath id={id('heap')}>
          <path d={heap} />
        </clipPath>
        <radialGradient id={id('heapShade')} cx="0.45" cy="0.15" r="0.9">
          <stop offset="0.5" stopColor="#000" stopOpacity="0" />
          <stop offset="1" stopColor="#5a1c04" stopOpacity="0.4" />
        </radialGradient>
        <pattern
          id={id('weave')}
          width="3"
          height="3"
          patternUnits="userSpaceOnUse"
          patternTransform="rotate(-11)"
        >
          <rect width="3" height="3" fill="#94764e" />
          <path d="M0 .75h3M0 2.25h3" stroke="#c4a676" strokeWidth="0.8" />
          <path d="M.75 0v3M2.25 0v3" stroke="#6a5232" strokeWidth="0.55" />
        </pattern>
        <linearGradient id={id('burlapShade')} x1="0.3" x2="0.5" y1="0" y2="1">
          <stop offset="0" stopColor="#000" stopOpacity="0" />
          <stop offset="0.6" stopColor="#000" stopOpacity="0.2" />
          <stop offset="1" stopColor="#000" stopOpacity="0.6" />
        </linearGradient>
        <radialGradient id={id('curry')} cx="0.42" cy="0.4" r="0.65">
          <stop offset="0" stopColor="#d8743a" />
          <stop offset="0.6" stopColor="#9a3410" />
          <stop offset="1" stopColor="#4e1404" />
        </radialGradient>
        <radialGradient id={id('onion')} cx="0.45" cy="0.4" r="0.6">
          <stop offset="0" stopColor="#f2a690" />
          <stop offset="1" stopColor="#a8424c" />
        </radialGradient>
        <radialGradient id={id('jug')} cx="0.28" cy="0.42" r="0.85">
          <stop offset="0" stopColor="#4e2612" />
          <stop offset="0.55" stopColor="#200e05" />
          <stop offset="1" stopColor="#080302" />
        </radialGradient>
        <linearGradient id={id('meat')} x1="0" x2="0.3" y1="0" y2="1">
          <stop offset="0" stopColor="#e2843e" />
          <stop offset="0.55" stopColor="#c85a22" />
          <stop offset="1" stopColor="#9a3a14" />
        </linearGradient>
        <radialGradient id={id('vignette')} cx="0.5" cy="0.48" r="0.72">
          <stop offset="0.62" stopColor="#000" stopOpacity="0" />
          <stop offset="1" stopColor="#000" stopOpacity="0.55" />
        </radialGradient>
      </defs>
      <rect x="-60" y="-60" width="335" height="266" fill={url('bg')} />
      {/* The table's dark planks. */}
      <path d="M-60 140 215 94h60v112H-60z" fill="#0b0908" />
      <path
        d="M40 146l175-52M90 146l125-36M140 146l75-22M-10 146l225-66"
        stroke="#1e1813"
        strokeWidth="0.7"
      />
      {/* A carved clay jug at the back. */}
      <path d="M190 0c-6 12-8 28-6 42 2 14 10 22 22 24h9V0z" fill={url('jug')} />
      <path
        d="M191 8l12 10M188 20l15 12M187 32l16 12M190 46l13 8M199 4l14 10M201 26l14 9M203 42l12 7"
        stroke="#8a4a24"
        strokeWidth="0.8"
        opacity="0.45"
      />
      {/* Coriander behind the pot. */}
      <Leaves
        seed={11}
        cx={163}
        cy={52}
        rx={15}
        ry={8}
        count={60}
        size={[0.9, 2]}
        colors={['#0f200c', '#183214', '#24481c', '#2f5a24']}
      />
      {/* Burlap under the dishes, frayed on the left. */}
      <path d="M16 96l12-7 120-22 67 3v47l-97 15-88-13-14-9z" fill={url('weave')} />
      <path d="M16 96l12-7 120-22 67 3v47l-97 15-88-13-14-9z" fill={url('burlapShade')} />
      <path
        d="M16 97l-5 .8M16 100l-5.6.4M16 103l-5 .9M16 106l-5.4.2M17 109l-4.6 1.4M23 113l-3 3M29 116l-2 3.6M36 117l-1 3.6"
        stroke="#a08058"
        strokeWidth="0.8"
        strokeLinecap="round"
      />
      {/* The copper pot: its ring handle, its body, the heap of rice and chicken, its rim. */}
      <ellipse cx="92" cy="106" rx="60" ry="7" fill="#000" opacity="0.6" />
      <ellipse cx="32" cy="57" rx="8.4" ry="10.8" fill="none" stroke="#5a2e14" strokeWidth="3.6" />
      <ellipse
        cx="32"
        cy="56.4"
        rx="8.4"
        ry="10.8"
        fill="none"
        stroke="#c39060"
        strokeWidth="1.5"
      />
      <path d="M35 64c2 26 17 40 39 43h36c22-3 35-17 37-43z" fill={url('copper')} />
      <path d="M35 64c2 26 17 40 39 43h36c22-3 35-17 37-43z" fill={url('hammer')} />
      <path d="M35 64c2 26 17 40 39 43h36c22-3 35-17 37-43z" fill={url('deep')} />
      <path
        d="M47 72c2 13 9 23 20 28"
        fill="none"
        stroke="#e8a46c"
        strokeWidth="1.3"
        strokeLinecap="round"
        opacity="0.45"
      />
      <path d={heap} fill="#e0913a" />
      <g clipPath={url('heap')}>
        <Rice seed={7} count={900} />
        <path d={heap} fill={url('heapShade')} />
      </g>
      {/* Chicken: a piece on the left, a drumstick at the back, a glossy piece in front. */}
      <path
        d="M120 34c1-3 4-4 6-3 2 1 2 4 0 5"
        fill="none"
        stroke="#ecd2b0"
        strokeWidth="2.6"
        strokeLinecap="round"
      />
      {pieces.map((d, i) => (
        <path key={i} d={d} fill={url('meat')} />
      ))}
      <g clipPath={url('heap')}>
        <Rice seed={29} count={260} box={[37, 57, 110, 15]} />
      </g>
      <Leaves
        seed={13}
        cx={62}
        cy={52}
        rx={12}
        ry={5}
        count={26}
        size={[0.35, 0.8]}
        colors={SPICE}
      />
      <Leaves
        seed={17}
        cx={114}
        cy={44}
        rx={8}
        ry={6}
        count={22}
        size={[0.35, 0.8]}
        colors={SPICE}
      />
      <Leaves
        seed={19}
        cx={103}
        cy={60}
        rx={10}
        ry={6}
        count={26}
        size={[0.35, 0.8]}
        colors={SPICE}
      />
      <g fill="none" stroke="#ffd6aa" strokeLinecap="round">
        <path d="M95 52c3-3 9-4 13-2" strokeWidth="1.6" opacity="0.85" />
        <path d="M51 49c3-3 9-4 13-3" strokeWidth="1.2" opacity="0.7" />
        <path d="M110 39c2-2 6-3 9-2" strokeWidth="1.1" opacity="0.7" />
      </g>
      <path
        d="M84 41l2.6-5.4M88 40.6l.6-6.4M91 41l3.8-4.6M86 38l-3.4-2.2M89.4 35l-1.8-2.6"
        stroke="#3f8a2e"
        strokeWidth="1.2"
        strokeLinecap="round"
      />
      <path d="M35 64c4 10 108 10 112 0" fill="none" stroke="#d29a66" strokeWidth="2.2" />
      <path
        d="M35 64c4 10 108 10 112 0"
        fill="none"
        stroke="#fbd6a8"
        strokeWidth="0.6"
        opacity="0.6"
      />
      <path
        d="M35 64c0-3 2-5 4-6M147 64c0-3-2-5-4-6"
        fill="none"
        stroke="#8a5228"
        strokeWidth="1.6"
      />
      <path d="M141 77l21-22" stroke="#8a8a8a" strokeWidth="1.8" strokeLinecap="round" />
      <path d="M141 77l21-22" stroke="#e8e8e8" strokeWidth="0.6" strokeLinecap="round" />
      {/* The bowl of pickled onions, to the right. */}
      <path d={bowl(185, 81, 29, 31)} fill={url('copper')} />
      <path d={bowl(185, 81, 29, 31)} fill={url('hammer')} />
      <path d={bowl(185, 81, 29, 31)} fill={url('deep')} />
      <ellipse cx="185" cy="81" rx="29" ry="7.6" fill="#2a1006" />
      <ellipse cx="185" cy="81.8" rx="26" ry="5.8" fill={url('onion')} />
      <g fill="#fbd6c4">
        <ellipse cx="174" cy="80.4" rx="4" ry="1.4" />
        <ellipse cx="188" cy="82" rx="5" ry="1.6" />
        <ellipse cx="197" cy="80" rx="3.4" ry="1.2" />
        <ellipse cx="181" cy="78.6" rx="3" ry="1.1" />
      </g>
      <g fill="#7e2c50">
        <ellipse cx="181" cy="83.4" rx="3" ry="1.1" />
        <ellipse cx="194" cy="83.6" rx="2.6" ry="1" />
        <ellipse cx="169" cy="82" rx="2.4" ry="0.9" />
        <ellipse cx="203" cy="82" rx="2" ry="0.8" />
      </g>
      <ellipse cx="185" cy="81" rx="29" ry="7.6" fill="none" stroke="#c99464" strokeWidth="1.4" />
      {/* The bowl of curry, in front. */}
      <path d={bowl(148, 100, 30, 30)} fill={url('copper')} />
      <path d={bowl(148, 100, 30, 30)} fill={url('hammer')} />
      <path d={bowl(148, 100, 30, 30)} fill={url('deep')} />
      <ellipse cx="148" cy="100" rx="30" ry="8.4" fill="#2a1006" />
      <ellipse cx="148" cy="101" rx="27" ry="6.4" fill={url('curry')} />
      <ellipse cx="140" cy="99.4" rx="9" ry="1.8" fill="#f0a060" opacity="0.4" />
      <ellipse cx="148" cy="100" rx="30" ry="8.4" fill="none" stroke="#d0986a" strokeWidth="1.6" />
      <rect x="-60" y="-60" width="335" height="266" fill={url('vignette')} />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* The dish from above, with its sides and lemons, on a dark wooden table. 215 × 146.          */

function drawOverhead({ id, url }: Ids) {
  const lemonHalf = (cx: number, cy: number, r: number) => (
    <g>
      <circle cx={cx} cy={cy} r={r} fill="#e3a816" />
      <circle cx={cx} cy={cy} r={r * 0.9} fill="#fcecb0" />
      <circle cx={cx} cy={cy} r={r * 0.8} fill={url('lemon')} />
      {Array.from({ length: 9 }, (_, i) => {
        const a = (i / 9) * Math.PI * 2;
        return (
          <path
            key={i}
            d={`M${cx} ${cy}L${round(cx + Math.cos(a) * r * 0.78)} ${round(cy + Math.sin(a) * r * 0.78)}`}
            stroke="#fdf3c8"
            strokeWidth={round(r * 0.06)}
          />
        );
      })}
      <circle cx={cx} cy={cy} r={round(r * 0.12)} fill="#fdf3c8" />
    </g>
  );
  const pieces = [
    'M70 62c-2-6 3-12 10-13 4 0 6 2 10 1 5 0 9 4 8 9-1 4-4 5-5 8-2 5-9 7-15 5-5-1-7-5-8-10z',
    'M98 80c0-6 5-10 11-10 3 0 5-2 9-1 5 1 8 6 6 11-1 3-4 4-4 7-1 5-8 7-14 5-5-2-8-6-8-12z',
    'M70 92c1-5 6-8 11-7 4 1 6 4 9 6 3 3 2 8-2 10-4 3-11 3-15 0-3-2-4-6-3-9z',
  ];
  return (
    <>
      <defs>
        <linearGradient id={id('table')} x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stopColor="#24180f" />
          <stop offset="1" stopColor="#0c0806" />
        </linearGradient>
        <radialGradient id={id('copper')} cx="0.38" cy="0.32" r="0.75">
          <stop offset="0.7" stopColor="#c47a42" />
          <stop offset="0.85" stopColor="#7a3a16" />
          <stop offset="1" stopColor="#3a1606" />
        </radialGradient>
        <clipPath id={id('pot')}>
          <circle cx="92" cy="76" r="45" />
        </clipPath>
        <radialGradient id={id('heap')} cx="0.45" cy="0.42" r="0.6">
          <stop offset="0" stopColor="#000" stopOpacity="0" />
          <stop offset="1" stopColor="#5a1c04" stopOpacity="0.45" />
        </radialGradient>
        <radialGradient id={id('lemon')} cx="0.4" cy="0.4" r="0.7">
          <stop offset="0" stopColor="#fbe27a" />
          <stop offset="1" stopColor="#f1c43a" />
        </radialGradient>
        <radialGradient id={id('curry')} cx="0.42" cy="0.4" r="0.65">
          <stop offset="0" stopColor="#d8743a" />
          <stop offset="0.6" stopColor="#9a3410" />
          <stop offset="1" stopColor="#4e1404" />
        </radialGradient>
        <radialGradient id={id('yogurt')} cx="0.4" cy="0.35" r="0.7">
          <stop offset="0" stopColor="#fbf7ef" />
          <stop offset="1" stopColor="#e0d6c4" />
        </radialGradient>
        <linearGradient id={id('meat')} x1="0" x2="0.3" y1="0" y2="1">
          <stop offset="0" stopColor="#e2843e" />
          <stop offset="0.55" stopColor="#c85a22" />
          <stop offset="1" stopColor="#9a3a14" />
        </linearGradient>
        <radialGradient id={id('vignette')} cx="0.5" cy="0.5" r="0.72">
          <stop offset="0.55" stopColor="#000" stopOpacity="0" />
          <stop offset="1" stopColor="#000" stopOpacity="0.6" />
        </radialGradient>
      </defs>
      {/* A dark wooden table, seen from above. */}
      <rect width="215" height="146" fill={url('table')} />
      <path
        d="M0 24h215M0 52h215M0 82h215M0 110h215M0 138h215"
        stroke="#33241a"
        strokeWidth="0.8"
      />
      <path
        d="M20 30q30 3 60 0M120 60q30 3 60 0M40 120q40 3 80 0"
        stroke="#2c2016"
        strokeWidth="0.6"
        fill="none"
      />
      {/* The pot, full of rice and chicken. */}
      <circle cx="96" cy="82" r="54" fill="#000" opacity="0.5" />
      <circle cx="92" cy="76" r="52" fill={url('copper')} />
      <circle cx="92" cy="76" r="47" fill="#3a1a0a" />
      <circle cx="92" cy="76" r="45" fill="#e0913a" />
      <g clipPath={url('pot')}>
        <Rice seed={37} count={1100} box={[47, 31, 90, 90]} even />
        <circle cx="92" cy="76" r="45" fill={url('heap')} />
      </g>
      {pieces.map((d, i) => (
        <path key={i} d={d} fill={url('meat')} />
      ))}
      <Leaves
        seed={39}
        cx={84}
        cy={70}
        rx={20}
        ry={18}
        count={50}
        size={[0.35, 0.8]}
        colors={SPICE}
      />
      <Leaves
        seed={41}
        cx={92}
        cy={76}
        rx={34}
        ry={30}
        count={46}
        size={[0.7, 1.4]}
        colors={['#2a6a22', '#3f8a2e', '#5aa63e']}
      />
      <path
        d="M44 52c-8-4-14-4-20-1"
        stroke="#b07a48"
        strokeWidth="3"
        strokeLinecap="round"
        fill="none"
      />
      <path
        d="M140 100c8 4 14 4 20 1"
        stroke="#b07a48"
        strokeWidth="3"
        strokeLinecap="round"
        fill="none"
      />
      {/* Curry, yogurt sauce, pickled onions in small bowls; lemons; a spoon. */}
      <circle cx="183" cy="38" r="22" fill="#000" opacity="0.4" />
      <circle cx="180" cy="35" r="21" fill={url('copper')} />
      <circle cx="180" cy="35" r="17" fill={url('curry')} />
      <ellipse cx="174" cy="30" rx="6" ry="2.6" fill="#f0a060" opacity="0.4" />
      <circle cx="182" cy="112" r="20" fill="#000" opacity="0.4" />
      <circle cx="180" cy="110" r="19" fill="#ece6da" />
      <circle cx="180" cy="110" r="15.5" fill={url('yogurt')} />
      <path
        d="M171 109c3-6 13-7 17-2 4 4-1 10-6 8-4-1-3-6 1-6"
        fill="none"
        stroke="#c9a238"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <g fill="#c4401b">
        <circle cx="174" cy="104" r="0.9" />
        <circle cx="187" cy="116" r="0.9" />
      </g>
      {lemonHalf(24, 28, 15)}
      {lemonHalf(30, 118, 13)}
      <ellipse cx="18" cy="76" rx="10" ry="6.6" fill="#efba26" />
      <ellipse cx="15" cy="74" rx="3.4" ry="1.6" fill="#fbe58c" opacity="0.8" />
      <path d="M150 140l26-22" stroke="#9a9a9a" strokeWidth="2.4" strokeLinecap="round" />
      <rect width="215" height="146" fill={url('vignette')} />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* A sunlit living room: a caramel sofa, two armchairs, a window over the city. 223 × 201.     */

function Armchair({ x }: { x: number }) {
  return (
    <g>
      <rect x={x + 3} y="107" width="26" height="19" rx="4" fill="#d6b588" />
      <rect x={x + 5} y="109" width="22" height="13" rx="3" fill="#e2c69c" />
      <path
        d={`M${x + 16} 109.5l7 6.5-7 6.5-7-6.5z`}
        fill="#bdb6aa"
        stroke="#a59e92"
        strokeWidth="0.8"
        strokeLinejoin="round"
      />
      <rect x={x} y="117" width="7" height="27" rx="3" fill="#caa170" />
      <rect x={x + 25} y="117" width="7" height="27" rx="3" fill="#c09566" />
      <rect x={x + 6} y="125" width="20" height="9" rx="2.5" fill="#e6cba2" />
      <rect x={x + 2} y="133" width="28" height="10" rx="2.5" fill="#bf9763" />
      <rect x={x + 3} y="143" width="2.2" height="3.4" rx="0.6" fill="#2e2a26" />
      <rect x={x + 27} y="143" width="2.2" height="3.4" rx="0.6" fill="#2e2a26" />
    </g>
  );
}

function drawLiving({ id, url }: Ids) {
  return (
    <>
      <defs>
        <linearGradient id={id('ceiling')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#cfd2d3" />
          <stop offset="1" stopColor="#e3e4e2" />
        </linearGradient>
        <linearGradient id={id('sky')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#d7e6ea" />
          <stop offset="1" stopColor="#a9c6d3" />
        </linearGradient>
        <linearGradient id={id('curtain')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#c9b48c" />
          <stop offset="0.5" stopColor="#e4d6ba" />
          <stop offset="1" stopColor="#c6af86" />
        </linearGradient>
        <pattern id={id('stripes')} width="7" height="10" patternUnits="userSpaceOnUse">
          <rect width="7" height="10" fill="#ebebea" />
          <rect width="3" height="10" fill="#d4d4d2" />
        </pattern>
        <linearGradient id={id('wallShade')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#fff" stopOpacity="0.3" />
          <stop offset="1" stopColor="#000" stopOpacity="0.14" />
        </linearGradient>
        <linearGradient id={id('sofa')} x1="0" x2="0.6" y1="0" y2="1">
          <stop offset="0" stopColor="#e9b47a" />
          <stop offset="1" stopColor="#c7894c" />
        </linearGradient>
        <linearGradient id={id('sofaBack')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#c48448" />
          <stop offset="1" stopColor="#dca266" />
        </linearGradient>
        <linearGradient id={id('table')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#26304a" />
          <stop offset="1" stopColor="#10141f" />
        </linearGradient>
        <linearGradient id={id('rug')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#d6c7ba" />
          <stop offset="1" stopColor="#cdb9aa" />
        </linearGradient>
        <radialGradient id={id('glow')} cx="0.45" cy="0.42" r="0.55">
          <stop offset="0" stopColor="#fff" stopOpacity="0.4" />
          <stop offset="1" stopColor="#fff" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={id('lamp')} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#fffaf0" stopOpacity="0.9" />
          <stop offset="1" stopColor="#fffaf0" stopOpacity="0" />
        </radialGradient>
      </defs>
      {/* Ceiling, back wall, side walls. */}
      <rect width="223" height="201" fill={url('ceiling')} />
      <path d="M0 46l28 9v68L0 134z" fill="#dee0de" />
      <path d="M28 55l129-4v69l-129 3z" fill="#e6e0d6" />
      <path d="M157 51 223 1v170l-66-51z" fill={url('stripes')} />
      <path d="M157 51 223 1v170l-66-51z" fill={url('wallShade')} />
      <path d="M28 55l129-4M157 51 223 1M28 55 0 46" stroke="#c8cac9" strokeWidth="0.6" />
      <g fill="#fdfdfd">
        <ellipse cx="50" cy="42" rx="2.2" ry="0.9" />
        <ellipse cx="96" cy="46" rx="2.2" ry="0.9" />
        <ellipse cx="132" cy="43" rx="2.2" ry="0.9" />
        <ellipse cx="30" cy="30" rx="2.6" ry="1" />
      </g>
      <circle cx="105" cy="24" r="1.5" fill="#b9bcbd" />
      {/* The window, the city, the curtains. */}
      <rect x="57" y="66" width="80" height="38" fill={url('sky')} />
      <g fill="#6f97b4" opacity="0.8">
        <rect x="104" y="69" width="9" height="35" />
        <rect x="115" y="73" width="7" height="31" />
        <rect x="124" y="78" width="8" height="26" />
      </g>
      <g fill="#9fbccb" opacity="0.8">
        <rect x="64" y="86" width="10" height="18" />
        <rect x="78" y="82" width="8" height="22" />
      </g>
      <path d="M57 87h80M83 66v38M109 66v38" stroke="#f4f4f2" strokeWidth="1" />
      <rect x="57" y="102" width="80" height="18" fill="#d9d2c6" />
      <path d="M27 57h31v65H27z" fill={url('curtain')} />
      <path d="M134 56h24v64h-24z" fill={url('curtain')} />
      <path
        d="M32 58v63M38 58v63M44 58v63M51 58v63M139 57v62M145 57v62M151 57v62"
        stroke="#ae9670"
        strokeWidth="0.6"
        opacity="0.6"
      />
      <ellipse cx="100" cy="84" rx="40" ry="26" fill={url('lamp')} opacity="0.35" />
      {/* A dining nook under the window: a table and a plant. */}
      <rect x="86" y="104" width="22" height="3" fill="#8a6e52" />
      <circle cx="97" cy="99" r="3.6" fill="#6d8c4a" />
      <rect x="95" y="101" width="4" height="4" fill="#c0a072" />
      {/* The left wall's round mirror and butterflies; the right wall's mirror and television. */}
      <ellipse cx="2" cy="72" rx="8" ry="14" fill="#ecebe6" stroke="#c4a66c" strokeWidth="1.5" />
      <g fill="#9aa0a0">
        <path d="M11 61l2 1-2 1zM17 66l2 1-2 1zM13 74l2 1-2 1zM19 58l2 1-2 1z" />
      </g>
      <ellipse cx="165" cy="78" rx="5" ry="17" fill="#d9d4c8" stroke="#a58a4e" strokeWidth="1.8" />
      <path d="M192 80l31-8v68l-31-9z" fill="#1b1f25" />
      <path d="M194 82l27-7v9l-27 7z" fill="#3d4550" opacity="0.6" />
      {/* Floor, and a rug with roses and leaves. */}
      <path d="M0 134l28-11 129-3 66 51v30H0z" fill="#cbc6bf" />
      <path d="M38 150l122-10 63 44v17H14z" fill={url('rug')} />
      {/* Leaves first, the roses over them. */}
      <g fill="#3f4d40" opacity="0.9">
        <path d="M150 166c8-5 18-6 27-2-8 5-18 6-27 2z" />
        <path d="M176 172c6 3 10 8 12 14-6-3-10-8-12-14z" />
        <path d="M150 196c9-7 21-9 32-5-10 6-22 8-32 5z" />
        <path d="M44 178c6-5 14-7 21-5-6 5-14 7-21 5z" />
        <path d="M94 168c4-4 10-5 15-3-5 4-10 5-15 3z" />
        <path d="M168 154c5 1 10 4 12 8-5-1-10-4-12-8z" />
      </g>
      <g fill="#c98a86">
        <ellipse cx="84" cy="160" rx="10" ry="4.2" />
        <ellipse cx="152" cy="157" rx="9" ry="3.6" />
        <ellipse cx="168" cy="184" rx="14" ry="5.6" />
        <ellipse cx="60" cy="191" rx="12" ry="4.6" />
        <ellipse cx="128" cy="196" rx="9" ry="3.4" />
        <ellipse cx="200" cy="196" rx="10" ry="3.6" />
      </g>
      <g fill="#a9605e" opacity="0.85">
        <ellipse cx="85" cy="160.4" rx="5" ry="2" />
        <ellipse cx="153" cy="157.4" rx="4.4" ry="1.7" />
        <ellipse cx="169" cy="184.6" rx="7" ry="2.6" />
        <ellipse cx="61" cy="191.4" rx="6" ry="2.2" />
        <ellipse cx="200" cy="196.4" rx="5" ry="1.7" />
      </g>
      <g fill="#ecc9c2" opacity="0.75">
        <ellipse cx="81" cy="158.6" rx="3.4" ry="1.2" />
        <ellipse cx="164" cy="182.4" rx="5" ry="1.6" />
        <ellipse cx="57" cy="189.6" rx="4" ry="1.3" />
      </g>
      {/* TV console along the right wall, and what stands on it. */}
      <path d="M168 133l55 12v6l-55-12z" fill="#3b3f47" />
      <path d="M168 139l55 12v40l-55-39z" fill="#14161a" />
      <path d="M170 141l51 11" stroke="#30343b" strokeWidth="0.6" />
      <rect x="181" y="121" width="4.4" height="14" rx="1.4" fill="#e9e6e0" />
      <rect x="189" y="126" width="3" height="10" rx="1" fill="#c9c4b8" />
      <circle cx="207" cy="139" r="4.5" fill="#56764a" />
      <circle cx="210" cy="136" r="3" fill="#6d8f5c" />
      {/* Armchairs facing the room. */}
      <Armchair x={85} />
      <Armchair x={128} />
      {/* Side table and lamp beside the sofa. */}
      <rect x="53" y="117" width="22" height="14" rx="1" fill="#eeeae4" />
      <rect x="62" y="106" width="5" height="12" rx="2" fill="#f2efea" />
      <path d="M55 93h18l2.4 13H52.6z" fill="#f7f4ef" />
      <ellipse cx="64" cy="104" rx="16" ry="13" fill={url('lamp')} />
      {/* The sofa: its back along the left wall, its seat, the arm nearest us, its cushions. */}
      <path d="M0 120l55-7 3 4v23L0 152z" fill="#c78b50" />
      <path d="M0 120l55-7" stroke="#e6b47e" strokeWidth="1.2" />
      <path d="M0 152l58-12 18 6 2 6-58 20-20 6z" fill="#e3ab6d" />
      <path d="M26 145l14 15M48 141l12 12" stroke="#c99052" strokeWidth="0.6" opacity="0.7" />
      <path d="M0 178l20-6 58-20v10l-54 24-24 7z" fill="#c38750" />
      <path d="M22 186l56-24 6 3v36H30z" fill={url('sofa')} />
      <path d="M22 186l56-24 6 3-56 24z" fill="#f2c897" opacity="0.75" />
      <path d="M6 131c7-6 21-8 27-3 4 6 3 20-3 26-6 4-20 4-24-2-3-6-3-15 0-21z" fill="#141414" />
      <g fill="#d9cf4a">
        <circle cx="15" cy="139" r="2" />
        <circle cx="19" cy="138" r="2" />
        <circle cx="20" cy="142" r="2" />
        <circle cx="16" cy="143" r="2" />
        <circle cx="25" cy="149" r="1.6" />
        <circle cx="28" cy="147" r="1.6" />
        <circle cx="28" cy="151" r="1.6" />
        <circle cx="11" cy="150" r="1.3" />
      </g>
      <g fill="#6a7a2a">
        <circle cx="17.6" cy="140.6" r="1" />
        <circle cx="26.6" cy="149.2" r="0.8" />
      </g>
      <path d="M29 121c6-5 18-5 22 2 3 7-3 15-11 15-8 0-14-10-11-17z" fill="#b0806a" />
      <path d="M31 125l18 4M30 130l19 3" stroke="#946450" strokeWidth="0.8" opacity="0.7" />
      <path d="M38 116c5-3 12-2 14 3 2 5-2 9-7 9s-9-6-7-12z" fill="#c69676" />
      {/* Coffee table: a dark glass top, a dark box under it. */}
      <path d="M80 146h55l4 28H76z" fill="#232c42" />
      <path d="M86 149h42l2 9H85z" fill="#3c4a6a" opacity="0.55" />
      <path d="M76 174h63v27H76z" fill={url('table')} />
      <path d="M76 174h63" stroke="#56647f" strokeWidth="0.7" />
      <rect x="106" y="143" width="7" height="11" rx="1.6" fill="#d9b04c" />
      <rect x="107" y="144" width="2" height="9" rx="0.8" fill="#f2d27a" opacity="0.7" />
      <rect x="96" y="161" width="19" height="3.4" rx="1" fill="#59636e" />
      <rect x="98" y="159.6" width="9" height="1.6" rx="0.6" fill="#1c1c1e" />
      <rect width="223" height="201" fill={url('glow')} />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* The same flat, its bedroom: linen, a lamp, a sheer curtain in the sun. 223 × 201.           */

function drawBedroom({ id, url }: Ids) {
  return (
    <>
      <defs>
        <linearGradient id={id('wall')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#e0d9ce" />
          <stop offset="1" stopColor="#f1ede6" />
        </linearGradient>
        <linearGradient id={id('sheer')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#f7f3ec" />
          <stop offset="0.5" stopColor="#fffdf8" />
          <stop offset="1" stopColor="#ece5d9" />
        </linearGradient>
        <linearGradient id={id('duvet')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#f8f6f2" />
          <stop offset="1" stopColor="#dfd9cf" />
        </linearGradient>
        <radialGradient id={id('sun')} cx="0.85" cy="0.35" r="0.6">
          <stop offset="0" stopColor="#fff8e8" stopOpacity="0.45" />
          <stop offset="1" stopColor="#fff8e8" stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect width="223" height="201" fill={url('wall')} />
      <rect width="223" height="14" fill="#ececea" />
      <path d="M0 14h223" stroke="#d6d2ca" strokeWidth="0.8" />
      {/* The window behind a sheer curtain. */}
      <rect x="152" y="22" width="71" height="128" fill={url('sheer')} />
      <path
        d="M158 22v128M165 22v128M173 22v128M181 22v128M190 22v128M199 22v128M208 22v128M216 22v128"
        stroke="#e2dacb"
        strokeWidth="1.2"
      />
      {/* Panels on the wall, light falling from the window, a plant by it. */}
      <g fill="none" stroke="#d6cdbf" strokeWidth="0.7">
        <rect x="10" y="24" width="52" height="44" />
        <rect x="130" y="24" width="16" height="44" />
      </g>
      <path d="M152 30l-70 171h60l81-171z" fill="#fffaf0" opacity="0.12" />
      <path d="M196 150l3 22h16l3-22z" fill="#d9cfc0" />
      <Leaves
        seed={113}
        cx={206}
        cy={136}
        rx={14}
        ry={18}
        count={70}
        size={[1.2, 2.8]}
        colors={['#3d5a32', '#4f7040', '#668a52', '#7fa266']}
      />
      {/* A framed print. */}
      <rect
        x="72"
        y="30"
        width="48"
        height="30"
        fill="#f4f0e8"
        stroke="#c9b48e"
        strokeWidth="1.6"
      />
      <circle cx="88" cy="46" r="7" fill="#d8b48a" />
      <path d="M96 54c4-10 10-14 18-12" stroke="#7d8a72" strokeWidth="2" fill="none" />
      {/* Nightstand and lamp. */}
      <rect x="6" y="112" width="30" height="26" rx="2" fill="#d6c3a4" />
      <path d="M6 122h30" stroke="#bfa985" strokeWidth="0.8" />
      <rect x="25" y="108" width="9" height="4" rx="0.6" fill="#8a6e5a" />
      <rect x="26" y="105.6" width="7" height="2.6" rx="0.6" fill="#c9b48e" />
      <rect x="18" y="98" width="5" height="15" rx="2" fill="#f2efea" />
      <path d="M10 80h21l3 18H7z" fill="#f1e8da" />
      <ellipse cx="21" cy="92" rx="22" ry="18" fill="#fff4dc" opacity="0.35" />
      {/* The bed. */}
      <rect x="42" y="72" width="128" height="44" rx="6" fill="#b49c7c" />
      <path
        d="M52 72v44M74 72v44M96 72v44M118 72v44M140 72v44M162 72v44"
        stroke="#ad9676"
        strokeWidth="0.7"
      />
      <rect x="54" y="96" width="44" height="22" rx="6" fill="#f6f3ee" />
      <rect x="104" y="96" width="44" height="22" rx="6" fill="#f6f3ee" />
      <rect x="70" y="104" width="24" height="16" rx="5" fill="#c9a676" />
      <rect x="110" y="104" width="24" height="16" rx="5" fill="#b07a5a" />
      <path d="M30 116h162l31 85H0z" fill={url('duvet')} />
      <path
        d="M36 128c40 6 110 6 160-2M22 150c60 8 130 8 186-2M60 120c-4 14-6 30-12 44M160 120c6 14 10 30 18 44"
        stroke="#d6d0c5"
        fill="none"
      />
      <path d="M12 168h199l6 18H6z" fill="#cdb38e" />
      <path d="M6 186h211l2 6H4z" fill="#b89c74" />
      <rect width="223" height="201" fill={url('sun')} />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* A white farmhouse in tall grass under a pale sky, smoke from its chimney, birds. 300 × 289. */
/* The house card shows a wide crop of it, the listing card a tall one.                        */

const FARM_BIRDS: readonly (readonly [number, number, number])[] = [
  [154, 47, 0.9],
  [167, 50, 1],
  [178, 47, 0.8],
  [184, 50, 1],
  [157, 57, 0.8],
  [163, 60, 0.9],
  [171, 66, 0.8],
  [190, 69, 0.9],
  [199, 58, 0.8],
  [211, 58, 1],
  [216, 55, 0.8],
  [225, 45, 0.9],
  [224, 55, 0.8],
  [202, 66, 0.7],
  [182, 61, 0.7],
];

const GRASS = ['#1a2414', '#222e19', '#2c391f', '#384627', '#465530', '#56663b', '#6a7a49'];

function drawFarm({ id, url }: Ids) {
  return (
    <>
      <defs>
        <linearGradient id={id('sky')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#7f949b" />
          <stop offset="0.16" stopColor="#8ea2a8" />
          <stop offset="0.34" stopColor="#abbbbb" />
          <stop offset="0.46" stopColor="#ccd1cc" />
          <stop offset="0.56" stopColor="#d6d5cb" />
        </linearGradient>
        <radialGradient id={id('puff')} cx="0.45" cy="0.4" r="0.55">
          <stop offset="0" stopColor="#f8f6ee" stopOpacity="0.95" />
          <stop offset="0.45" stopColor="#f1eee4" stopOpacity="0.8" />
          <stop offset="0.75" stopColor="#e6e3d8" stopOpacity="0.4" />
          <stop offset="1" stopColor="#e0ddd2" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={id('belly')} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#b9bcb5" stopOpacity="0.55" />
          <stop offset="1" stopColor="#c4c6bf" stopOpacity="0" />
        </radialGradient>
        <radialGradient id={id('haze')} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#efeee6" stopOpacity="0.95" />
          <stop offset="1" stopColor="#efeee6" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={id('wall')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#e3e1d8" />
          <stop offset="1" stopColor="#d2d1c7" />
        </linearGradient>
        <linearGradient id={id('roof')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#2a3f39" />
          <stop offset="1" stopColor="#14241f" />
        </linearGradient>
        <linearGradient id={id('ground')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#3c4a2b" />
          <stop offset="0.3" stopColor="#1b2616" />
          <stop offset="0.62" stopColor="#0f1a10" />
          <stop offset="1" stopColor="#0c140d" />
        </linearGradient>
        <pattern id={id('siding')} width="2.2" height="10" patternUnits="userSpaceOnUse">
          <rect width="2.2" height="10" fill="#e4e2da" />
          <rect width="0.35" height="10" fill="#c6c4bb" />
        </pattern>
        <pattern id={id('clap')} width="10" height="2.4" patternUnits="userSpaceOnUse">
          <rect width="10" height="2.4" fill="#e6e4dc" />
          <rect y="2.1" width="10" height="0.3" fill="#cdcbc2" />
        </pattern>
        <pattern id={id('metal')} width="2.4" height="10" patternUnits="userSpaceOnUse">
          <rect width="2.4" height="10" fill="#767c80" />
          <rect width="0.5" height="10" fill="#959ba0" />
        </pattern>
      </defs>
      <rect width="300" height="289" fill={url('sky')} />
      {/* Clouds, and the haze low on the horizon. */}
      <Cloud
        fill={url('puff')}
        belly={url('belly')}
        puffs={[
          [10, 86, 22],
          [30, 80, 24],
          [52, 74, 26],
          [76, 68, 28],
          [100, 66, 28],
          [124, 70, 26],
          [146, 77, 22],
          [164, 84, 16],
          [178, 89, 11],
          [40, 64, 20],
          [64, 54, 22],
          [88, 50, 22],
          [110, 52, 20],
          [132, 60, 17],
          [60, 46, 12],
          [78, 42, 14],
          [98, 40, 14],
          [20, 94, 18],
          [60, 90, 22],
          [100, 88, 24],
          [140, 90, 18],
        ]}
      />
      <Cloud
        fill={url('puff')}
        belly={url('belly')}
        puffs={[
          [220, 80, 10],
          [232, 76, 12],
          [246, 75, 12],
          [258, 80, 10],
          [240, 84, 10],
        ]}
      />
      <g fill={url('haze')}>
        <ellipse cx="34" cy="142" rx="34" ry="24" />
        <ellipse cx="52" cy="128" rx="22" ry="16" />
        <ellipse cx="262" cy="138" rx="38" ry="24" />
        <ellipse cx="244" cy="126" rx="20" ry="14" />
        <ellipse cx="280" cy="150" rx="30" ry="20" />
      </g>
      {/* Smoke rising from the chimney into the cloud. */}
      <g fill={url('puff')}>
        <circle cx="185.6" cy="95" r="4.6" />
        <circle cx="182.6" cy="90" r="6" />
        <circle cx="178" cy="85" r="7.6" />
        <circle cx="171" cy="80.6" r="9" />
      </g>
      <g>
        {FARM_BIRDS.map(([x, y, s], i) => (
          <Bird key={i} x={x} y={y} s={s} tilt={i % 3 === 0 ? 0.4 : -0.2} />
        ))}
      </g>
      {/* The low annex, its porch and its stovepipe. */}
      <rect x="58" y="108" width="2.4" height="15" fill="#3a3c3a" />
      <path d="M49 121l47 2 3 14H73z" fill={url('metal')} />
      <path d="M49 121l24 16v28H49z" fill={url('siding')} />
      <path d="M73 137h26v28H73z" fill="#454b45" />
      <path d="M74 137h24" stroke="#f0efe9" strokeWidth="1.4" />
      <path d="M78 138v27M90 138v27" stroke="#e6e5df" strokeWidth="1.2" />
      <rect x="81" y="142" width="7" height="17" fill="#5a625c" />
      {/* The gable end. */}
      <path d="M98 123l17-25 17 28v40H98z" fill={url('clap')} />
      <path d="M98 123l17-25 17 28" fill="none" stroke="#f2f1ec" strokeWidth="1.2" />
      <rect
        x="110"
        y="110"
        width="7"
        height="25"
        fill="#cacbc4"
        stroke="#9a9c95"
        strokeWidth="0.8"
      />
      <rect
        x="100.5"
        y="146"
        width="5"
        height="12"
        fill="#d2d2ca"
        stroke="#9c9e97"
        strokeWidth="0.6"
      />
      <rect
        x="123"
        y="144"
        width="5"
        height="14"
        fill="#d2d2ca"
        stroke="#9c9e97"
        strokeWidth="0.6"
      />
      {/* The roof, the chimney, the long wall. */}
      <path d="M115 98.6l90 1.4 27 26.3H132z" fill={url('roof')} />
      <path d="M132 126.3h100" stroke="#e7e6e0" strokeWidth="0.9" />
      <rect x="180" y="99" width="11.5" height="9.5" fill="#7c766c" />
      <rect x="179" y="97.6" width="13.5" height="2" fill="#5a554e" />
      <path d="M132 126.3h100v38l-100 1.4z" fill={url('clap')} />
      <path d="M132 126.3h100v38l-100 1.4z" fill={url('wall')} opacity="0.55" />
      <path d="M132 126v40" stroke="#a5a69f" strokeWidth="0.8" />
      <path d="M230.7 126v38" stroke="#5c605c" strokeWidth="1" />
      <g fill="#26312d" stroke="#3b4843" strokeWidth="0.9">
        <rect x="148" y="138.6" width="10" height="18.5" />
        <rect x="174.4" y="138.6" width="7" height="24.8" />
        <rect x="189.3" y="138.6" width="9.4" height="18.5" />
        <rect x="210.7" y="139.1" width="8.6" height="18" />
      </g>
      <g stroke="#56645e" strokeWidth="0.5">
        <path d="M153 139v18M148 147.8h10M194 139v18M189.3 147.8h9.4M215 139v18M210.7 148h8.6" />
      </g>
      <path d="M147 157.6h12M188.6 157.6h10.8M210 157.6h10" stroke="#f0efe9" strokeWidth="1" />
      {/* Tall grass in front of the house, darkening to the ground. */}
      <rect y="160" width="300" height="129" fill={url('ground')} />
      <Leaves
        seed={3}
        cx={150}
        cy={178}
        rx={165}
        ry={17}
        count={700}
        size={[1, 2.4]}
        colors={GRASS}
        light={[0, -1]}
      />
      <Blades
        seed={5}
        x0={-10}
        x1={310}
        top={158}
        depth={24}
        count={900}
        height={[1.6, 4.4]}
        colors={GRASS.slice(2, 6)}
        width={0.6}
      />
      <Leaves
        seed={9}
        cx={150}
        cy={162}
        rx={165}
        ry={4}
        count={260}
        size={[0.8, 1.8]}
        colors={GRASS.slice(3)}
        light={[0, -1]}
      />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* Inside the farmhouse: a kitchen table by a window onto the field. 216 × 153.                */

function drawKitchen({ id, url }: Ids) {
  return (
    <>
      <defs>
        <linearGradient id={id('wall')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#e2e0da" />
          <stop offset="1" stopColor="#efeee9" />
        </linearGradient>
        <linearGradient id={id('view')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#9fb1b6" />
          <stop offset="0.55" stopColor="#d6d6cc" />
          <stop offset="0.56" stopColor="#56713f" />
          <stop offset="1" stopColor="#34492a" />
        </linearGradient>
        <linearGradient id={id('floor')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#ad8c6a" />
          <stop offset="1" stopColor="#7b5e43" />
        </linearGradient>
        <radialGradient id={id('light')} cx="0.72" cy="0.35" r="0.6">
          <stop offset="0" stopColor="#fffdf6" stopOpacity="0.55" />
          <stop offset="1" stopColor="#fffdf6" stopOpacity="0" />
        </radialGradient>
      </defs>
      <rect width="216" height="153" fill={url('wall')} />
      <rect y="112" width="216" height="41" fill={url('floor')} />
      <path d="M0 122h216M0 134h216M0 146h216" stroke="#8a6c4e" strokeWidth="0.6" />
      {/* The window onto the field. */}
      <rect x="116" y="20" width="82" height="70" fill="#22312c" />
      <rect x="119.5" y="23.5" width="75" height="63" fill={url('view')} />
      <g fill="#eeece4" opacity="0.85">
        <ellipse cx="136" cy="36" rx="9" ry="3.6" />
        <ellipse cx="144" cy="33" rx="7" ry="3.4" />
        <ellipse cx="178" cy="42" rx="8" ry="3" />
      </g>
      <Bird x={170} y={30} s={0.7} />
      <Bird x={177} y={27} s={0.6} />
      <Bird x={183} y={32} s={0.6} />
      <path d="M157 23v63M119 54.5h76" stroke="#22312c" strokeWidth="2.4" />
      <Leaves
        seed={31}
        cx={157}
        cy={58}
        rx={36}
        ry={3}
        count={70}
        size={[1, 2.4]}
        colors={['#2a3e22', '#3a5530', '#4c6a3a']}
      />
      <rect x="112" y="89" width="90" height="4" fill="#e7e6e0" />
      <g fill="#b9774a">
        <path d="M126 89l1.4-7h7.2l1.4 7z" />
        <path d="M184 89l1.4-7h7.2l1.4 7z" />
      </g>
      <Leaves
        seed={117}
        cx={131}
        cy={78}
        rx={6}
        ry={5}
        count={30}
        size={[0.8, 1.6]}
        colors={['#3f6a2e', '#55853c', '#6e9e4e']}
      />
      <Leaves
        seed={119}
        cx={189}
        cy={78}
        rx={6}
        ry={5}
        count={30}
        size={[0.8, 1.6]}
        colors={['#3f6a2e', '#55853c', '#6e9e4e']}
      />
      {/* Shelf of dishes. */}
      <rect x="14" y="38" width="62" height="3" fill="#a07a54" />
      <g fill="#f6f5f1" stroke="#cfcdc6" strokeWidth="0.6">
        <circle cx="24" cy="31" r="6.5" />
        <circle cx="38" cy="31" r="6.5" />
        <rect x="50" y="27" width="8" height="11" rx="1" />
        <rect x="62" y="30" width="7" height="8" rx="1" />
      </g>
      {/* A pendant lamp over the table. */}
      <path d="M68 0v38" stroke="#22312c" strokeWidth="0.8" />
      <path d="M57 47c0-7 5-10 11-10s11 3 11 10z" fill="#22312c" />
      <ellipse cx="68" cy="48" rx="8" ry="2" fill="#fff4d6" />
      {/* A rug, the table, two chairs, a jug of wildflowers. */}
      <path d="M10 136h140l12 17H0z" fill="#c9b8a0" />
      <path d="M14 140h132M8 148h146" stroke="#a8927a" strokeWidth="0.8" />
      <path d="M20 96h104l4 6H16z" fill="#a67c52" />
      <path d="M30 99h80" stroke="#c49a6c" strokeWidth="0.6" />
      <rect x="16" y="102" width="112" height="4" fill="#8a643e" />
      <rect x="22" y="106" width="4" height="34" fill="#7d5a38" />
      <rect x="118" y="106" width="4" height="34" fill="#7d5a38" />
      <g fill="#22312c">
        <rect x="2" y="78" width="3" height="62" rx="1" />
        <rect x="2" y="104" width="20" height="3" />
        <rect x="19" y="106" width="3" height="34" />
        <rect x="138" y="80" width="3" height="60" rx="1" />
        <rect x="124" y="104" width="17" height="3" />
      </g>
      <path
        d="M60 96c-1-6 0-10 3-11h6c3 1 4 5 3 11z"
        fill="#f4f2ec"
        stroke="#cfccc4"
        strokeWidth="0.6"
      />
      <path d="M64 85l-6-14M66 85l1-17M68 85l7-13M65 85l-11-6" stroke="#6c8a4a" strokeWidth="0.8" />
      <g fill="#f2e09a">
        <circle cx="58" cy="70" r="2.4" />
        <circle cx="75" cy="71" r="2.4" />
      </g>
      <g fill="#ecb2a2">
        <circle cx="67" cy="67" r="2.6" />
        <circle cx="54" cy="78" r="2.2" />
      </g>
      <rect width="216" height="153" fill={url('light')} />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* A white cottage with arched windows on a mown lawn, tall trees behind. 222 × 287.           */

const WOODS = ['#121510', '#191d16', '#20251c', '#282e23', '#32392b', '#3f4636', '#4f5744'];

function ArchedWindow({ cx }: { cx: number }) {
  const w = 13.4;
  const left = cx - w / 2;
  return (
    <g>
      <path d={`M${left} 136.6V107a${w / 2} ${w / 2} 0 0 1 ${w} 0v29.6z`} fill="#b8672f" />
      <path
        d={`M${left + 1.4} 135.6V107.4a${w / 2 - 1.4} ${w / 2 - 1.4} 0 0 1 ${w - 2.8} 0v28.2z`}
        fill="#3a2a20"
      />
      <path
        d={`M${cx} 101v35M${left + 1.4} 108h${w - 2.8}M${left + 1.4} 115h${w - 2.8}M${left + 1.4} 122h${w - 2.8}M${left + 1.4} 129h${w - 2.8}`}
        stroke="#cf7c44"
        strokeWidth="0.9"
      />
      <path d={`M${left - 0.8} 136.6h${w + 1.6}`} stroke="#f2efe8" strokeWidth="1.2" />
    </g>
  );
}

function drawCottage({ id, url }: Ids) {
  return (
    <>
      <defs>
        <linearGradient id={id('lawn')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#6f7840" />
          <stop offset="0.25" stopColor="#7a8046" />
          <stop offset="1" stopColor="#6c733a" />
        </linearGradient>
        <linearGradient id={id('roof')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#53565b" />
          <stop offset="1" stopColor="#3d4045" />
        </linearGradient>
        <pattern id={id('siding')} width="10" height="3" patternUnits="userSpaceOnUse">
          <rect width="10" height="3" fill="#ebe8df" />
          <rect width="10" height="0.4" fill="#d6d2c7" />
        </pattern>
      </defs>
      <rect width="222" height="160" fill="#c9cbc6" />
      {/* The woods behind: a dark mass, crowns of leaves, palms on the right. */}
      <path d="M0 0h222v150H0z" fill={WOODS[1]} />
      {/* Pale sky through the crowns, at the top left. */}
      <path d="M2 0h74c-5 3-12 5-19 4-6 3-13 4-21 2-8 3-19 3-34-2z" fill="#c9cbc6" />
      <Leaves
        seed={41}
        cx={28}
        cy={60}
        rx={42}
        ry={70}
        count={460}
        size={[1.6, 4.2]}
        colors={WOODS}
      />
      <Leaves
        seed={43}
        cx={88}
        cy={30}
        rx={44}
        ry={30}
        count={300}
        size={[1.6, 4]}
        colors={WOODS}
      />
      <Leaves
        seed={47}
        cx={192}
        cy={60}
        rx={38}
        ry={66}
        count={420}
        size={[1.6, 4.2]}
        colors={WOODS}
      />
      <Leaves
        seed={53}
        cx={142}
        cy={50}
        rx={30}
        ry={32}
        count={180}
        size={[1.6, 3.8]}
        colors={WOODS}
      />
      <Leaves
        seed={57}
        cx={60}
        cy={6}
        rx={70}
        ry={10}
        count={160}
        size={[1.6, 3.6]}
        colors={WOODS}
      />
      <Leaves
        seed={58}
        cx={192}
        cy={8}
        rx={34}
        ry={12}
        count={100}
        size={[1.6, 3.6]}
        colors={WOODS}
      />
      <g stroke="#77736a" strokeWidth="1.1" fill="none">
        <path d="M181 30v108M197 44v94M213 24v114" />
      </g>
      <g stroke="#3a4232" strokeWidth="1.5" strokeLinecap="round" fill="none">
        <path d="M181 30c-6 0-11 3-14 7M181 30c6-1 11 1 14 5M181 30c-3-4-7-7-11-7M181 30c3-5 7-7 11-6M197 44c-5 0-9 3-12 6M197 44c5 0 10 3 12 7M213 24c-5 1-9 4-11 8M213 24c4 2 7 6 8 10" />
      </g>
      <Leaves
        seed={59}
        cx={110}
        cy={128}
        rx={118}
        ry={14}
        count={280}
        size={[1.4, 3.4]}
        colors={WOODS.slice(1)}
      />
      {/* The cottage: chimney, roof, front gable, white walls, three arched windows. */}
      <rect x="97" y="44" width="14" height="24" fill="#dcd9d3" />
      <rect x="97" y="44" width="4" height="24" fill="#c3bfb8" />
      <rect x="96" y="42.5" width="16" height="2.4" fill="#b9b5ae" />
      <path d="M51.4 68.6h110l10 24.3H47.1z" fill={url('roof')} />
      <path d="M93 92l24-36 24 36z" fill="#474a50" />
      <path d="M98.6 89l18.5-29.4 18.6 29.4z" fill="#e9e6df" stroke="#f6f4ef" strokeWidth="1.3" />
      <circle cx="117.1" cy="80.4" r="2.4" fill="#8f8b84" stroke="#f6f4ef" strokeWidth="0.6" />
      <rect x="47" y="92.9" width="126" height="3.8" fill="#f2f0ea" />
      <rect x="48" y="96.7" width="116" height="1.4" fill="#b5b2aa" />
      <rect x="48" y="97" width="116" height="40" fill={url('siding')} />
      <rect x="48" y="97" width="20" height="40" fill="#bdbab2" opacity="0.7" />
      <ArchedWindow cx={86} />
      <ArchedWindow cx={117.4} />
      <ArchedWindow cx={147.7} />
      <Leaves
        seed={61}
        cx={108}
        cy={139}
        rx={70}
        ry={3.4}
        count={150}
        size={[1.1, 2.4]}
        colors={['#161c13', '#1e261a', '#283322', '#34402b']}
      />
      <rect x="62" y="124" width="2" height="14" fill="#6b6a56" />
      <Leaves
        seed={67}
        cx={63}
        cy={122}
        rx={5}
        ry={7}
        count={24}
        size={[1, 2]}
        colors={['#3a4430', '#4b5640']}
      />
      {/* The goal on the right of the lawn. */}
      <g stroke="#ecebe6" strokeWidth="1.1" fill="none">
        <path d="M197.7 149v-32.4H226M197.7 116.6l6 6" />
      </g>
      <g stroke="#ecebe6" strokeWidth="0.35" opacity="0.5">
        <path d="M201 117v32M205 118v31M209 118v31M213 118v31M198 124h28M198 131h28M198 138h28" />
      </g>
      {/* The lawn, its mowing stripes. */}
      <rect y="146" width="222" height="141" fill={url('lawn')} />
      <g fill="#8a9058" opacity="0.32">
        <rect y="150" width="222" height="3" />
        <rect y="158" width="222" height="5" />
        <rect y="171" width="222" height="7" />
        <rect y="190" width="222" height="10" />
      </g>
      <rect y="146" width="222" height="2" fill="#3a4426" opacity="0.6" />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* Inside the cottage: tall arched windows onto the lawn, a cane chair. 222 × 287.             */

function drawSalon({ id, url }: Ids) {
  const pane = (left: number) => (
    <g>
      <path d={`M${left} 196V62a30 30 0 0 1 60 0v134z`} fill="#bf6d36" />
      <path d={`M${left + 4} 192V62a26 26 0 0 1 52 0v130z`} fill={url('outside')} />
      <path
        d={`M${left + 30} 36v156M${left + 4} 70h52M${left + 4} 100h52M${left + 4} 130h52M${left + 4} 160h52`}
        stroke="#d9874c"
        strokeWidth="2"
      />
    </g>
  );
  return (
    <>
      <defs>
        <linearGradient id={id('outside')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#cfd3cc" />
          <stop offset="0.2" stopColor="#3a5832" />
          <stop offset="0.55" stopColor="#2c4627" />
          <stop offset="0.62" stopColor="#6a8a3c" />
          <stop offset="1" stopColor="#5f7d35" />
        </linearGradient>
        <linearGradient id={id('floor')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#b7895c" />
          <stop offset="1" stopColor="#7a5534" />
        </linearGradient>
      </defs>
      <rect width="222" height="287" fill="#eeebe3" />
      {pane(30)}
      {pane(132)}
      <g fill="#e8e0cf">
        <path d="M14 22h16v176H10z" />
        <path d="M192 22h16l4 176h-20z" />
      </g>
      <path d="M17 24v172M24 24v172M197 24v172M204 24v172" stroke="#d2c7b2" strokeWidth="0.8" />
      <rect x="10" y="18" width="202" height="3" rx="1.5" fill="#9a7a56" />
      <path d="M105 92h12v10h-12z" fill="#c9a66a" />
      <ellipse cx="111" cy="90" rx="7" ry="3" fill="#f6e2b0" />
      <rect y="196" width="222" height="91" fill={url('floor')} />
      <path d="M0 210h222M0 228h222M0 250h222M0 276h222" stroke="#8e6440" strokeWidth="0.8" />
      <path d="M36 200h60l20 60H20zM138 200h60l12 60h-74z" fill="#fff6e0" opacity="0.16" />
      <path d="M44 214h136l26 40H18z" fill="#cdb48c" opacity="0.85" />
      <path d="M50 220h124M40 236h144" stroke="#b39a74" strokeWidth="0.8" />
      {/* A cane armchair. */}
      <path d="M72 168c0-18 12-26 28-26s28 8 28 26v26H72z" fill="#c69c62" />
      <path
        d="M78 160h44M76 170h48M76 180h48M100 144v48M88 147v46M112 147v46"
        stroke="#a87e48"
        strokeWidth="0.8"
      />
      <rect x="68" y="190" width="64" height="10" rx="3" fill="#e9e1d1" />
      <path d="M72 200l-4 22M128 200l4 22" stroke="#a87e48" strokeWidth="2.4" />
      {/* A potted palm. */}
      <path d="M180 196l4-22h22l4 22z" fill="#e6e1d6" />
      <g stroke="#3f6a34" strokeWidth="2.2" strokeLinecap="round" fill="none">
        <path d="M195 174c-6-14-16-22-26-24M195 174c2-18 10-28 18-32M195 174c-12-6-22-4-28 2M195 174c10-10 20-12 26-8M195 174c-2-16-6-28-4-40" />
      </g>
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* A stone guest house under ivy at dusk, lit windows, a path across the lawn. 222 × 288.      */

const HILLS_WINDOWS: readonly (readonly [number, number, number, number])[] = [
  [62, 33, 5.7, 15],
  [74.9, 33, 5.7, 15],
  [96.3, 32, 8.6, 17],
  [117.1, 33, 6.3, 15],
  [145.7, 33, 4.9, 15],
  [159.1, 33, 4.3, 14],
  [179.1, 32, 4.3, 15],
  [56.3, 64, 7.1, 30],
  [70.6, 66, 5.7, 28],
  [134.9, 74, 10, 25],
];

const IVY = ['#22260f', '#2c3214', '#3b4019', '#4a5220', '#5d651b'];
const SHADE_TREE = ['#060804', '#0c100a', '#14190e', '#1e2613', '#2a3418', '#3a461f', '#525d2a'];
const SUN_TREE = ['#1c2008', '#282d0c', '#363d10', '#4a5316', '#5f681d', '#78782c', '#958a3c'];

function drawStoneHouse({ id, url }: Ids) {
  return (
    <>
      <defs>
        <pattern id={id('stone')} width="11" height="7" patternUnits="userSpaceOnUse">
          <rect width="11" height="7" fill="#433c36" />
          <rect x="0.3" y="0.3" width="4.4" height="2.4" rx="1" fill="#6b625a" />
          <rect x="5" y="0.5" width="2.8" height="2.2" rx="0.9" fill="#5a524b" />
          <rect x="8.1" y="0.2" width="2.6" height="2.6" rx="1" fill="#766c64" />
          <rect x="1.6" y="3.4" width="3.2" height="2.6" rx="1" fill="#736961" />
          <rect x="5.2" y="3.2" width="3.8" height="2.4" rx="1" fill="#625a53" />
          <rect x="9.3" y="3.6" width="1.6" height="2.2" rx="0.7" fill="#524a44" />
        </pattern>
        <radialGradient id={id('lamp')} cx="0.5" cy="0.45" r="0.6">
          <stop offset="0" stopColor="#f2c486" />
          <stop offset="0.6" stopColor="#c8884a" />
          <stop offset="1" stopColor="#7a4a20" />
        </radialGradient>
        <linearGradient id={id('panels')} x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stopColor="#8a8e9e" />
          <stop offset="0.5" stopColor="#5a6074" />
          <stop offset="1" stopColor="#343a4c" />
        </linearGradient>
        <linearGradient id={id('lawn')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#585c18" />
          <stop offset="0.3" stopColor="#444818" />
          <stop offset="0.65" stopColor="#262818" />
          <stop offset="1" stopColor="#1a1a12" />
        </linearGradient>
        <linearGradient id={id('path')} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="#36352f" />
          <stop offset="1" stopColor="#6a6250" />
        </linearGradient>
        <linearGradient id={id('dim')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#14110c" stopOpacity="0" />
          <stop offset="1" stopColor="#14110c" stopOpacity="0.4" />
        </linearGradient>
      </defs>
      <rect width="222" height="288" fill="#14180f" />
      <path d="M0 0h40v6H0zM196 0h26v4h-26z" fill="#d3d9dd" />
      {/* The house: stone walls, a slate roof with solar panels, a gable on the right. */}
      <path d="M44 20h142v82H44z" fill={url('stone')} />
      <path d="M44 20h142v82H44z" fill={url('dim')} />
      <path d="M46 24 62 0h58l-6 24z" fill="#3a3836" />
      <path d="M50 22 64 2h52l-4.6 20z" fill={url('panels')} />
      <path
        d="M57 12h56M60 7h55M74 2l-9 20M88 2l-6 20M102 2l-4 20"
        stroke="#a2a8b8"
        strokeWidth="0.45"
        opacity="0.55"
      />
      <path d="M112 26l46-26 28 22v6h-74z" fill={url('stone')} />
      <path d="M112 26l46-26 28 22v6h-74z" fill="#1e1a14" opacity="0.25" />
      <path d="M111 26l47-26 30 23" fill="none" stroke="#2e2924" strokeWidth="2.4" />
      <rect x="44" y="23" width="72" height="3" fill="#2e2924" />
      <path d="M112 52h46l4 10h-54z" fill="#3e3a35" />
      {HILLS_WINDOWS.map(([x, y, w, h], i) => (
        <g key={i}>
          <rect x={x - 0.8} y={y - 0.8} width={w + 1.6} height={h + 1.6} fill="#221e19" />
          <rect x={x} y={y} width={w} height={h} fill={url('lamp')} />
        </g>
      ))}
      {/* Ivy over the walls. */}
      <Leaves
        seed={71}
        cx={88}
        cy={42}
        rx={24}
        ry={12}
        count={120}
        size={[0.9, 2.2]}
        colors={IVY}
      />
      <Leaves
        seed={73}
        cx={134}
        cy={42}
        rx={18}
        ry={18}
        count={130}
        size={[0.9, 2.2]}
        colors={IVY}
      />
      <Leaves
        seed={79}
        cx={170}
        cy={50}
        rx={16}
        ry={34}
        count={170}
        size={[0.9, 2.2]}
        colors={IVY}
      />
      <Leaves
        seed={83}
        cx={108}
        cy={70}
        rx={34}
        ry={9}
        count={130}
        size={[0.9, 2.2]}
        colors={IVY}
      />
      <Leaves seed={84} cx={56} cy={52} rx={10} ry={20} count={70} size={[0.9, 2.2]} colors={IVY} />
      {/* The trees and the bush in front of the house. */}
      <Leaves
        seed={89}
        cx={22}
        cy={46}
        rx={30}
        ry={54}
        count={480}
        size={[1.3, 3.4]}
        colors={SHADE_TREE}
      />
      <rect x="22" y="80" width="3.4" height="22" fill="#1a1510" />
      <Leaves
        seed={97}
        cx={102}
        cy={90}
        rx={30}
        ry={15}
        count={280}
        size={[1.3, 3.2]}
        colors={SUN_TREE}
      />
      <Leaves
        seed={101}
        cx={204}
        cy={58}
        rx={28}
        ry={62}
        count={520}
        size={[1.3, 3.4]}
        colors={SUN_TREE}
      />
      <Leaves
        seed={103}
        cx={34}
        cy={108}
        rx={42}
        ry={9}
        count={220}
        size={[1.2, 2.8]}
        colors={SHADE_TREE}
      />
      {/* The lawn and the path to the door. */}
      <path d="M0 149l169-43 53 4v178H0z" fill={url('lawn')} />
      <path d="M0 132l166-36 4 10L9 148l-9 2z" fill={url('path')} />
      <path d="M9 148l161-42" stroke="#9a8c70" strokeWidth="0.7" opacity="0.6" />
      <Leaves
        seed={107}
        cx={192}
        cy={110}
        rx={34}
        ry={5}
        count={100}
        size={[1.1, 2.4]}
        colors={SUN_TREE.slice(1, 5)}
      />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* Inside the guest house in the evening: stone walls, a reading lamp, a leather chair.        */
/* 222 × 288.                                                                                   */

function drawLounge({ id, url }: Ids) {
  return (
    <>
      <defs>
        <pattern id={id('stone')} width="12" height="8" patternUnits="userSpaceOnUse">
          <rect width="12" height="8" fill="#3e3731" />
          <rect x="0.5" y="0.5" width="6" height="3.2" rx="1" fill="#5c5249" />
          <rect x="7" y="0.7" width="4.4" height="3" rx="1" fill="#51483f" />
          <rect x="2.4" y="4.4" width="5" height="3.1" rx="1" fill="#62574d" />
          <rect x="8" y="4.3" width="3.5" height="3.2" rx="1" fill="#4a423a" />
        </pattern>
        <radialGradient id={id('warm')} cx="0.24" cy="0.28" r="0.65">
          <stop offset="0" stopColor="#ffc879" stopOpacity="0.7" />
          <stop offset="0.5" stopColor="#d9893a" stopOpacity="0.25" />
          <stop offset="1" stopColor="#000" stopOpacity="0.35" />
        </radialGradient>
        <linearGradient id={id('dusk')} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="#5a6e86" />
          <stop offset="0.6" stopColor="#2c3a3c" />
          <stop offset="1" stopColor="#1c2a1e" />
        </linearGradient>
        <linearGradient id={id('leather')} x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stopColor="#a65a32" />
          <stop offset="1" stopColor="#5e2c16" />
        </linearGradient>
      </defs>
      <rect width="222" height="288" fill={url('stone')} />
      {/* A window onto the garden at dusk. */}
      <rect x="122" y="34" width="72" height="104" fill="#2a231e" />
      <rect x="126" y="38" width="64" height="96" fill={url('dusk')} />
      <path d="M158 38v96M126 86h64" stroke="#2a231e" strokeWidth="2.4" />
      <Leaves
        seed={109}
        cx={158}
        cy={118}
        rx={30}
        ry={12}
        count={90}
        size={[1.4, 3]}
        colors={['#16231a', '#1d2e20', '#253a28']}
      />
      <rect x="172" y="96" width="5" height="7" fill="#f0c27a" opacity="0.9" />
      {/* The floor, a rug. */}
      <rect y="196" width="222" height="92" fill="#3a2a1e" />
      <path d="M0 214h222M0 236h222M0 262h222" stroke="#2a1d14" strokeWidth="1" />
      <path d="M20 230h182l14 40H6z" fill="#6e4a3a" opacity="0.8" />
      {/* A reading lamp, its glow, and a leather armchair. */}
      <path d="M40 70v126" stroke="#1e1a16" strokeWidth="1.6" />
      <path d="M26 52h28l6 22H20z" fill="#f6d590" />
      <rect x="32" y="194" width="16" height="3" fill="#1e1a16" />
      <path d="M62 150c0-18 10-28 30-28h16c20 0 30 10 30 28v42H62z" fill={url('leather')} />
      <rect x="56" y="160" width="16" height="40" rx="6" fill="#8c4626" />
      <rect x="128" y="160" width="16" height="40" rx="6" fill="#7a3c20" />
      <rect x="70" y="168" width="60" height="22" rx="5" fill="#9c5230" />
      <path d="M66 200l-2 10M134 200l2 10" stroke="#2a1d14" strokeWidth="2.4" />
      <rect x="152" y="170" width="26" height="4" fill="#5a3e2a" />
      <rect x="163" y="174" width="4" height="24" fill="#4a3322" />
      <rect x="156" y="162" width="18" height="4" fill="#c9b48a" />
      <rect x="158" y="158" width="14" height="4" fill="#7a5a8a" />
      <rect width="222" height="288" fill={url('warm')} />
    </>
  );
}

/* ------------------------------------------------------------------------------------------ */
/* The sprite, and the photos that show its scenes.                                            */

/** Every scene, with the box it is drawn in. */
const SCENES = {
  dish: { box: '0 0 215 146', draw: drawDish },
  overhead: { box: '0 0 215 146', draw: drawOverhead },
  living: { box: '0 0 223 201', draw: drawLiving },
  bedroom: { box: '0 0 223 201', draw: drawBedroom },
  farm: { box: '0 0 300 289', draw: drawFarm },
  kitchen: { box: '0 0 216 153', draw: drawKitchen },
  cottage: { box: '0 0 222 287', draw: drawCottage },
  salon: { box: '0 0 222 287', draw: drawSalon },
  stoneHouse: { box: '0 0 222 288', draw: drawStoneHouse },
  lounge: { box: '0 0 222 288', draw: drawLounge },
} satisfies Record<string, { box: string; draw: (ids: Ids) => ReactNode }>;

export type SceneName = keyof typeof SCENES;

/** The prefix of the sprite's ids, unique to each board on the page. */
export const SpriteContext = createContext('scene');

/** Every scene, drawn once and hidden; the photos show them. */
export const SceneSprite = memo(function SceneSprite({ prefix }: { prefix: string }) {
  return (
    <svg
      aria-hidden="true"
      width="0"
      height="0"
      style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
    >
      <defs>
        {(Object.keys(SCENES) as SceneName[]).map((name) => (
          <g key={name} id={`${prefix}-${name}`}>
            {SCENES[name].draw({
              id: (part) => `${prefix}-${name}-${part}`,
              url: (part) => `url(#${prefix}-${name}-${part})`,
            })}
          </g>
        ))}
      </defs>
    </svg>
  );
});

/** A photo: a scene from the sprite, whole or cropped to `view`, filling its box. */
export function Photo({
  scene,
  view,
  label,
}: {
  /** The scene to show. */
  scene: SceneName;
  /** The part of it to show, as a viewBox; the whole scene when left out. */
  view?: string;
  /** What the photo shows, for screen readers. */
  label: string;
}) {
  const prefix = useContext(SpriteContext);
  return (
    <svg
      viewBox={view ?? SCENES[scene].box}
      preserveAspectRatio="xMidYMid slice"
      role="img"
      aria-label={label}
      style={{ display: 'block', width: '100%', height: '100%' }}
    >
      <use href={`#${prefix}-${scene}`} />
    </svg>
  );
}
```

### listing-cards/icons.tsx

```tsx
/*
 * The cards' small line icons, drawn on a 16 × 16 grid in the current colour. They are static
 * drawings: a state that changes an icon swaps whole glyphs around them instead of restyling one.
 */

type IconProps = { className?: string };

const LINE = {
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1.25,
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
} as const;

export function StarIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M8 1.6l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.4l-3.8 2 .7-4.3-3.1-3 4.3-.6z"
        fill="#f5b51b"
        stroke="#f5b51b"
        strokeWidth="0.8"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export function HeartIcon({ className, color }: IconProps & { color: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <path
        d="M8 14.2S1.3 10.3 1.3 5.6A3.4 3.4 0 0 1 8 4.2a3.4 3.4 0 0 1 6.7 1.4c0 4.7-6.7 8.6-6.7 8.6z"
        fill={color}
      />
    </svg>
  );
}

export function ClockIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <circle cx="8" cy="8" r="6.2" />
      <path d="M8 4.6V8l2.3 1.5" />
    </svg>
  );
}

export function PeopleIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <circle cx="6" cy="5.4" r="2.3" />
      <path d="M1.8 13.2c.4-2.3 2.1-3.6 4.2-3.6s3.8 1.3 4.2 3.6" />
      <path d="M10.6 3.3a2.3 2.3 0 0 1 0 4.3M12 9.8c1.2.5 2 1.6 2.2 3.4" />
    </svg>
  );
}

export function ChefIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <path d="M4.6 13.4h6.8M4.6 13.4V9.6a3 3 0 0 1-.9-5.5 3.1 3.1 0 0 1 4.3-1.8 3.1 3.1 0 0 1 4.3 1.8 3 3 0 0 1-.9 5.5v3.8" />
      <path d="M4.6 11h6.8" />
    </svg>
  );
}

export function BedIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <path d="M2.4 12.4V5.3a1.7 1.7 0 0 1 1.7-1.7h7.8a1.7 1.7 0 0 1 1.7 1.7v7.1M2.4 9.4h11.2M2.4 12.4h11.2" />
      <rect x="4.3" y="6" width="3.1" height="2.1" rx="0.9" />
      <rect x="8.6" y="6" width="3.1" height="2.1" rx="0.9" />
    </svg>
  );
}

export function BathIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <path d="M1.6 7.8h12.8v1.4a3.6 3.6 0 0 1-3.6 3.6H5.2a3.6 3.6 0 0 1-3.6-3.6z" />
      <path d="M4.2 7.8V4.1a1.7 1.7 0 0 1 3.1-1M5.4 12.8l-.8 1.4M10.6 12.8l.8 1.4" />
    </svg>
  );
}

export function AreaIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <path d="M2.3 4.4V3.3a1 1 0 0 1 1-1h7.1a1 1 0 0 1 1 1v3.3" />
      <path d="M2.3 4.4v7.3a1 1 0 0 0 1 1H6" />
      <path d="M2.3 7.4h3.1a2.3 2.3 0 0 1 2.3 2.3v3" />
      <path d="M8.2 12.7h5.5M9.7 12.7v-1.3M11.2 12.7v-2M12.7 12.7v-1.3M13.7 12.7V9.3" />
    </svg>
  );
}

export function ExpandIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <path d="M9.6 2.6h3.8v3.8M13.4 2.6 9.4 6.6M6.4 13.4H2.6V9.6M2.6 13.4l4-4" />
    </svg>
  );
}

export function HouseIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <path d="M2.4 7.2 8 2.6l5.6 4.6M3.8 6.1v7.3h8.4V6.1" />
      <path d="M6.6 13.4V10a1.4 1.4 0 0 1 2.8 0v3.4" />
    </svg>
  );
}

export function MapIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE}>
      <path d="M2 3.9 5.6 2.4l4.8 1.5L14 2.4v9.7l-3.6 1.5-4.8-1.5L2 13.6z" />
      <path d="M5.6 2.4v9.7M10.4 3.9v9.7" />
    </svg>
  );
}

export function ChevronIcon({ className, back }: IconProps & { back?: boolean }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE} strokeWidth={1.8}>
      <path d={back ? 'M9.8 3.6 5.4 8l4.4 4.4' : 'M6.2 3.6 10.6 8l-4.4 4.4'} />
    </svg>
  );
}

export function CheckIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true" {...LINE} strokeWidth={1.7}>
      <path d="m3.4 8.4 3 3 6.2-6.6" />
    </svg>
  );
}
```

### listing-cards/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, or grab a photo and drag it aside, calling the
 * component's own handlers as a hand 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 while the demo plays needs a layout, because one here in the same
 * frame as one in the cards would make the browser lay out the whole page (see the note at the
 * top of the stylesheet). So it moves by animations the compositor plays (a drag too: the dragged
 * value follows that animation's clock); the press scales a box that keeps a transform at rest,
 * since a box that gains or loses one is laid out again; and the arrow and the hand are HTML boxes
 * that swap by their visibility (a change of style inside an SVG lays the SVG out again).
 */

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>;
  /** Glides the pointer to a point of the screen, in client px. */
  moveToPoint: (point: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
  /**
   * Grabs, drags to a point of the screen (client px) over `ms`, and lets go. `step` gets the
   * share of the way done on every frame, so the dragged value moves with the hand.
   */
  drag: (to: Point, ms: number, step: (share: number) => 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)`;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

/**
 * Plays `script` with the pointer over `stage`; returns the function that stops it. `stopped` runs
 * when the demo stops, so the script can let go of what it holds.
 */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
  stopped?: () => void,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  let frame = 0;
  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.82, y: stage.offsetHeight * 0.92 };

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

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const frameBox = stage.getBoundingClientRect();
    const scale = frameBox.width / stage.offsetWidth || 1;
    return { x: (point.x - frameBox.left) / scale, y: (point.y - frameBox.top) / scale };
  };

  const glide = (to: Point) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    return glide(toStage({ x: box.left + box.width * share.x, y: box.top + box.height * share.y }));
  };

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

  const drag: Demo['drag'] = (point, ms, onStep) => {
    const from = at;
    const to = toStage(point);
    at = to;
    pointer.dataset.grab = '';
    // The hand's path, eased, as an animation; the value reads the same clock every frame.
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const share = easeInOut(i / 16);
      keyframes.push({
        transform: place({
          x: from.x + (to.x - from.x) * share,
          y: from.y + (to.y - from.y) * share,
        }),
      });
    }
    flight?.cancel();
    pointer.style.transform = place(to);
    const motion = pointer.animate(keyframes, { duration: ms, easing: 'linear' });
    flight = motion;
    return step(
      new Promise<void>((resolve) => {
        const tick = () => {
          const time = motion.currentTime;
          // Clamped at both ends; a finished animation is the end of the way.
          const t =
            motion.playState === 'finished' ? 1 : clamp(typeof time === 'number' ? time / ms : 0);
          onStep(easeInOut(t));
          if (t < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            delete pointer.dataset.grab;
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

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

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

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

/** Inside it, what dips towards the tip when the pointer presses, on a layer of its own. */
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',
};

/** One glyph, over the other: the 20 × 20 drawing at 22 px. */
const GLYPH: CSSProperties = { position: 'absolute', top: 0, left: 0, display: 'block' };
const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };
const FINGERS: CSSProperties = { fill: 'none', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a closed hand while it drags. It is decorative:
 * the demo's effects show on the controls themselves.
 */
export function DemoPointer({ ref, className }: { ref: Ref<HTMLSpanElement>; className?: string }) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <span data-glyph="arrow" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            <path
              d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
              style={ARROW}
              strokeWidth="1.3"
              strokeLinejoin="round"
            />
          </svg>
        </span>
        <span data-glyph="hand" style={GLYPH}>
          <svg viewBox="0 0 20 20" style={DRAWING}>
            {/* The hand's middle sits on the arrow's tip. */}
            <g transform="translate(-6.6 -9.4)">
              <path
                d="M6.1 16.6c-1.2-1.2-1.8-2.6-1.8-4.3v-1.6c0-.7.5-1.2 1.1-1.2.2 0 .4 0 .6.1V8.1c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v-.5c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.3c0-.6.5-1.1 1.1-1.1s1.1.5 1.1 1.1v.7c0-.5.5-1 1-1 .6 0 1.1.5 1.1 1.1v3.7c0 1.9-.8 3.2-1.8 4.1z"
                style={HAND}
                strokeWidth="1.1"
                strokeLinejoin="round"
              />
              <path
                d="M8.2 8.1v1.7M10.4 7.9v1.9M12.6 8.6v1.5"
                style={FINGERS}
                strokeWidth="0.9"
                strokeLinecap="round"
              />
            </g>
          </svg>
        </span>
      </span>
    </span>
  );
}
```

### listing-cards/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 };
}
```