Skip to content

Give every Bot a face of its own, and follow the system's dark mode - #913

Open
jerelvelarde wants to merge 12 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-look
Open

jerelvelarde wants to merge 12 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/openbot-look

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Give every Bot a face of its own, and follow the system's dark mode

The problem

A Bot is drawn as a gradient circle from boring-avatars, picked from its seed. In a list, two Bots are told apart by a blur of colour with nothing to recognise, and OpenBot's own mark is a different drawing altogether, so the product and its Bots look as if they came from two places.

The colours are shadcn's neutral defaults, down to a mid-grey focus ring. Text sits on a scale of its own, and the stylesheet admits it: "Text is sized on a 15px base while everything else is on Tailwind's 16px rem."

Dark mode is a switch that ignores the operating system, and the old code wrote light to storage on every load, so nothing can tell a person who chose light from one who never chose.

The approach

The values go where every screen already reads them. The palette fills shadcn's own variables in app/src/theme.css, so Bots, Capabilities, Connections, Settings and Admin change without touching each screen, and each skin still overrides them under :root[data-skin]. Light is cool greys, a near-black primary and one accent, #6430ab, kept for focus and selection; dark is near-black with the primary turned light. Text drops the 15px scale and follows the browser's own size, so it is larger everywhere. That reaches the browser extension's pages too, since they import app/src/theme.css, and the recording notice copies the colours, with a test holding the copy equal.

Dark mode follows the system. Settings → General → Appearance offers System, the default, Light and Dark, stored under a new openbot-appearance key and written only when somebody chooses. For anybody who never chose, the old openbot-theme key's dark stays Dark and its light reads as System, since it was written whether or not anybody asked. index.html repeats the rule before the first paint, and a test holds the two together.

Every Bot is an orb, drawn on a canvas. BotOrb draws a glass sphere with two eyes that blink, follow the pointer, and show listening, working and done. A Bot's look comes from a hash of its seed: one of eight colours, and from the same hash a turn of its colour masses, a nudge to its hue and the spacing of its eyes, so two Bots in one colour still look apart. Violet is kept for OpenBot's own mark (AgentOrb), so no Bot is mistaken for the product. Nothing imports boring-avatars any more, and it leaves the app's dependencies.

A list of faces costs little. Every orb on the page draws on one shared animation frame, and none draws off screen or in a hidden tab. A small orb, under 40px, holds its colour still, only blinks and watches, skips any frame that would draw what its canvas already shows, and draws at most about 30 frames a second, so a sidebar at rest draws only blinks. With reduced motion an orb is one still picture.

The cost is real and worth naming: every Bot's colour changes on every deployment, and somebody who knew a Bot by its gradient learns it again. Larger text also means a little less fits on a dense screen.

Before After
The Bots page before The Bots page after

What is not covered

  • No setting in the app stills the orbs; only the system's reduced-motion setting does.
  • A tenant theme may still set only its approved variables, so it cannot set --brand or the new status colours, and nothing reads the status, glass and shadow tokens yet.
  • A Bot's colour comes from its seed alone and can't be chosen here; Let a Bot's owner choose its colour and draw it a new face #914 adds that.
  • pointercancel is not listened for, so when the browser takes a touch over to scroll, the eyes stay on the last point.
  • A frame callback that throws stops the shared loop, and every orb holds still until another one starts drawing.
  • Onboarding's example Bots are announced twice, by the avatar and the heading beside it.

Verification

typecheck, lint, format:check and the assertion-shape guard are clean. The full app suite is 2190 pass, 0 fail, against 2129 pass, 0 fail on upstream main at ab9855c1.

  • app/tests/bot-orb.test.tsx: no Bot is violet; the Bots OpenBot ships with are drawn unlike each other, two in one colour included; fifty orbs share one frame; a small orb at rest draws nothing until the pointer moves, and about thirty frames a second on a 120Hz screen; nothing draws off screen or in a hidden tab; AgentOrb maps every state and refuses a size that isn't px.
  • app/tests/visual-system.test.ts: every light variable has a dark value; text roles clear 4.5:1 on every ground, and each status word on its soft fill, in both themes; the root font size is the browser's.
  • app/tests/settings-appearance.test.tsx: Appearance offers System, Light and Dark, and Dark is drawn and remembered.
  • app/tests/theme-preference.test.ts, extended: the pre-paint script and the provider follow the system live; only a choice is written; storage that throws still follows the system.
  • app/tests/skins-openmuse.test.tsx, extended: that skin's header draws its face at its slot's size.

Merge notes

orb/ai-core.tsx is gone and AIState is exported from agent-orb. AgentOrb takes its size only in px, as "64px", and throws on anything else, because a canvas drawn at a share of its container has nothing to be a share of. A branch in flight that imports ai-core or passes size="100%" has to follow.

The last two places still drawing boring-avatars' gradient circles now draw the
orb in the colour the Bot's id picks, so a Bot looks the same in the sidebar,
the conversation header and its profile. Nothing imports boring-avatars any
more, so it leaves the app's dependencies.
…ll when asked

A Bot's orb is a block, so a group conversation's 12px faces no longer sit on
the header's text baseline and drop half out of their circles. It resizes its
canvas when the screen's pixel density changes, so dragging a window to another
screen or zooming no longer leaves it soft, and it follows the reduced-motion
setting live rather than only when it first draws. The changelog and the
stylesheet now describe what this step draws, not what later steps will.
The skins import the orb's state type from agent-orb now that ai-core is gone,
OpenBot's mark keeps its agent-orb class for them, and their tests check the
orb's colour rather than boring-avatars' SVG. The Appearance test's preferences
carry the new skin field.
AgentOrb read "100%" as 100px, so with no Bot yet the header drew a 100px orb
in a 44 or 52px slot. With a Bot, the classes that used to rescale
boring-avatars' SVG squeezed the avatar's circle to 44px below 900px wide while
the orb inside stayed 52px, clipped and off centre. The frame now picks 44 or
52px from the window's width and draws the face at that size, the selectors
that matched only the old SVG are gone here and from OpenDots' Bot rows, and
AgentOrb takes its size only in px and refuses anything else.
Every orb on screen redrew on every animation frame, uncapped, so a sidebar of
conversations kept a few dozen canvases repainting at the screen's full rate
while the app sat idle: a small orb's colour still drifted, so no two frames
were ever the same. A small orb's colour now holds still, an orb skips any
frame that would draw what its canvas already shows in the same theme and at
the same density, and a small orb draws at most about thirty frames a second.
A face at rest with the pointer still now draws only to blink.
…violet

A Bot's face was one of nine colours and nothing else, so a team of four had
about an even chance of two identical faces. In the Bots OpenBot ships with,
three were the same sky blue, and release notes wore violet, OpenBot's own
mark. A Bot is now drawn from its seed: one of the eight colours that aren't
violet, with its colour masses turned, its hue nudged and its eyes spaced by
the same hash, so two Bots in one colour still look apart. BotOrb takes the
seed rather than a colour and draws OpenBot's violet when it has none. The
OpenDots avatar test checked one colour in nine, so it passed or failed on the
hash; it now checks that the seed reaches the orb.

This branch has not been deployed

No deployments
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