Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions .design-sync/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,30 @@ Tailwind and TypeScript). Nothing there reimplements a component.
- Fixed overlays (`Dialog`, `Confirm`, `Toaster`) render inside a box with `transform:
translateZ(0)`, which makes it their containing block; without it the per-story capture is
0 px tall.
- `ListAvatar` is excluded (cfg.componentSrcMap): a wrapper over `BotAvatar` that needs a bot object.
- The app screens (`Sidebar`, `CrewBots`, `BotRun`, `InboundRow`, `ApprovalCard`) read the app's
store and API. `pkg/provider.tsx` (`BotloftProvider`) wires the app's own `DaemonProvider` and
`HostProvider` to `FakeBotloft` and `FakeHost`, seeded with the crews and bots it is given, and
turns `approvals.answer` into an `onAnswer` callback. `pkg/scene.ts` builds protocol objects
(`makeCrew`, `makeBot`, `makeMessage`, `chat.*`) with the fake daemon's defaults; request
inputs and summaries follow the daemon (`crates/botloft-core/src/chat.rs` `tool_summary`,
`crates/botloftd/src/tools/browser_sites.rs`): a site request is `{site, url}` with the site as
its summary.
- Type emission covers the whole package with the repo as root; `.design-sync` is renamed to `ds`
in `dist/types/emit` (ts-morph skips dot folders), and `paths` points bare imports at
`app/node_modules` (`@types` first, or React resolves to its JS). `vite/client.d.ts` is added
for the fake daemon's `?raw` import.
- Bot colors come from `AVATAR_PALETTE` (protocol.gen.ts); previews and conventions use it.

## Known render warns

- `[FONT_MISSING] "Cascadia Mono"`: a system fallback after IBM Plex Mono in `--font-mono`; Plex
Mono ships. Accepted.
- `Menu` shows only its closed button: opening takes a click.
- `SelectField` Disabled looks like the enabled one: the app's own control does not style disabled.
- `[RENDER_ERRORS] AbortError: Transition was skipped` on the app screens: each preview cell has
its own `BotloftProvider`, and each opens its first crew with the app's view transition
(`ui/motion.ts`); the transitions of several cells on one page cut each other off. Renders fine;
a design uses one provider.

## Re-sync risks

Expand All @@ -54,5 +70,8 @@ Tailwind and TypeScript). Nothing there reimplements a component.
ranges. Change both together.
- The icon set follows the app's imports: an icon the app stops using disappears from `icons`.
- Bot colors in previews and conventions are sample values, not tokens.
- The scope is the base pieces plus the bot badges (owner's choice on 2026-10-02). Chat rows,
approval cards and the sidebar are not in the package.
- Scope (owner's choices on 2026-10-02): the base pieces, the bot badges, and the app screens the
explainer video needs (sidebar, crew cards, a bot's turn, messages, requests). Not in it yet:
the whole crew view, the composer, the browser and screens panels, routines, settings.
- `scene.ts` mirrors protocol shapes and daemon summaries by hand; a protocol change
(`protocol.gen.ts`) or a new request kind needs it updated, or the tsc step fails.
31 changes: 27 additions & 4 deletions .design-sync/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
"SelectField": "label: string; hint?: string; options: { value: string; label: string }[]; value?: string; defaultValue?: string; onChange?: React.ChangeEventHandler<HTMLSelectElement>; name?: string; className?: string; id?: string; style?: React.CSSProperties; disabled?: boolean; autoFocus?: boolean; ",
"BotStateBadge": "/** The bot: its state and whether the owner paused it. */ bot: { state: \"offline\" | \"launching\" | \"idle\" | \"busy\" | \"needs_approval\" | \"rate_limited\" | \"auth_error\" | \"backoff\" | \"archived\"; paused: boolean }; /** A paused crew shows its stopped bots as paused. */ crewPaused?: boolean; compact?: boolean;",
"ChiefBadge": "/** The crew this bot leads; only its name is read, for the tooltip. */ crew: { name: string }; /** Just the crown, for tight rows. */ compact?: boolean;",
"Menu": "/** Accessible name of the button that opens the menu. */ label: string; /** An icon from window.Botloft.icons, e.g. icons.Ellipsis. */ icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; items: { label: string; icon?: React.ComponentType<{ size?: number; strokeWidth?: number }>; danger?: boolean; disabled?: boolean; /** Makes the item one of a set of choices, marked when chosen. */ checked?: boolean; onSelect: () => void }[];"
},
"componentSrcMap": {
"ListAvatar": null
"Menu": "/** Accessible name of the button that opens the menu. */ label: string; /** An icon from window.Botloft.icons, e.g. icons.Ellipsis. */ icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; items: { label: string; icon?: React.ComponentType<{ size?: number; strokeWidth?: number }>; danger?: boolean; disabled?: boolean; /** Makes the item one of a set of choices, marked when chosen. */ checked?: boolean; onSelect: () => void }[];",
"ApprovalCard": "/** A request: Botloft.chat.askCommand / askSite / suggestBot / askPlan(...).body. The card follows its toolName (command, website, suggested bot, plan) and its status (pending, allowed, denied, expired). Needs BotloftProvider around it. */ approval: any; /** A bot from Botloft.makeBot(...). */ bot: any;",
"BotRun": "/** One turn of the bot, in order: items from Botloft.chat.reply / tool / askCommand / askSite / suggestBot / askPlan / turn(bot, ...). Needs BotloftProvider around it. */ items: any[]; /** A bot from Botloft.makeBot(...). */ bot: any; /** The turn in progress: the reply being written (draft) or the working dots (working). Only a live run moves the avatar. */ live?: { draft: string; working: boolean };",
"InboundRow": "/** A message from Botloft.makeMessage({ from, to, body }). From \"owner\" it is your bubble on the right; from a bot or \"system\" it sits on the left with the sender's avatar. The sender must be among BotloftProvider bots. */ message: any; /** The bot whose chat this is: the message's recipient. */ /** A bot from Botloft.makeBot(...). */ bot: any;",
"CrewBots": "/** A crew from Botloft.makeCrew(...). */ crew: any; /** Its bots, from Botloft.makeBot({ crew, ... }); the Chief (chief: true) gets the crown. Pass the same ones to BotloftProvider. */ bots: any[]; onNewBot: () => void;",
"Sidebar": "/** No props: it lists the crews and bots given to BotloftProvider, with each bot's state and last activity, and the account at the bottom. Give it a fixed height. */ className?: never;",
"BotloftProvider": "/** Crews from Botloft.makeCrew(...). */ crews?: any[]; /** Bots from Botloft.makeBot(...). A new array updates them, e.g. to change a state over time. */ bots?: any[]; /** The bot whose chat is open: highlighted in the sidebar. */ selectedBotId?: string; /** Called when Allow or Deny is clicked on a request card. */ onAnswer?: (answer: { approvalId: string; allow: boolean; note?: string }) => void; children: React.ReactNode;"
},
"overrides": {
"Dialog": {
Expand All @@ -44,6 +47,26 @@
"Toaster": {
"cardMode": "single",
"primaryStory": "ErrorNotice"
},
"Sidebar": {
"cardMode": "single",
"viewport": "320x620"
},
"BotRun": {
"cardMode": "column"
},
"InboundRow": {
"cardMode": "column"
},
"ApprovalCard": {
"cardMode": "column"
},
"CrewBots": {
"cardMode": "column"
},
"BotloftProvider": {
"cardMode": "single",
"viewport": "1000x560"
}
},
"readmeHeader": ".design-sync/conventions.md"
Expand Down
71 changes: 54 additions & 17 deletions .design-sync/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ mascot in its own color. The look is quiet and warm: neutral surfaces, one orang

## Setup

- No provider is needed. Link `styles.css` and load `_ds_bundle.js`; components are on `window.Botloft`.
- Link `styles.css` and load `_ds_bundle.js`; components are on `window.Botloft`. The basic pieces
(Button, fields, Dialog, Callout, BotAvatar, BotStateBadge...) need nothing around them.
- **App screens need `BotloftProvider`.** `Sidebar`, `CrewBots`, `BotRun`, `InboundRow` and
`ApprovalCard` are the app's real screens and read the app's data: wrap them in one
`<BotloftProvider crews={[...]} bots={[...]}>` per design (not one per piece). Build the data
with `makeCrew`, `makeBot`, `makeMessage` and `chat.*` (below); never write a bot object by hand.
A new `bots` array updates every screen, which is how a design animates states.
- **Theme:** light by default. Put `data-theme="dark"` on a wrapper (or `<html>`) and every token
switches. Give the page `bg-canvas text-ink` so the background follows the theme.
- **Language:** the components' own words (state labels, Close, Cancel) follow the browser's
Expand Down Expand Up @@ -34,8 +40,29 @@ use `style={{ height: 360 }}` for one-off sizes.
The tokens behind them are CSS variables: `--canvas`, `--panel`, `--sunken`, `--line`,
`--line-strong`, `--ink`, `--ink-soft`, `--muted`, `--accent`, `--ok`, `--work`, `--warn`,
`--danger`. Use `var(--accent)` in inline styles; never hard-code hex for UI colors.
Bot colors are the exception: each bot has its own (`#FF7A59` Botloft orange, `#4FC382`, `#6EA6FF`,
`#F0B24A`, `#C084FC`), passed to `BotAvatar`.
Bot colors are the exception: each bot has its own from `Botloft.AVATAR_PALETTE` (`#FF7A59`
Botloft orange, `#5EC8FF`, `#A48BFF`, `#9BE564`, `#FFC857`, `#FF7EB6`, `#3DD9C1`, `#E8D5B0`);
`makeBot` hands them out in that order.

## Data for the app screens

```jsx
const { makeCrew, makeBot, makeMessage, chat } = window.Botloft;
const crew = makeCrew({ name: "Research" });
const chief = makeBot({ crew, name: "Chief", chief: true, state: "idle" });
const scout = makeBot({ crew, name: "Scout", state: "busy", role: "Finds sources.",
activity: { kind: "tool", text: "on-device AI news", tool: "WebSearch" } }); // sidebar line
```

- `state`: `idle`, `busy` (working), `needs_approval`, `launching`, `rate_limited`, `backoff`,
`auth_error`, `offline` (with `paused: true` it reads Paused).
- A bot's turn is a list for `BotRun`: `chat.reply(bot, markdown)`, `chat.tool(bot, { name:
"WebSearch" | "WebFetch" | "Read" | "Write" | "Bash", summary })`, `chat.askSite(bot, url)`,
`chat.askCommand(bot, command, { explanation })`, `chat.suggestBot(chief, { name, role, model,
instructions, reason })`, `chat.askPlan(bot, markdown)`, `chat.turn(bot, seconds)`. Each takes
`{ status: "allowed" | "denied" }` to show it answered. `ApprovalCard` takes one of them's `.body`.
- `makeMessage({ from: bot | "owner" | "system", to: bot, body, kind: "note" | "task" })` for
`InboundRow`: yours on the right, a bot's or Botloft's on the left.

## Components and their roles

Expand All @@ -47,6 +74,11 @@ Bot colors are the exception: each bot has its own (`#FF7A59` Botloft orange, `#
`Toaster` + `notifyError(what, error)` for failures away from a form.
- Fields: `TextField`, `TextArea`, `SelectField` (labeled), `Select` (custom dropdown),
`Choices` (2-6 options side by side), `Switch`, `Tabs`, `Menu`, `Details`, `SidePanel`.
- App screens: `Sidebar` (crews and bots, 288px wide, give it a height), `CrewBots` (a crew's bot
cards), `BotRun` (one turn of a bot in its chat, inside a `<ul>`), `InboundRow` (a message in a
chat, inside a `<ul>`), `ApprovalCard` (a request: a website, a command, a suggested bot, a plan).
The app's window is the `Sidebar` on the left and the open bot's chat on the right, on `bg-canvas`.
`BotloftProvider`'s `onAnswer` hears Allow and Deny on request cards.

Read `components/<group>/<Name>/<Name>.prompt.md` for each one's props.

Expand All @@ -58,24 +90,29 @@ you"), never "the agent" or "Claude". Never say "daemon"; say "Botloft" or "in t
## Example

```jsx
const { BotAvatar, BotStateBadge, Button, Callout, icons, setLocaleChoice } = window.Botloft;
const { BotloftProvider, Sidebar, BotRun, BotStateBadge, makeCrew, makeBot, chat, setLocaleChoice } =
window.Botloft;
setLocaleChoice("en");
const crew = makeCrew({ name: "Research" });
const chief = makeBot({ crew, name: "Chief", chief: true });
const scout = makeBot({ crew, name: "Scout", state: "needs_approval" });
const site = chat.askSite(scout, "https://arxiv.org/abs/2609.01234");

function CrewCard() {
function AppWindow() {
const [allowed, setAllowed] = React.useState(false);
const scoutNow = allowed ? { ...scout, state: "busy" } : scout;
const card = allowed ? { ...site, body: { ...site.body, status: "allowed" } } : site;
return (
<div className="flex max-w-md flex-col gap-3 rounded-xl border border-line bg-panel p-4">
<div className="flex items-center gap-3">
<BotAvatar color="#4FC382" size={36} mood="working" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-ink text-sm">Scout</p>
<BotStateBadge bot={{ state: "busy", paused: false }} compact />
</div>
<Button variant="ghost" icon={icons.Pause} label="Pause Scout" />
<BotloftProvider crews={[crew]} bots={[chief, scoutNow]} selectedBotId={scout.id}
onAnswer={(answer) => setAllowed(answer.allow)}>
<div className="flex bg-canvas text-ink" style={{ height: 560 }}>
<Sidebar />
<ul className="flex min-w-0 flex-1 flex-col gap-5 p-5">
<BotRun bot={scoutNow} items={[chat.reply(scout, "I need to open this paper."), card]}
live={{ draft: "", working: allowed }} />
</ul>
</div>
<Callout tone="warn" title="Scout needs your approval">
It wants to open arxiv.org for the first time.
</Callout>
</div>
</BotloftProvider>
);
}
```
45 changes: 19 additions & 26 deletions .design-sync/pkg/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// node .design-sync/pkg/build.mjs

import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
Expand Down Expand Up @@ -87,29 +87,32 @@ await build({
rmSync(noFonts, { force: true });
}

// Types: declarations for the app files the entry re-exports, then an
// index.d.ts that is the entry with its paths pointed at them.
const entry = readFileSync(join(here, "index.ts"), "utf8");
const sources = [...entry.matchAll(/from "\.\.\/\.\.\/app\/src\/([^"]+)"/g)].map(
(match) =>
existsSync(join(app, `src/${match[1]}.tsx`)) ? `src/${match[1]}.tsx` : `src/${match[1]}/index.ts`,
);
// Types: declarations for the whole entry (the app files it re-exports and
// the package's own provider and builders), emitted under dist/types/emit
// with the repo as the root. The .design-sync folder is renamed to ds there,
// since tools skip dot folders; the relative imports between the two trees
// keep their depth, so they still resolve.
const tsconfig = join(here, "tsconfig.types.json");
const slash = (path) => path.replaceAll("\\", "/");
const modules = join(app, "node_modules");
writeFileSync(
tsconfig,
`${JSON.stringify(
{
extends: join(app, "tsconfig.json").replaceAll("\\", "/"),
extends: slash(join(app, "tsconfig.json")),
compilerOptions: {
noEmit: false,
declaration: true,
emitDeclarationOnly: true,
rootDir: join(app, "src").replaceAll("\\", "/"),
outDir: join(dist, "types", "app").replaceAll("\\", "/"),
rootDir: slash(resolve(here, "../..")),
outDir: slash(join(dist, "types", "emit")),
types: [],
typeRoots: [join(app, "node_modules", "@types").replaceAll("\\", "/")],
typeRoots: [slash(join(modules, "@types"))],
// The package sits outside the app: its imports resolve in the app's node_modules.
paths: { "*": [slash(join(modules, "@types", "*")), slash(join(modules, "*"))] },
},
files: sources.map((source) => join(app, source).replaceAll("\\", "/")),
// `?raw` and the other Vite imports the app uses.
files: [slash(join(here, "index.ts")), slash(join(modules, "vite", "client.d.ts"))],
include: [],
},
null,
Expand All @@ -118,20 +121,10 @@ writeFileSync(
);
try {
const tsc = join(dirname(require.resolve("typescript/package.json")), "bin", "tsc");
execFileSync(process.execPath, [tsc, "-p", tsconfig], {
stdio: "inherit",
});
execFileSync(process.execPath, [tsc, "-p", tsconfig], { stdio: "inherit" });
} finally {
rmSync(tsconfig, { force: true });
}
writeFileSync(
join(dist, "types", "index.d.ts"),
entry
.split("\n")
.filter((line) => !line.startsWith("import ") && !line.startsWith("//"))
.join("\n")
.replaceAll("../../app/src/", "./app/")
.trimStart(),
);
writeFileSync(join(dist, "types", "icons.gen.d.ts"), iconsModule);
renameSync(join(dist, "types", "emit", ".design-sync"), join(dist, "types", "emit", "ds"));
writeFileSync(join(dist, "types", "index.d.ts"), 'export * from "./emit/ds/pkg/index";\n');
console.log(`built ${dist}`);
14 changes: 11 additions & 3 deletions .design-sync/pkg/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
// The pieces of Botloft's interface that the design system shares with
// claude.ai/design: the app's own components, re-exported as they are, and
// its stylesheet (tokens, fonts and the Tailwind utilities the app uses).
// Built by build.mjs; nothing here reimplements a component.
// Built by build.mjs; nothing here reimplements a component. provider.tsx
// and scene.ts only wire the app's providers and build sample data.

import "./styles.css";

export { BOTLOFT_COLOR, BotAvatar, ListAvatar, type Mood } from "../../app/src/features/bots/BotAvatar";
export { BOTLOFT_COLOR, BotAvatar, type Mood } from "../../app/src/features/bots/BotAvatar";
export { BotStateBadge } from "../../app/src/features/bots/BotStateBadge";
export { ChiefBadge } from "../../app/src/features/bots/ChiefBadge";
export { ApprovalCard } from "../../app/src/features/chat/ApprovalCard";
export { BotRun } from "../../app/src/features/chat/BotRun";
export { InboundRow } from "../../app/src/features/chat/InboundRow";
export { CrewBots } from "../../app/src/features/crews/CrewBots";
export { Sidebar } from "../../app/src/features/crews/Sidebar";
export { setLocaleChoice } from "../../app/src/i18n";
export { Button, type ButtonVariant } from "../../app/src/ui/Button";
export { Callout } from "../../app/src/ui/Callout";
export { Choices } from "../../app/src/ui/Choices";
Expand All @@ -21,5 +28,6 @@ export { SidePanel } from "../../app/src/ui/SidePanel";
export { Switch } from "../../app/src/ui/Switch";
export { type Tab, Tabs } from "../../app/src/ui/Tabs";
export { notifyError, Toaster } from "../../app/src/ui/toast";
export { setLocaleChoice } from "../../app/src/i18n";
export * as icons from "./icons.gen";
export { BotloftProvider, type BotloftProviderProps } from "./provider";
export { AVATAR_PALETTE, chat, makeBot, makeCrew, makeMessage } from "./scene";
Loading
Loading