Skip to content

feat(composer): replace the breathing dot with a working edge - #101

Merged
Broccolito merged 4 commits into
mainfrom
design/composer-thinking-indicator
Aug 22, 2026
Merged

feat(composer): replace the breathing dot with a working edge#101
Broccolito merged 4 commits into
mainfrom
design/composer-thinking-indicator

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

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:

  • Its dot was byte-identical to TurnActivityIndicator's, and deriveTrailingActivity only suppresses that one for WaitingForUserInput, LoadingConversation and streaming prose — so for Thinking, Compacting and all of tool execution (most of a turn) both were on screen at once.
  • They sat 8px apart and could not be aligned. The row's pl-2 put its pill on the composer's 12px grid; the transcript's sat on the transcript's 4px one. Both were correct, to different grids — deleting pl-2 would have broken the composer's internal alignment rather than fixing anything. One of the two had to go.
  • Returning null when 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-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 instead:

  • 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 strength

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. 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 tier 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 rather than 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, 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

  • Feature
  • Documentation
  • Tests

AI Assistance

  • This PR was created or reviewed with AI assistance

Testing

Gate Result
npx vitest run 318 files / 3143 tests, exit 0
npm run lint:check (typecheck + eslint + themes + 332 contrast + tokens) exit 0
node scripts/verify-composer-working-edge.mjs 12 scopes (3 families × 2 modes × normal/reduced motion), exit 0
cargo check -p biorouter-mcp --tests exit 0

New coverage: src/styles/composerWorkingEdge.test.ts (15, the declarations) and src/components/ChatInput.workingEdge.test.tsx (11, the hook).

jsdom can see none of this — no layout engine, no Tailwind, no :has(), no color-mix(), no @property — which is why scripts/verify-composer-working-edge.mjs drives 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 in chatStreamStore, so specimen D (typographic) restores it in one follow-up change.

Related

Merges cleanly with design/knowledge-ui-redesign (verified with git merge-tree: exit 0 — the two share main.css and docs/design/README.md but touch different regions). No overlap at all with fix/artifact-wrapper-csp.

🤖 Generated with Claude Code

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.
@Broccolito
Broccolito merged commit 2c07bc3 into main Aug 22, 2026
13 checks passed
@Broccolito
Broccolito deleted the design/composer-thinking-indicator branch August 22, 2026 19:55
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.

1 participant