Repository navigation
Let each person pick a skin: OpenDuet, OpenDots or OpenMuse - #856
Open
davidmckayv wants to merge 44 commits into
Open
davidmckayv wants to merge 44 commits into
davidmckayv wants to merge 44 commits into
Conversation
Adds app/src/skins: a registry of skins, a per-browser choice stored like the dark theme and applied before first paint as data-skin, a Skin row in Preferences, and /s/$screen for screens only a skin has. A skin may replace the frame around the app's pages and scope its tokens to its own data-skin; tests hold every skin's stylesheet to its own scope and to defining dark for every light token. OpenDuet, OpenDots and OpenMuse are registered as stubs.
…er pane around OpenBot's pages The OpenDots stub becomes OpenDots' own frame drawn over OpenBot's data. A 48px rail (home, sidebar toggle, new chat, Bots, activity, skills, connections, admin, settings, account) sits beside a 220px sidebar whose DOTS are the Bots from the agent list, each wearing one of OpenDots' four plush avatars chosen by its id, and whose RECENT CHATS are OpenBot's conversations with search, pin and delete. A Dot reopens its latest chat or starts one; its "..." opens the Bot's page. The top bar carries OpenDots' breadcrumbs, a pause and resume for the Bot on screen, and the computer toggle: on a conversation it drives the conversation's own computer pane, elsewhere it opens OpenBot's computer panel in OpenDots' right pane. At phone width the sidebar becomes OpenDots' hamburger drawer. Every page inside is OpenBot's own, chat included, in OpenDots' tokens for light and dark (the dark set derived with OpenDots' own lightness flip). Scheduled & activity and Memories are skin screens that put OpenBot's routines and memory lists under OpenDots' page heading. Spaces, pages, the Brief and the pause-everything switch are left out: OpenBot has nothing behind them. Tests draw the frame against OpenBot's own agent routes and hold it to: every OpenBot surface reachable, Settings reachable on every page even when nothing loads, DOTS listing the agent list, a Dot opening a chat with its Bot, the computer toggle driving the conversation's pane, and per-Bot pause reaching the server.
OpenDots opens a Dot on its persona: the plush, the name, what it does and a way to edit it, above an empty conversation. OpenBot's new-conversation page leaves that space empty, so the frame draws the persona there from the Bot's profile and hides it as soon as the page shows a message. Also keeps the composer and its notices off the pane's edges where the column is no wider than the composer, drops the rule under a conversation's header so it reads as one band with the top bar, and gives the disabled send button OpenDots' pale disc.
The top bar's computer toggle opens the conversation's own Computer pane, so it now wears the same grey ground, ruled header and selected-tab title as the frame's pane elsewhere.
…Bot's data OpenDuet's look, rebuilt from OpenBot's primitives, Tabler icons and OpenDuet's tokens, so a person can pick it in Settings and keep everything OpenBot does. The frame is one column at most 430px wide. On Chat and Tasks a header floats at the top with the menu disc, OpenDuet's mic orb and the person's initial (their Settings), and a frosted tab bar floats at the foot. The orb presses the composer's own dictation rather than running a second one, so recording, transcription and where the words land stay OpenBot's; on a screen with no composer it opens Chat and starts there. Every other page opens over the app with a back disc and its own heading in a title pill. The side menu lists Chat, every conversation and group (pinned first, pin and delete on right-click), New conversation, and under Manage: Bots, Connections, Skills, Memory, Routines, Responsibilities, Approvals, Notifications and, for administrators, Admin. Settings is never folded away. The frame keeps OpenBot's channel socket and the browser notification the sidebar's Bots badge sent. Tasks, at /s/tasks, shows only what OpenBot keeps: pending approvals and Bots' questions, decided and answered on their cards with OpenBot's own mutations; active responsibilities and switched-on routines; and, under Up next, background research's suggestions with Start and Hide. Left out because OpenBot has no backend for them: Make, the morning brief, triage, the setup checklist, first-day onboarding, reply chips, channel icons and colours, Archived, the "Match phone" theme and adding tasks.
…rding The orb watched the whole conversation for the composer's recording controls and set its state on every change to the page, which during a streamed reply is every word. It now sets state only when recording actually starts or stops.
…dark OpenBot's composer sits on the card colour, which in this skin's dark set is a step above the canvas; OpenDots' composer is the canvas with a hairline around it.
…ph, the composer's edge Driving the skin against a real OpenBot found three things. The orb rested while a Bot browsed with its computer: it read only the roster's busy flag, which arrives over the socket and missed the person's own run. It now also reads the composer's Stop control, which OpenBot draws for exactly as long as a turn runs, and waits 700ms before thinking so the moment Stop shows while a conversation opens is not a flash of "Thinking" on every open. A waiting computer_run_command was drawn with the browser's globe, because the card asked whether the tool was the computer's before asking whether it was a command. The kind is now worked out once, commands and files first. A conversation's composer ran flush to the 430px column's edges with its focus ring on the screen's edge. The skin insets OpenBot's composer wrapper by 12px, as OpenDuet floats its own.
OpenMuse is a personal agent shaped like a phone. This rebuilds it on OpenBot's own components, Tabler icons and tokens so a person can pick it in Settings and keep everything OpenBot does. The frame is a single 760px column: a menu button, the centre Bot as OpenMuse's capybara with its name and a live status line, a bell for the approvals and questions waiting on the person, and a floating pill of Chat, Activity, Ideas, Goals and Apps. The centre Bot follows the conversation on screen, then the person's choice, then OpenBot's default Bot; a sheet behind the name switches it. Chat is OpenBot's own chat route under the frame, restyled through the skin's tokens and scoped CSS, so the computer, approval and component tools keep working. Activity, Ideas, Goals and Apps read OpenBot's Bot activity, approval inbox, suggestions, routines, responsibilities, connections and memory through the existing query and mutation factories. The menu reaches every OpenBot surface, Settings and Admin included. Mail, calendar, page watches, PDF filling and task plans have no OpenBot backend and are left out. Light is OpenMuse's palette; dark is a night version in its spirit. The tests render the frame against OpenBot's API shapes and check that every surface is reachable and each tab reads and writes the right data.
index.html applies the stored skin before first paint without knowing which skins exist, so a removed skin's name stayed on <html> for the whole visit while the app drew the default. The store now settles the attribute when it loads.
…nBot's orb and avatars A skin keeps OpenDuet's look and takes its identity from the OpenBot deployment. The menu header and the orb's "Talk to …" now read the product name from brand.yaml (appConfig.brand.productName), as OpenBot's own sidebar does, and nothing a person reads says OpenDuet; the skin's picker name stays OpenDuet, since that names the look. The orb's face is OpenBot's AgentOrb, as sign-in and onboarding draw it, in place of OpenDuet's mic-orb robot, which is removed. Its behaviour is unchanged: it presses the composer's dictation, listens while recording or typing, and thinks while the Bot works. Conversation avatars are each Bot's own, drawn from its avatarSeed in OpenBot's ChannelAvatar, and Tasks uses OpenBot's words for decisions: Allow once, Always allow here, Deny, Start as task, Dismiss.
…ts' look A skin changes how OpenBot looks, never whose product it is. The wordmark and the home links now name the product from brand.yaml (appConfig.brand.productName), the rail's monogram is OpenBot's AgentOrb, and every Bot row and the coworker home draw OpenBot's own avatar from the Bot's avatarSeed. The plush avatars and their images are gone. The words follow OpenBot: BOTS, "Bots" in the breadcrumb, "Edit Bot". The rail, sidebar layout, top bar, computer pane, density, type and palette stay OpenDots'. The skin's own name in the picker is still OpenDots, since that names the look. New tests render the frame and hold it to the product name, to never showing "OpenDots" or "Dots", and to drawing each Bot with OpenBot's avatar from its seed; both fail on the previous skin.
Skins keep their look but take their identity from the OpenBot deployment. The OpenMuse skin now names itself with the brand.yaml product name, the same one OpenBot's sidebar shows, and never says "OpenMuse" to a person; only the picker still calls the look OpenMuse. The capybara is gone. The top bar, the Bot switcher, the Your Bot card, approval cards and conversation rows draw each Bot with its own OpenBot avatar, and the deployment's AgentOrb stands in when there is no Bot yet. Copy uses OpenBot's words: Bots, Connections, Approvals. A regression test renders the frame on every tab and checks that it shows the product name, never shows "OpenMuse" in text, labels or alt text, and draws no capybara.
davidmckayv
requested review from
MikeRyanDev,
guidovizoso,
mxmzb and
tylerslaton
as code owners
October 27, 2026 20:55
Anthropic's SDK 2.3.0, which CI now installs for the AG2 harness, sends the key as Authorization: Bearer instead of x-api-key. The agent's key still reaches Anthropic; the test only looked for the old header, so it failed on every pull request. It now accepts either, and passes with Anthropic's SDK 1.17.0 and 2.3.0 alike. (cherry picked from commit a1391e7)
davidmckayv
force-pushed
the
david/skins
branch
from
October 27, 2026 22:24
ed5b911 to
69b744d
Compare
guidovizoso
requested changes
Oct 27, 2026
guidovizoso
left a comment
Collaborator
There was a problem hiding this comment.
The plumbing is solid:
- Data access goes through the
lib/*factories. - Every skin selector is scoped under
data-skin, and a test enforces it. - Every light token has a dark one.
- The pre-paint script means no flash.
- An unknown stored skin falls back to the default.
- Settings, Sign out and Admin are reachable in every skin.
Requesting changes, mainly for the first point.
Blocker
The skins re-implement OpenBot's screens instead of reusing them.
- Approvals now exist three times:
components/approvals/waiting.tsx,skins/openmuse/approvals.tsxandskins/openduet/tasks-screen.tsx. The copies have already drifted:- there are three different tool-name formatters;
- OpenMuse hides "What it will send" behind a toggle, where OpenBot always shows it.
- OpenMuse rebuilds routines, memory and suggestions (
screens/goals.tsx,screens/apps.tsx,screens/ideas.tsx). Its routine delete is "Stop this routine" then "Remove for good", instead of the existing Delete confirmation dialog. - Navigation lives in four lists:
app-sidebar,openduet/places.ts,openmuse/menu-sheet.tsxandopendots/frame.tsx/sidebar.tsx.
Any new approval choice, routine field or route will silently be missing from two or three skins, and nothing fails. OpenDots already shows the right pattern: opendots/screens.tsx wraps RoutinesList/MemoryList in its own heading. Please build the skins from the existing components throughout, and read navigation from one shared list.
Should fix
- Skin CSS targets OpenBot's Tailwind classes and copy. For example
form.rounded-2xl,.sticky.top-0.border-band.bg-sidebar.border-l > .h-12.sticky > div:first-child(opendots/skin.css), and[aria-label="Message"](openmuse/skin.css).openduet/frame.tsx:42also writes an attribute onto a React-owned heading. Any composer restyle breaks the skins without a test failing. Please hook skins onto explicitdata-*slots instead. - "Each person" is per browser (
skins/store.ts). On a shared machine the next person inherits the skin, and also the Bot chosen in OpenMuse (openmuse/chosen-bot.ts). User preferences already exist (userPreferencesQueryOptions). Keep localStorage only as the pre-paint cache, or retitle. - A skin imports helpers from a route file.
openmuse/screens/apps.tsximports fromroutes/_authed/_app/connections.index, which works againstautoCodeSplitting. Move the helpers tolib/plugins/. - Contrast. OpenDots' "YOUR WORKSPACE" label (8px, 2.7:1) and its description (12px, 3.0:1) in
opendots/screens.tsxfail WCAG AA. - OpenMuse highlights the wrong tab.
tabFor(openmuse/model.ts) maps everything else to "apps", so/routines,/approvalsand/bots/x/setupmark Apps as the current page. - Copy against
openbot-copy:- "Always-on coworkers" (
opendots/index.ts); - "run on the server" (
opendots/screens.tsx); - "The beginning of something lighter" as an empty state (
openmuse/screens/activity.tsx); - "Personality & memory" under a tab called "Apps" (
apps.tsx).
- "Always-on coworkers" (
- The picker shows only names. Each skin's
descriptionis never displayed, and the default is hardcoded "OpenBot" even whenbrand.yamlrenames the product. - Wide screens (please check live): OpenDuet's 430px column and OpenMuse's 760px column hold every page, including Bot pages and the computer panel.
Nits
- The
agent-ag2/tests/test_main.pychange (an anthropic 2.x header change) belongs in its own PR. - Add
motion-reduceto OpenMuse'sanimate-pulse; OpenMuse and OpenDots have no reduced-motion handling at all. - Every OpenDuet approval card is a region with the same name.
- The
\nNext runline break collapses inside a<p>(goals.tsx). /s/$screenwith another skin's key silently goes home, andactivitymeans a different screen in each skin. Nothing tests switching skins while on a skin screen.
…ot's width From Guido's review of the skins. Tasks re-implemented OpenBot's approvals, suggestions and routines, and its copies had drifted: its own tool-name formatter, its own decision buttons, approval cards that were all regions with the same name. Tasks now draws OpenBot's components unchanged (WaitingForYou for approvals and questions, BotResponsibilities, RoutinesList and SuggestionsInbox) under OpenDuet's Active and Up next switch and section headings. What an action will send, every choice and every confirmation are OpenBot's. The skin keeps only the badge's count, and a test renders WaitingForYou and holds the badge to the rows it draws. The frame no longer writes an attribute onto a page's own heading. A page opened over the app has a back disc and keeps its heading as drawn. Only Chat and Tasks are phone-shaped now. Every other page keeps OpenBot's width on a wide screen, and a conversation with its computer or its Bot's details open grows by OpenBot's 400px pane, so the computer sits beside the conversation as it does in OpenBot. Every animated control in the frame says what reduced motion does, and a test holds new ones to it; the orb is AgentOrb, which reads the same preference. The skin's description no longer calls a Bot an agent.
…d reuse OpenBot's parts Review on #856 found the skin's page label and description below WCAG AA, house-style copy problems, no reduced-motion handling, and pieces rebuilt that OpenBot already has. - Contrast: every text colour now reaches 4.5:1 and every icon 3:1 on the surface it sits on, in light and dark. Greys that fell short are darkened along their own hue; the YOUR WORKSPACE label grows from 8px to 10px. - Copy: the picker description no longer says "coworkers"; the schedule screen is Routines, as OpenBot names it, at /s/routines, without "run on the server"; empty states and errors say what will appear and what to do next. - Reduced motion: the frame's chrome and the menus, tooltips, sheets and dialogs it opens stop transitioning and animating under prefers-reduced-motion. - Reuse: the phone drawer is OpenBot's Sheet (focus trap, Escape, scrim) at OpenBot's own 768px breakpoint, and older conversations load through OpenBot's ChannelPagination. New tests measure every colour pair against WCAG in both themes, hold the reduced-motion rule and the chrome markers in place, and open the phone drawer to reach every surface.
…ns data-slot hooks The skin was a per-browser choice, so the next person on a shared machine inherited it. It is now a user preference (`skin`, default "openbot") that the server validates against SKIN_IDS, which move to shared/. The picker draws a skin at once and saves it through an optimistic preferences mutation that puts the skin back and says so when the save fails. SkinAccountSync, mounted in _authed, applies the account's skin whenever the preferences load or change, so the account wins over the browser's cache. localStorage stays only as the pre-paint cache, and the central sign-out clears it. The picker names the default skin after brand.yaml's product name and shows each skin's description. OpenBot's destinations (Bots, Capabilities, Connections, Admin for admins, Settings) live in lib/navigation.ts. The sidebar renders its footer and user menu from that list, so skins can read the same one. Skins styled OpenBot through Tailwind classes and ARIA copy. The composer, its input and buttons, the conversation header, the composer column, the detail pane and the page title now carry data-slot hooks for them to use. The Connections listing rules move from the route file to lib/plugins/listed.ts, so OpenMuse's Apps screen stops importing a route.
The OpenMuse skin rebuilt approvals, routines, responsibilities, suggestions, memory and connections, and its copies had already drifted from OpenBot: a different tool-name formatter, "What it will send" hidden behind a toggle, and its own routine delete flow. A new approval choice or routine field would have been missing from the skin with nothing failing. Each tab now puts OpenBot's own sections for the centre Bot under OpenMuse's titles. The bell and Activity use WaitingForYou and BotNeedsYou; Activity adds the Bot's activity sections; Ideas uses SuggestionsInbox and ProactiveResearchSettings; Goals uses RoutinesList and BotResponsibilities, with OpenBot's Delete dialog. Apps is OpenBot's Connections page. The skin's copies and its formatter are gone. A tab is lit only on its own screen, not on every other page. Pages that are not a tab, and a conversation with its computer or settings open, take OpenBot's own width on wide screens; the top bar and tab bar stay centred. The chosen Bot is remembered per signed-in person, so the next person on a shared browser starts from OpenBot's default Bot. Buttons, tabs and sheets respect reduced motion, and copy follows OpenBot's style.
# Conflicts: # app/src/skins/openmuse/screens/apps.tsx
…y their hooks The menu's destinations are OpenBot's one navigation list now (useNavigationItems: Bots, Capabilities, Connections, Admin for admins, Settings), so a destination added to OpenBot reaches this menu too. Chat, the conversations and Tasks stay the skin's own. The skin's copy of the list is gone, and the avatar disc takes Settings from the same list. Every selector that reached OpenBot's markup by its classes or its words now uses the data-slot hooks: the composer column for the inset, the composer's stop control for "working", its input for the caret. Only the three dictation controls, which have no hook, are still found by the names OpenBot gives them, and a test holds those to the real composer. A page opened over the app has its title back in the pill, read from its page-title hook and never written to; the title stays for screen readers. Where the page draws its own back link, the chrome's back disc goes where that link goes and the page's own bar is hidden, so there is one way back.
A skin that restyles or drives dictation found the three buttons by their aria-labels, so rewording one would break it without a test failing. They carry composer-dictate, composer-dictate-cancel and composer-dictate-stop.
… pages through their hooks The rail and sidebar now draw OpenBot's destinations from useNavigationItems(), the same list OpenBot's own sidebar renders, so a destination added there reaches this skin too. The skin keeps only what is its own: BOTS, RECENT CHATS, and its Routines and Memories screens. Bots sits under the list of Bots; Capabilities and Connections are in the rail and the phone drawer; Admin and Settings close the sidebar. OpenBot's Bots badge, which also sends the browser notification, is mounted once: on the rail's Bots button, or on the menu button on a phone. skin.css no longer names OpenBot's Tailwind classes. The composer, send button, conversation header, composer column and detail pane are styled through the data-slot hooks those pages publish for skins. Tests hold the rail and sidebar to navigationFor() for a member and an admin, and fail any selector in skin.css that names a class other than .dark.
… orb The orb found the composer's dictate, cancel and stop controls by their accessible names, so rewording one for a screen reader would have broken it silently. It uses the composer-dictate, composer-dictate-cancel and composer-dictate-stop hooks now, and a test fails if any selector in the skin, in its stylesheet or its code, targets an aria-label.
The menu sheet kept its own list of OpenBot destinations, which would miss any page OpenBot adds. Its destinations now come from the shared navigation list OpenBot's sidebar draws, filtered for the signed-in person; conversations and the skin's tabs stay as they were. The stylesheet reached OpenBot's components through Tailwind classes and ARIA labels, so restyling the composer or the conversation header in OpenBot would have broken the skin without a test failing. It now uses only the data-slot hooks those components publish: composer, composer-input, composer-attach/send/stop, conversation-header and page-title. The system font is set through the font-sans token rather than by overriding OpenBot's font classes.
…raph AG-UI test anthropic 2.x sends the key as Authorization: Bearer instead of x-api-key and dates its Messages API version later than 2023-06-01. The provider-boundary test asserted both by their 1.x values, so it failed once the unpinned requirements resolved 2.x. It now reads the key from either header and checks the version is a date; the endpoint, key, model and body it asserts are unchanged. (cherry picked from commit 99685ee)
The Connections page's helpers moved from the route file into lib/plugins/listed.ts, and the guard's list of known brokered-provenance sites still named the route file.
guidovizoso
approved these changes
Oct 28, 2026
guidovizoso
left a comment
Collaborator
There was a problem hiding this comment.
Re-reviewed at 0e3788a0. The blocker and the should-fix items are resolved, so I'm approving:
- The skin copies of approvals, routines, memory, suggestions and apps are gone. The skins now render
WaitingForYou,RoutinesList,SuggestionsInboxand the others. - There is one navigation list (
lib/navigation.ts). - Skins style pages through
data-slothooks. - The skin is saved on the account, with localStorage only as the pre-paint cache.
- OpenDots passes AA, with a test.
tabFor, the copy, the picker descriptions, the brand name and the column widths are all fixed.
Follow-ups, fine as a quick next PR:
- OpenDuet finds the back link by DOM position (
openduet/header.tsx:225,header.parentElement.parentElement.previousElementSibling), andopenduet/skin.csshides the bar with a:has(+ div > div > [data-slot="page-header"])chain. One extra wrapper inPageShellbreaks both silently. Adddata-slot="page-bar"anddata-slot="page-back"inpage-shell.tsx. - OpenMuse still uses 11px text (
openmuse/frame.tsx:187,ui.tsx:32,216). - Tasks can hide an inbox error.
openduet/tasks-screen.tsxdrops "Needs input" whenwaitingCountis 0, which also happens when the inbox query fails, so the errorWaitingForYouwould show is never drawn. - Core imports from
@/skins/store(lib/auth/mutations.ts,lib/settings/message-list.ts).saveSkinMutationOptionsbelongs inlib/settings/mutations.ts. - The cached skin paints the sign-in page after a session expires without a sign-out.
- Two CI fixes don't belong here: the LangGraph AG-UI header test and the Chromium
channelchange in the enter-text test are unrelated to skins. Ideally they go in their own small PR.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Anyone, not just an admin, can change how OpenBot looks for them under Settings → Preferences → Skin. There are three skins, each taking the look of one of the open-source templates and running on OpenBot's own features:
Skins keep the deployment's identity. Every name a person reads comes from
tenant.product_nameinbrand.yaml. The mark is OpenBot'sAgentOrb, every Bot shows its own avatar from itsavatar_seed, and the words are OpenBot's (Bots, Approvals, Connections). Only the look changes. The picker shows each skin's description, and the default skin uses the brand's name.Left out on purpose: template features OpenBot has no server for, such as OpenDots' Spaces, OpenDuet's Make tab and morning brief, and OpenMuse's mail, calendar and page watches. There are no placeholders and no dead buttons.
How it is built
Skins reuse OpenBot's components. Approvals, responsibilities, routines, memory and suggestions are OpenBot's own components, wrapped in each skin's layout. No skin re-implements them, so a new approval choice or routine field appears in every skin.
One navigation list.
app/src/lib/navigation.ts(navigationFor(role)/useNavigationItems()) drives OpenBot's sidebar and every skin's menu. Each skin has a test checking that its menu matches the list.data-slothooks. Skins style OpenBot's own elements only through these explicit hooks:composer,composer-input,composer-attach,composer-send,composer-stopandcomposer-dictate(-cancel/-stop);composer-columnandconversation-header;detail-pane(-header/-title);page-headerandpage-title.Tests pin each hook to its element, and fail if any skin stylesheet names a Tailwind class or an aria-label.
The choice is saved on the account, as
skininusers.preferences. That column is jsonb, so there's no migration._authedapplies the account's skin whenever preferences load.app/index.htmlreads so a reload never flashes the default, and sign-out clears it.Tokens stay scoped. Each skin overrides shadcn's variables only under
:root[data-skin="<id>"]and its.dark. Tests hold every stylesheet to its own scope and to defining dark for every light token. Every skin meets WCAG AA contrast and respects reduced motion.The chat itself is OpenBot's under every skin, so the browser-side computer, approval and component tools keep working.
Layout. Only each skin's own screens use its narrow column. Bot pages, Admin, Settings and the computer panel get OpenBot's normal width.
Other existing code touched:
index.htmlpre-paint, thestyles.cssimport, the frame in_authed/_app.tsx, the Skin row in Settings and/s/$screen;data-slotattributes;lib/plugins.The AG2 test change (Anthropic's 2.x SDK sends the key as a Bearer token) is identical to Read the Anthropic key from either header in the AG2 test #859 and drops out once that merges.
Checked:
bun test app/tests(1764 pass) and the server preference tests (18 pass).typecheck,typecheck:tests,lint,format:checkand the app build.Where it runs
users.preferencesjsonb, merged in Postgres the way the other preferences are.preferences || patch), the same as the existing preferences, so concurrent changes to different preferences are kept.storageevent, and other devices pick up the skin on their next preferences load.Boundary and audit
Changelog
CHANGELOG.mdunderUnreleased.