feat(craft): stop losing drafts, add a focus floor, and give the ramp a perceptual ladder - #382
Conversation
… give the ramp a perceptual ladder The composer cleared its textarea unconditionally on send, so a rejected send destroyed the user's message; it now restores the exact bytes with the caret and names the reason, and holds an unsent draft rather than overwriting a replacement. A folder click in the vault did nothing; it now scopes search and new-file creation. A failed thread fetch rendered as 'No past conversations yet'; it renders as an error with a retry. Thirty of 176 focusable elements had no focus declaration and fell back to the browser ring, which computes to the same near-black in both themes because nothing sets color-scheme: 1.03:1 on the dark canvas. One :focus-visible floor in the token layer replaces 63 per-component restatements and paints 5.98:1 dark, 5.69:1 light. Neutrals move to a constant-chroma oklch ladder with HSL mirrors, radii derive from one root, borders soften in light and stay full strength in dark, and motion gets named durations and easings that collapse under prefers-reduced-motion.
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — dc8fe651
This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.
tangletools · auto-approval · reason: drewstone_author · 2026-08-04T16:28:13Z
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved drewstone PR — dc8fe651
This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.
tangletools · auto-approval · reason: drewstone_author · 2026-08-04T16:28:38Z
tangletools
left a comment
There was a problem hiding this comment.
🟡 Value Audit — sound-with-nits
| Verdict | sound-with-nits |
| Concerns | 2 (1 low, 1 weak-concern) |
| Heuristic | 0.1s |
| Duplication | 0.1s |
| Interrogation | 239.2s (2 bridge agents) |
| Total | 239.4s |
💰 Value — sound-with-nits
Three shared-shell defect fixes (rejected-send draft loss, dead folder clicks, failed-fetch-masquerading-as-empty) plus a token-layer keyboard-focus floor and a constant-chroma neutral/radius/motion re-tuning — all in the shared shell, extending existing primitives where they exist; ship.
- What it does: (1) ChatComposer no longer destroys a rejected send: a handler that throws/rejects/returns {ok:false} restores the exact untrimmed bytes with the caret where it was, names the reason via an alert, and if the user typed a replacement meanwhile, holds the unsent draft in the notice with its own Retry — neither draft is ever destroyed; onSendFailed lets the host restore consumed attachments. The send
- Goals it achieves: Stop silently losing the user's typed work on a rejected send; make every interactive element keyboard-visible by default instead of relying on 63 restated per-component rings that 30 elements were missing entirely; make a failed thread fetch distinguishable from an empty history; give the chrome a perceptually-coherent neutral ladder (constant chroma, not HSL's wandering saturation) and decidable
- Assessment: Sound. Every change lands in the shared shell (/web-react, /theme, /vault, /assistant, /chat-react, design-canvas-react, sequences-react) — no engine logic creeps in, satisfying AGENTS.md's one rule. Where an existing primitive exists it is reused, not reinvented: AssistantHistory's error branch goes through the already-shipped AsyncView from /web-react/async (whose documented invariant is exactly
- Better / existing approach: none — this is the right approach. Searched for a pre-existing draft-restore / send-failure / focus-ring primitive to reuse: grep for onSendFailed|sendFailure|FailedSend|restoreCaret|ComposerSendRejected returns matches only inside the new composer code; git grep focus-visible:ring across src/ returns only legitimate per-component custom rings (Send's ring-offset-card, destructive Stop, provenance
- Model: opencode/zai-coding-plan/glm-5.2
- Bridge attempts: 2
- Bridge warning: opencode/kimi-for-coding/k2p7: opencode: opencode error
🎯 Usefulness — sound
Four coherent shared-shell fixes (draft-restore on rejected send, live folder scoping in the vault, a failed-fetch error branch, and a token-layer focus floor) plus a measured neutral/motion/radius token refactor — all wired into existing exports and consumed in-tree; nothing dead.
- Integration: Every new behavior is reachable through existing seams. ChatComposer ships from the
./web-reactbarrel (src/web-react/index.tsx:31export * from './chat-composer'), so the newComposerSendResult/ComposerSendFailure/onSendFailedtypes are importable by fleet products, and the send-handler union is proven backward-compatible by a realtsccompile test (`tests/web-react/composer-send-ha - Fit with existing patterns: This fits the codebase's established grain throughout. Routing AssistantHistory through AsyncView is exactly the
web-react/asynccontract the package already mandates ("a failed fetch cannot render as empty data") — the PR applies the package's own rule to a place that violated it. The focus floor belongs in tokens.css per the AGENTS.md/themeownership note, and the companion guard test (`tes - Real-world viability: The restore logic is robust under the cases that matter: it reads a live
textRef(not the closure capture) to decide restore-vs-hold, so an async rejection that lands after the user started typing a replacement correctly holds the unsent bytes in a notice instead of clobbering the new draft; six tests cover throw / promise-reject /{ok:false}/ accept / replacement-draft /onSendFailedattac - Model: opencode/zai-coding-plan/glm-5.2
- Bridge attempts: 1
🔎 Heuristic Signals
🟡 Cruft: console debug added playground/scripts/focus-audit.mjs
- console.log(
${route} (${theme.label}): ${routeChecked} focusable checked, ${routeInvisible} with NO indicator)
💰 Value Audit
🟡 Dead FOCUS_RING constant left in agent-session-controls.tsx [maintenance] ``
src/web-react/agent-session-controls.tsx:87 now declares
const FOCUS_RING = ''but is still interpolated at lines 110, 127, and 245 (${FOCUS_RING}). The floor now handles these three sites, so the constant is dead weight and the empty interpolations are noise. Delete the constant and the three${FOCUS_RING}interpolations, or — if kept as a seam for a future custom ring — rename and document it. A passing focus-floor.test.ts confirms the sites are correct either way; this is pure cleanup.
What this audit checks
It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.
| Pass | What it asks |
|---|---|
| Heuristic | Vague title? Whitespace-only or cruft-bearing diff? (content signals only) |
| Duplication | Do added function/class names already exist elsewhere in the repo? |
| Value Audit | What does it do? What goal does it achieve? Is it good? Better architecture or already-exists? |
| Usefulness Audit | Does it integrate and fit? Will it hold up in real use and actually get used? |
Findings are concerns, not blocks — the human reviewer decides what to do with them.
Three shared-shell defects that lose the user's work, one accessibility floor, and the token system underneath both.
Defects fixed
The composer destroyed rejected messages.
setText('')ran unconditionally on send, and the send seam returnedvoid— failure was structurally unobservable. Type a long answer, hit send while offline or behind a seat gate, and it was gone with no reason and no recovery. Now the exact untrimmed bytes come back with the caret where it was, focused, plus a named reason. If a replacement was already typed, the unsent draft is held in the notice with its own Retry — neither draft is ever destroyed.The optimistic clear was kept deliberately, on evidence from the component: the textarea stays editable during streaming so the next turn can be composed, so holding sent text until the server confirms would put a late clear on a collision course with the user's next draft — trading one lost message for another on exactly the slow-but-successful send that motivates the question.
A folder click in the vault did nothing. Now it scopes search and prefills new-file creation.
A failed thread fetch read as "No past conversations yet." Now an error with a working retry, through
AsyncViewso the branch cannot go missing again.The accessibility floor
30 of 176 focusable elements had no focus declaration at all, falling back to the browser's ring — which computes to
rgb(16,16,16)in both themes because nothing setscolor-scheme. Against the dark canvas that is 1.03:1. Visible in light, absent in dark.outline-nonesitesaria-busyin srcMeasured by driving real Tab presses in Chromium across 5 routes x 2 themes, diffing each focused element against its own unfocused snapshot — programmatic
.focus()does not put Chromium in keyboard modality, and:focus-visibleis the modality question.Three further contrast defects surfaced and were fixed: the "Needs your approval" eyebrow at 1.24:1 in dark, the primary button's inherited white text at 3.02:1 (axe "serious"), and the loading spinner at 1.10:1 — the only signal that a fetch is running.
Tokens
--radius-mdstill computes to 8px, exactly what consumers already ship against.prefers-reduced-motion.Verification
pnpm typecheckclean -pnpm test4761 passed (275 files) -pnpm buildclean -pnpm knipclean - peer floors 9/9 - theme contract clean.14 break-then-restore proofs, each red on the right test and green on restore. Two of the lane's own measurements were caught as wrong before being reported: a ring read immediately after a keypress computes to the transparent start of a 150ms transition, which had inflated "no indicator" from 30 to 118.
One API note:
onSendwidened to a union so existingvoidhandlers still compile — a regression caught by typecheck and fixed before push.Generated with Claude Code