diff --git a/README.md b/README.md
index e92ae46..3f38f3b 100644
--- a/README.md
+++ b/README.md
@@ -1,382 +1,185 @@
# openadapt-viewer
-[](https://github.com/OpenAdaptAI/openadapt-viewer/actions/workflows/release.yml)
-[](https://pypi.org/project/openadapt-viewer/)
-[](https://opensource.org/licenses/MIT)
-[](https://www.python.org/downloads/)
+[](https://github.com/OpenAdaptAI/openadapt-viewer/actions/workflows/test.yml)
+[](https://pypi.org/project/openadapt-viewer/)
+[](https://pypi.org/project/openadapt-viewer/)
+[](LICENSE)
-Reusable component library for OpenAdapt visualization. Build standalone HTML viewers for training dashboards, benchmark results, capture playback, and demo retrieval.
+An eval finished overnight and left you a directory of JSON, a SQLite file, and
+a folder of screenshots. This turns that into one HTML file you can open,
+scroll, and mail to a colleague: metric cards, a filterable task list, click
+markers drawn over the screenshots, and playback controls for stepping through
+a recording frame by frame.
-## Features
+It's for people building on OpenAdapt who want to look at a run without
+standing up a server. It renders once and writes a file. If you want a
+dashboard that refreshes while a job is still running, look elsewhere.
-- **Component-based**: Reusable building blocks (screenshot, playback, metrics, filters)
-- **Composable**: Combine components to build custom viewers
-- **Standalone HTML**: Generated files work offline, no server required
-- **Event transcript**: Real-time audio transcription synchronized with playback
-- **Consistent styling**: Shared CSS variables and dark mode support
-- **Alpine.js integration**: Lightweight interactivity out of the box
+[PyPI](https://pypi.org/project/openadapt-viewer/) ·
+[Component reference](docs/COMPONENTS.md) ·
+[openadapt-capture](https://github.com/OpenAdaptAI/openadapt-capture), which
+records what this plays back
-## Installation
+## Sixty seconds
```bash
pip install openadapt-viewer
+openadapt-viewer demo --tasks 10 --output viewer.html
```
-Or with uv:
-```bash
-uv add openadapt-viewer
+```
+Generating demo viewer with 10 sample tasks...
+Generated: viewer.html
```
-## Quick Start
+Open `viewer.html` in a browser and you get this:
-### Using Components
+
-```python
-from openadapt_viewer.components import (
- screenshot_display,
- playback_controls,
- metrics_grid,
- filter_bar,
- badge,
-)
+Real output, macOS, 2026-08-28, with `task_003` clicked. The demo's pass and
+fail values come from an unseeded `random.random()`, so your success rate
+will probably differ and your tasks won't be these tasks. Rerun the picture with
+`python scripts/generate_demo_screenshot.py`.
-# Screenshot with click overlays
-html = screenshot_display(
- image_path="screenshot.png",
- overlays=[
- {"type": "click", "x": 0.5, "y": 0.3, "label": "H", "variant": "human"},
- {"type": "click", "x": 0.6, "y": 0.4, "label": "AI", "variant": "predicted"},
- ],
-)
+## Build a page out of parts
-# Metrics cards
-html = metrics_grid([
- {"label": "Total Tasks", "value": 100},
- {"label": "Passed", "value": 75, "color": "success"},
- {"label": "Failed", "value": 25, "color": "error"},
- {"label": "Success Rate", "value": "75%", "color": "accent"},
-])
-```
+Each component is a function that returns HTML text. Call `badge("Pass",
+color="success")` and you get this back, nothing more:
-### Using PageBuilder
+```html
+
+ Pass
+
+```
-Build complete pages from components:
+Paste that into a template you already own, or hand the pieces to
+`PageBuilder` and let it write the whole document:
```python
from openadapt_viewer.builders import PageBuilder
-from openadapt_viewer.components import metrics_grid, screenshot_display
-
-builder = PageBuilder(title="My Viewer", include_alpine=True)
-
-builder.add_header(
- title="Benchmark Results",
- subtitle="Model: gpt-5.1",
- nav_tabs=[
- {"href": "dashboard.html", "label": "Training"},
- {"href": "viewer.html", "label": "Viewer", "active": True},
- ],
-)
+from openadapt_viewer.components import badge, metrics_grid
-builder.add_section(
+page = PageBuilder(title="Nightly eval", dark_mode=True)
+page.add_header(title="Nightly eval", subtitle="run 2026-08-28")
+page.add_section(
metrics_grid([
- {"label": "Tasks", "value": 100},
- {"label": "Passed", "value": 75, "color": "success"},
+ {"label": "Tasks", "value": 42},
+ {"label": "Passed", "value": 39, "color": "success"},
+ {"label": "Failed", "value": 3, "color": "error"},
]),
title="Summary",
)
-
-# Render to file
-builder.render_to_file("output.html")
+page.add_section(badge("Pass", color="success"))
+print(page.render_to_file("eval.html"))
```
-### Ready-to-Use Viewers
-
-5 production viewers available:
-
-1. **Benchmark Viewer** - Visualize benchmark evaluation results
-2. **Capture Viewer** - Playback recorded GUI interactions
-3. **Training Dashboard** - Monitor ML training progress (via openadapt-ml)
-4. **Retrieval Viewer** - Display demo search results (via openadapt-retrieval)
-5. **Segmentation Viewer** - View episode segmentation results
-
-```python
-from openadapt_viewer.viewers.benchmark import generate_benchmark_html
-
-# From benchmark results directory
-generate_benchmark_html(
- data_path="benchmark_results/run_001/",
- output_path="viewer.html",
-)
```
-
-All viewers use the canonical component-based pattern. See `VIEWER_PATTERNS.md` for details.
-
-## CLI Usage
-
-```bash
-# Generate demo benchmark viewer
-openadapt-viewer demo --tasks 10 --output viewer.html
-
-# Generate from benchmark results
-openadapt-viewer benchmark --data results/run_001/ --output viewer.html
+eval.html
```
-## Components
-
-All components return HTML strings that can be composed together. Use them with PageBuilder or embed inline.
-
-### Core Components
-
-| Component | Description | Example Use Case |
-|-----------|-------------|-----------------|
-| `screenshot_display` | Screenshot with click/highlight overlays | Capture frames, demo screenshots |
-| `playback_controls` | Play/pause/speed controls for step playback | Video-like playback |
-| `timeline` | Progress bar for step navigation | Scrub through recordings |
-| `action_display` | Format actions (click, type, scroll, etc.) | Display action details |
-| `metrics_card` | Single statistic card | Individual metric display |
-| `metrics_grid` | Grid of metric cards | Summary dashboards |
-| `filter_bar` | Filter dropdowns with optional search | Filter and search data |
-| `filter_dropdown` | Single dropdown filter | Domain/status filters |
-| `selectable_list` | List with selection support | Task lists, file lists |
-| `list_item` | Individual list item | Custom list entries |
-| `badge` | Status badges (pass/fail, etc.) | Status indicators |
+15 KB with the stylesheet inlined. The page renders dark and the sun button in
+the header switches it; the `dark_mode` argument is ignored. There
+are 22 components: screenshot overlays, action timelines, filter bars,
+side-by-side comparison views, failure-analysis panels, and the small stuff
+like badges and metric cards. [docs/COMPONENTS.md](docs/COMPONENTS.md) has
+every signature.
-### Enhanced Components
+## Play back a capture
-| Component | Description | Example Use Case |
-|-----------|-------------|-----------------|
-| `video_playback` | Video playback from screenshot sequences | Smooth capture playback |
-| `video_playback_with_actions` | Video + synchronized action overlay | Capture with action overlay |
-| `action_timeline` | Timeline with action markers | Action sequence view |
-| `action_timeline_vertical` | Vertical action timeline | Compact action view |
-| `comparison_view` | Side-by-side comparison | Before/after, A/B test |
-| `overlay_comparison` | Overlay comparison with slider | Image comparison |
-| `action_type_filter` | Filter by action type | Filter clicks/types/scrolls |
-| `action_type_pills` | Action type pill buttons | Quick action filtering |
-| `action_type_dropdown` | Action type dropdown | Compact action filter |
-| `failure_analysis_panel` | Failure analysis dashboard | Benchmark failure analysis |
-| `failure_summary_card` | Failure summary card | Individual failure details |
-
-**Total: 22 components** available for building viewers.
-
-See `VIEWER_PATTERNS.md` for complete usage examples.
-
-## Project Structure
+```python
+from openadapt_viewer.viewers.capture.generator import generate_capture_html
+
+steps = [
+ {"timestamp": 0.0, "duration": 1.2, "action": {"type": "click", "x": 0.42, "y": 0.31}},
+ {"timestamp": 1.2, "duration": 2.4, "action": {"type": "type", "text": "Jane Doe"}},
+ {"timestamp": 3.6, "duration": 0.9, "action": {"type": "click", "x": 0.78, "y": 0.64}},
+]
+
+print(generate_capture_html(
+ capture_id="turn-off-nightshift",
+ goal="Turn off Night Shift in Display settings",
+ steps=steps,
+ output_path="capture_viewer.html",
+))
+```
```
-src/openadapt_viewer/
-├── components/ # Reusable UI building blocks
-│ ├── screenshot.py # Screenshot with overlays
-│ ├── playback.py # Playback controls
-│ ├── timeline.py # Progress bar
-│ ├── action_display.py # Action formatting
-│ ├── metrics.py # Stats cards
-│ ├── filters.py # Filter dropdowns
-│ ├── list_view.py # Selectable lists
-│ └── badge.py # Status badges
-├── builders/ # High-level page builders
-│ └── page_builder.py # PageBuilder class
-├── styles/ # Shared CSS
-│ └── core.css # CSS variables and base styles
-├── core/ # Core utilities
-│ ├── types.py # Pydantic models
-│ └── html_builder.py # Jinja2 utilities
-├── viewers/ # Full viewer implementations
-│ └── benchmark/ # Benchmark results viewer
-├── examples/ # Reference implementations
-│ ├── benchmark_example.py
-│ ├── training_example.py
-│ ├── capture_example.py
-│ └── retrieval_example.py
-└── templates/ # Jinja2 templates
+capture_viewer.html
```
-## Audio Transcript Feature
-
-The viewer includes a powerful **audio transcript** feature that displays real-time transcription of captured audio alongside the visual playback. This is particularly useful for:
-
-- **Debugging workflows**: See what was said at each step
-- **Documentation**: Auto-generate narrative descriptions of recorded sessions
-- **Analysis**: Correlate verbal instructions with UI actions
-- **Training**: Review narrated demonstrations with synchronized visuals
-
-### Key Capabilities
-
-The transcript panel provides:
-
-- **Timestamped transcription**: Each transcript segment is stamped with its time in the recording (e.g., `0:00.00`, `0:05.60`)
-- **Synchronized playback**: Transcript automatically highlights and scrolls as the video plays
-- **Searchable text**: Find specific moments in long recordings by searching transcript content
-- **Copy functionality**: Export transcript text for documentation or analysis
-
-### How It Works
-
-When captures are recorded with audio (using `openadapt-capture`'s audio recording features), the viewer automatically:
-
-1. Displays the transcript in a dedicated panel in the sidebar
-2. Timestamps each transcript segment relative to the recording start time
-3. Syncs transcript highlighting with the current playback position
-4. Updates the displayed transcript as you navigate through events
-
-The transcript appears alongside the event list and event details, providing a complete picture of what happened during the recording.
-
-## Synthetic Demo Viewer
+Give a step a `"screenshot"` key holding a path or a data URI and the player
+shows the frame with a marker on it. Click coordinates are fractions of the
+frame, not pixels, so `0.42` means 42% across.
-**NEW:** Interactive browser-based viewer for synthetic WAA demonstration data.
-
-### Quick Start
+A whole recording written by openadapt-capture goes through the benchmark
+viewer instead. Point it at the directory, which needs `episodes.json` and a
+`recording.db` inside it:
```bash
-# Open the synthetic demo viewer
-open synthetic_demo_viewer.html
+openadapt-viewer benchmark --data turn-off-nightshift --output viewer.html
```
-### What It Shows
-
-- **82 synthetic demos** across 6 domains (notepad, paint, clock, browser, file_explorer, office)
-- **Filter by domain** and select specific tasks
-- **View demo content** with syntax-highlighted steps
-- **See how demos are used** in actual API prompts
-- **Impact comparison**: 33% → 100% accuracy improvement with demo-conditioned prompting
-- **Action reference**: All 8 action types (CLICK, TYPE, WAIT, etc.)
-
-### Purpose
-
-Synthetic demos are **AI-generated example trajectories** that show step-by-step how to complete Windows automation tasks. They are included in prompts when calling Claude/GPT APIs during benchmark evaluation - this is called **demo-conditioned prompting**.
-
-**Impact:** Improved first-action accuracy from 33% to 100%!
-
-### Documentation
-
-- **Quick Start**: `QUICK_REFERENCE.md` - One-page overview
-- **Complete Guide**: `SYNTHETIC_DEMOS_EXPLAINED.md` - Full explanation
-- **Examples**: `DEMO_EXAMPLES_SHOWCASE.md` - 5 diverse demo examples
-- **Master Index**: `SYNTHETIC_DEMO_INDEX.md` - Central navigation hub
-
-### Features
-
-- Beautiful dark theme matching OpenAdapt style
-- Domain filtering (All, Notepad, Paint, Clock, Browser, File Explorer, Office)
-- Task selector with estimated step counts
-- Dual-panel display: demo content + prompt usage
-- Side-by-side impact comparison (with vs without demos)
-- Complete action types reference
-- Fully self-contained (no external dependencies)
-- Works offline
-
-See `SYNTHETIC_DEMO_INDEX.md` for complete documentation.
-
----
-
-## Screenshots
-
-### Full Viewer Interface
-
-The viewer provides a complete interface for exploring captured GUI interactions with playback controls, timeline navigation, event details, and **real-time audio transcript**.
-
-
-*Interactive viewer showing the "Turn off Night Shift" workflow with screenshot display (center), event list (right sidebar top), and **audio transcript** (right sidebar bottom)*
-
-### Playback Controls
-
-Step through captures with playback controls, timeline scrubbing, and keyboard shortcuts (Space to play/pause, arrow keys to navigate).
-
-
-*Timeline and playback controls with overlay toggle, plus event details and **synchronized transcript panel***
-
-### Event List, Details, and Transcript
-
-Browse all captured events with detailed information about each action. The **transcript panel** displays timestamped audio transcription that syncs with playback, showing exactly what was said at each moment in the recording.
-
-
-*Event list sidebar showing captured actions with timing and type information, plus **live audio transcript with timestamps***
-
-### Demo Workflow
-
-
-*Example demo workflow viewer*
-
-## Examples
-
-Run the examples to see how different OpenAdapt packages can use the component library:
-
-```bash
-# Benchmark results (openadapt-evals)
-python -m openadapt_viewer.examples.benchmark_example
-
-# Training dashboard (openadapt-ml)
-python -m openadapt_viewer.examples.training_example
-
-# Capture playback (openadapt-capture)
-python -m openadapt_viewer.examples.capture_example
-
-# Retrieval results (openadapt-retrieval)
-python -m openadapt_viewer.examples.retrieval_example
```
-
-### Generating Screenshots
-
-To regenerate the README screenshots:
-
-```bash
-# Install playwright (one-time setup)
-uv pip install "openadapt-viewer[screenshots]"
-uv run playwright install chromium
-
-# Install openadapt-capture (required)
-cd ../openadapt-capture
-uv pip install -e .
-cd ../openadapt-viewer
-
-# Generate screenshots
-uv run python scripts/generate_readme_screenshots.py
-
-# Or with custom options
-uv run python scripts/generate_readme_screenshots.py \
- --capture-dir /path/to/openadapt-capture \
- --output-dir docs/images \
- --max-events 50
+Generating benchmark viewer from: turn-off-nightshift
+Generated: viewer.html
```
-The script will:
-1. Load captures from `openadapt-capture` (turn-off-nightshift and demo_new)
-2. Generate interactive HTML viewers
-3. Take screenshots using Playwright
-4. Save screenshots to `docs/images/`
+Set `$OPENADAPT_CAPTURE_RECORDING` to that directory and you can drop `--data`.
+There's also `openadapt-viewer catalog scan --capture-dir DIR`, which indexes
+recordings into `~/.openadapt/catalog.db` so the segmentation viewer can find
+them; `catalog stats` prints the counts.
+
+## What it doesn't do
+
+The generated page loads Alpine.js from `cdn.jsdelivr.net`, so it isn't
+offline-safe. Block that request and the summary cards and the filter dropdowns
+still paint, because their markup sits in the file, but the task list comes up
+empty and clicking does nothing. `benchmark --standalone` does not help: the
+flag reaches the generator and the generator ignores it.
+
+Five more things to know before you file a bug:
+
+- The benchmark viewer writes a real recording's screenshots into the page as
+ absolute local paths, so a viewer built from a recording loses its images the
+ moment you move or mail the file. Only `demo` inlines them.
+
+- The capture viewer writes ``
+ into the page, resolved against wherever the HTML ends up. That file and its
+ companion `episode_timeline.js` ship inside the installed package, so unless
+ your output happens to land at the root of a source checkout, the episode
+ timeline renders unstyled and inert.
+- `screenshot_display` links images by path. It inlines them as base64 only
+ when you pass `embed_image=True`. Move the HTML away from the PNGs without
+ that flag and the images break.
+- Give `benchmark --data` a directory holding a pre-2026-07-17 `capture.db`
+ and it neither reads it nor says so. `LegacyCaptureError` subclasses
+ `FileNotFoundError`, the generator's fallback catches it, and you get a
+ viewer with zero tasks and a success message. The migration script named in
+ that error never reaches you.
+- `openadapt_viewer.__version__` reads `0.1.0`, which is not the packaged
+ version. Read the version from package metadata instead.
## Development
```bash
-# Clone and install
-git clone https://github.com/OpenAdaptAI/openadapt-viewer.git
-cd openadapt-viewer
+git clone https://github.com/OpenAdaptAI/openadapt-viewer && cd openadapt-viewer
uv sync --all-extras
-
-# Run tests
-uv run pytest tests/ -v
-
-# Run linter
uv run ruff check .
+uv run pytest tests/ -v
```
-## Integration
-
-Used by other OpenAdapt packages:
-
-- **openadapt-ml**: Training dashboards and model comparison
-- **openadapt-evals**: Benchmark result visualization
-- **openadapt-capture**: Capture recording playback
-- **openadapt-retrieval**: Demo search result display
+Two test modules read recordings that openadapt-capture actually wrote, rather
+than fixtures written to match this repo's idea of the schema. Clone it
+alongside and point them at it:
-## Documentation
+```bash
+export OPENADAPT_CAPTURE_DIR=/path/to/openadapt-capture
+export OPENADAPT_CAPTURE_EXAMPLES=$OPENADAPT_CAPTURE_DIR/examples/captures
+```
-- **[VIEWER_PATTERNS.md](VIEWER_PATTERNS.md)** - Canonical pattern for building viewers (MUST READ for new viewers)
-- **[MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)** - Step-by-step guide for converting inline viewers to component-based
-- **[ARCHITECTURE.md](ARCHITECTURE.md)** - System architecture and design patterns
-- **[CATALOG_SYSTEM.md](CATALOG_SYSTEM.md)** - Automatic recording discovery and indexing
-- **[SEARCH_FUNCTIONALITY.md](SEARCH_FUNCTIONALITY.md)** - Token-based search implementation
-- **[EPISODE_TIMELINE_QUICKSTART.md](EPISODE_TIMELINE_QUICKSTART.md)** - Adding episode timelines to viewers
+CI runs Python 3.10 and 3.11 on Ubuntu and macOS. `ruff check .` is a blocking
+gate over the whole repository, so lint before you push.
## License
-MIT License - see LICENSE file for details.
+MIT. See [LICENSE](LICENSE).
diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
new file mode 100644
index 0000000..1a6f2b5
--- /dev/null
+++ b/docs/COMPONENTS.md
@@ -0,0 +1,198 @@
+# Component reference
+
+Every component in `openadapt_viewer.components` is a plain function that
+returns a string of HTML. Nothing renders on its own and nothing holds state,
+so you can concatenate the output, drop it into a Jinja template you already
+have, or pass it to `PageBuilder.add_section`.
+
+Interactive components emit Alpine.js directives. They need `PageBuilder(...,
+include_alpine=True)`, which is the default, and they need the browser to reach
+`cdn.jsdelivr.net` when the page opens.
+
+To regenerate this list after changing a signature:
+
+```python
+import inspect
+import openadapt_viewer.components as c
+
+for name in c.__all__:
+ fn = getattr(c, name)
+ print(f"{name}{inspect.signature(fn)}")
+```
+
+### `screenshot_display`
+
+Render a screenshot with optional overlays.
+
+```python
+screenshot_display(image_path: 'str | Path | None' = None, width: 'int' = 800, height: 'int' = 450, overlays: 'list[Overlay] | None' = None, caption: 'str | None' = None, embed_image: 'bool' = False, placeholder_text: 'str' = 'No screenshot available', class_name: 'str' = '') -> 'str'
+```
+
+### `playback_controls`
+
+Render playback controls for step navigation.
+
+```python
+playback_controls(step_count: 'int' = 1, initial_step: 'int' = 0, speeds: 'list[float] | None' = None, default_speed: 'float' = 1.0, show_step_counter: 'bool' = True, alpine_data_name: 'str' = 'playback', class_name: 'str' = '') -> 'str'
+```
+
+### `timeline`
+
+Render a timeline progress bar.
+
+```python
+timeline(step_count: 'int' = 1, current_step: 'int' = 0, step_labels: 'list[str] | None' = None, clickable: 'bool' = True, show_markers: 'bool' = False, alpine_data_name: 'str' = 'playback', class_name: 'str' = '') -> 'str'
+```
+
+### `action_display`
+
+Render an action display with badge and details.
+
+```python
+action_display(action_type: 'str | None' = None, action_details: 'dict[str, Any] | None' = None, show_badge: 'bool' = True, show_details: 'bool' = True, show_reasoning: 'bool' = False, reasoning: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `metrics_card`
+
+Render a single metrics card.
+
+```python
+metrics_card(label: 'str', value: 'str | float', change: 'float | None' = None, color: 'str' = 'default', icon: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `metrics_grid`
+
+Render a grid of metrics cards.
+
+```python
+metrics_grid(cards: 'list[dict[str, Any]]', columns: 'int' = 4, class_name: 'str' = '') -> 'str'
+```
+
+### `filter_bar`
+
+Render a filter bar with multiple dropdowns and optional search.
+
+```python
+filter_bar(filters: 'list[FilterConfig]', search_placeholder: 'str | None' = None, search_model: 'str | None' = None, alpine_data_name: 'str' = 'filters', class_name: 'str' = '') -> 'str'
+```
+
+### `filter_dropdown`
+
+Render a single filter dropdown.
+
+```python
+filter_dropdown(filter_id: 'str', label: 'str', options: 'list[FilterOption] | list[str]', default_value: 'str' = '', alpine_model: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `selectable_list`
+
+Render a list with selection support.
+
+```python
+selectable_list(items: 'list[ListItemConfig]', title: 'str | None' = None, subtitle: 'str | None' = None, max_height: 'str' = '600px', alpine_data_name: 'str' = 'list', selected_item_var: 'str' = 'selectedItem', on_select: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `list_item`
+
+Render a single list item.
+
+```python
+list_item(item_id: 'str', title: 'str', subtitle: 'str | None' = None, badge: 'str | None' = None, badge_color: 'str' = 'info', selected: 'bool' = False, click_handler: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `badge`
+
+Render a status badge.
+
+```python
+badge(text: 'str', color: 'str' = 'info', size: 'str' = 'md', class_name: 'str' = '') -> 'str'
+```
+
+### `video_playback`
+
+Render a video playback component from screenshot frames.
+
+```python
+video_playback(frames: 'list[ScreenshotFrame] | None' = None, width: 'int' = 960, height: 'int' = 540, autoplay: 'bool' = False, loop: 'bool' = False, show_controls: 'bool' = True, show_timeline: 'bool' = True, show_frame_counter: 'bool' = True, default_fps: 'float' = 2.0, speeds: 'list[float] | None' = None, embed_images: 'bool' = False, alpine_data_name: 'str' = 'videoPlayer', class_name: 'str' = '') -> 'str'
+```
+
+### `video_playback_with_actions`
+
+Video playback with integrated action details panel.
+
+```python
+video_playback_with_actions(frames: 'list[ScreenshotFrame] | None' = None, width: 'int' = 960, height: 'int' = 540, show_action_details: 'bool' = True, **kwargs) -> 'str'
+```
+
+### `action_timeline`
+
+Render an action timeline with seek functionality.
+
+```python
+action_timeline(actions: 'list[TimelineAction] | None' = None, duration: 'float | None' = None, current_time: 'float' = 0, width: 'str' = '100%', height: 'int' = 60, show_labels: 'bool' = True, show_time_markers: 'bool' = True, clickable: 'bool' = True, alpine_sync_var: 'str | None' = None, on_seek: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `action_timeline_vertical`
+
+Render a vertical action list/timeline.
+
+```python
+action_timeline_vertical(actions: 'list[TimelineAction] | None' = None, height: 'str' = '400px', show_details: 'bool' = True, clickable: 'bool' = True, alpine_sync_var: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `comparison_view`
+
+Render a side-by-side comparison view.
+
+```python
+comparison_view(left_data: 'ComparisonData | None' = None, right_data: 'ComparisonData | None' = None, width: 'int' = 1200, height: 'int' = 450, show_diff: 'bool' = True, sync_playback: 'bool' = True, show_actions: 'bool' = True, click_tolerance: 'float' = 0.05, class_name: 'str' = '') -> 'str'
+```
+
+### `overlay_comparison`
+
+Render a single screenshot with overlays for both human and predicted actions.
+
+```python
+overlay_comparison(base_screenshot: 'str | None' = None, human_click: 'dict | None' = None, predicted_click: 'dict | None' = None, width: 'int' = 800, height: 'int' = 450, show_distance: 'bool' = True, class_name: 'str' = '') -> 'str'
+```
+
+### `action_type_filter`
+
+Render an action type filter component.
+
+```python
+action_type_filter(action_types: 'list[ActionTypeConfig] | None' = None, selected_types: 'list[str] | None' = None, show_counts: 'bool' = True, show_all_option: 'bool' = True, multi_select: 'bool' = True, alpine_model: 'str | None' = None, on_change: 'str | None' = None, layout: 'str' = 'horizontal', class_name: 'str' = '') -> 'str'
+```
+
+### `action_type_pills`
+
+Render a compact pill-style action type filter.
+
+```python
+action_type_pills(action_types: 'list[ActionTypeConfig] | None' = None, selected_types: 'list[str] | None' = None, alpine_model: 'str | None' = None, on_change: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `action_type_dropdown`
+
+Render a dropdown-style action type filter with checkboxes.
+
+```python
+action_type_dropdown(action_types: 'list[ActionTypeConfig] | None' = None, selected_types: 'list[str] | None' = None, placeholder: 'str' = 'Filter by action type', alpine_model: 'str | None' = None, on_change: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `failure_analysis_panel`
+
+Render a comprehensive failure analysis panel.
+
+```python
+failure_analysis_panel(failures: 'list[FailureRecord] | None' = None, total_tasks: 'int' = 0, show_categories: 'bool' = True, show_list: 'bool' = True, show_details: 'bool' = True, on_select_failure: 'str | None' = None, class_name: 'str' = '') -> 'str'
+```
+
+### `failure_summary_card`
+
+Render a compact failure summary card.
+
+```python
+failure_summary_card(total_failures: 'int' = 0, total_tasks: 'int' = 0, top_error_type: 'str | None' = None, top_error_count: 'int' = 0, class_name: 'str' = '') -> 'str'
+```
+
diff --git a/docs/images/demo_viewer.png b/docs/images/demo_viewer.png
new file mode 100644
index 0000000..1fda578
Binary files /dev/null and b/docs/images/demo_viewer.png differ
diff --git a/scripts/generate_demo_screenshot.py b/scripts/generate_demo_screenshot.py
new file mode 100755
index 0000000..fb4f21d
--- /dev/null
+++ b/scripts/generate_demo_screenshot.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Regenerate docs/images/demo_viewer.png, the screenshot in the README.
+
+The README shows the output of `openadapt-viewer demo`, so this script runs
+that command and photographs the result rather than mocking up a page. The
+demo's pass/fail data is unseeded, so a regenerated image will not match the
+committed one task for task. That is expected; the point is that the layout,
+the controls and the chrome in the README are what the tool actually emits.
+
+ uv run python scripts/generate_demo_screenshot.py
+
+Needs a browser (`uv run playwright install chromium`) and, because the
+generated page pulls Alpine.js from a CDN, a network connection. Without
+Alpine the task click below does nothing and the detail panel stays empty.
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_OUTPUT = REPO_ROOT / "docs" / "images" / "demo_viewer.png"
+
+# Wide enough for the four summary cards to sit on one row, which is how the
+# viewer is meant to be read.
+VIEWPORT_WIDTH = 1100
+SELECTED_TASK = "task_003"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", "-o", type=Path, default=DEFAULT_OUTPUT)
+ parser.add_argument("--tasks", "-n", type=int, default=10)
+ args = parser.parse_args()
+
+ try:
+ from playwright.sync_api import sync_playwright
+ except ImportError:
+ print("playwright is not installed: uv sync --all-extras", file=sys.stderr)
+ return 1
+
+ with tempfile.TemporaryDirectory() as tmp:
+ page_path = Path(tmp) / "viewer.html"
+ subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "openadapt_viewer.cli",
+ "demo",
+ "--tasks",
+ str(args.tasks),
+ "--output",
+ str(page_path),
+ ],
+ check=True,
+ )
+
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch()
+ page = browser.new_page(
+ viewport={"width": VIEWPORT_WIDTH, "height": 900},
+ device_scale_factor=2,
+ )
+ page.goto(page_path.as_uri())
+ # Alpine is fetched from the CDN; give it time to hydrate.
+ page.wait_for_timeout(3000)
+ page.get_by_text(SELECTED_TASK, exact=True).first.click()
+ page.wait_for_timeout(1500)
+ page.screenshot(path=str(args.output), full_page=True)
+ browser.close()
+
+ print(f"Wrote {args.output}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())