diff --git a/.changeset/ui-export-prop-types.md b/.changeset/ui-export-prop-types.md new file mode 100644 index 000000000..383d256fe --- /dev/null +++ b/.changeset/ui-export-prop-types.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/ui': minor +--- + +Component prop types are now exported: `AvatarProps`, `CheckboxProps`, `RadioProps`, `StatusProps`, `TooltipProps`, `MenuProps`, `ModalProps`, `EdgeLabelProps`, `NodeIconProps`, `NodeDescriptionProps`, `NodeAsPortWrapperProps`, `SegmentPickerProps` (with its controlled/uncontrolled variants), the NavButton variant prop types, and `DatePickerProps` now covers the component's full runtime surface (`value`, `defaultValue`, `placeholder`, `valueFormat`, `type`, `error`). Supporting types used in those signatures (`Shape`, `IconNode`) are exported as well. diff --git a/.changeset/ui-export-use-edge-style-params.md b/.changeset/ui-export-use-edge-style-params.md new file mode 100644 index 000000000..31e85cb50 --- /dev/null +++ b/.changeset/ui-export-use-edge-style-params.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/ui': minor +--- + +`UseEdgeStyleParams`, the parameter type of the `useEdgeStyle` hook, is now exported. 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/.github/workflows/pr-check-docs.yml b/.github/workflows/pr-check-docs.yml new file mode 100644 index 000000000..209e190a2 --- /dev/null +++ b/.github/workflows/pr-check-docs.yml @@ -0,0 +1,57 @@ +name: PR Check (docs) + +# The docs site is generated from packages/ui source (ui-api tables, live +# examples) and consumes its built dist, so a PR touching either can break +# the docs build - which otherwise first runs at release-time deploy. +# Full `astro build` is the gate: it runs the ui-api generator, the +# component-coverage guard, and page rendering (loud failures included). +# Typecheck is deliberately absent: apps/docs tolerates known starlight +# virtual-module type errors. + +on: + pull_request: + branches: + - main + paths: + - 'apps/docs/**' + - 'packages/ui/**' + - 'packages/tokens/**' + # starlight-typedoc generates the /api reference from SDK sources. + - 'packages/sdk/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - '.github/workflows/pr-check-docs.yml' + +permissions: + contents: read + +jobs: + docs: + name: Docs build (ui-api generator + coverage + astro) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable Corepack + run: npm i -g corepack@latest + + - name: Install pnpm + run: corepack prepare + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build UI library + run: pnpm build:ui + + - name: Build docs + run: pnpm build:docs diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 844b4d0df..f1b4dc3c2 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -3,9 +3,9 @@ name: PR Check # Runs on every PR to give reviewers fast feedback on whether the change # is safe to merge. Focus on the published packages @workflowbuilder/sdk and # @workflowbuilder/ui (plus its private @workflowbuilder/ui-tokens build) + -# global format consistency. Other workspaces (apps/docs, demo, ai-studio, …) -# are not checked here — they're internal and have their own broken-state -# tolerances (e.g. starlight virtual-module type errors). +# global format consistency. apps/docs has its own path-filtered workflow +# (pr-check-docs.yml); demo and ai-studio are not checked here — they're +# internal and have their own broken-state tolerances. on: pull_request: 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..0453e265c 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,19 @@ export default defineConfig({ }, }), ], - customCss: ['./src/styles/custom.css'], + // `@workflowbuilder/ui` styles are safe to load globally: everything is + // layered (no global reset), classes are hashed or opt-in, 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. index.css is required at document level: + // Modal/Menu/Select/Tooltip/DatePicker portal their popups to body, + // outside the shadow roots that carry the preview styles. + customCss: [ + './src/styles/custom.css', + '@workflowbuilder/ui/styles.css', + '@workflowbuilder/ui/tokens.css', + '@workflowbuilder/ui/index.css', + ], components: { Head: './src/components/head.astro', Search: './src/components/search.astro', @@ -159,6 +173,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 2ace85e85..2e643249c 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 && node scripts/copy-swa-config.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 && node scripts/copy-swa-config.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..6665c6b88 --- /dev/null +++ b/apps/docs/scripts/check-ui-component-coverage.mjs @@ -0,0 +1,77 @@ +// Cross-checks the documented component surface: every publishable subpath in +// packages/ui/vite.config.mts has a COMPONENTS entry, and every COMPONENTS +// entry is rendered by an MDX page. Without it a component ships to npm with +// no docs page and nothing in the build complains. Runs as part of +// `generate:ui-api`. + +import { globSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { COMPONENTS } from './ui-components.mjs'; + +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 NARRATIVE_ONLY = new Set([]); + +// Read as text, not imported - it is TypeScript. Empty means the shape changed. +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'); + const entries = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]); + if (entries.length === 0) { + throw new Error('Extracted zero entries from `componentEntries` in packages/ui/vite.config.mts'); + } + return entries; +} + +const componentEntries = extractComponentEntries(readFileSync(viteConfigPath, 'utf8')); +const componentDirectories = COMPONENTS.map((component) => component.dir).filter(Boolean); +const componentSlugs = COMPONENTS.map((component) => component.slug); + +const missing = componentEntries.filter((entry) => { + if (NARRATIVE_ONLY.has(entry)) return false; + return !componentDirectories.some((directory) => directory === entry || directory.startsWith(`${entry}/`)); +}); + +const contentRoot = path.resolve(documentsRoot, 'src/content/docs'); +const pageSources = globSync('**/*.mdx', { cwd: contentRoot }).map((file) => + readFileSync(path.resolve(contentRoot, file), 'utf8'), +); +const unrendered = componentSlugs.filter( + (slug) => !pageSources.some((source) => source.includes(`slug="${slug}"`)), +); + +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; +} + +if (unrendered.length > 0) { + console.error('error: COMPONENTS entries in generate-ui-api.mjs are rendered by no MDX page.\n'); + for (const slug of unrendered) { + console.error(` - ${slug}`); + } + console.error( + '\nAdd a docs page that uses / , ' + + 'or remove the generator entry.', + ); + process.exitCode = 1; +} + +if (missing.length === 0 && unrendered.length === 0) { + console.log( + `✓ component coverage ok — ${componentEntries.length} vite entries and ${componentSlugs.length} docs slugs 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..f641b21bc --- /dev/null +++ b/apps/docs/scripts/generate-ui-api.mjs @@ -0,0 +1,419 @@ +/* + * 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 { existsSync, 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'; + +import { COMPONENTS } from './ui-components.mjs'; + +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'); + +// Engineering notes in the CSS, never public documentation. +const INTERNAL_NOTE_RE = /missing token/i; + +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, + // Whole tree, not the barrel: variant prop types are not re-exported. + '--entryPoints', + path.resolve(uiSource, 'components'), + // A type outside the entry tree gets no reflection and vanishes from the 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, warnings) { + const matches = []; + (function walk(node) { + if (node.name === name && (node.kind === 2_097_152 || node.kind === 256)) matches.push(node); + for (const child of node.children ?? []) walk(child); + })(root); + if (matches.length > 1 && warnings) { + warnings.push(`type name "${name}" is ambiguous (${matches.length} declarations) - the table would document whichever TypeDoc emitted first`); + } + return matches[0] ?? null; +} + +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; +} + +// Which native element a component forwards its remaining props to; listing +// ~280 DOM attributes in the table would drown the props that are ours. +const NATIVE_ATTRIBUTE_TYPES = new Map([ + ['InputHTMLAttributes', 'input'], + ['ButtonHTMLAttributes', 'button'], + ['TextareaHTMLAttributes', 'textarea'], + ['SelectHTMLAttributes', 'select'], + ['AnchorHTMLAttributes', 'a'], + ['HTMLAttributes', 'element'], +]); + +function findNativeElement(typeNode, byId, depth = 0) { + if (!typeNode || depth > 8) return null; + if (typeNode.type === 'reference') { + const element = NATIVE_ATTRIBUTE_TYPES.get(typeNode.name.replace(/^React\./, '')); + if (element === 'element') { + const tag = /^HTML(\w*?)Element$/.exec(typeNode.typeArguments?.[0]?.name ?? '')?.[1]; + return tag ? tag.toLowerCase() || 'element' : 'element'; + } + if (element) return element; + if (typeof typeNode.target === 'number') { + const found = findNativeElement(byId.get(typeNode.target)?.type, byId, depth + 1); + if (found) return found; + } + } + for (const nested of [...(typeNode.types ?? []), ...(typeNode.typeArguments ?? [])]) { + const found = findNativeElement(nested, byId, depth + 1); + if (found) return found; + } + return null; +} + +// Own properties of a prop type, walking intersections and skipping native members. +function collectProps(typeNode, byId, accumulator = new Map(), context = null) { + if (!typeNode) return accumulator; + 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, context); + } + if (typeNode.type === 'intersection' || typeNode.type === 'union') { + for (const member of typeNode.types) collectProps(member, byId, accumulator, context); + 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); + // Follow first-party prop types only; both declaration forms count. + if (target && (target.kind === 2_097_152 || target.kind === 256)) { + collectProps(target, byId, accumulator, context); + } + return accumulator; + } + // Partial / Omit would silently drop every prop of X. + if (typeNode.type === 'reference' && typeNode.typeArguments?.length && context) { + const firstParty = typeNode.typeArguments.find( + (argument) => argument.type === 'reference' && typeof argument.target === 'number' && byId.get(argument.target), + ); + if (firstParty) { + context.warnings.push( + `"${context.slug}": props of ${firstParty.name} are hidden behind ${typeNode.name}<...> - unwrap the utility type or extend the generator`, + ); + } + } + 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), + }); +} + +// Merges the variants of a union/overload component into one flat table, +// noting in the description where a prop applies to some variants only. +function collectVariantProps(propsTypeNames, project, byId, warnings, slug, context) { + const perVariant = []; + for (const typeName of propsTypeNames) { + const typeNode = findTypeByName(project, typeName, warnings); + if (!typeNode) { + warnings.push(`props type "${typeName}" not found for "${slug}"`); + continue; + } + context.nativeElement ??= findNativeElement(typeNode.type, byId); + perVariant.push({ typeName, props: collectProps(typeNode, byId, new Map(), context) }); + } + + 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) })) + // `foo?: never` marks a prop forbidden in that variant. + .filter((occurrence) => occurrence.prop.type !== 'never'); + if (occurrences.length === 0) continue; + const distinctTypes = new Set(occurrences.map((o) => o.prop.type)); + const sharedByAll = occurrences.length === perVariant.length && distinctTypes.size === 1; + + // Required in every variant, else the table documents an impossible call. + const requiredEverywhere = + occurrences.length === perVariant.length && occurrences.every((o) => o.prop.required); + const requiredInItsVariants = !requiredEverywhere && occurrences.every((o) => o.prop.required); + + const base = occurrences[0].prop; + let description = base.description; + if (!sharedByAll) { + const variantLabel = (typeName) => typeName.replace(/Props$/, ''); + const variants = occurrences.map((o) => variantLabel(o.typeName)).join(', '); + const note = + distinctTypes.size > 1 + ? `Type varies by variant (${occurrences.map((o) => `${variantLabel(o.typeName)}: ${o.prop.type}`).join(', ')}).` + : requiredInItsVariants + ? `Only applies to the ${variants} variant (required there).` + : `Only applies to the ${variants} variant.`; + description = description ? `${description} ${note}` : note; + } + + merged.set(propertyName, { + name: propertyName, + type: sharedByAll ? base.type : [...distinctTypes].join(' | '), + required: requiredEverywhere, + default: base.default, + description, + }); + } + return merged; +} + +function extractCssVariables(directory, warnings, slug) { + // No directory - the entry documents an API, not a styled component. + if (!directory) return []; + + const abs = path.resolve(uiSource, 'components', directory); + if (!existsSync(abs)) { + warnings.push(`"${slug}": component directory ${directory} does not exist`); + return []; + } + // Subcomponents with their own page document their own variables; an + // override offered on the parent page would do nothing. + const nestedPrefixes = COMPONENTS.map((component) => component.dir) + .filter((nested) => nested?.startsWith(`${directory}/`)) + .map((nested) => `${nested.slice(directory.length + 1)}/`); + + const files = globSync('**/*.css', { cwd: abs }) + .filter((file) => !nestedPrefixes.some((prefix) => file.startsWith(prefix))) + .sort(); + const seen = new Set(); + const variables = []; + for (const file of files) { + const css = readFileSync(path.resolve(abs, file), 'utf8'); + const re = /(--ax-public-[\w-]+)\s*:\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[3] ?? '').trim(); + variables.push({ + name: m[1], + kind: valueKind(m[2].trim()), + comment: INTERNAL_NOTE_RE.test(comment) ? '' : comment, + }); + } + } + return variables; +} + +// Groups by what the value resolves to - the name misleads (`edge-stroke-width` +// is a length, `snackbar-success-border` is a color), so follow it to the literal. +const LITERAL_COLOR_RE = /^(#|rgb|hsl|oklch|color-mix|linear-gradient|radial-gradient|transparent\b|currentColor\b)/i; + +const tokenValues = readTokenValues(); + +function readTokenValues() { + const values = new Map(); + const tokenDistribution = path.resolve(repoRoot, 'packages/tokens/dist'); + if (!existsSync(tokenDistribution)) return values; + for (const file of globSync('*.css', { cwd: tokenDistribution })) { + const css = readFileSync(path.resolve(tokenDistribution, file), 'utf8'); + for (const [, name, value] of css.matchAll(/(--ax-[\w-]+)\s*:\s*([^;]+);/g)) { + if (!values.has(name)) values.set(name, value.trim()); + } + } + return values; +} + +function valueKind(value, depth = 0) { + if (LITERAL_COLOR_RE.test(value)) return 'color'; + const referenced = /var\(\s*(--[\w-]+)/.exec(value); + if (!referenced || depth > 8) return 'size'; + const resolved = tokenValues.get(referenced[1]); + return resolved ? valueKind(resolved, depth + 1) : 'size'; +} + +async function main() { + const project = await runTypedoc(); + const byId = indexById(project); + const out = {}; + const warnings = []; + + for (const component of COMPONENTS) { + let props = []; + const context = { warnings, slug: component.slug }; + if (Array.isArray(component.propsType)) { + props = [...collectVariantProps(component.propsType, project, byId, warnings, component.slug, context).values()].sort( + (a, b) => a.name.localeCompare(b.name), + ); + } else if (component.propsType) { + const typeNode = findTypeByName(project, component.propsType, warnings); + if (typeNode) { + context.nativeElement = findNativeElement(typeNode.type, byId); + props = [...collectProps(typeNode, byId, new Map(), context).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, + nativeElement: context.nativeElement ?? null, + cssVariables: extractCssVariables(component.dir, warnings, component.slug), + }; + } + + 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 type would silently ship a "no configurable props" page. + console.error('✗ ' + warnings.join('\n✗ ')); + process.exitCode = 1; + } +} + +try { + await main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/apps/docs/scripts/ui-components.mjs b/apps/docs/scripts/ui-components.mjs new file mode 100644 index 000000000..03f4d47a1 --- /dev/null +++ b/apps/docs/scripts/ui-components.mjs @@ -0,0 +1,68 @@ +/** + * The documented component surface: which UI components get generated Props / + * CSS variables tables, which prop type each one exposes, and where its source + * lives under packages/ui/src/components. + * + * Imported by both the generator and the coverage guard, so the guard can + * never disagree with the generator about what is documented. + */ +// `propsType`: the exported prop type, a list of variant types, or null when +// the parts are described in prose. `dir`: the folder under +// packages/ui/src/components, or null for an entry that owns no stylesheet. +export const COMPONENTS = [ + { slug: 'accordion', name: 'Accordion', propsType: 'AccordionProps', dir: 'accordion' }, + { slug: 'avatar', name: 'Avatar', propsType: 'AvatarProps', dir: 'avatar' }, + // No single props type - one of three variants depending on `children`. + { + 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: ['NavLabelButtonProps', 'NavIconButtonProps', 'NavIconLabelButtonProps'], + dir: 'button/nav-button', + }, + { slug: 'radio', name: 'Radio', propsType: 'RadioProps', dir: 'radio-button' }, + { + slug: 'segment-picker', + name: 'SegmentPicker', + propsType: ['ControlledSegmentPickerProps', 'UncontrolledSegmentPickerProps'], + 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. + { 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' }, + // Documented on the Edge page, which already renders the edge variables. + { slug: 'use-edge-style', name: 'useEdgeStyle', propsType: 'UseEdgeStyleParams', dir: null }, + // Compound component - parts described in prose, variables generated. + { slug: 'node-panel', name: 'NodePanel', propsType: null, dir: 'node/node-panel' }, +]; 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..01c8cde06 --- /dev/null +++ b/apps/docs/src/components/api/css-variables-table.astro @@ -0,0 +1,79 @@ +--- +// CSS custom properties of a UI component, generated from source into ui-api.json. +import data from '../../generated/ui-api.json'; + +interface CssVariable { + name: string; + kind: 'color' | 'size'; + comment: string; +} + +const { slug } = Astro.props as { slug: string }; +const entry = (data as Record)[slug]; +if (!entry) { + throw new Error( + `ui-api.json has no entry for slug "${slug}" - check the slug against COMPONENTS in generate-ui-api.mjs`, + ); +} +const cssVariables = entry?.cssVariables ?? []; + +const groups = [ + { label: 'Color', vars: cssVariables.filter((v) => v.kind === 'color') }, + { label: 'Size', vars: cssVariables.filter((v) => v.kind !== 'color') }, +].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..0f6d6b3c1 --- /dev/null +++ b/apps/docs/src/components/api/props-table.astro @@ -0,0 +1,128 @@ +--- +// Props of a UI component, generated from source into ui-api.json. +import data from '../../generated/ui-api.json'; + +interface PropertyRow { + name: string; + type: string; + required: boolean; + default: string | null; + description: string; +} + +interface Entry { + props: PropertyRow[]; + nativeElement: string | null; +} + +const { slug } = Astro.props as { slug: string }; +const entry = (data as Record)[slug]; +if (!entry) { + throw new Error( + `ui-api.json has no entry for slug "${slug}" - check the slug against COMPONENTS in generate-ui-api.mjs`, + ); +} +const props = [...(entry?.props ?? [])].sort( + (a, b) => Number(b.required) - Number(a.required) || a.name.localeCompare(b.name), +); +const nativeElement = entry?.nativeElement ?? null; +--- + +{ + nativeElement && ( +

+ Also accepts every standard <{nativeElement}> attribute (onChange,{' '} + placeholder, aria-*, …); they are forwarded to the underlying element. +

+ ) +} + +{ + 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..be646fae6 --- /dev/null +++ b/apps/docs/src/components/ui-examples/component-preview.module.css @@ -0,0 +1,42 @@ +.stage { + position: relative; + width: 100%; + max-width: 50rem; + aspect-ratio: 2 / 1; + /* The ratio is a preferred size, not a clamp: overflow:hidden zeroes the + content-based automatic minimum, so restore it - oversized examples grow + the stage instead of being clipped. */ + min-height: fit-content; + margin-top: 1rem; + padding: 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%); + max-width: calc(100% + 20rem); +} + +.host { + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + min-width: 0; +} 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..5ca9ad289 --- /dev/null +++ b/apps/docs/src/components/ui-examples/component-preview.tsx @@ -0,0 +1,38 @@ +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'; + +// Examples render in a shadow root so Starlight's rules cannot reach them and +// the library's cannot leak out. Inherited and custom properties still cross +// the boundary - that is how the docs theme reaches the examples. Inside a +// shadow root `:root` matches nothing, hence the retarget to `:host`. +const shadowCss = `${`${globalCss}\n${componentCss}`.replaceAll(':root', ':host')} +:host > :not(style) { max-width: 100%; }`; + +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 ( + +