# Quest log

> A game’s adventure log: quest rows by type and state, a cream selection frame that glides between them, and the quest’s story beside.

The pause-menu page where the player reviews the quests they picked up and chooses the one to follow. Icon tabs over a track sort them into main quests, trials, side quests and memories; each row carries its type’s glyph, the title and the location in the type’s colour, a breathing dot while it is new, a tab while it is the destination, dimmed once completed, a padlock while locked. The selection is one cream frame that glides from row to row, and the panel beside it tells the quest’s story with the names that matter in red. Use the arrow keys, A to set the destination, or click.

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

Category: Games · Menus.

## Anatomy

1. **Category tabs**: One icon tab per category (`role="tab"`), the open one lit and a touch larger, with its name riding over it; left and right switch them.
2. **Track and marker**: A hairline under the tabs, bright up to a shoulder marker that slides under the open tab in 260 ms with a small overshoot.
3. **Quest row**: A black bar with the type’s glyph, the title in italic and the location in the type’s colour; it is an option of a `listbox`.
4. **Destination tab**: A grey tab with a dark dot that slides out of the tracked row’s right end; only one row has it.
5. **New dot**: A ringed yellow light that breathes while a quest is new; 700 ms after the quest is selected it pops and goes, and `onSeen` is called.
6. **Selection frame**: One cream frame with notched corners, laid over the list and moved by a transform, so it glides from row to row.
7. **Detail panel**: The selected quest’s title, giver, location and story; words written in [brackets] are drawn in red. It swaps with a short fade and slide.
8. **Button hints**: Back (B) and Set Destination (A) as real buttons; the A hint reads Remove Destination on the tracked quest and greys out where it cannot act.

## When to use

- A pause-menu page where the player reviews the quests they picked up and picks the one to follow on the map.
- Tasks sorted into a few kinds (main, trials, side, memories) that the player pages through with the shoulders or Q and E.
- A list read with a controller: one frame moved by up and down, A to act, B to go back, with button hints at the foot.
- Entries that change state over the game: new until looked at, tracked as the destination, dimmed once done, padlocked until open.
- A story beside each entry, with the names that matter picked out, so the log doubles as a recap of the plot so far.

## When not to use

- A to-do list or task tracker in an app. Use a checklist with checkboxes and plain rows.
- A short objective shown while playing. Use a HUD toast or an objective line in the corner; this is a full-screen page.
- Choosing one of a few answers in a conversation. Use a choice list beside the dialogue box.
- Items with counts, stats or art to compare. Use an item grid, which shows each piece as a tile.
- Navigation between the pages of a pause menu. Use menu tabs or a system menu; the tabs here only sort quests.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/quest-log.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 { QuestLog, type Quest } from './quest-log';

const QUESTS: Quest[] = [
  {
    id: 'bells',
    type: 'main',
    title: 'Wake the Four Bells',
    location: 'Bellwater Vale',
    giver: 'The Tollwoman',
    body: ['Ring all four to break the [Long Hush] and open the road north.'],
  },
  { id: 'goat', type: 'side', title: 'The Wandering Goat', location: 'Fennick Mill', state: 'new' },
  { id: 'archive', type: 'main', title: 'The Sealed Archive', location: 'Unknown', state: 'locked' },
];

export function Journal({ onClose }: { onClose: () => void }) {
  return (
    <QuestLog
      quests={QUESTS}
      autoplay={false}
      defaultTracked="bells"
      onTrack={(quest) => console.log('Destination', quest?.title)}
      onBack={onClose}
    />
  );
}
```

## States

- **Main quests**: The defaults with `autoplay={false}`: the first quest selected and tracked, a new dot on the second.
- **Another quest selected**: `defaultSelected="four-bells"`: the frame on the third row; the destination tab stays on the first.
- **Destination set here**: `defaultTracked` is the selected quest: its tab is out and the A hint reads Remove Destination.
- **No destination**: `defaultTracked={null}`: no row shows the tab.
- **Completed**: A completed quest selected: its row is dimmed and the A hint greyed; pressing A shakes the frame.
- **Locked**: A locked quest: a padlock, “Locked” for the location, ??? in the panel and a line in place of its story.
- **Trials**: `defaultCategory="trial"`: the marker under the second tab, locations in cyan.
- **Side quests**: `defaultCategory="side"`: locations in lime.
- **Memories**: `defaultCategory="memory"`: locations in mint.
- **Empty category**: A category with no quests says “No quests yet.”, with no frame and an empty panel.
- **Narrow**: Under 700 px the panel moves under the list and the whole log scales with the list’s width.

## How it works

- Quests are a flat list, each with a type (main, trial, side, memory) and a state (default, new, completed, locked). The tabs filter it by type; each category remembers its own selected row, so going back to a category lands where the player left it.
- The selection is a single frame laid over the list and moved by translate (190 ms, an overshoot curve that passes the row by about 1.5 px and settles), so it glides instead of blinking from row to row; its notched cream corners are four small gradient triangles on a pseudo-element, above the inset glow.
- Changing category slides the shoulder marker and the bright part of the track to the new tab and moves the category name over it (260 ms); the list is keyed by category, so its rows remount and come in from the side of travel, 32 ms apart.
- The detail panel is keyed by quest: each change replays a 240 ms fade with an 8 px slide from the direction of travel (sideways for a category change). Words written in [brackets] in a paragraph are picked out in red.
- A new quest’s dot is a ringed radial gradient whose box-shadow breathes every 1.8 s. Once the row has stayed selected for 700 ms it is seen: the dot swells to 1.45 and vanishes, and onSeen is called. Passing over it quickly does not count.
- Setting the destination slides the grey tab out of the row’s right end with a small overshoot and a flash, while the previously tracked row pulls its tab back in; a new dot steps aside for it. On a completed or locked quest the frame shakes instead.
- Every size is a multiple of --u, 1 px when the plate has its full 932 px and less on a narrower stage (min(1px, 100cqi / 932)); under 700 px a container query moves the panel under the list and lets --u follow the list’s width. The plate, rows and panel stay dark on any page, as a game draws them; only the plate’s edge and shadow follow the colour scheme.
- A demo opens the log, reads a new quest, sets a destination, tries a completed one, and pages through the categories while the log is on screen and untouched; any input hands it to the reader.

## API reference

- **quests** (`Quest[]`, default `sixteen sample quests`): The quests, in order: { id, type, title, location, giver?, body?, state? }. type is main, trial, side or memory; state is default, new, completed or locked; body is one string per paragraph, with [words] picked out in red.
- **categories** (`QuestCategory[]`, default `Main Quests, Trials, Side Quests, Memories`): The tabs, in order, as { type, label }.
- **heading** (`string`, default `'Quest Log'`): The page’s title, over the log.
- **defaultCategory** (`'main' | 'trial' | 'side' | 'memory'`): The category it opens on (the first tab if not given).
- **defaultSelected** (`string`): The id of the quest selected first; its category opens.
- **defaultTracked** (`string | null`, default `'quiet-highland'`): The id of the quest whose destination is set at first, or null for none.
- **onSelect** (`(quest: Quest) => void`): Called when the selection moves to a quest, or a category opens on one.
- **onTrack** (`(quest: Quest | null) => void`): Called when the destination is set on a quest, or removed (null).
- **onSeen** (`(quest: Quest) => void`): Called when a new quest has stayed selected for 700 ms and loses its dot.
- **onBack** (`() => void`): Called on B, Escape or the Back hint.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short visit to the log: rows read, a destination set, categories paged.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the plate’s edge and shadow and the focus ring; by default it follows the page. The log itself stays dark.
- **className** (`string`): Added to the root.

## Accessibility

- The categories are a tablist of buttons named by their label, with a roving tab stop; the rows are a listbox in the tab panel, whose options are named by title, location and state (“Follow the Glimmer Map, Ashen Steps, new”) and followed with aria-activedescendant.
- Keyboard as a controller: up and down (arrows, W, S, Home, End) move the selection, left and right (arrows, Q, E, L, R) change category, A, Enter or Space set or remove the destination, B or Escape go back. The Back and Set Destination hints are buttons with aria-keyshortcuts.
- A polite live region says the category and its count when it changes, the destination set or removed, and why a completed or locked quest refuses.
- The detail panel is an article labelled by the quest’s title; the glyphs, the frame, the track and the dots are aria-hidden.
- Everything shows a :focus-visible ring, drawn around the whole list for the listbox.
- Reduced motion: the frame and the marker jump, rows appear without sliding, the panel only crossfades, the dot does not breathe and goes at once, and nothing shakes. The demo never plays.

## Source

### quest-log/quest-log.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import { FaceGlyph, GiverMark, TrackMarker, TypeGlyph } from './glyphs';
import {
  DEFAULT_CATEGORIES,
  DEFAULT_QUESTS,
  type Quest,
  type QuestCategory,
  type QuestState,
  type QuestType,
} from './quests';
import { useAutoplay } from './use-autoplay';
import styles from './quest-log.module.css';

/*
 * Quest log: the pause-menu page where the player reviews the quests they picked up and chooses
 * the one to follow. On the left, the categories (main quests, trials, side quests, memories) as
 * icon tabs over a track, and the category's quests as rows; on the right, the selected quest's
 * detail: title, giver, location and the story so far, with the names that matter in red.
 *
 * - A row is a black bar with the type's glyph, the title in italic and the location in the
 *   type's colour (yellow for main, lime for side, cyan for trials, mint for memories). A new
 *   quest has a yellow dot that breathes; a completed one is dimmed; a locked one shows a padlock;
 *   the tracked one (the destination set on the map) has a grey tab at its right end.
 * - The selection is one cream frame with notched corners, laid over the list and moved by a
 *   transform, so it glides from row to row with a little overshoot instead of blinking. The
 *   detail panel swaps with it: a short fade and a slide from the side of travel.
 * - Up and down (arrows, W/S) move the selection; left and right (arrows, Q/E, L/R) change the
 *   category: the shoulder marker slides along the track, the name swaps, the rows come in from
 *   the side of travel, staggered. A (or Enter, Space) sets the destination: the tab slides out of
 *   the row, and the one tracked before pulls back in; on a completed or locked quest the frame
 *   shakes. B (or Escape) calls onBack.
 * - A new quest that stays selected for 700 ms is seen: its dot pops and goes (onSeen).
 * - The whole log is a dark plate with its own hairline frame, so it reads on a light page or a
 *   dark one; every size is a multiple of --u, 1 px at full size, smaller on narrow stages, where
 *   the detail panel moves under the list.
 * - A demo plays a short visit to the log while it is on screen and untouched; any input hands it
 *   to the reader.
 */

export type { Quest, QuestCategory, QuestState, QuestType };
export { DEFAULT_CATEGORIES, DEFAULT_QUESTS };

export type QuestLogProps = {
  className?: string;
  /** The quests, in order. Each is listed under the category of its type. */
  quests?: Quest[];
  /** The category tabs, in order, with their names. */
  categories?: QuestCategory[];
  /** The page's title, over the log. */
  heading?: string;
  /** The category it opens on. */
  defaultCategory?: QuestType;
  /** The quest selected first (its category opens). */
  defaultSelected?: string;
  /** The quest tracked at first (its destination set), or null for none. */
  defaultTracked?: string | null;
  /** Called when the selection moves to a quest. */
  onSelect?: (quest: Quest) => void;
  /** Called when the destination is set on a quest, or removed (null). */
  onTrack?: (quest: Quest | null) => void;
  /** Called when a new quest has been looked at long enough to count as seen. */
  onSeen?: (quest: Quest) => void;
  /** Called on B, Escape or the Back hint. */
  onBack?: () => void;
  /** Plays a short visit to the log while on screen and untouched. */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

/** How long a new quest stays selected before it counts as seen. */
const SEEN_AFTER = 700;

type Step =
  { at: number; key: 'up' | 'down' | 'prev' | 'next' | 'a' } | { at: number; key: 'reset' };

/** The demo: a short visit to the log, as keys at ms from its start. */
const DEMO: Step[] = [
  { at: 0, key: 'reset' },
  { at: 900, key: 'down' },
  { at: 2300, key: 'down' },
  { at: 3100, key: 'a' },
  { at: 4300, key: 'down' },
  { at: 5000, key: 'a' },
  { at: 6200, key: 'next' },
  { at: 7100, key: 'down' },
  { at: 8500, key: 'down' },
  { at: 9300, key: 'next' },
  { at: 10200, key: 'down' },
  { at: 11500, key: 'down' },
  { at: 12100, key: 'down' },
  { at: 12900, key: 'a' },
  { at: 14000, key: 'next' },
  { at: 15000, key: 'down' },
  { at: 16600, key: 'prev' },
  { at: 17100, key: 'prev' },
  { at: 17600, key: 'prev' },
  { at: 18400, key: 'up' },
  { at: 18900, key: 'up' },
];
const DEMO_LENGTH = 20600;

const KEYS: Record<string, 'up' | 'down' | 'prev' | 'next' | 'first' | 'last'> = {
  ArrowUp: 'up',
  ArrowDown: 'down',
  w: 'up',
  s: 'down',
  ArrowLeft: 'prev',
  ArrowRight: 'next',
  q: 'prev',
  e: 'next',
  l: 'prev',
  r: 'next',
  Home: 'first',
  End: 'last',
};

/** Splits a paragraph into plain runs and [picked-out] runs. */
function Paragraph({ text }: { text: string }) {
  const parts = text.split(/(\[[^\]]+\])/);
  return (
    <p className={styles.paragraph}>
      {parts.map((part, index) =>
        part.startsWith('[') && part.endsWith(']') ? (
          <em key={index} className={styles.mark}>
            {part.slice(1, -1)}
          </em>
        ) : (
          part
        ),
      )}
    </p>
  );
}

const stateName = (quest: Quest, tracked: boolean, unseen: boolean) =>
  [
    quest.state === 'locked' ? 'locked' : quest.location,
    unseen && 'new',
    quest.state === 'completed' && 'completed',
    tracked && 'destination set',
  ]
    .filter(Boolean)
    .join(', ');

export function QuestLog({
  className,
  quests = DEFAULT_QUESTS,
  categories = DEFAULT_CATEGORIES,
  heading = 'Quest Log',
  defaultCategory,
  defaultSelected,
  defaultTracked = 'quiet-highland',
  onSelect,
  onTrack,
  onSeen,
  onBack,
  autoplay = true,
  colorScheme,
}: QuestLogProps) {
  const id = useId();
  const { ref, take, playing } = useAutoplay<HTMLDivElement>();
  const demo = playing && autoplay;

  const byType = useMemo(() => {
    const map = new Map<QuestType, Quest[]>();
    for (const category of categories) map.set(category.type, []);
    for (const quest of quests) map.get(quest.type)?.push(quest);
    return map;
  }, [quests, categories]);

  // Only the first render's defaults count.
  const [start] = useState(() => {
    const quest = quests.find((item) => item.id === defaultSelected);
    const type = quest?.type ?? defaultCategory ?? categories[0]?.type ?? 'main';
    const index = quest ? quests.filter((item) => item.type === type).indexOf(quest) : 0;
    return {
      category: Math.max(
        0,
        categories.findIndex((c) => c.type === type),
      ),
      index,
    };
  });

  const [category, setCategory] = useState(start.category);
  const [picks, setPicks] = useState<Record<string, number>>(() => ({
    [categories[start.category]?.type ?? 'main']: start.index,
  }));
  const [tracked, setTracked] = useState<string | null>(defaultTracked);
  const [seen, setSeen] = useState<Record<string, 'leaving' | 'gone'>>({});
  // Which way the last move went, for the slides: rows and panel come from that side.
  const [travel, setTravel] = useState<{ axis: 'x' | 'y'; dir: 1 | -1 }>({ axis: 'y', dir: 1 });
  const [refusals, setRefusals] = useState(0);
  const [said, setSaid] = useState('');

  const type = categories[category]?.type ?? 'main';
  const list = byType.get(type) ?? [];
  const index = Math.min(picks[type] ?? 0, Math.max(0, list.length - 1));
  const quest: Quest | undefined = list[index];
  const unseen = (item: Quest) => item.state === 'new' && seen[item.id] !== 'gone';

  const move = useCallback(
    (to: number) => {
      if (!list.length) return;
      const next = Math.max(0, Math.min(list.length - 1, to));
      if (next === index) return;
      setTravel({ axis: 'y', dir: next > index ? 1 : -1 });
      setPicks((now) => ({ ...now, [type]: next }));
      onSelect?.(list[next]);
    },
    [list, index, type, onSelect],
  );

  const switchTo = useCallback(
    (to: number) => {
      const count = categories.length;
      if (!count) return;
      const next = ((to % count) + count) % count;
      if (next === category) return;
      setTravel({ axis: 'x', dir: next > category ? 1 : -1 });
      setCategory(next);
      const nextType = categories[next].type;
      const nextList = byType.get(nextType) ?? [];
      setSaid(`${categories[next].label}, ${nextList.length} quests`);
      const pick = nextList[Math.min(picks[nextType] ?? 0, nextList.length - 1)];
      if (pick) onSelect?.(pick);
    },
    [categories, category, byType, picks, onSelect],
  );

  const confirm = useCallback(() => {
    if (!quest) return;
    if (quest.state === 'completed' || quest.state === 'locked') {
      setRefusals((count) => count + 1);
      setSaid(
        quest.state === 'locked' ? `${quest.title} is locked` : `${quest.title} is completed`,
      );
      return;
    }
    if (tracked === quest.id) {
      setTracked(null);
      onTrack?.(null);
      setSaid(`Destination removed: ${quest.title}`);
    } else {
      setTracked(quest.id);
      onTrack?.(quest);
      setSaid(`Destination set: ${quest.title}`);
    }
  }, [quest, tracked, onTrack]);

  // A new quest that stays selected for a beat is seen: its dot pops and goes.
  const markSeen = useEffectEvent((item: Quest) => {
    setSeen((now) => ({ ...now, [item.id]: 'leaving' }));
    onSeen?.(item);
  });
  useEffect(() => {
    if (!quest || quest.state !== 'new' || seen[quest.id]) return;
    const timer = window.setTimeout(() => markSeen(quest), SEEN_AFTER);
    return () => window.clearTimeout(timer);
  }, [quest, seen]);

  // The demo, on its own clock: it restarts from the top each time it is handed back.
  const step = useEffectEvent((item: Step) => {
    if (item.key === 'reset') {
      setCategory(start.category);
      setPicks({ [categories[start.category]?.type ?? 'main']: start.index });
      setTracked(defaultTracked);
      setSeen({});
      setTravel({ axis: 'x', dir: -1 });
    } else if (item.key === 'up') move(index - 1);
    else if (item.key === 'down') move(index + 1);
    else if (item.key === 'prev') switchTo(category - 1);
    else if (item.key === 'next') switchTo(category + 1);
    else confirm();
  });
  useEffect(() => {
    if (!demo) return;
    const ids: number[] = [];
    const run = (from: number) => {
      for (const item of DEMO) ids.push(window.setTimeout(() => step(item), from + item.at));
      ids.push(window.setTimeout(() => run(0), from + DEMO_LENGTH));
    };
    run(0);
    return () => ids.forEach((timer) => window.clearTimeout(timer));
  }, [demo]);

  const listRef = useRef<HTMLUListElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    take();
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
    const target = event.target as HTMLElement;
    const onTab = target.getAttribute('role') === 'tab';
    const onList = target === listRef.current;
    const action = KEYS[key];
    if (action === 'up' || action === 'down') {
      event.preventDefault();
      move(index + (action === 'down' ? 1 : -1));
      if (onTab) listRef.current?.focus();
    } else if (action === 'first' || action === 'last') {
      if (!onList) return;
      event.preventDefault();
      move(action === 'first' ? 0 : list.length - 1);
    } else if (action === 'prev' || action === 'next') {
      event.preventDefault();
      const next = category + (action === 'next' ? 1 : -1);
      switchTo(next);
      if (onTab) {
        const count = categories.length;
        tabRefs.current[((next % count) + count) % count]?.focus();
      }
    } else if (key === 'a' || (onList && (key === 'Enter' || key === ' '))) {
      event.preventDefault();
      confirm();
    } else if (key === 'b' || key === 'Escape') {
      event.preventDefault();
      setSaid('Back');
      onBack?.();
    }
  };

  const refused = quest?.state === 'completed' || quest?.state === 'locked';
  const isTracked = !!quest && tracked === quest.id;
  const optionId = (item: Quest) => `${id}-q-${item.id}`;

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-part="root"
      onPointerDown={take}
    >
      <div className={styles.plate} onKeyDown={onKeyDown} data-part="log">
        <h2 className={styles.heading} data-part="heading">
          {heading}
          <span className={styles.pager} aria-hidden="true">
            <i />
            <i />
            <i />
          </span>
        </h2>
        <div className={styles.frame} data-part="frame">
          <div
            className={styles.column}
            style={{ '--tab-n': category, '--tabs-n': categories.length } as CSSProperties}
          >
            <p className={styles.categoryName} aria-hidden="true" data-part="category-name">
              <span key={type} data-dir={travel.dir}>
                {categories[category]?.label}
              </span>
            </p>
            <div
              className={styles.tabs}
              role="tablist"
              aria-label="Quest categories"
              data-part="tabs"
            >
              {categories.map((item, at) => (
                <button
                  key={item.type}
                  ref={(element) => {
                    tabRefs.current[at] = element;
                  }}
                  type="button"
                  role="tab"
                  id={`${id}-tab-${item.type}`}
                  aria-selected={at === category}
                  aria-controls={`${id}-panel`}
                  aria-label={item.label}
                  tabIndex={at === category ? 0 : -1}
                  className={styles.tab}
                  onClick={() => switchTo(at)}
                  data-part="tab"
                >
                  <TypeGlyph type={item.type} />
                </button>
              ))}
              <span className={styles.track} aria-hidden="true" data-part="track">
                <span className={styles.marker}>
                  <TrackMarker />
                </span>
              </span>
            </div>
            <div
              className={styles.listWrap}
              role="tabpanel"
              id={`${id}-panel`}
              aria-labelledby={`${id}-tab-${type}`}
            >
              {list.length ? (
                <ul
                  ref={listRef}
                  key={type}
                  className={styles.list}
                  role="listbox"
                  tabIndex={0}
                  aria-label={categories[category]?.label}
                  aria-activedescendant={quest ? optionId(quest) : undefined}
                  data-dir={travel.axis === 'x' ? travel.dir : undefined}
                  data-part="list"
                >
                  {list.map((item, at) => {
                    const on = tracked === item.id;
                    const fresh = unseen(item);
                    return (
                      <li
                        key={item.id}
                        id={optionId(item)}
                        role="option"
                        aria-selected={at === index}
                        aria-label={`${item.title}, ${stateName(item, on, fresh)}`}
                        className={styles.row}
                        data-type={item.type}
                        data-state={item.state ?? 'default'}
                        data-selected={at === index || undefined}
                        data-tracked={on || undefined}
                        style={{ '--i': at } as CSSProperties}
                        onClick={() => {
                          move(at);
                          listRef.current?.focus({ preventScroll: true });
                        }}
                        onDoubleClick={confirm}
                        data-part="row"
                      >
                        <span className={styles.icon} data-part="icon">
                          <TypeGlyph type={item.state === 'locked' ? 'locked' : item.type} />
                        </span>
                        <span className={styles.text}>
                          <span className={styles.title} data-part="title">
                            {item.title}
                          </span>
                          <span className={styles.location} data-part="location">
                            {item.state === 'locked' ? 'Locked' : item.location}
                          </span>
                        </span>
                        {item.state === 'new' && seen[item.id] !== 'gone' && (
                          <span
                            className={styles.dot}
                            data-leaving={seen[item.id] === 'leaving' || undefined}
                            onAnimationEnd={(event) => {
                              if (event.animationName.includes('dotOut'))
                                setSeen((now) => ({ ...now, [item.id]: 'gone' }));
                            }}
                            aria-hidden="true"
                            data-part="new-dot"
                          />
                        )}
                        <span className={styles.pin} aria-hidden="true" data-part="destination" />
                      </li>
                    );
                  })}
                </ul>
              ) : (
                <p className={styles.empty}>No quests yet.</p>
              )}
              {quest && (
                <span
                  className={styles.selection}
                  style={{ '--sel': index } as CSSProperties}
                  aria-hidden="true"
                  data-part="selection"
                >
                  <span key={refusals} data-refused={refusals > 0 || undefined} />
                </span>
              )}
            </div>
          </div>
          <article
            className={styles.detail}
            aria-labelledby={`${id}-title`}
            data-part="detail"
            data-type={quest?.type}
          >
            {quest && (
              <div
                key={quest.id}
                className={styles.detailBody}
                data-axis={travel.axis}
                data-dir={travel.dir}
              >
                <h3 className={styles.detailTitle} id={`${id}-title`}>
                  {quest.title}
                </h3>
                <div className={styles.meta}>
                  <span className={styles.giver}>
                    {quest.giver && (
                      <>
                        <GiverMark />
                        <span>{quest.giver}</span>
                      </>
                    )}
                  </span>
                  <span className={styles.where}>
                    {quest.state === 'locked' ? '???' : quest.location}
                  </span>
                </div>
                {quest.state === 'locked' ? (
                  <Paragraph text="This quest has not opened yet. Keep exploring: it will find you." />
                ) : (
                  quest.body?.map((text, at) => <Paragraph key={at} text={text} />)
                )}
              </div>
            )}
          </article>
        </div>
        <div className={styles.hints} data-part="hints">
          <button
            type="button"
            className={styles.hint}
            aria-keyshortcuts="B Escape"
            onClick={() => {
              setSaid('Back');
              onBack?.();
            }}
          >
            <span>Back</span>
            <FaceGlyph letter="B" />
          </button>
          <button
            type="button"
            className={styles.hint}
            aria-keyshortcuts="A"
            aria-disabled={refused || !quest || undefined}
            onClick={confirm}
          >
            <span>{isTracked ? 'Remove Destination' : 'Set Destination'}</span>
            <FaceGlyph letter="A" />
          </button>
        </div>
        <p className={styles.live} aria-live="polite">
          {said}
        </p>
      </div>
    </div>
  );
}
```

### quest-log/quests.ts

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

import type { QuestType } from './glyphs';

export type { QuestType };

/** How a quest stands: new (not yet looked at), completed (dimmed), locked (not open yet). */
export type QuestState = 'default' | 'new' | 'completed' | 'locked';

export type Quest = {
  id: string;
  type: QuestType;
  title: string;
  /** Where it happens, under the title, in the type's colour. */
  location: string;
  /** Who gave it, beside a red mark in the detail panel. */
  giver?: string;
  /** The story so far, one string per paragraph. Words in [brackets] are picked out in red. */
  body?: string[];
  state?: QuestState;
};

export type QuestCategory = { type: QuestType; label: string };

export const DEFAULT_CATEGORIES: QuestCategory[] = [
  { type: 'main', label: 'Main Quests' },
  { type: 'trial', label: 'Trials' },
  { type: 'side', label: 'Side Quests' },
  { type: 'memory', label: 'Memories' },
];

export const DEFAULT_QUESTS: Quest[] = [
  {
    id: 'quiet-highland',
    type: 'main',
    title: 'The Quiet Highland',
    location: 'Windward Ridge',
    giver: 'Lantern Keeper',
    body: [
      'After your climb down from the watchtower, the keeper told you that the valley below fell silent a hundred years ago, the night the [Ashen Tide] rolled in.',
      'A bell is still ringing somewhere past the ridge, but you cannot reach it unless you leave the highland on the old glider.',
      'The keeper will trade the glider for whatever lies sealed in the ruins nearby...',
    ],
  },
  {
    id: 'glimmer-map',
    type: 'main',
    title: 'Follow the Glimmer Map',
    location: 'Ashen Steps',
    giver: 'Lantern Keeper',
    state: 'new',
    body: [
      'The map you found in the ruins only shows its paths by moonlight. One of them ends at the [Ashen Steps], east of the ridge.',
      'Reach the top of the steps before dawn and read the map again.',
    ],
  },
  {
    id: 'four-bells',
    type: 'main',
    title: 'Wake the Four Bells',
    location: 'Bellwater Vale',
    giver: 'The Tollwoman',
    body: [
      'Four bells once kept the vale safe. Each now hangs in a tower that has gone dark.',
      'Ring all four to break the [Long Hush] and open the road north.',
    ],
  },
  {
    id: 'old-pier',
    type: 'main',
    title: 'Light at the Old Pier',
    location: 'Saltmarsh',
    giver: 'Ferryman Oro',
    state: 'completed',
    body: [
      'You relit the beacon at the end of the pier. The ferry runs again, and Oro has promised you a ride whenever you need one.',
    ],
  },
  {
    id: 'sealed-archive',
    type: 'main',
    title: 'The Sealed Archive',
    location: 'Unknown',
    state: 'locked',
  },
  {
    id: 'echoes',
    type: 'trial',
    title: 'Trial of Echoes',
    location: 'Whisper Hollow',
    giver: 'Stone Warden',
    body: [
      'A voice inside the stone repeats every sound you make. Find the one sound it [cannot] repeat.',
    ],
  },
  {
    id: 'mirror-steps',
    type: 'trial',
    title: 'The Mirror Steps',
    location: 'Glasswater',
    giver: 'Stone Warden',
    state: 'new',
    body: [
      'The stairs only appear in the lake’s reflection. Walk them with your eyes on the water, and do not look up until you reach the [ninth step].',
    ],
  },
  {
    id: 'falling-light',
    type: 'trial',
    title: 'Catch the Falling Light',
    location: 'Ember Crag',
    giver: 'Stone Warden',
    body: ['Sparks drift down from the crag at dusk. Carry one to the altar before it goes out.'],
  },
  {
    id: 'weight-of-wind',
    type: 'trial',
    title: 'Weight of the Wind',
    location: 'High Fen',
    state: 'completed',
    body: ['The scales balanced at last. The warden left you a token of the fen.'],
  },
  {
    id: 'miller',
    type: 'side',
    title: 'A Gift for the Miller',
    location: 'Fennick Mill',
    giver: 'Miller Hesta',
    body: [
      'Hesta’s birthday is tomorrow and her son has nothing to give her. He thinks she would love a [sunpetal] from the cliffs.',
    ],
  },
  {
    id: 'lost-goat',
    type: 'side',
    title: 'The Wandering Goat',
    location: 'Fennick Mill',
    giver: 'Little Pim',
    state: 'new',
    body: ['Pim’s goat slipped its rope again. It was last seen heading for the [old orchard].'],
  },
  {
    id: 'scarecrow',
    type: 'side',
    title: 'Scarecrow’s Hat',
    location: 'Barley Rows',
    giver: 'Farmer Dov',
    state: 'completed',
    body: ['The scarecrow has its hat back, and the crows have gone elsewhere.'],
  },
  {
    id: 'traveling-show',
    type: 'side',
    title: 'The Travelling Show',
    location: 'Unknown',
    state: 'locked',
  },
  {
    id: 'rain-festival',
    type: 'memory',
    title: 'The Rain Festival',
    location: 'Bellwater Vale',
    body: [
      'Lanterns floated on the river while it rained. Someone laughed and held an umbrella over you, and you both missed the [last boat] home.',
    ],
  },
  {
    id: 'promise',
    type: 'memory',
    title: 'A Promise by the Gate',
    location: 'Windward Ridge',
    state: 'new',
    body: ['“Come back before the first snow,” she said. You said you would. You were [late].'],
  },
  {
    id: 'unseen',
    type: 'memory',
    title: 'Faded Memory',
    location: 'Unknown',
    state: 'locked',
  },
];
```

### quest-log/glyphs.tsx

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

/*
 * The quest log's art, small SVGs drawn in px of a full-size row (the CSS sizes each one, so they
 * scale with the log). One light fill, `currentColor`, so a row can dim or a tab can grey them;
 * the dark cut-outs use --ink.
 *
 * - Main: a winged emblem, a kite-shaped gem between two wings of three swept feathers.
 * - Trial: a diamond stone with a dark diamond inside and an arched doorway at its heart.
 * - Side: a speech bubble with three dots, its tail at the lower left.
 * - Memory: a keepsake picture, a card with a sun over two hills.
 * - Locked: a padlock, for a quest that does not open yet.
 * - The face buttons for the hints (A, B), and the shoulder marker that rides the category track.
 */

export type QuestType = 'main' | 'trial' | 'side' | 'memory';

export function TypeGlyph({ type }: { type: QuestType | 'locked' }) {
  if (type === 'main')
    return (
      <svg viewBox="0 0 40 28" aria-hidden="true">
        <g fill="currentColor">
          <path d="M20 1.6 L24.8 12 L20 22.4 L15.2 12 Z" />
          {[1, -1].map((side) => (
            <g key={side} transform={side < 0 ? 'translate(40 0) scale(-1 1)' : undefined}>
              <path d="M14.8 8 L0.4 3.2 L2.6 8.6 L14.4 13 Z" />
              <path d="M14.2 14 L2.8 11.8 L5.6 17 L14.8 17.8 Z" />
              <path d="M15.2 18.8 L6.8 19.6 L9.8 23.2 L17 21.6 Z" />
            </g>
          ))}
          <path d="M17.4 22.6 L20 27.2 L22.6 22.6 Z" />
        </g>
        <path d="M20 6.2 L22.2 12 L20 17.6 L17.8 12 Z" fill="var(--ink)" />
      </svg>
    );
  if (type === 'trial')
    return (
      <svg viewBox="0 0 40 28" aria-hidden="true">
        <path
          d="M20 1.2 L32.8 14 L20 26.8 L7.2 14 Z"
          fill="currentColor"
          stroke="currentColor"
          strokeWidth="1.2"
          strokeLinejoin="round"
        />
        <path d="M20 6.4 L27.6 14 L20 21.6 L12.4 14 Z" fill="var(--ink)" />
        <path d="M17.4 19 V14.6 A2.6 2.6 0 0 1 22.6 14.6 V19 Z" fill="currentColor" />
      </svg>
    );
  if (type === 'side')
    return (
      <svg viewBox="0 0 40 28" aria-hidden="true">
        <path
          d="M12.6 4.4 H27.4 A8.2 8.2 0 0 1 27.4 20.8 H17.2 L10.6 25.2 L12 20.6 A8.2 8.2 0 0 1 12.6 4.4 Z"
          fill="currentColor"
        />
        <circle cx="14.8" cy="12.6" r="2" fill="var(--ink)" />
        <circle cx="20" cy="12.6" r="2" fill="var(--ink)" />
        <circle cx="25.2" cy="12.6" r="2" fill="var(--ink)" />
      </svg>
    );
  if (type === 'memory')
    return (
      <svg viewBox="0 0 40 28" aria-hidden="true">
        <rect x="8.4" y="2.4" width="23.2" height="23.2" rx="2.4" fill="currentColor" />
        <rect x="11.4" y="5.4" width="17.2" height="13.6" fill="var(--ink)" />
        <circle cx="24.4" cy="9.2" r="2.2" fill="currentColor" />
        <path d="M11.4 19 L16.6 12.6 L20.2 16.2 L22.4 14 L28.6 19 Z" fill="currentColor" />
      </svg>
    );
  return (
    <svg viewBox="0 0 40 28" aria-hidden="true">
      <path
        d="M15 12.4 V9.4 A5 5 0 0 1 25 9.4 V12.4"
        fill="none"
        stroke="currentColor"
        strokeWidth="2.8"
      />
      <rect x="12.2" y="12" width="15.6" height="13" rx="2" fill="currentColor" />
      <circle cx="20" cy="17.4" r="1.9" fill="var(--ink)" />
      <path d="M19.1 18 h1.8 l0.5 4 h-2.8 z" fill="var(--ink)" />
    </svg>
  );
}

/** A face button: a light disc with the letter upright and bold. */
export function FaceGlyph({ letter }: { letter: string }) {
  return (
    <svg viewBox="0 0 32 32" aria-hidden="true">
      <circle cx="16" cy="16" r="16" fill="currentColor" />
      <text
        x="16"
        y="22.6"
        textAnchor="middle"
        fontSize="18.5"
        fontWeight="700"
        fontStyle="normal"
        fill="var(--ink)"
      >
        {letter}
      </text>
    </svg>
  );
}

/** The marker on the category track: a ringed R with a pointer to its right. */
export function TrackMarker() {
  return (
    <svg viewBox="0 0 36 28" aria-hidden="true">
      <path d="M27.4 9 L33.4 14 L27.4 19 Z" fill="currentColor" />
      <circle cx="14" cy="14" r="12.6" fill="currentColor" />
      <circle cx="14" cy="14" r="9.8" fill="none" stroke="var(--ink)" strokeWidth="1.6" />
      <text
        x="14"
        y="18.8"
        textAnchor="middle"
        fontSize="13"
        fontWeight="700"
        fontStyle="normal"
        fill="var(--ink)"
      >
        R
      </text>
    </svg>
  );
}

/** The giver's mark: a red disc with an exclamation point. */
export function GiverMark() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="8" cy="8" r="8" fill="var(--alert)" />
      <path d="M6.9 3.4 h2.2 l-0.4 6 h-1.4 z" fill="#fff" />
      <circle cx="8" cy="11.9" r="1.2" fill="#fff" />
    </svg>
  );
}
```

### quest-log/quest-log.module.css

```css
/* Quest log (quest-log.tsx): a game's adventure-log page on its own dark plate, centred on a
   plain stage. Every size is a multiple of --u, 1 px at full size (a 932 px plate), less on a
   narrower stage; under 700 px the detail panel moves under the list and --u follows the list's
   width instead.

   The plate, rows and panel are dark on any page, as a game draws them over its world; only the
   plate's edge and its shadow on the page follow the colour scheme, through light-dark() tokens.
   No filter touches a glyph or a word: glows are box- and text-shadows behind the sharp art.

   Motion: the selection frame and the category marker move by transitions on translate; rows,
   the panel's content, the category name, the new dot and the refusal shake are keyframes. */

.root {
  /* Text and glyphs on the plate. */
  --text: #e2ded3;
  --text-dim: #aaa79f;
  --glyph: #e2ded3;
  --glyph-off: #85837a;
  --ink: #141413;
  /* The plate, its hairline frame, its edge and its shadow on the page. */
  --plate: #1a1b17;
  --plate-deep: #121310;
  --plate-edge: rgb(255 255 255 / 0.06);
  --shade: rgb(20 18 12 / 0.24);
  --hairline: rgb(226 222 205 / 0.2);
  /* Rows. */
  --row: #141413;
  --row-edge: #2f2f2c;
  --row-hover: #33332f;
  --pin: #454440;
  --frame: #d9d5bf;
  --frame-glow: rgb(214 210 190 / 0.3);
  --new: #f0d25b;
  --new-core: #c49a22;
  /* The type colours, for the location lines. */
  --main: #e1c139;
  --side: #a1c925;
  --trial: #3cd3fc;
  --memory: #abddca;
  /* The detail panel and the picked-out words. */
  --panel: rgb(0 0 0 / 0.24);
  --panel-edge: rgb(226 222 205 / 0.16);
  --alert: #e93232;
  --mark: #f0484a;
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 400px;
  padding: 24px 0;
  box-sizing: border-box;
  color: var(--text);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --plate-edge: light-dark(rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.12));
    --shade: light-dark(rgb(20 18 12 / 0.24), 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: the whole page of the log. */
.plate {
  --u: min(1px, 100cqi / 932);
  --list-w: calc(338 * var(--u));
  --row-h: calc(46 * var(--u));
  --row-gap: calc(7 * var(--u));
  position: relative;
  width: calc(932 * var(--u));
  max-width: 100%;
  box-sizing: border-box;
  padding: calc(16 * var(--u)) calc(28 * var(--u)) calc(16 * var(--u));
  border-radius: calc(8 * var(--u));
  background:
    radial-gradient(120% 90% at 50% 0%, rgb(255 255 240 / 0.035), transparent 60%),
    linear-gradient(var(--plate), var(--plate-deep));
  box-shadow:
    inset 0 0 0 1px var(--plate-edge),
    0 calc(8 * var(--u)) calc(30 * var(--u)) var(--shade);
}

.heading {
  display: grid;
  justify-items: center;
  gap: calc(3 * var(--u));
  margin: 0 0 calc(12 * var(--u));
  font-size: calc(21 * var(--u));
  font-style: italic;
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.01em;
  color: var(--text);
}
.pager {
  display: flex;
  gap: calc(3 * var(--u));
}
.pager i {
  width: calc(3 * var(--u));
  height: calc(3 * var(--u));
  border-radius: 50%;
  background: var(--glyph-off);
}
.pager i:nth-child(2) {
  background: var(--text);
}

/* The hairline frame around the list and the panel, with a notch at its left corners. */
.frame {
  position: relative;
  display: grid;
  grid-template-columns: var(--list-w) minmax(0, 1fr);
  column-gap: calc(48 * var(--u));
  padding: calc(20 * var(--u)) calc(24 * var(--u)) calc(26 * var(--u));
  border: 1px solid var(--hairline);
  border-radius: calc(2 * var(--u));
  background:
    linear-gradient(135deg, var(--hairline) 0 calc(4 * var(--u)), transparent 0) top left /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat,
    linear-gradient(45deg, var(--hairline) 0 calc(4 * var(--u)), transparent 0) bottom left /
      calc(8 * var(--u)) calc(8 * var(--u)) no-repeat;
}

/* The left column: the category name, the tabs and their track, the rows. */
.column {
  --tab-w: calc(40 * var(--u));
  --tab-step: calc(47 * var(--u));
  --tabs-w: calc((var(--tabs-n) - 1) * var(--tab-step) + var(--tab-w));
  display: grid;
  grid-template-rows: auto auto 1fr;
  min-width: 0;
}
.categoryName {
  position: relative;
  height: calc(18 * var(--u));
  margin: 0 0 calc(4 * var(--u));
  font-size: calc(12.5 * var(--u));
  font-style: italic;
  font-weight: 500;
  line-height: calc(18 * var(--u));
  color: var(--text);
}
/* It rides over the active tab, as the marker rides under it. */
.categoryName span {
  position: absolute;
  left: calc(50% - var(--tabs-w) / 2 + var(--tab-n) * var(--tab-step) + var(--tab-w) / 2);
  translate: -50% 0;
  white-space: nowrap;
  transition: left 260ms cubic-bezier(0.25, 1.2, 0.45, 1);
  animation: nameIn 260ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.categoryName span[data-dir='1'] {
  --dir-x: calc(10 * var(--u));
}
.categoryName span[data-dir='-1'] {
  --dir-x: calc(-10 * var(--u));
}
@keyframes nameIn {
  from {
    opacity: 0;
    translate: calc(-50% + var(--dir-x, 0px)) 0;
  }
}

.tabs {
  position: relative;
  display: flex;
  justify-content: center;
  gap: calc(var(--tab-step) - var(--tab-w));
  padding-bottom: calc(28 * var(--u));
}
.tab {
  display: grid;
  place-items: center;
  width: var(--tab-w);
  height: calc(30 * var(--u));
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--glyph-off);
  cursor: pointer;
  transition:
    color 160ms,
    scale 200ms cubic-bezier(0.3, 1.6, 0.5, 1);
  --ink: #1a1b17;
}
.tab svg {
  width: 100%;
  height: calc(28 * var(--u));
  overflow: visible;
}
.tab:hover {
  color: #b9b6aa;
}
.tab[aria-selected='true'] {
  color: var(--glyph);
  scale: 1.04;
}
.tab:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
/* The track under the tabs, bright up to the marker. */
.track {
  position: absolute;
  left: 50%;
  bottom: calc(15 * var(--u));
  width: calc(var(--tabs-w) + 12 * var(--u));
  height: 1px;
  translate: -50% 0;
  background: rgb(226 222 211 / 0.38);
}
.track::before {
  content: '';
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(25 * var(--u) + var(--tab-n) * var(--tab-step) + var(--tab-w) / 2);
  background: rgb(226 222 211 / 0.85);
  transition: width 260ms cubic-bezier(0.25, 1.2, 0.45, 1);
}
.marker {
  position: absolute;
  top: 0;
  left: calc(6 * var(--u) + var(--tab-n) * var(--tab-step) + var(--tab-w) / 2);
  width: calc(27 * var(--u));
  height: calc(21 * var(--u));
  translate: calc(9 * var(--u)) -50%;
  color: var(--glyph);
  --ink: #1a1b17;
  transition: left 260ms cubic-bezier(0.25, 1.35, 0.45, 1);
}
.marker svg {
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* The rows, and the selection frame over them. */
.listWrap {
  position: relative;
  min-height: calc(5 * var(--row-h) + 4 * var(--row-gap));
}
.list {
  display: grid;
  gap: var(--row-gap);
  margin: 0;
  padding: 0;
  list-style: none;
  outline: none;
}
.list:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: calc(3 * var(--u));
  border-radius: calc(3 * var(--u));
}
.row {
  --accent: var(--main);
  position: relative;
  display: flex;
  align-items: center;
  height: var(--row-h);
  box-sizing: border-box;
  padding-left: calc(6 * var(--u));
  overflow: hidden;
  border: 1px solid var(--row-edge);
  border-radius: calc(2 * var(--u));
  background: var(--row);
  cursor: pointer;
  transition: border-color 160ms;
}
.row:hover {
  border-color: #4a4944;
}
.row[data-type='side'] {
  --accent: var(--side);
}
.row[data-type='trial'] {
  --accent: var(--trial);
}
.row[data-type='memory'] {
  --accent: var(--memory);
}
/* A new category's rows come in from the side of travel, staggered. */
.list[data-dir] .row {
  animation: rowIn 300ms cubic-bezier(0.2, 0.9, 0.3, 1.08) calc(var(--i) * 32ms) both;
}
.list[data-dir='-1'] .row {
  animation-name: rowInBack;
}
@keyframes rowIn {
  from {
    opacity: 0;
    translate: calc(26 * var(--u)) 0;
  }
}
@keyframes rowInBack {
  from {
    opacity: 0;
    translate: calc(-26 * var(--u)) 0;
  }
}
.icon {
  display: grid;
  place-items: center;
  flex: none;
  width: calc(40 * var(--u));
  height: calc(30 * var(--u));
  margin-right: calc(5 * var(--u));
  color: var(--glyph);
}
.icon svg {
  width: 100%;
  height: calc(28 * var(--u));
  overflow: visible;
}
.text {
  display: grid;
  min-width: 0;
  padding-right: calc(34 * var(--u));
  font-style: italic;
}
.title {
  overflow: hidden;
  font-size: calc(19.5 * var(--u));
  font-weight: 600;
  line-height: calc(21 * var(--u));
  letter-spacing: -0.005em;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--text);
}
.location {
  overflow: hidden;
  margin-top: calc(-1 * var(--u));
  font-size: calc(11 * var(--u));
  font-weight: 700;
  line-height: calc(13 * var(--u));
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--accent);
}
.row[data-state='completed'] .icon,
.row[data-state='completed'] .text {
  opacity: 0.24;
}
.row[data-state='locked'] .icon,
.row[data-state='locked'] .title {
  opacity: 0.42;
}
.row[data-state='locked'] .location {
  color: var(--text-dim);
  opacity: 0.6;
}

/* The new dot: a ringed yellow light that breathes, and pops away once seen. */
.dot {
  position: absolute;
  top: 50%;
  right: calc(8 * var(--u));
  width: calc(12 * var(--u));
  height: calc(12 * var(--u));
  margin-top: calc(-6 * var(--u));
  border-radius: 50%;
  background: radial-gradient(
    circle,
    var(--new) 0 26%,
    var(--new-core) 30% 44%,
    var(--new) 50% 100%
  );
  box-shadow: 0 0 calc(5 * var(--u)) rgb(255 220 90 / 0.55);
  transition: right 220ms cubic-bezier(0.3, 1.2, 0.5, 1);
  animation: breathe 1.8s ease-in-out infinite;
}
.row[data-tracked] .dot {
  right: calc(32 * var(--u));
}
@keyframes breathe {
  50% {
    box-shadow: 0 0 calc(9 * var(--u)) calc(1 * var(--u)) rgb(255 220 90 / 0.8);
  }
}
.dot[data-leaving] {
  animation: dotOut 360ms cubic-bezier(0.4, 0, 0.6, 1) forwards;
}
@keyframes dotOut {
  35% {
    scale: 1.45;
    box-shadow: 0 0 calc(12 * var(--u)) calc(2 * var(--u)) rgb(255 230 120 / 0.9);
  }
  to {
    scale: 0;
    opacity: 0;
  }
}

/* The destination tab: slides out of the row's right end when the quest is tracked. */
.pin {
  position: absolute;
  top: calc(1 * var(--u));
  right: calc(1 * var(--u));
  bottom: calc(1 * var(--u));
  width: calc(22 * var(--u));
  border-radius: calc(2 * var(--u)) 0 0 calc(2 * var(--u));
  background:
    radial-gradient(circle, var(--ink) 0 calc(2.2 * var(--u)), transparent calc(2.9 * var(--u))),
    var(--pin);
  translate: 105% 0;
  transition: translate 200ms cubic-bezier(0.4, 0, 0.6, 1);
}
.row[data-tracked] .pin {
  translate: 0 0;
  transition-timing-function: cubic-bezier(0.25, 1.4, 0.5, 1);
  transition-duration: 300ms;
  animation: pinFlash 520ms ease-out;
}
@keyframes pinFlash {
  from {
    background-color: #8d8b80;
  }
}

/* The selection: one frame over the list, moved to the selected row. */
.selection {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: var(--row-h);
  pointer-events: none;
  translate: 0 calc(var(--sel) * (var(--row-h) + var(--row-gap)));
  transition: translate 190ms cubic-bezier(0.25, 1.3, 0.5, 1);
}
.selection span {
  position: absolute;
  inset: 0;
  border: calc(1.25 * var(--u)) solid var(--frame);
  border-radius: calc(2 * var(--u));
  box-shadow:
    inset 0 0 0 calc(1.5 * var(--u)) rgb(20 20 19 / 0.9),
    inset 0 0 calc(7 * var(--u)) calc(2 * var(--u)) var(--frame-glow),
    0 0 calc(6 * var(--u)) rgb(255 250 225 / 0.14);
  animation: frameSettle 260ms ease-out;
}
.selection span::before {
  content: '';
  position: absolute;
  inset: calc(-1.25 * var(--u));
  background:
    linear-gradient(135deg, var(--frame) 0 calc(5.8 * var(--u)), transparent 0) top left /
      calc(12 * var(--u)) calc(12 * var(--u)) no-repeat,
    linear-gradient(225deg, var(--frame) 0 calc(5.8 * var(--u)), transparent 0) top right /
      calc(12 * var(--u)) calc(12 * var(--u)) no-repeat,
    linear-gradient(45deg, var(--frame) 0 calc(5.8 * var(--u)), transparent 0) bottom left /
      calc(12 * var(--u)) calc(12 * var(--u)) no-repeat,
    linear-gradient(315deg, var(--frame) 0 calc(5.8 * var(--u)), transparent 0) bottom right /
      calc(12 * var(--u)) calc(12 * var(--u)) no-repeat;
}
@keyframes frameSettle {
  from {
    box-shadow:
      inset 0 0 0 calc(1.5 * var(--u)) rgb(20 20 19 / 0.9),
      inset 0 0 calc(10 * var(--u)) calc(3 * var(--u)) rgb(230 226 205 / 0.45),
      0 0 calc(10 * var(--u)) rgb(255 250 225 / 0.3);
  }
}
.selection span[data-refused] {
  animation: refuse 340ms cubic-bezier(0.3, 0.6, 0.4, 1);
}
@keyframes refuse {
  20% {
    translate: calc(-6 * var(--u)) 0;
  }
  45% {
    translate: calc(5 * var(--u)) 0;
  }
  70% {
    translate: calc(-3 * var(--u)) 0;
  }
  88% {
    translate: calc(1 * var(--u)) 0;
  }
}
.empty {
  margin: calc(20 * var(--u)) 0 0;
  font-size: calc(15 * var(--u));
  font-style: italic;
  color: var(--text-dim);
}

/* The detail panel. */
.detail {
  --accent: var(--main);
  min-width: 0;
  margin-top: calc(26 * var(--u));
  min-height: calc(330 * var(--u));
  padding: calc(22 * var(--u)) calc(24 * var(--u));
  box-sizing: border-box;
  border: 1px solid var(--panel-edge);
  background: var(--panel);
  overflow: hidden;
}
.detail[data-type='side'] {
  --accent: var(--side);
}
.detail[data-type='trial'] {
  --accent: var(--trial);
}
.detail[data-type='memory'] {
  --accent: var(--memory);
}
.detailBody {
  max-width: calc(330 * var(--u));
  font-style: italic;
  animation: swapY 240ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
.detailBody[data-dir='-1'] {
  animation-name: swapYBack;
}
.detailBody[data-axis='x'] {
  animation-name: swapX;
}
.detailBody[data-axis='x'][data-dir='-1'] {
  animation-name: swapXBack;
}
@keyframes swapY {
  from {
    opacity: 0;
    translate: 0 calc(8 * var(--u));
  }
}
@keyframes swapYBack {
  from {
    opacity: 0;
    translate: 0 calc(-8 * var(--u));
  }
}
@keyframes swapX {
  from {
    opacity: 0;
    translate: calc(14 * var(--u)) 0;
  }
}
@keyframes swapXBack {
  from {
    opacity: 0;
    translate: calc(-14 * var(--u)) 0;
  }
}
.detailTitle {
  margin: 0;
  padding-bottom: calc(3 * var(--u));
  border-bottom: 1px solid rgb(226 222 211 / 0.38);
  font-size: calc(19.5 * var(--u));
  font-weight: 600;
  line-height: calc(25 * var(--u));
  color: var(--text);
}
.meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(12 * var(--u));
  min-height: calc(18 * var(--u));
  margin: calc(3 * var(--u)) 0 calc(18 * var(--u));
  font-size: calc(12.5 * var(--u));
  line-height: calc(16 * var(--u));
}
.giver {
  display: flex;
  align-items: center;
  gap: calc(4 * var(--u));
  min-width: 0;
  font-weight: 500;
  color: var(--text-dim);
}
.giver svg {
  flex: none;
  width: calc(13 * var(--u));
  height: calc(13 * var(--u));
}
.where {
  font-weight: 700;
  white-space: nowrap;
  color: var(--accent);
}
.paragraph {
  margin: 0 0 calc(15 * var(--u));
  font-size: calc(15 * var(--u));
  font-weight: 400;
  line-height: calc(19 * var(--u));
  color: var(--text);
}
.mark {
  font-style: inherit;
  color: var(--mark);
}

/* The button hints at the foot. */
.hints {
  display: flex;
  justify-content: flex-end;
  gap: calc(26 * var(--u));
  margin-top: calc(12 * var(--u));
  padding-right: calc(10 * var(--u));
}
.hint {
  display: inline-flex;
  align-items: center;
  gap: calc(6 * var(--u));
  margin: 0;
  padding: calc(2 * var(--u)) calc(4 * var(--u));
  border: 0;
  border-radius: calc(4 * var(--u));
  background: none;
  color: var(--text);
  font: inherit;
  font-size: calc(14 * var(--u));
  font-style: italic;
  font-weight: 500;
  cursor: pointer;
  touch-action: manipulation;
  --ink: #1a1b17;
}
.hint svg {
  width: calc(17 * var(--u));
  height: calc(17 * var(--u));
  color: var(--glyph);
}
.hint[aria-disabled='true'] {
  opacity: 0.4;
}
.hint:hover span {
  color: #fff;
}
.hint:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

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

/* A narrow stage: the panel moves under the list, and --u follows the list's width. */
@container (max-width: 699px) {
  .plate {
    --u: min(1px, 100cqi / 382);
    --list-w: 100%;
    width: 100%;
    padding: calc(14 * var(--u)) calc(12 * var(--u));
  }
  .frame {
    grid-template-columns: minmax(0, 1fr);
    padding: calc(16 * var(--u)) calc(10 * var(--u)) calc(14 * var(--u));
  }
  .detail {
    min-height: 0;
    margin-top: calc(18 * var(--u));
    padding: calc(16 * var(--u)) calc(16 * var(--u)) calc(4 * var(--u));
  }
  .detailBody {
    max-width: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .selection,
  .marker,
  .track::before,
  .categoryName span,
  .dot,
  .pin,
  .tab {
    transition: none;
  }
  .list[data-dir] .row,
  .categoryName span,
  .selection span,
  .selection span[data-refused],
  .row[data-tracked] .pin,
  .dot {
    animation: none;
  }
  .detailBody,
  .detailBody[data-dir='-1'],
  .detailBody[data-axis='x'],
  .detailBody[data-axis='x'][data-dir='-1'] {
    animation: fade 140ms ease-out;
  }
  .dot[data-leaving] {
    animation-duration: 1ms;
  }
}
@keyframes fade {
  from {
    opacity: 0;
  }
}
```

### quest-log/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.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

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

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

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