Sourced from
docs/led-ux.mdin the firmware repo.
The LED ring - motion & color language
The ring is not a status readout, it is an ambient companion on your desk. Its job is to make the state of your work felt at a glance and in the corner of your eye - calm when nothing needs you, alive when things happen, unmistakable when you're needed.
Reading it takes one sentence: color says what, motion says whether it is live, brightness says how much it matters. A sliding comet is work in progress; a slow breathe is something waiting on you; a still, settled ring means "handled, relax."
Three design principles behind everything below:
- Nothing pops. Every appearance, change, and disappearance is a short, purposeful transition (150–400 ms), never an instant swap. Instant swaps read as glitches; motion reads as intention.
- Motion means work; stillness means rest. Anything moving is happening now.
- Color is meaning, brightness is urgency, motion is life.
Reading the colors
| Meaning | Hue | Used for |
|---|---|---|
| Running / thinking | blue (170) | active jobs |
| Waiting for you (input) | purple (213) | your turn to answer |
| Waiting for you (approval) | amber (32) | permission gate |
| Done / success | green (85) | completion, connect success |
| Error | red (0) | failures, critical battery |
| Provider accents | Anthropic cyan · OpenAI green · Mistral amber · unknown white | segment tint |
| System / neutral | white | boot, connecting, "saved" blip |
| Setup needed | slow rainbow | unconfigured, invites setup |
Provider accent tints the segment; status drives the animation. The two compose: a blue-comet "running" segment tinted cyan reads as "Anthropic is thinking."
The exact hues follow your theme: each theme maps the status roles to its own color family, and changing the theme recolors the ring live. The status → role mapping and the theme rules (red reserved for errors, roles hue-distinct) are in Modes & signals §5 and the full specification, Notifier status language.
What you'll see - the job lifecycle
Every session on the ring is an arc, and every moment of its life gets motion:
| Moment | Motion | Why |
|---|---|---|
| Birth (new session) | arc grows from 1 LED to full width (~350 ms), starting as a bright shimmer, settling to the provider color | "a new thing began" - celebratory but brief |
| Status change | crossfade the segment old → new hue (~250 ms) | continuity, never a jarring recolor |
| Progress | fill eases to the new percentage, doesn't jump | smooth, legible |
| Attention arrives (input/approval/error) | one emphasis flash (bright, 120 ms), then the steady breathe | "hey - look here," then sustained |
| Completion (done) | a bright green success ripple sweeps the arc → fades to a dim ember | earned closure |
| Termination (offline) | arc contracts back to a point and winks out (~350 ms) | graceful closure, symmetric with birth |
And smaller, transient feedback:
| Moment | Motion |
|---|---|
| Cursor moves (menu navigation) | soft trailing comet follows the cursor LED, decays after the dwell |
| Config saved (menu or web change persisted) | a single white blip travels once around the ring - "saved" |
| Voice: recording / processing / speaking | breathe (in) / comet (thinking) / solid (out), with smooth entry and exit |
| Low battery (first warning) | a slow red breathe overlaid under everything else |
How the ring level changes the language
The ring level (Dark / Calm / Full - each battery mode binds one as its default) decides how much of this vocabulary plays:
- Dark: everything collapses to the single attention LED - system states and the top attention job play on that one LED (dim breathe / emphasis flash); the rest of the ring stays dark. Birth, death, and ripple are suppressed to a brief one-LED blip. Quiet by design.
- Calm (Balanced default): Dark, plus a soft working glow - whenever a session is working (a Notifier session running, or the Orchestrator's "working" heartbeat and sub-agent births/deaths) the single LED breathes softly, so an active ring dims but never goes fully dark, without the full segment treatment. It returns to dark once the sessions clear.
- Full: the full motion language above. When idle (no jobs, no voice) the ring goes fully dark - an all-day desk ring shouldn't glow with nothing happening. A call for your attention or a single-click reveal still lights it, and a real job lights instantly at full brightness.
- The battery mode's brightness cap scales everything; in the Dark battery mode the whole language runs dimmer and slower (longer cycles, fewer FPS).
How long a state lingers
The broker sends a frame on every change, not continuously, so a quiet ring is normal between events. What lingers is deliberately split so a call for your attention can't be lost while ambient noise still clears:
- Ambient states (running / done / idle) expire on a clock scaled to the ring level after the link goes quiet: Full = 5 min (a desk display stays lit), Calm = 30 s, Dark = 5 s (battery-frugal). A dead session shouldn't leave a stale ring, but a desk shouldn't blank every few seconds either.
- Calls for your attention (waiting for input / awaiting approval / error) are what you must not miss, so they hold well past the ambient cues - 1 minute in Dark, 2 in Balanced, 5 in Full, each tunable in Customize - and keep animating the whole time. A job waiting on you never vanishes just because the broker stopped re-sending.
Wake the ring - the single click
A single click briefly wakes the ring: for ~4 s it promotes any ring level to the full-brightness segment treatment, so you can glance live status on demand - even from a fully dark idle ring. It then decays back to normal. (Double-click still opens the menu in Orchestrator; long-press is the menu in Notifier / hold-to-talk in Orchestrator.)
Whole-ring system states - design targets, not yet wired
Some whole-ring moments are designed but not implemented - don't cite these rows as current behavior:
| Moment | Motion | Status |
|---|---|---|
| Boot / waking | very dim white breathe, slow (~2.5 s cycle) | live (main.cpp boot-breathe window → driver Pattern::Pulse) |
| Idle | dark in every ring level; a needs-you cue or a single click still lights it | live (ring_plan compose - an empty Full plan renders black) |
| Connecting (Wi-Fi/Bluetooth) | dim white comet, brightness building as it retries | design intent, not wired |
| Connected | one quick bright green flash (150 ms) → fade to idle | design intent, not wired |
| Connect failed / no credentials | slow amber double-pulse, then rest | design intent, not wired |
| Setup mode (unconfigured) | slow rainbow drift, dim - pairs with the sign-in QR | design intent, not wired |
| Entering deep sleep (critical battery) | inward collapse to the top LED, then off | design intent, not wired |
Why the "not wired" rows exist
These were once a whole-ring SysState overlay in ring_animator that no
production code ever drove (setSystem() had zero callers) - it was removed
in the 2026-07 UX cleanup to stop it reading as implemented. The boot/connect
audio cues are live (SFX link sounds on the Wi-Fi had-IP and Bluetooth
connect edges); the LED equivalents are kept here as the design target if and
when they are wired.
For developers - architecture and status
solide::leds today exposes only fixed Patterns and an agent-segment
API - no raw per-pixel access - so the transitions above cannot all be
expressed through it. The clean seam:
- The Animator is portable and host-tested: it holds per-segment animation
state (phase, start time, from/to color, progress) and a system-state
overlay, and on
frame(nowMs)computes all 45 LEDs. Deterministic (the clock is passed in), so tests assert exact LED colors at chosen times during a birth grow-in, a death collapse, a crossfade midpoint, a success ripple - the golden-frame equivalent for the ring. - Device glue is thin: feed it the same router/notifier events the ring
already gets, call
frame()at the profile FPS, push toshowFrame(). - One upstream ask (flagged to the solide worker):
solide::leds::showFrame(RGB[], n)- a raw framebuffer path alongside the existing pattern/segment renderer. Until it lands, the current pattern/segment rendering stays and the Animator ships host-tested and dark-launched.
Status: this document is the design. It is implemented incrementally in
lib/core ring::Animator (see test_ring_animator) - transitions land
host-tested first, and device rendering switches on when showFrame() exists
upstream. The existing ring_plan (Dark single-LED vs Full segments) remains
the steady-state decision layer; the Animator is the motion layer that
renders those decisions with transitions between them.
Candidate "working" animations
The processing slide is the one animated steady state the ambient grammar allows, and
it has five candidate looks behind a selector (nimbus::ring::RunStyle): comet with a
fading tail (the default), comet with sparse trailing sparks, a dual comet, a breathing
arc, and fireflies. Each is a deterministic function of (nowMs, arc geometry), so the
LED ring, the on-screen ring, and the web simulator render identical frames; the host
suite test_ring_styles pins the frame invariants (fail-dark, no stray pixels after a
retire) for every variant. Preview and compare them in the self-contained page
tools/ring_ab_demo.html (it ports the device math one-to-one), or drive them on real
hardware with the test-console RINGANIM 0..4 command. The device default stays the
comet until a variant is chosen.
Keeping the head arc honest (wake-ups)
The orchestrator paints a "head" arc while a turn runs and holds it while that
turn's sub-agents run (nimbus::harness::HeadArcTracker, host-tested in
test_head_arc). The main loop reconciles it every few seconds. A short wake-up
turn can start and finish between two of those ticks, so the tracker never sees
it in flight; the tracker also watches the engine's monotonic turn counter, so a
completed-turn edge still tells it to clear an arc a finished turn may have left
lit. Several belt-and-braces backstops (a frozen-children latch, the stuck-turn
reaper, an absolute working-breathe ceiling) exist purely as insurance. In a
healthy system the primary edge always clears first, so ringBackstopFires in
/api/state stays 0; a nonzero value means a real wedge slipped past the
primary path and is worth investigating.
Nothing lit outlives its job
A turn's arcs are armed and cleared on the poll task (the head arc in the turn guard, a sub-agent's arcs as its job runs). The always-alive main loop is the safety net for when that task stalls, and the rule it enforces is simple: every ring status has a main-loop terminating edge, so nothing can stay lit after the work behind it is gone.
| Ring status | What lights it | Main-loop terminating edge |
|---|---|---|
| Waiting / Approval / Error | a job that needs the owner, or failed | attention watchdog ages it out past the attention hold |
| "Needs you" ask | the assistant asked a question | same attention watchdog (the ask's own dwell clock) |
| Head "working" arc | a turn (and its sub-agents) running | the stuck-turn reaper + the head-arc reconciler |
| Done | a finished sub-agent's fading arc | the Done reaper collapses it past the same hold |
| Offline | (terminal) | frees the segment immediately |
The Done row is the one added last (the recurring "ring stays on after it
answers" report): a finished sub-agent's arc is a dim ember that the poll task
normally collapses a moment after the result ripples, but it is not an attention
status, so before this it was the one arc the main-loop net let through - a
stalled poll task stranded it lit. It now ages out from the main loop like the
rest. Every one of these clears count into ringBackstopFires, so a healthy
device still reads 0.