diff --git a/.changeset/move-ui-library-in-repo.md b/.changeset/move-ui-library-in-repo.md new file mode 100644 index 000000000..c33c30fae --- /dev/null +++ b/.changeset/move-ui-library-in-repo.md @@ -0,0 +1,7 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Consume the UI component library from the in-repo `@workflowbuilder/ui` (Base UI) instead of the published `@synergycodes/overflow-ui`. + +The SDK previously bundled `@synergycodes/overflow-ui@1.0.0-beta.27` (built on MUI / Mantine / Emotion / Floating UI). It now bundles the in-repo `@workflowbuilder/ui@1.0.0-beta.28`, rebuilt on [Base UI](https://base-ui.com/), together with `@base-ui/react` (both are inlined into the SDK bundle, so SDK consumers gain no new peer dependency). Bundled component visuals and interaction details change accordingly; the SDK's own public API surface is unchanged. diff --git a/.gitignore b/.gitignore index 3c2d2a3a0..f0626771d 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,8 @@ CLAUDE.local.md # and is copied into this dir by a tiny Astro integration after typedoc # generation (see astro.config.mjs). apps/docs/src/content/docs/api/ + +# UI Library props + CSS-variable data, generated from @workflowbuilder/ui by +# apps/docs/scripts/generate-ui-api.mjs (TypeDoc + CSS extraction) on every +# docs build / dev. Source of truth is the library, so keep it out of git. +apps/docs/src/generated/ diff --git a/CLAUDE.md b/CLAUDE.md index 6cf746e34..5b41e7f98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,21 +6,21 @@ Visual workflow editor SDK (React) with a reference backend and Temporal-based e Three onboarding paths (A installs from npm; B, C run the repo locally). README "Get started" covers all three. Path A ("Embed the SDK") installs `@workflowbuilder/sdk` from npm; the README has install + a minimal snippet, and the full guide lives in the [docs site](https://www.workflowbuilder.io/docs/get-started/quick-start/wb-as-react-component/). -| Command | Path | What it does | -| ---------------------------- | ---- | --------------------------------------------------------------------------- | -| `pnpm preflight` | B/C | Verify Node / pnpm / Docker / ports / `.env` files. Add `--json` for agents | -| `pnpm dev` / `pnpm dev:demo` | B | Demo (UI only, port 4200). No backend, no Docker | -| `pnpm infra:up` | C | Start Postgres + Temporal in Docker. Required before backend/worker | -| `pnpm -F backend db:migrate` | C | Apply Drizzle migrations. First run, or after schema changes | -| `pnpm dev:ai-studio` | C | Full stack: infra + backend (3001) + worker + AI Studio frontend (4201) | -| `pnpm dev:backend` | C | Backend only (debug). Needs infra up | -| `pnpm dev:worker` | C | Execution worker only (debug). Needs infra up | -| `pnpm infra:down` | C | Stop the Docker stack | -| `pnpm dev:docs` | - | Docs site (Astro + Starlight) | -| `pnpm build:lib` | - | Build the SDK package (`packages/sdk`) | -| `pnpm build` | - | Build the demo app | -| `pnpm test` | - | Run tests in `packages/sdk` and `packages/execution-core` | -| `pnpm check` | - | Lint + typecheck + format + knip | +| Command | Path | What it does | +| ---------------------------- | ---- | --------------------------------------------------------------------------------------- | +| `pnpm preflight` | B/C | Verify Node / pnpm / Docker / ports / `.env` files. Add `--json` for agents | +| `pnpm dev` / `pnpm dev:demo` | B | Demo (UI only, port 4200). No backend, no Docker | +| `pnpm infra:up` | C | Start Postgres + Temporal in Docker. Required before backend/worker | +| `pnpm -F backend db:migrate` | C | Apply Drizzle migrations. First run, or after schema changes | +| `pnpm dev:ai-studio` | C | Full stack: infra + backend (3001) + worker + AI Studio frontend (4201) | +| `pnpm dev:backend` | C | Backend only (debug). Needs infra up | +| `pnpm dev:worker` | C | Execution worker only (debug). Needs infra up | +| `pnpm infra:down` | C | Stop the Docker stack | +| `pnpm dev:docs` | - | Docs site (Astro + Starlight) | +| `pnpm build:lib` | - | Build the publishable chain: `ui-tokens` -> `ui` -> SDK (`build:ui` does the first two) | +| `pnpm build` | - | Build the demo app | +| `pnpm test` | - | Run tests in `packages/sdk` and `packages/execution-core` | +| `pnpm check` | - | Lint + typecheck + format + knip | Path B is UI-only and does not need Docker. Path C requires `pnpm infra:up` before backend/worker can start, and `db:migrate` on the first run. @@ -52,6 +52,8 @@ apps/ tools/ - @workflow-builder/tools workspace (decision-log collector, lint-staged config) packages/ sdk/ - @workflowbuilder/sdk public package (WorkflowBuilder compound component, plugin API, components) + ui/ - @workflowbuilder/ui published component library (Base UI), consumed by sdk/demo/ai-studio + tokens/ - @workflowbuilder/ui-tokens private design-token build (style-dictionary), feeds packages/ui execution-core/ - Pure topological graph runner + node executor registry types/ - Shared TypeScript types ``` @@ -62,20 +64,22 @@ Where to put a new script: root `tools/` for pure-Node bootstrap (runs before an Each workspace has its own context. Read the relevant file before extending a workspace. -| Workspace | Authoritative docs | -| ------------------------- | ----------------------------------- | -| `packages/sdk` | `packages/sdk/README.md` | -| `packages/execution-core` | `packages/execution-core/README.md` | -| `apps/demo` | `apps/demo/CLAUDE.md` | -| `apps/ai-studio` | `apps/ai-studio/README.md` | -| `apps/backend` | `apps/backend/README.md` | -| `apps/execution-worker` | `apps/execution-worker/README.md` | +| Workspace | Authoritative docs | +| ------------------------- | ------------------------------------------------------- | +| `packages/sdk` | `packages/sdk/README.md` | +| `packages/ui` | `packages/ui/README.md` (+ `packages/ui/css-layers.md`) | +| `packages/execution-core` | `packages/execution-core/README.md` | +| `apps/demo` | `apps/demo/CLAUDE.md` | +| `apps/ai-studio` | `apps/ai-studio/README.md` | +| `apps/backend` | `apps/backend/README.md` | +| `apps/execution-worker` | `apps/execution-worker/README.md` | ## Types & Aliases Shared types: `packages/types/` (imported as `@workflow-builder/types/*`). Icons: `apps/icons/` (imported as `@workflow-builder/icons`). SDK: `packages/sdk/` (imported as `@workflowbuilder/sdk`). +UI: `packages/ui/` (imported as `@workflowbuilder/ui`; styles via `@workflowbuilder/ui/styles.css`, `/index.css`, `/tokens.css`). ## Local Infrastructure @@ -130,7 +134,7 @@ If you're new to this repo and want to build your own consumer app or POC, follo ### Releasing `@workflowbuilder/sdk` -The SDK is the only npm-published workspace; everything else under `apps/` and `packages/` is private (and listed under `ignore` in `.changeset/config.json`). +Two workspaces are npm-published: `@workflowbuilder/sdk` and `@workflowbuilder/ui` (the component library, built on Base UI). Everything else under `apps/` and `packages/` is private (`@workflowbuilder/ui-tokens` is private too) and listed under `ignore` in `.changeset/config.json`. Because there are now two publishable packages, the release tag scheme below (single-package `v*`) still needs migrating to scoped tags (`@workflowbuilder/sdk@X.Y.Z`) before publishing `@workflowbuilder/ui` from this repo - see § "Tag format" and `packages/sdk/RELEASE.md`. **Commit format is enforced.** Every commit goes through `commitlint` via the `commit-msg` husky hook — Conventional Commits format only (`(): `, types from `feat / fix / perf / refactor / docs / test / chore / build / ci / style / revert`). Bad messages are rejected before they land in git history. @@ -155,7 +159,7 @@ The SDK is the only npm-published workspace; everything else under `apps/` and ` 4. GitHub Action triggered by the tag runs lint + typecheck + test + `pnpm publish --provenance` (authenticated via npm Trusted Publisher / OIDC, no `NPM_TOKEN` stored anywhere) + creates a GitHub Release. 5. Sync back: `git checkout main && git merge release && git push` so main picks up the bumped version + clean `.changeset/`. -Tag format follows the ng-diagram convention (single-package monorepo, `v*` regex). If we ever publish a second package we'll migrate to scoped (`@workflowbuilder/sdk@X.Y.Z`) — ~1h of work, see `packages/sdk/RELEASE.md` § "Why these decisions". +Tag format currently follows the ng-diagram convention (single-package monorepo, `v*` regex). A second publishable package (`@workflowbuilder/ui`) now exists, so this needs migrating to scoped tags (`@workflowbuilder/sdk@X.Y.Z`) — ~1h of work, see `packages/sdk/RELEASE.md` § "Why these decisions". Canonical procedure with edge cases and rollback: [`packages/sdk/RELEASE.md`](packages/sdk/RELEASE.md). diff --git a/apps/ai-studio/package.json b/apps/ai-studio/package.json index 9f3e7b85c..242a644e8 100644 --- a/apps/ai-studio/package.json +++ b/apps/ai-studio/package.json @@ -13,11 +13,12 @@ "test:watch": "vitest --passWithNoTests" }, "dependencies": { + "@base-ui/react": "catalog:", "@jsonforms/core": "^3.4.1", "@jsonforms/react": "^3.4.1", "@phosphor-icons/react": "^2.1.7", - "@synergycodes/overflow-ui": "1.0.0-beta.27", "@workflow-builder/types": "workspace:*", + "@workflowbuilder/ui": "workspace:*", "@xyflow/react": "catalog:", "clsx": "^2.1.1", "immer": "^10.1.1", diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx index 2e93bae8e..be95533b7 100644 --- a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx +++ b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx @@ -1,5 +1,5 @@ -import { NavButton } from '@synergycodes/overflow-ui'; import { Icon, getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk'; +import { NavButton } from '@workflowbuilder/ui'; import clsx from 'clsx'; import { useCallback } from 'react'; diff --git a/apps/ai-studio/vite.config.mts b/apps/ai-studio/vite.config.mts index f1e72892e..d2b9039ac 100644 --- a/apps/ai-studio/vite.config.mts +++ b/apps/ai-studio/vite.config.mts @@ -5,20 +5,12 @@ import { defineConfig } from 'vite'; import svgr from 'vite-plugin-svgr'; export default defineConfig(() => { - const shouldUseLocalOverflowUI = process.env.LOCAL_OVERFLOW_UI === 'true'; - const sdkDirectory = path.resolve(import.meta.dirname, '../../packages/sdk'); return { plugins: [svgr(), react()], resolve: { alias: [ - ...(shouldUseLocalOverflowUI - ? Object.entries(getLocalOverflowUIAliases()).map(([find, replacement]) => ({ - find, - replacement, - })) - : []), { find: /^@workflowbuilder\/sdk\/style\.css$/, replacement: path.resolve(sdkDirectory, 'src/index.css'), @@ -27,14 +19,6 @@ export default defineConfig(() => { find: /^@workflowbuilder\/sdk$/, replacement: path.resolve(sdkDirectory, 'src/index.ts'), }, - // overflow-ui doesn't expose ./dist/index.css via package.json exports - { - find: 'overflow-ui-css', - replacement: path.resolve( - sdkDirectory, - 'node_modules/@synergycodes/overflow-ui/dist/index.css', - ), - }, // SDK source files use @/ to refer to their own root (packages/sdk/src/...). // AI Studio files do not use @/ (they import via @workflowbuilder/sdk subpaths), // so it's safe to point @/ at the SDK root for cross-package alias parity. @@ -56,12 +40,3 @@ export default defineConfig(() => { }, }; }); - -function getLocalOverflowUIAliases(): Record { - const distribution = path.resolve(import.meta.dirname, '../../../overflow-ui/packages/ui/dist'); - - return { - '@synergycodes/overflow-ui/tokens.css': path.join(distribution, 'tokens.css'), - '@synergycodes/overflow-ui': path.join(distribution, 'overflow-ui.js'), - }; -} diff --git a/apps/backend/README.md b/apps/backend/README.md index 018180252..f6c03f0e7 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -79,7 +79,6 @@ All scripts run from the monorepo root. Grouped by purpose: | `dev:backend` | Backend only (Hono server with `tsx watch`) | | `dev:worker` | Execution worker only (Temporal worker with `tsx watch`) | | `dev:docs` | Docs site (Astro) | -| `dev:local` | Demo frontend with `LOCAL_OVERFLOW_UI=true` (linked local overflow-ui) | ### Infra (Docker lifecycle) diff --git a/apps/demo/package.json b/apps/demo/package.json index db77ad2cd..01e95b163 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -16,11 +16,12 @@ "test:watch": "vitest" }, "dependencies": { + "@base-ui/react": "catalog:", "@jsonforms/core": "^3.4.1", "@jsonforms/react": "^3.4.1", "@microsoft/clarity": "^1.0.0", "@phosphor-icons/react": "^2.1.7", - "@synergycodes/overflow-ui": "1.0.0-beta.27", + "@workflowbuilder/ui": "workspace:*", "@xyflow/react": "catalog:", "ajv": "catalog:", "clsx": "^2.1.1", diff --git a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx index 96b090494..05fb08080 100644 --- a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx +++ b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx @@ -1,6 +1,6 @@ -import { NodeDescription, NodeIcon, NodePanel } from '@synergycodes/overflow-ui'; import { Icon, defineNodeTemplate, getHandleId, statusOptions } from '@workflowbuilder/sdk'; import type { NodeDataProperties, WorkflowNodeTemplateProps } from '@workflowbuilder/sdk'; +import { NodeDescription, NodeIcon, NodePanel } from '@workflowbuilder/ui'; import { Handle, Position } from '@xyflow/react'; import clsx from 'clsx'; import { memo, useMemo } from 'react'; diff --git a/apps/demo/src/app/plugins/help/components/app-bar/get-app-bar-button.tsx b/apps/demo/src/app/plugins/help/components/app-bar/get-app-bar-button.tsx index f946a21f0..9f35d0abc 100644 --- a/apps/demo/src/app/plugins/help/components/app-bar/get-app-bar-button.tsx +++ b/apps/demo/src/app/plugins/help/components/app-bar/get-app-bar-button.tsx @@ -1,6 +1,6 @@ -import { NavButton } from '@synergycodes/overflow-ui'; import { Icon } from '@workflowbuilder/sdk'; import type { TranslationKey, WBIcon } from '@workflowbuilder/sdk'; +import { NavButton } from '@workflowbuilder/ui'; import i18n from 'i18next'; import { openNoAccessModal } from '../../functions/open-no-access-modal'; diff --git a/apps/demo/src/app/plugins/help/components/footer-support-button.tsx b/apps/demo/src/app/plugins/help/components/footer-support-button.tsx index 8acf4a4c6..b6498730a 100644 --- a/apps/demo/src/app/plugins/help/components/footer-support-button.tsx +++ b/apps/demo/src/app/plugins/help/components/footer-support-button.tsx @@ -1,4 +1,4 @@ -import { Button } from '@synergycodes/overflow-ui'; +import { Button } from '@workflowbuilder/ui'; import { useTranslation } from 'react-i18next'; import { openHelpModal } from '../functions/open-help-modal'; diff --git a/apps/demo/src/app/plugins/help/functions/add-items-to-dots.tsx b/apps/demo/src/app/plugins/help/functions/add-items-to-dots.tsx index 35096a5fb..bca5040e0 100644 --- a/apps/demo/src/app/plugins/help/functions/add-items-to-dots.tsx +++ b/apps/demo/src/app/plugins/help/functions/add-items-to-dots.tsx @@ -1,5 +1,5 @@ -import { type MenuItemProps } from '@synergycodes/overflow-ui'; import { Icon } from '@workflowbuilder/sdk'; +import { type MenuItemProps } from '@workflowbuilder/ui'; import i18n from 'i18next'; import { openNoAccessModal } from './open-no-access-modal'; diff --git a/apps/demo/src/app/plugins/help/modals/sales-contact/sales-contact.tsx b/apps/demo/src/app/plugins/help/modals/sales-contact/sales-contact.tsx index 1c778f81e..1c8b4b4a7 100644 --- a/apps/demo/src/app/plugins/help/modals/sales-contact/sales-contact.tsx +++ b/apps/demo/src/app/plugins/help/modals/sales-contact/sales-contact.tsx @@ -1,5 +1,5 @@ import { LinkedinLogo, PaperPlaneRight } from '@phosphor-icons/react'; -import { Avatar, Button } from '@synergycodes/overflow-ui'; +import { Avatar, Button } from '@workflowbuilder/ui'; import clsx from 'clsx'; import styles from './sales-contact.module.css'; diff --git a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx index c12f452fc..7472ffbca 100644 --- a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx +++ b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx @@ -1,5 +1,5 @@ -import { NavButton } from '@synergycodes/overflow-ui'; import { Icon, useStore } from '@workflowbuilder/sdk'; +import { NavButton } from '@workflowbuilder/ui'; import { useTranslation } from 'react-i18next'; import { redo, undo, useUndoRedoStore } from '../../stores/use-undo-redo-store'; diff --git a/apps/demo/vite.config.mts b/apps/demo/vite.config.mts index f7a6a5b55..2f1aa453a 100644 --- a/apps/demo/vite.config.mts +++ b/apps/demo/vite.config.mts @@ -16,7 +16,6 @@ import { export default defineConfig(({ mode }) => { const isProduction = mode === 'production'; const isAnalyze = process.env.ANALYZE === 'true'; - const shouldUseLocalOverflowUI = process.env.LOCAL_OVERFLOW_UI === 'true'; const plugins: PluginOption[] = []; @@ -52,12 +51,6 @@ export default defineConfig(({ mode }) => { plugins, resolve: { alias: [ - ...(shouldUseLocalOverflowUI - ? Object.entries(getLocalOverflowUIAliases()).map(([find, replacement]) => ({ - find, - replacement, - })) - : []), { find: /^@workflowbuilder\/sdk\/style\.css$/, replacement: path.resolve(sdkDirectory, 'src/index.css'), @@ -66,14 +59,6 @@ export default defineConfig(({ mode }) => { find: /^@workflowbuilder\/sdk$/, replacement: path.resolve(sdkDirectory, 'src/index.ts'), }, - // overflow-ui doesn't expose ./dist/index.css via package.json exports - { - find: 'overflow-ui-css', - replacement: path.resolve( - sdkDirectory, - 'node_modules/@synergycodes/overflow-ui/dist/index.css', - ), - }, // SDK source files use @/ to refer to their own root (packages/sdk/src/...). // Demo files do not use @/ (they import via @workflowbuilder/sdk subpaths), // so it's safe to point @/ at the SDK root for cross-package alias parity with sdk's vite.config. @@ -96,15 +81,6 @@ export default defineConfig(({ mode }) => { }; }); -function getLocalOverflowUIAliases(): Record { - const distribution = path.resolve(import.meta.dirname, '../../../overflow-ui/packages/ui/dist'); - - return { - '@synergycodes/overflow-ui/tokens.css': path.join(distribution, 'tokens.css'), - '@synergycodes/overflow-ui': path.join(distribution, 'overflow-ui.js'), - }; -} - type EnvMode = 'demo' | 'production' | 'development' | 'staging'; const fileReplacementsMap: Record = { demo: [], diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 599ee9638..71990073c 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -1,6 +1,7 @@ import { copyFileSync, mkdirSync } from 'node:fs'; import path from 'node:path'; +import react from '@astrojs/react'; import starlight from '@astrojs/starlight'; import umami from '@yeskunall/astro-umami'; import { defineConfig, passthroughImageService } from 'astro/config'; @@ -70,6 +71,7 @@ export default defineConfig({ }, integrations: [ icon(), + react(), umami({ id: UMAMI_WEBSITE_ID }), starlight({ plugins: [ @@ -113,7 +115,12 @@ export default defineConfig({ }, }), ], - customCss: ['./src/styles/custom.css'], + // `@workflowbuilder/ui` styles are safe to load globally: styles.css is + // just the @layer order + one :root var + opt-in `.ax-public-*` typography + // classes (no global reset), and tokens.css only defines `--ax-*` custom + // properties keyed on `html[data-theme]` — which Starlight already toggles, + // so the live component showcases follow the docs light/dark theme. + customCss: ['./src/styles/custom.css', '@workflowbuilder/ui/styles.css', '@workflowbuilder/ui/tokens.css'], components: { Head: './src/components/head.astro', Search: './src/components/search.astro', @@ -159,6 +166,15 @@ export default defineConfig({ { label: 'Node Schemas', autogenerate: { directory: 'node-schemas' } }, { label: 'Built-in Nodes', autogenerate: { directory: 'nodes' } }, { label: 'Plugins', autogenerate: { directory: 'plugins' } }, + { + label: 'UI Library', + items: [ + { label: 'Overview', link: '/ui-library/overview/' }, + { label: 'Design tokens', link: '/ui-library/design-tokens/' }, + { label: 'UI Components', autogenerate: { directory: 'ui-library/ui-components' } }, + { label: 'Diagram Components', autogenerate: { directory: 'ui-library/diagram-components' } }, + ], + }, // API Reference — pages auto-generated by `starlight-typedoc` from // packages/sdk's barrel into `src/content/docs/api//`. // Folder names match the `@category` tag in source TSDoc verbatim. diff --git a/apps/docs/package.json b/apps/docs/package.json index 9fe55bfe4..a1602b741 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm clean:typedoc && astro dev", - "build": "pnpm clean:typedoc && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs", + "dev": "pnpm clean:typedoc && pnpm generate:ui-api && astro dev", + "build": "pnpm clean:typedoc && pnpm generate:ui-api && node scripts/check-sidebar-categories.mjs && astro build && node scripts/touch-distribution-index.mjs", + "generate:ui-api": "node scripts/generate-ui-api.mjs", "clean:typedoc": "node -e \"import('node:fs').then(fs => fs.rmSync('src/content/docs/api', { recursive: true, force: true }))\"", "preview": "astro preview", "typecheck": "astro check", @@ -14,18 +15,25 @@ "lint:fix": "eslint . --fix" }, "dependencies": { + "@astrojs/react": "^4.4.0", "@astrojs/starlight": "^0.37.6", + "@base-ui/react": "catalog:", "@iconify-json/ph": "^1.2.2", "@workflowbuilder/sdk": "workspace:*", + "@workflowbuilder/ui": "workspace:*", "@yeskunall/astro-umami": "^0.0.7", "astro": "^5.18.0", "astro-icon": "^1.1.5", "marked": "^15.0.0", + "react": "catalog:", + "react-dom": "catalog:", "rehype-external-links": "^3.0.0", "starlight-image-zoom": "^0.13.2" }, "devDependencies": { "@astrojs/check": "^0.9.6", + "@types/react": "catalog:", + "@types/react-dom": "^19.1.0", "eslint-plugin-astro": "^1.3.1", "starlight-typedoc": "^0.21.3", "typedoc": "^0.28.9", diff --git a/apps/docs/scripts/generate-ui-api.mjs b/apps/docs/scripts/generate-ui-api.mjs new file mode 100644 index 000000000..08f7e20ce --- /dev/null +++ b/apps/docs/scripts/generate-ui-api.mjs @@ -0,0 +1,223 @@ +/* + * Generates `src/generated/ui-api.json` for the UI Library docs. + * + * Props are extracted with TypeDoc (source of truth: the component prop types + * in `@workflowbuilder/ui`); CSS variables are extracted from each component's + * stylesheets. The per-component docs pages render this JSON, so the Props and + * CSS variables tables never drift from source. Run by `pnpm generate:ui-api` + * and as a prebuild step in `dev` / `build`. + */ +import { execFile } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { globSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const docsRoot = path.resolve(here, '..'); +const repoRoot = path.resolve(docsRoot, '../..'); +const uiSrc = path.resolve(repoRoot, 'packages/ui/src'); +const outFile = path.resolve(docsRoot, 'src/generated/ui-api.json'); +const tdJson = path.resolve(docsRoot, 'node_modules/.cache/ui-typedoc.json'); + +// slug -> { name, propsType, dir }. `propsType` is the exported prop type the +// component accepts; `dir` is the component folder under packages/ui/src/components. +const COMPONENTS = [ + { slug: 'accordion', name: 'Accordion', propsType: 'AccordionProps', dir: 'accordion' }, + { slug: 'avatar', name: 'Avatar', propsType: 'AvatarProps', dir: 'avatar' }, + { slug: 'button', name: 'Button', propsType: 'BaseRegularButtonProps', dir: 'button' }, + { slug: 'checkbox', name: 'Checkbox', propsType: 'CheckboxProps', dir: 'checkbox' }, + { slug: 'date-picker', name: 'DatePicker', propsType: 'DatePickerProps', dir: 'date-picker' }, + { slug: 'input', name: 'Input', propsType: 'InputProps', dir: 'input' }, + { slug: 'menu', name: 'Menu', propsType: 'MenuProps', dir: 'menu' }, + { slug: 'modal', name: 'Modal', propsType: 'ModalProps', dir: 'modal' }, + { slug: 'nav-button', name: 'NavButton', propsType: 'NavBaseButtonProps', dir: 'button/nav-button' }, + { slug: 'radio', name: 'Radio', propsType: 'RadioProps', dir: 'radio-button' }, + { slug: 'segment-picker', name: 'SegmentPicker', propsType: 'SegmentPickerProps', dir: 'segment-picker' }, + { slug: 'select', name: 'Select', propsType: 'SelectBaseProps', dir: 'select' }, + { slug: 'separator', name: 'Separator', propsType: null, dir: 'separator' }, + { slug: 'snackbar', name: 'Snackbar', propsType: 'SnackbarProps', dir: 'snackbar' }, + { slug: 'status', name: 'Status', propsType: 'StatusProps', dir: 'status' }, + { slug: 'switch', name: 'Switch', propsType: 'BaseSwitchProps', dir: 'switch' }, + { slug: 'text-area', name: 'TextArea', propsType: 'TextAreaProps', dir: 'text-area' }, + { slug: 'tooltip', name: 'Tooltip', propsType: 'TooltipProps', dir: 'tooltip' }, + // Diagram components (props extracted the same way; NodePanel is a compound + // component documented narratively, so it has no flat props entry here). + { slug: 'node-icon', name: 'NodeIcon', propsType: 'NodeIconProps', dir: 'node/node-icon' }, + { slug: 'node-description', name: 'NodeDescription', propsType: 'NodeDescriptionProps', dir: 'node/node-description' }, + { slug: 'node-as-port-wrapper', name: 'NodeAsPortWrapper', propsType: 'NodeAsPortWrapperProps', dir: 'node/node-as-port-wrapper' }, + { slug: 'edge', name: 'EdgeLabel', propsType: 'EdgeLabelProps', dir: 'edge' }, +]; + +async function runTypedoc() { + await mkdir(path.dirname(tdJson), { recursive: true }); + const bin = path.resolve(docsRoot, 'node_modules/.bin/typedoc'); + await promisify(execFile)( + bin, + [ + '--json', tdJson, + '--entryPoints', path.resolve(uiSrc, 'index.ts'), + '--tsconfig', path.resolve(repoRoot, 'packages/ui/tsconfig.json'), + '--excludeExternals', '--excludePrivate', '--skipErrorChecking', '--logLevel', 'Error', + ], + { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 }, + ); + return JSON.parse(await readFile(tdJson, 'utf8')); +} + +function indexById(root) { + const byId = new Map(); + (function walk(node) { + if (node && typeof node.id === 'number') byId.set(node.id, node); + for (const child of node.children ?? []) walk(child); + })(root); + return byId; +} + +function findTypeByName(root, name) { + let found = null; + (function walk(node) { + if (found) return; + // 2097152 = TypeAlias, 256 = Interface + if (node.name === name && (node.kind === 2097152 || node.kind === 256)) found = node; + for (const child of node.children ?? []) walk(child); + })(root); + return found; +} + +function typeToString(t, byId, depth = 0) { + if (!t || depth > 6) return 'unknown'; + switch (t.type) { + case 'intrinsic': return t.name; + case 'literal': return typeof t.value === 'string' ? `'${t.value}'` : String(t.value); + case 'reference': { + const args = t.typeArguments?.length ? `<${t.typeArguments.map((a) => typeToString(a, byId, depth + 1)).join(', ')}>` : ''; + return `${t.name}${args}`; + } + case 'union': return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' | '); + case 'intersection': return t.types.map((x) => typeToString(x, byId, depth + 1)).join(' & '); + case 'array': return `${typeToString(t.elementType, byId, depth + 1)}[]`; + case 'tuple': return `[${(t.elements ?? []).map((x) => typeToString(x, byId, depth + 1)).join(', ')}]`; + case 'reflection': { + const sig = t.declaration?.signatures?.[0]; + if (sig) { + const params = (sig.parameters ?? []).map((p) => `${p.name}: ${typeToString(p.type, byId, depth + 1)}`).join(', '); + return `(${params}) => ${typeToString(sig.type, byId, depth + 1)}`; + } + return '{ … }'; + } + case 'indexedAccess': return `${typeToString(t.objectType, byId, depth + 1)}[${typeToString(t.indexType, byId, depth + 1)}]`; + case 'templateLiteral': return 'string'; + case 'query': return typeToString(t.queryType, byId, depth + 1); + case 'predicate': return 'boolean'; + case 'typeOperator': return `${t.operator} ${typeToString(t.target, byId, depth + 1)}`; + default: return t.name ?? 'unknown'; + } +} + +function summaryText(comment) { + if (!comment?.summary) return ''; + return comment.summary.map((s) => s.text).join('').trim(); +} + +function defaultTag(comment) { + const tag = (comment?.blockTags ?? []).find((b) => b.tag === '@default' || b.tag === '@defaultValue'); + if (!tag) return null; + let value = tag.content.map((c) => c.text).join('').trim(); + value = value.replace(/^```[a-z]*\s*/i, '').replace(/\s*```$/, '').trim(); // strip ```ts … ``` fences + value = value.replace(/^`+|`+$/g, '').trim(); // strip inline backticks + return value || null; +} + +// Collect own properties from a prop type alias / interface, walking +// intersections and skipping referenced (extended / native HTML) members. +function collectProps(typeNode, byId, acc = new Map()) { + if (!typeNode) return acc; + // TypeAlias / Interface: plain object members land directly on `.children`; + // computed types (intersections etc.) land on `.type`. + if (typeNode.kind === 2097152 || typeNode.kind === 256) { + if (typeNode.children?.length) { + for (const child of typeNode.children) addProp(child, byId, acc); + return acc; + } + return collectProps(typeNode.type, byId, acc); + } + if (typeNode.type === 'intersection' || typeNode.type === 'union') { + for (const member of typeNode.types) collectProps(member, byId, acc); + return acc; + } + if (typeNode.type === 'reflection' && typeNode.declaration?.children) { + for (const child of typeNode.declaration.children) addProp(child, byId, acc); + return acc; + } + if (typeNode.type === 'reference' && typeof typeNode.target === 'number') { + const target = byId.get(typeNode.target); + // Only follow references into our own package's prop types, not native ones. + if (target && target.kind === 2097152) collectProps(target, byId, acc); + return acc; + } + return acc; +} + +function addProp(child, byId, acc) { + if (child.kind !== 1024 || acc.has(child.name)) return; // 1024 = Property + acc.set(child.name, { + name: child.name, + type: typeToString(child.type, byId), + required: !child.flags?.isOptional, + default: defaultTag(child.comment), + description: summaryText(child.comment), + }); +} + +function extractCssVariables(dir) { + const abs = path.resolve(uiSrc, 'components', dir); + const files = globSync('**/*.css', { cwd: abs }).sort(); + const seen = new Set(); + const vars = []; + for (const file of files) { + const css = readFileSync(path.resolve(abs, file), 'utf8'); + // Match `--ax-public-xxx:` declarations, capturing an optional same-line comment. + const re = /(--ax-public-[\w-]+)\s*:[^;]*?(?:\/\*\s*(.*?)\s*\*\/)?\s*;/g; + let m; + while ((m = re.exec(css))) { + if (seen.has(m[1])) continue; + seen.add(m[1]); + vars.push({ name: m[1], comment: (m[2] ?? '').trim() }); + } + } + return vars; +} + +async function main() { + const project = await runTypedoc(); + const byId = indexById(project); + const out = {}; + const warnings = []; + + for (const c of COMPONENTS) { + let props = []; + if (c.propsType) { + const typeNode = findTypeByName(project, c.propsType); + if (typeNode) { + props = [...collectProps(typeNode, byId).values()].sort((a, b) => a.name.localeCompare(b.name)); + } else { + warnings.push(`props type "${c.propsType}" not found for "${c.slug}"`); + } + } + out[c.slug] = { name: c.name, props, cssVariables: extractCssVariables(c.dir) }; + } + + await mkdir(path.dirname(outFile), { recursive: true }); + await writeFile(outFile, JSON.stringify(out, null, 2) + '\n'); + + const summary = Object.entries(out).map(([s, v]) => `${s}: ${v.props.length} props, ${v.cssVariables.length} vars`); + console.log('✔ ui-api.json generated\n ' + summary.join('\n ')); + if (warnings.length) console.warn('⚠ ' + warnings.join('\n⚠ ')); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/docs/src/components/api/css-variables-table.astro b/apps/docs/src/components/api/css-variables-table.astro new file mode 100644 index 000000000..6ccfec8ee --- /dev/null +++ b/apps/docs/src/components/api/css-variables-table.astro @@ -0,0 +1,43 @@ +--- +// Renders a component's CSS custom properties from the generated ui-api.json +// (extracted from the component stylesheets - never hand-maintained). +import data from '../../generated/ui-api.json'; + +interface Props { + slug: string; +} + +const { slug } = Astro.props; +const entry = (data as Record)[slug]; +const cssVariables = entry?.cssVariables ?? []; + +interface CssVar { + name: string; + comment: string; +} +--- + +{ + cssVariables.length === 0 ? ( +

This component does not expose its own CSS variables.

+ ) : ( + + + + + + + + + {cssVariables.map((cssVar) => ( + + + + + ))} + +
VariableNotes
+ {cssVar.name} + {cssVar.comment}
+ ) +} diff --git a/apps/docs/src/components/api/props-table.astro b/apps/docs/src/components/api/props-table.astro new file mode 100644 index 000000000..3cda026d6 --- /dev/null +++ b/apps/docs/src/components/api/props-table.astro @@ -0,0 +1,53 @@ +--- +// Renders a component's props table from the generated ui-api.json (extracted +// from `@workflowbuilder/ui` source with TypeDoc - never hand-maintained). +import data from '../../generated/ui-api.json'; + +interface Props { + slug: string; +} + +const { slug } = Astro.props; +const entry = (data as Record)[slug]; +const props = entry?.props ?? []; + +interface PropRow { + name: string; + type: string; + required: boolean; + default: string | null; + description: string; +} +--- + +{ + props.length === 0 ? ( +

This component has no configurable props.

+ ) : ( + + + + + + + + + + + {props.map((prop) => ( + + + + + + + ))} + +
PropTypeDefaultDescription
+ {prop.name} + {!prop.required && ?} + + {prop.type} + {prop.default ? {prop.default} : '-'}{prop.description.replace(/\n/g, ' ')}
+ ) +} diff --git a/apps/docs/src/components/ui-examples/accordion.tsx b/apps/docs/src/components/ui-examples/accordion.tsx new file mode 100644 index 000000000..d3b3fb895 --- /dev/null +++ b/apps/docs/src/components/ui-examples/accordion.tsx @@ -0,0 +1,18 @@ +import { Accordion } from '@workflowbuilder/ui'; + +import frame from './example-frame.module.css'; + +export function AccordionExample() { + return ( +
+
+ + Our component library, built on Base UI. + + + Yes - override the --ax-* design tokens. + +
+
+ ); +} diff --git a/apps/docs/src/components/ui-examples/avatar.tsx b/apps/docs/src/components/ui-examples/avatar.tsx new file mode 100644 index 000000000..33ed17cc8 --- /dev/null +++ b/apps/docs/src/components/ui-examples/avatar.tsx @@ -0,0 +1,32 @@ +import { Avatar } from '@workflowbuilder/ui'; + +import frame from './example-frame.module.css'; + +const AVATAR = + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + "AL", + ); + +export function AvatarExample() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/button.tsx b/apps/docs/src/components/ui-examples/button.tsx new file mode 100644 index 000000000..c00c4e9ad --- /dev/null +++ b/apps/docs/src/components/ui-examples/button.tsx @@ -0,0 +1,20 @@ +import { Button } from '@workflowbuilder/ui'; + +import frame from './example-frame.module.css'; + +export function ButtonExample() { + return ( +
+ + + + + + + + +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/checkbox.tsx b/apps/docs/src/components/ui-examples/checkbox.tsx new file mode 100644 index 000000000..5a7e94713 --- /dev/null +++ b/apps/docs/src/components/ui-examples/checkbox.tsx @@ -0,0 +1,25 @@ +import { Checkbox } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import frame from './example-frame.module.css'; + +export function CheckboxExample() { + const [checked, setChecked] = useState(true); + + return ( +
+ + + +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/date-picker.tsx b/apps/docs/src/components/ui-examples/date-picker.tsx new file mode 100644 index 000000000..32e6a0b9e --- /dev/null +++ b/apps/docs/src/components/ui-examples/date-picker.tsx @@ -0,0 +1,24 @@ +import { DatePicker } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import frame from './example-frame.module.css'; + +export function DatePickerExample() { + const [date, setDate] = useState(null); + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/docs/src/components/ui-examples/example-frame.module.css b/apps/docs/src/components/ui-examples/example-frame.module.css new file mode 100644 index 000000000..08c894602 --- /dev/null +++ b/apps/docs/src/components/ui-examples/example-frame.module.css @@ -0,0 +1,36 @@ +.frame { + display: flex; + flex-wrap: wrap; + gap: 1rem 1.5rem; + align-items: center; + padding: 1.5rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.5rem; + background: var(--sl-color-black); + margin-top: 1rem; +} + +.stack { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 14rem; + max-width: 22rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.fieldLabel { + font-size: 0.8125rem; + color: var(--sl-color-gray-2); +} + +.inline { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} diff --git a/apps/docs/src/components/ui-examples/input.tsx b/apps/docs/src/components/ui-examples/input.tsx new file mode 100644 index 000000000..468f52dd1 --- /dev/null +++ b/apps/docs/src/components/ui-examples/input.tsx @@ -0,0 +1,23 @@ +import { Input } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import frame from './example-frame.module.css'; + +export function InputExample() { + const [value, setValue] = useState(''); + + return ( +
+
+ + +
+
+ ); +} diff --git a/apps/docs/src/components/ui-examples/menu.tsx b/apps/docs/src/components/ui-examples/menu.tsx new file mode 100644 index 000000000..e56da8112 --- /dev/null +++ b/apps/docs/src/components/ui-examples/menu.tsx @@ -0,0 +1,20 @@ +import { Button, Menu } from '@workflowbuilder/ui'; + +import frame from './example-frame.module.css'; + +export function MenuExample() { + return ( +
+ {} }, + { label: 'Duplicate', onClick: () => {} }, + { type: 'separator' }, + { label: 'Delete', destructive: true, onClick: () => {} }, + ]} + > + + +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/modal.tsx b/apps/docs/src/components/ui-examples/modal.tsx new file mode 100644 index 000000000..50dcb1415 --- /dev/null +++ b/apps/docs/src/components/ui-examples/modal.tsx @@ -0,0 +1,29 @@ +import { Button, Modal } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import frame from './example-frame.module.css'; + +export function ModalExample() { + const [open, setOpen] = useState(false); + + return ( +
+ + setOpen(false)} + title="Example modal" + subtitle="Rendered live from @workflowbuilder/ui" + footer={ + + } + > + The backdrop and popup fade in and out via the Base UI transition lifecycle. + +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/nav-button.tsx b/apps/docs/src/components/ui-examples/nav-button.tsx new file mode 100644 index 000000000..6de4d38cf --- /dev/null +++ b/apps/docs/src/components/ui-examples/nav-button.tsx @@ -0,0 +1,12 @@ +import { NavButton } from '@workflowbuilder/ui'; + +import frame from './example-frame.module.css'; + +export function NavButtonExample() { + return ( +
+ Default + Selected +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/radio.tsx b/apps/docs/src/components/ui-examples/radio.tsx new file mode 100644 index 000000000..a865737c5 --- /dev/null +++ b/apps/docs/src/components/ui-examples/radio.tsx @@ -0,0 +1,19 @@ +import { Radio } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import frame from './example-frame.module.css'; + +export function RadioExample() { + const [value, setValue] = useState('daily'); + + return ( +
+ {['daily', 'weekly', 'monthly'].map((v) => ( + + setValue(v)} /> + {v} + + ))} +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/segment-picker.tsx b/apps/docs/src/components/ui-examples/segment-picker.tsx new file mode 100644 index 000000000..03b6fb465 --- /dev/null +++ b/apps/docs/src/components/ui-examples/segment-picker.tsx @@ -0,0 +1,18 @@ +import { SegmentPicker } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import frame from './example-frame.module.css'; + +export function SegmentPickerExample() { + const [view, setView] = useState('list'); + + return ( +
+ setView(next)}> + List + Grid + Board + +
+ ); +} diff --git a/apps/docs/src/components/ui-examples/select.tsx b/apps/docs/src/components/ui-examples/select.tsx new file mode 100644 index 000000000..91d868b25 --- /dev/null +++ b/apps/docs/src/components/ui-examples/select.tsx @@ -0,0 +1,30 @@ +import { Select, type SelectItem } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import frame from './example-frame.module.css'; + +const ITEMS: SelectItem[] = [ + { value: 'opus', label: 'Claude Opus' }, + { value: 'sonnet', label: 'Claude Sonnet' }, + { value: 'haiku', label: 'Claude Haiku' }, +]; + +export function SelectExample() { + const [model, setModel] = useState('opus'); + + return ( +
+
+