diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 969b7c6..09a102d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - '@constructive-io/ui@*' - '@constructive-io/data@*' - '@constructive-io/sheets@*' + - '@constructive-io/command-palette@*' - '@constructive-io/schema-builder@*' pull_request: branches: [main] @@ -44,6 +45,11 @@ jobs: - 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 + packages: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/AGENTS.md b/AGENTS.md index 199bc89..a6cae99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,17 +2,19 @@ This public monorepo owns the Constructive Blocks documentation, the `@constructive` shadcn registry, and the `@constructive-io/ui`, -`@constructive-io/data`, and `@constructive-io/sheets` packages. The local -schema-builder package remains in the workspace but is not part of the public -registry. +`@constructive-io/data`, `@constructive-io/sheets`, +`@constructive-io/command-palette`, and `@constructive-io/schema-builder` +packages. Schema Builder is also distributed as editable source through the +public registry and binds to each host through an explicit adapter. ## Invariants - Canonical public source lives in `apps/blocks`, `packages/ui`, - `packages/data`, and `packages/sheets`; do not edit generated registry output. + `packages/data`, `packages/sheets`, `packages/command-palette`, and + `packages/schema-builder`; do not edit generated registry output. - Keep the registry collision-free and `@constructive`-namespaced. -- Preserve the seven feature-pack manifests and four experimental preset - profiles installed under `.constructive/feature-packs`. +- Preserve the reviewed feature-pack manifests and preset profiles installed + under `.constructive/feature-packs`. - Never auto-discover or mutate sibling repositories from flow tooling. - Keep normal CI independent of generated SDKs and live endpoints. - Never add automated npm publishing; publish verified tarballs manually. diff --git a/CLAUDE.md b/CLAUDE.md index 2088166..3f10618 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,14 +2,16 @@ This public monorepo owns the Constructive Blocks documentation, the `@constructive` shadcn registry, and the `@constructive-io/ui`, -`@constructive-io/data`, and `@constructive-io/sheets` packages. The local -schema-builder package remains available for development but is delisted from -the public registry. +`@constructive-io/data`, `@constructive-io/sheets`, +`@constructive-io/command-palette`, and `@constructive-io/schema-builder` +packages. Schema Builder is also distributed as editable source through the +public registry and binds to each host through an explicit adapter. ## Invariants -- `apps/blocks`, `packages/ui`, `packages/data`, and `packages/sheets` are - canonical public source trees; generated registry output is never edited. +- `apps/blocks`, `packages/ui`, `packages/data`, `packages/sheets`, + `packages/command-palette`, and `packages/schema-builder` are canonical public + source trees; generated registry output is never edited. - The combined registry must remain collision-free and every install command must use the `@constructive` namespace. - Feature-pack and preset manifests installed under diff --git a/README.md b/README.md index 6385524..85bfbc6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ component documentation, and published React packages. - `packages/ui` — the `@constructive-io/ui` npm package and UI registry source. - `packages/data` — runtime GraphQL generation and the strict July 2026 `_meta` contract. - `packages/sheets` — the metadata-driven application CRUD grid. -- `packages/schema-builder` — retained platform/operator tooling that is delisted from the public registry. +- `packages/command-palette` — the headless command registry, shortcuts, workflows, and background-task engine. +- `packages/schema-builder` — the source-installable Schema Builder, its host-adapter contract, and its npm package. The documentation site is published at . Registry JSON is served from @@ -21,7 +22,7 @@ expect an existing shadcn project; initialize one if the application does not already contain `components.json`: ```bash -pnpm dlx shadcn@4.13.1 init +pnpm dlx shadcn@latest init ``` Keep the generated alias configuration, then map the public registry in @@ -43,10 +44,11 @@ Then choose the smallest surface that owns the workflow you need: | Backend-aligned official composition | `preset-auth-hardened`, `preset-b2b-storage`, or `preset-full` | | Custom Console Kit composition | `console-kit-core` plus selected `console-module-*` items | | Provider-neutral view with host-owned data and actions | `feature-pack-*` | +| Application command center with editable presentation | `command-palette` | | Reusable primitive | npm subpath or namespaced primitive registry item | ```bash -pnpm dlx shadcn@4.13.1 add @constructive/console-kit-nextjs +pnpm dlx shadcn@latest add @constructive/console-kit-nextjs ``` Render the installed umbrella with a secret-free tenant descriptor returned by @@ -85,13 +87,14 @@ source through the same namespace: ```bash pnpm add @constructive-io/ui -pnpm dlx shadcn@4.13.1 add @constructive/button +pnpm dlx shadcn@latest add @constructive/button ``` The UI package exposes its Tailwind foundation at `@constructive-io/ui/globals.css`. Registry installs copy the required UI source and Constructive theme into the consumer and do not install the npm -package. Registry consumers require shadcn CLI 4.13.1 or newer. +package. Registry consumers should invoke the current shadcn CLI through +`shadcn@latest`. ## Development diff --git a/apps/blocks/e2e/docs.interaction.spec.ts b/apps/blocks/e2e/docs.interaction.spec.ts index f28bc6d..ca420d4 100644 --- a/apps/blocks/e2e/docs.interaction.spec.ts +++ b/apps/blocks/e2e/docs.interaction.spec.ts @@ -267,7 +267,7 @@ test('documentation order, anchors, and shared install/source mode remain synchr const install = page.locator('#installation'); await install.getByRole('tab', { name: 'registry' }).click(); - await expect(install).toContainText('pnpm dlx shadcn@4.13.1 add @constructive/select'); + await expect(install).toContainText('pnpm dlx shadcn@latest add @constructive/select'); await expect(install).toContainText("from '@/components/ui/select'"); await expect(install).toContainText('The npm package is not required.'); diff --git a/apps/blocks/e2e/mobile.interaction.spec.ts b/apps/blocks/e2e/mobile.interaction.spec.ts index 32e7e23..0fc9736 100644 --- a/apps/blocks/e2e/mobile.interaction.spec.ts +++ b/apps/blocks/e2e/mobile.interaction.spec.ts @@ -7,6 +7,7 @@ import { } from '@playwright/test'; const billingRoute = (name: string) => `/blocks/blocks/billing/${name}/`; +const applicationRoute = (name: string) => `/blocks/blocks/${name}/`; function billingPreviewFrame(page: Page): FrameLocator { return page.frameLocator( @@ -14,6 +15,12 @@ function billingPreviewFrame(page: Page): FrameLocator { ); } +function applicationPreviewFrame(page: Page): FrameLocator { + return page.frameLocator( + '[data-slot="application-block-showcase-preview"] iframe[title$="live preview"]', + ); +} + async function expectTouchTargets(targets: Locator) { const targetCount = await targets.count(); expect(targetCount).toBeGreaterThan(0); @@ -173,3 +180,123 @@ test('mobile billing controls expose 44px touch targets and switch account conte ), ); }); + +test('mobile application blocks keep their primary workflows inside the viewport', async ({ + page, +}) => { + await test.step('Org Chart fits every node and action target', async () => { + await page.goto(applicationRoute('org-chart'), { waitUntil: 'networkidle' }); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + const frame = applicationPreviewFrame(page); + const nodes = frame.locator('.react-flow__node'); + await expect(nodes).toHaveCount(5); + + await expect + .poll(() => + nodes.evaluateAll((elements) => + elements.every((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.left >= 0 && bounds.right <= window.innerWidth; + }), + ), + ) + .toBe(true); + + const actions = frame.getByRole('button', { name: /^Actions for / }); + await expect(actions).toHaveCount(5); + expect( + await actions.evaluateAll((elements) => + elements.every((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.left >= 0 && bounds.right <= window.innerWidth; + }), + ), + ).toBe(true); + + for (const person of ['Maya Chen', 'Theo Brooks']) { + await frame + .getByRole('button', { name: new RegExp(`^Actions for ${person}`) }) + .click(); + const menu = frame.getByRole('menu'); + await expect(menu).toBeVisible(); + expect( + await menu.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.left >= 0 && bounds.right <= window.innerWidth; + }), + ).toBe(true); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + } + + const preview = page.locator( + '[data-slot="application-block-showcase-preview"]', + ); + await preview + .getByRole('button', { name: 'Desktop preview, 1280 pixels' }) + .click(); + const fullscreenTrigger = preview.getByRole('button', { + name: 'Open full-screen preview', + }); + await fullscreenTrigger.click(); + const dialog = page.getByRole('dialog', { name: 'Org Chart preview' }); + const frameContainer = dialog.locator( + '[data-slot="application-block-preview-frame"]', + ); + const fullscreenFrame = dialog.locator('iframe'); + await expect + .poll(async () => Number(await frameContainer.getAttribute('data-preview-scale'))) + .toBeLessThan(1); + expect( + await fullscreenFrame.evaluate((element) => { + const frameBounds = element.getBoundingClientRect(); + const containerBounds = element.parentElement!.getBoundingClientRect(); + return frameBounds.width <= containerBounds.width + 1; + }), + ).toBe(true); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(fullscreenTrigger).toBeFocused(); + }); + + await test.step('Storage opens object details from the keyboard', async () => { + await page.goto(applicationRoute('storage-browser'), { + waitUntil: 'networkidle', + }); + const frame = applicationPreviewFrame(page); + const objectLink = frame.getByRole('button', { + name: 'Open details for launch-cover.png', + }); + await objectLink.focus(); + await objectLink.press('Enter'); + await expect(frame.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(frame.getByRole('dialog')).toBeHidden(); + await expect(objectLink).toBeFocused(); + }); + + await test.step('Schema Builder create cards do not overflow', async () => { + await page.goto(applicationRoute('schema-builder'), { + waitUntil: 'networkidle', + }); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + const frame = applicationPreviewFrame(page); + await frame.getByRole('button', { name: 'Create table' }).first().click(); + await expect(frame.getByTestId('table-name-input')).not.toBeFocused(); + const cards = frame.locator('[data-testid^="policy-card-"]'); + await expect(cards.first()).toBeVisible(); + expect( + await cards.evaluateAll((elements) => + elements.every((element) => { + const bounds = element.getBoundingClientRect(); + return bounds.left >= 0 && bounds.right <= window.innerWidth; + }), + ), + ).toBe(true); + expect( + await frame.locator('html').evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ).toBe(true); + }); +}); diff --git a/apps/blocks/package.json b/apps/blocks/package.json index 607657b..0209790 100644 --- a/apps/blocks/package.json +++ b/apps/blocks/package.json @@ -35,8 +35,10 @@ "@ai-sdk/openai-compatible": "^3.0.9", "@ai-sdk/react": "^4.0.27", "@base-ui/react": "^1.0.0", + "@constructive-io/command-palette": "workspace:*", "@constructive-io/data": "workspace:*", "@constructive-io/graphql-types": "^3.4.3", + "@constructive-io/schema-builder": "workspace:*", "@constructive-io/sheets": "workspace:*", "@constructive-io/ui": "workspace:*", "@tanstack/react-form": "^1.27.7", diff --git a/apps/blocks/registry.json b/apps/blocks/registry.json index dc63f35..c3a6225 100644 --- a/apps/blocks/registry.json +++ b/apps/blocks/registry.json @@ -258,6 +258,71 @@ } ] }, + { + "name": "command-palette", + "type": "registry:block", + "title": "Command Palette", + "description": "Framework-neutral command discovery with page-scoped registration, structured shortcuts, multi-step flows, and cancellable background tasks.", + "categories": [ + "blocks", + "application-shell" + ], + "docs": "## Usage\n\nCreate one registry for the application, adapt navigation at the host boundary, and render the palette near the root layout. Pages can register commands while mounted with `usePageCommands`.\n\n```tsx\nimport { createCommandRegistry, kbd } from '@constructive-io/command-palette';\nimport { CommandPalette } from '@/blocks/command-palette/command-palette';\n\nconst registry = createCommandRegistry({\n groups: [{ id: 'navigation', label: 'Navigation', priority: 1 }],\n commands: [{\n id: 'settings',\n label: 'Open settings',\n type: 'navigation',\n group: 'navigation',\n href: '/settings',\n shortcut: kbd(',', 'mod')\n }]\n});\n\n router.push(href)} />;\n```\n\nThe installed block owns presentation and keyboard interaction. The headless package owns command registration, execution, multi-step state, and background-task lifecycle. Route authorization, action permissions, errors, and business workflows remain the host application's responsibility.", + "dependencies": [ + "@constructive-io/command-palette@^0.3.0", + "lucide-react", + "motion" + ], + "registryDependencies": [ + "badge", + "button", + "cn", + "command", + "separator" + ], + "files": [ + { + "path": "registry/constructive/blocks/command-palette/command-palette.tsx", + "target": "src/blocks/command-palette/command-palette.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/command-palette/kbd-shortcut.tsx", + "target": "src/blocks/command-palette/kbd-shortcut.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/command-palette/multi-step/multi-step-view.tsx", + "target": "src/blocks/command-palette/multi-step/multi-step-view.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/command-palette/multi-step/step-indicator.tsx", + "target": "src/blocks/command-palette/multi-step/step-indicator.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/command-palette/background/background-task-stack.tsx", + "target": "src/blocks/command-palette/background/background-task-stack.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/command-palette/background/inline-task-bar.tsx", + "target": "src/blocks/command-palette/background/inline-task-bar.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/command-palette/background/task-card.tsx", + "target": "src/blocks/command-palette/background/task-card.tsx", + "type": "registry:component" + }, + { + "path": "registry/constructive/blocks/command-palette/background/task-icons.tsx", + "target": "src/blocks/command-palette/background/task-icons.tsx", + "type": "registry:component" + } + ] + }, { "name": "pack-foundation", "type": "registry:lib", @@ -371,7 +436,7 @@ "description": "The leaf-independent Console Kit shell, runtime, discovery, and single modular Zustand store.", "docs": "`console-kit-core` installed the shell, runtime, semantic routing, callback boundary, and one per-instance modular Zustand store. Core intentionally includes no feature view, so add selected `console-module-*` items.\n\nDegraded states: explicit endpoint, current `_meta`, introspection, capability, and adapter evidence fail closed independently; installation never grants authority.\n\nGuide: https://constructive-io.github.io/blocks/blocks/console-kit/", "dependencies": [ - "@constructive-io/data", + "@constructive-io/data@^0.4.0", "@tanstack/react-query", "graphql", "lucide-react", @@ -501,15 +566,12 @@ "description": "A current-_meta-only application data explorer with application-table filtering and spreadsheet CRUD.", "docs": "`feature-pack-data` installed a provider-neutral view and `.constructive/feature-packs/data.json`. Import from `@/blocks/feature-packs/data/data-feature-pack`. The host must supply the view resource, policy, and actions; add `console-module-data` when Console Kit should own discovery and Constructive integration.\n\nDegraded states: The host owns Sheets state and endpoint binding; incompatible metadata stays explicit, and PostgreSQL privileges and RLS decide every query and mutation.\n\nGuide: https://constructive-io.github.io/blocks/blocks/features/data/", "dependencies": [ - "@constructive-io/data", - "@constructive-io/sheets", + "@constructive-io/data@^0.4.0", "lucide-react" ], - "css": { - "@import '@constructive-io/sheets/styles.css'": {} - }, "registryDependencies": [ - "pack-foundation" + "pack-foundation", + "sheets" ], "files": [ { diff --git a/apps/blocks/scripts/check-docs.ts b/apps/blocks/scripts/check-docs.ts index d685465..3f42edf 100644 --- a/apps/blocks/scripts/check-docs.ts +++ b/apps/blocks/scripts/check-docs.ts @@ -7,10 +7,15 @@ 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; @@ -35,16 +40,136 @@ 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 === '.next' || entry.name === 'node_modules' || entry.name === 'out') return []; + 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) && @@ -164,6 +289,8 @@ const componentFiles = BASE_PRIMITIVES.map(({ name }) => path.join(uiDirectory, const { checker, program } = loadUiProgram(componentFiles); const errors: string[] = []; +validatePublicShadcnCommands(errors); + const contentFiles = fs .readdirSync(contentDirectory) .filter((file) => file.endsWith('.ts') && file !== 'index.ts') @@ -271,7 +398,7 @@ for (const primitive of BASE_PRIMITIVES) { 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@4.13.1 add @constructive/${primitive.name}`)) { + 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)) { @@ -349,6 +476,227 @@ for (const block of FEATURE_PACK_DOCS) { } } +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'); @@ -368,5 +716,8 @@ if (errors.length > 0) { console.log('Blocks docs expose exactly 29 source-checked primitive references.'); console.log('Feature-pack docs expose seven manifest-aligned live references.'); console.log( - 'Every page has complete examples, dual install paths, state guidance, accessibility, and API-last coverage.', + '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/command-palette/page.tsx b/apps/blocks/src/app/blocks/command-palette/page.tsx new file mode 100644 index 0000000..a11ab81 --- /dev/null +++ b/apps/blocks/src/app/blocks/command-palette/page.tsx @@ -0,0 +1,194 @@ +import type { Metadata } from 'next'; + +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@constructive-io/ui/table'; + +import { CommandPaletteDemo } from '@/components/command-palette-showcase/command-palette-demo'; +import { ComponentDocPagination } from '@/components/docs/component-doc-pagination'; +import { CodeBlock } from '@/components/docs/code-block'; +import { DocSection } from '@/components/docs/doc-section'; +import { COMMAND_PALETTE_DOC } from '@/lib/command-palette-docs'; +import { registryAdd } from '@/lib/install-mode'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +function GuidanceList({ items }: { items: readonly string[] }) { + return ( +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+ ); +} + +function PublicContract() { + const caption = `Public properties for ${COMMAND_PALETTE_DOC.title}.`; + + return ( + + {caption} + + + Prop + Type + Behavior + + + + {COMMAND_PALETTE_DOC.api.map((row) => ( + + + {row.name} + + + {row.type} + + + {row.behavior} + + + ))} + +
+ ); +} + +export default function CommandPalettePage() { + const doc = COMMAND_PALETTE_DOC; + + return ( +
+
+
+

Components

+

+ {doc.title} +

+

+ {doc.description} +

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

+ {item.title} +

+

+ {item.body} +

+
+ ))} +
+
+
+ + + + + + + + + + + + + + +
+ ); +} + +export const metadata: Metadata = { + title: COMMAND_PALETTE_DOC.title, + description: COMMAND_PALETTE_DOC.description, + alternates: { canonical: withBase('/blocks/command-palette') }, + openGraph: { + title: COMMAND_PALETTE_DOC.title, + description: COMMAND_PALETTE_DOC.description, + url: withBase('/blocks/command-palette'), + images: [OG_IMAGE] + } +}; diff --git a/apps/blocks/src/app/blocks/console-kit/page.tsx b/apps/blocks/src/app/blocks/console-kit/page.tsx index 7e6ef87..aaf3555 100644 --- a/apps/blocks/src/app/blocks/console-kit/page.tsx +++ b/apps/blocks/src/app/blocks/console-kit/page.tsx @@ -14,6 +14,7 @@ import { import { ConsoleKitShowcasePreview } from '@/components/console-kit-showcase/console-kit-showcase-preview'; import { CodeBlock } from '@/components/docs/code-block'; +import { ApplicationDocPagination } from '@/components/docs/application-doc-pagination'; import { registryAdd } from '@/lib/install-mode'; import { OG_IMAGE, withBase } from '@/lib/site'; @@ -769,6 +770,7 @@ export default function ConsoleKitPage() { + ); } diff --git a/apps/blocks/src/app/blocks/org-chart/page.tsx b/apps/blocks/src/app/blocks/org-chart/page.tsx new file mode 100644 index 0000000..d09df63 --- /dev/null +++ b/apps/blocks/src/app/blocks/org-chart/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockDocsPage } from '@/components/application-block-showcase/application-block-docs-page'; +import { getApplicationBlock } from '@/lib/application-blocks'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const block = getApplicationBlock('org-chart')!; + +export default function OrgChartPage() { + return ; +} + +export const metadata: Metadata = { + title: block.title, + description: block.description, + alternates: { canonical: withBase('/blocks/org-chart') }, + openGraph: { + title: block.title, + description: block.description, + url: withBase('/blocks/org-chart'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/org-chart/preview/page.tsx b/apps/blocks/src/app/blocks/org-chart/preview/page.tsx new file mode 100644 index 0000000..7b65866 --- /dev/null +++ b/apps/blocks/src/app/blocks/org-chart/preview/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockShowcaseCanvas } from '@/components/application-block-showcase/application-block-showcase-canvas'; +import { withBase } from '@/lib/site'; + +export default function OrgChartPreviewPage() { + return ( + <> +

Org Chart live preview

+ + + ); +} + +export const metadata: Metadata = { + alternates: { canonical: withBase('/blocks/org-chart') }, + robots: { follow: false, index: false }, +}; diff --git a/apps/blocks/src/app/blocks/page.tsx b/apps/blocks/src/app/blocks/page.tsx index 40bd49b..82425c7 100644 --- a/apps/blocks/src/app/blocks/page.tsx +++ b/apps/blocks/src/app/blocks/page.tsx @@ -2,13 +2,40 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { InstallToggle } from '@/components/docs/install-toggle'; -import { BASE_PRIMITIVES } from '@/lib/base-primitives'; +import { APPLICATION_BLOCKS } from '@/lib/application-blocks'; +import { COMPONENT_DOC_SEQUENCE } from '@/lib/component-doc-navigation'; import { packageCommands, registryCommands } from '@/lib/install-mode'; import { OG_IMAGE, withBase } from '@/lib/site'; +import { SOURCE_BLOCKS } from '@/lib/source-blocks'; const TITLE = 'Setup'; const DESCRIPTION = 'Choose npm package distribution or source installation through the shadcn CLI.'; +const APPLICATION_CATALOG = [ + { + href: '/blocks/features', + title: 'Feature packs', + description: + 'Data, authentication, users, organizations, storage, billing, and notifications.', + }, + ...APPLICATION_BLOCKS.map((block) => ({ + href: `/blocks/${block.name}`, + title: block.title, + description: block.description, + })), + ...SOURCE_BLOCKS.map((block) => ({ + href: `/blocks/${block.name}`, + title: block.title, + description: block.description, + })), + { + href: '/blocks/console-kit', + title: 'Console Kit for Next.js', + description: + 'A full-page console driven by injected endpoints, session state, adapters, and versioned _meta.', + }, +] as const; + export default function SetupPage() { return (
@@ -31,7 +58,7 @@ export default function SetupPage() { descriptions={{ npm: 'Stable package exports — updates follow your package manager.', registry: - 'Copy source into your project. Theme and dependencies install with the component. Requires shadcn CLI 4.13.1 or newer.', + 'Copy source into your project. Theme and dependencies install with the component through shadcn@latest.', }} /> @@ -41,26 +68,13 @@ export default function SetupPage() { Application blocks

- Start with a capability-aligned feature pack or install the full - Next.js console with its route-neutral app shell and dynamic data - explorer. + Start with a capability-aligned feature pack, add a composed + workflow block, or install the full Next.js console with its + route-neutral app shell and dynamic data explorer.

    - {[ - { - href: '/blocks/features', - title: 'Feature packs', - description: - 'Data, authentication, users, organizations, storage, billing, and notifications.' - }, - { - href: '/blocks/console-kit', - title: 'Console Kit for Next.js', - description: - 'A full-page console driven by injected endpoints, session state, adapters, and versioned _meta.' - } - ].map((item) => ( + {APPLICATION_CATALOG.map((item) => (
  • -
    +
    -

    - Base primitives +

    + Components

    - {BASE_PRIMITIVES.length} components · each page shows npm and registry install paths. + {COMPONENT_DOC_SEQUENCE.length} components · each page shows its supported install path and source contract.

    @@ -92,20 +106,20 @@ export default function SetupPage() { Index - {BASE_PRIMITIVES.length} + {COMPONENT_DOC_SEQUENCE.length}
      - {BASE_PRIMITIVES.map((primitive) => ( -
    • + {COMPONENT_DOC_SEQUENCE.map((component) => ( +
    • - {primitive.title} + {component.title} - {primitive.description} + {component.description}
    • diff --git a/apps/blocks/src/app/blocks/schema-builder/page.tsx b/apps/blocks/src/app/blocks/schema-builder/page.tsx new file mode 100644 index 0000000..1f2b252 --- /dev/null +++ b/apps/blocks/src/app/blocks/schema-builder/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { SourceBlockDocsPage } from '@/components/source-block-showcase/source-block-docs-page'; +import { OG_IMAGE, withBase } from '@/lib/site'; +import { getSourceBlock } from '@/lib/source-blocks'; + +const block = getSourceBlock('schema-builder')!; + +export default function SchemaBuilderPage() { + return ; +} + +export const metadata: Metadata = { + title: block.title, + description: block.description, + alternates: { canonical: withBase('/blocks/schema-builder') }, + openGraph: { + title: block.title, + description: block.description, + url: withBase('/blocks/schema-builder'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/schema-builder/preview/page.tsx b/apps/blocks/src/app/blocks/schema-builder/preview/page.tsx new file mode 100644 index 0000000..d614534 --- /dev/null +++ b/apps/blocks/src/app/blocks/schema-builder/preview/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import { SourceBlockShowcaseCanvas } from '@/components/source-block-showcase/source-block-showcase-canvas'; +import { withBase } from '@/lib/site'; + +export default function SchemaBuilderPreviewPage() { + return ( + <> +

      Schema Builder live preview

      + + + ); +} + +export const metadata: Metadata = { + alternates: { canonical: withBase('/blocks/schema-builder') }, + robots: { follow: false, index: false }, +}; diff --git a/apps/blocks/src/app/blocks/sheets/page.tsx b/apps/blocks/src/app/blocks/sheets/page.tsx new file mode 100644 index 0000000..de9252f --- /dev/null +++ b/apps/blocks/src/app/blocks/sheets/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { SourceBlockDocsPage } from '@/components/source-block-showcase/source-block-docs-page'; +import { OG_IMAGE, withBase } from '@/lib/site'; +import { getSourceBlock } from '@/lib/source-blocks'; + +const block = getSourceBlock('sheets')!; + +export default function SheetsPage() { + return ; +} + +export const metadata: Metadata = { + title: block.title, + description: block.description, + alternates: { canonical: withBase('/blocks/sheets') }, + openGraph: { + title: block.title, + description: block.description, + url: withBase('/blocks/sheets'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/sheets/preview/page.tsx b/apps/blocks/src/app/blocks/sheets/preview/page.tsx new file mode 100644 index 0000000..a801a45 --- /dev/null +++ b/apps/blocks/src/app/blocks/sheets/preview/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import { SourceBlockShowcaseCanvas } from '@/components/source-block-showcase/source-block-showcase-canvas'; +import { withBase } from '@/lib/site'; + +export default function SheetsPreviewPage() { + return ( + <> +

      Sheets live preview

      + + + ); +} + +export const metadata: Metadata = { + alternates: { canonical: withBase('/blocks/sheets') }, + robots: { follow: false, index: false }, +}; diff --git a/apps/blocks/src/app/blocks/storage-browser/page.tsx b/apps/blocks/src/app/blocks/storage-browser/page.tsx new file mode 100644 index 0000000..03a3721 --- /dev/null +++ b/apps/blocks/src/app/blocks/storage-browser/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockDocsPage } from '@/components/application-block-showcase/application-block-docs-page'; +import { getApplicationBlock } from '@/lib/application-blocks'; +import { OG_IMAGE, withBase } from '@/lib/site'; + +const block = getApplicationBlock('storage-browser')!; + +export default function StorageBrowserPage() { + return ; +} + +export const metadata: Metadata = { + title: block.title, + description: block.description, + alternates: { canonical: withBase('/blocks/storage-browser') }, + openGraph: { + title: block.title, + description: block.description, + url: withBase('/blocks/storage-browser'), + images: [OG_IMAGE], + }, +}; diff --git a/apps/blocks/src/app/blocks/storage-browser/preview/page.tsx b/apps/blocks/src/app/blocks/storage-browser/preview/page.tsx new file mode 100644 index 0000000..8240806 --- /dev/null +++ b/apps/blocks/src/app/blocks/storage-browser/preview/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import { ApplicationBlockShowcaseCanvas } from '@/components/application-block-showcase/application-block-showcase-canvas'; +import { withBase } from '@/lib/site'; + +export default function StorageBrowserPreviewPage() { + return ( + <> +

      Storage Browser live preview

      + + + ); +} + +export const metadata: Metadata = { + alternates: { canonical: withBase('/blocks/storage-browser') }, + robots: { follow: false, index: false }, +}; diff --git a/apps/blocks/src/app/blocks/ui/[name]/page.tsx b/apps/blocks/src/app/blocks/ui/[name]/page.tsx index 51d5729..018100f 100644 --- a/apps/blocks/src/app/blocks/ui/[name]/page.tsx +++ b/apps/blocks/src/app/blocks/ui/[name]/page.tsx @@ -13,12 +13,9 @@ export default async function PrimitivePage({ params }: PageProps) { const primitive = getBasePrimitive(name); if (!primitive) return notFound(); - const index = BASE_PRIMITIVES.findIndex((item) => item.name === primitive.name); - const previous = index > 0 ? BASE_PRIMITIVES[index - 1] : undefined; - const next = index < BASE_PRIMITIVES.length - 1 ? BASE_PRIMITIVES[index + 1] : undefined; const docs = getPrimitiveDocs(primitive.name); - return ; + return ; } export function generateStaticParams() { diff --git a/apps/blocks/src/app/globals.css b/apps/blocks/src/app/globals.css index a7c36e2..2d7f356 100644 --- a/apps/blocks/src/app/globals.css +++ b/apps/blocks/src/app/globals.css @@ -1,4 +1,6 @@ @import '@constructive-io/ui/globals.css'; +@import '@constructive-io/sheets/styles.css'; +@import '@constructive-io/schema-builder/styles.css'; @source '../'; diff --git a/apps/blocks/src/app/sitemap.test.ts b/apps/blocks/src/app/sitemap.test.ts index a4a9ca1..35c4347 100644 --- a/apps/blocks/src/app/sitemap.test.ts +++ b/apps/blocks/src/app/sitemap.test.ts @@ -1,22 +1,34 @@ import { describe, expect, it } from 'vitest'; +import { APPLICATION_BLOCKS } from '@/lib/application-blocks'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; import { BILLING_BLOCKS } from '@/lib/billing-blocks'; import { FEATURE_PACK_DOCS } from '@/lib/feature-packs'; +import { SOURCE_BLOCKS } from '@/lib/source-blocks'; import sitemap from './sitemap'; describe('sitemap', () => { - it('contains foundations, application docs, seven feature packs, 29 primitives, and the complete billing catalog', () => { + it('contains foundations, application blocks, seven feature packs, 29 primitives, and the complete billing catalog', () => { const entries = sitemap(); expect(BASE_PRIMITIVES).toHaveLength(29); - expect(entries).toHaveLength(BASE_PRIMITIVES.length + FEATURE_PACK_DOCS.length + BILLING_BLOCKS.length + 6); + expect(entries).toHaveLength( + BASE_PRIMITIVES.length + + FEATURE_PACK_DOCS.length + + BILLING_BLOCKS.length + + APPLICATION_BLOCKS.length + + SOURCE_BLOCKS.length + + 7, + ); expect(entries.map(({ url }) => url)).toEqual([ 'http://localhost:3005/', 'http://localhost:3005/blocks', 'http://localhost:3005/blocks/styling', 'http://localhost:3005/blocks/features', ...FEATURE_PACK_DOCS.map(({ id }) => `http://localhost:3005/blocks/features/${id}`), + 'http://localhost:3005/blocks/command-palette', + ...SOURCE_BLOCKS.map(({ name }) => `http://localhost:3005/blocks/${name}`), + ...APPLICATION_BLOCKS.map(({ name }) => `http://localhost:3005/blocks/${name}`), 'http://localhost:3005/blocks/console-kit', ...BASE_PRIMITIVES.map(({ name }) => `http://localhost:3005/blocks/ui/${name}`), 'http://localhost:3005/blocks/billing', diff --git a/apps/blocks/src/app/sitemap.ts b/apps/blocks/src/app/sitemap.ts index c918951..21bcb72 100644 --- a/apps/blocks/src/app/sitemap.ts +++ b/apps/blocks/src/app/sitemap.ts @@ -1,9 +1,11 @@ import type { MetadataRoute } from 'next'; +import { APPLICATION_BLOCKS } from '@/lib/application-blocks'; import { BASE_PRIMITIVES } from '@/lib/base-primitives'; import { BILLING_BLOCKS } from '@/lib/billing-blocks'; import { FEATURE_PACK_DOCS } from '@/lib/feature-packs'; import { BASE_PATH, SITE_ORIGIN, withBase } from '@/lib/site'; +import { SOURCE_BLOCKS } from '@/lib/source-blocks'; export const dynamic = 'force-static'; @@ -14,6 +16,9 @@ export default function sitemap(): MetadataRoute.Sitemap { '/blocks/styling', '/blocks/features', ...FEATURE_PACK_DOCS.map(({ id }) => `/blocks/features/${id}`), + '/blocks/command-palette', + ...SOURCE_BLOCKS.map(({ name }) => `/blocks/${name}`), + ...APPLICATION_BLOCKS.map(({ name }) => `/blocks/${name}`), '/blocks/console-kit', ...BASE_PRIMITIVES.map(({ name }) => `/blocks/ui/${name}`), '/blocks/billing', @@ -30,6 +35,8 @@ export default function sitemap(): MetadataRoute.Sitemap { : path === '/blocks' || path === '/blocks/styling' || path === '/blocks/features' || + path === '/blocks/command-palette' || + SOURCE_BLOCKS.some(({ name }) => path === `/blocks/${name}`) || path === '/blocks/console-kit' || path === '/blocks/billing' ? 0.9 diff --git a/apps/blocks/src/blocks/command-palette/background/background-task-stack.tsx b/apps/blocks/src/blocks/command-palette/background/background-task-stack.tsx new file mode 100644 index 0000000..930b82e --- /dev/null +++ b/apps/blocks/src/blocks/command-palette/background/background-task-stack.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { AnimatePresence } from 'motion/react'; +import { TaskCard } from './task-card'; +import type { BackgroundTask } from '@constructive-io/command-palette'; + +const MAX_VISIBLE = 5; + +export interface BackgroundTaskStackProps { + tasks: BackgroundTask[]; + onCancel: (taskId: string) => void; + onDismiss: (taskId: string) => void; + /** Override default positioning */ + className?: string; +} + +export function BackgroundTaskStack({ + tasks, + onCancel, + onDismiss, + className, +}: BackgroundTaskStackProps) { + if (tasks.length === 0) return null; + + const visible = tasks.slice(0, MAX_VISIBLE); + const overflow = tasks.length - MAX_VISIBLE; + + return ( +
      + {overflow > 0 && ( +
      + +{overflow} more task{overflow > 1 ? 's' : ''} +
      + )} + + {visible.map((task) => ( + + ))} + +
      + ); +} diff --git a/apps/blocks/src/blocks/command-palette/background/inline-task-bar.tsx b/apps/blocks/src/blocks/command-palette/background/inline-task-bar.tsx new file mode 100644 index 0000000..1eabf52 --- /dev/null +++ b/apps/blocks/src/blocks/command-palette/background/inline-task-bar.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import type { BackgroundTask } from '@constructive-io/command-palette'; +import { ChevronDownIcon } from 'lucide-react'; +import { Badge } from '@constructive-io/ui/badge'; +import { Button } from '@constructive-io/ui/button'; +import { cn } from '@/lib/utils'; +import { StatusIcon, ElapsedTime } from './task-icons'; + +export interface InlineTaskBarProps { + tasks: BackgroundTask[]; + onCancel: (taskId: string) => void; + onDismiss: (taskId: string) => void; +} + +const MAX_INLINE = 3; + +function InlineTaskRow({ + task, + onCancel, + onDismiss, +}: { + task: BackgroundTask; + onCancel: (id: string) => void; + onDismiss: (id: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const isRunning = task.status === 'running'; + + return ( + +
      + + +
      + + {/* Expanded error / details */} + + {expanded && ( + +
      +
      + Command: {task.commandId} +
      +
      Started: {new Date(task.startedAt).toLocaleTimeString()}
      + {task.completedAt && ( +
      Completed: {new Date(task.completedAt).toLocaleTimeString()}
      + )} + {task.error && ( +
      {task.error.message}
      + )} +
      +
      + )} +
      +
      + ); +} + +export function InlineTaskBar({ tasks, onCancel, onDismiss }: InlineTaskBarProps) { + if (tasks.length === 0) return null; + + const visible = tasks.slice(0, MAX_INLINE); + const overflow = tasks.length - MAX_INLINE; + + const runningCount = tasks.filter((t) => t.status === 'running').length; + const errorCount = tasks.filter((t) => t.status === 'error').length; + + return ( +
      + {/* Summary header */} +
      + + Tasks + + {runningCount > 0 && ( + + + {runningCount} + + )} + {errorCount > 0 && ( + + {errorCount} failed + + )} + {tasks.length > 0 && ( + + {tasks.length} total + + )} +
      + + {/* Task rows */} +
      + + {visible.map((task) => ( + + ))} + +
      + + {overflow > 0 && ( +
      + +{overflow} more task{overflow > 1 ? 's' : ''} +
      + )} +
      + ); +} diff --git a/apps/blocks/src/blocks/command-palette/background/task-card.tsx b/apps/blocks/src/blocks/command-palette/background/task-card.tsx new file mode 100644 index 0000000..545b593 --- /dev/null +++ b/apps/blocks/src/blocks/command-palette/background/task-card.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import { motion } from 'motion/react'; +import type { BackgroundTask } from '@constructive-io/command-palette'; +import { cn } from '@/lib/utils'; +import { Button } from '@constructive-io/ui/button'; +import { Separator } from '@constructive-io/ui/separator'; +import { StatusIcon, ElapsedTime } from './task-icons'; + +export interface TaskCardProps { + task: BackgroundTask; + onCancel: (taskId: string) => void; + onDismiss: (taskId: string) => void; +} + +const borderByStatus: Record = { + running: 'border-info/30', + success: 'border-success/30', + error: 'border-destructive/30', + cancelled: 'border-border' +}; + +export function TaskCard({ task, onCancel, onDismiss }: TaskCardProps) { + const [expanded, setExpanded] = useState(false); + const isRunning = task.status === 'running'; + + return ( + +
      + + +
      + + {/* Expanded details */} + {expanded && ( + + +
      +
      Command: {task.commandId}
      +
      Started: {new Date(task.startedAt).toLocaleTimeString()}
      + {task.completedAt && ( +
      + Completed: {new Date(task.completedAt).toLocaleTimeString()} +
      + )} + {task.error && ( +
      {task.error.message}
      + )} +
      +
      + )} +
      + ); +} diff --git a/apps/blocks/src/blocks/command-palette/background/task-icons.tsx b/apps/blocks/src/blocks/command-palette/background/task-icons.tsx new file mode 100644 index 0000000..889f021 --- /dev/null +++ b/apps/blocks/src/blocks/command-palette/background/task-icons.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import type { BackgroundTaskStatus } from '@constructive-io/command-palette'; +import { + BanIcon, + CircleCheckIcon, + CircleXIcon, + LoaderCircleIcon, + type LucideIcon +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const statusPresentation: Record< + BackgroundTaskStatus, + { icon: LucideIcon; className: string } +> = { + running: { icon: LoaderCircleIcon, className: 'animate-spin text-info' }, + success: { icon: CircleCheckIcon, className: 'text-success' }, + error: { icon: CircleXIcon, className: 'text-destructive' }, + cancelled: { icon: BanIcon, className: 'text-muted-foreground' } +}; + +export function StatusIcon({ + status, + className +}: { + status: BackgroundTaskStatus; + className?: string; +}) { + const presentation = statusPresentation[status]; + const Icon = presentation.icon; + return ( +