Skip to content

feat(craft): stop losing drafts, add a focus floor, and give the ramp a perceptual ladder - #382

Merged
drewstone merged 2 commits into
mainfrom
feat/design-craft
Aug 4, 2026
Merged

feat(craft): stop losing drafts, add a focus floor, and give the ramp a perceptual ladder#382
drewstone merged 2 commits into
mainfrom
feat/design-craft

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

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 returned void — 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 AsyncView so 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 sets color-scheme. Against the dark canvas that is 1.03:1. Visible in light, absent in dark.

before after
dark focus contrast 1.03:1 5.98:1
light focus contrast 5.69:1
outline-none sites 82 16 (each machine-justified)
colour spellings / widths / offsets 5 / 3 / 6 1 / 1 / 1
aria-busy in src 3 14
axe violations (WCAG 2 A/AA) 1 0

Measured 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-visible is 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

  • Neutrals on a constant-chroma oklch ladder with HSL mirrors; chroma was wandering 11x (0.0014-0.0154), now exactly 0.004. Largest visual move across 16 roles is DL 0.0124. Brand hues unchanged byte-for-byte.
  • Radii derive from one root; --radius-md still computes to 8px, exactly what consumers already ship against.
  • Borders tier and invert: light softens to 40% (measured 1.311 -> 1.108 on background), dark stays full strength — softening there would erase a hairline that is already low-alpha.
  • Motion: was zero tokens and 64 transitions on an implicit curve. Now named durations and easings that collapse to 1ms under prefers-reduced-motion.

Verification

pnpm typecheck clean - pnpm test 4761 passed (275 files) - pnpm build clean - pnpm knip clean - 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: onSend widened to a union so existing void handlers still compile — a regression caught by typecheck and fixed before push.

Generated with Claude Code

… 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 tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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-react barrel (src/web-react/index.tsx:31 export * from './chat-composer'), so the new ComposerSendResult/ComposerSendFailure/onSendFailed types are importable by fleet products, and the send-handler union is proven backward-compatible by a real tsc compile 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/async contract 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 /theme ownership 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 / onSendFailed attac
  • 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.

value-audit · 20260804T163319Z

@drewstone
drewstone merged commit 1a3122d into main Aug 4, 2026
1 check passed
@drewstone
drewstone deleted the feat/design-craft branch August 4, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants