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
58 changes: 58 additions & 0 deletions .design-sync/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Design sync notes

Botloft is an app, not a component library, so the design system is a small package built from the
app's own source: `.design-sync/pkg/` (`index.ts` re-exports the components, `styles.css` adds a
layout vocabulary to the app's stylesheet, `build.mjs` builds `dist/` with the app's Vite,
Tailwind and TypeScript). Nothing there reimplements a component.

## Running it

- `node .design-sync/pkg/build.mjs` first (cfg.buildCmd), then the converter with
`--node-modules ./app/node_modules --entry ./.design-sync/pkg/dist/index.js`.
- `app/node_modules` must be installed (`pnpm install` in `app/`).
- `.design-sync/node_modules` is a junction to `.ds-sync/node_modules` (gitignored, recreate per
clone). Without it `@types/react` is not found from `.design-sync/pkg` and React props collapse
([DTS_REACT]). On Windows: `node -e "require('fs').symlinkSync(require('path').resolve('.ds-sync/node_modules'), '.design-sync/node_modules', 'junction')"`.
- Render check and capture use `playwright@1.60.0` in `.ds-sync/` (matches the cached chromium-1223).

## Fixes made in this repo's setup

- Vite library mode inlines every asset, so the fonts would land in the CSS as base64 (1.1 MB, every
alphabet). `build.mjs` aliases `@fontsource/*.css` to an empty file and the Latin subsets ship as
files through cfg.extraFonts.
- `icons.gen.ts` is generated from the app's `lucide-react` imports and exported as `icons`
(a namespace, so the 94 icons do not become component cards). `lucide-react` is aliased to
`app/node_modules` because the file sits outside the app.
- `.d.ts` emit: tsc from `app/node_modules/typescript` (TypeScript 7; `bin/tsc` is not in its
exports map, so the path is built from `typescript/package.json`).
- Props that extend HTML attributes (`Button`, `TextField`, `TextArea`, `SelectField`) lose
`onClick`/`value`/`onChange` in extraction, and `BotStateBadge`, `ChiefBadge`, `Menu` reference
app types (`Bot`, `Crew`, `LucideIcon`). All six have hand-written bodies in cfg.dtsPropsFor.
Keep them in step with the app's source.
- Only Tailwind classes compiled into the CSS exist. The app's own set missed common layout
utilities (`max-w-sm`, arbitrary sizes), so `pkg/styles.css` adds a fixed vocabulary with
`@source inline(...)` and scans `.design-sync/previews`.
- Components speak the browser's language. Previews call `setLocaleChoice("en")` (exported from
the package) so cards are English on any machine.
- 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.

## 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.

## Re-sync risks

- `dtsPropsFor` bodies are copies of the app's props; a prop added in the app does not reach the
contract until the body is updated.
- The layout vocabulary in `pkg/styles.css` is a fixed list; the conventions header names its
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.
50 changes: 50 additions & 0 deletions .design-sync/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"projectId": "1f0e61c2-d3f4-4621-a902-66ef64e8cc5f",
"shape": "package",
"pkg": "@botloft/ui",
"globalName": "Botloft",
"buildCmd": "node .design-sync/pkg/build.mjs",
"entry": ".design-sync/pkg/dist/index.js",
"srcDir": "../../app/src",
"cssEntry": "dist/styles.css",
"extraFonts": [
"../../app/node_modules/@fontsource/ibm-plex-sans/latin-400.css",
"../../app/node_modules/@fontsource/ibm-plex-sans/latin-500.css",
"../../app/node_modules/@fontsource/ibm-plex-sans/latin-600.css",
"../../app/node_modules/@fontsource/ibm-plex-mono/latin-400.css",
"../../app/node_modules/@fontsource/ibm-plex-mono/latin-500.css"
],
"dtsPropsFor": {
"Button": "variant?: \"primary\" | \"secondary\" | \"ghost\" | \"danger\"; size?: \"sm\" | \"md\"; /** An icon from window.Botloft.icons, e.g. icons.Plus. */ icon?: React.ComponentType<{ size?: number; strokeWidth?: number }>; /** Shows only the icon; label becomes its accessible name and tooltip. */ label?: string; onClick?: React.MouseEventHandler<HTMLButtonElement>; type?: \"button\" | \"submit\"; title?: string; className?: string; id?: string; style?: React.CSSProperties; disabled?: boolean; autoFocus?: boolean; children?: React.ReactNode;",
"TextField": "label: string; hint?: string; /** Shows used / max and warns past it. */ max?: number; value?: string; defaultValue?: string; placeholder?: string; onChange?: React.ChangeEventHandler<HTMLInputElement>; type?: string; name?: string; className?: string; id?: string; style?: React.CSSProperties; disabled?: boolean; autoFocus?: boolean; ",
"TextArea": "label: string; hint?: string; /** Shows used / max and warns past it. */ max?: number; value?: string; defaultValue?: string; placeholder?: string; rows?: number; onChange?: React.ChangeEventHandler<HTMLTextAreaElement>; name?: string; className?: string; id?: string; style?: React.CSSProperties; disabled?: boolean; autoFocus?: boolean; ",
"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
},
"overrides": {
"Dialog": {
"cardMode": "single",
"viewport": "720x520"
},
"Confirm": {
"cardMode": "single",
"viewport": "720x480"
},
"BotAvatar": {
"cardMode": "column"
},
"SidePanel": {
"cardMode": "column"
},
"Toaster": {
"cardMode": "single",
"primaryStory": "ErrorNotice"
}
},
"readmeHeader": ".design-sync/conventions.md"
}
81 changes: 81 additions & 0 deletions .design-sync/conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Building with Botloft

Botloft is a desktop app where a crew of Claude Code bots works together. Each bot is a small flame
mascot in its own color. The look is quiet and warm: neutral surfaces, one orange accent, IBM Plex.

## Setup

- No provider is needed. Link `styles.css` and load `_ds_bundle.js`; components are on `window.Botloft`.
- **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
language. Call `Botloft.setLocaleChoice("en")` once before rendering to pin English
(`"pt-BR"` and `"es"` also exist).
- **Icons:** `Botloft.icons.<Name>` holds the lucide icons the app uses (`Plus`, `Pause`, `Settings`,
`Ellipsis`, `X`, `Archive`, `Trash2`, `RotateCw`, `FileText`, `Folder`, `Users`, `Crown`...). Pass one
as `icon` to `Button` and `Menu`, or render it: `<icons.FileText size={14} />`.

## Styling: Tailwind utilities on the design tokens

Style your own layout with Tailwind classes. **Only classes compiled into `_ds_bundle.css` exist**:
the app's own plus a fixed layout set. Arbitrary values like `h-[360px]` or `w-[37rem]` do NOT exist;
use `style={{ height: 360 }}` for one-off sizes.

| Family | Classes |
|---|---|
| Surfaces | `bg-canvas` (page), `bg-panel` (cards, dialogs), `bg-sunken` (wells, hover) |
| Text | `text-ink`, `text-ink-soft` (body), `text-muted` (hints), `text-accent` |
| Lines | `border border-line`, `border-line-strong` (controls), `divide-y divide-line` |
| State | `text-ok` ready, `text-work` working, `text-warn` waiting, `text-danger` error, `text-quiet` off; tints like `bg-accent/10`, `bg-danger/10` |
| Type | `text-xs` 12px, `text-sm` 13px, `text-base` 14px (body), `text-lg`...`text-2xl` for titles; `font-medium`, `font-semibold`; `font-mono` for paths and logs |
| Shape | `rounded-lg` (controls), `rounded-xl` (cards, callouts), `rounded-2xl` (dialogs), `rounded-full` (pills); `shadow-lift` for floating surfaces |
| Layout | `flex`, `grid grid-cols-1`...`grid-cols-12`, `gap-*`, `p-*`, `px-*`, `py-*`, `m*-*` on the 0.5-24 scale, `w-*`/`h-*`/`size-*` 0-96, `max-w-xs`...`max-w-7xl` |

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`.

## Components and their roles

- `BotAvatar` is the mascot: `color`, `size`, `mood` (idle, working, waiting, tired, sleeping;
it animates), `framed` for Botloft itself on its black square.
- `BotStateBadge` shows a bot's state with icon and word; `ChiefBadge` marks a crew's lead bot.
- `Button` variants: `primary` (one per view), `secondary`, `ghost` (toolbars), `danger`.
- `Dialog`, `Confirm` are modal (fixed, covering the window). `Callout` for notices in a page,
`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`.

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

## Voice

Plain words for people who are not programmers. Name bots by their name ("Scout is waiting for
you"), never "the agent" or "Claude". Never say "daemon"; say "Botloft" or "in the background".

## Example

```jsx
const { BotAvatar, BotStateBadge, Button, Callout, icons, setLocaleChoice } = window.Botloft;
setLocaleChoice("en");

function CrewCard() {
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" />
</div>
<Callout tone="warn" title="Scout needs your approval">
It wants to open arxiv.org for the first time.
</Callout>
</div>
);
}
```
137 changes: 137 additions & 0 deletions .design-sync/pkg/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Builds @botloft/ui into dist/: an ES module with the components (React
// left external), the app's compiled stylesheet with its fonts, and .d.ts
// files for the converter. Uses the app's own Vite, Tailwind and TypeScript
// from app/node_modules, so the output matches what the app ships.
//
// node .design-sync/pkg/build.mjs

import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const app = resolve(here, "../../app");
const dist = join(here, "dist");
const require = createRequire(join(app, "package.json"));
const load = async (name) => import(pathToFileURL(require.resolve(name)).href);

rmSync(dist, { recursive: true, force: true });

// The icons the app uses, as `icons.<Name>` (Button takes one as `icon`).
// Read from the app's imports so the set follows the app.
const iconNames = new Set();
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path);
else if (/\.tsx?$/.test(entry.name) && !/\.test\./.test(entry.name)) {
for (const match of readFileSync(path, "utf8").matchAll(/import \{([^}]*)\} from "lucide-react"/g)) {
for (const name of match[1].split(",")) {
const bare = name.replace(/^\s*type\s+/, "").trim();
if (/^[A-Z]\w*$/.test(bare) && !/^Lucide/.test(bare) && !/^\s*type\s/.test(name)) {
iconNames.add(bare);
}
}
}
}
}
};
walk(join(app, "src"));
const iconsModule = `// Generated by build.mjs from the app's lucide-react imports.\nexport { ${[...iconNames].sort().join(", ")} } from "lucide-react";\n`;
writeFileSync(join(here, "icons.gen.ts"), iconsModule);

const { build } = await load("vite");
const tailwindcss = (await load("@tailwindcss/vite")).default;
const react = (await load("@vitejs/plugin-react")).default;

// Library mode inlines every asset, so the fonts would land in the CSS as
// base64, every alphabet included. They are left out here and ship as
// files instead (cfg.extraFonts: the Latin subsets the app needs).
const noFonts = join(here, ".no-fonts.css");
writeFileSync(noFonts, "");

// The app is the root so Tailwind scans app/src and emits every utility
// the app uses: the design agent can then use the same class vocabulary.
try {
await build({
root: app,
configFile: false,
logLevel: "warn",
plugins: [react(), tailwindcss()],
resolve: {
alias: [
{ find: /^@fontsource\/.*\.css$/, replacement: noFonts },
// icons.gen.ts sits outside the app, beyond the reach of its node_modules.
{ find: /^lucide-react$/, replacement: join(app, "node_modules", "lucide-react") },
],
},
build: {
target: "chrome120",
outDir: dist,
emptyOutDir: true,
minify: false,
lib: {
entry: join(here, "index.ts"),
formats: ["es"],
fileName: () => "index.js",
cssFileName: "styles",
},
rollupOptions: {
external: (id) => /^react(-dom)?(\/|$)/.test(id),
},
},
});
} finally {
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`,
);
const tsconfig = join(here, "tsconfig.types.json");
writeFileSync(
tsconfig,
`${JSON.stringify(
{
extends: join(app, "tsconfig.json").replaceAll("\\", "/"),
compilerOptions: {
noEmit: false,
declaration: true,
emitDeclarationOnly: true,
rootDir: join(app, "src").replaceAll("\\", "/"),
outDir: join(dist, "types", "app").replaceAll("\\", "/"),
types: [],
typeRoots: [join(app, "node_modules", "@types").replaceAll("\\", "/")],
},
files: sources.map((source) => join(app, source).replaceAll("\\", "/")),
include: [],
},
null,
2,
)}\n`,
);
try {
const tsc = join(dirname(require.resolve("typescript/package.json")), "bin", "tsc");
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);
console.log(`built ${dist}`);
25 changes: 25 additions & 0 deletions .design-sync/pkg/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// 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.

import "./styles.css";

export { BOTLOFT_COLOR, BotAvatar, ListAvatar, 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 { Button, type ButtonVariant } from "../../app/src/ui/Button";
export { Callout } from "../../app/src/ui/Callout";
export { Choices } from "../../app/src/ui/Choices";
export { Confirm } from "../../app/src/ui/Confirm";
export { Details } from "../../app/src/ui/Details";
export { Dialog } from "../../app/src/ui/Dialog";
export { SelectField, TextArea, TextField } from "../../app/src/ui/Field";
export { Menu, type MenuItem } from "../../app/src/ui/Menu";
export { Select } from "../../app/src/ui/Select";
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";
9 changes: 9 additions & 0 deletions .design-sync/pkg/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@botloft/ui",
"version": "0.8.0",
"private": true,
"description": "Botloft's interface pieces, built from the app's own source for claude.ai/design. Not published.",
"type": "module",
"module": "dist/index.js",
"types": "dist/types/index.d.ts"
}
Loading
Loading