Canvas tiles that show their state — border prototypes
Five terminals on the canvas; which one needs you? Surface each session's run-state on the tile border itself — a loudness ladder ranked by attention (alert ▸ fresh-waiting ▸ working ▸ stale-waiting ▸ idle), where "waiting" cools with last-activity age via kolu's existing activity-window. Four live, animated visual languages to choose from.
Five terminals on the canvas — which one needs you? The state exists today (a title-bar label, a dock dot), but reading a title bar isn’t a glance. Put the signal on the tile border, where peripheral vision catches it across the whole canvas.
A chooser, now decided: refined Language C (run/sweep) shipped in #1348 — motion carries the state, in the tile’s own repo colour (the one colour used throughout), and the active tile is marked by an offset repo outline. The four live border languages below are kept as the design record; each renders the attention ladder (ranked by who needs you; a waiter cools as it ages).
The attention ladder
A loudness ladder ordered by who needs you — and waiting cools as it ages (a fresh waiter outranks a busy tile; hours later it sinks below). Reuses kolu’s existing ranking + ageing, no parallel scheme:
| Rank | State | When | Color | Border |
|---|---|---|---|---|
| 1 · Alert | unread — flipped to needing you while you weren’t looking | violet --color-alert | fast throb + halo (~1.2s); self-clears on focus | |
| 2 · Waiting · fresh | awaiting and recent — within your activity window (esp. < 4h) | violet --color-alert | gentle slow breathe, bright | |
| 3 · Working | thinking / running tools / background workflow | rust --color-busy | steady hum — no motion | |
| 4 · Waiting · stale | awaiting but older than your activity window (parked) | violet --color-alert | dim ember, static — cooled below the working hum | |
| 5 · Idle / done | no agent, finished, or acknowledged | repo identity | none |
Waiting cools as it ages
The same waiting tile at four ages — its glow dims and stops moving as
lastActivityAt recedes, crossing below the working hum at your activity-window
threshold. Same clock as the dock and minimap (useStaleCheck) — one vocabulary,
one persisted choice.
rank 2 — loud
still above the hum
crossing your window
rank 4 — ember
First tile = live breathe; the rest are the same state cooling. The crossover is wherever you set the activity window — yours to tune, not hard-coded.
The active tile — where focus meets the ladder
The ladder ranks attention demand (look next); focus marks where you are. Two axes — keep them apart:
- Focus = teal, its own channel: a teal inner ring + right-edge accent
(
activeId). Teal = “you are here”; the aura = “this wants you.” Inner ring vs outer glow, so they don’t collide. - The active tile is never alert — focusing clears
unread, so the loudest rung only ever lights an inactive tile. The eye-grab always points somewhere new. - Focus mutes the tile’s own aura (proposed
opacity: 0.4) so the focus accent dominates — you can still glimpse that it’s working/waiting.
One rule: loudness = state-tier × age-decay × presence. The tier picks
colour + motion; age-decay folds into it (stale waiter → ember, stale worker →
none); presence is the active mute. So the brightest border is always an
inactive, fresh, attention-class tile — exactly where the eye should land.
Prototypes — pick a language
Four languages, live across the ladder (waiting shown fresh), each with its wins and costs tagged. Two tiebreakers decide it: a continuous loudness ramp (so a waiter can dim) and surviving the minimap (so canvas and map speak one language).
A — Halo
continuous dim-ramp leaves repo + focus untouched shrinks imperfectly to minimapA soft outer glow carries state; the border line stays repo identity. A glow has a continuous brightness axis — it renders the whole ranked, aging ladder cleanly (alert brightest → stale waiter dims to an ember; the decay strip above is this language turned down).
B — Live border
most literal overwrites repo identity no room to dimThe border line itself takes the state colour. But it overwrites repo identity while busy (most of the time), and a solid line has nowhere to dim to (a faint border reads as a bug). Fine for a binary; awkward for a ranked, aging ladder.
C — Run / sweep: motion is the state, colour is the terminal
most alive + theme-native motion on every busy tile no learnable colour reduced-motion deletes it needs D at minimapThe type and speed of motion carry the state, freeing the colour to be the terminal’s own:
- Working runs — marching ants stream calmly around the edges (“busy,” asks nothing).
- Needs-you sweeps — a comet whose speed is the urgency: alert fastest → fresh slower → stale slowest (decay read as a comet winding down).
The light is the tile’s own theme colour, brightened for louder states — a teal terminal runs teal, an amber one amber. That answers “why orange and purple?” — but state then lives only in motion (see costs above).
sweep · fastest
sweep · medium
sweep · slow
marching ants
static
Read from how it moves: ants vs comet splits working from needs-you; sweep speed splits alert → fresh → stale. Colour is whatever the terminal is — the same waiting · sweep across five themes:
The trade: most alive and theme-native of the set — but the costs are real. Moving light on every busy tile; no learnable fixed colour; reduced motion deletes the only channel; and a comet doesn’t shrink to a legible minimap marker — so C falls back to D there anyway (two languages).
D — Top bar (reads at any zoom)
scale-invariant — one shape canvas + minimap calmest, zero ambient motion least expressive range easy to miss peripherallyA thin status bar on the top edge — steady rust (working), violet pulse (fresh-waiting), sharper blink (alert), nothing (idle). The calmest of the four, and its edge is scale-invariance: the same legible shape on a full tile or a 40px minimap marker — the others can’t claim it (a glow or comet dissolves when tiny).
The minimap already wants this: today it paints a corner dot. Language D unifies both surfaces — the same top edge, scaled down:
Decay survives — dim the bar’s opacity and shrink its width as a waiter ages (the short faint marker above): a draining gauge. The honest cost vs Halo is range — a 2.5px bar carries less nuance, and a top edge is easier to miss peripherally. Richness traded for one-shape-everywhere coherence.
Open decisions
Independent of the language — a few open choices, each with a leaning. None settled.
Working color — rust, not teal
Focus already owns teal, so working can’t also be teal (teal-on-teal is ambiguous). Working = rust keeps three legible hues: teal = you’re here · rust = it’s busy · violet = it wants you. (For A/B/D; C uses the tile’s own colour.)
Motion = needs-you (working stays steady)
Motion is reserved for the rungs that want you; working is a steady hum. Open lever: fully static, or a barely-there breath so it still reads “alive” — as long as it stays quieter than fresh-waiting.
The decay curve & threshold
- Continuous vs banded — a smooth
--aura-intensity, or reuse the existing age bands (idleBucketFor, packages/client/src/terminal/activityWindow.ts) for discrete steps, zero new math. - Which window — reuse the persisted
activityWindow(default24h;All= no decay). Open: is a filter knob the right decay clock? - Where stale lands — a dim violet ember (keeps the “it wanted you” memory), or demote to parked (quieter, matches the dock).
The active tile’s aura — mute or suppress
Mute to a whisper (still glimpse working/waiting on the tile you’re in) or fully suppress (focus ring only, calmest). Leaning: mute.
Idle / done · reduced motion · settings
- Idle / done stays bare (repo border only).
- Reduced motion collapses every gallery to a static state. ⚠ For C that deletes the only channel (motion) — a real reason to weigh A/D.
- Settings — likely on by default; a
PreferencesSchematoggle is a small add.
If built — how it would wire in
Small and contained: no new state machinery, because the upstream classifiers already exist. The shape, whichever language wins:
- A pure canvas-only mapper —
tileAura(bucket, unread, stale) → tier, reusing the same inputs the dock reads (agentBucket, the unread flag,useStaleCheck), kept pure so it unit-tests without a Solid harness. - One reactive socket, two surfaces — a
useTileAurareceptacle the canvas tile (packages/client/src/canvas/TerminalCanvas.tsx) and the minimap marker (packages/client/src/canvas/CanvasMinimap.tsx) both read, so they can’t drift. Age from the same 60s-ticking staleness the dock uses (packages/client/src/terminal/staleness.ts) — no new timer. - The tile (packages/client/src/canvas/CanvasTile.tsx) takes
the tier as a prop and paints the chosen treatment; the active tile mutes its own
aura;
prefers-reduced-motionfalls back to a static state.
No new state or timing — a CSS block, a small pure mapper + its socket, and the prop wiring on the tile and minimap.
Status: implemented in
#1348 — refined Language C
(run/sweep): working “runs” as marching ants, needs-you “sweeps” a comet whose
speed is the urgency, alert throbs loudest, all in the tile’s own repo
colour (one colour throughout — theme-derived hues, teal, and white were each
tried and rejected). A stale waiter cools to a dim ember; a stale worker parks to
nothing. The active tile is marked by an offset repo outline on the dark canvas
(not a ring, glow, or chrome accent — those were tried and rejected), and
prefers-reduced-motion freezes every aura to a static ring. The pure
tileAura mapper + useTileAura socket reuse the dock’s existing classifiers;
the minimap keeps its own marker. Shipped after a lens (lowy ⇄ hickey) + codex
review gauntlet.