feat(composer): replace the breathing dot with a working edge - #101
Merged
Conversation
The breathing dot above the composer duplicates the one in the transcript, and the two do not line up. Measuring it turned up more than the complaint: - The dot markup is byte-identical between LoadingBioRouter and TurnActivityIndicator, and deriveTrailingActivity's "do not double-narrate" suppression list misses Thinking, Compacting and all of tool execution -- i.e. most of a turn, during which both are on screen. - The 8px offset is not a stray padding value. The transcript pill sits 4px from the column edge, the composer pill 12px, and 12px is exactly where the composer's own context row sits. Both are correctly aligned, to different grids. Deleting pl-2 would break the composer's internal alignment; the duplicate has to go instead. - Three pulse vocabularies run concurrently (the custom ring+glow at 1.8s, and two stock animate-pulse dots at 2s), so the composer dot genuinely does not match the tool-call badge -- they are not the same component. - 1.8s is off the duration ladder. --dur-slow (525ms) is documented as the ambient-loop tier and has zero call sites. - design.md already specifies a spinner (§4.20) and a streaming caret (§4.18); neither was built, so three canonical periods disagree on paper. - The composer indicator still cannot simply be deleted: the transcript one switches itself off while prose streams, and there is no caret, so the composer pill is the only live signal for much of a turn. Adds the redesign of record plus a studio page where six directions animate at 1:1 inside a real 760px chat column, painted from the app's own tokens. Recommends A (travelling composer edge) + D (typographic, no dot) with F (composer recedes) to follow, since A and D both flatten under prefers-reduced-motion and F is a state rather than a loop. Proposed only -- no implementation, no change to any shipped component.
The specimens sketched the composer instead of reproducing it, so two of the six directions were being judged against a mock that flattered them. Rebuilt every rig from the source: rounded-container at py-2.5 pr-3 pl-4 (a 54px card), a 32x32 rounded-element Send/Stop box (8px, not a circle), three rows 6px apart, the real controls in rows 1 and 3 -- DirSwitcher, the extension/skill/knowledge counts, EffortBars, the model chip with its privacy and affiliation badges, the context ring and the cost readout -- with lucide path data taken verbatim from the installed package and the two hand-authored SVGs (EffortBars, the context ring) reproduced from their own geometry. Verified in a browser: all 7 rigs measure card 12px/54px, send 32x32 r8px, and the ink column the differing row paddings exist to preserve lands at 236/237/236 (folder / placeholder / reasoning glyph). What that surfaced, and what it changed: - During a turn the composer already carries TWO accent signals -- the card border, coral at rest because the composer autofocuses, and Stop, which passes no variant and so takes `default`, a solid accent square. The breathing dot is the third object in that box and the only neutral one. The composer is not short of signals, it is short of distinguishable ones. - A changes mechanism. An accent arc over an accent edge is invisible, so the working state now modulates the edge it already has: the border drops to 32% for the turn and a full-strength segment travels round it. - E gets worse. An accent orbit around an accent-filled button has no contrast to work with; the earlier mock drew Stop as a grey circle, which hid that. Also fixes a cascade collision the replication introduced: the chip count class `.n` was picking up the comparison matrix's red "no" cell colour.
Specimen A from the thinking-indicator study. While a turn runs, the composer card's own 1px border carries a slow travelling segment of the brand accent; the row above the composer that used to hold a breathing dot is gone. That row had three problems and none was fixable in place: its dot was byte-identical to TurnActivityIndicator's and both were on screen for most of a turn; the two sat 8px apart and could not be aligned, because the row's pl-2 put its pill on the COMPOSER's 12px grid while the transcript's sat on the transcript's 4px one -- both correct, to different grids; and returning null when idle made the composer jump ~34px on every Send. FOCUS AND WORKING ARE DIFFERENT AXES, AND BOTH CAN BE ON. This is the substance of the change. The composer autofocuses, so --border-accent is its RESTING appearance (D-15) -- painting the working edge accent would have made the two states the same picture. They are separated by what each does to the edge: focus = WHERE the accent is (the whole edge, evenly) working = WHERE THE INK IS CONCENTRATED (one bright segment, moving) | focused | working | edge | | no | no | the resting hairline | | yes | no | evenly accent (unchanged) | | no | yes | hairline + a bright accent segment travelling | | yes | yes | accent at 30% + the same segment at full | ONE HUE, AND IT IS THE FAMILY'S. Every declaration paints --border-accent and nothing else, so the edge is coral in Parchment, teal in Alma Mater and orange in Roche Limit, light and dark, with no literal colour anywhere in the block. The period is calc(var(--dur-slow) * 4) = 2.1s -- the FIRST call site of the ambient-loop tier, which main.css reserves for exactly this and which had none. The old dot hardcoded 1.8s, off the scale entirely. Authored CSS keyed off a data-working attribute, not a Tailwind utility: a newly written arbitrary class can silently fail to reach the stylesheet under BIOROUTER_NO_HMR, and this needs @Keyframes and @Property regardless. Under prefers-reduced-motion the sweep does not freeze mid-rotation (which would park a bright blob on one corner and read as damage) -- it holds a static DASHED accent ring, still the accent and still not the solid focus edge. Verification: - src/styles/composerWorkingEdge.test.ts (15) -- the declarations - src/components/ChatInput.workingEdge.test.tsx (11) -- the hook - scripts/verify-composer-working-edge.mjs -- what a browser COMPUTES, across 12 scopes (3 families x 2 modes x normal/reduced motion). Proven to fail when the dim is removed, which is the bug the feature exists to prevent. - Full suite 316 files / 3133 tests green; lint:check green (332 contrast assertions included). The two context-management e2e specs used [data-testid="loading-biorouter"] as their "turn is running" sentinel; they now use [data-working="true"] via a named constant, and the six compaction assertions that depended on the removed row's TEXT were retargeted at the transcript indicator's data-phase attribute. KNOWN LOSS, deliberate: the removed row displayed getThinkingMessage(), the agent-supplied narration for a turn, and nothing displays it now. That is the cost A's own entry listed; specimen D restores it in one change and the function is still live in chatStreamStore.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the breathing dot above the chat composer with a working edge: while a turn runs, the composer card's own 1px border carries a slow travelling segment of the brand accent. The status row that used to hold the dot is gone.
That row had three problems, and none was fixable in place:
TurnActivityIndicator's, andderiveTrailingActivityonly suppresses that one forWaitingForUserInput,LoadingConversationand streaming prose — so forThinking,Compactingand all of tool execution (most of a turn) both were on screen at once.pl-2put its pill on the composer's 12px grid; the transcript's sat on the transcript's 4px one. Both were correct, to different grids — deletingpl-2would have broken the composer's internal alignment rather than fixing anything. One of the two had to go.nullwhen idle made the composer jump ~34px on every Send.Focus and working are different axes, and both are on at once
This is the substance of the change. The composer autofocuses, so
--border-accentis its resting appearance (D-15) — painting the working edge accent would have made the two states the same picture. They are separated by what each does to the edge instead:One hue, and it is the family's
Every declaration paints
--border-accentand nothing else, so the edge is coral in Parchment, teal in Alma Mater and orange in Roche Limit, light and dark. A test asserts there is no literal colour anywhere in the block.The period is
calc(var(--dur-slow) * 4)= 2.1s — the first call site of the ambient-loop tiermain.cssreserves for exactly this and which had none. The old dot hardcoded1.8s, off the scale entirely.Authored CSS keyed off a
data-workingattribute rather than a Tailwind utility: a newly written arbitrary class can silently fail to reach the stylesheet underBIOROUTER_NO_HMR, and this needs@keyframesand@propertyregardless.Under
prefers-reduced-motionthe sweep does not freeze mid-rotation (which would park a bright blob on one corner and read as damage) — it holds a static dashed accent ring: still the accent, still not the solid focus edge.Design record
docs/design/composer-thinking-indicator/carries the diagnosis (seven measured findings), six candidate directions rendered live at 1:1, and why A was chosen. B–F remain proposals.Type of Change
AI Assistance
Testing
npx vitest runnpm run lint:check(typecheck + eslint + themes + 332 contrast + tokens)node scripts/verify-composer-working-edge.mjscargo check -p biorouter-mcp --testsNew coverage:
src/styles/composerWorkingEdge.test.ts(15, the declarations) andsrc/components/ChatInput.workingEdge.test.tsx(11, the hook).jsdom can see none of this — no layout engine, no Tailwind, no
:has(), nocolor-mix(), no@property— which is whyscripts/verify-composer-working-edge.mjsdrives a real browser and asserts what it computes. The gate was sabotage-tested: setting focused+working back to the full accent produces 12 failures and exit 1, so it can actually fail.⚠ Note for anyone extending it:
:has()style invalidation lags a focus change, so reading computed style in the same task returns the previous value. Every read waits two animation frames; that is deliberate, not incidental.Known loss, flagged deliberately
The removed row displayed
getThinkingMessage()— the agent-supplied narration for a turn — and nothing displays it now. That is exactly the cost this option's entry listed in the design doc. The function is still live inchatStreamStore, so specimen D (typographic) restores it in one follow-up change.Related
Merges cleanly with
design/knowledge-ui-redesign(verified withgit merge-tree: exit 0 — the two sharemain.cssanddocs/design/README.mdbut touch different regions). No overlap at all withfix/artifact-wrapper-csp.🤖 Generated with Claude Code