diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09a102d..8b07b00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,13 @@ on: concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: contents: read jobs: - registry: + quality: runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -42,15 +42,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build shadcn registry - run: pnpm build:registry - - - name: Verify migration registry installs - env: - SMOKE_CASE: org-chart,storage-browser,sheets,schema-builder,command-palette - run: pnpm --filter @constructive-io/registry smoke:install + - name: Check types and tests + run: pnpm check - packages: + delivery: runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -74,30 +69,41 @@ jobs: - name: Verify packed packages in clean consumers run: pnpm pack:check - pages: - needs: packages - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - persist-credentials: false + - name: Build shadcn registry + run: pnpm build:registry - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - name: Verify representative registry installs + env: + SMOKE_CASE: ai,schema-builder,command-palette + SMOKE_REUSE_PACKED_ARTIFACTS: '1' + run: pnpm --filter @constructive-io/registry smoke:install - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '24' - cache: pnpm + - name: Build static Blocks site + run: pnpm --filter blocks build:pages - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Assemble Pages artifact + run: pnpm pages:artifact - - name: Build Pages artifact - run: pnpm build:pages + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: actions/upload-pages-artifact@v5 + with: + path: .artifacts/pages + + deploy: + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + needs: [quality, delivery] + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 -# npm releases are intentionally manual. Package tags build the registry and -# Pages artifact only; this workflow contains no publish credentials or job. +# Package tags validate publishable tarballs and Pages inputs, but npm +# publication remains a manual maintainer action. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 90f3b96..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Pages - -on: - push: - branches: [main] - workflow_dispatch: {} - -concurrency: - group: pages-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '24' - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build static Blocks site - run: pnpm build:pages - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: .artifacts/pages - - deploy: - if: github.ref == 'refs/heads/main' - needs: build - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 - -# npm publication is a maintainer-run local process and is intentionally absent. diff --git a/apps/blocks/README.md b/apps/blocks/README.md index b5056f6..5b89ae9 100644 --- a/apps/blocks/README.md +++ b/apps/blocks/README.md @@ -20,8 +20,7 @@ pnpm --filter blocks build:pages `src/lib/base-primitives.ts`, `src/lib/billing-blocks.ts`, and `src/lib/feature-packs.ts` are the documentation catalogs. `pnpm gen:check` -validates primitive distribution and keeps the feature-pack docs aligned with -their manifests, registry roots, and live previews. +regenerates (or checks) generated UI demo source used by the docs app. From the repository root, `pnpm --silent console-kit:inspect --item ` diff --git a/apps/blocks/next.config.ts b/apps/blocks/next.config.ts index c516483..337fa75 100644 --- a/apps/blocks/next.config.ts +++ b/apps/blocks/next.config.ts @@ -18,7 +18,9 @@ const nextConfig: NextConfig = { : {}), transpilePackages: ['@constructive-io/ui'], experimental: { - optimizePackageImports: ['@base-ui/react', '@constructive-io/ui', 'lucide-react'], + // Do not include @constructive-io/ui: subpath exports like /ai, /tabs, /button + // break under optimizePackageImports (client components resolve to undefined → blank page). + optimizePackageImports: ['@base-ui/react', 'lucide-react'], }, turbopack: { root: path.join(__dirname, '..', '..'), diff --git a/apps/blocks/package.json b/apps/blocks/package.json index 0209790..c45e527 100644 --- a/apps/blocks/package.json +++ b/apps/blocks/package.json @@ -3,11 +3,10 @@ "version": "0.0.0", "private": true, "scripts": { - "gen": "tsx scripts/generate-ui-demo-source.ts && tsx scripts/check-docs.ts", - "gen:check": "tsx scripts/generate-ui-demo-source.ts --check && tsx scripts/check-docs.ts", - "build:registry": "pnpm --filter @constructive-io/registry build", - "check:flows": "pnpm gen:check", - "check:install-namespace": "pnpm gen:check", + "gen": "tsx scripts/generate-ui-demo-source.ts", + "gen:check": "tsx scripts/generate-ui-demo-source.ts --check", + "build:registry": "pnpm --workspace-root build:registry", + "check:generated": "pnpm gen:check", "check:selections": "tsx scripts/check-mutation-selections.ts", "predev": "pnpm gen:check", "prebuild": "pnpm gen:check", @@ -15,8 +14,7 @@ "build": "next build", "build:pages": "BLOCKS_PAGES=1 next build", "start": "next start --port 3005", - "lint:types": "tsc --noEmit && pnpm scripts:typecheck", - "scripts:typecheck": "tsc --noEmit -p tsconfig.scripts.json", + "lint:types": "tsc --noEmit", "test": "vitest run && pnpm test:native-fixture", "test:native-fixture": "tsx --test e2e-live/native-fixture.test.ts", "test:watch": "vitest --watch", diff --git a/apps/blocks/scripts/check-docs.ts b/apps/blocks/scripts/check-docs.ts deleted file mode 100644 index 3f42edf..0000000 --- a/apps/blocks/scripts/check-docs.ts +++ /dev/null @@ -1,723 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import ts from 'typescript'; - -import { PRIMITIVE_DOCS } from '../src/content/ui'; -import { FEATURE_PACK_IDS, FEATURE_PACK_MANIFESTS } from '../src/feature-packs'; -import { UI_DEMO_SOURCE } from '../src/generated/ui-demo-source'; -import { APPLICATION_BLOCKS } from '../src/lib/application-blocks'; -import { APPLICATION_DOC_SEQUENCE } from '../src/lib/application-doc-navigation'; -import { BASE_PRIMITIVES, packageImport, type BasePrimitiveName } from '../src/lib/base-primitives'; -import { COMPONENT_DOC_SEQUENCE } from '../src/lib/component-doc-navigation'; -import { COMMAND_PALETTE_DOC } from '../src/lib/command-palette-docs'; -import { packageCommands, registryCommands } from '../src/lib/install-mode'; -import { FEATURE_PACK_DOCS } from '../src/lib/feature-packs'; -import { PRIMITIVE_DOC_SECTION_ORDER, type PrimitiveApiPart } from '../src/lib/primitive-docs'; -import { SOURCE_BLOCKS } from '../src/lib/source-blocks'; - -type PackageManifest = { - devDependencies?: Record; - exports?: Record; -}; - -type RegistryManifest = { - items?: Array<{ name?: string; docs?: string }>; -}; - -const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); -const appDirectory = path.resolve(scriptDirectory, '..'); -const repositoryRoot = path.resolve(appDirectory, '..', '..'); -const uiDirectory = path.join(repositoryRoot, 'packages', 'ui'); -const contentDirectory = path.join(appDirectory, 'src', 'content', 'ui'); - -function readJson(file: string): T { - return JSON.parse(fs.readFileSync(file, 'utf8')) as T; -} - -function isEmpty(values: readonly unknown[]): boolean { - return values.length === 0; -} - -const APPLICATION_DOC_SECTION_ORDER = [ - 'installation', - 'when-to-use', - 'usage', - 'state', - 'composition', - 'examples', - 'accessibility', - 'api-reference', -] as const; - -function validateSectionOrder( - errors: string[], - relativePath: string, - label: string, -): void { - const file = path.join(appDirectory, relativePath); - if (!fs.existsSync(file)) { - errors.push(`${label}: missing documentation template ${relativePath}`); - return; - } - - const source = fs.readFileSync(file, 'utf8'); - let previousPosition = -1; - for (const section of APPLICATION_DOC_SECTION_ORDER) { - const position = source.indexOf(`id="${section}"`); - if (position === -1) { - errors.push(`${label}: missing ${section} documentation section`); - continue; - } - if (position < previousPosition) { - errors.push(`${label}: ${section} is out of canonical documentation order`); - } - previousPosition = position; - } -} - -function collectFiles(directory: string): string[] { - if (!fs.existsSync(directory)) return []; - - return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - if ( - entry.name === '.git' || - entry.name === '.next' || - entry.name === 'dist' || - entry.name === 'node_modules' || - entry.name === 'out' - ) { - return []; - } - const target = path.join(directory, entry.name); - return entry.isDirectory() ? collectFiles(target) : [target]; - }); -} - -function isPublicDocumentationSource(file: string): boolean { - const relativePath = path.relative(repositoryRoot, file).split(path.sep).join('/'); - const extension = path.extname(file); - - if (extension === '.md' || extension === '.mdx') return true; - if ( - relativePath.startsWith('apps/blocks/src/') && - (extension === '.ts' || extension === '.tsx') - ) { - return true; - } - if (relativePath === 'apps/blocks/registry.json') return true; - if (relativePath === 'apps/registry/registry.json') return true; - if ( - relativePath.startsWith('apps/registry/public/r/') && - extension === '.json' - ) { - return true; - } - if (/^packages\/[^/]+\/registry\.json$/.test(relativePath)) return true; - return /^packages\/[^/]+\/scripts\/build-registry\.ts$/.test(relativePath); -} - -function validatePublicShadcnCommands(errors: string[]): void { - const rules: ReadonlyArray<{ - message: string; - pattern: RegExp; - isViolation?: (match: RegExpMatchArray, source: string) => boolean; - }> = [ - { - message: 'use pnpm dlx instead of npx for public shadcn commands', - pattern: /\bnpx\s+shadcn(?:@[^\s`"']+)?/g, - }, - { - message: 'use pnpm dlx instead of bunx for public shadcn commands', - pattern: /\bbunx(?:\s+--bun)?\s+shadcn(?:@[^\s`"']+)?/g, - }, - { - message: 'public shadcn references must use shadcn@latest instead of a pinned tag or version', - pattern: /\bshadcn@([a-z0-9_-]+(?:\.[a-z0-9_-]+)*)/gi, - isViolation: (match) => match[1] !== 'latest', - }, - { - message: 'public add commands must use pnpm dlx shadcn@latest', - pattern: /\bshadcn\s+add\b/g, - }, - { - message: 'public shadcn commands must use the pnpm dlx runner', - pattern: /\bshadcn@[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\s+[a-z][a-z0-9:-]*\b/gi, - isViolation: (match, source) => { - const prefix = source - .slice(Math.max(0, (match.index ?? 0) - 200), match.index) - .replace(/<[^>]*>/g, ' ') - .replace(/\{\s*['"]\s*['"]\s*\}/g, ' '); - return !/pnpm\s+dlx\s+$/.test(prefix); - }, - }, - ] as const; - - const files = collectFiles(repositoryRoot).filter(isPublicDocumentationSource); - for (const file of files) { - const source = fs.readFileSync(file, 'utf8'); - for (const rule of rules) { - rule.pattern.lastIndex = 0; - for (const match of source.matchAll(rule.pattern)) { - if (rule.isViolation && !rule.isViolation(match, source)) continue; - const line = source.slice(0, match.index).split('\n').length; - errors.push( - `${path.relative(repositoryRoot, file)}:${line}: ${rule.message} (found "${match[0]}")`, - ); - } - } - } -} - -function hasExportModifier(node: ts.Node): boolean { - return ( - ts.canHaveModifiers(node) && - (ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false) - ); -} - -function bindingNames(name: ts.BindingName): string[] { - if (ts.isIdentifier(name)) return [name.text]; - return name.elements.flatMap((element) => (ts.isOmittedExpression(element) ? [] : bindingNames(element.name))); -} - -function runtimeExports(sourceFile: ts.SourceFile): string[] { - const exports = new Set(); - - for (const statement of sourceFile.statements) { - if ( - ts.isExportDeclaration(statement) && - !statement.isTypeOnly && - statement.exportClause && - ts.isNamedExports(statement.exportClause) - ) { - for (const specifier of statement.exportClause.elements) { - if (!specifier.isTypeOnly) exports.add(specifier.name.text); - } - continue; - } - - if (!hasExportModifier(statement)) continue; - if ( - (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && - statement.name - ) { - exports.add(statement.name.text); - continue; - } - if (ts.isVariableStatement(statement)) { - for (const declaration of statement.declarationList.declarations) { - for (const name of bindingNames(declaration.name)) exports.add(name); - } - } - } - - return [...exports].sort(); -} - -function loadUiProgram(files: string[]): { checker: ts.TypeChecker; program: ts.Program } { - const configFile = path.join(uiDirectory, 'tsconfig.json'); - const config = ts.readConfigFile(configFile, ts.sys.readFile); - if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')); - const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, uiDirectory, undefined, configFile); - if (parsed.errors.length > 0) { - throw new Error(parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')); - } - const program = ts.createProgram({ rootNames: files, options: { ...parsed.options, noEmit: true } }); - return { checker: program.getTypeChecker(), program }; -} - -function exportedPropNames( - checker: ts.TypeChecker, - sourceFile: ts.SourceFile, - exportName: string, -): Set | undefined { - const moduleSymbol = checker.getSymbolAtLocation(sourceFile); - if (!moduleSymbol) return undefined; - const exported = checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === exportName); - if (!exported) return undefined; - const target = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported; - const declaration = target.valueDeclaration ?? target.declarations?.[0] ?? sourceFile; - const type = checker.getTypeOfSymbolAtLocation(target, declaration); - const signatures = [...type.getCallSignatures(), ...type.getConstructSignatures()]; - if (signatures.length === 0) return undefined; - - const props = new Set(); - for (const signature of signatures) { - const parameter = signature.getParameters()[0]; - if (!parameter) continue; - const location = parameter.valueDeclaration ?? parameter.declarations?.[0] ?? declaration; - const parameterType = checker.getTypeOfSymbolAtLocation(parameter, location); - for (const prop of checker.getPropertiesOfType(parameterType)) props.add(prop.name); - } - return props; -} - -function validateApiProps( - errors: string[], - checker: ts.TypeChecker, - sourceFile: ts.SourceFile, - primitiveName: string, - api: readonly PrimitiveApiPart[], -): void { - for (const part of api) { - if (!part.props?.length) continue; - const props = exportedPropNames(checker, sourceFile, part.name); - if (!props) { - errors.push(`${primitiveName}: cannot resolve callable props for documented ${part.name}`); - continue; - } - for (const prop of part.props) { - if (!props.has(prop.name)) { - errors.push(`${primitiveName}: ${part.name}.${prop.name} is not present in the canonical source type`); - } - } - } -} - -const names = BASE_PRIMITIVES.map(({ name }) => name); -const uniqueNames = new Set(names); -if (names.length !== 29 || uniqueNames.size !== names.length) { - throw new Error(`The docs catalog must contain exactly 29 unique primitives; received ${names.length}.`); -} - -const uiPackage = readJson(path.join(uiDirectory, 'package.json')); -const uiRegistry = readJson(path.join(uiDirectory, 'registry.json')); -const registryNames = new Set((uiRegistry.items ?? []).flatMap((item) => (item.name ? [item.name] : []))); -const componentFiles = BASE_PRIMITIVES.map(({ name }) => path.join(uiDirectory, 'src', 'components', `${name}.tsx`)); -const { checker, program } = loadUiProgram(componentFiles); -const errors: string[] = []; - -validatePublicShadcnCommands(errors); - -const contentFiles = fs - .readdirSync(contentDirectory) - .filter((file) => file.endsWith('.ts') && file !== 'index.ts') - .sort(); -const expectedContentFiles = names.map((name) => `${name}.ts`).sort(); -if (JSON.stringify(contentFiles) !== JSON.stringify(expectedContentFiles)) { - errors.push('src/content/ui must contain exactly one TypeScript content module per base primitive'); -} - -if (PRIMITIVE_DOC_SECTION_ORDER.at(-1) !== 'api-reference') { - errors.push('API Reference must remain the final primitive documentation section'); -} - -if (JSON.stringify(Object.keys(PRIMITIVE_DOCS)) !== JSON.stringify(names)) { - errors.push('Primitive docs map must match the ordered base primitive catalog exactly'); -} -if (JSON.stringify(Object.keys(UI_DEMO_SOURCE)) !== JSON.stringify([...names].sort())) { - errors.push('Generated demo source must contain exactly the 29 sorted base primitive slugs'); -} - -for (const primitive of BASE_PRIMITIVES) { - if (!uiPackage.exports?.[`./${primitive.name}`]) { - errors.push(`${primitive.name}: missing @constructive-io/ui package export`); - } - if (!registryNames.has(primitive.name)) { - errors.push(`${primitive.name}: missing packages/ui registry item`); - } - - const demoPath = path.join(appDirectory, 'src', 'components', 'docs', 'demos', `ui-${primitive.name}.demo.tsx`); - if (!fs.existsSync(demoPath)) { - errors.push(`${primitive.name}: missing package-backed preview`); - continue; - } - const demoSource = fs.readFileSync(demoPath, 'utf8'); - if (!demoSource.includes(`@constructive-io/ui/${primitive.name}`)) { - errors.push(`${primitive.name}: preview must import its npm package subpath`); - } - - const docs = PRIMITIVE_DOCS[primitive.name]; - if (docs.name !== primitive.name) errors.push(`${primitive.name}: content module name does not match its key`); - if (docs.whenToUse.length === 0) errors.push(`${primitive.name}: missing when-to-use guidance`); - if (docs.examples.length < 1 || docs.examples.length > 6) { - errors.push(`${primitive.name}: expected one to six focused component-specific examples`); - } - if (docs.accessibility.length === 0) errors.push(`${primitive.name}: missing accessibility guidance`); - if (docs.stateModel === 'stateless' && docs.state) - errors.push(`${primitive.name}: stateless docs cannot render state guidance`); - if (docs.stateModel !== 'stateless' && !docs.state) - errors.push(`${primitive.name}: stateful docs require state guidance`); - - const generated = UI_DEMO_SOURCE[primitive.name] as Record; - const demoReferences = [ - 'BlockDemo', - docs.usage.demo, - ...(docs.state?.demo ? [docs.state.demo] : []), - ...docs.examples.map(({ demo }) => demo), - ]; - if (new Set(demoReferences).size !== demoReferences.length) { - errors.push(`${primitive.name}: example references must be distinct across page sections`); - } - for (const demo of demoReferences) { - const source = generated?.[demo]; - if (!source) { - errors.push(`${primitive.name}: missing generated source for ${demo}`); - continue; - } - if ( - source.npm.includes('@/components/docs/showcase-kit') || - source.registry.includes('@/components/docs/showcase-kit') - ) { - errors.push(`${primitive.name}:${demo}: consumer source must omit the docs-only Demo wrapper`); - } - if (!source.npm.includes(`@constructive-io/ui/${primitive.name}`)) { - errors.push(`${primitive.name}:${demo}: npm source must use the primitive package subpath`); - } - if (source.registry.includes('@constructive-io/ui')) { - errors.push(`${primitive.name}:${demo}: registry source contains a package import`); - } - if (!source.registry.includes(`@/components/ui/${primitive.name}`)) { - errors.push(`${primitive.name}:${demo}: registry source must use the local UI alias`); - } - } - - const sourceFile = program.getSourceFile(componentFiles[names.indexOf(primitive.name)]); - if (!sourceFile) { - errors.push(`${primitive.name}: TypeScript did not load the canonical component source`); - continue; - } - const actualExports = runtimeExports(sourceFile); - const documentedExports = docs.api.map(({ name }) => name).sort(); - if (JSON.stringify(documentedExports) !== JSON.stringify(actualExports)) { - const missing = actualExports.filter((name) => !documentedExports.includes(name)); - const extra = documentedExports.filter((name) => !actualExports.includes(name)); - errors.push( - `${primitive.name}: API export mismatch${missing.length ? `; missing ${missing.join(', ')}` : ''}${ - extra.length ? `; extra ${extra.join(', ')}` : '' - }`, - ); - } - validateApiProps(errors, checker, sourceFile, primitive.name, docs.api); - - const npmCommands = packageCommands({ globals: true, importLine: packageImport(primitive) }); - const registryImport = `import { ${primitive.exportName} } from '@/components/ui/${primitive.name}';`; - const shadcnCommands = registryCommands({ item: primitive.name, includeConfig: true, importLine: registryImport }); - if (!npmCommands.some(({ code }) => code === "@import '@constructive-io/ui/globals.css';")) { - errors.push(`${primitive.name}: npm installation must include globals.css`); - } - if (!shadcnCommands.some(({ code }) => code === `pnpm dlx shadcn@latest add @constructive/${primitive.name}`)) { - errors.push(`${primitive.name}: registry installation command is incorrect`); - } - if (!shadcnCommands.some(({ code }) => code === registryImport)) { - errors.push(`${primitive.name}: registry installation must show the local alias import`); - } -} - -const documentedFeaturePackIds = FEATURE_PACK_DOCS.map(({ id }) => id); -if (JSON.stringify(documentedFeaturePackIds) !== JSON.stringify(FEATURE_PACK_IDS)) { - errors.push('Feature-pack docs must match the seven canonical ids in dependency order'); -} - -const featurePackRegistry = readJson(path.join(appDirectory, 'registry.json')); -const featurePackRegistryItems = new Map( - (featurePackRegistry.items ?? []).flatMap((item) => (item.name ? [[item.name, item] as const] : [])), -); - -for (const relativePath of [ - path.join('src', 'app', 'blocks', 'features', '[pack]', 'page.tsx'), - path.join('src', 'app', 'blocks', 'features', '[pack]', 'preview', 'page.tsx'), -]) { - if (!fs.existsSync(path.join(appDirectory, relativePath))) { - errors.push(`Missing feature-pack route ${relativePath}`); - } -} - -for (const block of FEATURE_PACK_DOCS) { - const manifest = FEATURE_PACK_MANIFESTS.find(({ id }) => id === block.id); - if (!manifest) { - errors.push(`${block.id}: missing canonical feature-pack manifest`); - continue; - } - - const expectedEndpoints = [ - ...manifest.endpoints.required, - ...manifest.endpoints.optional.map((endpoint) => `optional ${endpoint}`), - ].join(', '); - if (block.registryName !== `feature-pack-${block.id}`) { - errors.push(`${block.id}: registry name must be feature-pack-${block.id}`); - } - if (JSON.stringify(block.dependencies) !== JSON.stringify(manifest.dependencies)) { - errors.push(`${block.id}: documented dependencies drifted from its manifest`); - } - if (block.endpoints !== expectedEndpoints) { - errors.push(`${block.id}: documented endpoints drifted from its manifest`); - } - if ( - block.whenToUse.length < 2 || - isEmpty(block.surfaces) || - isEmpty(block.accessibility) || - isEmpty(block.api) || - !block.state.description || - !block.state.actionGuidance || - !block.usage.description - ) { - errors.push(`${block.id}: feature-pack docs are missing required editorial coverage`); - } - - const apiLabels = block.api.flatMap(({ name }) => name.split(' / ').map((prop) => prop.trim())); - const documentedApiProps = [...block.apiProps]; - if ( - new Set(apiLabels).size !== apiLabels.length || - JSON.stringify([...apiLabels].sort()) !== JSON.stringify([...documentedApiProps].sort()) - ) { - errors.push(`${block.id}: API labels must exactly match its typed public property catalog`); - } - - const expectedImport = `@/blocks/feature-packs/${block.id}/${block.id}-feature-pack`; - if (!block.usage.example.includes(block.exportName) || !block.usage.example.includes(expectedImport)) { - errors.push(`${block.id}: basic usage must import and render ${block.exportName}`); - } - const registryItem = featurePackRegistryItems.get(block.registryName); - if (!registryItem?.docs?.includes(expectedImport)) { - errors.push(`${block.id}: registry item must document its feature-pack root import`); - } -} - -validateSectionOrder( - errors, - path.join('src', 'components', 'application-block-showcase', 'application-block-docs-page.tsx'), - 'Application blocks', -); -validateSectionOrder( - errors, - path.join('src', 'components', 'source-block-showcase', 'source-block-docs-page.tsx'), - 'Source blocks', -); -validateSectionOrder( - errors, - path.join('src', 'app', 'blocks', 'command-palette', 'page.tsx'), - 'Command Palette', -); - -const applicationBlockNames = APPLICATION_BLOCKS.map(({ name }) => name); -if ( - APPLICATION_BLOCKS.length !== 2 || - new Set(applicationBlockNames).size !== APPLICATION_BLOCKS.length -) { - errors.push('Application-block docs must contain exactly two unique entries'); -} - -for (const block of APPLICATION_BLOCKS) { - for (const relativePath of [ - path.join('src', 'app', 'blocks', block.name, 'page.tsx'), - path.join('src', 'app', 'blocks', block.name, 'preview', 'page.tsx'), - ]) { - if (!fs.existsSync(path.join(appDirectory, relativePath))) { - errors.push(`${block.name}: missing application-block route ${relativePath}`); - } - } - - if ( - block.whenToUse.length < 2 || - isEmpty(block.composition) || - isEmpty(block.accessibility) || - isEmpty(block.api) || - !block.usage.description || - !block.usage.example || - !block.state.description || - !block.previewDescription || - block.previewHeight < 480 - ) { - errors.push(`${block.name}: application-block docs are missing required editorial coverage`); - } -} - -const orgChartDoc = APPLICATION_BLOCKS.find(({ name }) => name === 'org-chart'); -for (const requiredSource of [ - 'type CompanyOrgChartProps', - 'saveReportingLine:', - 'openPositionEditor:', - 'openRemovalConfirmation:', - '}: CompanyOrgChartProps)', - 'positionTitle: preserve.positionTitle', -]) { - if (!orgChartDoc?.usage.example.includes(requiredSource)) { - errors.push(`org-chart: basic usage is missing injected host callback ${requiredSource}`); - } -} - -const storageBrowserDoc = APPLICATION_BLOCKS.find( - ({ name }) => name === 'storage-browser', -); -for (const requiredSource of [ - 'useMemo', - 'const visibleObjects', - 'object.bucketId !== bucketId', - 'compareObjects(left, right, sort)', - 'objects={visibleObjects}', - 'onBulkDelete={actions.confirmDelete}', - 'onDelete={(object) => actions.confirmDelete([object.id])}', -]) { - if (!storageBrowserDoc?.usage.example.includes(requiredSource)) { - errors.push(`storage-browser: basic usage is missing ${requiredSource}`); - } -} -if (storageBrowserDoc?.usage.example.includes('onEmptyStateAction=')) { - errors.push( - 'storage-browser: basic usage must not route every empty-state action to one workflow', - ); -} - -const sourceBlockNames = SOURCE_BLOCKS.map(({ name }) => name); -if ( - JSON.stringify(sourceBlockNames) !== JSON.stringify(['sheets', 'schema-builder']) || - new Set(sourceBlockNames).size !== SOURCE_BLOCKS.length -) { - errors.push('Source-block docs must contain Sheets and Schema Builder in navigation order'); -} - -const applicationDocIds = APPLICATION_DOC_SEQUENCE.map(({ id }) => id); -const expectedApplicationDocIds = [ - 'org-chart', - 'storage-browser', - 'sheets', - 'schema-builder', - 'console-kit', -]; -if ( - JSON.stringify(applicationDocIds) !== JSON.stringify(expectedApplicationDocIds) || - new Set(applicationDocIds).size !== APPLICATION_DOC_SEQUENCE.length -) { - errors.push('Application documentation pagination must follow the canonical sidebar sequence'); -} - -const componentDocIds = COMPONENT_DOC_SEQUENCE.map(({ id }) => id); -const expectedComponentDocIds = BASE_PRIMITIVES.map(({ name }) => name) as string[]; -const dialogIndex = expectedComponentDocIds.indexOf('dialog'); -expectedComponentDocIds.splice(dialogIndex, 0, 'command-palette'); -if ( - JSON.stringify(componentDocIds) !== JSON.stringify(expectedComponentDocIds) || - new Set(componentDocIds).size !== COMPONENT_DOC_SEQUENCE.length -) { - errors.push('Component documentation pagination must include Command Palette in sidebar order'); -} - -for (const block of SOURCE_BLOCKS) { - for (const relativePath of [ - path.join('src', 'app', 'blocks', block.name, 'page.tsx'), - path.join('src', 'app', 'blocks', block.name, 'preview', 'page.tsx'), - ]) { - if (!fs.existsSync(path.join(appDirectory, relativePath))) { - errors.push(`${block.name}: missing source-block route ${relativePath}`); - } - } - - if ( - block.whenToUse.length < 2 || - isEmpty(block.composition) || - isEmpty(block.accessibility) || - isEmpty(block.api) || - !block.usage.description || - !block.usage.example || - !block.state.description || - !block.state.actionGuidance || - !block.previewDescription || - block.previewHeight < 480 - ) { - errors.push(`${block.name}: source-block docs are missing required editorial coverage`); - } -} - -const sheetsDoc = SOURCE_BLOCKS.find(({ name }) => name === 'sheets'); -const sheetsSupportingSource = sheetsDoc?.usage.supportingExamples - ?.map(({ source }) => source) - .join('\n') ?? ''; -if ( - !sheetsDoc?.usage.example.includes(' name === 'schema-builder'); -for (const requiredSource of [ - 'QueryClientProvider', - 'adapter={adapter}', - 'scope={scope}', - 'colorMode={colorMode}', - 'activeTab={activeTab}', - 'onActiveTabChange={setActiveTab}', - 'preferences={preferences}', - 'onPreferencesChange={setPreferences}', - 'min-h-0', -]) { - if (!schemaBuilderDoc?.usage.example.includes(requiredSource)) { - errors.push(`schema-builder: basic usage is missing ${requiredSource}`); - } -} - -const sourcePreview = fs.readFileSync( - path.join(appDirectory, 'src', 'components', 'source-block-showcase', 'source-block-showcase-canvas.tsx'), - 'utf8', -); -for (const expectedSource of [ - "from '@constructive-io/schema-builder'", - "from '@constructive-io/sheets'", - ' name.split(' / ').map((property) => property.trim())) - .sort(); -const expectedCommandPaletteApi = [ - 'backgroundTasks', - 'label', - 'navigate', - 'onOpenChange', - 'open', - 'placeholder', - 'registry', - 'shortcut', -].sort(); -if (JSON.stringify(commandPaletteApi) !== JSON.stringify(expectedCommandPaletteApi)) { - errors.push('command-palette: API Reference must match CommandPaletteProps'); -} - -const appPackage = readJson(path.join(appDirectory, 'package.json')); -if (appPackage.devDependencies?.shadcn !== '4.13.1') { - errors.push('apps/blocks must pin shadcn to 4.13.1'); -} - -const firstPartyMjs = collectFiles(repositoryRoot).filter( - (file) => file.endsWith('.mjs') && !file.includes(`${path.sep}node_modules${path.sep}`), -); -for (const file of firstPartyMjs) { - errors.push(`${path.relative(repositoryRoot, file)}: first-party .mjs files are not allowed`); -} - -if (errors.length > 0) { - throw new Error(`Blocks docs contract failed:\n- ${errors.join('\n- ')}`); -} - -console.log('Blocks docs expose exactly 29 source-checked primitive references.'); -console.log('Feature-pack docs expose seven manifest-aligned live references.'); -console.log( - 'Application docs expose two composed blocks and two package-backed source blocks.', -); -console.log('Component docs include Command Palette in the canonical component sequence.'); -console.log('Every checked docs family has complete integration, state, accessibility, and API-last coverage.'); -console.log('Public docs use pnpm dlx shadcn@latest for every shadcn CLI command.'); diff --git a/apps/blocks/src/app/blocks/ai/[name]/page.tsx b/apps/blocks/src/app/blocks/ai/[name]/page.tsx new file mode 100644 index 0000000..8719540 --- /dev/null +++ b/apps/blocks/src/app/blocks/ai/[name]/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; + +import { AiComponentDocsPage } from '@/components/ai-showcase/ai-component-docs-page'; +import { AI_COMPONENTS, getAiComponent } from '@/lib/ai-components'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +type PageProps = { params: Promise<{ name: string }> }; + +/** Former standalone pages folded into a parent surface. */ +const AI_DOC_REDIRECTS: Record = { + 'scroll-button': '/blocks/ai/chat-container', +}; + +export default async function AiComponentPage({ params }: PageProps) { + const { name } = await params; + const redirectTo = AI_DOC_REDIRECTS[name]; + if (redirectTo) permanentRedirect(redirectTo); + + const component = getAiComponent(name); + if (!component) return notFound(); + return ; +} + +export function generateStaticParams() { + return AI_COMPONENTS.map(({ name }) => ({ name })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { name } = await params; + const component = getAiComponent(name); + if (!component) return {}; + + const url = withBase(`/blocks/ai/${component.name}`); + return { + title: component.title, + description: component.description, + alternates: { canonical: url }, + openGraph: { + title: component.title, + description: component.description, + url, + images: [OG_IMAGE], + }, + }; +} diff --git a/apps/blocks/src/app/blocks/ai/page.tsx b/apps/blocks/src/app/blocks/ai/page.tsx new file mode 100644 index 0000000..911b823 --- /dev/null +++ b/apps/blocks/src/app/blocks/ai/page.tsx @@ -0,0 +1,188 @@ +import type { Metadata } from 'next'; + +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@constructive-io/ui/table'; + +import Link from 'next/link'; + +import { AiShowcaseDemo } from '@/components/ai-showcase/ai-showcase-demo'; +import { CodeBlock } from '@/components/docs/code-block'; +import { DocSection } from '@/components/docs/doc-section'; +import { AI_COMPONENTS } from '@/lib/ai-components'; +import { AI_DOC } from '@/lib/ai-docs'; +import { REGISTRY_COMPONENTS_JSON, registryAdd } from '@/lib/install-mode'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +function GuidanceList({ items }: { items: readonly string[] }) { + return ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ ); +} + +function ApiTable() { + const caption = `Primary exports for ${AI_DOC.title}.`; + return ( + + {caption} + + + Export + Kind + Behavior + + + + {AI_DOC.api.map((row) => ( + + {row.name} + + {row.type} + + + {row.behavior} + + + ))} + +
+ ); +} + +export default function AiPage() { + const doc = AI_DOC; + + return ( +
+
+
+

AI

+

+ {doc.title} +

+

+ {doc.description} +

+
+ + +
+ + + {REGISTRY_COMPONENTS_JSON} +
+ {registryAdd(doc.name)} +
+
+ + + + + + + + {doc.usage.example} + + + + + + + + +
+ {doc.composition.boundaries.map((item) => ( +
+

{item.title}

+

{item.body}

+
+ ))} +
+
+ + + + + + + + + + + + + + +
    + {AI_COMPONENTS.map((component) => ( +
  • + + {component.title} + + {component.description} + + +
  • + ))} +
+
+
+ ); +} + +export const metadata: Metadata = { + title: AI_DOC.title, + description: AI_DOC.description, + alternates: { canonical: withBase('/blocks/ai') }, + openGraph: { + title: AI_DOC.title, + description: AI_DOC.description, + url: withBase('/blocks/ai'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/page.tsx b/apps/blocks/src/app/blocks/page.tsx index 82425c7..fe79855 100644 --- a/apps/blocks/src/app/blocks/page.tsx +++ b/apps/blocks/src/app/blocks/page.tsx @@ -34,6 +34,12 @@ const APPLICATION_CATALOG = [ description: 'A full-page console driven by injected endpoints, session state, adapters, and versioned _meta.', }, + { + href: '/blocks/ai', + title: 'AI', + description: + 'Chat, agent traces, tools, HITL approval, and plan chrome for AI-native apps.', + }, ] as const; export default function SetupPage() { diff --git a/apps/blocks/src/app/sitemap.test.ts b/apps/blocks/src/app/sitemap.test.ts index 35c4347..a50f256 100644 --- a/apps/blocks/src/app/sitemap.test.ts +++ b/apps/blocks/src/app/sitemap.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { AI_COMPONENTS } from '@/lib/ai-components'; import { APPLICATION_BLOCKS } from '@/lib/application-blocks'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; import { BILLING_BLOCKS } from '@/lib/billing-blocks'; @@ -9,7 +10,7 @@ import { SOURCE_BLOCKS } from '@/lib/source-blocks'; import sitemap from './sitemap'; describe('sitemap', () => { - it('contains foundations, application blocks, seven feature packs, 29 primitives, and the complete billing catalog', () => { + it('contains foundations, application blocks, seven feature packs, 29 primitives, AI catalog, and the complete billing catalog', () => { const entries = sitemap(); expect(BASE_PRIMITIVES).toHaveLength(29); expect(entries).toHaveLength( @@ -18,7 +19,8 @@ describe('sitemap', () => { BILLING_BLOCKS.length + APPLICATION_BLOCKS.length + SOURCE_BLOCKS.length + - 7, + AI_COMPONENTS.length + + 8, ); expect(entries.map(({ url }) => url)).toEqual([ 'http://localhost:3005/', @@ -27,6 +29,8 @@ describe('sitemap', () => { 'http://localhost:3005/blocks/features', ...FEATURE_PACK_DOCS.map(({ id }) => `http://localhost:3005/blocks/features/${id}`), 'http://localhost:3005/blocks/command-palette', + 'http://localhost:3005/blocks/ai', + ...AI_COMPONENTS.map(({ name }) => `http://localhost:3005/blocks/ai/${name}`), ...SOURCE_BLOCKS.map(({ name }) => `http://localhost:3005/blocks/${name}`), ...APPLICATION_BLOCKS.map(({ name }) => `http://localhost:3005/blocks/${name}`), 'http://localhost:3005/blocks/console-kit', diff --git a/apps/blocks/src/app/sitemap.ts b/apps/blocks/src/app/sitemap.ts index 21bcb72..6d8aadc 100644 --- a/apps/blocks/src/app/sitemap.ts +++ b/apps/blocks/src/app/sitemap.ts @@ -1,5 +1,6 @@ import type { MetadataRoute } from 'next'; +import { AI_COMPONENTS } from '@/lib/ai-components'; import { APPLICATION_BLOCKS } from '@/lib/application-blocks'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; import { BILLING_BLOCKS } from '@/lib/billing-blocks'; @@ -17,6 +18,8 @@ export default function sitemap(): MetadataRoute.Sitemap { '/blocks/features', ...FEATURE_PACK_DOCS.map(({ id }) => `/blocks/features/${id}`), '/blocks/command-palette', + '/blocks/ai', + ...AI_COMPONENTS.map(({ name }) => `/blocks/ai/${name}`), ...SOURCE_BLOCKS.map(({ name }) => `/blocks/${name}`), ...APPLICATION_BLOCKS.map(({ name }) => `/blocks/${name}`), '/blocks/console-kit', @@ -36,6 +39,7 @@ export default function sitemap(): MetadataRoute.Sitemap { path === '/blocks/styling' || path === '/blocks/features' || path === '/blocks/command-palette' || + path === '/blocks/ai' || SOURCE_BLOCKS.some(({ name }) => path === `/blocks/${name}`) || path === '/blocks/console-kit' || path === '/blocks/billing' diff --git a/apps/blocks/src/components/ai-showcase/ai-component-docs-page.tsx b/apps/blocks/src/components/ai-showcase/ai-component-docs-page.tsx new file mode 100644 index 0000000..fcba528 --- /dev/null +++ b/apps/blocks/src/components/ai-showcase/ai-component-docs-page.tsx @@ -0,0 +1,145 @@ +import Link from 'next/link'; + +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@constructive-io/ui/table'; + +import { AiComponentPreview } from '@/components/ai-showcase/ai-component-preview'; +import { CodeBlock } from '@/components/docs/code-block'; +import { DocSection } from '@/components/docs/doc-section'; +import { + type AiComponentDoc, + type AiComponentName, + getAiComponentNeighbors, +} from '@/lib/ai-components'; +import { REGISTRY_COMPONENTS_JSON, registryAdd } from '@/lib/install-mode'; + +function GuidanceList({ items }: { items: readonly string[] }) { + return ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ ); +} + +export function AiComponentDocsPage({ component }: { component: AiComponentDoc }) { + const { previous, next } = getAiComponentNeighbors(component.name); + const caption = `API for ${component.title}.`; + + return ( +
+
+
+

+ + AI + +

+

+ {component.title} +

+

+ {component.description} +

+
+ + +
+ + + {REGISTRY_COMPONENTS_JSON} +
+ {registryAdd('ai')} +
+
+ + + + + + + + {component.importExample} + + + + + + {caption} + + + Prop + Type + Behavior + + + + {component.api.map((row) => ( + + {row.name} + + {row.type} + + + {row.behavior} + + + ))} + +
+
+ + +
+ ); +} diff --git a/apps/blocks/src/components/ai-showcase/ai-component-preview.tsx b/apps/blocks/src/components/ai-showcase/ai-component-preview.tsx new file mode 100644 index 0000000..26c8f3e --- /dev/null +++ b/apps/blocks/src/components/ai-showcase/ai-component-preview.tsx @@ -0,0 +1,646 @@ +'use client'; + +import { ArrowUp, Paperclip } from 'lucide-react'; +import { useState } from 'react'; + +import { + AgentLoader, + AiImage, + ApprovalCard, + ChatContainer, + ChatContainerContent, + CodeBlock, + ContextCard, + ContextCards, + ContextRing, + DiffTable, + FeedbackBar, + FileUpload, + InlineDiff, + Markdown, + Message, + MessageContent, + PlanTracker, + PromptInput, + PromptInputAction, + PromptInputActions, + PromptInputBody, + PromptInputTextarea, + PromptSuggestion, + PromptSuggestions, + Reasoning, + RecommendationCard, + ResponseStream, + ScrollButton, + Source, + Sources, + Step, + Steps, + StreamingText, + SystemMessage, + TaskList, + TaskRow, + TextShimmer, + ThinkingTrace, + Tool, + ToolGroup, +} from '@constructive-io/ui/ai'; +import { Button } from '@constructive-io/ui/button'; + +import type { AiComponentName } from '@/lib/ai-components'; + +function Frame({ + children, + className, + /** Skip the centered max-w-xl shell (full-bleed demos like chat). */ + fullBleed = false, +}: { + children: React.ReactNode; + className?: string; + fullBleed?: boolean; +}) { + return ( +
+ {fullBleed ? ( + children + ) : ( +
{children}
+ )} +
+ ); +} + +function StepsDemoBlock({ + label, + description, + children, +}: { + label: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{label}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} + +function CodeBlockDemo({ + label, + description, + children, +}: { + label: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{label}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} + +export function AiComponentPreview({ name }: { name: AiComponentName }) { + switch (name) { + case 'text-shimmer': + return ( + +
+ Thinking + Thought for 2.4s +
+ + ); + case 'agent-loader': + return ( + +
+ + + +
+ + + + +
+
+ + ); + case 'prompt-input': + return ( + + + + ); + case 'message': + return ( + +
+ + Compare mint chip to last summer. + + + + {`Mint chip is up **12%** with stronger weekend peaks.`} + + +
+ + ); + case 'markdown': + return ( + + {`### Summary\n\n- **Mint chip** is up 12%\n- Check \`inventory\` before promos\n\n[Docs](https://constructive.io)`} + + ); + case 'code-block': + return ( + +
+ + s !== 'rocky-road')\n}\n`} + /> + + + list[Risk]:\n \"\"\"Rank SKUs by stockout probability.\"\"\"\n return [Risk(r[\"sku\"], r[\"prob\"]) for r in rows if r[\"prob\"] > 0.4]\n`} + /> + + + + + + + + + = now() - interval '90 days'\nGROUP BY 1, 2\nORDER BY units DESC\nLIMIT 50;\n`} + /> + + + + + + + + + + + + s !== 'rocky-road')\n`} + /> + + + + + + {\n const { dryRun = false, region = 'us-west', since } = options\n let updated = 0\n let skipped = 0\n\n for (const store of stores) {\n if (region && store.region !== region) {\n skipped += 1\n continue\n }\n\n for (const sku of skus) {\n const qty = await fetchOnHand(store.id, sku.id, since)\n if (qty === null) {\n skipped += 1\n continue\n }\n if (!dryRun) {\n await writeSnapshot(store.id, sku.id, qty)\n }\n updated += 1\n }\n }\n\n return { updated, skipped }\n}\n\nasync function fetchOnHand(\n storeId: string,\n skuId: string,\n since?: Date,\n): Promise {\n // Host wires the data client; this is illustrative only.\n void storeId\n void skuId\n void since\n return 12\n}\n\nasync function writeSnapshot(\n storeId: string,\n skuId: string,\n qty: number,\n): Promise {\n void storeId\n void skuId\n void qty\n}\n`} + /> + + + + +
+ + ); + case 'response-stream': + return ( + + + + ); + case 'chat-container': + return ( + +

+ Scroll up in the transcript — the jump-to-latest control appears over the messages. +

+ + + + Compare mint chip to last summer. + + + + Mint chip is up 12% with stronger weekend peaks. Scroll this panel to inspect the + history, then use the floating control to jump back to the latest message. + + + {Array.from({ length: 10 }).map((_, i) => ( + + + {i % 3 === 0 + ? `Follow-up ${i + 1}: any stockout risk on cones?` + : `Update ${i + 1}: waffle cone inventory looks healthy through next week.`} + + + ))} + + + + + ); + case 'system-message': + return ( + + + Add a provider key in settings to continue. + + + ); + case 'reasoning': + return ( + + + + ); + case 'thinking-trace': + return ( + + + + ); + case 'steps': + return ( + +
+ + + + + + + + + + + + + + + + + + + + +
{`SELECT store_id, sku, SUM(qty)
+FROM pos.line_items
+WHERE sold_at >= now() - interval '90 days'
+GROUP BY 1, 2`}
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ); + case 'tool': + return ( + + + + + + + + ); + case 'approval-card': + return ( + + + + ); + case 'source': + return ( + + + + + + + ); + case 'inline-diff': + return ( + + + + ); + case 'diff-table': + return ( + + r.flavor }, + { id: 'c', header: 'Category', cell: (r: { category: string }) => r.category }, + ]} + rows={[ + { id: '1', flavor: 'Rocky Road', category: 'Classic', removed: true }, + { id: '2', flavor: 'Mint Chip', category: 'Classic' }, + ]} + addedRows={[{ id: 'a', cells: ['Pistachio', 'Seasonal'] }]} + /> + + ); + case 'streaming-text': + return ( + + + + ); + case 'plan-tracker': + return ( + + + + ); + case 'context-ring': + return ( + +
+ {( + [ + { label: 'Healthy', percent: 42, tokens: 48_000 as number | null }, + { label: 'High', percent: 88, tokens: 110_000 }, + { label: 'Critical', percent: 97, tokens: 124_000 }, + ] as const + ).map((state) => ( +
+ +

{state.label}

+
+ ))} +
+ + ); + case 'task-row': + return ( + + + + + undefined} /> + + + ); + case 'context-card': + return ( + + + + + + ); + case 'recommendation-card': + return ( + + + + ); + case 'feedback-bar': + return ( + + + + ); + case 'prompt-suggestion': + return ( + + + Forecast summer demand + Find waffle cone suppliers + + + ); + case 'file-upload': + return ( + + + + ); + case 'image': + return ( + + + + ); + default: + return null; + } +} + +function PromptInputDemo() { + const [value, setValue] = useState(''); + return ( + setValue('')}> + + + + + + + + + + + ); +} diff --git a/apps/blocks/src/components/ai-showcase/ai-showcase-demo.tsx b/apps/blocks/src/components/ai-showcase/ai-showcase-demo.tsx new file mode 100644 index 0000000..048c2a8 --- /dev/null +++ b/apps/blocks/src/components/ai-showcase/ai-showcase-demo.tsx @@ -0,0 +1,505 @@ +'use client'; + +import { ArrowUp, Paperclip, Square } from 'lucide-react'; +import { useState } from 'react'; + +import { + AgentLoader, + ApprovalCard, + ChatContainer, + ChatContainerContent, + CodeBlock, + ContextCard, + ContextCards, + ContextRing, + DiffTable, + FeedbackBar, + Message, + MessageContent, + PlanTracker, + PromptInput, + PromptInputAction, + PromptInputActions, + PromptInputBody, + PromptInputTextarea, + PromptSuggestion, + PromptSuggestions, + Reasoning, + RecommendationCard, + ScrollButton, + Source, + Sources, + Step, + Steps, + StreamingText, + SystemMessage, + TaskList, + TaskRow, + TextShimmer, + ThinkingTrace, + Tool, + ToolGroup, +} from '@constructive-io/ui/ai'; +import { Button } from '@constructive-io/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs'; + +/** Full-width stage — never center a max-w-xl island. */ +function Stage({ + children, + className = '', +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function SceneLabel({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} + +/** 1 — Composer: input shell + light status chrome */ +function ComposerDemo() { + const [value, setValue] = useState(''); + const [loading, setLoading] = useState(false); + + const submit = () => { + if (!value.trim()) return; + setLoading(true); + window.setTimeout(() => { + setLoading(false); + setValue(''); + }, 1400); + }; + + return ( + + + Composer shell with plan band, context meter, and send/stop. Status atoms sit above the input. + +
+ {loading ? ( + Thinking + ) : ( + Ready + )} + + + +
+
+ + + + + +
+ + + + +
+ {loading ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +} + +/** 2 — Chat: stick-to-bottom transcript */ +function ChatDemo() { + return ( + +
+ + Stick-to-bottom transcript with messages, feedback, follow-ups, and jump-to-latest. + +
+
+ + + + Static props only — hosts own streaming and history. + + + + Compare mint chip to last summer and draft a reorder. + + + +
+ + {`Mint chip is up **12%** with stronger weekend peaks. + +- Reorder waffle cones +- Hold rocky road promotions`} + + + + Which flavors sell best in winter + Compare gelato margins + +
+
+ + Also check cone inventory before we commit. + + + + {`Cone stock is **healthy** for two weeks of peak weekend demand. I can open a draft PO if you want.`} + + +
+ +
+
+
+ ); +} + +/** 3 — Reasoning: thinking surfaces */ +function ReasoningDemo() { + return ( + + + Thinking UX: collapsible reasoning, structured trace, and ordered steps with status. + +
+ + + + + + +
{`create table reorder_queue (…)`}
+
+ +
+
+
+ ); +} + +/** 4 — Tools: tool rows + code */ +function ToolsDemo() { + return ( + + + Tool calls at row density, with diff chips and a highlighted code result. + +
+ + + + + + s !== 'rocky-road') + .map((sku) => ({ sku, qty: 24 })) +} +`} + /> +
+ + +
+
+
+ ); +} + +/** 5 — HITL: approvals and proposed edits */ +function HitlDemo() { + return ( + + + Human-in-the-loop gates: confirm, recommend with alternatives, and tabular proposed edits. + +
+ + + Reorder waffle cones from{' '} + cone_king with + lead time{' '} + 7_days. + + } + confidence={0.86} + confidenceLabel="High confidence" + alternatives={[ + { id: 'a', label: 'Switch supplier', badge: 'Needs review' }, + { id: 'b', label: 'Full restock', badge: 'No signal' }, + ]} + /> + r.flavor, + }, + { + id: 'category', + header: 'Category', + cell: (r: { category: string }) => r.category, + }, + { + id: 'supplier', + header: 'Supplier', + cell: (r: { supplier: string }) => r.supplier, + }, + ]} + rows={[ + { + id: '1', + flavor: 'Rocky Road', + category: 'Classic', + supplier: 'aurora-scoops', + removed: true, + }, + { + id: '2', + flavor: 'Mint Chip', + category: 'Classic', + supplier: 'maple-orbit', + }, + ]} + addedRows={[ + { + id: 'add-1', + cells: ['Pistachio', 'Seasonal', 'maple-orbit'], + }, + ]} + /> +
+
+ ); +} + +/** 6 — Workspace: tasks, RAG, streamed answer chrome */ +function WorkspaceDemo() { + return ( + + + Side-panel workspace: live tasks, retrieved chunks, and a settled streamed answer with + citations. + +
+
+ + + + undefined} + /> + + + + + +
+
+ + + + + +
+
+
+ ); +} + +/** + * Overview showcase for /blocks/ai — tabbed use cases ordered simple → complex. + */ +export function AiShowcaseDemo() { + return ( +
+ +
+ + + Composer + + + Chat + + + Reasoning + + + Tools + + + HITL + + + Workspace + + +
+ + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/blocks/src/components/site/site-sidebar.tsx b/apps/blocks/src/components/site/site-sidebar.tsx index 39bc8c5..6c58c06 100644 --- a/apps/blocks/src/components/site/site-sidebar.tsx +++ b/apps/blocks/src/components/site/site-sidebar.tsx @@ -15,6 +15,7 @@ import { ChevronRight, X } from 'lucide-react'; import { Button } from '@constructive-io/ui/button'; import { ConstructiveMark } from '@/components/brand/constructive-mark'; +import { AI_COMPONENTS } from '@/lib/ai-components'; import { APPLICATION_BLOCKS } from '@/lib/application-blocks'; import { COMPONENT_DOC_SEQUENCE } from '@/lib/component-doc-navigation'; import { FEATURE_PACK_DOCS } from '@/lib/feature-packs'; @@ -147,24 +148,37 @@ export const SiteSidebar = forwardRef(function Si ) { const pathname = normalizePath(usePathname() ?? ''); const onComponents = - pathname.startsWith('/blocks/ui/') || - pathname === '/blocks/command-palette'; + pathname.startsWith('/blocks/ui/') || pathname === '/blocks/command-palette'; + const onAi = pathname === '/blocks/ai' || pathname.startsWith('/blocks/ai/'); const onBillingDocs = pathname === '/blocks/billing' || pathname.startsWith('/blocks/billing/'); const onFoundations = pathname === '/' || pathname === '/blocks' || pathname === '/blocks/styling'; const [foundationsOpen, setFoundationsOpen] = useState(true); - const [componentsOpen, setComponentsOpen] = useState(true); + // Collapse Components while browsing AI so the AI group is not buried under 30 primitives. + const [componentsOpen, setComponentsOpen] = useState(() => !onAi); + const [aiOpen, setAiOpen] = useState(true); // Expand the section that owns the active route so deep links stay visible useEffect(() => { if (onComponents) setComponentsOpen(true); + if (onAi) { + setAiOpen(true); + setComponentsOpen(false); + } if (onFoundations) setFoundationsOpen(true); - }, [onComponents, onFoundations]); + }, [onComponents, onAi, onFoundations]); const componentLinks = COMPONENT_DOC_SEQUENCE.map((component) => ({ href: component.href, label: component.title, })); + const aiLinks = [ + { href: '/blocks/ai', label: 'Overview' }, + ...AI_COMPONENTS.map((component) => ({ + href: `/blocks/ai/${component.name}`, + label: component.title, + })), + ]; const featurePackLinks = FEATURE_PACK_DOCS.map((pack) => ({ href: `/blocks/features/${pack.id}`, label: pack.title, @@ -304,6 +318,28 @@ export const SiteSidebar = forwardRef(function Si + +
+ setAiOpen((v) => !v)} + count={aiLinks.length} + > +
    + {aiLinks.map(({ href, label }) => { + const active = pathname === href; + return ( +
  • + + {label} + +
  • + ); + })} +
+
+