# Shop list

> A game vendor’s overlay: a list of goods with stock and price, a sliding cursor frame, the item’s detail, the coin balance and the vendor’s voice. Buying rolls the coins down; short of coins, it shakes.

The screen a game opens when the player talks to a merchant. A header row (All Items, Stock, Price) tops a list of plates, each with its glyph, name, stock and price; a cream frame with white corner ticks marks the row under the cursor and slides from row to row. Beside it, the detail panel shows the item big on a turning burst of light, its price, how many the player already carries, its effects and a short description, under the coin balance. The vendor speaks in a rounded bubble below. Buying ticks the stock down, rolls the balance down with a floating −20 and pops the inventory count; without enough coins, the row, the cursor and the balance shake and flash red, and the vendor says so. Browse with the arrows or the mouse, buy with A, Enter or a second click.

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

Category: Games · Overlays.

## Anatomy

1. **Header**: Three dark tabs over the columns: All Items, Stock and Price. Decoration only (`aria-hidden`); each row says its own stock and price.
2. **Row**: One `role="option"` per item on its own dark plate: the glyph overhanging the top edge, the name, the stock and the price with a coin. The selected row’s plate sinks a shade.
3. **Cursor**: A cream frame with white corner ticks, drawn once over the list and slid to the selected row, overshooting a little; while it rests, it breathes.
4. **Balance**: The player’s coins on a dark pill fading to the right, a gold coin first. After a purchase it rolls down to the new total, with the price floating under it.
5. **Detail panel**: The selected item: its glyph big on a slowly turning burst, the price and the count In Inventory on ruled lines, the name, its effects and a short description.
6. **Effect chip**: One per effect, on a black chip: an icon alone (a heart), with a boxed value, or a green buff with its words (`label`).
7. **Vendor bubble**: A long rounded bubble with the speaker’s name on a tab. Each new line types itself in, already laid out, so the bubble never reflows.
8. **Hints**: Back (B) and Buy (A) as real buttons with `aria-keyshortcuts`; pressing one dips its face button and spreads a ring.

## When to use

- A vendor the player talks to in a game: a short list of goods to scroll, read and buy with one button, while the world waits behind it.
- Goods whose effects matter more than their picture: the detail panel names what one does (a heart, Defense Up) before the coins are spent.
- A shop with a running balance and limited stock, where buying must answer at once: the coins roll down, the stock ticks, the vendor thanks you.
- A menu driven by a pad or the keyboard as much as by the pointer: a cursor that moves row to row, A to buy, B to leave.
- A merchant with a voice: the vendor’s line types itself in under the goods and changes with what the player does.

## When not to use

- A real store with real money, a cart and a checkout. Use a product list with an add-to-cart button and a cart drawer.
- Dozens of goods to filter or sort. Use an item grid with tabs or a searchable table; this list shows five to eight rows.
- Selling from the player’s own bag, or trading item for item. Use an inventory grid with a sell action.
- A single offer, such as a one-time upgrade. Use a game modal with a price and a confirm button.
- Choosing among answers in a conversation, with nothing to pay. Use a choice list under the dialog box.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/shop-list.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 { ShopList, type ShopItem } from './shop-list';

const GOODS: ShopItem[] = [
  {
    id: 'squash',
    name: 'Armored Squash',
    price: 20,
    stock: 2,
    owned: 4,
    glyph: 'squash',
    effects: [{ icon: 'half-heart' }, { icon: 'shield', label: 'Defense Up' }],
    description: 'A thick-skinned squash grown along the terrace walls.',
  },
  {
    id: 'melon',
    name: 'Dewmelon',
    price: 24,
    stock: 5,
    glyph: 'melon',
    effects: [{ icon: 'heart' }, { icon: 'frost', label: 'Heat Resist' }],
    description: 'A melon heavy with cold dew, picked before sunrise.',
  },
];

export function Vendor({ coins, onBuy }: { coins: number; onBuy: (id: string) => void }) {
  return (
    <ShopList
      items={GOODS}
      vendor="Mara"
      coins={coins}
      onBuy={(item) => onBuy(item.id)}
      autoplay={false}
    />
  );
}
```

## States

- **Browsing**: Opened on the first item, with the vendor’s greeting: `autoplay={false}`, nothing bought yet.
- **Another item**: `defaultIndex={4}`: the cursor on the plantains; the panel shows their price, count and Attack Up.
- **Just bought**: After a squash for 20: one left in stock, five In Inventory, 44 coins, and the vendor’s thanks.
- **Short of coins**: Twelve coins against a 24-coin melon: a purchase is refused, the vendor says so, nothing changes.
- **Sold out**: A row with no stock dims and shows Sold out in place of its price; buying it is refused.
- **Leaving**: After Back (B or Escape): the vendor’s goodbye, and `onBack` tells the game to close the shop.
- **Own goods**: Three `items` of your own, another `vendor` and a stat value on an effect chip.
- **Stacked**: Under 780 px wide the balance, the list and the panel stack in one column, 348 kit px wide.
- **Light**: `colorScheme="light"`: soft shadows under the plates. The plates, art and text never change.
- **Dark**: `colorScheme="dark"`: deeper shadows and a lighter edge, so the plates hold on a dark page.

## How it works

- Every length is a multiple of --px, one pixel of the layout it was measured on: rows 40 px tall on a 58 px pitch, a 348 px list beside a 342 × 249 px detail panel, a 481 × 98 px bubble. On a wide stage --px is min(1.12px, (100cqi − 48px) / 738), a container query unit, so the whole overlay scales as one; below 780 px the list, the panel and the bubble stack in one 348-unit column and --px follows the stage down to about 0.8 px at 320.
- The cursor is one frame laid over the list, not a style on the selected row: --at holds the selected index and the frame’s translate is --at × the row pitch, eased with a small overshoot, so it slides from row to row. A 1.6-unit cream border and four white corner triangles (clip-path) make it; while it rests, the border and a soft light breathe every 1.6 s.
- Moving the cursor remounts the big glyph and the text below it (keyed by item), which replays their keyframes: the glyph pops from 0.82, the name, effects and description fade in. The burst of light behind the glyph is a repeating conic gradient masked by a radial one, turning once every 36 s.
- Buying (A, Enter, Space, a second click on the selected row or the Buy hint) checks stock, then coins. A purchase decrements the stock (the digit drops in), adds one to In Inventory (it pops green), sweeps a light across the row and rolls the balance to its new total over 520 ms with requestAnimationFrame, with a floating −20 under it. Short of coins, nothing changes: the row, the cursor and the balance shake sideways in 420 ms and the price and the balance flash red. A sold-out row dims, shows Sold out and is refused.
- The vendor’s line types itself in two letters every 22 ms. The whole line is in the bubble from the start, the letters still to come only transparent, so the bubble never reflows and a screen reader reads it all at once.
- Uncontrolled, a demo plays a short visit while the shop is on screen and untouched: browse down, buy plantains, come back for a squash, try a melon one coin short, settle for a nut, and the stall resets. Any input hands it to the reader for 7 s.
- There is no world behind it here, so every part sits on its own dark plate (#33322e) and reads the same on a light page or a dark one; the plates’ edges and shadows follow the colour scheme through light-dark() tokens. The produce, the effect icons and the coins are small SVGs drawn for it; no filter touches them, so they stay sharp at any pixel density.

## API reference

- **items** (`ShopItem[]`, default `five invented produce items`): The goods: { id, name, price, stock, owned?, glyph, effects?, description }. glyph is squash, melon, nut, cap or plantain; an effect is { icon, value?, label? }, icon one of heart, half-heart, shield, flame, frost, speed, blade, and a label makes it a green buff chip. Stock and owned are where it starts; a new array starts over.
- **vendor** (`string`, default `'Mara'`): The speaker’s name on the bubble’s tab, and the list’s accessible name.
- **lines** (`ShopLines`, default `DEFAULT_LINES`): What the vendor says: { greeting, bought(item), short(item, coins), soldOut(item), back }.
- **coins** (`number`): The balance, controlled: the shop then never changes it itself; update it from onBuy.
- **defaultCoins** (`number`, default `64`): Uncontrolled, the balance it starts with.
- **defaultIndex** (`number`, default `0`): The item the cursor starts on, to open the shop on a given row; giving it also turns the demo off.
- **onBuy** (`(item: ShopItem, result: { coins: number; stock: number; owned: number }) => void`): Called after a purchase, with the balance, stock and count it leaves.
- **onSelect** (`(item: ShopItem, index: number) => void`): Called when the cursor moves to an item.
- **onBack** (`() => void`): Called on Back (B, Escape or the Back hint); the vendor says goodbye.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short visit: browse, buy twice, come up short, buy again, reset. Only with the default items.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the plates’ edges and shadows; by default it follows the page. The plates, art and text keep their colours.
- **className** (`string`): Added to the root.

## Accessibility

- The goods are a listbox with one option per item, named with its stock and price (“Dewmelon, 5 in stock, 24 coins”); the cursor is aria-activedescendant, so the focus stays on the list.
- With the list focused: the up and down arrows move the cursor (wrapping), Home and End jump, Enter, Space or A buys, B or Escape is Back. The Back and Buy hints are real buttons with aria-keyshortcuts.
- A polite live region says what happened: “Bought Dewmelon for 24. 40 coins left, 4 in stock.”, “Not enough coins: Dewmelon costs 24, you have 19.”, or that an item is sold out.
- The detail panel is a labelled section with the price and inventory count as a description list and the effects as a list; icon-only effects carry a hidden name. The vendor’s whole line is in the DOM from the start, only its untyped letters transparent.
- All art is aria-hidden; the list and the hints show a :focus-visible ring.
- Reduced motion: no slides, shakes, pops, sweeps, typing or turning light; values change in place and only the colour flashes remain. The demo never plays, and it pauses offscreen and in a hidden tab.

## Source

### shop-list/shop-list.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import {
  CoinGold,
  CoinOutline,
  Effect,
  FaceButton,
  Pouch,
  Produce,
  type EffectIcon,
  type ProduceGlyph,
} from './art';
import { useAutoplay } from './use-autoplay';
import styles from './shop-list.module.css';

/*
 * Shop list: the overlay a game opens when the player talks to a vendor. The world pauses behind
 * it; the player scrolls the goods, reads what one does, and buys with A.
 *
 * - The list: a header row (All Items · Stock · Price) on dark plates, then one plate per item: its
 *   glyph (overhanging the plate's top, as game icons do), name, stock and price with a coin. The
 *   cursor is a cream frame with white corner ticks, drawn once over the list and slid from row to
 *   row (it overshoots a little and settles); while it rests, it breathes.
 * - The detail panel: the item's glyph, big, on a slowly turning burst of light; its price and how
 *   many the player already carries (In Inventory), each on a ruled line; the name, its effect
 *   chips, and a short description. Moving the cursor pops the new glyph in and fades the text.
 * - The balance: a gold coin and the player's coins on a dark pill above the panel.
 * - The vendor speaks in a rounded bubble under it all, the speaker's name on a tab. A new line
 *   types itself in, a few letters per frame; the whole line is laid out (and read) from the start,
 *   the letters still to come are only transparent, so the bubble never reflows.
 * - Buying (A, Enter, a click on the selected row, or the Buy hint): the stock ticks down, the
 *   balance rolls down to its new total with a floating "−20" under it, the row flashes, "In
 *   Inventory" ticks up and pops, and the vendor thanks the player. Short of coins, nothing changes:
 *   the row, the cursor and the balance shake, the price and the balance flash red, and the vendor
 *   says so. A sold-out row dims, shows "Sold out", and is refused.
 * - Uncontrolled, a demo plays a short visit while the shop is on screen and untouched: browse,
 *   buy two things, try one too many, then the stall resets. Any input hands it to the reader.
 * - It has no world behind it here: every part is its own dark plate (the kit's #33322e), so it
 *   reads the same on a light page or a dark one; only the plates' edges and shadows follow the
 *   colour scheme. Every size is a multiple of --px, one kit pixel, which grows to 1.12 px on a wide
 *   stage and shrinks with a narrow one, where the list and the panel stack.
 */

export type ShopEffect = {
  icon: EffectIcon;
  /** A small number boxed beside the icon (a stat). */
  value?: number | string;
  /** A buff, as a green chip ("Defense Up"). Without it, the icon gets its own dark chip. */
  label?: string;
};

export type ShopItem = {
  id: string;
  name: string;
  price: number;
  /** How many the vendor has. */
  stock: number;
  /** How many the player already carries. */
  owned?: number;
  glyph: ProduceGlyph;
  effects?: ShopEffect[];
  description: string;
};

export type ShopLines = {
  greeting: string;
  bought: (item: ShopItem) => string;
  short: (item: ShopItem, coins: number) => string;
  soldOut: (item: ShopItem) => string;
  back: string;
};

export const DEFAULT_ITEMS: ShopItem[] = [
  {
    id: 'squash',
    name: 'Armored Squash',
    price: 20,
    stock: 2,
    owned: 4,
    glyph: 'squash',
    effects: [{ icon: 'half-heart' }, { icon: 'shield', label: 'Defense Up' }],
    description:
      'A thick-skinned squash grown along the terrace walls. Cooked, its toughness carries over and hardens your guard.',
  },
  {
    id: 'melon',
    name: 'Dewmelon',
    price: 24,
    stock: 5,
    owned: 1,
    glyph: 'melon',
    effects: [{ icon: 'heart' }, { icon: 'frost', label: 'Heat Resist' }],
    description:
      'A melon heavy with cold dew, picked before sunrise. Eaten in the heat, it keeps you cool for a good while.',
  },
  {
    id: 'nut',
    name: 'Sprintnut',
    price: 14,
    stock: 2,
    owned: 0,
    glyph: 'nut',
    effects: [{ icon: 'half-heart' }, { icon: 'speed', label: 'Speed Up' }],
    description:
      'A small, oily nut the hill goats love. Roasted over a fire, it puts a spring in every step.',
  },
  {
    id: 'cap',
    name: 'Emberstool',
    price: 18,
    stock: 2,
    owned: 3,
    glyph: 'cap',
    effects: [{ icon: 'half-heart' }, { icon: 'flame', label: 'Cold Resist' }],
    description:
      'Ember-red caps that grow around hot springs. They warm you from the inside on the coldest ridges.',
  },
  {
    id: 'plantain',
    name: 'Brawn Plantains',
    price: 25,
    stock: 11,
    owned: 0,
    glyph: 'plantain',
    effects: [{ icon: 'half-heart' }, { icon: 'blade', label: 'Attack Up' }],
    description:
      'A heavy bunch of starchy plantains. Cooked, they lend a little extra force to every swing.',
  },
];

export const DEFAULT_LINES: ShopLines = {
  greeting: 'Rain keeps the crowds away, so I’ve marked down the best of the harvest. Have a look!',
  bought: (item) => `${item.name}, fresh off the cart. Thank you kindly!`,
  short: (item) =>
    `That one’s ${item.price} coins, friend. Come back when your purse is a little heavier.`,
  soldOut: (item) => `That was the last of the ${item.name}, I’m afraid. More after the rain.`,
  back: 'Safe travels! My cart is here whenever you need it.',
};

type Feedback = {
  kind: 'buy' | 'short' | 'sold-out';
  index: number;
  price: number;
  /** Bumps on every event, to restart its animations. */
  n: number;
};

type Step = { wait: number; act: 'move' | 'buy' | 'reset'; to?: number };
// One visit: browse down, buy plantains, come back up for a squash, try a melon one coin short,
// settle for a nut, then the stall resets.
const DEMO: Step[] = [
  { wait: 1500, act: 'move', to: 1 },
  { wait: 480, act: 'move', to: 2 },
  { wait: 420, act: 'move', to: 3 },
  { wait: 420, act: 'move', to: 4 },
  { wait: 1000, act: 'buy' },
  { wait: 2300, act: 'move', to: 3 },
  { wait: 360, act: 'move', to: 2 },
  { wait: 360, act: 'move', to: 1 },
  { wait: 360, act: 'move', to: 0 },
  { wait: 1000, act: 'buy' },
  { wait: 2300, act: 'move', to: 1 },
  { wait: 1100, act: 'buy' },
  { wait: 2800, act: 'move', to: 2 },
  { wait: 1000, act: 'buy' },
  { wait: 3200, act: 'reset' },
];

const format = (n: number) => n.toLocaleString('en-US');

export function ShopList({
  items = DEFAULT_ITEMS,
  vendor = 'Mara',
  lines = DEFAULT_LINES,
  coins: coinsProp,
  defaultCoins = 64,
  defaultIndex,
  onBuy,
  onBack,
  onSelect,
  autoplay = true,
  colorScheme,
  className,
}: {
  items?: ShopItem[];
  vendor?: string;
  lines?: ShopLines;
  coins?: number;
  defaultCoins?: number;
  /** The item the cursor starts on; giving it also turns the demo off. */
  defaultIndex?: number;
  onBuy?: (item: ShopItem, result: { coins: number; stock: number; owned: number }) => void;
  onBack?: () => void;
  onSelect?: (item: ShopItem, index: number) => void;
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const id = useId();
  const { ref, take, visible, reduced, playing } = useAutoplay<HTMLDivElement>(7000);
  const [index, setIndex] = useState(defaultIndex ?? 0);
  const [stock, setStock] = useState(() => items.map((item) => item.stock));
  const [owned, setOwned] = useState(() => items.map((item) => item.owned ?? 0));
  const [ownCoins, setOwnCoins] = useState(defaultCoins);
  const coins = coinsProp ?? ownCoins;
  const [line, setLine] = useState(lines.greeting);
  const [feedback, setFeedback] = useState<Feedback | null>(null);
  const [press, setPress] = useState<{ button: 'A' | 'B'; n: number } | null>(null);
  const [announce, setAnnounce] = useState('');
  const count = useRef(0);
  const listRef = useRef<HTMLDivElement>(null);

  // A new list (a different vendor) starts over.
  const [seen, setSeen] = useState(items);
  if (seen !== items) {
    setSeen(items);
    setStock(items.map((item) => item.stock));
    setOwned(items.map((item) => item.owned ?? 0));
    setIndex(0);
  }
  const current = Math.min(index, items.length - 1);
  const item = items[current];

  const select = useCallback(
    (next: number) => {
      const wrapped = (next + items.length) % items.length;
      setIndex(wrapped);
      onSelect?.(items[wrapped], wrapped);
    },
    [items, onSelect],
  );

  const pressButton = (button: 'A' | 'B') => setPress({ button, n: ++count.current });

  const buy = (at: number) => {
    const goods = items[at];
    pressButton('A');
    if (stock[at] <= 0) {
      setFeedback({ kind: 'sold-out', index: at, price: goods.price, n: ++count.current });
      setLine(lines.soldOut(goods));
      setAnnounce(`${goods.name} is sold out.`);
      return;
    }
    if (coins < goods.price) {
      setFeedback({ kind: 'short', index: at, price: goods.price, n: ++count.current });
      setLine(lines.short(goods, coins));
      setAnnounce(`Not enough coins: ${goods.name} costs ${goods.price}, you have ${coins}.`);
      return;
    }
    const left = coins - goods.price;
    const result = { coins: left, stock: stock[at] - 1, owned: owned[at] + 1 };
    if (coinsProp === undefined) setOwnCoins(left);
    setStock((all) => all.map((n, i) => (i === at ? n - 1 : n)));
    setOwned((all) => all.map((n, i) => (i === at ? n + 1 : n)));
    setFeedback({ kind: 'buy', index: at, price: goods.price, n: ++count.current });
    setLine(lines.bought(goods));
    setAnnounce(
      `Bought ${goods.name} for ${goods.price}. ${format(left)} coins left, ${result.stock} in stock.`,
    );
    onBuy?.(goods, result);
  };

  const back = () => {
    pressButton('B');
    setLine(lines.back);
    onBack?.();
  };

  // The demo.
  const [step, setStep] = useState(0);
  const run = useEffectEvent((s: Step) => {
    if (s.act === 'move' && s.to !== undefined) select(s.to);
    else if (s.act === 'buy') buy(current);
    else if (s.act === 'reset') {
      setStock(items.map((goods) => goods.stock));
      setOwned(items.map((goods) => goods.owned ?? 0));
      if (coinsProp === undefined) setOwnCoins(defaultCoins);
      setFeedback(null);
      setIndex(0);
      setLine(lines.greeting);
    }
  });
  const demo = autoplay && defaultIndex === undefined && playing && items === DEFAULT_ITEMS;
  useEffect(() => {
    if (!demo) return;
    const s = DEMO[step % DEMO.length];
    const timer = window.setTimeout(() => {
      run(s);
      setStep((n) => (n + 1) % DEMO.length);
    }, s.wait);
    return () => window.clearTimeout(timer);
  }, [demo, step]);

  // The balance rolls to its new value.
  const [shown, setShown] = useState(coins);
  const shownRef = useRef(coins);
  useEffect(() => {
    const from = shownRef.current;
    if (from === coins) return;
    if (reduced || !visible) {
      shownRef.current = coins;
      setShown(coins);
      return;
    }
    let frame = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / 520);
      const eased = 1 - (1 - t) ** 3;
      const value = Math.round(from + (coins - from) * eased);
      shownRef.current = value;
      setShown(value);
      if (t < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [coins, reduced, visible]);

  // The vendor's line types itself in.
  const [typed, setTyped] = useState(line.length);
  const [typing, setTyping] = useState(line);
  if (typing !== line) {
    setTyping(line);
    setTyped(0);
  }
  useEffect(() => {
    if (typed >= line.length) return;
    if (reduced || !visible) {
      setTyped(line.length);
      return;
    }
    const timer = window.setTimeout(() => setTyped((n) => n + 2), 22);
    return () => window.clearTimeout(timer);
  }, [typed, line, reduced, visible]);

  const onKeyDown = (event: KeyboardEvent) => {
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    const key = event.key;
    // On a hint button, Enter and Space are its own click.
    const onList = event.target === listRef.current;
    if (!onList && (key === 'Enter' || key === ' ')) return;
    let handled = true;
    if (key === 'ArrowDown') select(current + 1);
    else if (key === 'ArrowUp') select(current - 1);
    else if (key === 'Home') select(0);
    else if (key === 'End') select(items.length - 1);
    else if (key === 'Enter' || key === ' ' || key === 'a' || key === 'A') buy(current);
    else if (key === 'b' || key === 'B' || key === 'Escape') back();
    else handled = false;
    if (handled) {
      event.preventDefault();
      take();
    }
  };

  const onRowPointer = (event: PointerEvent, at: number) => {
    // A mouse moves the cursor by hovering, as a stick would; a touch selects on tap.
    if (event.pointerType === 'mouse' && at !== current) select(at);
  };
  const onRowClick = (at: number) => {
    take();
    listRef.current?.focus({ preventScroll: true });
    if (at === current) buy(at);
    else select(at);
  };

  const shake = feedback && feedback.kind !== 'buy' ? feedback : null;
  const bought = feedback?.kind === 'buy' ? feedback : null;
  const optionId = (at: number) => `${id}-item-${at}`;

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-paused={visible ? undefined : ''}
      onPointerDown={take}
    >
      <div className={styles.shop} data-part="shop" onKeyDown={onKeyDown}>
        <div className={styles.goods}>
          <div className={styles.header} data-part="header" aria-hidden="true">
            <span className={styles.headAll}>All Items</span>
            <span className={styles.headCol}>Stock</span>
            <span className={styles.headCol}>Price</span>
          </div>
          <div
            className={styles.list}
            role="listbox"
            tabIndex={0}
            aria-label={`${vendor}’s goods`}
            aria-activedescendant={optionId(current)}
            ref={listRef}
            style={{ '--at': current } as CSSProperties}
          >
            <div className={styles.frame} data-part="cursor" aria-hidden="true">
              <div
                key={shake ? `shake-${shake.n}` : 'cursor'}
                className={styles.cursor}
                data-shake={shake ? '' : undefined}
              >
                <span className={styles.corner} data-corner="tl" />
                <span className={styles.corner} data-corner="tr" />
                <span className={styles.corner} data-corner="bl" />
                <span className={styles.corner} data-corner="br" />
              </div>
            </div>
            {items.map((goods, at) => {
              const left = stock[at];
              const shaking = shake?.index === at;
              return (
                <div
                  key={goods.id}
                  id={optionId(at)}
                  role="option"
                  aria-selected={at === current}
                  aria-label={`${goods.name}, ${left ? `${left} in stock` : 'sold out'}, ${goods.price} coins`}
                  className={styles.row}
                  data-part="row"
                  data-selected={at === current ? '' : undefined}
                  data-sold-out={left ? undefined : ''}
                  onPointerMove={(event) => onRowPointer(event, at)}
                  onClick={() => onRowClick(at)}
                >
                  <div
                    key={shaking ? `s${shake.n}` : 'plate'}
                    className={styles.plate}
                    data-shake={shaking ? '' : undefined}
                    data-short={shaking && shake.kind === 'short' ? '' : undefined}
                  >
                    {bought?.index === at && <span key={bought.n} className={styles.flash} />}
                    <span className={styles.icon}>
                      <Produce glyph={goods.glyph} />
                    </span>
                    <span className={styles.name}>{goods.name}</span>
                    <span className={styles.stock}>
                      <span key={left} className={styles.tick}>
                        {left}
                      </span>
                    </span>
                    <span className={styles.price}>
                      {left ? (
                        <>
                          <span className={styles.coin}>
                            <CoinOutline />
                          </span>
                          {goods.price}
                        </>
                      ) : (
                        <span className={styles.soldOut}>Sold out</span>
                      )}
                    </span>
                  </div>
                </div>
              );
            })}
          </div>
        </div>

        <div className={styles.side}>
          <div
            key={shake?.kind === 'short' ? `b${shake.n}` : 'balance'}
            className={styles.balance}
            data-part="balance"
            data-shake={shake?.kind === 'short' ? '' : undefined}
            aria-label={`${format(coins)} coins`}
            role="img"
          >
            <span className={styles.gold}>
              <CoinGold />
            </span>
            <span className={styles.amount}>{format(shown)}</span>
            {bought && (
              <span key={bought.n} className={styles.delta} aria-hidden="true">
                −{bought.price}
              </span>
            )}
          </div>

          <section className={styles.detail} data-part="detail" aria-label="Item detail">
            <div className={styles.hero} data-part="hero" aria-hidden="true">
              <span className={styles.burst} />
              <span key={item.id} className={styles.heroGlyph}>
                <Produce glyph={item.glyph} />
              </span>
            </div>
            <dl className={styles.stats}>
              <div className={styles.stat}>
                <dt>Price</dt>
                <dd>
                  <span className={styles.statCoin}>
                    <CoinOutline />
                  </span>
                  <span className={styles.big}>{item.price}</span>
                </dd>
              </div>
              <div className={styles.stat}>
                <dt>In Inventory</dt>
                <dd>
                  <span className={styles.statPouch}>
                    <Pouch />
                  </span>
                  <span className={styles.times}>×</span>
                  <span
                    key={bought?.index === current ? `o${bought.n}` : item.id}
                    className={styles.big}
                    data-pop={bought?.index === current ? '' : undefined}
                  >
                    {owned[current]}
                  </span>
                </dd>
              </div>
            </dl>
            <div key={item.id} className={styles.about}>
              <h3 className={styles.title}>{item.name}</h3>
              {item.effects?.length ? (
                <ul className={styles.effects} aria-label="Effects">
                  {item.effects.map((effect, at) => (
                    <li
                      key={at}
                      className={styles.effect}
                      data-part="effect"
                      data-buff={effect.label ? '' : undefined}
                    >
                      <span className={styles.effectIcon}>
                        <Effect icon={effect.icon} />
                      </span>
                      {effect.value !== undefined && (
                        <span className={styles.effectValue}>{effect.value}</span>
                      )}
                      {effect.label ? (
                        <span className={styles.effectLabel}>
                          <span aria-hidden="true">▲ </span>
                          {effect.label}
                        </span>
                      ) : (
                        <span className={styles.hidden}>{effectName(effect.icon)}</span>
                      )}
                    </li>
                  ))}
                </ul>
              ) : null}
              <p className={styles.description}>{item.description}</p>
            </div>
          </section>
        </div>

        <figure className={styles.talk} data-part="vendor">
          <figcaption className={styles.speaker}>{vendor}</figcaption>
          <blockquote className={styles.bubble}>
            <span className={styles.arc} data-side="start" aria-hidden="true" />
            <p className={styles.line}>
              {line.slice(0, typed)}
              <span className={styles.unsaid}>{line.slice(typed)}</span>
            </p>
            <span className={styles.arc} data-side="end" aria-hidden="true" />
          </blockquote>
        </figure>

        <div className={styles.hints} data-part="hints">
          <button
            type="button"
            className={styles.hint}
            aria-keyshortcuts="B"
            onClick={() => {
              take();
              back();
            }}
          >
            <span className={styles.hintLabel}>Back</span>
            <span className={styles.disc}>
              <span
                key={press?.button === 'B' ? `d${press.n}` : 'b'}
                className={styles.dip}
                data-pressed={press?.button === 'B' ? '' : undefined}
              >
                <FaceButton letter="B" />
              </span>
              {press?.button === 'B' && <span key={`r${press.n}`} className={styles.ring} />}
            </span>
          </button>
          <button
            type="button"
            className={styles.hint}
            aria-keyshortcuts="A"
            aria-label={`Buy ${item.name}, A button`}
            onClick={() => {
              take();
              buy(current);
            }}
          >
            <span className={styles.hintLabel}>Buy</span>
            <span className={styles.disc}>
              <span
                key={press?.button === 'A' ? `d${press.n}` : 'a'}
                className={styles.dip}
                data-pressed={press?.button === 'A' ? '' : undefined}
              >
                <FaceButton letter="A" />
              </span>
              {press?.button === 'A' && <span key={`r${press.n}`} className={styles.ring} />}
            </span>
          </button>
        </div>
      </div>
      <p className={styles.hidden} aria-live="polite">
        {announce}
      </p>
    </div>
  );
}

function effectName(icon: EffectIcon) {
  const names: Record<EffectIcon, string> = {
    heart: 'Restores a heart',
    'half-heart': 'Restores half a heart',
    shield: 'Defense',
    flame: 'Warmth',
    frost: 'Cooling',
    speed: 'Speed',
    blade: 'Attack',
  };
  return names[icon];
}
```

### shop-list/art.tsx

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

import { useId } from 'react';

/*
 * The shop's art, all small SVGs drawn for this component (every one is decorative: aria-hidden).
 *
 * - Produce glyphs, on a 48 × 48 box: a ribbed squash, a striped melon, a capped nut, a cluster of
 *   ember caps, a bunch of plantains. Flat shapes with one soft gradient each, a darker rim and a
 *   highlight, so they read at 30 px in a row and at 72 px in the detail panel.
 * - Effect icons on a 20 × 20 box (a heart, a shield, a flame, a snowflake, a winged boot, a blade),
 *   in the bold, flat, glowing spirit of a game's status icons.
 * - The coin: an upright oval with an inner ring, as a white outline for prices, and as a struck gold
 *   coin for the balance. The pouch, for "In Inventory". The face buttons, a light disc with its
 *   letter.
 *
 * Gradient ids come from useId, so any number of shops can share a page.
 */

export type ProduceGlyph = 'squash' | 'melon' | 'nut' | 'cap' | 'plantain';
export type EffectIcon = 'heart' | 'half-heart' | 'shield' | 'flame' | 'frost' | 'speed' | 'blade';

const RIM = '#2b1a0c';

function Squash() {
  const id = useId();
  return (
    <svg viewBox="0 0 48 48" aria-hidden="true">
      <defs>
        <radialGradient id={`${id}b`} cx="0.38" cy="0.32" r="0.75">
          <stop offset="0" stopColor="#ffc46b" />
          <stop offset="0.55" stopColor="#f08a1e" />
          <stop offset="1" stopColor="#b2510b" />
        </radialGradient>
      </defs>
      <g stroke={RIM} strokeOpacity="0.55" strokeWidth="1.1">
        <ellipse cx="13.5" cy="28.5" rx="9" ry="13" fill={`url(#${id}b)`} />
        <ellipse cx="34.5" cy="28.5" rx="9" ry="13" fill={`url(#${id}b)`} />
        <ellipse cx="19" cy="28.5" rx="9" ry="14" fill={`url(#${id}b)`} />
        <ellipse cx="29" cy="28.5" rx="9" ry="14" fill={`url(#${id}b)`} />
        <ellipse cx="24" cy="28.5" rx="8" ry="14.5" fill={`url(#${id}b)`} />
      </g>
      <path
        d="M20.5 18.5c1.5-.9 5.5-.9 7 0"
        fill="none"
        stroke="#7a3a08"
        strokeOpacity="0.6"
        strokeWidth="1.4"
        strokeLinecap="round"
      />
      <path
        d="M24 17.5c-.2-3.4.6-6.2 3.4-8.4"
        fill="none"
        stroke="#3d5a1f"
        strokeWidth="3.2"
        strokeLinecap="round"
      />
      <path
        d="M27 9.3c2.4-.8 4.3.1 5.4 1.8"
        fill="none"
        stroke="#6b8a33"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <ellipse cx="18.5" cy="23" rx="2.4" ry="4.6" fill="#fff3d6" opacity="0.55" />
    </svg>
  );
}

function Melon() {
  const id = useId();
  return (
    <svg viewBox="0 0 48 48" aria-hidden="true">
      <defs>
        <radialGradient id={`${id}b`} cx="0.36" cy="0.3" r="0.8">
          <stop offset="0" stopColor="#c9f0a8" />
          <stop offset="0.5" stopColor="#6fb357" />
          <stop offset="1" stopColor="#2f6a2c" />
        </radialGradient>
        <clipPath id={`${id}c`}>
          <circle cx="24" cy="26" r="18" />
        </clipPath>
      </defs>
      <circle cx="24" cy="26" r="18" fill={`url(#${id}b)`} />
      <g clipPath={`url(#${id}c)`} fill="#24552a" opacity="0.85">
        <path d="M10 9c-4 6-4 26 1 36l3-1c-4-9-4-26-1-34z" />
        <path d="M20 7c-3 7-3 30 0 39h3c-3-9-3-31 0-38z" />
        <path d="M30 7c2 8 2 31-1 39l3 .5c3-9 3-31 0-39z" />
        <path d="M39 10c4 8 4 25-1 34l2.5 1.5c5-10 5-27 1-35z" />
      </g>
      <circle
        cx="24"
        cy="26"
        r="18"
        fill="none"
        stroke="#12301a"
        strokeOpacity="0.6"
        strokeWidth="1.1"
      />
      <path
        d="M24 8.2c0-2 .8-3.4 2.4-4.2"
        fill="none"
        stroke="#5b4421"
        strokeWidth="2"
        strokeLinecap="round"
      />
      <ellipse
        cx="17"
        cy="18"
        rx="4.5"
        ry="3"
        fill="#f4ffe6"
        opacity="0.5"
        transform="rotate(-35 17 18)"
      />
    </svg>
  );
}

function Nut() {
  const id = useId();
  return (
    <svg viewBox="0 0 48 48" aria-hidden="true">
      <defs>
        <radialGradient id={`${id}b`} cx="0.35" cy="0.35" r="0.8">
          <stop offset="0" stopColor="#c0663a" />
          <stop offset="0.6" stopColor="#7e3618" />
          <stop offset="1" stopColor="#4a1d0c" />
        </radialGradient>
        <linearGradient id={`${id}c`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#c9a26a" />
          <stop offset="1" stopColor="#7a5a32" />
        </linearGradient>
        <pattern
          id={`${id}p`}
          width="3.4"
          height="3.4"
          patternUnits="userSpaceOnUse"
          patternTransform="rotate(45)"
        >
          <path d="M0 0h3.4M0 0v3.4" stroke="#4e3418" strokeWidth="0.9" opacity="0.7" />
        </pattern>
      </defs>
      <path
        d="M14 22c0 9 4.6 20 10 20s10-11 10-20z"
        fill={`url(#${id}b)`}
        stroke={RIM}
        strokeOpacity="0.6"
        strokeWidth="1.1"
      />
      <path
        d="M11 23c0-7 6-12 13-12s13 5 13 12c-4 1.6-22 1.6-26 0z"
        fill={`url(#${id}c)`}
        stroke={RIM}
        strokeOpacity="0.6"
        strokeWidth="1.1"
      />
      <path d="M11 23c0-7 6-12 13-12s13 5 13 12c-4 1.6-22 1.6-26 0z" fill={`url(#${id}p)`} />
      <path
        d="M24 11c0-2.4.6-4 2-5.4"
        fill="none"
        stroke="#5a3d1e"
        strokeWidth="2.2"
        strokeLinecap="round"
      />
      <ellipse cx="19.5" cy="30" rx="2" ry="4.4" fill="#ffd9bd" opacity="0.4" />
    </svg>
  );
}

function Cap() {
  const id = useId();
  const cap = (cx: number, cy: number, r: number) => (
    <g key={`${cx}`}>
      <path
        d={`M${cx - 2.2} ${cy}c0 ${r * 0.7} -.6 ${r * 1.4} -1 ${r * 1.9}h${6.4}c-.4 ${-r * 0.5} -1 ${-r * 1.2} -1 ${-r * 1.9}z`}
        fill="#f1dcb8"
        stroke={RIM}
        strokeOpacity="0.5"
        strokeWidth="1"
      />
      <path
        d={`M${cx - r} ${cy + 1}a${r} ${r * 0.8} 0 0 1 ${r * 2} 0c-${r * 0.5} 1.2 -${r * 1.5} 1.2 -${r * 2} 0z`}
        fill={`url(#${id}c)`}
        stroke={RIM}
        strokeOpacity="0.6"
        strokeWidth="1.1"
      />
      <circle cx={cx - r * 0.4} cy={cy - r * 0.35} r={r * 0.16} fill="#ffe7b8" />
      <circle cx={cx + r * 0.3} cy={cy - r * 0.5} r={r * 0.12} fill="#ffe7b8" />
      <circle cx={cx + r * 0.55} cy={cy - r * 0.05} r={r * 0.1} fill="#ffe7b8" />
    </g>
  );
  return (
    <svg viewBox="0 0 48 48" aria-hidden="true">
      <defs>
        <radialGradient id={`${id}c`} cx="0.4" cy="0.25" r="0.9">
          <stop offset="0" stopColor="#ffae55" />
          <stop offset="0.55" stopColor="#e5561c" />
          <stop offset="1" stopColor="#962b0c" />
        </radialGradient>
      </defs>
      {cap(33, 28, 8)}
      {cap(14, 23, 9)}
      {cap(25, 14, 10)}
    </svg>
  );
}

function Plantain() {
  const id = useId();
  const finger = (d: string, key: number) => (
    <path
      key={key}
      d={d}
      fill={`url(#${id}b)`}
      stroke={RIM}
      strokeOpacity="0.55"
      strokeWidth="1.1"
    />
  );
  return (
    <svg viewBox="0 0 48 48" aria-hidden="true">
      <defs>
        <linearGradient id={`${id}b`} x1="0" y1="0" x2="0.4" y2="1">
          <stop offset="0" stopColor="#fff38a" />
          <stop offset="0.55" stopColor="#f2cc1d" />
          <stop offset="1" stopColor="#b88a06" />
        </linearGradient>
      </defs>
      {finger('M11 9c-5 9-5 23 1.5 31 2.2 2.4 5.6 1.4 4.6-1.4-4.4-8-4.6-19-1.4-27z', 0)}
      {finger('M12 9c7-1.6 16 .4 22.6 5.6 2.4 2 .8 5.2-1.8 4-6.4-3.6-12.6-5-19-4.4z', 1)}
      {finger(
        'M12.6 11.4c8.6 3 15.8 8.8 21 16.4 1.6 2.6-1 4.8-3.2 2.8-5-6.2-11-11.2-18.6-14.2z',
        2,
      )}
      {finger(
        'M12.6 12.6c6.4 6.6 10.6 15 11.8 23.6.4 2.8-2.8 3.8-3.8 1.2-1.6-8-5.2-15.4-10.4-20.8z',
        3,
      )}
      <path d="M9.4 6.4l4.6 1.8-1.8 5.4-4-2.2z" fill="#5a4a1e" />
      <g fill="#3a2a10">
        <circle cx="14.4" cy="39.2" r="1.6" />
        <circle cx="34.6" cy="16.8" r="1.5" />
        <circle cx="32" cy="30.4" r="1.5" />
        <circle cx="22.6" cy="37.4" r="1.5" />
      </g>
    </svg>
  );
}

const PRODUCE = { squash: Squash, melon: Melon, nut: Nut, cap: Cap, plantain: Plantain };

export function Produce({ glyph }: { glyph: ProduceGlyph }) {
  const Art = PRODUCE[glyph];
  return <Art />;
}

/** A status icon for an effect chip. */
export function Effect({ icon }: { icon: EffectIcon }) {
  switch (icon) {
    case 'heart':
    case 'half-heart':
      return (
        <svg viewBox="0 0 20 20" aria-hidden="true">
          <path d="M10 17.2 3.2 10.4a4 4 0 0 1 6.8-4.6 4 4 0 0 1 6.8 4.6z" fill="#3a3732" />
          <path
            d={
              icon === 'heart'
                ? 'M10 17.2 3.2 10.4a4 4 0 0 1 6.8-4.6 4 4 0 0 1 6.8 4.6z'
                : 'M10 17.2 3.2 10.4A4 4 0 0 1 10 5.8z'
            }
            fill="#e8343a"
          />
        </svg>
      );
    case 'shield':
      return (
        <svg viewBox="0 0 20 20" aria-hidden="true">
          <path d="M10 2.5 16 5v4.6c0 3.8-2.6 6.6-6 8-3.4-1.4-6-4.2-6-8V5z" fill="#7fb6ff" />
          <path d="M10 2.5V17.6c-3.4-1.4-6-4.2-6-8V5z" fill="#4c8be6" />
        </svg>
      );
    case 'flame':
      return (
        <svg viewBox="0 0 20 20" aria-hidden="true">
          <path
            d="M10 2c1 3.4 5.4 5.2 5.4 10a5.4 5.4 0 0 1-10.8 0c0-2.4 1.4-3.6 2.2-4.8.4 1.4 1 2 1.8 2.4C8.4 6.8 9 4.4 10 2z"
            fill="#ff7a2e"
          />
          <path
            d="M10 9.6c.8 1.8 2.6 2.6 2.6 4.6a2.6 2.6 0 0 1-5.2 0c0-1.6 1.4-2.6 2.6-4.6z"
            fill="#ffd36b"
          />
        </svg>
      );
    case 'frost':
      return (
        <svg viewBox="0 0 20 20" aria-hidden="true">
          <g stroke="#8fe3ff" strokeWidth="1.8" strokeLinecap="round" fill="none">
            <path d="M10 2.5v15M3.5 6.25l13 7.5M3.5 13.75l13-7.5" />
            <path d="M8 4l2 1.6L12 4M8 16l2-1.6 2 1.6" strokeWidth="1.3" />
          </g>
        </svg>
      );
    case 'speed':
      return (
        <svg viewBox="0 0 20 20" aria-hidden="true">
          <path d="M6 4h4.4l-.4 6.2 5.4 2.2c.8.4 1 1 1 2V16H4.4l.4-4z" fill="#ffe36b" />
          <path
            d="M1.6 7.4h3M1.2 10.4h3.2M2 13.4h2.4"
            stroke="#ffe36b"
            strokeWidth="1.4"
            strokeLinecap="round"
          />
        </svg>
      );
    case 'blade':
      return (
        <svg viewBox="0 0 20 20" aria-hidden="true">
          <path d="M16.8 2.2 17.4 5 8.6 13.8 6.2 11.4 15 2.6z" fill="#e9eef3" />
          <path d="M4.4 10.6l5 5-1.2 1.2-1.4-1.4-2.4 2.4-1.2-1.2 2.4-2.4-1.4-1.4z" fill="#d79a45" />
        </svg>
      );
  }
}

/** The price coin: an upright oval with an inner ring, drawn as an outline. */
export function CoinOutline() {
  return (
    <svg viewBox="0 0 12 16" aria-hidden="true">
      <ellipse cx="6" cy="8" rx="5" ry="7" fill="none" stroke="currentColor" strokeWidth="1.5" />
      <ellipse cx="6" cy="8" rx="2.6" ry="4.4" fill="currentColor" />
    </svg>
  );
}

/** The balance coin: a struck gold coin, faceted rim and a raised centre. */
export function CoinGold() {
  const id = useId();
  return (
    <svg viewBox="0 0 20 28" aria-hidden="true">
      <defs>
        <linearGradient id={`${id}g`} x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#fff2a8" />
          <stop offset="0.45" stopColor="#f0c43a" />
          <stop offset="1" stopColor="#a8700e" />
        </linearGradient>
      </defs>
      <ellipse cx="10" cy="14" rx="9" ry="12.6" fill="#7a4f08" />
      <ellipse cx="10" cy="13.4" rx="8.6" ry="12.2" fill={`url(#${id}g)`} />
      <ellipse
        cx="10"
        cy="13.4"
        rx="5.4"
        ry="8.4"
        fill="none"
        stroke="#9a6510"
        strokeWidth="1.2"
        opacity="0.7"
      />
      <ellipse cx="10" cy="13.4" rx="2.4" ry="5" fill="#fff4bf" opacity="0.85" />
      <path
        d="M4.2 7.6c1.2-2.6 2.8-3.8 4.4-4.2"
        stroke="#fffbe0"
        strokeWidth="1.3"
        strokeLinecap="round"
        fill="none"
      />
    </svg>
  );
}

/** A pouch, for the count already carried. */
export function Pouch() {
  return (
    <svg viewBox="0 0 14 14" aria-hidden="true">
      <path
        d="M5 1.4h4L8 3.6c2.6 1 4.4 3.4 4.4 6 0 2.2-1.6 3.2-5.4 3.2S1.6 11.8 1.6 9.6c0-2.6 1.8-5 4.4-6z"
        fill="currentColor"
      />
      <path d="M5.4 3.8h3.2" stroke="#1d1c18" strokeWidth="0.9" />
    </svg>
  );
}

/** A face button: a light disc with its letter. */
export function FaceButton({ letter }: { letter: 'A' | 'B' }) {
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true">
      <circle cx="16" cy="16" r="16" fill="var(--glyph)" />
      <text
        x="16"
        y="22.8"
        textAnchor="middle"
        fontSize="19"
        fill="var(--glyph-ink)"
        style={{ fontWeight: 600, fontStyle: 'normal' }}
      >
        {letter}
      </text>
    </svg>
  );
}
```

### shop-list/shop-list.module.css

```css
/* Shop list (shop-list.tsx): a vendor overlay alone on the stage. Every length is a multiple of
   --px, one pixel of the kit the layout was measured on: 1 px to 1.12 px on a wide stage (the list
   and the detail panel side by side, 738 kit px), and on a narrow one (below 780 px, where they
   stack, 348 kit px wide) whatever fits, down to about 0.8 px at 320.

   Every part sits on its own dark plate (#33322e, as in the kit), so the overlay reads on a light
   page or a dark one; the plates' edges and shadows follow the colour scheme through light-dark()
   tokens, the art and the text keep their colours. No filter touches the art or the text: glows
   are gradients and shadows drawn behind them, so everything stays sharp.

   Motion: the cursor frame slides (a transition on translate) and breathes (keyframes); a press,
   a purchase, a shake, a pop and the burst behind the big glyph are keyframes restarted by
   remounting their element. data-paused (offscreen) stops the endless ones. */

.root {
  /* Text and art on the plates. */
  --text: #d7d3c9;
  --text-dim: #c4c0b5;
  --text-hot: #f6f3e6;
  --buff: #6fd49c;
  --warn: #ff5d4f;
  --glyph: #e1ded1;
  --glyph-ink: #24221d;
  /* The plates. */
  --plate: #33322e;
  --plate-deep: #292825;
  --plate-sel: #2c2b27;
  --chip: #141412;
  --chip-edge: #2c2b27;
  --rule: #5c5a53;
  --frame: #cfc9a6;
  --tick: #e2ded3;
  --burst: rgb(214 196 118 / 0.62);
  --arc: rgb(205 214 160 / 0.7);
  /* Edges and shadows on the page. */
  --edge: rgb(255 255 255 / 0.07);
  --edge-out: rgb(0 0 0 / 0.35);
  --shade: rgb(20 18 12 / 0.2);
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 420px;
  padding: 32px 16px;
  box-sizing: border-box;
  color: var(--text);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(rgb(255 255 255 / 0.07), rgb(255 255 255 / 0.12));
    --edge-out: light-dark(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.6));
    --shade: light-dark(rgb(20 18 12 / 0.2), rgb(0 0 0 / 0.45));
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The layout: list and panel side by side, the vendor's bubble and the hints under them. */
.shop {
  --px: min(1.12px, (100cqi - 48px) / 738);
  --row: calc(40 * var(--px));
  --pitch: calc(58 * var(--px));
  display: grid;
  grid-template-columns: calc(348 * var(--px)) calc(342 * var(--px));
  grid-template-areas:
    'goods side'
    'talk talk'
    'hints hints';
  column-gap: calc(48 * var(--px));
  font-size: calc(15 * var(--px));
  line-height: 1.2;
}
.goods {
  grid-area: goods;
}
.side {
  grid-area: side;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: calc(17 * var(--px));
}
@container (max-width: 779px) {
  .shop {
    --px: min(1.1px, (100cqi - 32px) / 348);
    grid-template-columns: calc(348 * var(--px));
    grid-template-areas:
      'balance'
      'goods'
      'detail'
      'talk'
      'hints';
    justify-items: stretch;
  }
  .side {
    display: contents;
  }
  .shop .balance {
    grid-area: balance;
    justify-self: end;
    margin-bottom: calc(14 * var(--px));
  }
  .shop .detail {
    grid-area: detail;
    width: auto;
    margin-top: calc(28 * var(--px));
  }
}

/* The header row: three dark tabs. */
.header {
  display: grid;
  grid-template-columns: calc(205.5 * var(--px)) calc(64 * var(--px)) calc(71 * var(--px));
  gap: calc(3.5 * var(--px));
  height: calc(21 * var(--px));
  margin-bottom: calc(20 * var(--px));
  font-size: calc(13.4 * var(--px));
  font-style: italic;
  font-weight: 500;
  line-height: calc(21 * var(--px));
}
.header > span {
  background: var(--plate-deep);
  box-shadow:
    inset 0 0 0 1px var(--edge),
    0 calc(1 * var(--px)) calc(5 * var(--px)) var(--shade);
  border-radius: calc(1.5 * var(--px));
}
.headAll {
  padding-left: calc(16 * var(--px));
}
.headCol {
  text-align: center;
}

/* The list: one plate per row, the cursor frame laid over the selected one. */
.list {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: calc(18 * var(--px));
  outline: none;
}
.list:focus-visible::after {
  content: '';
  position: absolute;
  inset: calc(-8 * var(--px));
  border: 2px solid var(--focus);
  border-radius: calc(6 * var(--px));
  pointer-events: none;
}
.row {
  position: relative;
  height: var(--row);
  cursor: pointer;
  touch-action: manipulation;
}
.plate {
  position: relative;
  display: grid;
  grid-template-columns: calc(68 * var(--px)) 1fr calc(64 * var(--px)) calc(71 * var(--px));
  align-items: center;
  height: 100%;
  box-sizing: border-box;
  border-radius: calc(1.5 * var(--px));
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px var(--edge),
    0 0 0 1px var(--edge-out),
    0 calc(1.5 * var(--px)) calc(6 * var(--px)) var(--shade);
  transition: background-color 160ms;
}
.row[data-selected] .plate {
  background: var(--plate-sel);
  box-shadow:
    inset 0 0 calc(10 * var(--px)) rgb(0 0 0 / 0.45),
    0 0 0 1px var(--edge-out),
    0 calc(1.5 * var(--px)) calc(6 * var(--px)) var(--shade);
}
.icon {
  display: grid;
  width: calc(52 * var(--px));
  height: calc(52 * var(--px));
  margin-left: calc(9 * var(--px));
  translate: 0 calc(-3 * var(--px));
}
.icon svg,
.heroGlyph svg {
  width: 100%;
  height: 100%;
  overflow: visible;
}
.name {
  overflow: hidden;
  font-size: calc(16.6 * var(--px));
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.stock {
  display: grid;
  justify-items: center;
  overflow: hidden;
  height: 100%;
  align-items: center;
  font-size: calc(16.6 * var(--px));
  font-style: italic;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}
.tick {
  animation: tick 260ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
@keyframes tick {
  from {
    opacity: 0;
    translate: 0 -70%;
  }
}
.price {
  display: flex;
  align-items: center;
  gap: calc(3 * var(--px));
  padding-left: calc(13 * var(--px));
  font-size: calc(19 * var(--px));
  font-style: italic;
  font-weight: 500;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  transition: color 160ms;
}
.coin {
  display: grid;
  width: calc(11 * var(--px));
  height: calc(16 * var(--px));
  color: var(--text);
}
.coin svg,
.statCoin svg,
.statPouch svg,
.gold svg,
.effectIcon svg,
.disc svg {
  width: 100%;
  height: 100%;
}
.soldOut {
  font-size: calc(13 * var(--px));
  letter-spacing: 0;
  color: var(--text-dim);
}
.row[data-sold-out] .plate {
  background: var(--plate-deep);
}
.row[data-sold-out] .icon,
.row[data-sold-out] .name,
.row[data-sold-out] .stock {
  opacity: 0.45;
}

/* A purchase: a light sweeps the row. */
.flash {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(90deg, transparent, rgb(255 246 214 / 0.32), transparent);
  pointer-events: none;
  animation: flash 520ms ease-out forwards;
}
@keyframes flash {
  from {
    opacity: 1;
    translate: -40% 0;
  }
  to {
    opacity: 0;
    translate: 40% 0;
  }
}

/* The cursor: a cream frame with a white tick in each corner, slid to the selected row. */
.frame {
  position: absolute;
  z-index: 1;
  top: 0;
  left: 0;
  width: 100%;
  height: var(--row);
  pointer-events: none;
  translate: 0 calc(var(--at) * var(--pitch));
  transition: translate 190ms cubic-bezier(0.25, 1.25, 0.45, 1);
}
.cursor {
  position: absolute;
  inset: 0;
  border: calc(1.6 * var(--px)) solid var(--frame);
  border-radius: calc(1.5 * var(--px));
  animation: breathe 1600ms ease-in-out infinite alternate;
}
@keyframes breathe {
  from {
    border-color: var(--tick);
    box-shadow: 0 0 calc(6 * var(--px)) rgb(255 246 214 / 0.35);
  }
}
.corner {
  position: absolute;
  width: calc(8 * var(--px));
  height: calc(8 * var(--px));
  background: var(--tick);
}
.corner[data-corner='tl'] {
  top: calc(-0.6 * var(--px));
  left: calc(-0.6 * var(--px));
  clip-path: polygon(0 0, 100% 0, 0 100%);
}
.corner[data-corner='tr'] {
  top: calc(-0.6 * var(--px));
  right: calc(-0.6 * var(--px));
  clip-path: polygon(0 0, 100% 0, 100% 100%);
}
.corner[data-corner='bl'] {
  bottom: calc(-0.6 * var(--px));
  left: calc(-0.6 * var(--px));
  clip-path: polygon(0 0, 100% 100%, 0 100%);
}
.corner[data-corner='br'] {
  right: calc(-0.6 * var(--px));
  bottom: calc(-0.6 * var(--px));
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
}

/* Can't afford (or sold out): a sideways shake; short of coins, the price flashes red. */
.plate[data-shake],
.balance[data-shake] {
  animation: shake 420ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
.cursor[data-shake] {
  animation:
    shake 420ms cubic-bezier(0.36, 0.07, 0.19, 0.97),
    breathe 1600ms ease-in-out 420ms infinite alternate;
}
@keyframes shake {
  15%,
  55% {
    translate: calc(-6 * var(--px)) 0;
  }
  35%,
  75% {
    translate: calc(5 * var(--px)) 0;
  }
  90% {
    translate: calc(-2 * var(--px)) 0;
  }
}
.plate[data-short] .price,
.balance[data-shake] .amount {
  animation: warn 900ms ease-out;
}
@keyframes warn {
  10%,
  45% {
    color: var(--warn);
    text-shadow: 0 0 calc(8 * var(--px)) rgb(255 70 50 / 0.55);
  }
}

/* The balance: a gold coin on a dark pill that fades out to the right. */
.balance {
  position: relative;
  display: flex;
  align-items: center;
  gap: calc(8 * var(--px));
  width: calc(132 * var(--px));
  height: calc(23.5 * var(--px));
  box-sizing: border-box;
  padding-left: calc(14 * var(--px));
  border-radius: 999px 0 0 999px;
  background: linear-gradient(90deg, var(--plate-deep) 70%, rgb(41 40 37 / 0.5));
  box-shadow: 0 calc(1 * var(--px)) calc(5 * var(--px)) var(--shade);
}
.gold {
  display: grid;
  flex: none;
  width: calc(15 * var(--px));
  height: calc(21 * var(--px));
  position: relative;
}
.gold::before {
  content: '';
  position: absolute;
  inset: calc(-4 * var(--px)) calc(-7 * var(--px));
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(255 222 120 / 0.38), transparent);
  pointer-events: none;
}
.gold svg {
  position: relative;
}
.amount {
  font-size: calc(17 * var(--px));
  font-style: italic;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}
.delta {
  position: absolute;
  top: 100%;
  left: calc(36 * var(--px));
  padding: calc(1 * var(--px)) calc(7 * var(--px));
  border-radius: 999px;
  background: var(--plate-deep);
  color: var(--text-hot);
  font-size: calc(13 * var(--px));
  font-style: italic;
  font-weight: 600;
  pointer-events: none;
  animation: delta 1100ms cubic-bezier(0.2, 0.8, 0.3, 1) forwards;
}
@keyframes delta {
  from {
    opacity: 0;
    translate: 0 calc(-8 * var(--px));
  }
  18% {
    opacity: 1;
    translate: 0 calc(3 * var(--px));
  }
  70% {
    opacity: 1;
  }
  to {
    opacity: 0;
    translate: 0 calc(10 * var(--px));
  }
}

/* The detail panel. */
.detail {
  position: relative;
  width: calc(342 * var(--px));
  min-height: calc(249 * var(--px));
  box-sizing: border-box;
  padding: calc(31 * var(--px)) calc(36 * var(--px)) calc(18 * var(--px)) calc(22.5 * var(--px));
  border-radius: calc(1.5 * var(--px));
  background: var(--plate);
  box-shadow:
    inset 0 0 0 1px rgb(255 255 255 / 0.08),
    0 0 0 1px var(--edge-out),
    0 calc(2 * var(--px)) calc(10 * var(--px)) var(--shade);
}
.hero {
  position: absolute;
  top: calc(8.5 * var(--px));
  left: calc(17.5 * var(--px));
  display: grid;
  place-items: center;
  width: calc(114 * var(--px));
  height: calc(114 * var(--px));
}
.burst {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: repeating-conic-gradient(
    var(--burst) 0deg 5deg,
    rgb(214 196 118 / 0.34) 10deg 14deg,
    var(--burst) 20deg
  );
  mask: radial-gradient(closest-side, #000 22%, rgb(0 0 0 / 0.7) 55%, transparent);
  animation: turn 36s linear infinite;
}
@keyframes turn {
  to {
    rotate: 1turn;
  }
}
.heroGlyph {
  position: relative;
  display: grid;
  width: calc(84 * var(--px));
  height: calc(84 * var(--px));
  animation: pop 340ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.82;
  }
}
.stats {
  display: grid;
  margin: 0 0 0 calc(120.5 * var(--px));
}
.stat {
  display: flex;
  flex-direction: row-reverse;
  justify-content: space-between;
  align-items: center;
  height: calc(23.5 * var(--px));
  border-bottom: 1px solid var(--rule);
}
.stat dt {
  font-size: calc(12.4 * var(--px));
  font-style: italic;
}
.stat dd {
  display: flex;
  align-items: center;
  margin: 0;
  padding-left: calc(6.5 * var(--px));
}
.statCoin {
  display: grid;
  width: calc(10 * var(--px));
  height: calc(16 * var(--px));
  margin-right: calc(3 * var(--px));
}
.statPouch {
  display: grid;
  width: calc(12 * var(--px));
  height: calc(12 * var(--px));
  translate: 0 calc(-1 * var(--px));
}
.times {
  margin: 0 calc(1 * var(--px)) 0 calc(4 * var(--px));
  font-size: calc(11 * var(--px));
  font-style: italic;
  translate: 0 calc(2 * var(--px));
}
.big {
  display: inline-block;
  font-size: calc(19.5 * var(--px));
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.stat + .stat .big {
  font-style: italic;
}
.big[data-pop] {
  animation: owned 460ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
@keyframes owned {
  from {
    color: var(--buff);
    scale: 1.55;
  }
  40% {
    color: var(--buff);
  }
}
.about {
  margin-top: calc(39 * var(--px));
  animation: fade 260ms ease-out;
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
.title {
  margin: 0;
  padding-bottom: calc(3 * var(--px));
  border-bottom: 1px solid var(--rule);
  font-size: calc(20 * var(--px));
  font-style: italic;
  font-weight: 700;
  letter-spacing: -0.005em;
  line-height: calc(26 * var(--px));
}
.effects {
  display: flex;
  flex-wrap: wrap;
  gap: calc(6 * var(--px));
  margin: calc(3.5 * var(--px)) 0 calc(3 * var(--px));
  padding: 0;
  list-style: none;
}
.effect {
  display: flex;
  align-items: center;
  gap: calc(6 * var(--px));
  height: calc(20 * var(--px));
}
.effect:not([data-buff]) {
  padding: 0 calc(8 * var(--px));
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-edge);
}
.effect[data-buff] {
  padding-right: calc(8 * var(--px));
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-edge);
  padding-left: calc(6 * var(--px));
}
.effectIcon {
  display: grid;
  width: calc(15 * var(--px));
  height: calc(15 * var(--px));
}
.effectValue {
  padding: 0 calc(8 * var(--px));
  font-size: calc(13 * var(--px));
}
.effectLabel {
  color: var(--buff);
  font-size: calc(13.4 * var(--px));
  font-weight: 500;
  white-space: nowrap;
}
.effectLabel > span {
  font-size: 0.8em;
}
.description {
  margin: 0;
  color: var(--text-dim);
  font-size: calc(12.8 * var(--px));
  font-style: italic;
  line-height: calc(15 * var(--px));
}

/* The vendor's bubble: a long pill, a tab with the speaker's name, a thin arc inside each end. */
.talk {
  grid-area: talk;
  position: relative;
  justify-self: center;
  width: calc(481 * var(--px));
  max-width: 100%;
  margin: calc(56 * var(--px)) 0 0;
}
.speaker {
  position: absolute;
  z-index: 1;
  top: calc(-11 * var(--px));
  left: calc(38 * var(--px));
  padding: calc(2 * var(--px)) calc(8 * var(--px)) 0;
  border-radius: calc(8 * var(--px)) calc(8 * var(--px)) 0 0;
  background: var(--plate-deep);
  font-size: calc(12.5 * var(--px));
  font-style: italic;
  font-weight: 500;
  line-height: calc(15 * var(--px));
}
.bubble {
  position: relative;
  display: flex;
  align-items: center;
  min-height: calc(98 * var(--px));
  box-sizing: border-box;
  margin: 0;
  padding: calc(14 * var(--px)) calc(70 * var(--px)) calc(14 * var(--px)) calc(80 * var(--px));
  border-radius: calc(49 * var(--px));
  background: var(--plate-deep);
  box-shadow:
    inset 0 0 0 1px var(--edge),
    0 calc(2 * var(--px)) calc(10 * var(--px)) var(--shade);
}
.line {
  margin: 0;
  font-size: calc(16.6 * var(--px));
  font-style: italic;
  font-weight: 600;
  letter-spacing: -0.005em;
  line-height: calc(21 * var(--px));
}
.unsaid {
  opacity: 0;
}
.arc {
  position: absolute;
  top: 50%;
  width: calc(14 * var(--px));
  height: calc(32 * var(--px));
  margin-top: calc(-16 * var(--px));
  box-sizing: border-box;
  border: solid var(--arc);
  border-width: 0;
  border-radius: 50%;
}
.arc[data-side='start'] {
  left: calc(14 * var(--px));
  border-left-width: calc(1.5 * var(--px));
}
.arc[data-side='end'] {
  right: calc(14 * var(--px));
  border-right-width: calc(1.5 * var(--px));
}
@container (max-width: 779px) {
  .talk {
    width: 100%;
    margin-top: calc(40 * var(--px));
  }
  .bubble {
    padding-left: calc(36 * var(--px));
    padding-right: calc(30 * var(--px));
    border-radius: calc(36 * var(--px));
  }
  .arc[data-side='start'] {
    left: calc(10 * var(--px));
  }
  .arc[data-side='end'] {
    right: calc(10 * var(--px));
  }
}

/* The hints: a verb and its face button, each on a small dark plate. */
.hints {
  grid-area: hints;
  justify-self: center;
  display: flex;
  justify-content: center;
  gap: calc(14 * var(--px));
  margin-top: calc(14 * var(--px));
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(6 * var(--px));
  margin: 0;
  padding: calc(3 * var(--px)) calc(4 * var(--px)) calc(3 * var(--px)) calc(11 * var(--px));
  border: 0;
  border-radius: 999px;
  background: var(--plate-deep);
  box-shadow:
    inset 0 0 0 1px var(--edge),
    0 calc(1 * var(--px)) calc(5 * var(--px)) var(--shade);
  color: var(--text);
  font: inherit;
  cursor: pointer;
  touch-action: manipulation;
}
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.hintLabel {
  font-size: calc(14.4 * var(--px));
  font-style: italic;
  font-weight: 500;
}
@media (hover: hover) {
  .hint:hover .hintLabel {
    color: var(--text-hot);
  }
}
.disc {
  position: relative;
  display: grid;
  width: calc(17 * var(--px));
  height: calc(17 * var(--px));
}
.dip {
  display: grid;
}
.dip[data-pressed] {
  animation: dip 280ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
@keyframes dip {
  30% {
    scale: 0.8;
  }
  68% {
    scale: 1.08;
  }
}
.ring {
  position: absolute;
  inset: calc(-1 * var(--px));
  border: calc(1.5 * var(--px)) solid var(--text-hot);
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
  animation: ring 420ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes ring {
  from {
    opacity: 0.9;
    scale: 0.85;
  }
  to {
    opacity: 0;
    scale: 1.8;
  }
}

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

/* Offscreen or in a hidden tab, the endless motion stops. */
.root[data-paused] .cursor,
.root[data-paused] .burst {
  animation-play-state: paused;
}

/* Reduced motion: no slides, shakes, pops, sweeps or turning light; colour flashes stay. */
@media (prefers-reduced-motion: reduce) {
  .frame,
  .cursor,
  .plate[data-shake],
  .balance[data-shake],
  .burst,
  .heroGlyph,
  .about,
  .tick,
  .flash,
  .dip[data-pressed],
  .ring {
    animation: none;
    transition: none;
  }
  .flash,
  .ring {
    display: none;
  }
  .delta {
    animation: fade-out 1400ms steps(1, end) forwards;
  }
  .big[data-pop] {
    animation: owned-still 600ms ease-out;
  }
}
@keyframes fade-out {
  to {
    opacity: 0;
  }
}
@keyframes owned-still {
  from,
  40% {
    color: var(--buff);
  }
}
```

### shop-list/use-autoplay.ts

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

'use client';

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

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

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

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

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