# Item grid

> A game inventory page: category tabs, a grid of dark item tiles with power badges, an equipped fill and a gliding cursor, and the item’s stats below.

The page a player opens to choose what to carry into the next fight. Category tabs run along the top, with the open one lit, its name above it and a stick hint showing the way to the next. The items fill a grid of dark tiles: each shows its art, its power or how many there are, and a mark for any bonus; the one in hand is filled blue, a worn one warns in amber and a broken one is cracked and red. A bright frame glides from tile to tile, and the item under it shows its name, its stats against the one in hand and a line of description. Equip, eat, sort and change category with the controller’s keys, a gamepad or the pointer. The page brings its own dark plate, so it reads on a light or a dark page.

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

Category: Games · Menus.

## Anatomy

1. **Plate**: The dark plate the whole page sits on, its own colours in both schemes; every size inside is a multiple of `--u`, set from the component’s width.
2. **Category tabs**: The open category’s name, a real tab list of icons on a 46-unit pitch, and a lit line with a stick hint that slides under the open tab. Dots mark items not yet seen.
3. **Equipped tile**: A 67-unit dark tile with the item’s art; the one in hand is filled blue with a light rim (one per category).
4. **Power badge**: Attack or defence in a black badge flush with the tile’s lower right corner; amber and breathing when the item is about to break, red when broken.
5. **Cursor**: One cream frame with filled corners and a soft glow, laid over the grid and moved by a transform; it bumps against the grid’s edge.
6. **Empty slot**: A faint square for each unused slot, so the page keeps its shape (`slots`, 15 by default, five to a row).
7. **Details**: The item under the cursor: from 720 px a large view of it, then its name, its stat against the one in hand, bonus and wear chips, and a line of description.
8. **Button hints**: Sort (Y) and the use verb (A: Equip, Unequip, Eat, or a disabled Select), real buttons with their keys in `aria-keyshortcuts`.

## When to use

- A game’s inventory page, where the player stops to compare gear and choose what to carry, equip or eat.
- Items that split cleanly into a handful of categories, each browsed as one page of tiles by tabs or shoulder buttons.
- Gear the player reads by shape and a single number: art, power or amount, a bonus mark, and wear shown at a glance.
- Menus driven by a controller as much as a pointer: a cursor that moves tile by tile, face buttons for use and sort.
- Comparing the item under the cursor with the one in hand before committing, with a green or red arrow on the stat.

## When not to use

- One item looked at in depth, with its full stats and lore. Use an item card.
- Buying or selling, where price and the player’s money lead. Use a shop list.
- A handful of items always on screen during play. Use a quick selector or a HUD slot.
- Long lists read by name, such as quests or settings. Use a list or a quest log; a grid of icons hides the words.
- Files, products or photos in an ordinary app. Use a plain card grid with the app’s own controls.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/item-grid.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 { ItemGrid, type InventoryItem } from './item-grid';

const pack: InventoryItem[] = [
  { id: 'sword', name: 'Soldier Sword', category: 'weapons', icon: 'sword', power: 40, durability: 0.7, equipped: true },
  { id: 'axe', name: 'Woodcutter Axe', category: 'weapons', icon: 'axe', power: 90, durability: 0.18 },
  { id: 'apple', name: 'Apple', category: 'food', icon: 'apple', count: 12, hearts: 1 },
];

export function Inventory({ items, setItems }: { items: InventoryItem[]; setItems: (next: InventoryItem[]) => void }) {
  return (
    <ItemGrid
      items={items}
      onItemsChange={setItems}
      onSelect={(item) => console.log('on', item.name)}
    />
  );
}
```

## States

- **Browsing**: Weapons, the cursor on a weaker sword: its attack drops in red against the spear in hand. Dots over Materials and Food count new items.
- **In hand**: `defaultSelected="w4"`: the cursor on the equipped item; no comparison, and the hint reads Unequip.
- **Wearing out**: Under a quarter of its durability: the badge turns amber and breathes, a Worn chip shows, and this one beats the spear (green arrow).
- **Broken**: Durability 0: the tile is drained of colour and cracked, the badge is red, and A refuses it with a shake.
- **Sorted by power**: `defaultSort="power"`: strongest first, named under the grid. Y steps through type, power and wear.
- **Stacks**: Materials: no power, a ×count at the tile’s lower left instead, and the amount in the details. A has nothing to do (Select).
- **Food**: A heart on each tile and the hearts it restores in the details; A eats one, the last one leaves the grid.
- **Empty category**: No items in the open category: only empty slots, “Nothing here yet.”, and a disabled Select.
- **Narrow**: Under 720 px the page is one column, 425 units wide: the details move under the grid and the large view goes.
- **Light**: `colorScheme="light"`: the plate keeps its colours; only its edge and a warmer shadow follow the scheme.
- **Dark**: `colorScheme="dark"`: a faint light edge and a deeper shadow lift the plate off a dark page.

## How it works

- Every size is a multiple of one design unit, --u, worked out on the plate from the component’s width with a container query unit, so the page scales as one drawing and keeps the kit’s proportions: 67-unit tiles 10.5 units apart, a 29 × 19 badge flush with the tile’s lower right corner, 23-unit tab icons on a 46-unit pitch. Narrow, it is one column 425 units wide; from 720 px the details move beside the grid with a large view of the item (817 units).
- The cursor is a single element over the grid, placed by a transform from its column and row: a step glides in 150 ms with a small overshoot, after a sort it travels with its item in 420 ms, and on a new page it jumps. Against the grid’s edge it bumps 5 px towards the wall and springs back, played with the Web Animations API on the separate translate property so it never fights the glide. The frame is a 2-unit cream border with filled corners (four small gradients), a dark inner rim and a soft glow that breathes.
- Sorting steps through type, power and wear (or type and amount for things that stack). Each tile keeps its key, so React moves the same element; before paint, every tile’s old and new slot are compared and the tile is played from its old place to its new one (FLIP), slightly staggered. A new page cascades its tiles in from the top left corner, 22 ms per step, also in script, so a sort (which moves tiles in the DOM) never replays it.
- Equipping floods the tile blue from its centre (a layer scaling from 0.4 with a light rim), pops the tile to 0.9 and past 1, and sends out a ring of light; the other item of the category drains at the same time. A broken item refuses with a short shake. Eating takes one at a time, with the count bouncing, and the last one leaves the grid while the tiles after it slide back.
- The tab row’s lit line and the right stick hint slide under the open tab, its name swaps above it and its icon pops; dots over a tab count items not yet seen, and clear a moment after the category is opened. Each category keeps its own cursor and sort order.
- Input: arrows move the cursor while the grid has focus (Home and End jump to the ends); A, Enter or Space equip or eat; Y or S sort; Q and E, L and R, or Page Up and Page Down change category, and the tabs follow the arrows as a tab list. A connected gamepad drives it too (d-pad or stick with repeat, A, Y, the shoulders), and a click on a tile selects it, a second click uses it.
- Uncontrolled, it keeps its own items, and a demo plays a short browse while it is on screen and untouched: a refused broken sword, an equip, a bump against the edge, three sorts, a bow equipped, a run through the categories, two meals and back. Any real input hands it to the reader where the demo left it.
- All art is SVG drawn for this component: flat two-tone items with a thin dark outline laid on the diagonal, glows made of a wide faint copy of the shape (no blur filters), one-colour tab icons, and the face button and stick glyphs of the other game components. The plate, tiles and type keep their colours in both schemes, as a game menu does; only the plate’s edge, its shadow and the focus ring follow light-dark() tokens.

## API reference

- **items** (`InventoryItem[]`): The inventory, controlled: every category’s items in their own order ({ id, name, category, icon, power?, count?, durability?, equipped?, modifier?, hearts?, fresh?, description? }). Turns the demo off; changes come back through onItemsChange.
- **defaultItems** (`InventoryItem[]`, default `a sample pack`): Uncontrolled, the items it starts with.
- **onItemsChange** (`(items: InventoryItem[]) => void`): Called with the next items when the reader equips, unequips or eats something, or opens a category with new items.
- **category** (`'weapons' | 'bows' | 'shields' | 'armor' | 'materials' | 'food' | 'key'`): The open category, controlled; pair it with onCategoryChange.
- **defaultCategory** (`ItemCategory`, default `'weapons'`): Uncontrolled, the category it opens on.
- **onCategoryChange** (`(category: ItemCategory) => void`): Called when the reader asks for another category (keys, tabs or a gamepad).
- **onSelect** (`(item: InventoryItem) => void`): Called when the cursor lands on an item.
- **defaultSelected** (`string`): Uncontrolled, the id of the item the cursor opens on (in that item’s category). Turns the demo off.
- **defaultSort** (`'type' | 'power' | 'durability' | 'count'`, default `'type'`): The order the opening category starts in (power and durability for gear, count for things that stack). Turns the demo off.
- **slots** (`number`, default `15`): Slots drawn per page at least; empty ones show as faint squares, and more rows appear when a category has more items.
- **keys** (`boolean`, default `true`): Listen to the keyboard while the component has focus, and to a connected gamepad.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short browse: moves, a refused broken item, equip, sort, categories, eating. Any input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark, or the reader’s; without it, the page’s color-scheme. The plate keeps its colours on both; its edge, shadow and the focus ring follow it.
- **className** (`string`): Added to the root.

## Accessibility

- The categories are a real tab list (arrow keys move along it, the open tab is the one in the tab order), and the grid is its tab panel: a listbox whose options are the items, with the cursor’s item as the active descendant, so a screen reader reads each item as the cursor reaches it.
- Each item is named in full: name, attack or defence, how many, its bonus, and whether it is equipped, worn out or broken (“Woodcutter Axe, Attack 90, about to break”). Empty slots, the cursor and all art are hidden from assistive technology.
- A polite live region reports what actions do: equipping, eating (“Ate Apple. 11 left.”), a refusal for a broken item, the sort order and the category opened with its item count.
- Sort and Equip are also real buttons at the foot, with their controller keys in aria-keyshortcuts; the use button is disabled for items that cannot be used.
- Focus shows as an outline round the tab, the grid or the button that has it, blue on a light page and light blue on a dark one; the cursor frame shows the item inside the grid.
- Under reduced motion the demo does not play and every change lands at once: no glide, bump, pop, cascade or flight, and the worn badge does not breathe.

## Source

### item-grid/item-grid.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from 'react';
import {
  BrokenMark,
  Cracks,
  FaceGlyph,
  Heart,
  ItemArt,
  ModifierMark,
  StickGlyph,
  TabGlyph,
} from './glyphs';
import {
  CATEGORIES,
  DEFAULT_ITEMS,
  SORT_LABEL,
  isGear,
  sortItems,
  sortOrders,
  type InventoryItem,
  type ItemCategory,
  type SortOrder,
} from './items';
import { useAutoplay } from './use-autoplay';
import styles from './item-grid.module.css';

export type { InventoryItem, ItemCategory, SortOrder } from './items';
export type { ItemIcon, Modifier } from './glyphs';

/*
 * Item grid: the inventory page of an adventure game, where the player stops to choose what to
 * carry into the next fight. Category tabs run along the top, the items of the open category fill
 * a grid of dark tiles, and the item under the cursor shows its name, stats and a line of
 * description below (beside the grid, with a large view of the item, when there is room).
 *
 * - A tile shows the item's art, its power (weapons, bows, shields, armour) in a black badge at the
 *   lower right, or how many there are (×12) at the lower left, and a small mark at the upper left
 *   for a bonus. The item in hand is filled blue; one that is wearing out has an amber badge that
 *   breathes; a broken one is drained of colour, cracked, and its badge is red. Unused slots are
 *   faint empty squares.
 * - The cursor is one bright frame that glides from tile to tile (arrow keys, the d-pad or the
 *   stick of a gamepad, a click); against the grid's edge it bumps and springs back. A (Enter,
 *   Space, or clicking the tile under the cursor) equips or unequips gear, with a blue flood and a
 *   pop, or eats food, one at a time. Y (or S) sorts: type, then power, then wear (or amount); the
 *   tiles fly to their new places (FLIP) and the cursor stays on its item.
 * - L and R (or Q and E, or Page Up / Page Down, or the tabs) change category: the lit line and the
 *   stick hint slide under the new tab, its name swaps, the new tiles cascade in. Each category
 *   keeps its own cursor and sort. A dot over a tab marks items not yet seen.
 * - Uncontrolled, it keeps its own items, and a demo plays a short browse (move, equip, bump,
 *   sort, change category, eat) while it is on screen and untouched; any input hands it to the
 *   reader. Given `items`, it shows those and reports changes through `onItemsChange`.
 * - The inventory brings its own dark plate, so it reads on a light page and a dark one, with no
 *   scene behind it; only the plate's edge and shadow follow the colour scheme.
 */

const COLUMNS = 5;
const TAP = 140;
/** Moves held on a gamepad repeat after this long, then every REPEAT ms. */
const HOLD = 320;
const REPEAT = 110;

type Action = 'left' | 'right' | 'up' | 'down' | 'use' | 'sort' | 'prev' | 'next';
type Bump = 'left' | 'right' | 'up' | 'down';

const DEMO: { at: number; action: Action }[] = [
  { at: 900, action: 'right' },
  { at: 1500, action: 'right' },
  { at: 2300, action: 'use' },
  { at: 3100, action: 'up' },
  { at: 3900, action: 'use' },
  { at: 4700, action: 'right' },
  { at: 5200, action: 'right' },
  { at: 5800, action: 'right' },
  { at: 6600, action: 'down' },
  { at: 7400, action: 'sort' },
  { at: 8800, action: 'sort' },
  { at: 10200, action: 'sort' },
  { at: 11400, action: 'next' },
  { at: 12100, action: 'right' },
  { at: 12800, action: 'use' },
  { at: 13800, action: 'next' },
  { at: 14300, action: 'next' },
  { at: 14800, action: 'next' },
  { at: 15500, action: 'next' },
  { at: 16300, action: 'right' },
  { at: 17100, action: 'use' },
  { at: 17900, action: 'use' },
  { at: 19000, action: 'prev' },
  { at: 19400, action: 'prev' },
  { at: 19800, action: 'prev' },
  { at: 20200, action: 'prev' },
  { at: 20600, action: 'prev' },
];
const DEMO_LENGTH = 23000;

/** A standard-layout gamepad's buttons: face right (A), face left (Y), the shoulders, the d-pad. */
const PAD: [number, Action][] = [
  [1, 'use'],
  [2, 'sort'],
  [4, 'prev'],
  [5, 'next'],
  [12, 'up'],
  [13, 'down'],
  [14, 'left'],
  [15, 'right'],
];

const STAT_NAME: Record<ItemCategory, string> = {
  weapons: 'Attack',
  bows: 'Attack',
  shields: 'Defense',
  armor: 'Defense',
  materials: 'Amount',
  food: 'Hearts',
  key: 'Amount',
};

const MODIFIER_LABEL = {
  attack: 'Attack up',
  durability: 'Durability up',
  critical: 'Critical hit',
  quick: 'Quick shot',
  hearts: 'Extra hearts',
  stamina: 'Stamina up',
} as const;

const label = (category: ItemCategory) => CATEGORIES.find((c) => c.id === category)!.label;
const broken = (item: InventoryItem) => isGear(item.category) && item.durability === 0;
const worn = (item: InventoryItem) =>
  isGear(item.category) &&
  item.durability !== undefined &&
  item.durability > 0 &&
  item.durability < 0.25;

/** What A does to this item, if anything. */
function verbFor(item: InventoryItem | undefined) {
  if (!item) return null;
  if (isGear(item.category)) return item.equipped ? 'Unequip' : 'Equip';
  if (item.category === 'food') return 'Eat';
  return null;
}

function describe(item: InventoryItem) {
  const parts = [item.name];
  if (item.power !== undefined) parts.push(`${STAT_NAME[item.category]} ${item.power}`);
  if (item.count && item.count > 1) parts.push(`${item.count} held`);
  if (item.hearts) parts.push(`restores ${item.hearts} hearts`);
  if (item.modifier) parts.push(MODIFIER_LABEL[item.modifier.kind]);
  if (broken(item)) parts.push('broken');
  else if (worn(item)) parts.push('about to break');
  if (item.equipped) parts.push('equipped');
  return parts.join(', ');
}

function firstOf(items: InventoryItem[]) {
  const cursor: Partial<Record<ItemCategory, string>> = {};
  for (const item of items) cursor[item.category] ??= item.id;
  return cursor;
}

const START_CURSOR = { ...firstOf(DEFAULT_ITEMS), weapons: 'w6' };

export function ItemGrid({
  items,
  defaultItems = DEFAULT_ITEMS,
  onItemsChange,
  category,
  defaultCategory = 'weapons',
  onCategoryChange,
  onSelect,
  defaultSelected,
  defaultSort,
  slots = 15,
  keys = true,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The inventory, controlled: every category's items, in their own order. Turns the demo off. */
  items?: InventoryItem[];
  /** Uncontrolled, the items it starts with. */
  defaultItems?: InventoryItem[];
  /** Called with the next items when the reader equips, eats or opens a category with new items. */
  onItemsChange?: (items: InventoryItem[]) => void;
  /** The open category, controlled. */
  category?: ItemCategory;
  /** Uncontrolled, the category it opens on. */
  defaultCategory?: ItemCategory;
  /** Called when the reader asks for another category. */
  onCategoryChange?: (category: ItemCategory) => void;
  /** Called when the cursor lands on an item. */
  onSelect?: (item: InventoryItem) => void;
  /** Uncontrolled, the id of the item the cursor opens on in its category. Turns the demo off. */
  defaultSelected?: string;
  /** The order the open category starts in. Turns the demo off. */
  defaultSort?: SortOrder;
  /** Slots drawn per page at least (a multiple of 5 is a full grid); more rows appear as needed. */
  slots?: number;
  /** Listen to the keyboard while focused, and to a connected gamepad. */
  keys?: boolean;
  /** While on screen and untouched, a short browse: moves, equip, sort, categories, eating. */
  autoplay?: boolean;
  /** Light or dark, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(8000);
  const uid = useId().replace(/[^a-zA-Z0-9-]/g, '');
  const controlled = items !== undefined;
  const demo =
    autoplay &&
    playing &&
    !controlled &&
    category === undefined &&
    defaultSelected === undefined &&
    defaultSort === undefined;

  const [ownItems, setOwnItems] = useState(defaultItems);
  const [ownCategory, setOwnCategory] = useState<ItemCategory>(defaultCategory);
  const [cursor, setCursor] = useState<Partial<Record<ItemCategory, string>>>(() => {
    const start = defaultItems === DEFAULT_ITEMS ? START_CURSOR : firstOf(defaultItems);
    const chosen = defaultItems.find((item) => item.id === defaultSelected);
    return chosen ? { ...start, [chosen.category]: chosen.id } : start;
  });
  const [orders, setOrders] = useState<Partial<Record<ItemCategory, SortOrder>>>(() =>
    defaultSort ? { [category ?? defaultCategory]: defaultSort } : {},
  );
  const [message, setMessage] = useState('');
  const [hit, setHit] = useState<{ use: number; sort: number; tab: number }>({
    use: 0,
    sort: 0,
    tab: 0,
  });
  const [ran, setRan] = useState(false);

  const all = items ?? ownItems;
  const open = category ?? ownCategory;
  const order = orders[open] ?? 'type';
  const base = all.filter((item) => item.category === open).map((item) => item.id);
  const shown = sortItems(
    all.filter((item) => item.category === open),
    order,
    base,
  );
  const index = Math.max(
    0,
    shown.findIndex((item) => item.id === cursor[open]),
  );
  const selected = shown[index];
  const held = shown.find((item) => item.equipped);
  const verb = verbFor(selected);
  const total = Math.max(slots, Math.ceil(Math.max(shown.length, 1) / COLUMNS) * COLUMNS);
  const tabIndex = CATEGORIES.findIndex((c) => c.id === open);

  const commit = (next: InventoryItem[]) => {
    if (!controlled) setOwnItems(next);
    onItemsChange?.(next);
  };

  // ---- Motion helpers (the Web Animations API; nothing moves under reduced motion) ----
  const tiles = useRef(new Map<string, HTMLElement>());
  const cursorRef = useRef<HTMLDivElement>(null);
  const gridRef = useRef<HTMLDivElement>(null);
  const reducedRef = useRef(reduced);
  useEffect(() => {
    reducedRef.current = reduced;
  }, [reduced]);
  const animate = (
    element: Element | null | undefined,
    frames: Keyframe[],
    options: KeyframeAnimationOptions,
  ) => {
    if (!element || reducedRef.current || !element.animate) return;
    element.animate(frames, options);
  };
  const bump = (direction: Bump) => {
    const [x, y] = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] }[direction];
    const step = (n: number) => `${x * n}px ${y * n}px`;
    animate(
      cursorRef.current,
      [
        { translate: '0 0' },
        { translate: step(5) },
        { translate: step(-1.5) },
        { translate: '0 0' },
      ],
      { duration: 240, easing: 'ease-out' },
    );
  };
  const pop = (id: string, flash: 'equip' | 'eat' | 'deny') => {
    const tile = tiles.current.get(id);
    if (flash === 'deny') {
      animate(
        tile,
        [
          { translate: '0 0' },
          { translate: '-3px 0' },
          { translate: '3px 0' },
          { translate: '-2px 0' },
          { translate: '0 0' },
        ],
        { duration: 260, easing: 'linear' },
      );
      return;
    }
    animate(
      tile,
      [{ scale: 1 }, { scale: 0.9, offset: 0.3 }, { scale: 1.05, offset: 0.7 }, { scale: 1 }],
      { duration: 300, easing: 'ease-out' },
    );
    animate(
      tile?.querySelector(`.${styles.ring}`),
      [
        { opacity: 0.9, scale: 0.9 },
        { opacity: 0, scale: 1.35 },
      ],
      { duration: 420, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)' },
    );
  };

  // ---- Actions (keys, pad, pointer, demo) ----
  const move = (dx: number, dy: number, direction: Bump) => {
    if (!shown.length) return bump(direction);
    const col = index % COLUMNS;
    const row = Math.floor(index / COLUMNS);
    let next = index;
    if (dx) {
      const target = col + dx;
      if (target < 0 || target >= COLUMNS || index + dx >= shown.length) return bump(direction);
      next = index + dx;
    } else {
      const target = row + dy;
      const rows = Math.ceil(shown.length / COLUMNS);
      if (target < 0 || target >= rows) return bump(direction);
      next = Math.min(target * COLUMNS + col, shown.length - 1);
    }
    select(shown[next]);
  };
  const select = (item: InventoryItem) => {
    setCursor((now) => ({ ...now, [item.category]: item.id }));
    onSelect?.(item);
  };
  const use = () => {
    setHit((now) => ({ ...now, use: now.use + 1 }));
    if (!selected) return;
    if (isGear(selected.category)) {
      if (broken(selected)) {
        pop(selected.id, 'deny');
        setMessage(`${selected.name} is broken and can't be equipped.`);
        return;
      }
      const on = !selected.equipped;
      commit(
        all.map((item) =>
          item.category !== selected.category
            ? item
            : { ...item, equipped: item.id === selected.id ? on : false },
        ),
      );
      pop(selected.id, 'equip');
      setMessage(on ? `${selected.name} equipped.` : `${selected.name} unequipped.`);
      return;
    }
    if (selected.category === 'food') {
      const left = (selected.count ?? 1) - 1;
      pop(selected.id, 'eat');
      animate(
        tiles.current.get(selected.id)?.querySelector(`.${styles.count}`),
        [{ scale: 1.35, color: '#f2b33d' }, { scale: 1 }],
        { duration: 300, easing: 'cubic-bezier(0.3, 1.6, 0.5, 1)' },
      );
      if (left > 0) {
        commit(all.map((item) => (item.id === selected.id ? { ...item, count: left } : item)));
        setMessage(`Ate ${selected.name}. ${left} left.`);
      } else {
        commit(all.filter((item) => item.id !== selected.id));
        const next = shown[index + 1] ?? shown[index - 1];
        if (next) setCursor((now) => ({ ...now, [open]: next.id }));
        setMessage(`Ate the last ${selected.name}.`);
      }
      return;
    }
    pop(selected.id, 'deny');
  };
  const sort = () => {
    setHit((now) => ({ ...now, sort: now.sort + 1 }));
    const list = sortOrders(open);
    const next = list[(list.indexOf(order) + 1) % list.length];
    setOrders((now) => ({ ...now, [open]: next }));
    setMessage(`${label(open)}: ${SORT_LABEL[next].toLowerCase()}.`);
  };
  const switchTo = (next: ItemCategory) => {
    if (next === open) return;
    setHit((now) => ({ ...now, tab: now.tab + 1 }));
    if (category === undefined) setOwnCategory(next);
    onCategoryChange?.(next);
    const count = all.filter((item) => item.category === next).length;
    setMessage(`${label(next)}, ${count ? `${count} items` : 'empty'}.`);
  };
  const step = (delta: number) =>
    switchTo(CATEGORIES[(tabIndex + delta + CATEGORIES.length) % CATEGORIES.length].id);

  const act = useEffectEvent((action: Action) => {
    if (action === 'left') move(-1, 0, 'left');
    else if (action === 'right') move(1, 0, 'right');
    else if (action === 'up') move(0, -1, 'up');
    else if (action === 'down') move(0, 1, 'down');
    else if (action === 'use') use();
    else if (action === 'sort') sort();
    else if (action === 'prev') step(-1);
    else step(1);
  });

  // Opening a category marks its new items as seen (after a beat, so the dot is noticed).
  const fresh = all.some((item) => item.category === open && item.fresh);
  const clearFresh = useEffectEvent(() =>
    commit(
      all.map((item) => (item.category === open && item.fresh ? { ...item, fresh: false } : item)),
    ),
  );
  useEffect(() => {
    if (!fresh) return;
    const timer = window.setTimeout(clearFresh, 900);
    return () => window.clearTimeout(timer);
  }, [fresh, open]);

  // ---- The demo ----
  const timers = useRef(new Set<number>());
  const later = (ms: number, run: () => void) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      run();
    }, ms);
    timers.current.add(timer);
  };
  const reset = useEffectEvent(() => {
    setOwnItems(defaultItems);
    setOwnCategory(defaultCategory);
    setCursor(defaultItems === DEFAULT_ITEMS ? START_CURSOR : firstOf(defaultItems));
    setOrders({});
  });
  useEffect(() => {
    if (!demo) return;
    const run = () => {
      reset();
      for (const { at, action } of DEMO) later(at, () => act(action));
    };
    setRan(true);
    run();
    const loop = window.setInterval(run, DEMO_LENGTH);
    const pending = timers.current;
    return () => {
      window.clearInterval(loop);
      pending.forEach((timer) => window.clearTimeout(timer));
      pending.clear();
    };
  }, [demo]);

  // Any real input hands it to the reader, from where the demo left it.
  const handOver = useCallback(() => {
    take();
    if (ran) setRan(false);
  }, [take, ran]);

  // ---- Keys ----
  const onKeyDown = (event: React.KeyboardEvent) => {
    if (!keys || event.metaKey || event.ctrlKey || event.altKey) return;
    const target = event.target as HTMLElement;
    const inTabs = Boolean(target.closest('[role="tablist"]'));
    const inGrid = Boolean(target.closest('[role="listbox"]'));
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    let action: Action | null = null;
    if (key === 'q' || key === 'l' || key === 'PageUp') action = 'prev';
    else if (key === 'e' || key === 'r' || key === 'PageDown') action = 'next';
    else if (key === 'y' || key === 's') action = 'sort';
    else if (inTabs && key === 'ArrowLeft') action = 'prev';
    else if (inTabs && key === 'ArrowRight') action = 'next';
    else if (inGrid) {
      if (key === 'ArrowLeft') action = 'left';
      else if (key === 'ArrowRight') action = 'right';
      else if (key === 'ArrowUp') action = 'up';
      else if (key === 'ArrowDown') action = 'down';
      else if (key === 'a' || key === 'Enter' || key === ' ') action = 'use';
      else if (key === 'Home' && shown[0]) {
        event.preventDefault();
        handOver();
        select(shown[0]);
        return;
      } else if (key === 'End' && shown.length) {
        event.preventDefault();
        handOver();
        select(shown[shown.length - 1]);
        return;
      }
    }
    if (!action) return;
    event.preventDefault();
    handOver();
    act(action);
    if (inTabs && (action === 'prev' || action === 'next')) focusTab.current = true;
  };

  // Arrowing along the tabs keeps the focus on the selected tab.
  const focusTab = useRef(false);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  useEffect(() => {
    if (!focusTab.current) return;
    focusTab.current = false;
    tabRefs.current[tabIndex]?.focus();
  }, [tabIndex]);

  // ---- A gamepad: polled once one is connected (browsers report one only after a gesture) ----
  const [pad, setPad] = useState(false);
  useEffect(() => {
    if (!keys || !('getGamepads' in navigator)) return;
    const connected = () => setPad(navigator.getGamepads().some(Boolean));
    connected();
    window.addEventListener('gamepadconnected', connected);
    window.addEventListener('gamepaddisconnected', connected);
    return () => {
      window.removeEventListener('gamepadconnected', connected);
      window.removeEventListener('gamepaddisconnected', connected);
    };
  }, [keys]);
  const downSince = useRef(new Map<Action, number>());
  const poll = useEffectEvent((now: number) => {
    const down = new Set<Action>();
    navigator.getGamepads().forEach((gamepad) => {
      if (!gamepad) return;
      for (const [button, action] of PAD) if (gamepad.buttons[button]?.pressed) down.add(action);
      const [x = 0, y = 0] = gamepad.axes;
      if (y < -0.5) down.add('up');
      if (y > 0.5) down.add('down');
      if (x < -0.5) down.add('left');
      if (x > 0.5) down.add('right');
    });
    for (const action of [...downSince.current.keys()])
      if (!down.has(action)) downSince.current.delete(action);
    for (const action of down) {
      const since = downSince.current.get(action);
      const moving =
        action === 'left' || action === 'right' || action === 'up' || action === 'down';
      if (since === undefined) {
        downSince.current.set(action, now);
        handOver();
        act(action);
      } else if (moving && now - since > HOLD) {
        downSince.current.set(action, now - HOLD + REPEAT);
        act(action);
      }
    }
  });
  useEffect(() => {
    if (!pad || !visible) return;
    let frame = requestAnimationFrame(function tick(now) {
      poll(now);
      frame = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(frame);
  }, [pad, visible]);

  // ---- FLIP: after a sort or a removal, tiles glide from their old slot to the new one ----
  const lastSlots = useRef<{ category: ItemCategory; slots: Map<string, number> } | null>(null);
  useLayoutEffect(() => {
    const before = lastSlots.current;
    const now = new Map(shown.map((item, i) => [item.id, i]));
    lastSlots.current = { category: open, slots: now };
    if (!before || before.category !== open) {
      // A new page: its tiles cascade in, corner first. (In script, not CSS, so that a sort, which
      // moves tiles in the DOM, does not replay it.)
      const grid = gridRef.current;
      if (!grid) return;
      for (const [i, element] of [...grid.children].entries()) {
        if (element === cursorRef.current) continue;
        animate(
          element,
          [
            { opacity: 0, scale: 0.86 },
            { opacity: 1, scale: 1 },
          ],
          {
            duration: 300,
            easing: 'cubic-bezier(0.2, 0.9, 0.3, 1.15)',
            delay: ((i % COLUMNS) + Math.floor(i / COLUMNS)) * 22,
            fill: 'backwards',
          },
        );
      }
      return;
    }
    const first = tiles.current.get(shown[0]?.id ?? '');
    const grid = first?.parentElement;
    if (!first || !grid) return;
    const pitchX = first.offsetWidth + parseFloat(getComputedStyle(grid).columnGap || '0');
    const pitchY = first.offsetHeight + parseFloat(getComputedStyle(grid).rowGap || '0');
    for (const [id, slot] of now) {
      const was = before.slots.get(id);
      if (was === undefined || was === slot) continue;
      const dx = ((was % COLUMNS) - (slot % COLUMNS)) * pitchX;
      const dy = (Math.floor(was / COLUMNS) - Math.floor(slot / COLUMNS)) * pitchY;
      animate(
        tiles.current.get(id),
        [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
        {
          duration: 420,
          easing: 'cubic-bezier(0.25, 0.9, 0.3, 1.04)',
          delay: (slot % 7) * 14,
          fill: 'backwards',
        },
      );
    }
  });

  // The cursor glides; after a sort it travels with its tile (slower); on a new page it jumps.
  const lastOpen = useRef(open);
  const lastSort = useRef(hit.sort);
  const glide =
    lastOpen.current !== open ? 'jump' : lastSort.current !== hit.sort ? 'long' : 'short';
  useEffect(() => {
    lastOpen.current = open;
    lastSort.current = hit.sort;
  });

  // What screen readers hear: actions, by a polite live region (moves are read by the listbox).
  const live = message;
  const lineX = `calc(${tabIndex} * var(--tab-pitch))`;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-reduced={reduced || undefined}
      onPointerDown={handOver}
      onKeyDown={onKeyDown}
    >
      <section className={styles.plate} data-part="plate" aria-label="Inventory">
        <header
          className={styles.head}
          data-part="tabs"
          style={{ '--tab': tabIndex } as CSSProperties}
        >
          <p className={styles.heading} aria-hidden="true">
            <span key={open} className={styles.headingWord}>
              {label(open)}
            </span>
          </p>
          <div className={styles.tabs} role="tablist" aria-label="Item categories">
            {CATEGORIES.map((c, i) => {
              const news = all.filter((item) => item.category === c.id && item.fresh).length;
              const active = c.id === open;
              return (
                <button
                  key={c.id}
                  ref={(element) => {
                    tabRefs.current[i] = element;
                  }}
                  type="button"
                  role="tab"
                  id={`${uid}-tab-${c.id}`}
                  aria-selected={active}
                  aria-controls={`${uid}-panel`}
                  aria-label={news ? `${c.label}, ${news} new` : c.label}
                  tabIndex={active ? 0 : -1}
                  className={styles.tab}
                  data-part="tab"
                  data-active={active || undefined}
                  onClick={() => {
                    handOver();
                    switchTo(c.id);
                  }}
                >
                  <span key={active ? hit.tab : 'off'} className={styles.tabIcon}>
                    <TabGlyph icon={c.id} className={styles.tabSvg} />
                  </span>
                  {news > 0 && (
                    <span className={styles.dots} aria-hidden="true">
                      {Array.from({ length: Math.min(news, 3) }, (_, d) => (
                        <span key={d} className={styles.dot} />
                      ))}
                    </span>
                  )}
                </button>
              );
            })}
          </div>
          <div className={styles.rail} aria-hidden="true" style={{ '--x': lineX } as CSSProperties}>
            <span className={styles.railLit} />
            <span className={styles.railStick}>
              <StickGlyph className={styles.stickSvg} />
            </span>
          </div>
        </header>

        <div
          className={styles.panel}
          id={`${uid}-panel`}
          role="tabpanel"
          aria-labelledby={`${uid}-tab-${open}`}
        >
          <div
            ref={gridRef}
            className={styles.grid}
            data-part="grid"
            role="listbox"
            tabIndex={0}
            aria-label={`${label(open)}, ${SORT_LABEL[order].toLowerCase()}`}
            aria-activedescendant={selected ? `${uid}-item-${selected.id}` : undefined}
            aria-keyshortcuts={keys ? 'A Y Q E' : undefined}
          >
            {Array.from({ length: total }, (_, i) => {
              const item = shown[i];
              if (!item)
                return (
                  <div
                    key={`${open}-empty-${i}`}
                    className={styles.empty}
                    data-part="empty"
                    aria-hidden="true"
                  />
                );
              const isBroken = broken(item);
              return (
                <div
                  key={`${open}-${item.id}`}
                  ref={(element) => {
                    if (element) tiles.current.set(item.id, element);
                    else tiles.current.delete(item.id);
                  }}
                  id={`${uid}-item-${item.id}`}
                  role="option"
                  aria-selected={i === index}
                  aria-label={describe(item)}
                  className={styles.tile}
                  data-part="tile"
                  data-equipped={item.equipped || undefined}
                  data-broken={isBroken || undefined}
                  data-worn={worn(item) || undefined}
                  onClick={() => {
                    if (i === index) use();
                    else select(item);
                  }}
                >
                  <span className={styles.fill} aria-hidden="true" />
                  <span className={styles.ring} aria-hidden="true" />
                  <ItemArt icon={item.icon} className={styles.art} />
                  {isBroken && <Cracks className={styles.cracks} />}
                  {isBroken ? (
                    <BrokenMark className={styles.mark} />
                  ) : (
                    item.modifier && (
                      <ModifierMark kind={item.modifier.kind} className={styles.mark} />
                    )
                  )}
                  {item.hearts !== undefined && <Heart className={styles.heal} fill="#a8242a" />}
                  {item.power !== undefined && (
                    <span className={styles.badge} data-part="badge">
                      {item.power}
                    </span>
                  )}
                  {item.count !== undefined && item.count > 1 && (
                    <span className={styles.count} data-part="count">
                      ×{item.count}
                    </span>
                  )}
                </div>
              );
            })}
            {selected && (
              <div
                ref={cursorRef}
                className={styles.cursor}
                data-part="cursor"
                data-glide={glide}
                aria-hidden="true"
                style={
                  {
                    '--col': index % COLUMNS,
                    '--row': Math.floor(index / COLUMNS),
                  } as CSSProperties
                }
              />
            )}
          </div>
        </div>

        <div className={styles.side} data-part="side">
          <div className={styles.preview} aria-hidden="true">
            {selected && (
              <span
                key={`${selected.id}-${selected.equipped ? 'on' : 'off'}`}
                className={styles.previewArt}
              >
                <ItemArt icon={selected.icon} className={styles.previewSvg} />
              </span>
            )}
          </div>
          <Info item={selected} held={held} category={open} />
        </div>

        <div className={styles.sortNote} data-part="sort-note" aria-hidden="true">
          <span key={`${open}-${order}`} className={styles.sortWord}>
            {SORT_LABEL[order]}
          </span>
        </div>

        <div className={styles.hints} data-part="hints">
          <button
            type="button"
            className={styles.hint}
            aria-keyshortcuts={keys ? 'Y' : undefined}
            onClick={() => {
              handOver();
              sort();
            }}
          >
            <span>Sort</span>
            <span key={hit.sort} className={styles.hintGlyph} data-hit={hit.sort || undefined}>
              <FaceGlyph letter="Y" className={styles.faceSvg} />
            </span>
          </button>
          <button
            type="button"
            className={styles.hint}
            disabled={!verb}
            aria-keyshortcuts={keys ? 'A' : undefined}
            onClick={() => {
              handOver();
              use();
            }}
          >
            <span>{verb ?? 'Select'}</span>
            <span key={hit.use} className={styles.hintGlyph} data-hit={hit.use || undefined}>
              <FaceGlyph letter="A" className={styles.faceSvg} />
            </span>
          </button>
        </div>
      </section>
      <p className={styles.live} aria-live="polite">
        {live}
      </p>
    </div>
  );
}

/** The selected item's name, stats and description. */
function Info({
  item,
  held,
  category,
}: {
  item: InventoryItem | undefined;
  held: InventoryItem | undefined;
  category: ItemCategory;
}) {
  if (!item)
    return (
      <div className={styles.info} data-part="info">
        <p className={styles.none}>Nothing here yet.</p>
      </div>
    );
  const compare =
    held && held.id !== item.id && item.power !== undefined && held.power !== undefined;
  const trend = compare ? Math.sign(item.power! - held.power!) : 0;
  return (
    <div className={styles.info} data-part="info">
      <div key={item.id} className={styles.infoBody}>
        <h3 className={styles.name}>{item.name}</h3>
        <div className={styles.stats} data-part="stats">
          {item.power !== undefined && (
            <>
              <TabGlyph icon={category} className={styles.statIcon} />
              {compare && (
                <>
                  <span className={styles.box} title={`Equipped: ${held.power}`}>
                    {held.power}
                  </span>
                  <span className={styles.arrow} aria-hidden="true" />
                </>
              )}
              <span
                className={styles.box}
                data-trend={trend > 0 ? 'up' : trend < 0 ? 'down' : undefined}
                aria-label={`${STAT_NAME[category]} ${item.power}${compare ? `, equipped ${held.power}` : ''}`}
              >
                {item.power}
              </span>
            </>
          )}
          {item.hearts !== undefined && (
            <span className={styles.box} data-hearts aria-label={`Restores ${item.hearts} hearts`}>
              {Array.from({ length: Math.min(item.hearts, 5) }, (_, h) => (
                <Heart key={h} className={styles.boxHeart} />
              ))}
            </span>
          )}
          {item.power === undefined && item.hearts === undefined && (
            <span className={styles.box} aria-label={`${item.count ?? 1} held`}>
              ×{item.count ?? 1}
            </span>
          )}
          {item.modifier && (
            <span className={styles.chip}>
              <ModifierMark kind={item.modifier.kind} className={styles.chipIcon} />
              {MODIFIER_LABEL[item.modifier.kind]}
              {item.modifier.value ? ` + ${item.modifier.value}` : ''}
            </span>
          )}
          {broken(item) && (
            <span className={styles.chip} data-tone="broken">
              Broken
            </span>
          )}
          {worn(item) && (
            <span className={styles.chip} data-tone="worn">
              Worn
            </span>
          )}
        </div>
        {item.description && <p className={styles.text}>{item.description}</p>}
      </div>
    </div>
  );
}
```

### item-grid/item-grid.module.css

```css
/* Item grid (item-grid.tsx): an inventory page on its own dark plate, centred on a plain stage.

   Every size is a multiple of --u, one design unit (1 px at full size), set on the plate from the
   component's width (a container query unit), so the page scales as one drawing and keeps the
   kit's proportions: 67-unit tiles 10.5 apart, a 29 × 19 badge, a 46-unit tab pitch. Narrow, the
   page is one column (tabs, grid, details, hints: 425 units wide); from 720 px the details move
   beside the grid with a large view of the item (817 units).

   Colours are the tokens below. The plate and everything on it keep their colours in both schemes,
   as a game menu does: dark tiles, cream type, a blue equipped fill, a cream cursor. Only the
   plate's edge and shadow and the focus ring follow the scheme, through light-dark().

   Motion: the cursor glides by a transform transition (an overshoot for a step, slower after a
   sort, none on a new page); a bump, a pop, a refusal shake and the sort's FLIP are played by the
   script with the Web Animations API; tiles cascade in, words swap, the equipped fill floods and
   the worn badge breathes as CSS animations and transitions. */

.root {
  /* The plate. */
  --plate: #191a17;
  --plate-top: #1f201c;
  --plate-edge: rgb(255 255 255 / 0);
  --plate-shade: rgb(30 26 16 / 0.28);
  /* Type and lines. */
  --ink: #e9e1d1;
  --ink-soft: #d3cdbd;
  --ink-dim: #8f8a7e;
  --tab-off: #6f6c63;
  --rule: #5d5a52;
  /* Tiles. */
  --tile: #141413;
  --tile-edge: #2b2b29;
  --badge: #080808;
  --badge-edge: #333331;
  --empty: rgb(233 225 209 / 0.13);
  --empty-edge: rgb(233 225 209 / 0.2);
  --held: #0390f9;
  --held-edge: #235579;
  --held-rim: #2fa4fa;
  --held-badge: #043a63;
  --cursor: #efe9d3;
  --cursor-rim: #5a5338;
  --cursor-glow: rgb(255 246 214 / 0.55);
  --warn: #f2b33d;
  --bad: #ff6b5a;
  --bad-ground: #3a0f0b;
  --good: #8be07a;
  /* Details. */
  --panel: rgb(6 8 5 / 0.5);
  --panel-edge: rgb(200 196 182 / 0.5);
  --box: #0c0d0b;
  --box-edge: #545654;
  --glyph: #e1ded1;
  --glyph-ink: #24221d;
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 360px;
  padding: 28px 16px;
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate-edge: light-dark(rgb(255 255 255 / 0), rgb(255 255 255 / 0.1));
    --plate-shade: light-dark(rgb(30 26 16 / 0.28), rgb(0 0 0 / 0.5));
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* ---- The plate and its layout ---- */

.plate {
  --u: min(1px, calc((100cqi - 32px) / 425));
  --tile-size: calc(67 * var(--u));
  --gap: calc(10.5 * var(--u));
  --pitch: calc(var(--tile-size) + var(--gap));
  --tab-pitch: calc(46 * var(--u));
  position: relative;
  display: grid;
  grid-template-columns: calc(377 * var(--u));
  grid-template-areas:
    'head'
    'panel'
    'side'
    'foot';
  align-items: start;
  box-sizing: border-box;
  padding: calc(22 * var(--u)) calc(24 * var(--u)) calc(20 * var(--u));
  border-radius: calc(10 * var(--u));
  background: linear-gradient(180deg, var(--plate-top), var(--plate) 40%);
  box-shadow:
    0 0 0 1px var(--plate-edge),
    0 calc(14 * var(--u)) calc(36 * var(--u)) var(--plate-shade);
  font-size: calc(14 * var(--u));
  line-height: 1.2;
  user-select: none;
  -webkit-user-select: none;
}
@container (min-width: 720px) {
  .plate {
    --u: min(1px, calc((100cqi - 32px) / 817));
    grid-template-columns: calc(377 * var(--u)) calc(360 * var(--u));
    grid-template-rows: auto auto 1fr auto;
    grid-template-areas:
      'head side'
      'panel side'
      '. side'
      'foot foot';
    column-gap: calc(32 * var(--u));
  }
}

/* ---- Tabs ---- */

.head {
  grid-area: head;
  position: relative;
  height: calc(71 * var(--u));
}
.heading {
  position: absolute;
  left: calc(31 * var(--u));
  top: 0;
  margin: 0;
  font-size: calc(13.5 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: calc(18 * var(--u));
  white-space: nowrap;
  translate: calc(var(--tab) * var(--tab-pitch)) 0;
  transition: translate 240ms cubic-bezier(0.3, 1.2, 0.5, 1);
}
.headingWord {
  display: block;
  animation: word-in 220ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
@keyframes word-in {
  from {
    opacity: 0;
    translate: 0 calc(5 * var(--u));
  }
}
.tabs {
  position: absolute;
  left: calc(34 * var(--u));
  top: calc(21 * var(--u));
  display: flex;
}
.tab {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--tab-pitch);
  height: calc(24 * var(--u));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--tab-off);
  cursor: pointer;
  transition: color 160ms ease;
}
.tab:hover {
  color: #9a968b;
}
.tab[data-active] {
  color: var(--ink);
}
.tab:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.tabIcon {
  display: block;
}
.tab[data-active] .tabIcon {
  animation: tab-pop 300ms cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
@keyframes tab-pop {
  from {
    scale: 0.82;
  }
}
.tabSvg {
  display: block;
  width: calc(23 * var(--u));
  height: calc(23 * var(--u));
}
/* New items: small dots over the tab. */
.dots {
  position: absolute;
  left: 50%;
  top: calc(-9 * var(--u));
  display: flex;
  gap: calc(2.6 * var(--u));
  translate: -50% 0;
}
.dot {
  width: calc(2.6 * var(--u));
  height: calc(2.6 * var(--u));
  border-radius: 50%;
  background: var(--ink-soft);
  animation: dot-in 300ms cubic-bezier(0.3, 1.6, 0.5, 1) both;
}
@keyframes dot-in {
  from {
    scale: 0;
  }
}

/* The rail under the tabs: lit up to the stick hint, which sits just past the open tab. */
.rail {
  position: absolute;
  left: calc(32 * var(--u));
  top: calc(56 * var(--u));
  width: calc(316 * var(--u));
  height: 0;
}
.rail::before,
.railLit {
  content: '';
  position: absolute;
  left: 0;
  top: calc(-0.75 * var(--u));
  height: calc(1.5 * var(--u));
  border-radius: 1px;
}
.rail::before {
  right: 0;
  background: var(--rule);
}
.railLit {
  width: calc(var(--x) + 28 * var(--u));
  background: var(--ink-soft);
  transition: width 260ms cubic-bezier(0.3, 1.15, 0.5, 1);
}
.railStick {
  position: absolute;
  left: calc(var(--x) + 28 * var(--u));
  top: calc(-11 * var(--u));
  transition: left 260ms cubic-bezier(0.3, 1.15, 0.5, 1);
}
.stickSvg {
  display: block;
  width: calc(28.4 * var(--u));
  height: calc(22.2 * var(--u));
}

/* ---- The grid ---- */

.panel {
  grid-area: panel;
}
.grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(5, var(--tile-size));
  grid-auto-rows: var(--tile-size);
  gap: var(--gap);
  outline: none;
}
.grid:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(6 * var(--u));
  border-radius: calc(4 * var(--u));
}

.tile,
.empty {
  position: relative;
  box-sizing: border-box;
  border-radius: calc(3 * var(--u));
}
.empty {
  border: calc(1 * var(--u)) solid var(--empty-edge);
  background: var(--empty);
}
.tile {
  border: calc(1 * var(--u)) solid var(--tile-edge);
  background: var(--tile);
  box-shadow: 0 calc(1 * var(--u)) 0 rgb(0 0 0 / 0.5);
  cursor: pointer;
}
/* The equipped fill: floods out from the centre. */
.fill {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--held);
  box-shadow: inset 0 0 0 calc(1.5 * var(--u)) var(--held-rim);
  opacity: 0;
  scale: 0.4;
  transition:
    opacity 180ms ease,
    scale 260ms cubic-bezier(0.2, 0.9, 0.3, 1.1);
}
.tile[data-equipped] {
  border-color: var(--held-edge);
}
.tile[data-equipped] .fill {
  opacity: 1;
  scale: 1;
}
/* A ring of light, played on a pop. */
.ring {
  position: absolute;
  inset: calc(-2 * var(--u));
  border: calc(2 * var(--u)) solid var(--cursor);
  border-radius: calc(5 * var(--u));
  opacity: 0;
  pointer-events: none;
}
.tile[data-equipped] .ring {
  border-color: #7cc8ff;
}

.art {
  position: absolute;
  inset: calc(2 * var(--u));
  width: calc(100% - 4 * var(--u));
  height: calc(100% - 4 * var(--u));
}
.tile[data-broken] .art {
  opacity: 0.42;
  filter: grayscale(1);
}
.cracks {
  position: absolute;
  inset: calc(4 * var(--u));
  width: calc(100% - 8 * var(--u));
  height: calc(100% - 8 * var(--u));
}
.mark {
  position: absolute;
  left: calc(4 * var(--u));
  top: calc(4 * var(--u));
  width: calc(18 * var(--u));
  height: calc(18 * var(--u));
}
.heal {
  position: absolute;
  right: calc(5 * var(--u));
  top: calc(5 * var(--u));
  width: calc(11 * var(--u));
  height: calc(11 * var(--u));
}

.badge {
  position: absolute;
  right: calc(-1 * var(--u));
  bottom: calc(-1 * var(--u));
  box-sizing: border-box;
  min-width: calc(29 * var(--u));
  height: calc(19 * var(--u));
  padding: 0 calc(5 * var(--u));
  border: calc(1 * var(--u)) solid var(--badge-edge);
  border-radius: calc(2 * var(--u));
  background: var(--badge);
  color: #f4f1e8;
  font-size: calc(13.5 * var(--u));
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  line-height: calc(17 * var(--u));
  text-align: right;
}
.tile[data-equipped] .badge {
  border-color: #0b4b7c;
  background: var(--held-badge);
}
.tile[data-worn] .badge {
  border-color: var(--warn);
  color: var(--warn);
  animation: worn 1.6s ease-in-out infinite;
}
@keyframes worn {
  50% {
    box-shadow: 0 0 calc(7 * var(--u)) rgb(242 179 61 / 0.6);
  }
}
.tile[data-broken] .badge {
  border-color: var(--bad);
  background: var(--bad-ground);
  color: var(--bad);
}
.count {
  position: absolute;
  left: calc(5 * var(--u));
  bottom: calc(3 * var(--u));
  color: #fff;
  font-size: calc(15 * var(--u));
  font-style: italic;
  font-weight: 600;
  line-height: 1;
  transform-origin: left bottom;
  text-shadow:
    0 0 calc(2 * var(--u)) #000,
    0 calc(1 * var(--u)) calc(1 * var(--u)) #000;
}

/* The cursor: a cream frame with filled corners and a soft glow, gliding over the tiles. */
.cursor {
  position: absolute;
  left: calc(-1 * var(--u));
  top: calc(-1 * var(--u));
  width: calc(var(--tile-size) + 2 * var(--u));
  height: calc(var(--tile-size) + 2 * var(--u));
  box-sizing: border-box;
  border: calc(2 * var(--u)) solid var(--cursor);
  background:
    linear-gradient(135deg, var(--cursor) calc(3.4 * var(--u)), transparent 0) top left /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat,
    linear-gradient(225deg, var(--cursor) calc(3.4 * var(--u)), transparent 0) top right /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat,
    linear-gradient(45deg, var(--cursor) calc(3.4 * var(--u)), transparent 0) bottom left /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat,
    linear-gradient(315deg, var(--cursor) calc(3.4 * var(--u)), transparent 0) bottom right /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat;
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) var(--cursor-rim),
    0 0 calc(5 * var(--u)) calc(1 * var(--u)) var(--cursor-glow),
    0 0 calc(14 * var(--u)) rgb(255 240 200 / 0.22);
  pointer-events: none;
  transform: translate(calc(var(--col) * var(--pitch)), calc(var(--row) * var(--pitch)));
  transition: transform 150ms cubic-bezier(0.3, 1.35, 0.55, 1);
  animation: cursor-breathe 2.4s ease-in-out infinite;
}
.cursor[data-glide='long'] {
  transition-duration: 420ms;
  transition-timing-function: cubic-bezier(0.25, 0.9, 0.3, 1.04);
}
.cursor[data-glide='jump'] {
  transition: none;
}
@keyframes cursor-breathe {
  50% {
    box-shadow:
      inset 0 0 0 calc(1 * var(--u)) var(--cursor-rim),
      0 0 calc(7 * var(--u)) calc(1.5 * var(--u)) var(--cursor-glow),
      0 0 calc(18 * var(--u)) rgb(255 240 200 / 0.3);
  }
}

/* ---- The details ---- */

.side {
  grid-area: side;
  display: grid;
  margin-top: calc(18 * var(--u));
}
.preview {
  display: none;
}
@container (min-width: 720px) {
  .side {
    grid-template-rows: 1fr auto;
    height: 100%;
    margin-top: 0;
  }
  .preview {
    position: relative;
    display: grid;
    place-items: center;
    min-height: calc(150 * var(--u));
  }
  .preview::before {
    content: '';
    position: absolute;
    width: calc(190 * var(--u));
    aspect-ratio: 1;
    border-radius: 50%;
    background: radial-gradient(closest-side, rgb(233 225 209 / 0.09), transparent);
  }
}
.previewArt {
  position: relative;
  display: block;
  animation: preview-in 320ms cubic-bezier(0.2, 0.9, 0.3, 1.1) both;
}
@keyframes preview-in {
  from {
    opacity: 0;
    scale: 0.9;
    translate: calc(-8 * var(--u)) calc(8 * var(--u));
  }
}
.previewSvg {
  display: block;
  width: calc(170 * var(--u));
  height: calc(170 * var(--u));
}

.info {
  box-sizing: border-box;
  min-height: calc(132 * var(--u));
  padding: calc(8 * var(--u)) calc(16 * var(--u)) calc(12 * var(--u)) calc(18 * var(--u));
  border: calc(1 * var(--u)) solid var(--panel-edge);
  background: var(--panel);
}
.infoBody {
  animation: info-in 240ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
@keyframes info-in {
  from {
    opacity: 0;
    translate: 0 calc(6 * var(--u));
  }
}
.name {
  margin: 0;
  padding-bottom: calc(3 * var(--u));
  border-bottom: calc(1 * var(--u)) solid rgb(233 225 209 / 0.35);
  font-size: calc(23 * var(--u));
  font-style: italic;
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: 0.005em;
}
.stats {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: calc(5 * var(--u)) calc(6 * var(--u));
  margin-top: calc(4 * var(--u));
}
.statIcon {
  width: calc(19 * var(--u));
  height: calc(19 * var(--u));
  margin-right: calc(4 * var(--u));
  color: #f4f1e8;
}
.box {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: calc(2 * var(--u));
  box-sizing: border-box;
  min-width: calc(35 * var(--u));
  height: calc(22 * var(--u));
  padding: 0 calc(6 * var(--u));
  border: calc(1 * var(--u)) solid var(--box-edge);
  border-radius: calc(2 * var(--u));
  background: var(--box);
  color: #f4f1e8;
  font-size: calc(14 * var(--u));
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}
.box[data-trend='up'] {
  color: var(--good);
}
.box[data-trend='down'] {
  color: var(--bad);
}
.boxHeart {
  width: calc(12 * var(--u));
  height: calc(12 * var(--u));
}
.arrow {
  width: 0;
  height: 0;
  border-block: calc(4 * var(--u)) solid transparent;
  border-left: calc(5 * var(--u)) solid var(--ink-soft);
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: calc(5 * var(--u));
  box-sizing: border-box;
  height: calc(22 * var(--u));
  padding: 0 calc(7 * var(--u)) 0 calc(5 * var(--u));
  border: calc(1 * var(--u)) solid var(--box-edge);
  border-radius: calc(2 * var(--u));
  background: var(--box);
  color: #f4f1e8;
  font-size: calc(14 * var(--u));
  font-weight: 500;
  white-space: nowrap;
}
.chip[data-tone='broken'] {
  border-color: var(--bad);
  color: var(--bad);
  padding-left: calc(7 * var(--u));
}
.chip[data-tone='worn'] {
  border-color: var(--warn);
  color: var(--warn);
  padding-left: calc(7 * var(--u));
}
.chipIcon {
  width: calc(14 * var(--u));
  height: calc(14 * var(--u));
}
.text {
  margin: calc(5 * var(--u)) 0 0;
  color: var(--ink-soft);
  font-size: max(11px, calc(14.5 * var(--u)));
  font-style: italic;
  line-height: 1.06;
}
.none {
  margin: calc(8 * var(--u)) 0 0;
  color: var(--ink-dim);
  font-style: italic;
}

/* ---- The foot: the sort order and the button hints ---- */

.sortNote,
.hints {
  grid-area: foot;
  align-self: center;
  margin-top: calc(16 * var(--u));
}
.sortNote {
  justify-self: start;
  box-sizing: border-box;
  height: calc(34 * var(--u));
  padding: 0 calc(16 * var(--u)) 0 calc(14 * var(--u));
  border: calc(1 * var(--u)) solid var(--panel-edge);
  background: var(--panel);
  font-size: calc(14 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: calc(32 * var(--u));
  white-space: nowrap;
  overflow: hidden;
}
.sortWord {
  display: block;
  animation: word-in 260ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.hints {
  justify-self: end;
  display: flex;
  gap: calc(18 * var(--u));
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(6 * var(--u));
  margin: 0;
  padding: calc(2 * var(--u)) calc(2 * var(--u)) calc(2 * var(--u)) calc(4 * var(--u));
  border: 0;
  border-radius: calc(12 * var(--u));
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: calc(14.5 * var(--u));
  font-style: italic;
  font-weight: 500;
  cursor: pointer;
  touch-action: manipulation;
}
.hint:disabled {
  color: var(--ink-dim);
  --glyph: #6f6c63;
  cursor: default;
}
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.hintGlyph {
  display: block;
}
.hintGlyph[data-hit] {
  animation: hint-press 260ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
@keyframes hint-press {
  0% {
    scale: 0.78;
  }
}
.faceSvg {
  display: block;
  width: calc(19 * var(--u));
  height: calc(19 * var(--u));
}

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

/* ---- Reduced motion: every change lands at once ---- */

.root[data-reduced] *,
.root[data-reduced] *::before {
  animation: none !important;
  transition: none !important;
}
```

### item-grid/glyphs.tsx

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

/*
 * The item grid's art, all small SVGs drawn for this component:
 *
 * - Item glyphs, on a 64 × 64 box: flat shapes in two tones (a lit face and a shaded face) with a
 *   thin dark outline, laid on the diagonal from lower left to upper right as an inventory shows a
 *   weapon. Long things (blades, spears, bows) are drawn upright about the box's centre and turned
 *   45°. A few carry a glow: a wide, faint copy of the shape behind it (no blur filter, so the art
 *   stays sharp at any pixel density).
 * - Tab icons, on a 24 × 24 box, in one colour (currentColor), bold and flat.
 * - Corner marks (a modifier on the item, a broken blade, a heart for food), on a 16 × 16 box.
 * - The controller glyphs of the hints: a face button disc with its letter, and the right stick
 *   seen slightly from above with an arrow, as in the other game components.
 */

export type ItemIcon =
  | 'sword'
  | 'glow-sword'
  | 'great-sword'
  | 'curved-sword'
  | 'spear'
  | 'axe'
  | 'club'
  | 'hammer'
  | 'bow'
  | 'great-bow'
  | 'spark-bow'
  | 'shield'
  | 'round-shield'
  | 'tower-shield'
  | 'tunic'
  | 'helm'
  | 'boots'
  | 'gem'
  | 'mushroom'
  | 'herb'
  | 'ore'
  | 'apple'
  | 'skewer'
  | 'meat'
  | 'stew'
  | 'bomb'
  | 'key'
  | 'map';

export type Modifier = 'attack' | 'durability' | 'critical' | 'quick' | 'hearts' | 'stamina';
export type TabIcon = 'weapons' | 'bows' | 'shields' | 'armor' | 'materials' | 'food' | 'key';

const LINE = '#0b0b0a';
const STEEL = '#c9d2cc';
const STEEL_DARK = '#7d8a84';
const BRASS = '#c8ac64';
const BRASS_DARK = '#8a7339';
const WOOD = '#8a5530';
const WOOD_DARK = '#5c351c';

const TURN = 'translate(32 32) scale(1.28) rotate(45) translate(-32 -32)';
const outline = { stroke: LINE, strokeWidth: 1.6, strokeLinejoin: 'round' as const };

/** An upright straight blade, from y = top to y = 40, half-width w, with a guard, grip, pommel. */
function Blade({
  top,
  w,
  light,
  dark,
  guard = BRASS,
  glow,
}: {
  top: number;
  w: number;
  light: string;
  dark: string;
  guard?: string;
  glow?: string;
}) {
  const blade = `M32 ${top} L${32 + w} ${top + w * 1.6} L${32 + w} 41 L${32 - w} 41 L${32 - w} ${top + w * 1.6} Z`;
  return (
    <g transform={TURN}>
      {glow && (
        <path
          d={blade}
          fill="none"
          stroke={glow}
          strokeWidth="7"
          strokeLinejoin="round"
          opacity="0.32"
        />
      )}
      <path d={blade} fill={light} {...outline} />
      <path
        d={`M32 ${top + 1} L${32 + w - 0.6} ${top + w * 1.6} L${32 + w - 0.6} 40.2 L32 40.2 Z`}
        fill={dark}
      />
      <rect
        x={32 - w - 5}
        y="40.5"
        width={2 * w + 10}
        height="4.4"
        rx="1.6"
        fill={guard}
        {...outline}
      />
      <rect x="30" y="45" width="4" height="10" rx="1" fill={WOOD} {...outline} />
      <circle cx="32" cy="57.4" r="2.9" fill={guard} {...outline} />
    </g>
  );
}

function Art({ icon }: { icon: ItemIcon }) {
  switch (icon) {
    case 'sword':
      return <Blade top={9} w={3.4} light={STEEL} dark={STEEL_DARK} />;
    case 'glow-sword':
      return (
        <Blade top={6} w={3.6} light="#d8fbff" dark="#62d8f2" guard="#6e7fd6" glow="#5fe3ff" />
      );
    case 'great-sword':
      return <Blade top={4} w={4.6} light="#d9d6c8" dark="#8f8b7c" guard="#a7a39a" />;
    case 'curved-sword':
      return (
        <g transform={TURN}>
          <path
            d="M33 6 C40 16 38 30 35 41 L29.6 41 C32 30 34 18 33 6 Z"
            fill={STEEL}
            {...outline}
          />
          <path
            d="M33.6 9 C38.6 18 37 30 34.6 40.2 L32.6 40.2 C34.6 30 35.4 19 33.6 9 Z"
            fill={STEEL_DARK}
          />
          <path d="M26 41 h13 v3.6 h-13 z" fill={BRASS} {...outline} />
          <rect x="30" y="44.6" width="4.4" height="11" rx="1.2" fill="#3c3f4a" {...outline} />
          <circle cx="32.2" cy="57.6" r="2.6" fill={BRASS} {...outline} />
        </g>
      );
    case 'spear':
      return (
        <g transform={TURN}>
          <rect x="30.6" y="16" width="2.8" height="46" rx="1" fill={WOOD} {...outline} />
          <path d="M32 1.5 L37 12 L32 19.5 L27 12 Z" fill={STEEL} {...outline} />
          <path d="M32 3 L36 12 L32 18 Z" fill={STEEL_DARK} />
          <rect x="29.6" y="19" width="4.8" height="3" rx="1" fill={BRASS} {...outline} />
          <rect x="29.6" y="40" width="4.8" height="3.6" rx="1" fill="#6a8f8c" {...outline} />
        </g>
      );
    case 'axe':
      return (
        <g transform={TURN}>
          <rect x="30.4" y="8" width="3.2" height="54" rx="1.2" fill={WOOD} {...outline} />
          <path d="M33.4 9 C44 7 50 13 51 22 C46 19 40 20 33.4 24 Z" fill={STEEL} {...outline} />
          <path d="M36 12 C43 11 47 15 48.4 19.4 C44 18 39.6 19 36 21 Z" fill={STEEL_DARK} />
          <path d="M30.6 11 C25 12 22 15 21 19 L30.6 20 Z" fill={STEEL_DARK} {...outline} />
          <rect x="29.4" y="50" width="5.2" height="3.4" rx="1" fill={BRASS} {...outline} />
        </g>
      );
    case 'club':
      return (
        <g transform={TURN}>
          <path d="M26 8 C26 4 38 4 38 8 L35.4 46 L28.6 46 Z" fill="#d79a58" {...outline} />
          <path d="M32 6 C35 5.6 37 6.4 37.4 8 L34.8 45 L32 45 Z" fill="#a8693a" />
          <path
            d="M27 18 L37 15.4 M27.4 27 L36.6 24 M27.8 36 L36 33.4"
            stroke="#e9d39b"
            strokeWidth="1.8"
            strokeLinecap="round"
          />
          <rect x="29.4" y="46" width="5.2" height="13" rx="1.6" fill="#efe1c4" {...outline} />
        </g>
      );
    case 'hammer':
      return (
        <g transform={TURN}>
          <rect x="30.4" y="16" width="3.2" height="45" rx="1.2" fill="#c5b8a2" {...outline} />
          <path d="M33.4 22 h4 M33.4 28 h4 M33.4 34 h4" stroke={LINE} strokeWidth="1.2" />
          <rect x="18" y="5" width="28" height="14" rx="3" fill="#8f3b3b" {...outline} />
          <rect x="18" y="12" width="28" height="7" rx="2" fill="#5f2427" />
          <rect x="29" y="4" width="6" height="16" rx="1" fill="#d7cfbf" {...outline} />
        </g>
      );
    case 'bow':
    case 'great-bow':
    case 'spark-bow': {
      const wood = icon === 'great-bow' ? '#6b6f80' : icon === 'spark-bow' ? '#2e5bd8' : WOOD;
      const tip = icon === 'great-bow' ? '#c4c9d6' : icon === 'spark-bow' ? '#f2e64a' : BRASS;
      const string = icon === 'spark-bow' ? '#f6f39a' : '#e3ded2';
      return (
        <g transform={TURN}>
          {icon === 'spark-bow' && (
            <path
              d="M24 6 C46 18 46 46 24 58"
              fill="none"
              stroke="#fff35a"
              strokeWidth="8"
              opacity="0.28"
            />
          )}
          <path d="M24 6 L24 58" stroke={string} strokeWidth="1.2" />
          <path
            d={
              icon === 'great-bow'
                ? 'M24 6 C30 6 44 16 44 32 C44 48 30 58 24 58'
                : 'M24 6 C44 16 44 48 24 58'
            }
            fill="none"
            stroke={LINE}
            strokeWidth="6"
            strokeLinecap="round"
          />
          <path
            d={
              icon === 'great-bow'
                ? 'M24 6 C30 6 44 16 44 32 C44 48 30 58 24 58'
                : 'M24 6 C44 16 44 48 24 58'
            }
            fill="none"
            stroke={wood}
            strokeWidth="3.6"
            strokeLinecap="round"
          />
          <rect x="37.6" y="28" width="5.4" height="8" rx="1.4" fill={tip} {...outline} />
          <circle cx="24" cy="6" r="2" fill={tip} {...outline} />
          <circle cx="24" cy="58" r="2" fill={tip} {...outline} />
        </g>
      );
    }
    case 'shield':
      return (
        <g>
          <path
            d="M32 6 L52 13 C52 34 45 49 32 58 C19 49 12 34 12 13 Z"
            fill="#9aa3ad"
            {...outline}
          />
          <path d="M32 11 L47.4 16.4 C47.2 33 41.6 45.4 32 52.6 Z" fill="#2f4f8f" />
          <path d="M32 11 L16.6 16.4 C16.8 33 22.4 45.4 32 52.6 Z" fill="#3e66b3" />
          <path d="M24 26 L32 20 L40 26 L32 40 Z" fill="#e8dfbf" {...outline} />
        </g>
      );
    case 'round-shield':
      return (
        <g>
          <circle cx="32" cy="32" r="24" fill="#a46a3a" {...outline} />
          <circle cx="32" cy="32" r="19.4" fill="#d79a58" />
          <path d="M32 13 L32 51 M13 32 L51 32" stroke="#a46a3a" strokeWidth="2" />
          <path
            d="M32 19 L35 28 L44.4 28.2 L36.8 33.8 L39.6 43 L32 37.4 L24.4 43 L27.2 33.8 L19.6 28.2 L29 28 Z"
            fill="#7a2f22"
            {...outline}
          />
          <circle cx="32" cy="32" r="3.4" fill={BRASS} {...outline} />
        </g>
      );
    case 'tower-shield':
      return (
        <g>
          <path
            d="M16 8 H48 V44 C48 52 40 58 32 60 C24 58 16 52 16 44 Z"
            fill="#8c8f86"
            {...outline}
          />
          <path d="M32 10 H46 V44 C46 50 40 55.6 32 57.6 Z" fill="#5d6159" />
          <path d="M20 22 H44 M20 36 H44" stroke={LINE} strokeWidth="1.4" />
          <circle cx="32" cy="29" r="4" fill="#c9b36a" {...outline} />
        </g>
      );
    case 'tunic':
      return (
        <g>
          <path
            d="M22 9 L28 7 C29 11 35 11 36 7 L42 9 L54 19 L48 28 L44 25 L44 56 L20 56 L20 25 L16 28 L10 19 Z"
            fill="#4f9ad6"
            {...outline}
          />
          <path d="M32 11 L42 9 L54 19 L48 28 L44 25 L44 56 L32 56 Z" fill="#3576ae" />
          <rect x="20" y="36" width="24" height="5" fill="#7a5530" {...outline} />
          <rect x="29.4" y="35" width="5.2" height="7" rx="1" fill={BRASS} {...outline} />
        </g>
      );
    case 'helm':
      return (
        <g>
          <path
            d="M12 40 C12 20 22 10 32 10 C42 10 52 20 52 40 L52 50 L12 50 Z"
            fill="#b9b6a8"
            {...outline}
          />
          <path d="M32 12 C42 12 50 22 50 40 L50 48 L32 48 Z" fill="#848173" />
          <path d="M18 34 H46 V40 H18 Z" fill="#26261f" {...outline} />
          <path d="M32 10 L32 50" stroke={LINE} strokeWidth="1.4" />
        </g>
      );
    case 'boots':
      return (
        <g>
          <path
            d="M14 10 H28 V40 L40 44 C44 45 46 48 46 52 V56 H14 Z"
            fill="#7a5134"
            {...outline}
          />
          <path d="M22 10 H28 V40 L40 44 C44 45 46 48 46 52 V56 H22 Z" fill="#5b3a23" />
          <path d="M14 18 H28 M14 48 H46" stroke="#b9925a" strokeWidth="2" />
        </g>
      );
    case 'gem':
      return (
        <g>
          <path d="M32 8 L48 24 L32 56 L16 24 Z" fill="#52c6f2" {...outline} />
          <path d="M32 8 L48 24 L32 56 Z" fill="#2b8fc6" />
          <path
            d="M16 24 H48 M26 24 L32 8 L38 24 L32 56 Z"
            fill="none"
            stroke="#c9f1ff"
            strokeWidth="1.2"
            opacity="0.8"
          />
        </g>
      );
    case 'mushroom':
      return (
        <g>
          <path d="M27 34 H37 L38 54 C38 57 26 57 26 54 Z" fill="#efe3c7" {...outline} />
          <path d="M10 34 C10 18 20 10 32 10 C44 10 54 18 54 34 Z" fill="#d6463a" {...outline} />
          <path d="M32 10 C44 10 54 18 54 34 H32 Z" fill="#a8302a" />
          <circle cx="22" cy="24" r="3.4" fill="#f7eedc" />
          <circle cx="35" cy="18" r="2.6" fill="#f7eedc" />
          <circle cx="44" cy="27" r="3" fill="#f7eedc" />
        </g>
      );
    case 'herb':
      return (
        <g>
          <path
            d="M32 58 C32 44 30 30 26 14"
            fill="none"
            stroke="#3c6b2a"
            strokeWidth="3"
            strokeLinecap="round"
          />
          <path d="M26 14 C16 14 12 22 14 30 C22 30 27 24 26 14 Z" fill="#7cc04e" {...outline} />
          <path d="M31 34 C40 26 50 28 52 34 C46 42 37 42 31 34 Z" fill="#62a83c" {...outline} />
          <path d="M30 46 C22 40 13 42 11 48 C17 54 26 53 30 46 Z" fill="#7cc04e" {...outline} />
        </g>
      );
    case 'ore':
      return (
        <g>
          <path
            d="M10 44 L18 22 L34 14 L50 22 L56 42 L40 54 L20 54 Z"
            fill="#6f6a62"
            {...outline}
          />
          <path d="M34 14 L50 22 L56 42 L40 54 L34 36 Z" fill="#4c4842" />
          <path d="M22 30 L28 26 L30 34 Z M38 38 L44 34 L46 42 Z" fill="#f2c94c" {...outline} />
        </g>
      );
    case 'apple':
      return (
        <g>
          <path
            d="M32 18 C24 11 10 15 10 31 C10 46 20 57 32 54 C44 57 54 46 54 31 C54 15 40 11 32 18 Z"
            fill="#e2332b"
            {...outline}
          />
          <path d="M32 18 C40 11 54 15 54 31 C54 46 44 57 32 54 Z" fill="#b2201c" />
          <path
            d="M17 26 C18 21 22 19 26 20"
            fill="none"
            stroke="#ff9d8a"
            strokeWidth="3"
            strokeLinecap="round"
          />
          <path
            d="M32 18 C32 12 34 8 37 6"
            fill="none"
            stroke="#5c351c"
            strokeWidth="2.6"
            strokeLinecap="round"
          />
          <path d="M36 12 C40 6 47 6 50 9 C46 14 40 14 36 12 Z" fill="#6cb244" {...outline} />
        </g>
      );
    case 'skewer':
      return (
        <g transform={TURN}>
          <rect x="31" y="2" width="2" height="60" rx="1" fill="#d8c08a" {...outline} />
          <rect x="24" y="10" width="16" height="12" rx="5" fill="#a1542f" {...outline} />
          <rect x="25" y="25" width="14" height="10" rx="4" fill="#5f9f3a" {...outline} />
          <rect x="24" y="38" width="16" height="12" rx="5" fill="#a1542f" {...outline} />
          <path d="M33 11 h6 v10 h-6 z M33 39 h6 v10 h-6 z" fill="#7a3a1f" />
        </g>
      );
    case 'meat':
      return (
        <g>
          <path
            d="M42 12 C54 14 58 28 50 36 C44 42 34 42 28 36 C22 30 30 10 42 12 Z"
            fill="#c5523c"
            {...outline}
          />
          <path d="M44 16 C52 19 53 30 47 34 C42 37 36 36 33 33 Z" fill="#963a2a" />
          <path d="M30 36 L16 50" stroke="#efe3c7" strokeWidth="5" strokeLinecap="round" />
          <circle cx="13" cy="48" r="3.6" fill="#efe3c7" {...outline} />
          <circle cx="18" cy="53" r="3.6" fill="#efe3c7" {...outline} />
        </g>
      );
    case 'stew':
      return (
        <g>
          <path d="M8 28 H56 C56 44 46 54 32 54 C18 54 8 44 8 28 Z" fill="#c9c2b0" {...outline} />
          <path d="M32 28 H56 C56 44 46 54 32 54 Z" fill="#8f897a" />
          <ellipse cx="32" cy="28" rx="24" ry="6" fill="#b5652f" {...outline} />
          <circle cx="24" cy="27" r="3.2" fill="#e8a33c" />
          <circle cx="36" cy="26" r="3" fill="#6cb244" />
          <circle cx="42" cy="29" r="2.6" fill="#e2332b" />
          <path
            d="M22 18 C20 14 24 12 22 8 M32 18 C30 14 34 12 32 8 M42 18 C40 14 44 12 42 8"
            fill="none"
            stroke="#e9e1d1"
            strokeWidth="1.8"
            strokeLinecap="round"
            opacity="0.7"
          />
        </g>
      );
    case 'bomb':
      return (
        <g>
          <circle cx="29" cy="37" r="22" fill="#18a8f2" opacity="0.28" />
          <circle cx="29" cy="37" r="18" fill="#1cb2ff" {...outline} />
          <path d="M29 19 A18 18 0 0 1 29 55 Z" fill="#0d86d0" />
          <rect
            x="38"
            y="13"
            width="9"
            height="9"
            rx="2"
            transform="rotate(45 42.5 17.5)"
            fill="#1cb2ff"
            {...outline}
          />
          <circle cx="49" cy="10" r="4.6" fill="none" stroke="#1cb2ff" strokeWidth="2.6" />
          <path
            d="M19 30 C20 26 23 24 26 23.4"
            fill="none"
            stroke="#bdeaff"
            strokeWidth="3"
            strokeLinecap="round"
          />
        </g>
      );
    case 'key':
      return (
        <g transform={TURN}>
          <circle cx="32" cy="15" r="9" fill={BRASS} {...outline} />
          <circle cx="32" cy="15" r="3.6" fill="#141413" />
          <rect x="29.6" y="23" width="4.8" height="34" rx="1.4" fill={BRASS} {...outline} />
          <path d="M34.4 44 h6 v4 h-6 z M34.4 51 h4 v4 h-4 z" fill={BRASS_DARK} {...outline} />
        </g>
      );
    case 'map':
      return (
        <g>
          <path
            d="M10 14 L24 10 L40 14 L54 10 V50 L40 54 L24 50 L10 54 Z"
            fill="#e3d4ac"
            {...outline}
          />
          <path d="M24 10 V50 M40 14 V54" stroke="#b9a678" strokeWidth="1.4" />
          <path
            d="M16 40 C22 30 30 34 34 26 C38 20 44 22 48 18"
            fill="none"
            stroke="#b23a2c"
            strokeWidth="1.8"
            strokeDasharray="3 2.4"
          />
          <path
            d="M44 15 l6 6 m0 -6 l-6 6"
            stroke="#b23a2c"
            strokeWidth="2.2"
            strokeLinecap="round"
          />
        </g>
      );
  }
}

export function ItemArt({ icon, className }: { icon: ItemIcon; className?: string }) {
  return (
    <svg viewBox="0 0 64 64" className={className} aria-hidden="true">
      <Art icon={icon} />
    </svg>
  );
}

/** The cracks laid over a broken item's art. */
export function Cracks({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 64 64" className={className} aria-hidden="true">
      <path
        d="M20 50 L27 40 L24 35 L33 27 L31 22 L41 13 M27 40 L33 42 M33 27 L38 30"
        fill="none"
        stroke="#0b0b0a"
        strokeWidth="3.4"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M20 50 L27 40 L24 35 L33 27 L31 22 L41 13 M27 40 L33 42 M33 27 L38 30"
        fill="none"
        stroke="#ff6b5a"
        strokeWidth="1.4"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

const TAB_PATHS: Record<TabIcon, string> = {
  weapons:
    'M19.6 2.4 L21.6 4.4 L11.4 14.6 L9.4 12.6 Z M7.4 11.2 L12.8 16.6 L11.6 17.8 L10 16.2 L6.4 19.8 L7.4 20.8 L5.6 22.6 L1.4 18.4 L3.2 16.6 L4.2 17.6 L7.8 14 L6.2 12.4 Z',
  bows: 'M4 3 C14 4 20 10 21 20 L19 20.4 C18.2 11.6 12.4 5.8 3.6 5 Z M3.6 4.4 L19.8 20.6 L18.6 21.8 L2.4 5.6 Z M15 3 H21 V9 L19 7 L9.6 16.4 L7.6 14.4 L17 5 Z',
  shields:
    'M12 1.6 L21 4.6 C21 13 18 19 12 22.6 C6 19 3 13 3 4.6 Z M12 5 L6.2 7 C6.4 12.6 8.4 16.8 12 19.4 Z',
  armor:
    'M7.6 2.6 L10 2 C10.4 3.8 13.6 3.8 14 2 L16.4 2.6 L22.4 7 L19.6 11.4 L17.6 10.2 L17.6 21.6 L6.4 21.6 L6.4 10.2 L4.4 11.4 L1.6 7 Z',
  materials:
    'M12 6.6 C9 3.8 3 4.6 3 11.4 C3 17.4 7 22.4 12 21 C17 22.4 21 17.4 21 11.4 C21 4.6 15 3.8 12 6.6 Z M12 6.4 C12 4 13 2.4 14.4 1.6 L15.4 2.6 C14.2 3.4 13.4 4.6 13.4 6.4 Z',
  food: 'M2 10 H22 V12 H20.6 L19.4 20.6 C19.2 21.4 18.6 22 17.8 22 H6.2 C5.4 22 4.8 21.4 4.6 20.6 L3.4 12 H2 Z M5 6.6 C7 4.4 9.4 3.4 12 3.4 C14.6 3.4 17 4.4 19 6.6 L19 8.2 H5 Z M10.6 1.2 H13.4 V3.6 H10.6 Z',
  key: 'M12 1.6 L14.8 8.6 L22.4 9 L16.4 13.8 L18.4 21.2 L12 17 L5.6 21.2 L7.6 13.8 L1.6 9 L9.2 8.6 Z',
};

export function TabGlyph({ icon, className }: { icon: TabIcon; className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} aria-hidden="true">
      {icon === 'weapons' ? (
        <g
          fill="currentColor"
          transform="rotate(45 12 12) translate(12 12) scale(1.16) translate(-12 -12)"
        >
          <path d="M12 0.4 L15.6 4 V14.6 H8.4 V4 Z" />
          <rect x="5.6" y="15.2" width="12.8" height="3" rx="1" />
          <rect x="10.5" y="18.6" width="3" height="3.4" />
          <circle cx="12" cy="22.6" r="1.7" />
        </g>
      ) : icon === 'bows' ? (
        <g transform="rotate(45 12 12)" stroke="currentColor" fill="none">
          <path d="M9 1.6 C17.4 5 17.4 19 9 22.4" strokeWidth="2.6" strokeLinecap="round" />
          <path d="M9 1.6 V22.4" strokeWidth="1.1" />
          <path d="M2 12 H19" strokeWidth="1.8" />
          <path
            d="M23.4 12 L18.6 9 V15 Z M1 9.6 L4 12 L1 14.4"
            fill="currentColor"
            strokeWidth="1"
            strokeLinejoin="round"
          />
        </g>
      ) : (
        <path d={TAB_PATHS[icon]} fill="currentColor" fillRule="evenodd" />
      )}
    </svg>
  );
}

/** The small marks in a tile's top-left corner, each in its own colours. */
export function ModifierMark({ kind, className }: { kind: Modifier; className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden="true">
      {kind === 'attack' && (
        <g stroke={LINE} strokeWidth="0.9" strokeLinejoin="round">
          <path
            d="M9.4 1.4 L11 3 L5 9 L3.4 7.4 Z M2.6 8.2 L5.8 11.4 L5 12.2 L4 11.2 L2.2 13 L1 11.8 L2.8 10 L1.8 9 Z"
            fill="#f2c230"
          />
          <path d="M12.4 6.4 L15.4 9.6 H13.4 V14.4 H11.4 V9.6 H9.4 Z" fill="#f2c230" />
        </g>
      )}
      {kind === 'durability' && (
        <g stroke={LINE} strokeWidth="0.9" strokeLinejoin="round">
          <path d="M4 2 H12 L15 6 L8 14.6 L1 6 Z" fill="#3ec8ff" />
          <path
            d="M1 6 H15 M5.4 6 L8 14.6 L10.6 6 L8 2"
            fill="none"
            stroke="#bff0ff"
            strokeWidth="0.8"
          />
        </g>
      )}
      {kind === 'critical' && (
        <g fill="#f4f1e6" stroke={LINE} strokeWidth="0.8" strokeLinejoin="round">
          <path d="M12.4 0.8 L15.2 0.8 L15.2 3.6 L7.4 11.4 L4.6 8.6 Z M3.4 9 L7 12.6 L5.8 13.8 L4.8 12.8 L2.6 15 L1 13.4 L3.2 11.2 L2.2 10.2 Z" />
          <path d="M3.6 0.8 L0.8 0.8 L0.8 3.6 L5.4 8.2 L8.2 5.4 Z M10.6 7.6 L8 10.2 L11.2 13.4 L12.8 11.8 Z" />
        </g>
      )}
      {kind === 'quick' && (
        <path
          d="M2 9 C2 4.6 5.4 2 9.4 2.6 C11.6 3 13.2 4.4 14 6.4 L15.6 5.4 L15 10.6 L10.4 8.6 L12 7.6 C11.4 6.2 10.2 5.2 8.8 5.2 C6.6 5.2 5 7 5.2 9.6 Z"
          fill="#f2c230"
          stroke={LINE}
          strokeWidth="0.9"
          strokeLinejoin="round"
        />
      )}
      {kind === 'hearts' && (
        <path
          d="M8 14.4 C3 10.6 1 8.2 1 5.6 C1 3.4 2.6 1.8 4.6 1.8 C6 1.8 7.2 2.6 8 3.8 C8.8 2.6 10 1.8 11.4 1.8 C13.4 1.8 15 3.4 15 5.6 C15 8.2 13 10.6 8 14.4 Z"
          fill="#f2c230"
          stroke={LINE}
          strokeWidth="0.9"
        />
      )}
      {kind === 'stamina' && (
        <path
          d="M9.6 0.8 L3 9 H7.4 L5.8 15.2 L13 6.4 H8.6 Z"
          fill="#c6e64a"
          stroke={LINE}
          strokeWidth="0.9"
          strokeLinejoin="round"
        />
      )}
    </svg>
  );
}

/** A heart, for food that heals (top right of its tile) and for the hearts in its stats. */
export function Heart({ className, fill = '#e1352b' }: { className?: string; fill?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden="true">
      <path
        d="M8 14.6 C3 10.8 0.8 8.2 0.8 5.6 C0.8 3.2 2.6 1.6 4.6 1.6 C6 1.6 7.2 2.4 8 3.6 C8.8 2.4 10 1.6 11.4 1.6 C13.4 1.6 15.2 3.2 15.2 5.6 C15.2 8.2 13 10.8 8 14.6 Z"
        fill={fill}
      />
    </svg>
  );
}

/** A broken blade: the mark of an item with no durability left. */
export function BrokenMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} aria-hidden="true">
      <g fill="#ff5a48" stroke={LINE} strokeWidth="0.9" strokeLinejoin="round">
        <path d="M14.6 1.4 L15 4 L11.4 7.6 L10.4 6.2 L9.2 6.6 L8.6 5.4 Z" />
        <path d="M7.6 6.6 L8.4 8.2 L9.6 7.8 L6.4 11 L4.8 9.4 Z M3.6 9.6 L6.6 12.6 L5.8 13.4 L4.8 12.4 L2.8 14.4 L1.6 13.2 L3.6 11.2 L2.8 10.4 Z" />
      </g>
    </svg>
  );
}

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

/** The right stick seen slightly from above, with an arrow pointing on to the next tab. */
export function StickGlyph({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 46 36" className={className} aria-hidden="true">
      <ellipse cx="17.6" cy="32.4" rx="6" ry="2.6" fill="var(--glyph)" />
      <path d="M12 27 h11.4 l-1 4.6 h-9.4 z" fill="var(--glyph)" />
      <ellipse cx="18" cy="17.6" rx="16.8" ry="11.6" fill="var(--glyph)" />
      <ellipse cx="18" cy="14" rx="18" ry="12.6" fill="var(--glyph)" />
      <ellipse
        cx="18"
        cy="14.4"
        rx="15.2"
        ry="10.6"
        fill="none"
        stroke="var(--glyph-ink)"
        strokeWidth="1.6"
      />
      <text
        x="18"
        y="20.4"
        textAnchor="middle"
        fontSize="16"
        fill="var(--glyph-ink)"
        style={{ fontWeight: 600, fontStyle: 'normal' }}
      >
        R
      </text>
      <path d="M38.4 8 L45.4 15.4 L38.4 22.8 Z" fill="var(--glyph)" />
    </svg>
  );
}
```

### item-grid/items.ts

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

import type { ItemIcon, Modifier, TabIcon } from './glyphs';

/*
 * The inventory's model: categories (the tabs), items, and the sort orders. The default items are
 * invented: plain names a player would recognise by shape, none from any game.
 */

export type ItemCategory = TabIcon;

export type InventoryItem = {
  /** Stable id: keeps a tile (and the cursor on it) through sorts and changes. */
  id: string;
  name: string;
  category: ItemCategory;
  icon: ItemIcon;
  /** Attack (weapons, bows) or defence (shields, armour): shown in the tile's badge. */
  power?: number;
  /** How many: shown as ×n in the tile when more than one (materials, food, key items). */
  count?: number;
  /** What is left, from 1 (new) to 0 (broken). Under 0.25 the badge warns; at 0 it is broken. */
  durability?: number;
  /** In hand: the tile is filled blue. One per category. */
  equipped?: boolean;
  /** A bonus, shown as a mark in the tile's corner and a chip in the stats. */
  modifier?: { kind: Modifier; value?: number };
  /** Food: the hearts it restores. */
  hearts?: number;
  /** Picked up since the category was last opened: a dot over its tab. */
  fresh?: boolean;
  description?: string;
};

export type SortOrder = 'type' | 'power' | 'durability' | 'count';

export const CATEGORIES: { id: ItemCategory; label: string }[] = [
  { id: 'weapons', label: 'Weapons' },
  { id: 'bows', label: 'Bows' },
  { id: 'shields', label: 'Shields' },
  { id: 'armor', label: 'Armor' },
  { id: 'materials', label: 'Materials' },
  { id: 'food', label: 'Food' },
  { id: 'key', label: 'Key items' },
];

/** Gear wears out and has power; the rest stacks. */
export const isGear = (category: ItemCategory) =>
  category === 'weapons' || category === 'bows' || category === 'shields' || category === 'armor';

/** The orders Sort (Y) steps through, per kind of category. */
export const sortOrders = (category: ItemCategory): SortOrder[] =>
  isGear(category) ? ['type', 'power', 'durability'] : ['type', 'count'];

export const SORT_LABEL: Record<SortOrder, string> = {
  type: 'Sorted by type',
  power: 'Sorted by power',
  durability: 'Sorted by wear',
  count: 'Sorted by amount',
};

export function sortItems(items: InventoryItem[], order: SortOrder, base: string[]) {
  const rank = new Map(base.map((id, index) => [id, index]));
  const typeRank = (item: InventoryItem) => rank.get(item.id) ?? Number.MAX_SAFE_INTEGER;
  const value = (item: InventoryItem) => {
    if (order === 'power') return item.power ?? item.hearts ?? 0;
    if (order === 'durability') return item.durability ?? 1;
    if (order === 'count') return item.count ?? 1;
    return 0;
  };
  return [...items].sort(
    (a, b) => (order === 'type' ? 0 : value(b) - value(a)) || typeRank(a) - typeRank(b),
  );
}

export const DEFAULT_ITEMS: InventoryItem[] = [
  {
    id: 'w1',
    name: 'Lantern Blade',
    category: 'weapons',
    icon: 'glow-sword',
    power: 30,
    durability: 0.9,
    description:
      'A blade that hums with a cold light. It never dulls, but it tires after long use.',
  },
  {
    id: 'w2',
    name: 'Soldier Sword',
    category: 'weapons',
    icon: 'sword',
    power: 40,
    durability: 0.7,
    modifier: { kind: 'attack', value: 12 },
    description:
      'A sturdy issue sword, re-forged by a careful hand. It strikes harder than it looks.',
  },
  {
    id: 'w3',
    name: 'Driftwood Club',
    category: 'weapons',
    icon: 'club',
    power: 26,
    durability: 0.5,
    modifier: { kind: 'critical' },
    description: 'Bound with rope and stubbornness. Hits from behind land a heavy blow.',
  },
  {
    id: 'w4',
    name: 'Tidecaller Spear',
    category: 'weapons',
    icon: 'spear',
    power: 83,
    durability: 0.8,
    equipped: true,
    modifier: { kind: 'attack', value: 23 },
    description:
      'A long spear that seems to lean towards the sea. It reaches further than any blade in the pack.',
  },
  {
    id: 'w5',
    name: 'Woodcutter Axe',
    category: 'weapons',
    icon: 'axe',
    power: 90,
    durability: 0.18,
    description:
      'Meant for trees, fine for anything else. The edge is chipped and close to giving.',
  },
  {
    id: 'w6',
    name: 'Traveler Sword',
    category: 'weapons',
    icon: 'great-sword',
    power: 34,
    durability: 0.6,
    modifier: { kind: 'critical' },
    description: 'A plain, well-balanced sword carried by those who walk the long roads.',
  },
  {
    id: 'w7',
    name: 'Drum Hammer',
    category: 'weapons',
    icon: 'hammer',
    power: 4,
    durability: 1,
    description: 'Mostly for breaking rock. Swinging it at anything that moves is a gamble.',
  },
  {
    id: 'w8',
    name: 'Rusty Shortsword',
    category: 'weapons',
    icon: 'sword',
    power: 50,
    durability: 0,
    modifier: { kind: 'critical' },
    description: 'It finally snapped. What is left is good for nothing but memories.',
  },
  {
    id: 'w9',
    name: 'Moon Saber',
    category: 'weapons',
    icon: 'curved-sword',
    power: 50,
    durability: 0.75,
    modifier: { kind: 'quick' },
    description: 'A thin, curved blade made for quick cuts. Light enough to swing twice.',
  },
  {
    id: 'w10',
    name: 'Ridge Axe',
    category: 'weapons',
    icon: 'axe',
    power: 62,
    durability: 0.65,
    description: 'A broad axe from the high passes. Slow, but it cleaves through guards.',
  },
  {
    id: 'w11',
    name: 'Reed Spear',
    category: 'weapons',
    icon: 'spear',
    power: 18,
    durability: 0.9,
    modifier: { kind: 'quick' },
    description: 'Light as a fishing pole and almost as cheap. Quick jabs, short life.',
  },
  {
    id: 'b1',
    name: 'Hunter Bow',
    category: 'bows',
    icon: 'bow',
    power: 12,
    durability: 0.8,
    equipped: true,
    description: 'A simple bow of bent ash. Reliable, quiet and easy to draw.',
  },
  {
    id: 'b2',
    name: 'Iron Longbow',
    category: 'bows',
    icon: 'great-bow',
    power: 28,
    durability: 0.55,
    modifier: { kind: 'durability' },
    description: 'A heavy bow with a long reach. Arrows fly flat and far.',
  },
  {
    id: 'b3',
    name: 'Spark Bow',
    category: 'bows',
    icon: 'spark-bow',
    power: 18,
    durability: 0.4,
    modifier: { kind: 'attack', value: 6 },
    description: 'Its string crackles when drawn. Arrows leave a short trail of light.',
  },
  {
    id: 's1',
    name: 'Guard Shield',
    category: 'shields',
    icon: 'shield',
    power: 16,
    durability: 0.85,
    equipped: true,
    description: 'A kite shield in faded blue. Takes a hit and asks for more.',
  },
  {
    id: 's2',
    name: 'Pot Lid Shield',
    category: 'shields',
    icon: 'round-shield',
    power: 4,
    durability: 0.3,
    modifier: { kind: 'durability' },
    description: 'It used to cover a stew. Now it covers you, more or less.',
  },
  {
    id: 's3',
    name: 'Bulwark',
    category: 'shields',
    icon: 'tower-shield',
    power: 40,
    durability: 0.95,
    description: 'A wall you can carry. Slow to raise, very hard to get through.',
  },
  {
    id: 'a1',
    name: 'Field Tunic',
    category: 'armor',
    icon: 'tunic',
    power: 3,
    durability: 1,
    equipped: true,
    description: 'Light cloth for long walks. Better than nothing, not by much.',
  },
  {
    id: 'a2',
    name: 'Iron Helm',
    category: 'armor',
    icon: 'helm',
    power: 8,
    durability: 1,
    description: 'Heavy, warm and loud when it rains.',
  },
  {
    id: 'a3',
    name: 'Climbing Boots',
    category: 'armor',
    icon: 'boots',
    power: 4,
    durability: 1,
    modifier: { kind: 'stamina' },
    description: 'Soft soles that grip wet stone. Climbing tires you a little less.',
  },
  {
    id: 'm1',
    name: 'Blue Gem',
    category: 'materials',
    icon: 'gem',
    count: 3,
    description: 'A clear blue stone. Merchants pay well for it.',
  },
  {
    id: 'm2',
    name: 'Red Cap',
    category: 'materials',
    icon: 'mushroom',
    count: 9,
    description: 'A spotted mushroom that grows near warm springs. Better cooked.',
  },
  {
    id: 'm3',
    name: 'Valley Herb',
    category: 'materials',
    icon: 'herb',
    count: 14,
    description: 'Fragrant leaves picked by the river. Good in almost any pot.',
  },
  {
    id: 'm4',
    name: 'Flint Ore',
    category: 'materials',
    icon: 'ore',
    count: 5,
    fresh: true,
    description: 'Rock with bright flecks in it. Strike it to make a spark.',
  },
  {
    id: 'f1',
    name: 'Apple',
    category: 'food',
    icon: 'apple',
    count: 12,
    hearts: 1,
    description: 'Crisp and sweet. Restores a little health.',
  },
  {
    id: 'f2',
    name: 'Hearty Skewer',
    category: 'food',
    icon: 'skewer',
    count: 2,
    hearts: 4,
    modifier: { kind: 'hearts', value: 2 },
    description: 'Grilled meat and greens on a stick. Fills you up past your limit.',
  },
  {
    id: 'f3',
    name: 'Roast Leg',
    category: 'food',
    icon: 'meat',
    count: 4,
    hearts: 3,
    fresh: true,
    description: 'Cooked over an open fire until the fat crackles.',
  },
  {
    id: 'f4',
    name: 'Energy Stew',
    category: 'food',
    icon: 'stew',
    count: 1,
    hearts: 5,
    modifier: { kind: 'stamina' },
    fresh: true,
    description: 'A thick stew that puts a spring in your step. Restores stamina too.',
  },
  {
    id: 'k1',
    name: 'Charge Bomb',
    category: 'key',
    icon: 'bomb',
    description: 'A round charge that rebuilds itself after each blast.',
  },
  {
    id: 'k2',
    name: 'Old Key',
    category: 'key',
    icon: 'key',
    count: 2,
    description: 'Opens doors in the old ruins. Nobody knows which.',
  },
  {
    id: 'k3',
    name: 'Torn Map',
    category: 'key',
    icon: 'map',
    description: 'Half a map. The cross marks somewhere north.',
  },
];
```

### item-grid/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` alone says whether it is on screen
 * in a visible tab, for clocks that should stop when nobody can see them.
 */
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 };
}
```