# Component catalogs

Auto-generated visual catalogs for each SageOx product surface. Browsable at
[`sageox-design.netlify.app/catalog/`](https://sageox-design.netlify.app/catalog/).

## Why every surface ships a catalog

> Codified as **[DESIGN.md §11 Rule 7](https://sageox-design.netlify.app/docs/DESIGN/#7-every-surface-ships-a-live-agent-iterable-design-catalog)** — required for every SageOx surface.

In an AI-native company the people most likely to be looking at any given
component on any given day are **coding agents**, not humans. An agent
improving a component, fixing visual drift, or learning a surface for the
first time can only do its job if the live component catalog is *one route
away*. Hand-curated documentation goes stale; a route that renders the
actual component registry never does.

This is also the only honest mechanism for **ralph loops** — an agent
iterating on a component while continuously re-checking its real rendered
output. The catalog is the harness for those loops; without it, the
feedback signal is somebody manually screenshotting an app.

The deal each surface team makes:

1. **Live catalog** — every surface exposes its catalog at a discoverable,
   stable route (`/internal/design-catalog` for web, `<cli> dev catalog`
   for terminal, etc.) so agents can find it without out-of-band
   knowledge.
2. **Snapshot** — the same catalog gets exported to a static HTML file in
   this directory by a `publish-design-catalog.sh` script in the surface's
   repo. Sageox-design.netlify.app serves the snapshot publicly.
3. **Source-of-truth registry** — the catalog reads from one registry
   file in the surface's repo (no parallel hand-curated docs). A lint
   check fails the build if a new component is added without a registry
   entry.

## Standard routes by surface type

| Surface type | Live route | Snapshot export |
|---|---|---|
| Web app (authenticated console) | `/internal/design-catalog` | `catalog/console/index.html` |
| Web marketing site (public) | `/internal/design-catalog` (on the marketing host) | `catalog/marketing/index.html` |
| CLI / TUI | `<cli> dev catalog` + `freeze` SVG export | `catalog/<surface>/index.html` (inline SVGs + asciinema) |
| Embedded device | host-side screenshot extraction (`make catalog-capture HOST=...` against the device's `/ui/screenshot` endpoint) — see [Embedded carve-out](#embedded-surfaces--live-route-carve-out) | `catalog/<surface>/index.html` (PNG per screen) |
| Mobile | `internal://design-catalog` deep link | `catalog/<surface>/index.html` (screen recordings) |
| Email | `<service>/internal/email-catalog` (renders every template) | `catalog/email/index.html` (template HTML + per-client screenshots) |

New surface? Pick the row that fits, name your route the same way, and
ship the snapshot before the surface ships to users.

### Embedded surfaces — live-route carve-out

Embedded devices (Ox Dot today; future Ox-class hardware tomorrow)
don't host an in-device design-catalog browser. The round 360×360 LCD
doesn't accommodate a TOC-and-figures layout, and the device's HTTP
debug surface is dev-only and dedicated to log / diagnostic endpoints,
not catalog browsing. The "LVGL headless render" formulation that
appears in earlier drafts of this README was aspirational — no
embedded surface has actually wired one up, and the bar for doing so
(porting `lib/ui/` to a host LVGL build path that doesn't depend on
device-only fonts, image data, and theme tokens) is higher than the
payoff.

For embedded surfaces, the **screenshot extraction script is the live
route**. The web/CLI equivalent of "navigate to `/internal/design-catalog`
and see the components as they currently render" is, for embedded:

```bash
make catalog-capture HOST=<device>.local   # pulls live PNGs from the device
make catalog-build                          # assembles into self-contained HTML
```

`catalog-capture` POSTs to the firmware's `/test/ui/show?screen=NAME`
and snaps `/ui/screenshot` — the framebuffer is the source of truth.
`catalog-build` is mechanical assembly from those captures. The live
visuals come from the device every time you re-run capture; the
output HTML is byte-identical to what the next publish pushes.

What substitutes for each in-device-route benefit:

| Benefit a live route normally provides | Embedded substitute |
|---|---|
| Discoverability for agents and humans | The snapshot URL (`sageox-design.netlify.app/catalog/<surface>/`) is the canonical browse target. Documented in the surface repo's `CLAUDE.md`. |
| Ralph-loop freshness | `make catalog-capture HOST=...` + `make catalog-build` is the loop: re-snap PNGs from the running device, rebuild, observe. Device must be flashed with the iteration under test; no separate "open this URL" step. |
| Live visuals from the running surface | The screenshot extraction script (`make catalog-capture`) drives the device through every registered screen via `/test/ui/show` and snaps `/ui/screenshot`. The framebuffer is the source of truth — same role `react-dom/server.renderToStaticMarkup` plays for web. |
| Static fallback when the device isn't reachable | `existing_png` field on each `Entry` points at a checked-in PNG (e.g. `docs/screens/v2/idle.png`). The build prefers device-captured PNGs when present in `assets/`, falls back to `existing_png` otherwise. The snapshot caption tells the reader which they're looking at. |

A future embedded surface that ships an in-device catalog browser
(unlikely but possible — QR-code + tap-through, or a companion-app
browser) should add itself to the table proper rather than relying on
this carve-out. The carve-out is the documented exit, not a permanent
license.

**Coverage lint still applies.** Embedded surfaces must still run a
lint that fails when a new screen / component lacks a registry entry —
the carve-out only affects the live route, not the source-of-truth or
screenshot requirements.

## Structure

```
catalog/
  index.html          ← surfaces landing page (this directory's homepage)
  console/            ← apps/web · authenticated console surface
    index.html        ← generated by sageox-mono's publish-design-catalog.sh
    README.md
  cast/               ← Visual Cast (packages/cast-web · TV + join), published from sageox-mono
  mcp/                ← SageOx rendered inside other AI tools (ui:// App cards + connect hub). v0 mockup-seeded; sageox-mono registry export pending (DDR-005)
  mobile/             ← planned: native iOS + Android companion app
  ox-dot/             ← planned: Ox Dot device UI
  cli/                ← sageox/ox (freeze SVG snapshots + asciinema players)
  marketing/          ← planned: sageox.ai · public marketing site
  email/              ← transactional + lifecycle emails, published from sageox-mono (build-email-catalog.ts)
```

Listed in the same order they appear on `/catalog/`'s landing page so
adding a new surface is one mental model: drop a directory matching its
slug, register the card in `index.html`, ship the snapshot.

**Note:** the **marketing site** is treated as a distinct surface from the
console. They share tokens and the brand language but serve different
audiences (prospects vs. logged-in users) and lean on different patterns
(hero sections / pricing / testimonials vs. data tables / forms /
dashboards). Each gets its own catalog so marketing-only patterns don't
leak into product code and vice versa.

Snapshot-only — no versioning. Each surface's repo owns its own export
pipeline and pushes the latest snapshot here. Git history in each source
repo is the time machine if you need older states.

## The contract for surface repos

Every surface repo publishes its catalog via a `publish-design-catalog.sh`
script that satisfies the following contract:

| Element | Contract |
|---|---|
| **Source of truth** | A single registry file in the surface's repo enumerating every reusable component. |
| **Build** | A command that produces a single self-contained `index.html` in `.context/catalog-out/<surface>/` (or equivalent local path). Inline CSS, optional inline JS for chrome toggles only, fonts via CDN. No external asset dependencies. |
| **Push** | `publish-design-catalog.sh` copies the output into `<sageox-design-checkout>/catalog/<surface>/`. Surface slugs (in landing-page order): `console`, `mcp`, `cast`, `mobile`, `ox-dot`, `cli`, `marketing`, `email`. |
| **Coverage lint** | A CI check that fails when an in-scope component — or, for email, any template the platform can **send from any service** — lacks a registry entry. Coverage is send-scoped, not single-app-scoped. See `apps/web/scripts/lint-catalog-coverage.ts` and [DDR-007](../decisions/007-catalog-snapshot-freshness.md). |
| **Freshness** | The snapshot must not silently lag the product. The source repo regenerates and opens a PR against `sageox-design` whenever catalog inputs change, and stamps each `catalog/<surface>/` with a `PROVENANCE.yaml` (`source_repo`, `source_sha`, `generated_at`). Snapshots are never hand-edited. See [DDR-007](../decisions/007-catalog-snapshot-freshness.md). |
| **Chrome** | Top header with Mode / Calm / Notes toggles. Left-rail TOC ordered **Surfaces → Components → Tokens** (see § _Reading order_ below). Tokens mirrored from `sageox-design/tokens/`. |
| **Screenshots (Tier 3)** | **Every Tier 3 surface MUST ship a full-frame screenshot** at `<surface-repo>/catalog-screenshots/<surface-slug>/<surface-id>.png`. See § _Screenshot requirement_ below. |
| **Script header** | Every `publish-design-catalog.sh` (and any associated build / screenshot-capture / coverage scripts) MUST start with a comment block pointing at this README. See § _Script header_ below — this is how guidance propagates. |

### Reading order — Surfaces first, then Components, then Tokens

Every catalog presents its content in this top-to-bottom order, with the
left-rail TOC matching:

1. **Surfaces** — whole-screen flows the user actually experiences.
   Login, dashboard, settings, an error state, an empty state, a busy
   state. Real screenshots. This is what the surface *looks like in
   use*, end to end.
2. **Components** — the reusable primitives those surfaces are
   composed of. Buttons, inputs, modals, cards, toasts. Each shown
   in all variants and states.
3. **Tokens** — the raw values underneath: color scales, type ramp,
   spacing, motion. The bottom of the stack.

**Why this order matters.** Components have meaning *in context*. An
agent picking through components without seeing the surfaces first
has no grounding for why each one exists or how to compose them; a
human reviewer wants to scan "does this look right end-to-end" before
"is each button using the right token." Component-first ordering was
the old engineer-up-from-primitives model. Surface-first ordering
reflects how the work is actually evaluated in 2026: by the user
experience, then by the harness that produced it.

### Script header — guidance propagates through comments

Every script that publishes into a catalog (`publish-design-catalog.sh`,
its build helper, its screenshot-capture script, the coverage linter)
**MUST start with a header comment** pointing at this file:

```bash
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# publish-design-catalog.sh — <surface name> catalog publish
#
# READ FIRST (before any change): catalog/README.md in sageox-design
#   https://sageox-design.netlify.app/catalog/README.md
#   https://github.com/sageox/sageox-design/blob/main/catalog/README.md
#
# This script implements the contract defined there:
#   · Source of truth = single registry file in this repo
#   · Build = self-contained index.html
#   · Push = into <sageox-design>/catalog/<surface>/
#   · Coverage lint, chrome, screenshots (Tier 3), reading order
#     (Surfaces → Components → Tokens), script-header requirement
#
# When you find yourself wanting to deviate from the contract,
# propose the change in catalog/README.md first — don't fork
# behavior here.
# ─────────────────────────────────────────────────────────────
```

The TypeScript / JS equivalent uses `//` comments with the same content.
The Python equivalent uses `"""..."""` docstrings.

**Why.** The contract evolves. When a future agent (you, later) is
asked to modify a publish script, the first thing they'll see is this
header pointing at the canonical source — so today's guidance
propagates to tomorrow's edits without anyone having to remember it.
Without the header, agents copy patterns from the script they're
editing and the contract silently drifts.

### Screenshot requirement (Tier 3)

The catalog is **not just a component library** — it's a record of every
real screen in the product. Without screenshots, a reader can't tell what
the surface actually looks like; they have to clone the repo, set up auth,
seed test data, and navigate to the route just to grok the design. That's
hours of friction for a decision that should take seconds.

**Every `SurfaceEntry` ships a screenshot.** No exceptions. The build
script renders a "Screenshot missing — required" placeholder where one is
absent, and the contract is enforced socially in code review until the
coverage lint extends to cover screenshot presence.

**Layout.** Screenshots are tracked alongside the catalog source, per
surface slug:

```
<surface-repo>/
  catalog-screenshots/
    cast/
      cast-meeting.png       # matches SurfaceEntry.id
      cast-idle.png
      cast-team-pulse.png
      join-public.png
      …
    console/
      kb-list.png
      kb-detail.png
      settings-account.png
      …
```

The build script copies any PNG whose filename matches a `SurfaceEntry.id`
into `<OUT_DIR>/screenshots/` and references it as
`<img src="screenshots/<id>.png">` on the catalog page.

**Capture.** The single-screen capture path is
`apps/web/scripts/capture-catalog-screenshot.sh`. It wraps headless Chrome:

```bash
# A running dev server (most authenticated surfaces):
pnpm dev &
scripts/capture-catalog-screenshot.sh console kb-list http://localhost:3000/kb

# A static mockup (no auth needed):
scripts/capture-catalog-screenshot.sh console kb-list \
  "file://$HOME/Code/sageox/sageox-design/proposals/2026-05-15-kb-pages-mockup/v2/index.html"

# Mobile dims (390x844):
scripts/capture-catalog-screenshot.sh cast join-public http://localhost:3000/join 390 844
```

For batch captures across many surfaces, a Playwright harness is
forthcoming — same output path, parallel runs, deterministic test-data
seeding. Until then, single-screen captures + checked-in mockup renders
are the bar.

**Acceptable sources, in order of preference:**

1. **Live screenshot of the real product** (running dev server with
   seeded test data). Most honest; ages with the surface.
2. **Render of the canonical mockup** from `sageox-design/proposals/…`
   (e.g. the KB v2 mockup → console/kb-list.png). Use when the live
   surface hasn't been polished to a catalog-worthy state.
3. **PR-vintage baseline** kept in the dated proposal's `_existing/` dir.
   Use when reconstructing history.

**Dimensions.** TV surfaces: 1920×1080. Mobile surfaces: 390×844 (iPhone
14 logical pixels). Console / desktop: 1920×1080 default.

The catalog page captions each screenshot — "Live" if
`SurfaceEntry.conformsToKbStandard === 'yes'`, "Target · KB v2 mockup"
otherwise — so readers can tell which sources they're looking at.

### Reference implementation (console)

- Registry: [`sageox-monorepo/apps/web/src/lib/catalog/registry.tsx`](https://github.com/sageox/sageox-monorepo/tree/main/apps/web/src/lib/catalog/registry.tsx)
- Build: [`scripts/build-static-catalog.ts`](https://github.com/sageox/sageox-monorepo/tree/main/apps/web/scripts/build-static-catalog.ts) — `react-dom/server` + Tailwind CLI scan-mode
- Push: [`scripts/publish-design-catalog.sh`](https://github.com/sageox/sageox-monorepo/tree/main/apps/web/scripts/publish-design-catalog.sh)
- Pnpm scripts: `catalog:build`, `catalog:publish`, `catalog:lint`

### Adapting the push script for your surface

Copy `publish-design-catalog.sh` into your repo and edit the four
"Per-surface configuration" variables at the top. Everything else is
surface-agnostic:

```bash
SURFACE="${CATALOG_SURFACE:-cast}"        # /catalog/<this>/
BUILD_CMD=(pnpm catalog:build)            # or: make catalog, ./build.sh, …
BUILD_CWD_REL=".."                        # cwd relative to the script
OUT_REL=".context/catalog-out/${SURFACE}" # where your build emits HTML
REPO_ROOT_REL="../../.."                  # script → repo git root
```

Then any contributor in your repo can run:

```bash
SAGEOX_DESIGN_REPO=~/Code/sageox/sageox-design \
  bash scripts/publish-design-catalog.sh --pr
```

…and a PR lands in `sageox/sageox-design` with the refreshed snapshot.

## Rendering technology by surface

| Surface | Renderer |
|---|---|
| **Web (console, RN, cast)** | `react-dom/server.renderToStaticMarkup` + Tailwind v4 CLI in `@source` scan-mode |
| **Email** | `@react-email/render` → real cross-client HTML (table-based, inline-styled). Each template renders to its own `templates/<id>.html`, embedded in the catalog page via `<iframe>` so reviewers see the actual artifact the recipient gets, not a re-skin of it. |
| **CLI** (`ox`) | [asciinema](https://asciinema.org/) `.cast` recordings embedded with the [asciinema-player](https://github.com/asciinema/asciinema-player) web component (preferred default); [`charmbracelet/freeze`](https://github.com/charmbracelet/freeze) for inline SVG fallback snapshots; [`charmbracelet/vhs`](https://github.com/charmbracelet/vhs) tapes only as a legacy escape hatch |
| **Embedded** (Ox Dot) | Headless LVGL renderer producing PNGs, stitched into HTML |

The push script doesn't care which renderer you used — it only requires
that the surface produces a valid `<surface>/index.html` at a known path.

### Asciinema is the default for CLI surfaces

As of May 2026, animated terminal recordings via `.cast` are the
preferred default for CLI catalogs. The format is plain JSON-Lines —
text-diffable in git, ~10× smaller than GIFs, lossless at any zoom — and
the player applies themes client-side, so the catalog's Mode toggle
(light/dark) re-themes every recording without re-buffering.

**Self-contained constraint**: the single-file `index.html` rule means
the asciinema-player JS + CSS must be **inlined** into `<surface>/index.html`
rather than fetched from a CDN, and cast payloads are inlined as
`<script type="application/json">` blocks then handed to
`AsciinemaPlayer.create({data: …}, element, opts)`. One file, no
external assets, snapshot ages out cleanly years from now.

**Static fallback**: every `.cast` also produces a one-frame SVG (via
`agg --one-shot` or `freeze`) shown inside `<noscript>` and as a player
poster frame. Progressive-enhancement crawlers and no-JS readers still
see the component.

Reference: [sageox/ox · internal/uicatalog/htmlexport.go](https://github.com/sageox/ox/tree/main/internal/uicatalog/htmlexport.go)
produces a conformant `cli/index.html`.

## Why static + auto-generated, not hand-authored

The live interactive catalog lives behind auth on the product domain
(e.g. `test.sageox.ai/internal/design-catalog`). That's where engineers
iterate.

This public mirror exists for:
- **Designer reference** — browse without an account
- **Partner pitches** — share a URL, no auth needed
- **Component archaeology** — visual record of what existed when

A hand-authored static page would drift the moment any component changed.
Auto-generation means the snapshot is always accurate at export time.

## Onboarding new surface teams

Drop the prompt below in a Linear ticket or to an AI coworker:

> Publish your surface's component catalog to
> `sageox-design.netlify.app/catalog/<surface>/`. Reference implementation:
> [sageox-monorepo/apps/web](https://github.com/sageox/sageox-monorepo/tree/main/apps/web).
> Required outputs: a registry, a build script that emits one
> self-contained `index.html`, a `publish-design-catalog.sh` that copies
> the output into a local sageox-design checkout and opens a PR. Match
> the chrome conventions (Mode/Calm/Notes toggles, left-rail TOC,
> mirrored tokens). Snapshot only — don't version. Coverage-lint enforces
> that new components ship with a registry entry. See
> `sageox-design.netlify.app/catalog/README.md` for the full contract.
