# UI-Review Run/Auth Recipe Contract

The `/dev-loops:loop-review-ui` command (or `/loop-review-ui` in the dev-loops repo itself) reviews a pull request by proving the change in the **running app** from an isolated worktree. Each project declares its own recipe for booting the app, logging in as the change's target role and driving UI flows. This doc is the contract a **consuming repo** satisfies so the command can run against it.

All keys below exist in the shipped config schema (`packages/core/src/config/config.mjs`) and are enforced at load time. `test/docs/ui-review-recipe-doc.test.mjs` fails CI if the [config key reference](#config-key-reference) drifts from the schema in either direction.

Every value shown is a **generic example**. Replace it with your project's own. No downstream or private identifiers belong in this repo.

## Prerequisites — install the browser runner

Playwright is an **optional peer dependency** of `dev-loops`, so nothing installs it for you.

```
npm install --save-dev @playwright/test
npx playwright install webkit
```

Stage 2 (`ui-review-drive`) and the loop-grill's `visual-grill-capture` stage
launch headless WebKit through it. With either the package or the browser binary
missing, both stages **stop with the install command as their stated reason** and
report no failures — a setup gap on your machine is never charged to the PR under
review.

`@axe-core/playwright` is a separate opt-in, only needed for the
computed-accessibility artifact:

```
npm install --save-dev @axe-core/playwright
```

The drive resolves it from the reviewed worktree first (its `package.json`), then
from the dev-loops install, so a globally installed dev-loops works when the
consumer repo lists the package. When neither location has it, `axe.json` holds
`{ "skipped": true, "reason": "axe: @axe-core/playwright not resolvable from <worktree> or dev-loops" }`
(never a bare `null`) and the rest of the review still works. The drive also logs one stderr line `[ui-review-drive] <reason>` per run when axe is skipped.

## Where the recipe lives

The recipe is declared in your repo-root `.devloops` file (`.devloops.yaml`,
`.yml`, and `.json` are also accepted). Two sections are involved:

- `worktree` — which gitignored files/dirs to provision into a fresh worktree.
- `uiReview` — the boot recipe, the dev-login recipe, the changed-flow
  allowlist, and the caps.

`uiReview` is optional. When a section it needs is absent, the corresponding
stage **stops with that as a stated reason** rather than guessing how to run or
drive your app.

## The loop, in one pass

`resolve → provision/boot → auth+drive → diagnose → report → teardown`

1. **Provision + boot** — provisions an isolated worktree for the PR head,
   installs dependencies with the frozen install matching the worktree lockfile
   (`bun.lock`, `pnpm-lock.yaml`, `package-lock.json` or `yarn.lock`; first present
   wins) when the lock differs from the primary checkout's or the worktree has no
   `node_modules`, runs pending dev-DB migrations, then boots the app and polls an
   HTTP readiness probe. Boot stdout and stderr go to
   `<tmpdir>/dev-loops-ui-review-pr-<n>-XXXXXX/boot.log`. If the boot process exits before
   the app is ready, the stage stops at once with a `boot-exited` finding that
   carries the exit code and the last log lines.
2. **Auth + drive** — launches one headless WebKit context, authenticates
   through your dev-login recipe, dismisses declared interstitials once, then
   walks the changed UI flows, capturing a screenshot + `state.json` + `snapshot.json` + `axe.json` + `console.json` per step.
   Each run carries a unique **drive-session id** on the
   `X-UI-Review-Drive-Session` request header and emits a **row manifest** — one
   session-stamped record per mutating step — so teardown can drop exactly the
   dev-DB rows the walk created (see `uiReview.run.rowTeardown`).
3. **Diagnose** — maps each captured failure to a source line and then to a PR
   diff line so a finding can anchor on a real changed line.
4. **Report** — posts a head-pinned **pending** PR review and produces a
   self-contained HTML artifact.
5. **Teardown** — stops the booted app and, only with explicit confirmation,
   drops the drive-tagged dev-DB rows (see `uiReview.run.rowTeardown`), removes
   the worktree, and prunes the Stage-4 hosting gist (`gh gist delete`, when a
   gist id is recorded in the report result). Row-drop deletes exactly the rows
   the drive's manifest tags with that run's drive-session id. An absent
   manifest, an untagged one, or a missing `rowTeardown` recipe **fails closed**
   (rows may remain). A side-effect ledger is ALWAYS emitted, enumerating what
   was done vs. left behind.

## Guardrails (non-negotiable)

- **Sources-only edits.** The loop never edits generated `.claude/**` directly;
  if command help changes, sources are edited and `.claude/**` is regenerated
  via `scripts/claude/generate-claude-assets.mjs`.
- **Worktree-only.** The loop refuses to operate in the primary checkout and
  refuses a run-recipe `cwd` that resolves outside the provisioned worktree.
- **Dev-DB-only + destructive-migration acknowledgement.** Migrations run
  against the branch's dev DB only. A migration whose status output matches the
  destructive pattern is refused unless the run explicitly acknowledges it.
- **Outward review defaults to pending/draft.** The report stage never
  auto-submits; a confirmed user-facing server error maps to a change request
  **only when submit is separately authorized**.
- **Bounded caps are logged.** Every ceiling (max screenshots, max flows, max
  steps per flow) is enforced and any overflow is logged, never silent.
- **Branch-controlled input is trusted-branch input.** The run command and the
  login form come from the PR branch and are executed as such — not treated as
  untrusted data.

## `worktree` — provisioning gitignored files

A fresh worktree starts from a clean checkout, so anything gitignored that the
app needs at boot (installed dependencies, a local DB config) must be provided.
`worktree.entries` is a list of `{ path, mode }`: `path` is a repo-relative
literal path or glob pattern, `mode` is `copy` (isolated writable copy) or
`link` (absolute symlink into the main checkout, shared, treat as read-only).

```yaml
worktree:
  entries:
    - path: node_modules          # example: large, immutable install shared read-only
      mode: link
    - path: config/database.local.yml   # example: gitignored local DB config
      mode: copy
```

## `uiReview.run` — boot recipe (required to boot)

- `uiReview.run.command` — shell command that starts the app. It must stay in
  the foreground. An exit before readiness, including exit code 0, stops the
  stage with `boot-exited`.
- `uiReview.run.readyUrl` — an `http(s)` URL an HTTP probe polls until the app
  answers (never a fixed sleep).
- `uiReview.run.readyTimeoutMs` — probe budget in ms (default `60000`).
- `uiReview.run.readyIntervalMs` — probe interval in ms (default `1000`).
- `uiReview.run.cwd` — optional worktree-relative subdir to run `command` in.
- `uiReview.run.migrate` — optional dev-DB migration sub-recipe:
  - `uiReview.run.migrate.statusCommand` — lists pending migrations (one per line).
  - `uiReview.run.migrate.applyCommand` — applies them.
  - `uiReview.run.migrate.destructivePattern` — optional regex, matched
    case-insensitively per line **against the status output**, not the
    migration files. The shipped default detects SQL-bearing status output
    (`DROP`/`TRUNCATE`/`DELETE FROM`). If your status command emits migration
    identifiers or filenames instead, the default matches nothing and the
    destructive guard is inert — set a `destructivePattern` matching your own
    status format (e.g. a `destructive`/`down` marker), or make `statusCommand`
    emit the destructive SQL/marker.
- `uiReview.run.rowTeardown` — optional dev-DB row-teardown sub-recipe. The drive
  advertises a per-run drive-session id on the `X-UI-Review-Drive-Session` request
  header; instrument your create/edit/upload paths to tag the rows they persist
  with that header value so teardown can remove exactly them.
  - `uiReview.run.rowTeardown.deleteCommand` — shell command that deletes the rows
    tagged with a drive session. Teardown runs it in the provisioned worktree (dev
    DB) with the session id in the `UI_REVIEW_DRIVE_SESSION` env var, only on
    `--confirm`. Absent recipe means row-drop fails closed (rows may remain).

```yaml
uiReview:
  run:
    command: "bin/example-app-server --port 4000"   # example
    readyUrl: "http://127.0.0.1:4000/healthz"        # example
    readyTimeoutMs: 90000
    readyIntervalMs: 1000
    cwd: "web"                                        # example subdir
    migrate:
      statusCommand: "bin/example-migrate status"    # example
      applyCommand: "bin/example-migrate up"         # example
    rowTeardown:
      deleteCommand: "bin/example-cleanup --session \"$UI_REVIEW_DRIVE_SESSION\""  # example
```

## `uiReview.login` — dev-login recipe

The drive stage obtains a session by driving this login form in the browser.
The credential is a **shared dev-only value** (a dev password or role), never a
real user secret.

- `uiReview.login.loginUrl` — an `http(s)` URL of the login page.
- `uiReview.login.usernameSelector` / `uiReview.login.usernameValue` — optional
  username field selector and its value.
- `uiReview.login.passwordSelector` / `uiReview.login.passwordValue` — optional
  password field selector and its dev-only value.
- `uiReview.login.submitSelector` — **required**; the submit control.
- `uiReview.login.successSelector` — **required**; the element that proves the
  session was established. Without it the drive stage cannot confirm auth and
  fails closed.

```yaml
uiReview:
  login:
    loginUrl: "http://127.0.0.1:4000/login"   # example
    usernameSelector: "#email"                  # example
    usernameValue: "dev@example.test"           # example dev-only account
    passwordSelector: "#password"               # example
    passwordValue: "dev-only-password"          # example dev-only credential
    submitSelector: "button[type=submit]"       # example
    successSelector: "[data-testid=user-menu]"  # example proof-of-session
```

## `uiReview.interstitials` — one-time dismissals

Selectors dismissed **once per browser context** (cookie consent, a welcome
modal). Best-effort — a missing interstitial is not an error.

- `uiReview.interstitials[].selector` — the element to click to dismiss.

```yaml
uiReview:
  interstitials:
    - selector: "#cookie-consent-accept"   # example
```

## `uiReview.flows` — the changed-flow allowlist

Flows are an **explicit allowlist**, never an unbounded crawl. Each flow
declares `pathPatterns` (plain substrings matched against the PR's changed file
paths); a flow with none is always driven, and an unknown diff drives every
allowlisted flow. The selection is capped and any overflow logged.

- `uiReview.flows[].name` — flow label.
- `uiReview.flows[].pathPatterns` — optional substrings scoping the flow to a diff.
- `uiReview.flows[].steps[].action` — one of `goto`, `click`, `fill`, `select`,
  `upload`, `dispatch`.
- `uiReview.flows[].steps[].name` — optional step label.
- `uiReview.flows[].steps[].selector` — required for every action except `goto`.
- `uiReview.flows[].steps[].path` — required for `goto` (the app path to visit).
- `uiReview.flows[].steps[].value` — required for `upload` (the file path);
  also the typed/selected value for `fill`/`select`.
- `uiReview.flows[].steps[].event` — optional event name for `dispatch`.
- `uiReview.flows[].steps[].viewport` — optional `{ width, height }`; resizes the
  page before the step and slugs the capture so a responsive render lands in its
  own reviewable directory. The viewport is **sticky**: an omitted `viewport`
  inherits the last set size. There is no reset sentinel. To render a later step
  at a different or the original size, give that step an explicit `viewport`.
- `uiReview.flows[].steps[].interactionState` — optional `none`/`focus`/`hover`/`error`;
  labels a stateful render the route names, slugged into its own directory.

```yaml
uiReview:
  flows:
    - name: "create widget"                 # example
      pathPatterns: ["app/widgets/", "widgets_controller"]  # example
      steps:
        - { action: goto, path: "/widgets/new" }
        - { action: fill, selector: "#widget_name", value: "Example" }
        - { action: click, selector: "button[type=submit]" }
```

## `uiReview.caps` — bounded ceilings

Every field is optional and clamped at resolve time — a project may only
**tighten** a cap, never loosen the shipped ceiling. Retries are pinned at 0.

- `uiReview.caps.maxScreenshots`
- `uiReview.caps.maxFlows`
- `uiReview.caps.maxStepsPerFlow`

```yaml
uiReview:
  caps:
    maxScreenshots: 40
    maxFlows: 5
    maxStepsPerFlow: 20
```

## `uiReview.serverLogPath` / `uiReview.serverLogExceptionPattern`

Optional. The drive stage tails your server log so a swallowed 500 the UI hid
behind a success state is still recorded.

- `uiReview.serverLogPath` — filesystem path (worktree-relative or absolute).
- `uiReview.serverLogExceptionPattern` — optional regex; the default is a
  heuristic you **must override** when your log format differs.

```yaml
uiReview:
  serverLogPath: "log/development.log"           # example
  serverLogExceptionPattern: "(FATAL|ERROR|Exception)"  # example — match your format
```

## Artifact hosting (Stage 4)

The report stage always produces a self-contained, CSP-safe HTML artifact
(ranked findings, the reproduced-evidence screenshot and one labeled screenshot
per named state, each inlined as a data URI within the size cap, also when there
are 0 findings; no external resources).

- On the **Claude Code** harness the stage emits a publishable directive, and the
  orchestrating agent publishes the artifact via **Claude Code Artifacts**. The
  review body links the hosted result. The module never calls an Artifacts tool
  itself, and the core report flow does not depend on this path.
- On **any other harness** the portable **GitHub-native default** publishes the
  self-contained HTML as a **secret GitHub Gist** (`gh gist create`). The review
  body links the gist and points at its **raw** file, because a gist renders HTML
  as source. **Stage 5 teardown prunes the gist** (`gh gist delete`) when the run
  records the gist id in the report result and teardown is confirmed. If gist
  creation does not yield a URL, the stage **fails closed with a stated reason**:
  the review body states the artifact is unhosted and why, and never links a
  fabricated URL.

  Setup a consuming repo must provide: an authenticated **`gh`** with the **`gist`**
  scope (`gh auth login`/`gh auth refresh -s gist`). No `.devloops` config key is
  involved — hosting rides on the same `gh` auth the loop already uses for reviews.
  An explicit `--hosted-url` overrides the gist path when you host the HTML yourself.

## Config key reference

Every `uiReview.*` and `worktree.*` key this doc references.

<!-- ui-review-config-keys:start -->
- `worktree.entries[].path`
- `worktree.entries[].mode`
- `worktree.commitMsgGuard.requireClaudeSession`
- `uiReview.run.command`
- `uiReview.run.readyUrl`
- `uiReview.run.readyTimeoutMs`
- `uiReview.run.readyIntervalMs`
- `uiReview.run.cwd`
- `uiReview.run.migrate.statusCommand`
- `uiReview.run.migrate.applyCommand`
- `uiReview.run.migrate.destructivePattern`
- `uiReview.run.rowTeardown.deleteCommand`
- `uiReview.login.loginUrl`
- `uiReview.login.usernameSelector`
- `uiReview.login.usernameValue`
- `uiReview.login.passwordSelector`
- `uiReview.login.passwordValue`
- `uiReview.login.submitSelector`
- `uiReview.login.successSelector`
- `uiReview.interstitials[].selector`
- `uiReview.flows[].name`
- `uiReview.flows[].pathPatterns`
- `uiReview.flows[].steps[].name`
- `uiReview.flows[].steps[].action`
- `uiReview.flows[].steps[].selector`
- `uiReview.flows[].steps[].path`
- `uiReview.flows[].steps[].value`
- `uiReview.flows[].steps[].event`
- `uiReview.flows[].steps[].viewport.width`
- `uiReview.flows[].steps[].viewport.height`
- `uiReview.flows[].steps[].interactionState`
- `uiReview.caps.maxScreenshots`
- `uiReview.caps.maxFlows`
- `uiReview.caps.maxStepsPerFlow`
- `uiReview.serverLogPath`
- `uiReview.serverLogExceptionPattern`
<!-- ui-review-config-keys:end -->

## See also

- [UI Validation Contract](./ui-validation-contract.md)
- [UI Artifact Contract](./ui-artifact-contract.md)
- [Worktree Usage Guidance](./worktree-guidance.md)
