# Visual evidence  -  before/after screenshots and the UI flow video

<!-- toc -->
- [1. When it is required](#1-when-it-is-required)
- [2. Before  -  the reporter's screenshot, or nothing](#2-before---the-reporters-screenshot-or-nothing)
- [3. After  -  Phase 2, not the Phase 3 user test](#3-after---phase-2-not-the-phase-3-user-test)
- [4. Video  -  the recording rides on a test run](#4-video---the-recording-rides-on-a-test-run)
- [5. Size, and what happens when it does not fit](#5-size-and-what-happens-when-it-does-not-fit)
- [5b. Where the artefacts live  -  the host](#5b-where-the-artefacts-live---the-host)
- [6. Rendering](#6-rendering)
- [7. Blocker](#7-blocker)
- [8. State](#8-state)
<!-- /toc -->

A UI fix that reads as three changed files in a diff is not reviewable. The
reviewer cannot see what was wrong, and the tester cannot see what to look for.
This contract makes the pipeline carry the picture: the state the reporter saw,
the state the fix produces, and where possible a recording of the flow running.

The artefacts live as **Jira attachments** and are referenced from two places -
the Phase 5 Jira comment, where the picture belongs next to the work summary and
the test scenarios, and the PR body, which tells the reviewer they exist.

Consumers: Phase 2 (capture), Phase 3 (video, user test), Phase 4 (blocker),
`channels/jira.md` and `channels/pr.md` (render). Gate: `smoke-visual-evidence.sh`.

## 1. When it is required

Decided mechanically from `taskType` plus the changed-file list, never from the
model's reading of the task. A file counts as UI by stack:

| Stack | UI file test |
|---|---|
| iOS | `.swift` declaring `: View`, `: ViewController`, or under a `Views/` path |
| Android | `.kt` with `@Composable`, or declaring `: Activity` / `: Fragment` |
| Web | `.tsx` / `.jsx` / `.vue` / `.svelte` |

| Case | Before | After | Video |
|---|---|---|---|
| `taskType` is `bugfix` AND a UI file changed | required | required | when a tier allows |
| Design work: `taskType` is `component`, or Figma was referenced, or a UI file was **added** | not applicable | required | when a tier allows |
| Anything else | - | - | - |

`state.visualEvidence.required` records the verdict and which rule produced it.
Nothing about this section is optional-by-omission: when it is required and an
artefact is absent, the absence is written down with its reason (section 5).

### 1a. Who writes the verdict, and when

**Phase 0 Step 7.7 writes `required`, `requiredBy` and `platform`**, before it
probes. Naming the writer is not a formality: five phases read these three fields
and nothing set them, so `required` was never true, Step 7.7 never ran, Phase 2
never captured, and Phase 4's blocker never blocked. A contract with readers and
no writer reads exactly like a contract that is satisfied.

Phase 0 has no diff, so it decides on what it actually knows - `taskType` and
whether the intake carried a Figma reference - and that is enough for the whole
design-work row. The `bugfix` row needs a changed-file list, so Phase 0 records
it as provisional (`requiredBy: "bugfix (pending diff)"`) and **Phase 2 re-decides
from the real diff** before capturing. A provisional `false` never suppresses the
Phase 2 decision.

### 1b. The platform, and when there is not one

`platform` comes from the stack, not from the diff: it selects which device
tooling is probed, and that is a property of the repo.

```bash
eval "$(bash $HOME/.claude/lib/stack-detect.sh "$PROJECT_ROOT")"   # MA_STACKS
EVIDENCE_PLATFORM=""
case " $MA_STACKS " in
  *" ios "*)     EVIDENCE_PLATFORM=ios ;;
  *" android "*) EVIDENCE_PLATFORM=android ;;
  *" web "*)     EVIDENCE_PLATFORM=web ;;
esac
```

iOS wins a tie, and the tie is recorded in `stackWhy`: `simctl` discovery is the
cheaper of the two, and a repo that is honestly both gets the same answer on
every run rather than one that depends on ordering. Web is last for the same
reason and not because it is lesser: a repo that is both iOS and web is a mobile
app with a marketing site, and the screen the ticket is about is the mobile one.

**Web is a device platform here.** It was not until v17.1.0, and the omission was
not a decision anyone made: `run-ui-tests.sh` rejected everything but
`ios|android`, so a repo with a full Playwright suite reported "no UI test
target". The PR body then said UI tests had not run, which was true of the runner
and false of the repo - the exact shape of claim this file exists to prevent. The
browser is the device, the runner is the recorder (both Playwright and Cypress
write their own video), and `npx --no-install` is what keeps a probe from
installing one behind the user's back.

**An empty value is still a real outcome, not an error.** A backend-only repo has
nothing to probe. Phase 0 calls `probe-evidence-capability.sh --platform auto`,
which reads the stacks with `stack-detect.sh` and takes the first of ios,
android, web; with none of them nothing is probed, `platform` is written as
`other`, and the reason goes to `state.evidenceCapability.skippedReason` in the
probe's own `*_REASON` idiom - "no device platform in stacks [backend]". An
explicit empty `--platform` is still exit 2: swallowing that exit would turn a
missing value into a silent skip.

Every `--platform` argument in this pipeline is `$EVIDENCE_PLATFORM`, read from
`state.visualEvidence.platform` in later phases. `$PLATFORM` is not a pipeline
variable: nothing ever assigned it, in any phase document, which is why
`smoke-evidence-probe.sh` now fails a `--platform` argument whose variable is not
assigned earlier in the same file.

## 2. Before  -  the reporter's screenshot, or nothing

**The pipeline does not build the old state to photograph it.** Reproducing a
pre-fix screen costs a second build and a second launch on every UI bug, to
recreate evidence the reporter has usually already attached.

So: Phase 0 already fetches the issue. Any image attachment on it (`.png`,
`.jpg`, `.jpeg`, `.gif`, `.heic`) is downloaded to `$WORKTREE/.pipeline/evidence/`
and recorded as `state.visualEvidence.before[]`. More than one is kept - a bug
reported on two platforms has two.

No image on the ticket means no "before". Record the gap
(`before_missing: "ticket carries no image attachment"`) and continue. The
section still renders, saying so.

## 3. After  -  Phase 2, not the Phase 3 user test

The user test is the natural home: the simulator is already up. It is also **dropped by
every `autopilot` entry and by any run whose
workspace is local** (`phase-3-review.md` TLDR), so a capture
that lives only there produces nothing for unattended runs - which are exactly
the runs where nobody watched the screen.

The capture therefore happens in **Phase 2, after the build+test gate passes**,
which is in every mode's phase set. The Phase 3 user test may add richer evidence on top; it is
never the only source.

```bash
bash $HOME/.claude/scripts/capture-evidence.sh after \
  --task "$TASK_ID" --platform "$EVIDENCE_PLATFORM" --label "<screen-slug>"
```

What the capture is worth checking against - the per-component catalog, the
measure-never-the-token rule, the presence gate - is
`$HOME/.claude/multi-agent-refs/features/design-conformance.md`. This contract
owns getting the picture; that one owns reading it.

The script boots the device if needed, launches the built app, cleans the status
bar (`ios_status_bar preset:clean` / the Android equivalent) so the frame carries
no clock, carrier or battery noise, captures at device resolution, downscales to
at most 1242px wide, and writes
`$WORKTREE/.pipeline/evidence/<TASK_ID>-<label>-after.png`.

A clean status bar is not cosmetic: without it two captures of the same screen
differ by the clock, which makes every "after" look like a change.

## 4. Video  -  the recording rides on a test run

The flow video is not recorded on its own. It wraps something that drives the
screen, and what drives it is chosen by the user at intake, because running a UI
suite costs minutes and that is their time to spend.

### 4.1 Probe first

`probe-evidence-capability.sh` measures, before the question is asked, what this
machine and this repo can actually do: the UI test target, the tests matching
this change, the device, the recorder CLI, and whether the toolkit MCP is
registered. Detection is delegated to `run-ui-tests.sh detect`, which is also
what runs the tests, so there is one implementation of the answer.

Two rules the probe keeps, and the reason for each:

- **Absence carries a reason.** `no booted simulator, but one is available to
  boot` and `no iOS simulator available on this machine` close the same menu row
  and ask the user for completely different things.
- **Unmeasurable is `null`, never `false`.** With no `adb` on the PATH the probe
  cannot see whether a device is attached; reporting that as "no device" is a
  negative nobody looked for, and it reads exactly like one somebody checked.

### 4.2 Then the question

Phase 0 Step 7.7 asks the depth, with the options built from the probe. A closed
option stays on the list carrying its reason; when every option but the first is
closed, nothing is asked and `testDepthSource` records `forced`. The full menu
rules are in `phases/phase-0-init.md`.

### 4.3 Then the recording

| `testDepth` | Tier | What drives the screen |
|---|---|---|
| `unit+ui` | 1 | The repo's own UI test, selected by the changed files. Most faithful, and it is a test that already runs in CI |
| `unit+mcp` | 2 | `mcp__multi-agent-toolkit__agent_run_steps` drives the flow. No test target needed |
| `unit` | 3 | Nothing. No recording, and the reason is the user's own answer |

The recorder itself is `capture-evidence.sh video start|stop`, which writes
`<task-id>[-<label>]-flow.mp4` into the evidence directory. The label is optional
because one recording per task is the common case, and both renderers cite the
unlabelled form. It is shell rather than MCP for three reasons: the sibling still capture already shells out,
a host with no toolkit MCP registered still produces evidence, and Phase 2 and
Phase 3 are exactly where an MCP call is contested.

**iOS UI test targets are not named, they are detected.** A path containing
`UITests` is not the signal: in the reference app 477 files sit under such a path
and exactly 2 drive the UI, the rest being snapshot tests that render a view and
compare pixels without ever launching the app. The signal is `XCUIApplication`,
the only API that drives another process's interface, which is precisely the
precondition a flow recording has. On Android the signal is the `androidTest`
source set, which is instrumentation by definition.

**A real app has many candidates** - 17 UI test directories on the reference iOS
app, 8 instrumentation modules on the Android one. Detection reports the whole
set and lets the changed files choose; taking the first off a `find` is a guess
wearing a measurement's clothes.

### 4.4 Re-check before recording

The probe runs at intake and the recording happens in Phase 2. A simulator booted
then can be gone by the time the build goes green, so Phase 2 re-measures the
device row alone and downgrades the tier if it has to, recording the transition
(`tier 1 -> 3: simulator no longer booted`). A tier taken from a stale
measurement is a promise the run cannot keep.

### 4.5 Duration, and the recording that shows nothing

The cap is `visualEvidence.maxVideoSeconds` (default 60), read through
`capture-evidence.sh limits` so one value serves the phase doc and the recorder.
Android's `screenrecord` has its own ceiling of 180 seconds that no setting can
lift, so the script clamps to it and says when it did: one preference honoured on
one platform and silently halved on the other is worse than a stated limit.

Both recorders encode on change. A flow over a screen that never moved therefore
produces a valid two-frame mp4 a fraction of a second long - not a broken file,
but not evidence of a flow either. `video stop` says so on stderr when the result
is under a second, and the caller records that as a gap reason. The duration is
never asserted against wall clock anywhere, because doing so fails a correct
capture of a static screen.

`visualEvidence.enabled` turns the whole feature off - capture, upload, both
render sections and the Phase 4 blocker with it.

### 4.6 Web - the recorder IS the runner

Web is a real evidence platform: the schema has it in `visualEvidence.platform`,
the probe opens tier 1 and tier 2 for it, and `run-ui-tests.sh` has a web arm.
What it does NOT share with iOS and Android is the recording model, and forcing
it to would record the same run twice - Playwright and Cypress write the video
themselves.

| Step | iOS / Android | Web |
|---|---|---|
| `after` | screenshot the booted device | drive a browser at a URL |
| the URL | not needed, a simulator already shows something | `--url`, else `prefs.global.visualEvidence.webBaseUrl`, else a gap with that reason |
| `video start` | spawn a recorder, hold its pid | write a marker with the current time; spawn nothing |
| `video stop` | kill the recorder, pull the file | harvest the newest video under `test-results/` or `cypress/videos/` written AFTER the marker |
| container | mp4 already | webm re-encoded to h264, for the same reason the iOS arm re-encodes: that is what the Jira preview and the PR body play |

The marker is the part that matters and the part that is easy to leave out. Without
a timestamp to compare against, `stop` harvests whatever video is lying around -
and a video from yesterday's run attached as today's evidence is worse than no
video, because an artefact that is present does not get re-checked.

Two honest gaps, both exit 4 with a reason rather than a failure: no address to
point at, and a project whose runner config does not record video at all. Neither
is a phase failure; both are recorded and shown.

Every browser call goes through `npx --no-install`, never a bare `npx`. A bare one
DOWNLOADS the browser stack, which turns "this project has no browser tooling"
into a silent network fetch. `run-ui-tests.sh` carries the same rule for the same
reason, and it is the line that keeps the pipeline's own dependency list empty.

## 5. Size, and what happens when it does not fit

Jira's attachment ceiling is an instance setting, so it is a preference:
`visualEvidence.maxAttachmentMb` (default `10`).

Order of attempts, each one recorded:

1. Upload as captured.
2. On `413` or a local size overrun: re-encode. Video drops to 720p and a lower
   bitrate; PNG converts to JPEG at quality 80. **Reducing quality is allowed;
   dropping the artefact is not.**
3. Still over: replace the video with a four-frame contact sheet (start, two
   midpoints, end) as a single PNG, and say in the caption that the recording
   exceeded the limit.

Never silently attach nothing.

## 5b. Where the artefacts live  -  the host

Resolved in Phase 4 Step 2.9, recorded as `state.visualEvidence.host`:

| Order | Host | Condition | Stills | Video |
|---|---|---|---|---|
| 1 | `jira` | `jiraId` present | Jira attachments | Jira attachment |
| 2 | `github-public` | no Jira, GitHub remote, public repo | `evidence/<task-id>` orphan branch, embedded in the PR body | none |
| 3 | `github-private` | no Jira, GitHub remote, private repo | same branch, blob permalink in the PR body | none |
| 4 | `none` | anything else, or `githubHost: off` | not published, gap recorded | none |

**GitHub cannot be given a file.** There is no API that attaches an image to an
issue or a pull request; the web uploader posts to an endpoint that needs a
browser session, so no token can drive it. The PR body can only point at
something already hosted, and an orphan branch is the one mechanism a script has
that neither touches the PR diff nor publishes a release.

**A private repo cannot show an inline image.** GitHub renders markdown images
through its own proxy, which carries no credentials for a private repo, so an
embedded `raw.githubusercontent.com` URL renders broken for every reader
including the author. That is worse than a link, because a broken image looks
like missing evidence. The private variant therefore links rather than embeds.

**Video is Jira-only, by decision.** On a GitHub-hosted run none is recorded at
all. An mp4 behind a blob link is a download rather than something a reviewer
opens mid-review, and paying minutes of UI-test time for a recording nobody
watches is worse than saying plainly that there is none. The gap line carries
that reason.

## 6. Rendering

### Jira comment (`channels/jira.md`)

The comment's section order is fixed and nothing may be inserted between the
three sections. The evidence goes **inside** two of them:

- `summary`  -  after the summary sentences, the before/after pair as a Jira wiki
  thumbnail row: `!<TASK>-<label>-before.png|thumbnail! !<TASK>-<label>-after.png|thumbnail!`
  preceded by one line naming which is which in `outputLanguage`
  (`Düzeltme öncesi / Düzeltme sonrası`).
- `test_scenarios`  -  the flow video attached under the scenario it demonstrates:
  `!<TASK>-flow.mp4!`, one line stating the tier used.

Missing artefacts print their reason on the same line, never an empty frame.

### PR body (`channels/pr.md`)

A `visuals` section whose job is to tell the reviewer the evidence exists and
where it lives - the images themselves stay on the ticket:

```markdown
## Görsel Kanıt

- Düzeltme öncesi: <before-filename> (PROJ-123 ekinde)
- Düzeltme sonrası: <after-filename> (PROJ-123 ekinde)
- Akış videosu: <flow-filename>, tier <N> (PROJ-123 ekinde)
```

## 7. Blocker

When section 1 says required and `state.visualEvidence` carries neither an
artefact nor a recorded reason for its absence, **Phase 4 Step 3 blocks** - the
same shape as the `risk` section blocker. A recorded reason is enough to pass:
the gate is against silence, not against an honest "no image on the ticket".

## 8. State

```json
"visualEvidence": {
  "required": true,
  "requiredBy": "bugfix + ui-file-changed",
  "platform": "ios",
  "before": [{"file": "...", "source": "ticket", "jiraFilename": "...", "url": "..."}],
  "after":  [{"file": "...", "capturedAt": "phase-3", "jiraFilename": "...", "url": "..."}],
  "videoTier": 2,
  "video": {"file": "...", "seconds": 41, "jiraFilename": "...", "url": "..."},
  "gaps": [{"what": "before", "reason": "ticket carries no image attachment"}]
}
```
