diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 7d55e66bc..4cd7d557c 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -42,6 +42,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # UI must build first: docs imports @workflowbuilder/ui/{styles,tokens}.css + # and its components, both resolved from packages/ui/dist. + - name: Build UI + run: pnpm build:ui + # SDK must build first so TypeDoc can read its compiled types. - name: Build SDK run: pnpm --filter @workflowbuilder/sdk build:lib 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/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..561310968 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,28 +4,37 @@ "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 && node scripts/check-ui-component-coverage.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", + "typecheck": "pnpm generate:ui-api && astro check", "format": "prettier --write --log-level silent \"**/*.astro\"", "lint": "eslint .", "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", + "@phosphor-icons/react": "catalog:", "@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/check-ui-component-coverage.mjs b/apps/docs/scripts/check-ui-component-coverage.mjs new file mode 100644 index 000000000..187625d41 --- /dev/null +++ b/apps/docs/scripts/check-ui-component-coverage.mjs @@ -0,0 +1,68 @@ +// Asserts that every entry in packages/ui/vite.config.mts's `componentEntries` +// (the list of publishable subpath bundles) has at least one matching entry +// in generate-ui-api.mjs's `COMPONENTS` (the list of docs pages with a +// generated Props / CSS variables table). +// +// Without this guard, a new component can be added to the package's public +// entry points and shipped to npm without ever getting a docs page - nothing +// else in the build fails, the page just silently never exists. TypeDoc's +// strict mode doesn't catch this either: it only checks that exported types +// have doc comments, not that a docs page renders them. +// +// A vite entry counts as covered if COMPONENTS has a `dir` equal to the entry +// name, or nested under it (`${entry}/...`) - entries like `node` bundle +// several flat docs pages (node-icon, node-description, ...) rather than +// mapping 1:1 by name. `NARRATIVE_ONLY` is an escape hatch for a vite entry +// that is deliberately docs-only-by-prose with no generated props table at +// all (none today - kept for the next one, e.g. a future compound component +// documented like NodePanel). +// +// Wired into apps/docs/package.json's `generate:ui-api` script, right after +// the generator runs, so `dev` / `build` / `typecheck` all catch the drift. + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const documentsRoot = path.resolve(here, '..'); +const repoRoot = path.resolve(documentsRoot, '../..'); +const viteConfigPath = path.resolve(repoRoot, 'packages/ui/vite.config.mts'); +const generatorPath = path.resolve(here, 'generate-ui-api.mjs'); + +const NARRATIVE_ONLY = new Set([]); + +function extractComponentEntries(source) { + const match = /const componentEntries = \[([\s\S]*?)] as const;/.exec(source); + if (!match) throw new Error('Could not find `componentEntries` in packages/ui/vite.config.mts'); + return [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]); +} + +function extractComponentDirectories(source) { + const match = /const COMPONENTS = \[([\s\S]*?)];/.exec(source); + if (!match) throw new Error('Could not find `COMPONENTS` in generate-ui-api.mjs'); + return [...match[1].matchAll(/dir:\s*'([^']+)'/g)].map((m) => m[1]); +} + +const componentEntries = extractComponentEntries(readFileSync(viteConfigPath, 'utf8')); +const componentDirectories = extractComponentDirectories(readFileSync(generatorPath, 'utf8')); + +const missing = componentEntries.filter((entry) => { + if (NARRATIVE_ONLY.has(entry)) return false; + return !componentDirectories.some((directory) => directory === entry || directory.startsWith(`${entry}/`)); +}); + +if (missing.length > 0) { + console.error('error: componentEntries in packages/ui/vite.config.mts have no matching COMPONENTS entry.\n'); + for (const entry of missing) { + console.error(` - ${entry}`); + } + console.error( + '\nAdd a COMPONENTS entry in apps/docs/scripts/generate-ui-api.mjs with a matching `dir`, ' + + 'or add the entry to NARRATIVE_ONLY in this script if it is deliberately prose-only.', + ); + process.exitCode = 1; +} else { + console.log(`✓ component coverage ok — ${componentEntries.length} vite entries cross-checked.`); +} diff --git a/apps/docs/scripts/generate-ui-api.mjs b/apps/docs/scripts/generate-ui-api.mjs new file mode 100644 index 000000000..aa1a16f84 --- /dev/null +++ b/apps/docs/scripts/generate-ui-api.mjs @@ -0,0 +1,374 @@ +/* + * 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 { globSync, readFileSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const documentsRoot = path.resolve(here, '..'); +const repoRoot = path.resolve(documentsRoot, '../..'); +const uiSource = path.resolve(repoRoot, 'packages/ui/src'); +const outFile = path.resolve(documentsRoot, 'src/generated/ui-api.json'); +const tdJson = path.resolve(documentsRoot, 'node_modules/.cache/ui-typedoc.json'); + +// Comment patterns that are engineering notes on the token pipeline, not +// public documentation - stripped so they never render as CSS variable +// descriptions in the docs (see e.g. status.module.css, icon-size.module.css). +const INTERNAL_NOTE_RE = /missing token/i; + +// slug -> { name, propsType, dir }. `propsType` is the exported prop type the +// component accepts (or the list of variant prop types for a component whose +// public surface is a discriminated union - see collectVariantProps); `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' }, + // Button has no single public props type - it renders one of three variant + // components depending on `children` (label / icon / icon+label). Merge + // their prop sets instead of documenting only the shared base. + { + slug: 'button', + name: 'Button', + propsType: ['LabelButtonProps', 'IconButtonProps', 'IconLabelButtonProps'], + dir: 'button', + }, + { slug: 'checkbox', name: 'Checkbox', propsType: 'CheckboxProps', dir: 'checkbox' }, + { slug: 'collapsible', name: 'Collapsible', propsType: 'CollapsibleProps', dir: 'collapsible' }, + { slug: 'date-picker', name: 'DatePicker', propsType: 'DatePickerProps', dir: 'date-picker' }, + { slug: 'icon-switch', name: 'IconSwitch', propsType: 'IconSwitchProps', dir: 'switch/icon-switch' }, + { 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(documentsRoot, 'node_modules/.bin/typedoc'); + await promisify(execFile)( + bin, + [ + '--json', + tdJson, + // `expand` over the whole components tree (rather than resolving just + // `index.ts`'s re-exports) so that per-variant prop types like + // LabelButtonProps/IconButtonProps/IconLabelButtonProps - not + // individually re-exported from the package barrel, only reachable + // through the Button component's overloaded signature - still get a + // full type reflection collectVariantProps can look up by name. + '--entryPoints', + path.resolve(uiSource, 'components'), + // `shared` must be an entry too: helper prop types like WithIcon live + // there, and a type outside the entry tree gets no reflection - its + // intersection members (e.g. Accordion/Modal's `icon`) silently vanish + // from the generated tables. + '--entryPoints', + path.resolve(uiSource, 'shared'), + '--entryPointStrategy', + 'expand', + '--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 === 2_097_152 || 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 arguments_ = t.typeArguments?.length + ? `<${t.typeArguments.map((a) => typeToString(a, byId, depth + 1)).join(', ')}>` + : ''; + return `${t.name}${arguments_}`; + } + 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.replaceAll(/^`+|`+$/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, accumulator = new Map()) { + if (!typeNode) return accumulator; + // TypeAlias / Interface: plain object members land directly on `.children`; + // computed types (intersections etc.) land on `.type`. + if (typeNode.kind === 2_097_152 || typeNode.kind === 256) { + if (typeNode.children?.length) { + for (const child of typeNode.children) addProperty(child, byId, accumulator); + return accumulator; + } + return collectProps(typeNode.type, byId, accumulator); + } + if (typeNode.type === 'intersection' || typeNode.type === 'union') { + for (const member of typeNode.types) collectProps(member, byId, accumulator); + return accumulator; + } + if (typeNode.type === 'reflection' && typeNode.declaration?.children) { + for (const child of typeNode.declaration.children) addProperty(child, byId, accumulator); + return accumulator; + } + 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 === 2_097_152) collectProps(target, byId, accumulator); + return accumulator; + } + return accumulator; +} + +function addProperty(child, byId, accumulator) { + if (child.kind !== 1024 || accumulator.has(child.name)) return; // 1024 = Property + accumulator.set(child.name, { + name: child.name, + type: typeToString(child.type, byId), + required: !child.flags?.isOptional, + default: defaultTag(child.comment), + description: summaryText(child.comment), + }); +} + +// Merge the prop sets of a discriminated-union component's variants (e.g. +// Button's Label/Icon/IconLabel components). Props shared by every variant +// with the same type are documented once, unqualified; props that are +// variant-specific (missing from, or typed differently in, some variants) +// get a note appended to their description so the table stays a single flat +// list without a separate "variants" column. +function collectVariantProps(propsTypeNames, project, byId, warnings, slug) { + const perVariant = []; + for (const typeName of propsTypeNames) { + const typeNode = findTypeByName(project, typeName); + if (!typeNode) { + warnings.push(`props type "${typeName}" not found for "${slug}"`); + continue; + } + perVariant.push({ typeName, props: collectProps(typeNode, byId) }); + } + + const propertyNames = new Set(); + for (const variant of perVariant) for (const name of variant.props.keys()) propertyNames.add(name); + + const merged = new Map(); + for (const propertyName of propertyNames) { + const occurrences = perVariant + .filter((variant) => variant.props.has(propertyName)) + .map((variant) => ({ typeName: variant.typeName, prop: variant.props.get(propertyName) })); + const distinctTypes = new Set(occurrences.map((o) => o.prop.type)); + const sharedByAll = occurrences.length === perVariant.length && distinctTypes.size === 1; + + const base = occurrences[0].prop; + let description = base.description; + if (!sharedByAll) { + const variantLabel = (typeName) => typeName.replace(/Props$/, ''); + const note = + distinctTypes.size > 1 + ? `Type varies by variant (${occurrences.map((o) => `${variantLabel(o.typeName)}: ${o.prop.type}`).join(', ')}).` + : `Only applies to the ${occurrences.map((o) => variantLabel(o.typeName)).join(', ')} variant.`; + description = description ? `${description} ${note}` : note; + } + + merged.set(propertyName, { + name: propertyName, + type: sharedByAll ? base.type : [...distinctTypes].join(' | '), + required: occurrences.every((o) => o.prop.required), + default: base.default, + description, + }); + } + return merged; +} + +function extractCssVariables(directory) { + const abs = path.resolve(uiSource, 'components', directory); + const files = globSync('**/*.css', { cwd: abs }).sort(); + const seen = new Set(); + const variables = []; + 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]); + const comment = (m[2] ?? '').trim(); + variables.push({ name: m[1], comment: INTERNAL_NOTE_RE.test(comment) ? '' : comment }); + } + } + return variables; +} + +async function main() { + const project = await runTypedoc(); + const byId = indexById(project); + const out = {}; + const warnings = []; + + for (const component of COMPONENTS) { + let props = []; + if (Array.isArray(component.propsType)) { + props = [...collectVariantProps(component.propsType, project, byId, warnings, component.slug).values()].sort( + (a, b) => a.name.localeCompare(b.name), + ); + } else if (component.propsType) { + const typeNode = findTypeByName(project, component.propsType); + if (typeNode) { + props = [...collectProps(typeNode, byId).values()].sort((a, b) => a.name.localeCompare(b.name)); + } else { + warnings.push(`props type "${component.propsType}" not found for "${component.slug}"`); + } + } + out[component.slug] = { name: component.name, props, cssVariables: extractCssVariables(component.dir) }; + } + + await mkdir(path.dirname(outFile), { recursive: true }); + await writeFile(outFile, JSON.stringify(out, null, 2) + '\n'); + + const summary = Object.entries(out).map( + ([slug, entry]) => `${slug}: ${entry.props.length} props, ${entry.cssVariables.length} vars`, + ); + console.log('✔ ui-api.json generated\n ' + summary.join('\n ')); + + if (warnings.length > 0) { + // An unresolved props type means a rename/typo silently shipped an empty + // "no configurable props" page - fail the build instead of warning. + console.error('✗ ' + warnings.join('\n✗ ')); + process.exitCode = 1; + } +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exitCode = 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..6d331cd8a --- /dev/null +++ b/apps/docs/src/components/api/css-variables-table.astro @@ -0,0 +1,76 @@ +--- +// Renders a component's CSS custom properties (from the component stylesheets, +// generated into ui-api.json) as a list grouped into Size / Color, mirroring the +// Overflow UI docs. +import data from '../../generated/ui-api.json'; + +interface CssVariable { + name: string; + comment: string; +} + +const { slug } = Astro.props as { slug: string }; +const entry = (data as Record)[slug]; +const cssVariables = entry?.cssVariables ?? []; + +const isColor = (name: string) => /color|background|bg|shadow|stroke|fill/.test(name); +const groups = [ + { label: 'Color', vars: cssVariables.filter((v) => isColor(v.name)) }, + { label: 'Size', vars: cssVariables.filter((v) => !isColor(v.name)) }, +].filter((group) => group.vars.length > 0); +--- + +{ + cssVariables.length === 0 ? ( +

This component does not expose its own CSS variables.

+ ) : ( +
+ {groups.map((group) => ( +
+

{group.label}

+ {group.vars.map((cssVariable) => ( +
+ {cssVariable.name} + {cssVariable.comment && {cssVariable.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..b7fc19bea --- /dev/null +++ b/apps/docs/src/components/api/props-table.astro @@ -0,0 +1,104 @@ +--- +// Renders a component's props as a card list (name + optional "required" chip + +// Type / Default + description), generated from `@workflowbuilder/ui` source via +// TypeDoc. Mirrors the Overflow UI docs - required props first, no `?` marker. +import data from '../../generated/ui-api.json'; + +interface PropertyRow { + name: string; + type: string; + required: boolean; + default: string | null; + description: string; +} + +const { slug } = Astro.props as { slug: string }; +const entry = (data as Record)[slug]; +const props = [...(entry?.props ?? [])].sort( + (a, b) => Number(b.required) - Number(a.required) || a.name.localeCompare(b.name), +); +--- + +{ + props.length === 0 ? ( +

This component has no configurable props.

+ ) : ( +
+ {props.map((property) => ( +
+
+ {property.name} + {property.required && required} +
+
+ Type + {property.type} +
+ {property.default && ( +
+ Default + {property.default} +
+ )} + {property.description &&

{property.description.replaceAll('\n', ' ')}

} +
+ ))} +
+ ) +} + + 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..f873f4260 --- /dev/null +++ b/apps/docs/src/components/ui-examples/accordion.tsx @@ -0,0 +1,13 @@ +import { Accordion } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function AccordionExample() { + return ( + + + Our component library, built on Base UI. + + + ); +} 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..02cd08366 --- /dev/null +++ b/apps/docs/src/components/ui-examples/avatar.tsx @@ -0,0 +1,17 @@ +import { Avatar } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +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..c2e5a32e9 --- /dev/null +++ b/apps/docs/src/components/ui-examples/button.tsx @@ -0,0 +1,11 @@ +import { Button } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +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..5c7176382 --- /dev/null +++ b/apps/docs/src/components/ui-examples/checkbox.tsx @@ -0,0 +1,14 @@ +import { Checkbox } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function CheckboxExample() { + const [checked, setChecked] = useState(true); + + return ( + + setChecked(event.target.checked)} /> + + ); +} diff --git a/apps/docs/src/components/ui-examples/collapsible.tsx b/apps/docs/src/components/ui-examples/collapsible.tsx new file mode 100644 index 000000000..3abaf5645 --- /dev/null +++ b/apps/docs/src/components/ui-examples/collapsible.tsx @@ -0,0 +1,14 @@ +import { Collapsible } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function CollapsibleExample() { + return ( + + + + Additional details go here. + + + ); +} diff --git a/apps/docs/src/components/ui-examples/component-preview.module.css b/apps/docs/src/components/ui-examples/component-preview.module.css new file mode 100644 index 000000000..4282db4e6 --- /dev/null +++ b/apps/docs/src/components/ui-examples/component-preview.module.css @@ -0,0 +1,34 @@ +.stage { + position: relative; + width: 100%; + max-width: 50rem; + aspect-ratio: 2 / 1; + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.5rem; + background-color: var(--sl-color-bg); + /* dot grid */ + background-image: radial-gradient(circle at center, var(--sl-color-gray-5) 1.5px, transparent 1.5px); + background-size: 1.5rem 1.5rem; +} + +/* A soft radial of the page background behind the component so it reads cleanly + over the dot grid (mirrors the reference preview spotlight). */ +.spotlight { + display: flex; + align-items: center; + justify-content: center; + padding: 6rem 10rem; + margin: -6rem -10rem; + background: radial-gradient(closest-side, var(--sl-color-bg) 45%, transparent 100%); +} + +.host { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/apps/docs/src/components/ui-examples/component-preview.tsx b/apps/docs/src/components/ui-examples/component-preview.tsx new file mode 100644 index 000000000..69ddb7e96 --- /dev/null +++ b/apps/docs/src/components/ui-examples/component-preview.tsx @@ -0,0 +1,41 @@ +import componentCss from '@workflowbuilder/ui/index.css?raw'; +import globalCss from '@workflowbuilder/ui/styles.css?raw'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import styles from './component-preview.module.css'; + +// Render examples inside a shadow root so the components are styled ONLY by +// `@workflowbuilder/ui`, isolated from the Starlight docs CSS (and vice versa) - +// the same isolation the original Overflow UI docs use, so previews are faithful. +// +// Inside a shadow root `:root` matches nothing, so retarget the library's +// root-scoped custom-property defaults to `:host`. The `--ax-*` design tokens +// still inherit in from the document (tokens.css defines them per data-theme), +// because custom properties cross the shadow boundary. +const shadowCss = `${globalCss}\n${componentCss}`.replaceAll(':root', ':host'); + +export function ComponentPreview({ children }: { children: ReactNode }) { + const hostRef = useRef(null); + const [shadow, setShadow] = useState(null); + + useEffect(() => { + const host = hostRef.current; + if (!host || shadow) return; + const root = host.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = shadowCss; + root.append(style); + setShadow(root); + }, [shadow]); + + return ( +
+
+
+ {shadow ? createPortal(children, shadow) : null} +
+
+
+ ); +} 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..bc5575afd --- /dev/null +++ b/apps/docs/src/components/ui-examples/date-picker.tsx @@ -0,0 +1,19 @@ +import { DatePicker } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function DatePickerExample() { + const [date, setDate] = useState(null); + + return ( + + setDate((next as Date | null) ?? null)} + /> + + ); +} diff --git a/apps/docs/src/components/ui-examples/edge.tsx b/apps/docs/src/components/ui-examples/edge.tsx new file mode 100644 index 000000000..cec6f5def --- /dev/null +++ b/apps/docs/src/components/ui-examples/edge.tsx @@ -0,0 +1,31 @@ +import { DiamondsFour } from '@phosphor-icons/react'; +import { EdgeLabel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +// EdgeLabel defaults to `position: absolute` for placement on a diagram edge. +// Outside a canvas we override it to `relative` so the variants lay out inline. +export function EdgeExample() { + return ( + +
+ Edge Label + + + Edge Label + + + + Edge Label + + + + Edge Label + + + + +
+
+ ); +} 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..f1d4cc4b2 --- /dev/null +++ b/apps/docs/src/components/ui-examples/input.tsx @@ -0,0 +1,14 @@ +import { Input } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function InputExample() { + const [value, setValue] = useState(''); + + return ( + + setValue(event.target.value)} /> + + ); +} 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..8583d4787 --- /dev/null +++ b/apps/docs/src/components/ui-examples/menu.tsx @@ -0,0 +1,20 @@ +import { Button, Menu } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +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..07b4de026 --- /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 { ComponentPreview } from './component-preview'; + +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..edba42241 --- /dev/null +++ b/apps/docs/src/components/ui-examples/nav-button.tsx @@ -0,0 +1,11 @@ +import { NavButton } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NavButtonExample() { + return ( + + Nav button + + ); +} diff --git a/apps/docs/src/components/ui-examples/node-description.tsx b/apps/docs/src/components/ui-examples/node-description.tsx new file mode 100644 index 000000000..bb552affb --- /dev/null +++ b/apps/docs/src/components/ui-examples/node-description.tsx @@ -0,0 +1,15 @@ +import { NodeDescription, NodePanel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NodeDescriptionExample() { + return ( + + + + + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/node-icon.tsx b/apps/docs/src/components/ui-examples/node-icon.tsx new file mode 100644 index 000000000..b527c3349 --- /dev/null +++ b/apps/docs/src/components/ui-examples/node-icon.tsx @@ -0,0 +1,17 @@ +import { User } from '@phosphor-icons/react'; +import { NodeIcon, NodePanel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NodeIconExample() { + return ( + + + + } /> + Node with Icon + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/node-panel.tsx b/apps/docs/src/components/ui-examples/node-panel.tsx new file mode 100644 index 000000000..ff561ca49 --- /dev/null +++ b/apps/docs/src/components/ui-examples/node-panel.tsx @@ -0,0 +1,17 @@ +import { User } from '@phosphor-icons/react'; +import { NodeDescription, NodeIcon, NodePanel } from '@workflowbuilder/ui'; + +import { ComponentPreview } from './component-preview'; + +export function NodePanelExample() { + return ( + + + + } /> + + + + + ); +} 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..e50209f0e --- /dev/null +++ b/apps/docs/src/components/ui-examples/radio.tsx @@ -0,0 +1,18 @@ +import { Radio } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function RadioExample() { + const [value, setValue] = useState('daily'); + + return ( + + + {['daily', 'weekly', 'monthly'].map((v) => ( + setValue(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..023fcc8ff --- /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 { ComponentPreview } from './component-preview'; + +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..9b9831a76 --- /dev/null +++ b/apps/docs/src/components/ui-examples/select.tsx @@ -0,0 +1,20 @@ +import { Select, type SelectItem } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +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 ( + +