From c839554fc1c775f36bcfb98ea5986894a2ac3e9b Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Thu, 29 Oct 2026 16:35:44 -0700 Subject: [PATCH 01/36] docs(app): Impeccable product record for OpenBot Who OpenBot is for, what it must keep, and the brand inputs for the design work. --- app/PRODUCT.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 app/PRODUCT.md diff --git a/app/PRODUCT.md b/app/PRODUCT.md new file mode 100644 index 000000000..fe5c7cd44 --- /dev/null +++ b/app/PRODUCT.md @@ -0,0 +1,59 @@ +# Product + + + +## Platform + +web + +## Users + +Two audiences use the same app. + +- **Company employee (daily end user).** Usually not technical. Asks Bots to do jobs: read an expense claim, turn a meeting note into follow-ups, research a topic. Expects the ease of ChatGPT, Claude or Grok. +- **IT owner (buyer and administrator).** Chooses and deploys OpenBot. Adds Bots, grants tools and connections, answers approvals, and reads the audit log. Needs trust and control. + +## Product Purpose + +OpenBot is an AI assistant that a company runs on its own infrastructure and can change completely. Any agent that speaks AG-UI arrives as a coworker. Success: employees use it as their everyday assistant, and the IT owner is confident about what each Bot can do. + +## Positioning + +Bots are coworkers, and you own them. Each Bot has a computer of its own (a real browser with its own logins, files and only the tools you grant). It runs on your infrastructure, with your PostgreSQL and your model choice. A hosted chatbot cannot truthfully say either. + +## Operating Context + +- The web app in `app/` is the main surface. A Tauri desktop app and a mobile client use the same frame. +- Main areas: chat with a chosen Bot, the Bot's own computer and browser, Bots, skills, routines, connections, memory, approvals, audit, admin and settings. +- OpenBot is a template. Companies clone the repository and rename it through `brand.yaml`. +- A person can pick a skin per account: the default `openbot` skin, OpenDuet, OpenDots or OpenMuse. + +## Capabilities and Constraints + +- Light and dark mode must both ship finished. +- The skin system stays. This redesign replaces the default `openbot` skin. The other skins stay as alternates. +- Desktop and mobile must both work at native quality. +- Dense admin screens (audit log, approvals, tables, settings) must stay easy to scan. +- Stack: React 19, Vite, Tailwind 4, shadcn and Base UI components, Tabler icons, Motion. + +## Brand Commitments + +- The new look is opinionated. When a company renames OpenBot, it swaps the name and logo. The visual world stays. +- The design must move away from the generic shadcn look (gray neutrals, Inter, default components). +- The user named Grok and Muse as references for simplicity, polish and elegance. The result must be unique, not a copy of either. +- Binding brand input (pinned 2026-10-28): take the pixelation and color schemes of copilotkit.ai. Pixelation means square-pixel dither fields. The colors are periwinkle `#7480F1` to mint `#23D5A2`, sky `#8DC1F1` to violet `#686FDF`, lilac `#BEC2FF`, pale lavender `#EDEDF5` and deep plum `#35295F`. The rest of that website is not a reference. +- Direction rounds 1 and 2 were rejected as generic. The user likes bold directions, except extreme ones such as a hand-drawn newsprint catalog. + +## Evidence on Hand + +- Real product copy in `README.md` and `docs/`. +- Real Bots: General Assistant, Knowledge, Risk Analyst, and ten catalog Bots in `examples/fintech/catalog/`. +- No customer logos, testimonials or usage numbers. Do not invent them. + +## Product Principles + +1. A Bot is a colleague, not a chat widget. It has a name, a computer and a record of its work. +2. Ownership feels calm and solid. Nothing in the interface suggests a hosted service. +3. The conversation leads. Chrome that does not help the current job stays out of the way. +4. Control is visible but quiet. Approvals and audit are part of the product, not an alarm system. +5. Every detail is finished. Rough spacing, type and motion break trust. From 7078dd58aaaa1e6a8c569806594b30944e8258d0 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Thu, 29 Oct 2026 16:35:44 -0700 Subject: [PATCH 02/36] feat(app): design tokens, Atkinson type and dark palette for the default skin New tokens live in app/src/openbot.css, scoped to :root:not([data-skin]), so OpenDuet, OpenDots and OpenMuse keep their look. theme.css is unchanged because the extension's pages import it. Inter stays for OpenDuet. --- app/package.json | 2 + app/src/openbot.css | 134 ++++++++++++++++++++++++++++++++ app/src/styles.css | 4 + app/tests/design-tokens.test.ts | 92 ++++++++++++++++++++++ bun.lock | 6 ++ 5 files changed, 238 insertions(+) create mode 100644 app/src/openbot.css create mode 100644 app/tests/design-tokens.test.ts diff --git a/app/package.json b/app/package.json index b5be1f7ac..3c5d42e0d 100644 --- a/app/package.json +++ b/app/package.json @@ -18,6 +18,8 @@ "@base-ui/react": "^1.6.0", "@better-auth/sso": "^1.7.1", "@copilotkit/react-core": "1.83.0", + "@fontsource-variable/atkinson-hyperlegible-mono": "^5.3.0", + "@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", diff --git a/app/src/openbot.css b/app/src/openbot.css new file mode 100644 index 000000000..99ed6b808 --- /dev/null +++ b/app/src/openbot.css @@ -0,0 +1,134 @@ +/* + * OpenBot's own look: the default skin only. Every rule is scoped to `:root:not([data-skin])`, so OpenDuet, + * OpenDots and OpenMuse keep theirs. It lives apart from theme.css because the extension's pages import + * theme.css and must keep today's palette. + */ +/* + * OpenBot's own look, drawn only while no skin is active. The `:root` and `.dark` above stay as the + * base the skins inherit; everything here sits under `:root:not([data-skin])`, so OpenDuet, OpenDots + * and OpenMuse never see it. That selector outranks `.dark`, so every token set for light is set + * again for dark below, or dark mode would show the light value. `design-tokens.test.ts` holds the + * two blocks to each other. + * + * White surfaces with cool hairlines in light, a plum ground in dark. Orange is kept out of these + * tokens on purpose: it means "waits for you" and nothing else. + */ +:root:not([data-skin]) { + --background: #ffffff; + --foreground: #010507; + --card: #ffffff; + --card-foreground: #010507; + --popover: #ffffff; + --popover-foreground: #010507; + --primary: #010507; + --primary-foreground: #ffffff; + --secondary: #f0f0f4; + --secondary-foreground: #010507; + --muted: #f0f0f4; + --muted-foreground: #64646c; + --accent: #f0f0f4; + --accent-foreground: #010507; + --destructive: #b42530; + --success: #12795b; + --border: #dbdbe5; + --input: #dbdbe5; + --ring: #7480f1; + --radius: 0.625rem; + --sidebar: #ffffff; + --sidebar-foreground: #010507; + --sidebar-primary: #010507; + --sidebar-primary-foreground: #ffffff; + /* The selected row: a lilac tint rather than a gray, as the rest of the frame is white. */ + --sidebar-accent: rgb(190 194 255 / 20%); + --sidebar-accent-foreground: #010507; + --sidebar-border: #dbdbe5; + --sidebar-ring: #7480f1; + + /* A firmer edge for rows, cards and tiles that sit on the white ground. */ + --line-strong: #c4c6d8; + /* The faint fill behind an empty state. */ + --wash: #f4f4fa; + --card-shadow: 0 1px 2px rgb(1 5 7 / 5%), 0 6px 16px -10px rgb(1 5 7 / 16%); + --card-shadow-hover: + 0 2px 4px rgb(1 5 7 / 6%), 0 14px 28px -14px rgb(1 5 7 / 24%); + /* The hairline and drop that lift a Bot's orb off the surface. */ + --orb-edge: 0 0 0 1px rgb(1 5 7 / 7%), 0 1px 3px -1px rgb(1 5 7 / 20%); + /* + * CopilotKit's square-dot matrix: one 108px tile of 5px squares on a 9px pitch, each at its own + * opacity, repeated. A tile is drawn per theme, in lilac. It appears only behind a conversation's + * large orb and in empty states, at `--accent-dot-opacity`. + */ + --dot-tile: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='108' height='108' fill='%239ea4f5' shape-rendering='crispEdges'%3E%3Cpath fill-opacity='.26' d='M11 2h5v5h-5z'/%3E%3Cpath fill-opacity='.66' d='M20 2h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M29 2h5v5h-5z'/%3E%3Cpath fill-opacity='.69' d='M38 2h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M47 2h5v5h-5z'/%3E%3Cpath fill-opacity='.76' d='M56 2h5v5h-5z'/%3E%3Cpath fill-opacity='.32' d='M65 2h5v5h-5z'/%3E%3Cpath fill-opacity='.62' d='M74 2h5v5h-5z'/%3E%3Cpath fill-opacity='.40' d='M92 2h5v5h-5z'/%3E%3Cpath fill-opacity='.75' d='M101 2h5v5h-5z'/%3E%3Cpath fill-opacity='.44' d='M2 11h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M11 11h5v5h-5z'/%3E%3Cpath fill-opacity='.50' d='M20 11h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M29 11h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M38 11h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M47 11h5v5h-5z'/%3E%3Cpath fill-opacity='.26' d='M56 11h5v5h-5z'/%3E%3Cpath fill-opacity='.26' d='M65 11h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M74 11h5v5h-5z'/%3E%3Cpath fill-opacity='.84' d='M83 11h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M101 11h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M2 20h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M11 20h5v5h-5z'/%3E%3Cpath fill-opacity='.30' d='M29 20h5v5h-5z'/%3E%3Cpath fill-opacity='.90' d='M38 20h5v5h-5z'/%3E%3Cpath fill-opacity='.19' d='M47 20h5v5h-5z'/%3E%3Cpath fill-opacity='.25' d='M56 20h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M65 20h5v5h-5z'/%3E%3Cpath fill-opacity='.45' d='M74 20h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M83 20h5v5h-5z'/%3E%3Cpath fill-opacity='.39' d='M92 20h5v5h-5z'/%3E%3Cpath fill-opacity='.56' d='M101 20h5v5h-5z'/%3E%3Cpath fill-opacity='.44' d='M2 29h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M11 29h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M20 29h5v5h-5z'/%3E%3Cpath fill-opacity='.23' d='M29 29h5v5h-5z'/%3E%3Cpath fill-opacity='.30' d='M38 29h5v5h-5z'/%3E%3Cpath fill-opacity='.33' d='M47 29h5v5h-5z'/%3E%3Cpath fill-opacity='.84' d='M56 29h5v5h-5z'/%3E%3Cpath fill-opacity='.77' d='M65 29h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M83 29h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M101 29h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M2 38h5v5h-5z'/%3E%3Cpath fill-opacity='.51' d='M11 38h5v5h-5z'/%3E%3Cpath fill-opacity='.90' d='M20 38h5v5h-5z'/%3E%3Cpath fill-opacity='.85' d='M29 38h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M38 38h5v5h-5z'/%3E%3Cpath fill-opacity='.25' d='M47 38h5v5h-5z'/%3E%3Cpath fill-opacity='.15' d='M65 38h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M83 38h5v5h-5z'/%3E%3Cpath fill-opacity='.41' d='M92 38h5v5h-5z'/%3E%3Cpath fill-opacity='.23' d='M101 38h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M2 47h5v5h-5z'/%3E%3Cpath fill-opacity='.86' d='M11 47h5v5h-5z'/%3E%3Cpath fill-opacity='.41' d='M20 47h5v5h-5z'/%3E%3Cpath fill-opacity='.56' d='M38 47h5v5h-5z'/%3E%3Cpath fill-opacity='.14' d='M47 47h5v5h-5z'/%3E%3Cpath fill-opacity='.20' d='M56 47h5v5h-5z'/%3E%3Cpath fill-opacity='.49' d='M65 47h5v5h-5z'/%3E%3Cpath fill-opacity='.24' d='M74 47h5v5h-5z'/%3E%3Cpath fill-opacity='.21' d='M83 47h5v5h-5z'/%3E%3Cpath fill-opacity='.15' d='M92 47h5v5h-5z'/%3E%3Cpath fill-opacity='.63' d='M101 47h5v5h-5z'/%3E%3Cpath fill-opacity='.42' d='M2 56h5v5h-5z'/%3E%3Cpath fill-opacity='.89' d='M20 56h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M29 56h5v5h-5z'/%3E%3Cpath fill-opacity='.14' d='M38 56h5v5h-5z'/%3E%3Cpath fill-opacity='.82' d='M47 56h5v5h-5z'/%3E%3Cpath fill-opacity='.94' d='M56 56h5v5h-5z'/%3E%3Cpath fill-opacity='.35' d='M65 56h5v5h-5z'/%3E%3Cpath fill-opacity='.66' d='M74 56h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M83 56h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M92 56h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M101 56h5v5h-5z'/%3E%3Cpath fill-opacity='.54' d='M2 65h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M11 65h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M20 65h5v5h-5z'/%3E%3Cpath fill-opacity='.27' d='M29 65h5v5h-5z'/%3E%3Cpath fill-opacity='.99' d='M38 65h5v5h-5z'/%3E%3Cpath fill-opacity='.83' d='M47 65h5v5h-5z'/%3E%3Cpath fill-opacity='.21' d='M56 65h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M65 65h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M74 65h5v5h-5z'/%3E%3Cpath fill-opacity='.93' d='M83 65h5v5h-5z'/%3E%3Cpath fill-opacity='.20' d='M92 65h5v5h-5z'/%3E%3Cpath fill-opacity='.19' d='M101 65h5v5h-5z'/%3E%3Cpath fill-opacity='.44' d='M2 74h5v5h-5z'/%3E%3Cpath fill-opacity='.43' d='M11 74h5v5h-5z'/%3E%3Cpath fill-opacity='.31' d='M20 74h5v5h-5z'/%3E%3Cpath fill-opacity='.14' d='M29 74h5v5h-5z'/%3E%3Cpath fill-opacity='.46' d='M38 74h5v5h-5z'/%3E%3Cpath fill-opacity='.65' d='M47 74h5v5h-5z'/%3E%3Cpath fill-opacity='.23' d='M56 74h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M65 74h5v5h-5z'/%3E%3Cpath fill-opacity='.77' d='M83 74h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M92 74h5v5h-5z'/%3E%3Cpath fill-opacity='.94' d='M101 74h5v5h-5z'/%3E%3Cpath fill-opacity='.52' d='M11 83h5v5h-5z'/%3E%3Cpath fill-opacity='.86' d='M20 83h5v5h-5z'/%3E%3Cpath fill-opacity='.16' d='M38 83h5v5h-5z'/%3E%3Cpath fill-opacity='.16' d='M47 83h5v5h-5z'/%3E%3Cpath fill-opacity='.93' d='M65 83h5v5h-5z'/%3E%3Cpath fill-opacity='.17' d='M74 83h5v5h-5z'/%3E%3Cpath fill-opacity='.60' d='M83 83h5v5h-5z'/%3E%3Cpath fill-opacity='.39' d='M92 83h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M101 83h5v5h-5z'/%3E%3Cpath fill-opacity='.71' d='M2 92h5v5h-5z'/%3E%3Cpath fill-opacity='.35' d='M11 92h5v5h-5z'/%3E%3Cpath fill-opacity='.75' d='M29 92h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M38 92h5v5h-5z'/%3E%3Cpath fill-opacity='.43' d='M47 92h5v5h-5z'/%3E%3Cpath fill-opacity='.21' d='M56 92h5v5h-5z'/%3E%3Cpath fill-opacity='.19' d='M65 92h5v5h-5z'/%3E%3Cpath fill-opacity='.57' d='M83 92h5v5h-5z'/%3E%3Cpath fill-opacity='.41' d='M92 92h5v5h-5z'/%3E%3Cpath fill-opacity='.15' d='M101 92h5v5h-5z'/%3E%3Cpath fill-opacity='.28' d='M2 101h5v5h-5z'/%3E%3Cpath fill-opacity='.16' d='M29 101h5v5h-5z'/%3E%3Cpath fill-opacity='.90' d='M38 101h5v5h-5z'/%3E%3Cpath fill-opacity='.70' d='M47 101h5v5h-5z'/%3E%3Cpath fill-opacity='.81' d='M56 101h5v5h-5z'/%3E%3Cpath fill-opacity='.74' d='M65 101h5v5h-5z'/%3E%3Cpath fill-opacity='.49' d='M74 101h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M83 101h5v5h-5z'/%3E%3Cpath fill-opacity='.42' d='M92 101h5v5h-5z'/%3E%3Cpath fill-opacity='.65' d='M101 101h5v5h-5z'/%3E%3C/svg%3E"); + --accent-dot-opacity: 0.1; + --font-sans: + "Atkinson Hyperlegible Next Variable", ui-sans-serif, system-ui, sans-serif; + /* Only for URLs, times, codes and file names. */ + --font-mono: "Atkinson Hyperlegible Mono Variable", ui-monospace, monospace; +} + +:root:not([data-skin]).dark { + --background: #242032; + --foreground: #f0f0f4; + --card: #242032; + --card-foreground: #f0f0f4; + --popover: #2b2645; + --popover-foreground: #f0f0f4; + --primary: #f0f0f4; + --primary-foreground: #010507; + --secondary: #1f1c2c; + --secondary-foreground: #f0f0f4; + --muted: #1f1c2c; + --muted-foreground: #9a9aa6; + --accent: #2b2645; + --accent-foreground: #f0f0f4; + --destructive: #ff9aa0; + --success: #5fe3bb; + --border: #3a3452; + --input: #3a3452; + --ring: #8d97ff; + --radius: 0.625rem; + --sidebar: #242032; + --sidebar-foreground: #f0f0f4; + --sidebar-primary: #f0f0f4; + --sidebar-primary-foreground: #010507; + --sidebar-accent: rgb(190 194 255 / 12%); + --sidebar-accent-foreground: #f0f0f4; + --sidebar-border: #3a3452; + --sidebar-ring: #8d97ff; + + --line-strong: #4d4670; + --wash: #211d30; + --card-shadow: 0 1px 2px rgb(0 0 0 / 35%), 0 8px 18px -12px rgb(0 0 0 / 60%); + --card-shadow-hover: + 0 2px 6px rgb(0 0 0 / 40%), 0 16px 30px -14px rgb(0 0 0 / 70%); + --orb-edge: 0 0 0 1px rgb(255 255 255 / 8%); + --dot-tile: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='108' height='108' fill='%23bec2ff' shape-rendering='crispEdges'%3E%3Cpath fill-opacity='.26' d='M11 2h5v5h-5z'/%3E%3Cpath fill-opacity='.66' d='M20 2h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M29 2h5v5h-5z'/%3E%3Cpath fill-opacity='.69' d='M38 2h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M47 2h5v5h-5z'/%3E%3Cpath fill-opacity='.76' d='M56 2h5v5h-5z'/%3E%3Cpath fill-opacity='.32' d='M65 2h5v5h-5z'/%3E%3Cpath fill-opacity='.62' d='M74 2h5v5h-5z'/%3E%3Cpath fill-opacity='.40' d='M92 2h5v5h-5z'/%3E%3Cpath fill-opacity='.75' d='M101 2h5v5h-5z'/%3E%3Cpath fill-opacity='.44' d='M2 11h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M11 11h5v5h-5z'/%3E%3Cpath fill-opacity='.50' d='M20 11h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M29 11h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M38 11h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M47 11h5v5h-5z'/%3E%3Cpath fill-opacity='.26' d='M56 11h5v5h-5z'/%3E%3Cpath fill-opacity='.26' d='M65 11h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M74 11h5v5h-5z'/%3E%3Cpath fill-opacity='.84' d='M83 11h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M101 11h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M2 20h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M11 20h5v5h-5z'/%3E%3Cpath fill-opacity='.30' d='M29 20h5v5h-5z'/%3E%3Cpath fill-opacity='.90' d='M38 20h5v5h-5z'/%3E%3Cpath fill-opacity='.19' d='M47 20h5v5h-5z'/%3E%3Cpath fill-opacity='.25' d='M56 20h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M65 20h5v5h-5z'/%3E%3Cpath fill-opacity='.45' d='M74 20h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M83 20h5v5h-5z'/%3E%3Cpath fill-opacity='.39' d='M92 20h5v5h-5z'/%3E%3Cpath fill-opacity='.56' d='M101 20h5v5h-5z'/%3E%3Cpath fill-opacity='.44' d='M2 29h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M11 29h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M20 29h5v5h-5z'/%3E%3Cpath fill-opacity='.23' d='M29 29h5v5h-5z'/%3E%3Cpath fill-opacity='.30' d='M38 29h5v5h-5z'/%3E%3Cpath fill-opacity='.33' d='M47 29h5v5h-5z'/%3E%3Cpath fill-opacity='.84' d='M56 29h5v5h-5z'/%3E%3Cpath fill-opacity='.77' d='M65 29h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M83 29h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M101 29h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M2 38h5v5h-5z'/%3E%3Cpath fill-opacity='.51' d='M11 38h5v5h-5z'/%3E%3Cpath fill-opacity='.90' d='M20 38h5v5h-5z'/%3E%3Cpath fill-opacity='.85' d='M29 38h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M38 38h5v5h-5z'/%3E%3Cpath fill-opacity='.25' d='M47 38h5v5h-5z'/%3E%3Cpath fill-opacity='.15' d='M65 38h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M83 38h5v5h-5z'/%3E%3Cpath fill-opacity='.41' d='M92 38h5v5h-5z'/%3E%3Cpath fill-opacity='.23' d='M101 38h5v5h-5z'/%3E%3Cpath fill-opacity='.22' d='M2 47h5v5h-5z'/%3E%3Cpath fill-opacity='.86' d='M11 47h5v5h-5z'/%3E%3Cpath fill-opacity='.41' d='M20 47h5v5h-5z'/%3E%3Cpath fill-opacity='.56' d='M38 47h5v5h-5z'/%3E%3Cpath fill-opacity='.14' d='M47 47h5v5h-5z'/%3E%3Cpath fill-opacity='.20' d='M56 47h5v5h-5z'/%3E%3Cpath fill-opacity='.49' d='M65 47h5v5h-5z'/%3E%3Cpath fill-opacity='.24' d='M74 47h5v5h-5z'/%3E%3Cpath fill-opacity='.21' d='M83 47h5v5h-5z'/%3E%3Cpath fill-opacity='.15' d='M92 47h5v5h-5z'/%3E%3Cpath fill-opacity='.63' d='M101 47h5v5h-5z'/%3E%3Cpath fill-opacity='.42' d='M2 56h5v5h-5z'/%3E%3Cpath fill-opacity='.89' d='M20 56h5v5h-5z'/%3E%3Cpath fill-opacity='.61' d='M29 56h5v5h-5z'/%3E%3Cpath fill-opacity='.14' d='M38 56h5v5h-5z'/%3E%3Cpath fill-opacity='.82' d='M47 56h5v5h-5z'/%3E%3Cpath fill-opacity='.94' d='M56 56h5v5h-5z'/%3E%3Cpath fill-opacity='.35' d='M65 56h5v5h-5z'/%3E%3Cpath fill-opacity='.66' d='M74 56h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M83 56h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M92 56h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M101 56h5v5h-5z'/%3E%3Cpath fill-opacity='.54' d='M2 65h5v5h-5z'/%3E%3Cpath fill-opacity='.29' d='M11 65h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M20 65h5v5h-5z'/%3E%3Cpath fill-opacity='.27' d='M29 65h5v5h-5z'/%3E%3Cpath fill-opacity='.99' d='M38 65h5v5h-5z'/%3E%3Cpath fill-opacity='.83' d='M47 65h5v5h-5z'/%3E%3Cpath fill-opacity='.21' d='M56 65h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M65 65h5v5h-5z'/%3E%3Cpath fill-opacity='.48' d='M74 65h5v5h-5z'/%3E%3Cpath fill-opacity='.93' d='M83 65h5v5h-5z'/%3E%3Cpath fill-opacity='.20' d='M92 65h5v5h-5z'/%3E%3Cpath fill-opacity='.19' d='M101 65h5v5h-5z'/%3E%3Cpath fill-opacity='.44' d='M2 74h5v5h-5z'/%3E%3Cpath fill-opacity='.43' d='M11 74h5v5h-5z'/%3E%3Cpath fill-opacity='.31' d='M20 74h5v5h-5z'/%3E%3Cpath fill-opacity='.14' d='M29 74h5v5h-5z'/%3E%3Cpath fill-opacity='.46' d='M38 74h5v5h-5z'/%3E%3Cpath fill-opacity='.65' d='M47 74h5v5h-5z'/%3E%3Cpath fill-opacity='.23' d='M56 74h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M65 74h5v5h-5z'/%3E%3Cpath fill-opacity='.77' d='M83 74h5v5h-5z'/%3E%3Cpath fill-opacity='.88' d='M92 74h5v5h-5z'/%3E%3Cpath fill-opacity='.94' d='M101 74h5v5h-5z'/%3E%3Cpath fill-opacity='.52' d='M11 83h5v5h-5z'/%3E%3Cpath fill-opacity='.86' d='M20 83h5v5h-5z'/%3E%3Cpath fill-opacity='.16' d='M38 83h5v5h-5z'/%3E%3Cpath fill-opacity='.16' d='M47 83h5v5h-5z'/%3E%3Cpath fill-opacity='.93' d='M65 83h5v5h-5z'/%3E%3Cpath fill-opacity='.17' d='M74 83h5v5h-5z'/%3E%3Cpath fill-opacity='.60' d='M83 83h5v5h-5z'/%3E%3Cpath fill-opacity='.39' d='M92 83h5v5h-5z'/%3E%3Cpath fill-opacity='.36' d='M101 83h5v5h-5z'/%3E%3Cpath fill-opacity='.71' d='M2 92h5v5h-5z'/%3E%3Cpath fill-opacity='.35' d='M11 92h5v5h-5z'/%3E%3Cpath fill-opacity='.75' d='M29 92h5v5h-5z'/%3E%3Cpath fill-opacity='.18' d='M38 92h5v5h-5z'/%3E%3Cpath fill-opacity='.43' d='M47 92h5v5h-5z'/%3E%3Cpath fill-opacity='.21' d='M56 92h5v5h-5z'/%3E%3Cpath fill-opacity='.19' d='M65 92h5v5h-5z'/%3E%3Cpath fill-opacity='.57' d='M83 92h5v5h-5z'/%3E%3Cpath fill-opacity='.41' d='M92 92h5v5h-5z'/%3E%3Cpath fill-opacity='.15' d='M101 92h5v5h-5z'/%3E%3Cpath fill-opacity='.28' d='M2 101h5v5h-5z'/%3E%3Cpath fill-opacity='.16' d='M29 101h5v5h-5z'/%3E%3Cpath fill-opacity='.90' d='M38 101h5v5h-5z'/%3E%3Cpath fill-opacity='.70' d='M47 101h5v5h-5z'/%3E%3Cpath fill-opacity='.81' d='M56 101h5v5h-5z'/%3E%3Cpath fill-opacity='.74' d='M65 101h5v5h-5z'/%3E%3Cpath fill-opacity='.49' d='M74 101h5v5h-5z'/%3E%3Cpath fill-opacity='.34' d='M83 101h5v5h-5z'/%3E%3Cpath fill-opacity='.42' d='M92 101h5v5h-5z'/%3E%3Cpath fill-opacity='.65' d='M101 101h5v5h-5z'/%3E%3C/svg%3E"); + --accent-dot-opacity: 0.05; + --font-sans: + "Atkinson Hyperlegible Next Variable", ui-sans-serif, system-ui, sans-serif; + --font-mono: "Atkinson Hyperlegible Mono Variable", ui-monospace, monospace; +} + +/* + * The default skin's face. `body` above names Inter outright, which the skins still inherit, so + * OpenBot's own look reads the token instead. + */ +:root:not([data-skin]) body { + font-family: var(--font-sans); +} + +/* + * A Bot's live orb, toned for white surfaces. AgentOrb's own contrast boost and full-strength + * halftone wash light colors out to white; these keep each Bot's palette true. Only `BotOrb` + * carries `.bot-orb-live`, so the orb anywhere else is unchanged. + */ +:root:not([data-skin]) .bot-orb-live .agent-orb::before { + filter: blur(var(--blur-amount)) contrast(1.15) saturate(1.3); +} +:root:not([data-skin]) .bot-orb-live .agent-orb::after { + opacity: 0.25; + backdrop-filter: blur(calc(var(--blur-amount) * 1.5)) contrast(1.25); +} +:root:not([data-skin]).dark .bot-orb-live .agent-orb::before { + filter: blur(var(--blur-amount)) contrast(1) saturate(0.95) brightness(1.18); +} diff --git a/app/src/styles.css b/app/src/styles.css index 46f20924b..3532103a0 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -3,8 +3,12 @@ @import "shadcn/tailwind.css"; /* Composer chips/dropdown. Reads the shadcn tokens declared below, so it themes itself. */ @import "prompt-area/tailwind.css"; +/* Inter is OpenDuet's face; Atkinson Hyperlegible is OpenBot's own (openbot.css). */ @import "@fontsource-variable/inter"; +@import "@fontsource-variable/atkinson-hyperlegible-next"; +@import "@fontsource-variable/atkinson-hyperlegible-mono"; @import "./theme.css"; +@import "./openbot.css"; @import "./skins/skins.css"; /* diff --git a/app/tests/design-tokens.test.ts b/app/tests/design-tokens.test.ts new file mode 100644 index 000000000..818d47af9 --- /dev/null +++ b/app/tests/design-tokens.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +/* + * OpenBot's own look is drawn only while no skin is active, so every token it adds or changes sits + * under `:root:not([data-skin])`. That selector outranks the plain `.dark`, so each one must be set + * again under `:root:not([data-skin]).dark`, or dark mode would show the light value. The skins + * keep the base `:root` and `.dark` they always inherited; `skins.test.ts` pins their side. + */ + +const read = (path: string) => + readFileSync(new URL(path, import.meta.url), "utf8"); + +const LIGHT = ":root:not([data-skin])"; +const DARK = ":root:not([data-skin]).dark"; + +/** Every custom property declared in a block whose selector list includes `selector`. */ +function declared(css: string, selector: string) { + const text = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const tokens = new Map(); + for (const block of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + const heads = block[1].split(",").map((head) => head.trim()); + if (!heads.includes(selector)) continue; + for (const token of block[2].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) { + tokens.set(token[1], token[2].trim()); + } + } + return tokens; +} + +const ADDED = [ + "--line-strong", + "--wash", + "--card-shadow", + "--card-shadow-hover", + "--orb-edge", + "--dot-tile", + "--accent-dot-opacity", + "--font-sans", + "--font-mono", +]; + +describe("default skin tokens", () => { + const theme = read("../src/openbot.css"); + const light = declared(theme, LIGHT); + const dark = declared(theme, DARK); + + test.each(ADDED)("%s is set for light", (token) => { + expect(light.has(token)).toBe(true); + }); + + test.each(ADDED)("%s is set for dark", (token) => { + expect(dark.has(token)).toBe(true); + }); + + test("every light token is set for dark too", () => { + expect([...light.keys()].filter((token) => !dark.has(token))).toEqual([]); + }); + + test("the type is Atkinson Hyperlegible", () => { + expect(light.get("--font-sans")).toContain("Atkinson Hyperlegible Next"); + expect(light.get("--font-mono")).toContain("Atkinson Hyperlegible Mono"); + }); +}); + +describe("fonts", () => { + const styles = read("../src/styles.css"); + + test("both Atkinson faces load", () => { + expect(styles).toContain( + '@import "@fontsource-variable/atkinson-hyperlegible-next"', + ); + expect(styles).toContain( + '@import "@fontsource-variable/atkinson-hyperlegible-mono"', + ); + }); + + // OpenDuet is set in Inter. + test("Inter still loads", () => { + expect(styles).toContain('@import "@fontsource-variable/inter"'); + }); +}); + +describe("the extension keeps today's palette", () => { + test("theme.css, which the extension's pages import, has no default-skin blocks", () => { + expect(read("../src/theme.css")).not.toContain(":root:not([data-skin])"); + }); + + test("only the app's own stylesheet brings in OpenBot's look", () => { + expect(read("../src/styles.css")).toContain('@import "./openbot.css";'); + }); +}); diff --git a/bun.lock b/bun.lock index 64588375b..11d5d63cc 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,8 @@ "@base-ui/react": "^1.6.0", "@better-auth/sso": "^1.7.1", "@copilotkit/react-core": "1.83.0", + "@fontsource-variable/atkinson-hyperlegible-mono": "^5.3.0", + "@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", @@ -496,6 +498,10 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@fontsource-variable/atkinson-hyperlegible-mono": ["@fontsource-variable/atkinson-hyperlegible-mono@5.3.0", "", {}, "sha512-0IbthhO18EEpLe69h+cmrsxCkl4yk80gMk+fAN3+9M5zQvoR1JlkzoJRR6U9/UXLYpVBGEXu+Md2LO8TIp30dw=="], + + "@fontsource-variable/atkinson-hyperlegible-next": ["@fontsource-variable/atkinson-hyperlegible-next@5.3.0", "", {}, "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], "@graphql-tools/executor": ["@graphql-tools/executor@2.0.0", "", { "dependencies": { "@graphql-tools/utils": "^12.0.0", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.1.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-BjoqO5UfcV3BUqamGtHNm4ITBk040VOmWql/MrUefPo30x6t4v1+engfjgAjWikMsRPLU4ZC6e2QjICLucnOTA=="], From ff036a47a4e7c255ec580e179d850670d58aa8db Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Thu, 29 Oct 2026 16:35:44 -0700 Subject: [PATCH 03/36] feat(app): derive a Bot's orb palette from its seed Eight palettes from the CopilotKit color family, picked by hashing the Bot's avatarSeed, so a rename never changes a Bot's color and no data is added. --- app/src/lib/orb-palette.ts | 49 +++++++++++++++++++++++++++++++++++ app/tests/orb-palette.test.ts | 39 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 app/src/lib/orb-palette.ts create mode 100644 app/tests/orb-palette.test.ts diff --git a/app/src/lib/orb-palette.ts b/app/src/lib/orb-palette.ts new file mode 100644 index 000000000..bacf1aa48 --- /dev/null +++ b/app/src/lib/orb-palette.ts @@ -0,0 +1,49 @@ +/** The colors `AgentOrb` draws with: a ground and four mesh stops. */ +export type OrbColors = { + bg: string; + c1: string; + c2: string; + c3: string; + c4: string; +}; + +/* + * The orb's ground is the page's own background, so the dot pattern laid over the mesh is the + * surface's color in light and in dark. + */ +const BG = "var(--background)"; + +/** + * Eight palettes, all inside the CopilotKit family: mint, violet, periwinkle, sky, orange, red, + * lilac and teal. The order is part of the contract: a Bot's color is its index here, so + * reordering or inserting would recolor every Bot. + */ +export const ORB_PALETTES: readonly OrbColors[] = [ + { bg: BG, c1: "#23d5a2", c2: "#1fbf92", c3: "#7480f1", c4: "#85ecce" }, + { bg: BG, c1: "#8dc1f1", c2: "#686fdf", c3: "#4b4fb8", c4: "#a9aef8" }, + { bg: BG, c1: "#8dc1f1", c2: "#7480f1", c3: "#686fdf", c4: "#a9aef8" }, + { bg: BG, c1: "#23d5a2", c2: "#8dc1f1", c3: "#5e8fd9", c4: "#a9c9f5" }, + { bg: BG, c1: "#7480f1", c2: "#ffac4d", c3: "#e8894a", c4: "#ffd36b" }, + { bg: BG, c1: "#ffac4d", c2: "#fa5f67", c3: "#686fdf", c4: "#ff9aa0" }, + { bg: BG, c1: "#bec2ff", c2: "#9aa3ff", c3: "#7480f1", c4: "#b3a6f5" }, + { bg: BG, c1: "#8dc1f1", c2: "#23d5a2", c3: "#189370", c4: "#85ecce" }, +]; + +const FNV_OFFSET = 0x811c9dc5; +const FNV_PRIME = 0x01000193; + +/** + * A Bot's palette, from its seed (`avatarSeed`, the Bot's id unless set). Never from its name, so + * renaming a Bot keeps its color. A 32-bit FNV-1a hash spreads similar ids across the eight. + */ +export function orbPalette(seed: string): OrbColors { + // No seed has no identity to keep, so it gets the first palette rather than whatever the hash of + // nothing happens to land on. + if (!seed) return ORB_PALETTES[0]; + let hash = FNV_OFFSET; + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, FNV_PRIME); + } + return ORB_PALETTES[(hash >>> 0) % ORB_PALETTES.length]; +} diff --git a/app/tests/orb-palette.test.ts b/app/tests/orb-palette.test.ts new file mode 100644 index 000000000..dbe763af9 --- /dev/null +++ b/app/tests/orb-palette.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { ORB_PALETTES, orbPalette } from "../src/lib/orb-palette"; + +/* + * A Bot's orb color comes from its seed (`avatarSeed`, the Bot's id by default), never its name, + * so renaming a Bot keeps its color. + */ + +const SEEDS = [ + "general-assistant", + "knowledge", + "risk-analyst", + "expense-review", + "research-desk", + "meeting-follow-ups", + "release-notes", + "onboarding-buddy", + "ticket-triage", + "vendor-review", +]; + +describe("orbPalette", () => { + test("the same seed always gives the same palette", () => { + for (const seed of SEEDS) expect(orbPalette(seed)).toBe(orbPalette(seed)); + }); + + test("every palette is one of the eight", () => { + expect(ORB_PALETTES).toHaveLength(8); + for (const seed of SEEDS) expect(ORB_PALETTES).toContain(orbPalette(seed)); + }); + + test("a roster of ten Bots shows at least five colors", () => { + expect(new Set(SEEDS.map(orbPalette)).size).toBeGreaterThanOrEqual(5); + }); + + test("an empty seed gives the first palette", () => { + expect(orbPalette("")).toBe(ORB_PALETTES[0]); + }); +}); From 5a239020d2e6598e46cca172b32b8a903bd2cc1d Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Thu, 29 Oct 2026 16:35:44 -0700 Subject: [PATCH 04/36] feat(app): BotOrb, a Bot's orb in its own palette Small idle orbs are drawn still, so a long sidebar does not animate dozens of blurred layers. Larger or working orbs use AgentOrb, toned so light palettes do not wash out to white. --- app/src/components/agents/bot-orb.tsx | 83 +++++++++++++++++++++++++++ app/tests/bot-orb.test.tsx | 53 +++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 app/src/components/agents/bot-orb.tsx create mode 100644 app/tests/bot-orb.test.tsx diff --git a/app/src/components/agents/bot-orb.tsx b/app/src/components/agents/bot-orb.tsx new file mode 100644 index 000000000..2c7c2c7ea --- /dev/null +++ b/app/src/components/agents/bot-orb.tsx @@ -0,0 +1,83 @@ +import type { CSSProperties } from "react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import { orbPalette } from "@/lib/orb-palette"; + +/** At this size and smaller, an idle orb is drawn still. */ +const STATIC_MAX_SIZE = 32; + +/* + * The still orb: the live orb's base sphere (a lit radial in the palette's own stops) with one + * conic sweep over it for the mesh, and a soft highlight on top. Drawn from the custom properties + * set on the element, so the fill is written once and the palette is the only thing that varies. + */ +const STATIC_FILL = [ + "radial-gradient(circle at 30% 24%, hsl(0 0% 100% / 0.28), transparent 34%)", + "conic-gradient(from 200deg at 70% 30%, var(--c1), transparent 30% 65%, var(--c1))", + "radial-gradient(circle at 38% 34%, var(--c4) 0%, var(--c2) 42%, var(--c3) 100%)", +].join(", "); + +/** + * A Bot drawn as its orb, in the palette its seed picks (`orbPalette`), so renaming a Bot keeps + * its color. + * + * At avatar sizes an idle Bot gets a still orb drawn with CSS alone: twenty animated orbs in a + * sidebar would each run a blur and a backdrop filter on every frame. A larger orb, or one whose + * Bot is working, is the live `AgentOrb`. Neither moves under reduced motion: `AgentOrb` honours + * it, and the still orb has nothing to move. + */ +export function BotOrb({ + seed, + name, + size = 32, + state = "idle", +}: { + seed: string; + name: string; + size?: number; + state?: "idle" | "working" | "waiting"; +}) { + const colors = orbPalette(seed); + + if (size <= STATIC_MAX_SIZE && state === "idle") { + return ( + + ); + } + + return ( + + {/* Hidden so the Bot is announced once, by name. A waiting Bot idles: the next move is the + person's, not the Bot's. */} + + + ); +} diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx new file mode 100644 index 000000000..6e6fcc51f --- /dev/null +++ b/app/tests/bot-orb.test.tsx @@ -0,0 +1,53 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render } from "@testing-library/react"; +import { BotOrb } from "@/components/agents/bot-orb"; +import { settleReactWork } from "./settle-react-work"; + +/* + * A Bot's orb, in its own palette. At avatar sizes a still orb is drawn with CSS alone, because a + * sidebar of twenty animated orbs is expensive; larger or busy orbs are the live `AgentOrb`. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +test("the orb is announced by the Bot's name", () => { + const view = render(); + expect(view.getByRole("img").getAttribute("aria-label")).toBe( + "Expense Review", + ); +}); + +test("a small, idle orb is drawn still", () => { + const view = render(); + expect(view.getByRole("img").dataset.variant).toBe("static"); +}); + +test("a large orb is the live one", () => { + const view = render(); + expect(view.getByRole("img").dataset.variant).toBe("live"); +}); + +test("a working orb is live at any size", () => { + const view = render( + , + ); + expect(view.getByRole("img").dataset.variant).toBe("live"); +}); + +test("two Bots with different seeds get different colors", () => { + const style = (seed: string) => { + const view = render(); + const orb = view.getByRole("img"); + const value = orb.getAttribute("style") ?? ""; + view.unmount(); + return value; + }; + // These two seeds land on different palettes. + expect(style("knowledge")).not.toBe(style("risk-analyst")); +}); From 378e206be0710bbadd081028e1a277b09e00801f Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Thu, 29 Oct 2026 16:35:44 -0700 Subject: [PATCH 05/36] feat(app): Bots appear as their orb in the default skin AbstractAvatar and ChannelAvatar render BotOrb when no skin is set, so every avatar call site follows without changes. Skins keep their generated faces. --- app/src/components/agents/abstract-avatar.tsx | 11 +++ app/src/components/channels/avatar.tsx | 59 +++++++++++++-- app/tests/avatar-skin.test.tsx | 73 +++++++++++++++++++ 3 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 app/tests/avatar-skin.test.tsx diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index 9ca984507..8b06b5916 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,5 +1,11 @@ import Avatar from "boring-avatars"; +import { BotOrb } from "@/components/agents/bot-orb"; +import { DEFAULT_SKIN, useSkinId } from "@/skins/store"; +/** + * A Bot's avatar. OpenBot's own look draws the Bot as its orb; a skin keeps the face it was + * designed with. + */ export function AbstractAvatar({ name, seed, @@ -9,6 +15,11 @@ export function AbstractAvatar({ seed: string; size?: number; }) { + const skin = useSkinId(); + if (skin === DEFAULT_SKIN) { + return ; + } + return (