From 331a75de653966a39299b14c2ce8ae24ecb4066f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 13:01:01 -0700 Subject: [PATCH 1/2] feat(skills): ship an agent skill and usage rules in the package skills/react-ui holds a task-to-reference skill, consumer rules for agents, and component references generated from the built declarations at build and pack time. Generation fails on public components no group covers. typescript is pinned exactly because the references depend on its output. --- .gitignore | 1 + AGENTS.md | 6 +- README.md | 4 + bun.lock | 2 +- package.json | 6 +- scripts/agent-references.mjs | 481 +++++++++++++++++++++++++++++++++++ skills/react-ui/AGENTS.md | 14 + skills/react-ui/SKILL.md | 28 ++ 8 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 scripts/agent-references.mjs create mode 100644 skills/react-ui/AGENTS.md create mode 100644 skills/react-ui/SKILL.md diff --git a/.gitignore b/.gitignore index 0ee26fc3..91451235 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ build/ test-results/ playwright-report/ +skills/react-ui/references/ diff --git a/AGENTS.md b/AGENTS.md index 1efb035a..28035cae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,11 @@ arrives as props. - `src/theme.css`, `src/styles.css`: tokens and keyframes; the prebuilt sheet entry. - `stories/`: Storybook stories, kept out of `src/` so they never ship in `dist/`. -- `scripts/dep-guard.mjs`, `scripts/contrast-test.mjs`: the CI gates. +- `scripts/dep-guard.mjs`, `scripts/contrast-test.mjs`, `scripts/agent-references.mjs`: + the CI gates; the last is the build step that generates + `skills/react-ui/references/` and fails on a public module no group lists. +- `skills/react-ui/`: the agent skill that ships in the package. `SKILL.md` and + `AGENTS.md` are hand-written; `references/` is generated and git-ignored. - `scripts/test-dom-setup.ts`: happy-dom preload for unit tests (`bunfig.toml`). - `e2e/`: checks against the built package. diff --git a/README.md b/README.md index 07529823..4a809576 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,10 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", | `ThemeProvider` | Persists light, dark or system mode and presets | | `ThemeToggle` | Light, dark or system switch | +## For coding agents + +The package ships an agent skill: a short guide, usage rules and a props reference for every component. Point your agent at `node_modules/@corbits/react-ui/skills/react-ui` (start with `SKILL.md`), or copy that folder into your agent's skills directory. + ## Using with Interchange The host app owns the connection to the hub; components only take props. diff --git a/bun.lock b/bun.lock index ebc851ad..9b23d4f5 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "sonner": "^2.0.7", "storybook": "10.6.2", "tailwindcss": "^4.3.3", - "typescript": "^5.9.3", + "typescript": "5.9.3", "vite": "^7", }, "peerDependencies": { diff --git a/package.json b/package.json index 1a9d8cf5..1b6c9567 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "files": [ "dist", + "skills", "README.md", "LICENSE" ], @@ -72,7 +73,7 @@ "access": "public" }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && bun run build:css", + "build": "rm -rf dist && tsc -p tsconfig.build.json && bun run build:css && bun run build:references", "prepack": "bun run build", "typecheck": "tsc --noEmit", "lint": "oxlint", @@ -82,6 +83,7 @@ "test:e2e": "bun test ./e2e/exports.test.ts ./e2e/contrast.test.ts && playwright test", "check": "bun run typecheck && bun run lint && bun run format:check && bun run test", "build:css": "tailwindcss -i src/styles.css -o dist/styles.css && cp src/theme.css dist/theme.css", + "build:references": "node scripts/agent-references.mjs", "dep-guard": "node scripts/dep-guard.mjs", "contrast-test": "node scripts/contrast-test.mjs", "stories": "storybook dev -p 6006 --host 127.0.0.1 --no-open", @@ -115,7 +117,7 @@ "sonner": "^2.0.7", "storybook": "10.6.2", "tailwindcss": "^4.3.3", - "typescript": "^5.9.3", + "typescript": "5.9.3", "vite": "^7" }, "peerDependencies": { diff --git a/scripts/agent-references.mjs b/scripts/agent-references.mjs new file mode 100644 index 00000000..64208a59 --- /dev/null +++ b/scripts/agent-references.mjs @@ -0,0 +1,481 @@ +// Generates skills/react-ui/references/.md from the built declarations in +// dist/: props per exported component, the types they name, and the exported hooks +// and helpers, so an agent reads the real API instead of guessing it. The files are +// build output and ship in the package. A public module that is in no group fails +// the run, so a new component cannot ship undocumented. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const DIST = join(ROOT, "dist"); +const OUT = join(ROOT, "skills/react-ui/references"); +const MAX_SIZE = 16_000; + +const PKG = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); + +const GROUPS = { + layout: { + title: "app shell and page layout", + modules: [ + "blocks/login/auth-layout", + "ui/boot-screen", + "ui/card", + "ui/corbits-mark", + "ui/inspector-shell", + "ui/kind-card-grid", + "ui/library-page-header", + "ui/list-detail", + "ui/notifications-bell", + "ui/now-cards", + "ui/page-panel", + "ui/page-shell", + "ui/panel", + "ui/panel-tabs", + "ui/profile-card", + "ui/section", + "ui/settings-panel", + "ui/sidebar", + "ui/sidebar-item-row", + "ui/sidebar-panel", + "ui/sidebar-search", + "ui/theme-provider", + "ui/theme-toggle", + "ui/top-bar", + "ui/view-toggle", + ], + }, + controls: { + title: "buttons, inputs, filters, dialogs, menus and toasts", + modules: [ + "blocks/login/login-form", + "ui/bulk-action-bar", + "ui/button", + "ui/checkbox", + "ui/command-palette", + "ui/confirm-button", + "ui/dialog", + "ui/file-input", + "ui/filter-bar", + "ui/filter-chip", + "ui/horizontal-stepper", + "ui/input", + "ui/intake-form", + "ui/menu", + "ui/mic-permission-dialog", + "ui/run-now-button", + "ui/select", + "ui/selection-checkbox", + "ui/switch", + "ui/tabs", + "ui/textarea", + "ui/toast", + "ui/tooltip", + ], + }, + "data-display": { + title: "tables, stats, charts, status, avatars, empty and loading states", + modules: [ + "ui/animated-number", + "ui/avatar", + "ui/badge", + "ui/bar-chart", + "ui/block-card", + "ui/category-bars", + "ui/chart-frame", + "ui/csv-table", + "ui/empty-state", + "ui/gate-block", + "ui/live-run-banner", + "ui/live-status-line", + "ui/progress-checklist", + "ui/quote-card", + "ui/rich-empty-state", + "ui/shimmer-text", + "ui/skeleton", + "ui/sortable-table", + "ui/sparkline", + "ui/stat-grid", + "ui/status-dot", + "ui/step-list", + "ui/table", + "ui/time-series-chart", + "ui/token-mosaic", + "ui/trace-waterfall", + ], + }, + conversation: { + title: "conversation container, thread and status indicators", + modules: [ + "ui/conversation", + "ui/conversation-divider", + "ui/conversation-earlier", + "ui/conversation-map", + "ui/conversation-thread", + "ui/thinking-indicator", + "ui/thinking-label", + "ui/thinking-mark", + "ui/typing-indicator", + ], + }, + messages: { + title: "messages, reasoning, activity and tool calls", + modules: [ + "ui/activity", + "ui/message", + "ui/message-actions", + "ui/message-fold", + "ui/message-parts", + "ui/message-reactions", + "ui/message-replies", + "ui/reasoning", + "ui/tool", + "ui/tool-approval", + "ui/tool-group", + ], + }, + composer: { + title: "prompt input, dictation and tool picker", + modules: [ + "ui/dictation-status-line", + "ui/mic-button", + "ui/prompt-input", + "ui/prompt-input-toolbar", + "ui/tool-picker", + "ui/voice-waveform", + ], + }, + media: { + title: "artifacts, attachments, media and sources", + modules: [ + "ui/artifact-body", + "ui/artifact-notice", + "ui/attachment-drop-zone", + "ui/attachments", + "ui/compare-body", + "ui/dither-canvas", + "ui/embed-body", + "ui/generated-images", + "ui/media-viewer", + "ui/provider-mark", + "ui/research-body", + "ui/sources", + ], + }, + "hooks-input": { + title: + "hooks for prompts, attachments, files, copy, selection and popovers", + modules: [ + "hooks/use-attachments", + "hooks/use-command-palette-navigation", + "hooks/use-controllable-state", + "hooks/use-copy", + "hooks/use-dismissable-popover", + "hooks/use-file-drop", + "hooks/use-list-selection", + "hooks/use-media-viewer", + "hooks/use-prompt-input", + ], + }, + "hooks-view": { + title: "hooks for history, reading position, resizing, tabs and motion", + modules: [ + "hooks/use-history-window", + "hooks/use-prefers-reduced-motion", + "hooks/use-reading-position", + "hooks/use-resizable", + "hooks/use-scroll-current-into-view", + "hooks/use-tab-drag", + "hooks/use-tabs", + ], + }, + helpers: { + title: "helpers for messages, attachments, charts and theme", + modules: [ + "lib/artifact", + "lib/attachment-limits", + "lib/chart-palette", + "lib/conversation-activity", + "lib/media-kind", + "lib/prompt-tokens", + "lib/relative-time", + "lib/theme", + ], + }, + "helpers-runs": { + title: "helpers for workflow runs, metrics and now items", + modules: [ + "lib/metrics", + "lib/now-item", + "lib/workflow-display-flow", + "lib/workflow-run", + "lib/workflow-run-error", + "lib/workflow-run-progress", + ], + }, +}; + +const moduleGroup = new Map( + Object.entries(GROUPS).flatMap(([group, { modules }]) => + modules.map((module) => [module, group]), + ), +); + +const SUBPATHS = Object.keys(PKG.exports) + .filter((entry) => entry.startsWith("./ui/")) + .map((entry) => entry.slice(2)); + +function publicModules() { + const barrel = readFileSync(join(DIST, "index.d.ts"), "utf8"); + const reExported = [...barrel.matchAll(/from "\.\/(.+)\.js"/g)].map( + (match) => match[1], + ); + return [...new Set([...reExported, ...SUBPATHS])].sort(); +} + +const printFlags = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + ts.TypeFormatFlags.WriteArrowStyleSignature; + +function firstSentence(text) { + const flat = text + .replace(/\s+/g, " ") + .replace(/\b(e\.g|i\.e)\./g, "$1") + .trim(); + const end = flat.search(/[.!?](\s|$)/); + return end === -1 ? flat : flat.slice(0, end + 1); +} + +function shortType(text) { + return text + .replace(/import\("[^"]*"\)\./g, "") + .replace(/\bReact\./g, "") + .replace( + /string \| number \| bigint \| boolean \| ReactElement<[^|]*?> \| Iterable \| ReactPortal \| Promise<[^|]*?> \| null \| undefined/g, + "ReactNode", + ) + .replace(/\s+/g, " ") + .trim(); +} + +function declarationText(declaration) { + return declaration + .getText() + .replace(/^export /, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/import\("[^"]*"\)\./g, "") + .replace(/\bReact\./g, "") + .replace(/\n\s*\n/g, "\n") + .replace(/[ \t]+\n/g, "\n"); +} + +const isComponentModule = (module) => /^(ui|blocks)\//.test(module); + +function describeModules(modules) { + const files = modules.map((module) => join(DIST, `${module}.d.ts`)); + const program = ts.createProgram(files, { + noEmit: true, + skipLibCheck: true, + strict: true, + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ES2022, + }); + const checker = program.getTypeChecker(); + const isOwn = (declaration) => + declaration.getSourceFile().fileName.startsWith(DIST); + const typeText = (type) => + shortType(checker.typeToString(type, undefined, printFlags)); + + function propsOf(type, location) { + let native = false; + const props = []; + for (const symbol of checker.getPropertiesOfType(type)) { + const declaration = symbol.declarations?.[0]; + if (!declaration || !isOwn(declaration)) { + native = true; + continue; + } + props.push({ + name: symbol.name, + optional: (symbol.flags & ts.SymbolFlags.Optional) !== 0, + type: typeText( + checker.getTypeOfSymbolAtLocation(symbol, location), + ).replace(/ \| undefined$/, ""), + doc: firstSentence( + ts.displayPartsToString(symbol.getDocumentationComment(checker)), + ), + }); + } + return { props, native }; + } + + const types = new Map(); + const described = modules.map((module, index) => { + const file = program.getSourceFile(files[index]); + const moduleSymbol = file && checker.getSymbolAtLocation(file); + const components = []; + const functions = []; + for (const exported of moduleSymbol + ? checker.getExportsOfModule(moduleSymbol) + : []) { + const symbol = + exported.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(exported) + : exported; + const declaration = symbol.declarations?.[0]; + if (!declaration || !isOwn(declaration)) continue; + if ( + symbol.flags & + (ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias) + ) { + if (!types.has(exported.name)) { + types.set(exported.name, declarationText(declaration)); + } + continue; + } + if (!(symbol.flags & (ts.SymbolFlags.Function | ts.SymbolFlags.Variable))) + continue; + const type = checker.getTypeOfSymbolAtLocation(symbol, declaration); + const signature = type.getCallSignatures()[0]; + const summary = firstSentence( + ts.displayPartsToString(symbol.getDocumentationComment(checker)), + ); + if (!/^[A-Z]/.test(exported.name)) { + if (signature) { + functions.push({ + name: exported.name, + summary, + signature: shortType( + checker.signatureToString(signature, undefined, printFlags), + ), + }); + } + continue; + } + const propsParam = signature?.getParameters()[0]; + const propsType = propsParam + ? checker.getTypeOfSymbolAtLocation(propsParam, declaration) + : undefined; + const { props, native } = propsType + ? propsOf(propsType, declaration) + : { props: [], native: false }; + components.push({ name: exported.name, summary, props, native }); + } + return { module, components, functions }; + }); + return { described, types }; +} + +const identifiers = (text) => text.match(/\b[A-Z][A-Za-z0-9]*\b/g) ?? []; + +function typesFor(entries, types) { + const wanted = new Map(); + const queue = entries.flatMap((entry) => [ + ...entry.components.flatMap((component) => + component.props.flatMap((prop) => identifiers(prop.type)), + ), + ...entry.functions.flatMap((fn) => identifiers(fn.signature)), + ]); + while (queue.length > 0) { + const name = queue.pop(); + const text = types.get(name); + if (text === undefined || wanted.has(name)) continue; + wanted.set(name, text); + queue.push(...identifiers(text)); + } + return [...wanted].sort(([a], [b]) => a.localeCompare(b)); +} + +function render(group, entries, types, withPropDocs) { + const subpaths = SUBPATHS.map((subpath) => `\`${subpath}\``).join(", "); + const lines = [ + "", + "", + `# react-ui: ${GROUPS[group].title}`, + "", + `Props per exported component. \`?\` marks optional. \`+native\` means the component also accepts the native props of the element it renders (\`className\`, \`onClick\`, ...). Import from \`@corbits/react-ui\` unless the module is a package subpath (${subpaths}), which imports from \`@corbits/react-ui/\`.`, + "", + ]; + for (const { module, components, functions } of entries) { + lines.push(`## ${module}`, ""); + for (const component of components) { + const tag = component.native ? " +native" : ""; + lines.push( + `### ${component.name}${tag}${component.summary ? ` — ${component.summary}` : ""}`, + ); + for (const prop of component.props) { + const doc = withPropDocs && prop.doc ? ` — ${prop.doc}` : ""; + lines.push( + `${prop.name}${prop.optional ? "?" : ""}: ${prop.type}${doc}`, + ); + } + lines.push(""); + } + for (const fn of functions) { + lines.push( + `### ${fn.name}${fn.summary ? ` — ${fn.summary}` : ""}`, + `${fn.name}${fn.signature}`, + "", + ); + } + } + const named = typesFor(entries, types); + if (named.length > 0) { + lines.push("## Types", "", "```ts"); + for (const [, text] of named) lines.push(text); + lines.push("```", ""); + } + return `${lines.join("\n").trimEnd()}\n`; +} + +if (!existsSync(join(DIST, "index.d.ts"))) { + console.error("dist/ is missing; run the build first."); + process.exit(1); +} + +const { described, types } = describeModules(publicModules()); +const documented = described.filter( + ({ module, components, functions }) => + components.length > 0 || + (!isComponentModule(module) && functions.length > 0), +); +const ungrouped = documented + .map(({ module }) => module) + .filter((module) => !moduleGroup.has(module)); +if (ungrouped.length > 0) { + console.error( + `Public modules in no group (add them to GROUPS in scripts/agent-references.mjs):\n${ungrouped.map((module) => ` ${module}`).join("\n")}`, + ); + process.exit(1); +} +const stale = [...moduleGroup.keys()].filter( + (module) => !documented.some((entry) => entry.module === module), +); +if (stale.length > 0) { + console.error( + `Grouped modules that export nothing public to document:\n${stale.map((module) => ` ${module}`).join("\n")}`, + ); + process.exit(1); +} + +mkdirSync(OUT, { recursive: true }); +for (const group of Object.keys(GROUPS)) { + const entries = documented.filter( + ({ module }) => moduleGroup.get(module) === group, + ); + let content = render(group, entries, types, true); + if (content.length > MAX_SIZE) content = render(group, entries, types, false); + if (content.length > MAX_SIZE) { + console.error( + `${group}.md is ${content.length} characters; split the group.`, + ); + process.exit(1); + } + const path = join(OUT, `${group}.md`); + writeFileSync(path, content); + console.log(`${path}: ${content.length} characters`); +} diff --git a/skills/react-ui/AGENTS.md b/skills/react-ui/AGENTS.md new file mode 100644 index 00000000..0119bd75 --- /dev/null +++ b/skills/react-ui/AGENTS.md @@ -0,0 +1,14 @@ +# Using @corbits/react-ui + +Rules for an agent writing screens with this package. Props for every component are in [references/](references/); the task-to-reference map is in [SKILL.md](SKILL.md). + +1. **Reuse first.** Use the component that covers the job, and compose from its exported parts (`Message` with `MessageContent`, `PromptInput` with `PromptInputSubmit`). +2. **Props are the contract.** Pass data in as props and handle events through callbacks; fetching and subscriptions stay in the host. Use only props the reference lists. +3. **Theme once.** Import `@corbits/react-ui/styles.css` once at the app root, before your CSS, and mount `ThemeProvider` near the root. Apps that build their own Tailwind sheet import `@corbits/react-ui/theme.css`. +4. **Style with tokens.** Use the token utilities (`bg-card`, `text-muted-foreground`, `border-border`) and the `--radius`, `--chart-*` values. To restyle, override tokens in your CSS. Dark mode is a `.dark` class on an ancestor, so components stay free of mode checks. +5. **Wording is configuration.** Component text comes from its text props (`labels`, `label`, `title`, `caption`, children), fed from one config module, and screen copy lives there too. +6. **Keep the accessibility the library gives you.** Give every icon-only control an `aria-label`, pair colour with text or an icon, use `Button`, `Input` and `Select` for interactive elements, and leave focus styles and reduced-motion handling as shipped. +7. **One action bar, one way per action.** Each screen has a single place for its primary actions (`PageShell`, `TopBar`; `MessageActions` or `BulkActionBar` for rows), and each action is one control. +8. **Import from the root.** `@corbits/react-ui` covers everything except modules the package lists as their own subpath in `exports` (optional-peer modules); those import from that subpath and need their peer installed. Paths in `exports` are the public surface. +9. **Server Components.** Stateful modules carry `"use client"`; import them directly. +10. **Missing component.** Say so and compose the closest ones. diff --git a/skills/react-ui/SKILL.md b/skills/react-ui/SKILL.md new file mode 100644 index 00000000..98874f46 --- /dev/null +++ b/skills/react-ui/SKILL.md @@ -0,0 +1,28 @@ +--- +name: react-ui +description: Build or change a React interface with @corbits/react-ui, the component library for agent and workflow surfaces (chat, tool calls, approvals, runs, artifacts, charts, layout, controls). Use when a task adds or edits screens in an app that depends on @corbits/react-ui, or asks which component to use. +--- + +# @corbits/react-ui + +Props-in, markup-out React components for agent products, styled by one Tailwind v4 token theme. Components never fetch; the host passes data as props. + +Rules for writing screens (theme, wording, accessibility, actions, imports) are in [AGENTS.md](AGENTS.md); read it first. + +## Choose a component + +Find the task, open that reference for props, and use what is there before writing markup. Each reference lists every exported component, hook or helper with its props or signature and the types they name; check it before using a prop. + +| Task | Reference | +| ----------------------------------------------------------------------------- | ------------------------------------------ | +| Conversation container, thread, typing and status indicators | [conversation](references/conversation.md) | +| Messages, reasoning, tool calls, tool approval, activity | [messages](references/messages.md) | +| Prompt box, dictation, tool picker | [composer](references/composer.md) | +| Artifacts, attachments, media viewer, sources, generated images | [media](references/media.md) | +| Page shell, top bar, sidebar, panels, settings, theme provider | [layout](references/layout.md) | +| Buttons, inputs, selects, tabs, filters, dialogs, menus, toasts | [controls](references/controls.md) | +| Tables, charts, stats, badges, status, empty and loading states, run progress | [data-display](references/data-display.md) | +| Hooks for prompts, attachments, files, copy, selection, popovers | [hooks-input](references/hooks-input.md) | +| Hooks for history, reading position, resizing, tabs, reduced motion | [hooks-view](references/hooks-view.md) | +| Message, attachment, chart and theme helpers (`groupActivity`, ...) | [helpers](references/helpers.md) | +| Workflow run, metrics and now-item helpers | [helpers-runs](references/helpers-runs.md) | From 81613cbcdc50695f7434daf05ad9280c42fa3e40 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 31 Oct 2026 13:31:58 -0700 Subject: [PATCH 2/2] fix(skills): keep the references build quiet on stdout --- scripts/agent-references.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/agent-references.mjs b/scripts/agent-references.mjs index 64208a59..58f752c5 100644 --- a/scripts/agent-references.mjs +++ b/scripts/agent-references.mjs @@ -475,7 +475,5 @@ for (const group of Object.keys(GROUPS)) { ); process.exit(1); } - const path = join(OUT, `${group}.md`); - writeFileSync(path, content); - console.log(`${path}: ${content.length} characters`); + writeFileSync(join(OUT, `${group}.md`), content); }