# Proof audit, pass 1 — ZIP attachment preview

## Findings

1. **Capture handoff, not merge approval.** This is a fresh derivation from the complete diff for [#19463](https://git.cafe/pingdotgg/t3code/pull/19463), head [420bc45432](https://github.com/pingdotgg/t3code/commit/420bc45432a7560a11dc4d0b710cd4155f716c0f), against merge-base [f3b058cd6a](https://github.com/pingdotgg/t3code/commit/f3b058cd6a737e51894bc265927560b23742f0f0). The worktree HEAD and merge-base match. No repository edits, captures, devices, servers, pushes, comments, or tests were made/run by this audit. Current GitCafe metadata reports the same head and 39 successful checks; that is not visual proof. The earlier matrix was not read or reused.
2. **46 fixture rows, without a Cartesian product:** 37 new mobile states, six regression states for the extracted attachment body, three web import-path gates. Most mobile states use one archive and consecutive chapters of a recording. A row is a visual equivalence class, not a new device lease. Different parser messages sharing the same layout are data variants within one row; they do not need one capture each. No full light × dark × Android × iOS expansion is required.
3. **Use the actual limits:** archive/download and aggregate expanded bytes 50 MiB, at most 2,048 ZIP entries including directories, entry preview 10 MiB, text preview 1 MiB; the shared reader also caps each `.xml` expansion at 8 MiB and expansion ratio at 200. The old description's 32 MiB / 2,000 files plus 2,000 folders / 64 MiB save limits do not describe this revision. Entry Save/Add remain available above the 10 MiB preview limit, provided the archive itself passes the 50 MiB guards.
4. **Web can be reduced to three cold paths.** ZIP/archive binary worker, SQLite binary worker, and Office worker reach every moved runtime module. Existing [web evidence](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/57794f78c72c551d/evidence-section.md) explicitly supplies all three at these exact revisions (W01/W19, W19 SQLite, W11/W18/W19 Word), plus rejection and recovery recordings. Its reported 24 completed rows already exceed this change's visual no-regression minimum. No new web capture is required for this narrowed matrix. Screenshots do not prove production chunk/performance parity: constants now value-import `boundedZip`, which imports JSZip. The existing traces are warmed-Vite dev traces, explicitly not production transfer proof. No production-performance conclusion is made here.
5. **Android and native-viewer branches matter.** An entry with Preview + Add + Save has three actions: Android columns below 600 logical px show one direct action and an overflow menu; columns at least 600 px show all three. iOS uses a native toolbar menu. Image, PDF, and document/video previews resolve through different Android implementations and through iOS Quick Look, so those representative rows require both platforms. Other shared tree/error/body layouts need one platform each.
6. **Do not invent production state controls.** No production RPC holds a stream at a chosen byte count, controls ETA, delays a file write, or makes the native share service unavailable. Where those states cannot be driven by a recorded external network/device fault, the table explicitly requires a labelled native harness capture. A mocked DOM test is not mobile evidence. Natural app opening and actions should still be recorded; a transient not visible in that recording remains uncovered unless a labelled controlled capture supplies it.
7. **A shared mobile Before is contextual, not a pixel-diff substitute.** Main's ZIP body is the generic “No preview for this file” screen. Reuse one representative Before per device/theme for new ZIP-only states, and a draft Before if demonstrating Remove. Preserve its name/size/source in the manifest. Different outer names, sizes, or draft status change the old header; label such reuse “shared generic ZIP baseline,” not an exact matched screenshot pair. Regular attachment regressions use actual matched Before/After files and route metadata.

8. **Save expectation is source-confirmed:** `shareGeneratedAttachment` catches sharing-availability failure and returns false; the new entry action runner treats any resolved promise as success and ignores that boolean. Therefore unavailable sharing is a silent return, not the “Could not save file” alert. M35 distinguishes the two so the capturer cannot accidentally claim a failure alert that the code never emits. This is a known code caveat, not a newly run reproduction.

## Capture notation and minimum

- **AP:** Pixel 9 portrait, approximately 411 × 923 logical px, 1080 × 2424 physical at DPR 2.625 in the prior device setup. Record the device's actual returned metrics, safe areas, font scale, and orientation rather than assuming those values.
- **IP:** iPhone 17 Pro portrait, 402 × 874 logical px, 1206 × 2622 physical at DPR 3. Use actual returned metrics.
- **AW:** Pixel 9 landscape, approximately 923 × 411 logical px, **only if the actual attachment header column is at least 600 px wide**. A landscape screen whose app column remains narrower does not cover this branch. If necessary, use a labelled native harness with a 640 px header column; do not count it as a real-app wide capture.
- **WD:** existing Chromium 1280 × 800 CSS px, DPR 1, light. Desktop wraps the same web render path; this import-only change does not introduce an Electron/IPC branch requiring another matrix.
- **L/D:** explicit built-in light/dark. Default typography and 100% font scale, default code font size. Keep Material You/custom themes out of the minimum. The new ZIP components have no theme-dependent conditional rendering; their semantic colors do not justify multiplying every row. IP-D checks the alternate palette while covering its platform branch; the source row additionally checks the theme-aware highlighter on AP-D.
- **Recording:** stills alone are insufficient. Record the action/time sequence, then extract labelled settled checkpoints. Several row IDs can point to different timestamps in the same video. Opening a seeded static fixture does not itself turn all static rows into interaction tests; rows that test the opening transition explicitly say recording.
- **N:** normal production mobile route/UI and synthetic files. **D:** driver through the registered real-app navigation/local draft store or normal RPC; record exactly what was injected. **H:** labelled native harness or controlled fault, not a normal production state. H must use the real React Native components and native primitives; include its injected boundary in the evidence.
- **Δ:** new mobile screen/behavior. **R:** unchanged intended layout reached through moved/extracted code. Every table row is relevant to the diff; R means no visible change is expected.

The exact required viewport × theme × row tuples are in the Minimum column below. There are **50 new capture tuples** (44 for M01–M37 and six for R01–R06) and **three already-covered web tuples**. Each tuple is a checkpoint/sequence, not a separate process/lease. Shared baseline shots are additional contextual Before images; do not duplicate them per entry.

## Fixture matrix

| ID | Named fixture and data required | Δ / R | Minimum viewport × theme | Proof / required action | Real-app seed or labelled harness |
| --- | --- | --- | --- | --- | --- |
| M01 | Expanded mixed tree: long archive title, many files, directories first, file2/file10 natural order, explicit empty folder, same-named file/folder, Unicode, long basename/path, visible sizes including zero; short and >3-segment compact folder labels | Δ | AP-L; IP-D | **recording**: open from committed message file card, scroll to lower/deeper rows and back; check title/subtitle, no header overlap, safe areas, 44 px rows, filename ellipsis and size column | N, remote `proof.zip` via U + T below. Same archive also serves almost all entry rows. Include a context-reference open in the same story as a caller check; it should reach this identical screen. |
| M02 | Exactly one file, outer `sizeBytes="0"`: singular “1 file” header and “Filter 1 file,” no outer size suffix | Δ | AP-L | still | D, T route with real `single.zip` id and sizeBytes 0. The 0 metadata also represents missing/unparseable route size, which the parent normalizes to 0. |
| M03 | Zero-entry ZIP: 0 files, filter control, “This ZIP is empty,” no list rows | Δ | AP-L | still | N, valid EOCD-only `empty.zip`, U + T. |
| M04 | Directory-only ZIP: explicit `empty/`, `a/b/c/d/`, fileCount 0 but visible folder rows | Δ | AP-L | **recording**: collapse/expand empty folder, show “0 files” badge | N, `dirs.zip`, U + T. Distinct from M03: zero files does not imply zero tree rows. |
| M05 | Mixed tree with a one-file folder and a multi-file folder collapsed | Δ | AP-L | **recording**: collapse both, show right chevrons and “1 file” / “N files,” expand again | N, M01 archive; tap folder rows. No new seed. |
| M06 | Nonempty case-insensitive path filter matching children under a collapsed folder; clear icon and keyboard visible | Δ | AP-L | **recording**: collapse folder, type padded/mixed-case path query, select result (keyboard dismisses), Back returns to preserved query/collapse/scroll, drag dismisses keyboard, clear icon restores tree | N, M01 archive. Filtering traverses collapsed descendants; record actual chevron/child behavior rather than assuming filter resets collapse. Whitespace-only query shares the tree layout with a clear icon and is a short chapter here. |
| M07 | Nonempty filter with no matches; long query wraps the “No files match …” title, detail and Clear filter button | Δ | AP-L | **recording**: type long absent query; clear using empty-state button, then verify restored tree | N, M01 archive. Empty ZIP + a query shares this state; not another capture. |
| M08 | Opening ZIP spinner: URI not resolved, local read/indexing, or remote receivedBytes 0; initial parent subtitle rather than final file count | Δ | AP-L | **recording**: opening → ready, include navigation away during pending load and reopen without stale state | N, record ordinary local and remote opening in M01/M14 stories. If too brief to inspect, H must hold asset authorization/local arrayBuffer/first remote chunk; show “Opening ZIP...” and label which boundary was held. No invented “indexing” progress UI exists. |
| M09 | Remote known-size partial transfer: received “of total,” determinate native progress, optional ETA paragraph, Cancel | Δ | AP-L; IP-D | **recording**: initial bytes before 1 s/no ETA → minute ETA → seconds/one-second ETA → completed byte count/no ETA → ready. Show increasing bytes and determinate bar | H unless a recorded external throttling proxy can pace the real signed asset GET. Use a bounded STORE ZIP, positive sizeBytes, real timed chunks. ETA requires elapsed ≥1 s, 0 < received < total. Delay the final EOF if a completed-byte progress checkpoint is needed. |
| M10 | Remote unknown-size partial transfer: received bytes only, indeterminate progress, no ETA | Δ | AP-L | **recording**: bytes advance → finish. Include reduced-motion-on checkpoint: indeterminate native animation becomes a static track | D sets T sizeBytes 0; H/controlled throttling as M09. Reduced motion through device accessibility setting. Do not fake a missing Content-Length alone: this component uses route sizeBytes, not the response header. |
| M11 | Retryable failure: cancelled transfer or failed GET, two buttons (Try again primary, Save ZIP secondary) | Δ | AP-L | **recording**: M09 Cancel → “Download cancelled” → Try again → ready; separately return HTTP 503 / missing body → network title → retry recovery. Same layout, two payloads, one row | Cancel is normal UI once M09 is held. Network failure needs recorded network interruption after URL resolution or H failing expo/fetch. Failure before URI resolution instead belongs to R01. Refresh returning null falls back to original URI, not an error by itself. |
| M12 | ZIP too large: “Too large to browse here,” limit detail, only primary Save ZIP | Δ | AP-L | still | D, registered T with sizeBytes 52,428,801 (metadata guard); normal current upload UI rejects truly oversized files. H needed for actual local-file-size/stream-crossing guard inputs. These all share this screen; one visual capture suffices, byte-boundary tests prove the individual guards. |
| M13 | Unreadable/refused ZIP: “Couldn't browse this ZIP,” wrapped parser detail + save hint, only primary Save ZIP | Δ | AP-L | still | N, upload a corrupted or encrypted `bad.zip`; choose expanded-limit failure text if testing longest normal detail. Count, ratio, XML, path-length, damaged/unsupported ZIP variants all share this layout; no one-capture-per-parser-error expansion. |
| M14 | Local draft ZIP header: Save ZIP and destructive Remove from draft; initial “Draft attachment” while opening, count/size after ready | Δ | AP-L; IP-D | **recording**: file picker → draft chip → ZIP; open overflow/ZIP actions; Save whole ZIP → dismiss share; reopen → Remove → source draft no longer contains ZIP | N, F below. Repeat entry via question-answer draft as a caller checkpoint in this story; no need to duplicate all entry previews. Root header without Remove is already M01. |
| M15 | UTF-8 source entry under 1 MiB, long entry basename and line, nested path subtitle, selectable text, Save and Add, no preview toggle | Δ | AP-D | **recording**: open the long-basename alias of `code.ts`, inspect line numbers/tokens/long line, select text, Back retains ZIP. Also open `index.html` containing a distinctive script literal: it must be source, never a running HTML page | N, M01 entries (include a long-basename alias ending .ts). AP-D deliberately uses the other palette for the theme-aware source surface; light source chrome is also visible in R02/M19. Use Settings Appearance word wrap off then on; record both checkpoints. ZIP entry UI has no Copy/word-wrap action; use settings. |
| M16 | Empty text entry: 0 bytes in subtitle (formatter displays 1 KB), empty selectable source body, no endless loader | Δ | AP-L | **recording**: open zero-byte `empty.txt`, then Back | N, M01 entry. Reuse this file for M33's real Add failure. Empty content is an object with text="", not missing content. |
| M17 | Text entry 1 MiB + 1 byte but ≤10 MiB: “Partial file” warning above source, first 1 MiB only | Δ | IP-D | **recording**: open `partial.txt`, scroll; Save still uses full entry bytes | N, M01 entry, STORE compression. Byte guard boundaries do not require another visual row; ensure tail marker after byte 1,048,576 is absent from preview and retained in saved file. |
| M18 | Markdown entry with environment: rendered headings/list/link/code block and captured relative-image reference, preview/source toggle and Add available | Δ | IP-D | **recording**: rendered README.md → open action menu / Show source → source → Show preview → rendered; Back returns ZIP | N, M01 + T with environment/thread. Narrow Android has 3 actions and therefore overflow; iOS shows native File actions menu. Markdown without environment is M29's plain source. Captured Markdown must not resolve workspace-relative content. |
| M19 | Small CSV/TSV with long entry basename (~120 ASCII chars): ordinary table, header row, no clipping warning; preview/source actions | Δ | AP-L | **recording**: `small.csv` table → source → table; horizontal/vertical table scrolling | N, M01 includes a long-basename CSV with 4 × 3 cells, an empty cell and a long cell. TSV uses the same table shape; parser delimiter is a nonvisual data variant. |
| M20 | Clipped CSV: >100 rows or >30 columns/overlong cell/unclosed quote, table-limit warning above bounded cells | Δ | AP-L | **recording**: open `clipped.csv` (101 × 31), scroll both axes, switch to source | N, M01 entry. One clipped table covers the shared notice; the parser's different truncation reasons need logic assertions, not separate screenshots. The simultaneous text+table warning combination is M36; a zero-row CSV table is M37. |
| M21 | Audio entry: cached URI, duration/time labels, seek/play controls; loading/playing/paused/error states | Δ | AP-L | **recording**: open valid short WAV → Play → ±15 seconds → Pause → Back; also open corrupt WAV and Try again so the audio error block and retry path are shown | N, M01 `tone.wav` and corrupt `broken.wav`. Initial loading is time-dependent. If a reproducible native seek failure is unavailable, that unchanged internal audio error variant is not separately required; corrupt audio supplies the same error block. |
| M22 | Entry >10 MiB but archive still browseable: “Too big to preview,” Save file button and normal header actions retained | Δ | AP-L | **recording**: open 10,485,761-byte STORE `large.bin`; Save or Add remains reachable; Back returns tree | N, M01. Keep aggregate archive <50 MiB and ratio ≤200. Do not use `.xml`, whose 8 MiB shared-reader cap would reject the entire archive instead. |
| M23 | Entry read/decode failure: “Couldn't read this file,” diagnostic message, Save file body action | Δ | AP-L | **recording**: open invalid UTF-8 `bad-text.txt`, then Save/Back remain available | N, M01 invalid bytes such as 0xFF. NUL text, local cache-create/write failures, and decode fallback strings share this layout. H is required if specifically claiming file-system failure coverage; UTF-8 failure needs none. There is no read-retry button on this entry error screen. |
| M24 | Image entry native viewer: pending/open transition and platform image presentation | Δ | AP-L; IP-D | **recording**: tap valid PNG → image viewer → zoom → close. Continue into M27 without leaving attachment route | N, M01 `pattern.png`. Android uses image-viewing; iOS uses native Quick Look. If the transient “Opening in file viewer...” is too brief, its timed coverage needs a labelled file-write hold, not a fake settled loading still. |
| M25 | PDF entry native viewer: pending/open transition and PDF presentation | Δ | AP-L; IP-D | **recording**: tap valid 2-page PDF → navigate/scroll → close → Open in file viewer reopens | N, M01 `two-pages.pdf`. Android has the in-app PDF modal, while iOS uses Quick Look. Do not infer one from the image row. |
| M26 | Document-kind entry: representative playable video file, external/native document viewer handoff | Δ | AP-L; IP-D | **recording**: open valid short MP4 → observe native viewer / playback → return. Preserve any temporary Android “Opening document…”/Cancel prompt in the recording | N, M01 `clip.mp4`. For this component, video, unsupported types, font, Mermaid, nested archives, SQLite, and rich Office types dispatch to the same `document` kind; no new in-app ZIP/Office/font renderer exists. A valid DOCX can substitute if the device cannot play the fixture, but playback is then not covered. |
| M27 | Native viewer closed successfully: entry title, neutral “Open it in the file viewer…” detail, Open in file viewer action, Save/Add header retained | Δ | AP-L | **recording**: close M24/M25/M26 → neutral retained screen → reopen → close → Back to preserved ZIP list | N, no new fixture. This is a distinct body state; ordinary attachments can navigate back when their native viewer closes, whereas ZIP entries intentionally stay selected. |
| M28 | Native viewer unavailable/failed: “No preview for this file,” native error detail, Open in file viewer action | Δ | AP-L | **recording**: open unknown `unknown.zzz` → error return → retry viewer → Back; Save/Add remain reachable | N when the device rejects the unknown type. Quick Look may accept arbitrary files as metadata: if it does not reject, H must reject the native presentation boundary and label the capture. Record the error and close callback order. |
| M29 | No environment, local draft README.md: source fallback, no rendered toggle and no Add; Save only | Δ | IP-D | **recording**: open file from registered NewTaskAttachment route with valid local draftKey but omitted environmentId/threadId; inspect File actions → Save only | D, reuse local F draft/file, omit environment from route. Needs no remote URI and demonstrates the in-memory text path with uri=null. Capability-unavailable and draftKey-null also hide Add; identical action absence is not another iOS row. |
| M30 | Environment present but no destination draft/thread: Markdown rendered preview still available, Add absent; Android has Preview + Save as two direct actions | Δ | AP-L | **recording**: registered NewTaskAttachment route for a valid remote archive, environment set, draftKey/threadId omitted; open README → preview/source → menu inspection | D, N route below with pending/committed attachment id. This exercises the alternate no-Add layout without pretending the current server has a UI setting to disable fileUploads. Missing capability with a draft key is visually equivalent. |
| M31 | Wide Android Markdown entry: 3 direct actions rather than narrow overflow; long title/path truncation | Δ | AW-L | **recording**: rotate/reflow M18, verify actual header column ≥600 px, inspect Show source / Add / Save, rotate back | N if real app layout yields a ≥600 px attachment column. Otherwise H with 640 px native column, explicitly labelled. Source row/native viewer rows with only one or two actions do not need this width multiplier. |
| M32 | Add to chat in flight → success alert → composer attachment/reference; question draft origin must target thread composer | Δ | AP-L | **recording**: Add once, show busy action availability if visible, “Added to chat” alert; dismiss, Back twice, verify filename attachment/reference in thread composer and question draft unchanged | N, open M01 archive from QuestionAttachments F flow; uses threadDraftKey precedence. Also perform an Add from committed attachment/no draftKey: same UI row, different caller input. If the busy interval is too brief, H can hold persistence; record it as supplemental harness proof. Closing entry while pending must suppress late alert/append. |
| M33 | Add failure: “Could not add file to chat” alert with detail, entry remains usable | Δ | AP-L | **recording**: Add zero-byte M16 → failure → dismiss → Back; no success alert/chip | N, M16 normal Add. Empty files fail `createComposerFileAttachment`; size limit, full 100-attachment/context capacity, long filename and copy failure use the same alert layout. For quota specifically D may prefill through the actual draft store; for lower advertised max use H/older-server fixture because this server advertises a fixed 50 MiB. Do not count every error string as a capture. |
| M34 | Entry Save in flight → platform share chooser; Add disabled while busy; save file uses full original entry bytes | Δ | AP-L; IP-D | **recording**: Save `partial.txt` → native share UI → cancel/complete → entry restored; compare saved bytes/hash externally; optionally close selected entry during held save to verify no late UI | N, M17 entry. Use a receiver capable of saving a file on the emulator/simulator; if no receiver exists, chooser presence is still visual proof but actual byte export remains unverified. Android spinner is only on a direct Save header action / body button; iOS menu items disable. H hold is needed for deterministic brief busy visuals. |
| M35 | Entry Save throws: “Could not save file” alert; dismiss leaves entry and Back usable | Δ | AP-L | **recording** | H, fail generated-cache write or native share rejection. No normal RPC sets this. `shareGeneratedAttachment` returns false when sharing availability fails and `run` ignores the boolean: record the actual silent result if testing that case; it is not proof of the throwing-error alert. |
| M36 | Partial CSV: >1 MiB text and >100 rows/30 columns, both “Partial file” and table-limit warning stacked | Δ | AP-L | **recording**: open `partial.csv` in table mode → source (only text warning remains) → table; scroll below both notices | N, M01 entry, STORE archive. Use many bounded CSV rows with a marker beyond 1 MiB; keep the entry ≤10 MiB. This is the one combined-warning layout that cannot be inferred from M17 and M20 separately. |
| M37 | Empty CSV: parsed table with zero rows, blank table body, Preview/Source toggle still present | Δ | AP-L | **recording**: open `empty.csv` table → source → table → Back | N, zero-byte M01 entry. Unlike empty.txt, `table` is a truthy object with rows=[], so it chooses DelimitedFilePreview even with no cells. No empty-ZIP or no-match notice should appear. |
| R01 | Parent asset/local resolution failure before ZIP view; “File unavailable,” detail, Try again; recovered opening loader | R | AP-L | **recording**, matched Before/After: broken/deleted attachment id → Try again (may remain failed); then open a valid fixture to observe resolution | D through T with nonexistent/deleted id, or H for deterministic local-resolution failure. This is AttachmentDocumentBody's `document.error` branch, not M11's ZIP GET failure. |
| R02 | Ordinary text attachment still has source, original menus (Copy, word wrap, Save/open/remove), selectable text; partial/table/Markdown body paths share M15/M17/M18/M19/M20 components | R | AP-L | **recording**, matched Before/After: ordinary code.ts draft → source/menu → wrap/copy → Back | N, F with ordinary source file. Inspect loading until content resolves. ZIP in-memory text without URI is already M15/M29 and must not remain on “Loading file...”. Do not re-capture every old body renderer with every parent. |
| R03 | Ordinary HTML, htmlRender absent: WorkspaceFileWebPreview rather than ZIP HTML source; source/preview switch | R | AP-L | **recording**, matched Before/After | N/D, T for HTML with no htmlRender flag. Page + source switch proves this extracted branch. Use local/self-contained HTML so sibling/network content does not obscure result. |
| R04 | Ordinary HTML, htmlRender="1": HtmlRenderWebView path, nested=false | R | AP-L | **recording**, matched Before/After | D, T with htmlRender=1, or open fullscreen from the inline HtmlRenderWebView button (the actual caller sets this flag). Distinct renderer from R03; no ZIP entry can select this branch. |
| R05 | Non-ZIP archive (e.g. tar): generic “No preview for this file” and Try again/manual file-viewer action, no ZIP list | R | AP-L | **recording**, matched Before/After: inspect generic screen → try native viewer → return | N, `archive.tar` through T/F. ZIP MIME aliases/uppercase `.ZIP` are positive routing variants of M01; `.jar`/gzip/tar without ZIP MIME are negative variants here. Name .zip with a conflicting specific MIME may fail the outer `kind === archive` gate: record actual route classification, do not assume filename alone wins. |
| R06 | Ordinary unsupported/document attachment auto native pending/open and close behavior | R | IP-D | **recording**, matched Before/After: ordinary DOCX → native viewer → close back to prior screen, or native failure → generic fallback/retry | N, ordinary valid DOCX through T/F. Completes the extracted body's nativeViewer pending/open/unavailable family. Image/PDF/video bytes use the same ordinary-screen dispatcher when routed here; their native presentation primitives are separately covered by M24–M26. |
| W01 | Cold web ZIP open: BinaryFilePreview lazy chunk → BinaryViewer/readBinaryPreview/protocol → binary worker → moved boundedZip and directory; source entry/back | R | WD-L, existing | **recording + paired stills, REUSE** | Existing web W01/W19; actual message card and cold tree.zip. W01 has settled paired roots/entry/return; W19 has cold trace. No new browser run. |
| W02 | Cold web SQLite open: same binary lazy chunk and worker, sql.js/WASM branch; no boundedZip invocation | R | WD-L, existing | **recording + paired still, REUSE** | Existing web W19 smoke.sqlite. Specifically covers value-import of limits on a non-ZIP path; retain cold network trace caveat. |
| W03 | Cold web Office worker: lazy OfficeFilePreview → OfficeViewer/OpenXmlOfficeViewer → officePreview.worker → officeArchive → moved directory | R | WD-L, existing | **recording + paired stills, REUSE** | Existing W11/W19 readable Word; W18 pre-engine 2,049-directory refusal. These exercise the moved `checkZipDirectory` import in the shared Office worker. XLSX/PPTX/OpenDocument/EPUB use the same changed import; their unchanged renderers do not add capture gates. |

## Seeding contract for the capturer

### U — synthetic assets through real RPC

Use an isolated development environment and never start a server against the live T3 home. Generate all bytes before leasing the device. Upload file assets using `attachments.createUploadUrl({ type: "file", name, mimeType, sizeBytes })`, then send the exact bytes to the returned relative upload URL with HTTP **POST**, using the matching server origin and required upload headers. Retain returned attachmentId and metadata. Do not call the legacy image-only persistChatAttachments RPC for ZIP bytes. Use an existing synthetic thread with these attachment records, or make one with ordinary orchestration commands and stopped/no-provider work; a pending uploaded id can also be opened through the registered attachment route without running an agent. The upload URL is a credential: do not publish it.

### T — registered production screen route

Navigate the real app to `ThreadAttachment` (linking path `threads/:environmentId/:threadId/attachments/:attachmentId`) with:

```ts
{
  environmentId, threadId, attachmentId,
  name: "proof.zip", mimeType: "application/zip", sizeBytes: String(actualBytes)
  // no draftKey for committed/remote attachment
}
```

The route also accepts optional draftKey and htmlRender="1". Its other registered entry is `NewTaskAttachment`, linking path `new/draft/attachments/:attachmentId`; use that for a valid asset with omitted thread/draft or an existing local draft with omitted environment. Choose the development app's registered scheme/build, not an assumed universal link. Route params are strings; URI is not a supported route param. Real URI resolution is by attachmentId/environment or the matching local draft attachment. A driver that passes an arbitrary file URI to the production route is not exercising this screen's actual loading path.

### F — local and question drafts

Put the synthetic ZIP in the leased device's file-provider/Documents area, choose it with the real composer file picker, and tap its file chip. ThreadComposer passes `environmentId`, `threadId`, metadata and composerDraftKey to `ThreadAttachment`. A question-answer attachment chip passes the question draft key instead. The ZIP screen intentionally computes `scopedThreadKey(environmentId, threadId)` first, so Add goes to the **thread chat composer**, while Remove still removes from the originating draft. Preserve both local stores to verify this distinction. The normal append function is `appendComposerDraftAttachments(key, attachments, { appendReference: true })`; do not mutate SQLite or live server state to emulate it. A driver using this actual local store must supply real readable per-device file URIs, not local paths from the Linux lead.

### One shared archive, four small variants

Prepare `proof.zip` as a STORE archive (or similarly incompressible bounded data) containing all M01/M05–M07/M15–M37 entry files: natural-sort names; a file `same` and `same/child.txt`; single/multi-file folders; an explicit empty directory; compact chains of 2/3 and >3 folder segments; long and Unicode names; code.ts and a long-basename .ts alias; README.md with heading/list/link/code/relative-image reference; index.html with visible script source; empty.txt; partial.txt with a tail marker just after byte 1,048,576; small.csv (use a ~120-character basename for header stress); clipped.csv (101 rows × 31 cols); partial.csv (>1 MiB); empty.csv; bad-text.txt invalid UTF-8; valid short tone.wav + corrupt broken.wav; valid pattern.png; two-pages.pdf; short valid clip.mp4; unknown.zzz; a STORE large.bin of 10,485,761 bytes. Keep the total under 50 MiB, paths within 1,024 bytes/characters, and all `.xml` files below 8 MiB. Long names for visual ellipsis should remain comfortably below filesystem basename limits; a 230+ character entry belongs to an explicit attachment-failure diagnostic, not the default happy fixture. A >10 MiB BIN must not be a highly compressed zero bomb, which would fail the archive ratio guard before its entry screen.

Add `single.zip`, EOCD-only `empty.zip`, `dirs.zip`, and `bad.zip` as tiny variants. All can use the same display name when useful for a shared baseline, but retain distinct asset ids and byte hashes in the manifest. Do not assign one device lease to each file. A physical >50 MiB ZIP is only needed for nonvisual byte-limit proof, not the M12 layout checkpoint.

### Controlled-state discipline

No existing general-purpose production route directly selects internal loading/error/busy states. For H rows, use a native fixture scene importing the exported `ZipArchiveView`, with real navigation/theme/safe-area providers and real native controls. Hold/fail only the named external boundary (`expo/fetch` stream, local File read/write, native presenter, sharing). Preserve actual build/head/OS and label the injected dependency in the recording. The existing `ZipArchiveView.test.tsx` uses DOM slots and replaces native controls; its output cannot serve as native screenshot evidence. No harness was implemented or run in this read-only audit. If the capturer cannot supply a named H state, leave it uncovered explicitly; do not spend a lease repeatedly trying to make a millisecond operation slow by chance.

## Complete render/caller inventory and fixture mapping

Source links below are pinned to the audited head. Changed rendering is limited to the first three components; zipTree supplies their row shapes. Imports, exports, generated-file creation, tests/config/docs/lockfile changes have no other direct visual render path.

| Changed component/helper | Callers and branch/data/interaction inventory | Fixture mapping |
| --- | --- | --- |
| [AttachmentFileScreen](https://github.com/pingdotgg/t3code/blob/420bc45432a7560a11dc4d0b710cd4155f716c0f/apps/mobile/src/features/files/AttachmentFileScreen.tsx#L46) | Registered twice in Stack: ThreadAttachment and NewTaskAttachment. Live navigation callers: ThreadFeed attachment card (PDF bypasses this route); UserMessageContent document/context reference in ThreadFeed; ThreadComposer document chip; QuestionAttachments chip; HtmlRenderWebView fullscreen HTML button. Environment optional/trimmed; size string parsed or 0; optional threadId/draftKey; draft matching id + file-backed attachment chooses local URI. New branch requires `document.kind === archive && isZipFile(params) && !document.error`; MIME aliases normalized for semicolon/case/whitespace; ZIP suffix only wins after outer kind gate. Non-ZIP archives and parent resolution error remain generic. | M01, M02, M08, M14, M29, M30; R01–R06. Caller checkpoints share fixtures; do not multiply the same body by every caller. |
| AttachmentFileScreen old screen chrome | Android custom header/menu vs iOS native stack/Toolbar; name/subtitle, known size suffix, draft Remove; source/preview/table action state; source-only wrap; content Copy/Copy preview; URI-dependent Save/open; sharing label; native pending URI auto-open; native close backs out on success, unavailable error stays with retry; manual open when nativeViewer=null leaves generic screen. New ZIP branch replaces that chrome, old paths still flow through extracted body. | M14, R02, R03, R04, R05, R06. All old content modes mapped to corresponding body fixtures; no Settings/palette/keybinding action was added. |
| [AttachmentDocumentBody](https://github.com/pingdotgg/t3code/blob/420bc45432a7560a11dc4d0b710cd4155f716c0f/apps/mobile/src/features/files/AttachmentDocumentBody.tsx#L21) | Exactly two callers: AttachmentFileScreen with full hook result; ZipEntryView with synthesized document. Precedence: document.error → File unavailable/Try again; non-null pending/open nativeViewer → opening viewer spinner; needsText and missing content OR nontext and missing URI → loading spinner. Text+content: optional partial warning (can stack above table warning); table + activeMode table → DelimitedFilePreview; markdown mode + environment → FileMarkdownPreview captured=true/cwd empty/thread null; otherwise selectable SourceFileSurface. Nontext URI audio → AudioFilePreview keyed by revision; HTML → htmlRender true HtmlRenderWebView nested=false, otherwise WorkspaceFileWebPreview; remaining → generic fallback using nativeError or default text and Try again. Final `!uri → null` is defensive/unreachable from valid prior branches (needsText with content already returned, missing nontext URI already returned loading); no fabricated blank row. | R01, M08/M24–M26, M15–M23, M28–M30, M36–M37, R02–R06. Important changed guard: ZIP text has content but URI=null and must render. No independent per-theme JSX branch. |
| nativeViewerKind, extracted with body | image → image; PDF → pdf; video/unsupported → document; all other kinds null. ZipEntryView overrides null to document for any nontext/non-audio kind. | M24–M26, M28, R06. All enum values accounted for; not a rich Office/grid renderer on mobile. |
| [ZipArchiveView](https://github.com/pingdotgg/t3code/blob/420bc45432a7560a11dc4d0b710cd4155f716c0f/apps/mobile/src/features/files/ZipArchiveView.tsx#L500) / useZipArchive | Sole production caller AttachmentFileScreen. Props name/subtitle/tint/size/URI/isLocal/refresh/environment/draftKey/maxAttachmentBytes/onSave/onRemove. URI missing holds loading. Local reads File; remote reauthorizes and fetches body. Declared size, local size or received bytes >50 MiB → TOO_LARGE. Bad GET/body/generic read/refresh rejection → NETWORK; boundedZip rejection → parser failure. Loading received=0 or local → spinner, otherwise progress. Ready tree; failed failure card. Retry increments revision; cancel aborts + cancelled state. Selection is valid only for the current archive object; entry overlay absolute above still-mounted list. Native Back prevented when selected → closes entry, preserves filter/collapse/scroll. Cleanup aborts read; URI/isLocal/size/revision changes reload, new refresh callback alone must not. | M01–M14, M15/M06 Back, M32/M34 cancellation. Stable refresh/re-render covered by existing regression test; a duplicate visual capture is unnecessary unless a parent render visibly resets the list. |
| ZipArchiveHeader | Name, initial supplied subtitle vs ready count with 0/1/many and optional size. Android back + Save direct + Remove overflow-only; iOS direct Save without draft, ZIP actions menu with Save and destructive Remove for draft. Back canGoBack false is a no-op, not another layout. Theme tint supplied by parent. | M01–M04, M08, M14. |
| ZipArchiveProgress / Failure | Positive route total renders “of total” and determinate progress; unknown total omits total and has indeterminate progress. ETA absent for <1 s, no bytes, unknown total, or complete; otherwise second/seconds/minutes string. Native progress primitives differ; reduced-motion indeterminate is static. Cancel; retryable failure has two button tones, nonretry failure one Save button. TOO_LARGE, parser detail, NETWORK, CANCELLED are different payloads with three title/detail classes but two failure action layouts. | M08–M13. No automatic timeout branch exists in mobile ZIP loader. |
| ZipArchiveList | Nonvirtual ListHeader search; LegendList 44 px rows/estimated item, recycleItems. iOS NativeScrollView/automatic content inset & indicators plus headerInset; Android ordinary scroll/no automatic inset. fileCount 0/1/many placeholder; clear icon raw query truthy; normalized query trim/lowercase for results; raw query + no rows renders no-match, otherwise empty ZIP. On-change query; clear icon/button; tap folder toggles persistent set; tap file dismisses keyboard and selects; drag dismisses keyboard. Empty directories can remain visible at fileCount=0. | M01–M07, M14/M06 iOS inset/keyboard. No columns/breakpoint branches in list itself. |
| ZipTreeRowView / [zipTree](https://github.com/pingdotgg/t3code/blob/420bc45432a7560a11dc4d0b710cd4155f716c0f/apps/mobile/src/features/files/zipTree.ts#L30) | Directory vs file row: chevron / none, directory/file icon, compact label prefix + terminal name vs filename, indentation 8 + 18×depth, 1-line title, size vs collapsed descendant count (singular/plural) vs blank expanded count. Single-child directory chains compact; >3 path segments use first/ellipsis/penultimate prefix. Folders sort first, names numeric/case-insensitive. File/folder same basename coexist; explicit vs inferred directory produces same node. Filtering matches full entry path, retains ancestors and traverses collapsed branches. With a query, folder collapsed chevron can remain even while matching descendants render; do not silently assert expansion state changed. | M01–M07. Deep pathological paths, count/ratio guards and byte normalization are logic/performance inputs, not a demand for one screenshot per parser case. |
| ZipEntryView classification/content | Text/Markdown/HTML → in-memory decode; archive HTML forcibly text/source; audio → cached file/internal player; remaining kinds → cached file/native viewer with image/pdf/document kind. >10 MiB initializes failed state and skips preview write, but header Save/Add remain. Loading text/file; decode/file write failed → failure; text empty vs missing; partial text; small/clipped table; Markdown environment/no environment; rendered mode source toggle; no URI required for text. Name/basename, archive+folder subtitle, size, long path/name; keyboard dismissed on entry open. Cleanup aborts pending read and disposes cached file. | M15–M30, M36–M37. Exactly 10 MiB is accepted, >10 MiB refused; text warning threshold is strictly >1 MiB. |
| ZipEntryView native/return | viewerKind + URI + viewerOpen presents FilePreviewModal, recording native error before close; underlying body is opening-viewer spinner. Close success shows retained neutral entry; failure shows native-error fallback; reopen clears displayed viewerError and sets viewerOpen; Back closes overlay through usePreventRemove. Android image/PDF/document paths vs iOS Quick Look; video maps document. | M24–M28. Invalid media decoding internal to unchanged viewers is not a new matrix expansion. |
| ZipEntryView actions | canAdd iff draftKey nonnull and maxAttachmentBytes defined. renderedMode iff table, environment-backed Markdown; HTML has none. Therefore four toolbar-presence combinations (toggle yes/no × Add yes/no), plus busy. Android custom header: preview toggle, optional disabled Add, Save with busy-save spinner; overflow below 600 px when 3 actions, all direct above. iOS File actions menu: toggle label/icon, optional Add, Save; Add/Save disabled for busy. Entry failure body has Save button with busy spinner. Save/Add guard duplicate execution in run; pending cleanup aborts. Add success alert; action failure title differs by action. Add to chat appends reference to destination draft, may fail on byte limits/empty file/capacity/copy; file temp lease disposed. Save writes full bytes/shares, no success alert; false return from unavailable sharing is ignored. | M15 (no toggle/Add yes), M18 (toggle/Add yes), M29 (neither), M30 (toggle/no Add), M31 width; M22/M23 body Save; M32–M35 action/time states. |
| [Shared ZIP move](https://github.com/pingdotgg/t3code/blob/420bc45432a7560a11dc4d0b710cd4155f716c0f/packages/client-runtime/src/zip/boundedZip.ts#L1) and web import callers | boundedZip has one native caller openArchive and one web production caller binaryPreview.worker. Web binaryPreviewProtocol now imports/re-exports limit values; used by BinaryViewer, readBinaryPreview and worker (other uses are types/tests). officeArchive switches checkZipDirectory import; sole production importer officePreview.worker; both OpenXmlOfficeViewer and ExtendedOfficeViewer instantiate that worker. Shared ZIP constants retain original numeric values; directory implementation unchanged. No web JSX/CSS changes. | M01/M13, W01–W03. No extra web theme, viewport, resource-origin, Office-format, TAR/gzip, or Electron screenshots solely for this move. |

## Efficient order and acceptance

1. Generate/upload all fixtures and record ids/hashes before starting a mobile lease. Keep one normal app session per device/build. Capture one main generic ZIP Before per selected device/theme, plus appropriate matched regular regressions. Do not reopen/build/lease per row.
2. AP-L normal story: M01–M07 tree/search, then M15–M28/M36–M37 entries and M32–M34 actions from the same archive; M14 draft/remove can go last so it does not destroy the source early. Most of these are checkpoints in one or two videos.
3. IP-D normal story: only the explicitly listed native header/menu/progress/viewer/share/inset and partial-text rows, plus R06. Shared regular text is R02 on AP-L. It is not a second copy of the complete Android checklist.
4. H story: M08 deterministic hold if needed, M09–M11 paced network, M35 thrown share/cache fault and any missing busy/native-failure checkpoints. Separate this labelled harness video from the production client story. AW only if a measured wide app column exists, otherwise explicitly H.
5. AP-D is used only for M15 source/highlighter. R01–R06 and web reuse provide extraction/import regression proof. Every native platform capture states OS/device/safe areas, theme, app revision, fixture id/hash and whether D/H was used. Stable read-only ZIP listing does not require frame-rate timing claims; progress, keyboard, viewer transitions, audio, share/add, removal and Back must have recordings.
6. Existing web evidence satisfies W01–W03 by coverage declaration and linked captures at the exact revisions; this audit read its manifest/report and did not re-review its image/video pixels. Keep the evidence's cold-trace and timing caveats. Production bundle eager-load behavior would need a separate build/trace measurement, not more visual-state rows.

No server, emulator, lease, browser or worktree was created by this step. Only the step scratch directory was created; it is removed after upload. The lead's worktree and parent scratch remain owned by the lead.

FRICTION: tool | web.open could not retrieve the ghdrop Markdown evidence; curl retrieved the same public artifact successfully | use direct artifact download for ghdrop Markdown rather than the web page extractor
