# Proof audit, pass 1: mobile Settings provider icons

Findings first:

1. **Evidence is insufficient, not an identified implementation defect.** [#28135](https://git.cafe/pingdotgg/t3code/pull/28135) at [2dc4772](https://github.com/pingdotgg/t3code/commit/2dc4772467a109a390af202e8d8ece29495d2c97) changes one call site: it forwards `provider.iconUrl` to `ProviderIcon` at size 18. The pinned base is its direct parent, [79f1fad](https://github.com/pingdotgg/t3code/commit/79f1fadfc6020b820d541ca4691d2feebb15dd1b). This audit uses that base; the checkout's local `main` is older and was not substituted for it.
2. **The existing Android before/after images are useful but partial.** All four linked PNGs were downloaded and visually inspected. They are 1080×2424. Both themes show the intended Copilot glyph replacement, the local Replay ACP fallback, and unchanged Codex/Claude. They also show `Not installed` plus a wrapped registry-inspection error for Copilot, `Version unknown` plus a message for Replay, and latest-version/update-action rows for Codex/Claude. They do not show iOS; a pending image becoming visible; a failed image load; URL replacement/removal while mounted; a wrapped provider title; or simultaneous distinct registry images. A backend inspection error in the Copilot row is **not** an image-load failure: its registry image successfully loaded.
3. **The PR body's claim that image failure is unchanged is incomplete for proof purposes.** The helper is unchanged, but this call site previously never supplied a URL. This diff newly exercises its native `expo-image` loading/error path on this screen. Code inspection of `onError` does not prove native behavior, and Android cannot establish iOS SVG decoding/tinting.
4. **Keep the gate proportional.** No new captures are required for unrelated server-update UI, usage screens, all custom theme palettes, every built-in logo, every message string, or every tablet/foldable size. Their branches are inventoried below as unchanged context. This is not a demand for a Cartesian product of the whole Settings page. The actionable missing proof is R1/R2/R8 on iOS, R4–R6 recordings, and R7 wrapped-title geometry.

This pass derived the matrix before producing any new capture. No code edits, tests, app launches, device leases, pushes, comments, or new screenshots/recordings were performed. Existing screenshots are credited only to the rows they show. PR metadata still names the requested head; 39 checks are successful and the bot reviews add no independent visual coverage.

## Render-path inventory

Changed path: [SettingsEnvironmentDetailRouteScreen](https://github.com/pingdotgg/t3code/blob/2dc4772467a109a390af202e8d8ece29495d2c97/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx#L41) → keyed `EnvironmentDetail(environmentId)` → saved environment → `config && !outdated` → `config.providers.filter(p => p.enabled)` → row keyed by `instanceId` → `ProviderIcon(driver, iconUrl, 18)`. Neither installed state, provider status/auth, maintenance permission, nor connection state independently hides an enabled provider row when cached config remains present.

All callers/entry points of the changed route:

- [Stack registration](https://github.com/pingdotgg/t3code/blob/2dc4772467a109a390af202e8d8ece29495d2c97/apps/mobile/src/Stack.tsx#L254): `SettingsEnvironmentDetail`, linking path `settings/environments/:environmentId`.
- [SettingsEnvironmentsRouteScreen](https://github.com/pingdotgg/t3code/blob/2dc4772467a109a390af202e8d8ece29495d2c97/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx#L67): Settings → Environments → selected local/cloud environment.
- [SettingsServerControlsRouteScreen](https://github.com/pingdotgg/t3code/blob/2dc4772467a109a390af202e8d8ece29495d2c97/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx#L473): Manage environments → Server and provider updates, when no project is selected. Both navigation callers reach the identical route; no separate visual matrix is needed per entry point.

[ProviderIcon](https://github.com/pingdotgg/t3code/blob/2dc4772467a109a390af202e8d8ece29495d2c97/apps/mobile/src/components/ProviderIcon.tsx#L15) is not edited. Its exhaustive branches relevant to understanding this call:

- `provider` is string/null/undefined generally, but the Settings snapshot supplies a nonempty driver slug. `size` defaults to 16 generally; this route fixes it at 18.
- Case-normalized Antigravity: bundled raster, unaffected by `iconUrl`.
- Exact `acpRegistry`: 18×18 wrapper. Missing/rejected URL → ACP fallback. Accepted URL with no matching loaded state → fallback plus transparent absolute image. Load → tinted contained image, fallback removed. Error → fallback, image unmounted. `currentImage` applies only when its stored URL equals the new normalized URL, so URL changes re-enter loading even under the same row key.
- Exact `claudeAgent`: fixed orange SVG; package-defined drivers (Cursor, Grok, fx, OpenCode, Muse, Pi) use their path fills and optional gradients; Codex/no package icon/unknown driver use the final monochrome SVG. These branches ignore `iconUrl`. Unknown valid slugs can exist in forward-compatible snapshots; null/undefined are helper-only inputs, not normal Settings rows.
- Light/dark chooses `#171717` / `#e5e5e5` for ACP fallback and remote-image tint. Built-in package fills can also vary. There is no theme-specific URL.
- URL resolution allows only HTTPS on the official registry CDN, no nondefault port or credentials. Server contracts make `iconUrl` optional, nonempty, at most 2048 characters; null/empty strings are helper-only equivalents of absence. Current registry snapshots derive an official URL from probe metadata or agent ID; local-command ACP deliberately omits it. Therefore arbitrary rejected-URL rendering needs a labelled harness, not a fabricated claim about a normal settings seed.
- No press/hover/selection handler exists on the provider title/icon row. Only refresh and conditional provider-update actions are interactive.

All other mobile callers of `ProviderIcon`/`ProviderInstanceIcon` were located. They are unchanged and do not inherit this call-site-only edit: `ComposerModelControl` (direct icon or instance badge); `ThreadSettingsSheet`; `thread-handoff-row` (direct or badge); `SubagentRow`; `ProviderSubagentBar`; `thread-subagent-group`; `thread-list-v2-items` (direct or badge); `UsageLimitsSection`; both pooled/account sites in `UsageLimitsPooled`; `ChatGptUsageSummary`; `UsageRouteScreen`; `ChatGptSharingStatus`; `ChatGptUsageLimitNotice`; and `ProviderInstanceIcon`'s internal delegation. Web/desktop, contracts, server adapters, MCP and scheduled tasks are unchanged. Their screenshots are not proof obligations for this patch.

Layout/data branches of the provider row: `displayName ?? driver` (no truncation, wraps); installed known version / installed unknown version / not installed; optional ` · Latest …`; update text absent for idle/missing or present for queued/running/succeeded/failed/unchanged, with explicit message or generated status, danger only for failed; compatibility message; `unavailableReason ?? message`; behind-latest/manual-update guidance; conditional update action, including permission/busy disabled and pending spinner. Zero enabled providers leaves only Refresh, one gives one row, multiple gives ordered keyed rows; there is no count-specific row design or empty-state message.

Surrounding branches: missing saved environment, missing config, and outdated server remove all provider icons. Offline cached config or failed/unauthenticated/read-only session can retain them and disable actions. Refresh/server/provider work can set global busy, error, notice and update indicators. Connection disclosure/reconnect/remove/enable/edit/add-route and release checking/server update are unchanged surrounding interactions. Theme settings can change resolved appearance; there is no new theme-switch interaction in the diff.

Width/platform: the icon box is fixed, but the title's flex space and text scaling determine wrapping. Android and iOS have different native image renderers and Settings chrome/card radii. `SettingsScreen` also accounts for Android split-view margins, iPad/compact-tab bottom inset, folded-Duo surface and safe-area padding. No width breakpoint exists inside the changed row. `SettingsSidebarContext` changes the root Settings list, not this separately registered detail route. These facts justify phone platform coverage and one stress fixture without requiring a full tablet/foldable cross-product.

## Fixture matrix

Definitions: **A** = Android Pixel 9 portrait, 1080×2424 px, DPR 2.625 (about 411×923 dp), large system text, matching existing evidence. **I** = iPhone portrait, nominal 390×844 pt (record actual model/OS/scale/bounds). **L/D** = resolved light/dark appearance. A different supported small iPhone is acceptable if its exact bounds are recorded. Keep data, OS text size, viewport and font preferences the same before/after. "Still pair" means pinned base and reviewed head. Recordings must show the entire transition; endpoint stills cannot replace them.

**Required** rows are the minimum proof gate. **Context** rows inventory unchanged visually distinct states; absent contextual captures do not independently fail this patch. Fixtures may share a frame/recording when every named row remains legible. A capture label must say `real app` or `native harness`.

| ID / named fixture | Data / expected appearance | Diff affects? | Minimum viewport × theme × media | Seed / drive method | Pass-1 status |
|---|---|---|---|---|---|
| R1 Registry image, short name | Enabled `acpRegistry`, official loadable Copilot URL, short display name; settled registry image at 18×18 | Yes | Required: A and I × L/D, still pairs | Real app seed S1; navigate N1; wait for actual image load | Android L/D covered by existing pair; I L/D missing |
| R2 No URL, local/legacy ACP control | Enabled local-command ACP with absent URL; ACP fallback; null/empty helper values collapse here | No visual change; fallback control | Required: co-frame with R1 on A and I × L/D; no separate files | Real app S2 or older server omitting optional field | Android Replay L/D covered; I L/D missing |
| R3 Rejected URL | `acpRegistry` URL is malformed, off-origin, HTTP, credentials or nondefault port; all normalize to no URL and same fallback | Newly forwarded input, but current ACP server sanitizes it | Context: one labelled native-harness still, either platform × L, if this guard is to be claimed visually | Cannot force arbitrary URL through normal provider settings; H1 injects snapshot at client boundary; one representative rejected value is enough | Uncaptured; not a separate gate for a current-server call-site fix |
| R4 Cold image → success | Accepted URL, image cache cold, controlled pending interval; ACP fallback → registry image with stable title/bounds | Yes | Required **recording**: A × L and I × L; R1/R2 already cover D endpoint colors | Real app S1 plus device-side cache clearing and selective CDN delay/release; otherwise H2 | Missing both platforms; existing settled stills do not establish this |
| R5 Cold image → failure | Accepted URL actually fails/does not decode; fallback remains and failed image is removed; no blank slot | Yes | Required **recording**: A × L and I × L; can continue R4's clip with a separate instance/request | Real app S1 with cold cache and device-side selective CDN failure; H2 if deterministic native network fault injection unavailable | Missing both platforms; backend `Not installed`/inspection error does not count |
| R6 Same instance, new/removed URL | Keep `instanceId`: loaded A → delayed B → loaded B; failed B → new valid C; valid C → absent URL. Old image must not remain under a changed identity | Yes | Required **recording**: A × L and I × L; combine sequences in one clip per platform | Real app S3 then refresh; local-source switch removes URL; deterministic delay/failure uses H2 if needed | Missing both platforms; no existing mounted transition evidence |
| R7 Wrapped provider title | Long display name that wraps at the recorded phone width, loaded registry icon; long secondary message; large text | Icon changes inside this geometry; text layout itself unchanged | Required: A × L and I × L still pairs, can share R1 scene with a second long-name instance. No extra width or D cross-product | Real app S1 with long displayName; increase length until title actually wraps at A/I | Missing; existing wrapped error text is not a wrapped title |
| R8 Multiple registry rows / isolation | Two distinct enabled ACP registry instances/URLs plus local fallback; unique keys, recognizable distinct loaded glyphs, stable ordering | Yes, each mapped registry row | Required: co-frame/scroll segments within R1 A/I × L/D captures, not a new device/theme set | Real app S1 plus S4 and S2. If all do not fit, use labelled contiguous segments | Existing scene has only one registry-image instance; missing distinct second image on A/I |
| C1 Built-in icon controls | Codex final SVG, Claude orange SVG; Antigravity raster; package SVG plain/theme fills and Muse gradient; optional URL ignored | No | Required representative Codex/Claude co-frame in R1 A/I × L/D; additional logos are context, no separate capture gate | Real app default instances; settings can enable additional built-ins. Unknown driver is H1 if not naturally registered | Android Codex/Claude covered; iOS representatives missing; remaining logos not re-captured |
| C2 Installed known version, no extras | `installed:true`, known version, no advisory/update/message; shortest row | No text-layout change | Context: co-frame if available; no independent capture | Real app installed local-command fixture with valid version probe; exact arbitrary version-only registry snapshot requires H1 | Not shown cleanly in earlier image; no standalone gate |
| C3 Installed unknown version | `installed:true`, `version:null` → Version unknown | No | Context; existing A × L/D is enough | Real app Replay/local ACP with unknown version | Covered on Android; iOS not shown |
| C4 Not installed | `installed:false` → Not installed, regardless of any version field | Icon changes if registry URL exists | Covered as a data shape of R1; do not require a second installed state/platform cross-product | Real app S1 without installing/authenticating the CLI | Covered on Android; iOS R1 missing |
| C5 Latest-version suffix | Truthy `versionAdvisory.latestVersion` adds latest suffix; absent/null removes it; long version can wrap | No | Context, can co-frame | Real app current/outdated built-in CLI; exact advisories/long versions H1 | Latest suffix shown on Android Codex/Claude; long version not shown |
| C6 Update action available | Installed, available, behind latest, canUpdate, latestVersion nonnull, latest compatibility not broken/unsupported, not queued/running; action rendered | No | Context; if pressing it, **recording** | Real app outdated disposable built-in CLI; do not update a real provider for this audit. Exact state H1 | Android visible action covered; press not recorded and not required here |
| C7 Manual-update guidance | Behind latest and `canUpdate:false`; helper instruction, no automatic update action | No | Context; still | Naturally supported non-updatable stale CLI or H1; not a provider config property | Uncaptured; outside gate |
| C8 Compatibility message | Nonempty compatibility message adds muted selectable text; absence removes it | No | Context; still; long text may share R7 stress fixture | Real app installed incompatible CLI, or H1 for exact text | Uncaptured as compatibility field; generic error message elsewhere is not this field; outside gate |
| C9 Provider reason/message | `unavailableReason` wins over message; message alone rendered; both absent removes paragraph; long values wrap | No text-layout change | Context; R1/R7 already exercise secondary text | Real app failed registry inspection/local probe; H1 for exact precedence pair | Android inspection error and Replay message covered; precedence pair uncaptured; outside gate |
| C10 Failed provider update | Non-idle failed update text in danger color; explicit message or generated `Update failed` | No | Context; **recording** if reached through update; a labelled injected settled failure may be a still | Disposable real update failure or H1; never seed by adding updateState to settings.json | Uncaptured; outside gate |
| C11 Other provider update states | Missing/idle: no update text. Queued/running: muted progress and global busy. Succeeded/unchanged: muted result. Explicit message vs generated `Update <status>` | No | Context; **recording** for lifecycle; static endpoint can be a labelled harness still | Controlled disposable RPC update lifecycle; H1 if exact state cannot be held. Same paragraph style collapses statuses for this diff | Uncaptured; outside gate |
| C12 Refresh providers flow | Press refresh → spinner/pending and disabled actions → success notice or error; repeated press guarded; icons receive latest snapshot | Only resulting URL changes matter here | **recording**; required URL-changing subset is R6, unchanged notice/error branches are context | Real app N1 → Refresh providers, RPC `server.refreshProviders` with reloadSecrets true; H2 for held/failing reply | Uncaptured; R6 mandatory subset remains missing |
| C13 Enabled count 0 / 1 / many | Disabled providers filtered. Zero enabled leaves only Refresh, no empty placeholder; one/many repeat same row; scroll for overflow | Changed call cannot run at zero; many handled in R8 | Context at zero/one; R8 covers many; **recording** only if claiming enable/disable interaction | Real app settings provider enabled flags; H1 for exact all-empty config if defaults prevent it | Four enabled rows shown on Android; zero/one not shown, no independent gate |
| C14 Providers section absent | Saved environment missing → missing-env text. Config missing → no version/provider sections. Outdated → update-required section and no icons | No execution of changed call | Context; loading/removal/reconnect claims need **recording**, not a still | Real app remove environment/back-stack; first-connect/no config; deliberately old disposable server; H1 for exact static states | Uncaptured; outside gate |
| C15 Cached offline / permissions / busy | Cached provider rows retained offline; warning text varies by session failure/unauthenticated/no-maintain. Refresh requires read scope; update requires manage. Global busy from disconnect, pending, server update, queued/running provider update | No change to icon input rule | Context; **recording** for disconnect/reconnect or permission changes | Real app disable/reconnect saved env or read-only scoped session; H1 for exact combinations | Uncaptured; outside gate |
| C16 Other page chrome and interactions | Connection expanded/collapsed, reconnect/remove/edit/enable, add route; server update running/resuming/failed; release check available/current/unsupported; global action error/notice; platform/safe-area/tab/split/fold surfaces | No | Context; **recording** for interactions; no extra tablet/foldable/theme captures required by this diff | Real app normal controls in disposable environment; H1 for arbitrary lifecycle states | Not audited visually beyond existing frame; outside gate |

## Seeding and harness boundary

N1: Pair a native dev client with an isolated environment; open Settings → Environments → the saved environment → Providers. Alternative Manage environments entry and `settings/environments/:environmentId` reach the same screen. Scroll to keep named rows legible. No provider turn, authentication or managed-agent install is needed for the icon-only fixture.

S1: Before isolated server start, place the following entry in that environment's `providerInstances` settings map (merge with existing defaults, do not replace or mutate live settings). Alternatively use `server.updateSettings` with `{patch:{}, providerInstanceMutation:{operation:"upsert", instanceId:"proofCopilot", instance:{...}}}` using this same instance object. This is an existing RPC, not an arbitrary snapshot setter.

```json
{
  "proofCopilot": {
    "driver": "acpRegistry",
    "displayName": "Copilot",
    "config": { "source": "registry", "agentId": "github-copilot-cli", "enabled": true }
  }
}
```

The driver supplies the official icon URL even when registry inspection/install fails. For R7, change displayName to `Copilot registry provider with a deliberately long name that wraps across multiple lines`. There is no `iconUrl` override in provider settings.

S2: Use the dev runner's Replay (dev) local-command ACP instance, or add a local-command ACP with `config.source:"local"` and a harmless local ACP executable. Its server snapshot omits iconUrl. Do not run a paid agent turn.

S3: Through `server.updateSettings`/providerInstanceMutation upsert, retain the same instance ID but change `config.agentId` between two known registry agents with visibly different valid icons. Then change source to local to remove the URL. Return to/retain the detail screen and use Refresh providers where needed. Pick/verify actual available registry IDs at capture time; Copilot plus `pi-acp` is a candidate, not a claim that the latter asset was fetched in this pass. Keeping the route mounted is essential to the URL-state proof.

S4: Create a second registry instance under a different instanceId, with a different known loadable official icon; do not duplicate Copilot under another name and claim distinct-image coverage. Each row remains enabled; do not seed `ServerProvider` snapshot fields directly into settings.

H1: Current production seeding does not expose arbitrary `iconUrl`, version, advisory or update-state values. If precise branch coverage needs these, use a **labelled native harness capture** that renders the production screen/ProviderIcon with controlled client config/session atoms and realistic contract-valid snapshots. Keep production files unchanged. A fake HTML page or hand-drawn icon is not native image proof. For helper-only invalid values, label that the input is outside the normal ServerProvider shape.

H2: expo-image loads on the device, independently of the server's network. Server-side CDN blocking does not reproduce a client image failure. To capture real R4/R5 use a cold device image cache and device-side selective fault/delay for the official CDN; preserve the backend connection. Record the cache/fault setup and actual request outcome. If unavailable, a native harness must preserve the real expo-image component/decoding/events and control its network response; replacing Image with a synchronous mock cannot prove iOS/Android image loading. A deterministic 404 official-CDN URL in a harness covers failure, but not success decoding; keep the real-app R1 proof. Do not claim deterministic delay/failure from merely waiting a fixed number of seconds or from a cached offline image.

## Minimum capture plan and acceptance

1. Static scenes: **A/I × L/D × before/after**, showing R1, R2, R8 and representative C1 controls. Existing Android scenes partially satisfy this: they lack R8's second distinct registry image. Frames can be reused for C3/C4/C5/C9 where those fields are actually visible.
2. Geometry: **A/I × L × before/after** with R7's actually wrapped title; include in the static scenes if space permits. This needs no extra wide device or dark geometry run because the width/layout code is shared and unchanged.
3. Native recordings: **A/I × L × before/after** covering R4/R5/R6. A single multi-segment clip per platform/revision is sufficient if fault setup, instance identity, ordered transition and results are documented. Base remains generic ACP while head loads the registry image; error/fallback endpoints remain visible on both. Existing L/D static endpoints already cover tint colors, so repeating each temporal sequence in dark is not the minimum.
4. Optional R3/unchanged context captures must be labelled honestly; their absence alone is not a blocker. No separate composer/usage/web/desktop evidence is required for this call-site-only change.

No runtime behavior has been visually verified beyond the downloaded existing Android images. No implementation defect is asserted. Completion requires reviewing the new media against these rows, not merely attaching links. Budget: completed within the 45-minute allowance; all specified code/evidence derivation covered, new capture execution deliberately belongs to a later pass.

FRICTION: none

VERDICT: INSUFFICIENT R1/R2/C1 iOS light-dark; R4/R5/R6 Android+iOS recordings; R7 wrapped-title Android+iOS; R8 distinct registry rows Android+iOS
