# fix\(web\): limit project monograms and retain icon save errors · PR \#22830 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22830)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: open

Source repository: pingdotgg/t3code

Source branch: fix/issue-22793

Target branch: main

Head commit: 93ecde635d67589b4b79193b26fd562ee81936ce

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/22830)

[Commits](https://git.cafe/pingdotgg/t3code/pull/22830/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/22830/diffs?format=markdown)

## Description

---

## Problem

Typing ABCD in Settings → Projects → Choose icon → Monogram kept all four characters; Save closed the picker and showed “Failed to mutate project.” The server returned “Project monograms must contain at most two characters.” Reproduced twice on [1688f965](https://git.cafe/pingdotgg/t3code/commit/1688f965682d331da16a8c39f049592361f69c8d) using the real isolated web app.

## Change

Normalize input before keeping two complete graphemes, preserve composition until commit, and disable invalid saves. Await icon saves and retain the selection with an inline server reason on failure; Icons and Emoji share that save behavior. Contracts remain unchanged: snapshot decoding permits up to32 code units; the server command enforces two graphemes. Mobile has a separate renderer and no shared picker.

## Scope and approval

Fixes https://git.cafe/pingdotgg/t3code/pulls/22793

Triaged bug via the automated issue flow; GitCafe mirror https://git.cafe/pingdotgg/t3code/issues/22794.

The supplied issue number maps to [#22794](https://git.cafe/pingdotgg/t3code/issues/22794) on GitCafe by exact title and author; GitHub report [#22793](https://github.com/pingdotgg/t3code/issues/22793). No issue comments, labels, closes or conversions were made.

## Verification

Compared merge-base [1688f965](https://git.cafe/pingdotgg/t3code/commit/1688f965682d331da16a8c39f049592361f69c8d) with head [7aa1e55f7b](https://git.cafe/pingdotgg/t3code/commit/7aa1e55f7b669bbf0bd1587485b531f1a215efbe): **13/13 rows captured, 52 matched condition pairs, 96 recordings**, plus eight S0 contact sheets containing 24 original source stills.

[Full capture index with runner-clock narration and disclosed media offsets, Before/After media and mutation/event traces](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md). The public index is the exact audited snapshot, sha256 `d5907f06b542e926e8c03593015710d57ceef3d737af23dd2a13addf3623ad52`; the superseded 19:09 upload must not be cited. Each table link below opens its named pair; narration appears above that pair in the index. Runner timestamps are not exact video seek times: measured desktop visible opening is 1.45–2.17s into the media and later sampled actions roughly +1.5–2s from runner offsets; narrow opening is 0.30–0.92s with later action lag generally under 0.9s. Offsets include action latency. Videos are ordinary links.

Narration: M1 holds a valid AB/blue save, attempts a repeat, releases and reopens. M2 types a/b/c/d separately. M3 performs all five real clipboard pastes and saves/reopens the Unicode cases. M4 attempts pointer/Enter saves on empty, spaces and A!, then corrects AB. M5 uses disclosed synthetic composition through commit. M6 rewrites outgoing AB→ABC for the real server invariant rejection, then retries AB. M7 injects generic failures, edits AC, cancels/reopens and saves. I1/E1 hold the selected icon/emoji save through edit and dismissal attempts; I2/E2 inject failure then retry. The raw I2 trace step labels preserve the runner’s original wording, including “duplicate Save attempt”; request modes/counts and the corrected index narration govern that claim. I2 head’s second Save follows unlock after the immediate failure: two sequential rejected requests precede the final successful retry; it does not prove in-flight deduplication. Emoji reopening additionally re-saves the unchanged selection to verify restoration. R1 seeds AB/blue, holds Reset with an explicitly synthetic same-turn handler double invocation, attempts panel actions, then exercises failure/retry. S0 shows all three resting picker tabs.

| Row | 1440×900 light pair | 1440×900 dark pair | 390×844 light pair | 390×844 dark pair |
|---|---|---|---|---|
| S0 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#S0-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#S0-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#S0-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#S0-390-dark) |
| M1 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M1-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M1-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M1-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M1-390-dark) |
| M2 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M2-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M2-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M2-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M2-390-dark) |
| M3 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M3-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M3-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M3-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M3-390-dark) |
| M4 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M4-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M4-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M4-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M4-390-dark) |
| M5 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M5-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M5-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M5-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M5-390-dark) |
| M6 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M6-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M6-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M6-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M6-390-dark) |
| M7 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M7-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M7-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M7-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#M7-390-dark) |
| I1 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I1-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I1-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I1-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I1-390-dark) |
| I2 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I2-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I2-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I2-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#I2-390-dark) |
| E1 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E1-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E1-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E1-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E1-390-dark) |
| E2 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E2-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E2-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E2-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#E2-390-dark) |
| R1 | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#R1-1440-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#R1-1440-dark) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#R1-390-light) | [Before / After](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/e0301852e8053b3d/captures-index.md#R1-390-dark) |

Independent proof audit: [VERDICT: SUFFICIENT](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/58878c9ce46e07cb/proof-audit-pass2.md), Claude Opus 5.5 through ClaudeAgent. The audit’s exact findings and inspection limits govern this evidence. The first review decoded all 96 and checked traces/timing; directly inspected selected frames from about a dozen recordings and one S0 pair, and watched none continuously end to end. Capture counts do not imply every matrix checkpoint was verified.

Unverified boundaries: native OS IME (M5 is synthetic), native iOS/Android, Electron native file selection, remote/relay/tunnel and multi-environment/member behavior. R1 checks Choose file availability only. The custom emoji entry field reopens blank on both revisions; persistence/restored selection is proven by the settings value and unchanged real re-save, not by a refilled field. I1/I2 picker selected-icon/color restoration on reopen is unverified in all four conditions on both revisions: Layers 2 is off-grid and the helper clicks Blue after reopening. Persistence is proven by Settings and real success responses. M4 aria-invalid was not retained in the trace or independently asserted; head spaces normalize to empty. Pending deduplication evidence comes from M1/I1/E1 and R1, not I2.

Environment: fs-13 fleet VM, Linux 6.1.102 x86_64; Chromium 154.0.0.0 desktop UA; DPR 2; root font 16px/default scaling; system UI fonts with Noto Color Emoji/Unifont fallbacks; UTC. UI clock frozen after auth to 2026-10-21T12:00:00Z, server clock real. Light/dark forced consistently. C4 is responsive web. Encoded videos are 2880×1800 and 780×1688; original preview PNGs are 1280×800 desktop (tool downsampling) and 780×1688 narrow. Source stills are unaltered inside labeled contact sheets.

The dev runner internally set T3CODE_HOME to its worktree `.t3`; the agent did not set an ambient override. Both servers used isolated state; head ports 14735/6695, before 15415/7375. Fixture folder `/tmp/t3-monogram-evidence`, retained identical display name `t3-monogram-evidence` on both revisions, one local environment/member, Automatic icon reset between cases, Layers 2 for icon fixtures. Baseline prior-case toasts persist at frame zero in 1440-light M6/E1 and 390-light/dark M3/M6; first-frame scans also flag likely 390 M4 carry-over. These starts are not pristine independent resets. The index discloses each affected recording; the stale desktop M6 toast is gone by 1.18s and the current-operation toast appears separately at about 5.35s. Synthetic hold/failure/composition/handler probes are disclosed per recording; M6 alone uses an actual server-rule rejection. No agents ran in the fixture. Disk: 3.5 GB initially, approximately 2.86 GB after teardown, never below the 2 GB stop threshold. Original capture and later excluded probe sessions stopped by Ctrl-C through captured exec sessions; no listeners remain on their four ports. Worktrees are clean; PR body/comments/branches unchanged. No forge CLI, repo-wide checks or check-monitor watch mode ran.

Capture: GPT-6.1-Sol, Codex harness. Public writes limited to requested ghdrop evidence uploads.

Evidence re-captured as recordings per EVIDENCE.md (capture: Codex gpt-6.1-sol; proof audit: Claude Opus 5.5).

Implemented with Codex gpt-6.1-sol in T3 Code; independently reviewed with Codex gpt-6.1-sol and Claude Opus 5.5.


### Focused save-guard follow-up

[93ecde635d](https://github.com/pingdotgg/t3code/commit/93ecde635d67589b4b79193b26fd562ee81936ce) moves both save-guard resets into `finally`, so a throwing error formatter cannot strand the dialog. The button now returns the save promise directly. The formatter itself is unchanged.

Verification: the new executable handler regression injects a rejection with a throwing `cause` getter, verifies saving is cleared, dismissal works, and a second save succeeds. It fails with the old cleanup placement and passes with this fix. Both focused unit files pass (6 tests); changed-file lint/format and web-only typecheck pass (existing Effect suggestions only). Tests use mocked React hooks, not a mounted client. No browser was authorized for this follow-up; earlier recordings do not certify this diff. Browser/native/remote runtime verification remains a gap.

Follow-up: GPT-6.1-Sol through the Codex harness in T3 Code.

Fixes #22793
GitCafe: https://git.cafe/pingdotgg/t3code/issues/22793

