From 571b23da9f50b3611c445eac2141b5830c1717ab Mon Sep 17 00:00:00 2001 From: Kevin Strom Date: Wed, 22 Jul 2026 16:44:46 -0700 Subject: [PATCH 1/7] spec(02): component registry requirements (Nuxt UI primary blocks) --- .../specs/02-component-registry/.config.kiro | 1 + .../02-component-registry/requirements.md | 264 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 .kiro/specs/02-component-registry/.config.kiro create mode 100644 .kiro/specs/02-component-registry/requirements.md diff --git a/.kiro/specs/02-component-registry/.config.kiro b/.kiro/specs/02-component-registry/.config.kiro new file mode 100644 index 0000000..423cef0 --- /dev/null +++ b/.kiro/specs/02-component-registry/.config.kiro @@ -0,0 +1 @@ +{"specId": "0a73b3bd-09a7-4ffb-9bc0-95ced3ac2604", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/02-component-registry/requirements.md b/.kiro/specs/02-component-registry/requirements.md new file mode 100644 index 0000000..e33f657 --- /dev/null +++ b/.kiro/specs/02-component-registry/requirements.md @@ -0,0 +1,264 @@ +# Requirements Document + +## Introduction + +The Component Registry is the single source of truth describing every placeable building block in the Nuxt Visual Builder. It defines each component's editable props, slots, child acceptance rules, and compile hints. The registry is consumed by the render engine, the auto-generated property panel, and the compiler — none of which should need to hardcode component knowledge. + +The primary building blocks are **Nuxt UI page-building components** (`UPageHero`, `UPageSection`, `UPageColumns`, etc.). A small set of custom gap-filler blocks (`RichText`, `Image`, `Spacer`) covers functionality that Nuxt UI does not provide out-of-the-box. Block wrappers in `/app/components/blocks/` are thin pass-through wrappers that delegate rendering to the real Nuxt UI component. + +## Glossary + +- **Registry**: A record mapping component type strings to their RegistryEntry definitions +- **RegistryEntry**: A data object describing a single placeable building block's metadata, props, slots, and constraints +- **PropSchema**: A discriminated union type (defined in `/types/shared.ts`) describing the schema for a single editable property +- **Component_Map**: A runtime record mapping type strings to their actual Vue component references for use with `` +- **Block_Component**: A Vue single-file component under `/app/components/blocks/` that renders a placeable building block — either a thin Nuxt UI wrapper or a custom gap-filler +- **Nuxt_UI_Block**: A Block_Component that wraps a real Nuxt UI component (e.g., `UPageHero`, `UButton`) and passes registered props through +- **Custom_Block**: A Block_Component that implements functionality not available from Nuxt UI (RichText, Image, Spacer) +- **Entries_Module**: The pure-data module at `/registry/entries.ts` containing registry entries with zero `.vue` imports +- **Registry_Module**: The Vue-aware module at `/registry/index.ts` that combines entries with component imports +- **UseRegistry_Composable**: The Vue composable at `/app/composables/useRegistry.ts` exposing registry data to Vue components +- **Zod_Validator**: A Zod schema that validates RegistryEntry objects at module load time in development mode + +## Requirements + +### Requirement 1: Registry Type Definitions + +**User Story:** As a developer, I want a well-typed RegistryEntry interface and Registry type, so that all systems consuming the registry share a single contract. + +#### Acceptance Criteria + +1. THE Registry_Module SHALL export a `RegistryEntry` interface from `/types/registry.ts` containing: `type` (string, PascalCase, maximum 50 characters), `label` (string, maximum 50 characters), `category` (one of 'layout', 'content', 'media', 'form'), `icon` (optional string), `props` (Record), `slots` (optional string array with a maximum of 20 elements), `acceptsChildren` (boolean), `allowedChildren` (optional string array with a maximum of 50 elements), `allowedParents` (optional string array with a maximum of 50 elements), and `compileAs` (optional string) +2. THE Registry_Module SHALL export a `Registry` type defined as `Record` from `/types/registry.ts` +3. THE Registry_Module SHALL require the `type` field of each RegistryEntry to be a PascalCase string (matching the pattern `/^[A-Z][a-zA-Z0-9]*$/`) that is identical to its key in the Registry record +4. THE Registry_Module SHALL import `PropSchema` from `/types/shared.ts` for the `props` field definition +5. IF `acceptsChildren` is set to `false` on a RegistryEntry, THEN THE Registry_Module SHALL disallow the presence of the `allowedChildren` field on that entry + +### Requirement 2: Zod Validation of Registry Entries + +**User Story:** As a developer, I want registry entries to be validated at load time with clear error messages, so that malformed entries are caught early during development. + +#### Acceptance Criteria + +1. WHEN the registry module loads in development mode (detected via `import.meta.dev` or `process.env.NODE_ENV !== 'production'`), THE Zod_Validator SHALL validate every RegistryEntry in the registry against its Zod schema before the module exports become available +2. IF a RegistryEntry has a missing or empty `label` field, THEN THE Zod_Validator SHALL throw an error that includes the entry's `type` and indicates the label is missing or empty +3. IF a RegistryEntry has a `props` field containing a PropSchema with a `type` value not in the set ('string', 'text', 'number', 'boolean', 'enum', 'color', 'url', 'image'), THEN THE Zod_Validator SHALL throw an error that includes the entry's `type` and the invalid prop key name +4. IF a RegistryEntry has `acceptsChildren` set to false but declares a non-empty `slots` array containing a "default" slot, THEN THE Zod_Validator SHALL throw an error that includes the entry's `type` and indicates the conflict between `acceptsChildren: false` and the default slot declaration +5. IF a RegistryEntry has `allowedChildren` defined but `acceptsChildren` is false, THEN THE Zod_Validator SHALL throw an error that includes the entry's `type` and indicates that `allowedChildren` requires `acceptsChildren` to be true +6. WHEN validation passes for all entries, THE Zod_Validator SHALL allow the module to load without throwing any errors +7. WHILE the application is running in production mode, THE Zod_Validator SHALL skip all registry validation to avoid runtime overhead +8. IF a RegistryEntry has a missing or empty `type` field, THEN THE Zod_Validator SHALL throw an error that includes the entry's key in the registry record to identify the malformed entry +9. IF a RegistryEntry has a `category` field with a value not in the set ('layout', 'content', 'media', 'form'), THEN THE Zod_Validator SHALL throw an error that includes the entry's `type` and the invalid category value + +### Requirement 3: Nuxt UI Page-Building Block Set (Primary) + +**User Story:** As a visual builder user, I want Nuxt UI page-building components as my primary placeable blocks, so that I can compose professional landing pages using the same components that Nuxt UI provides. + +#### Acceptance Criteria + +1. THE Block_Component set SHALL include Nuxt_UI_Block entries for the following types: PageHero, PageSection, PageColumns, PageGrid, PageCTA, PageFeature, PageCard, Button, Card, and Separator +2. WHEN a Nuxt_UI_Block registry entry is defined, THE registry entry SHALL set `compileAs` to the actual Nuxt UI component tag name (e.g., PageHero sets `compileAs: 'UPageHero'`, Button sets `compileAs: 'UButton'`) +3. THE Nuxt_UI_Block registry entries SHALL expose only the most editor-friendly props from each Nuxt UI component as PropSchema fields, keeping the property panel manageable rather than exposing all available props +4. THE Nuxt_UI_Block wrapper components SHALL live under `/app/components/blocks/` and SHALL be thin wrappers that pass all registered props through to the actual Nuxt UI component +5. WHEN a Nuxt_UI_Block renders in the editor canvas, THE Block_Component SHALL render the actual Nuxt UI component so that the editor preview matches production output +6. THE Nuxt_UI_Block registry entries SHALL each include an `icon` field with an appropriate Lucide icon name for palette display + +### Requirement 4: PageHero Block + +**User Story:** As a visual builder user, I want a PageHero block that wraps UPageHero, so that I can create prominent hero sections with titles, descriptions, and optional call-to-action links. + +#### Acceptance Criteria + +1. THE PageHero RegistryEntry SHALL set `compileAs` to `'UPageHero'` +2. THE PageHero RegistryEntry SHALL expose a `title` prop of string type with default `'Your Page Title'` +3. THE PageHero RegistryEntry SHALL expose a `description` prop of text type with default `'A compelling description for your hero section'` +4. THE PageHero RegistryEntry SHALL expose a `headline` prop of string type with default `''` +5. THE PageHero RegistryEntry SHALL expose an `orientation` prop of enum type with options `'vertical'`, `'horizontal'` and default `'vertical'` +6. THE PageHero RegistryEntry SHALL have category `'layout'` +7. THE PageHero RegistryEntry SHALL have `acceptsChildren` set to false +8. WHEN the PageHero Block_Component renders, THE Block_Component SHALL pass `title`, `description`, `headline`, and `orientation` props through to the `UPageHero` component +9. WHEN the PageHero Block_Component renders with no props, THE Block_Component SHALL display the default title and description text within a UPageHero component without error + +### Requirement 5: PageSection Block + +**User Story:** As a visual builder user, I want a PageSection block that wraps UPageSection, so that I can create titled content sections with descriptions and optional child content. + +#### Acceptance Criteria + +1. THE PageSection RegistryEntry SHALL set `compileAs` to `'UPageSection'` +2. THE PageSection RegistryEntry SHALL expose a `title` prop of string type with default `'Section Title'` +3. THE PageSection RegistryEntry SHALL expose a `description` prop of text type with default `''` +4. THE PageSection RegistryEntry SHALL expose a `headline` prop of string type with default `''` +5. THE PageSection RegistryEntry SHALL expose an `orientation` prop of enum type with options `'vertical'`, `'horizontal'` and default `'vertical'` +6. THE PageSection RegistryEntry SHALL expose a `reverse` prop of boolean type with default `false` +7. THE PageSection RegistryEntry SHALL have category `'layout'` +8. THE PageSection RegistryEntry SHALL have `acceptsChildren` set to true +9. WHEN the PageSection Block_Component renders, THE Block_Component SHALL pass all registered props through to the `UPageSection` component and render a default `` passing through to UPageSection's default slot +10. WHEN the PageSection Block_Component has no children, THE Block_Component SHALL render successfully showing the title and description text + +### Requirement 6: PageColumns and PageGrid Blocks + +**User Story:** As a visual builder user, I want PageColumns and PageGrid blocks, so that I can create responsive multi-column and grid layouts using Nuxt UI's built-in layout components. + +#### Acceptance Criteria + +1. THE PageColumns RegistryEntry SHALL set `compileAs` to `'UPageColumns'` +2. THE PageColumns RegistryEntry SHALL have category `'layout'` +3. THE PageColumns RegistryEntry SHALL have `acceptsChildren` set to true +4. THE PageColumns RegistryEntry SHALL have an empty `props` record (no editable props — layout is handled by Nuxt UI internally) +5. THE PageGrid RegistryEntry SHALL set `compileAs` to `'UPageGrid'` +6. THE PageGrid RegistryEntry SHALL have category `'layout'` +7. THE PageGrid RegistryEntry SHALL have `acceptsChildren` set to true +8. THE PageGrid RegistryEntry SHALL have an empty `props` record +9. WHEN the PageColumns Block_Component renders, THE Block_Component SHALL render a `UPageColumns` component with a default `` for child content +10. WHEN the PageGrid Block_Component renders, THE Block_Component SHALL render a `UPageGrid` component with a default `` for child content + +### Requirement 7: PageCTA Block + +**User Story:** As a visual builder user, I want a PageCTA block that wraps UPageCTA, so that I can create call-to-action sections with titles and descriptions. + +#### Acceptance Criteria + +1. THE PageCTA RegistryEntry SHALL set `compileAs` to `'UPageCTA'` +2. THE PageCTA RegistryEntry SHALL expose a `title` prop of string type with default `'Ready to Get Started?'` +3. THE PageCTA RegistryEntry SHALL expose a `description` prop of text type with default `'Start building your next project today.'` +4. THE PageCTA RegistryEntry SHALL expose a `headline` prop of string type with default `''` +5. THE PageCTA RegistryEntry SHALL have category `'content'` +6. THE PageCTA RegistryEntry SHALL have `acceptsChildren` set to false +7. WHEN the PageCTA Block_Component renders, THE Block_Component SHALL pass `title`, `description`, and `headline` props through to the `UPageCTA` component +8. WHEN the PageCTA Block_Component renders with no props, THE Block_Component SHALL display the default CTA text within a UPageCTA component without error + +### Requirement 8: PageFeature Block + +**User Story:** As a visual builder user, I want a PageFeature block that wraps UPageFeature, so that I can showcase individual features with icons, titles, and descriptions. + +#### Acceptance Criteria + +1. THE PageFeature RegistryEntry SHALL set `compileAs` to `'UPageFeature'` +2. THE PageFeature RegistryEntry SHALL expose a `title` prop of string type with default `'Feature Title'` +3. THE PageFeature RegistryEntry SHALL expose a `description` prop of text type with default `'Describe this feature and its benefits.'` +4. THE PageFeature RegistryEntry SHALL expose an `icon` prop of string type with default `'i-lucide-star'` +5. THE PageFeature RegistryEntry SHALL expose an `orientation` prop of enum type with options `'vertical'`, `'horizontal'` and default `'vertical'` +6. THE PageFeature RegistryEntry SHALL have category `'content'` +7. THE PageFeature RegistryEntry SHALL have `acceptsChildren` set to false +8. WHEN the PageFeature Block_Component renders, THE Block_Component SHALL pass `title`, `description`, `icon`, and `orientation` props through to the `UPageFeature` component + +### Requirement 9: PageCard and Card Blocks + +**User Story:** As a visual builder user, I want PageCard and Card blocks, so that I can create card-style content containers — PageCard for page-level cards with links, and Card for general-purpose containers. + +#### Acceptance Criteria + +1. THE PageCard RegistryEntry SHALL set `compileAs` to `'UPageCard'` +2. THE PageCard RegistryEntry SHALL expose a `title` prop of string type with default `'Card Title'` +3. THE PageCard RegistryEntry SHALL expose a `description` prop of text type with default `'Card description goes here.'` +4. THE PageCard RegistryEntry SHALL expose a `to` prop of url type with default `''` +5. THE PageCard RegistryEntry SHALL have category `'content'` +6. THE PageCard RegistryEntry SHALL have `acceptsChildren` set to false +7. THE Card RegistryEntry SHALL set `compileAs` to `'UCard'` +8. THE Card RegistryEntry SHALL have category `'content'` +9. THE Card RegistryEntry SHALL have `acceptsChildren` set to true +10. THE Card RegistryEntry SHALL have an empty `props` record (content goes in the default slot) +11. WHEN the PageCard Block_Component renders, THE Block_Component SHALL pass `title`, `description`, and `to` props through to the `UPageCard` component +12. WHEN the Card Block_Component renders, THE Block_Component SHALL render a `UCard` component with a default `` for child content + +### Requirement 10: Button Block + +**User Story:** As a visual builder user, I want a Button block that wraps UButton, so that I can add interactive button/link elements styled with Nuxt UI's design system. + +#### Acceptance Criteria + +1. THE Button RegistryEntry SHALL set `compileAs` to `'UButton'` +2. THE Button RegistryEntry SHALL expose a `label` prop of string type with default `'Click Me'` +3. THE Button RegistryEntry SHALL expose a `to` prop of url type with default `'#'` +4. THE Button RegistryEntry SHALL expose a `color` prop of enum type with options `'primary'`, `'secondary'`, `'neutral'`, `'success'`, `'warning'`, `'error'` and default `'primary'` +5. THE Button RegistryEntry SHALL expose a `variant` prop of enum type with options `'solid'`, `'outline'`, `'soft'`, `'subtle'`, `'ghost'`, `'link'` and default `'solid'` +6. THE Button RegistryEntry SHALL expose a `size` prop of enum type with options `'xs'`, `'sm'`, `'md'`, `'lg'`, `'xl'` and default `'md'` +7. THE Button RegistryEntry SHALL have category `'content'` +8. THE Button RegistryEntry SHALL have `acceptsChildren` set to false +9. WHEN the Button Block_Component renders, THE Block_Component SHALL pass `label`, `to`, `color`, `variant`, and `size` props through to the `UButton` component + +### Requirement 11: Separator Block + +**User Story:** As a visual builder user, I want a Separator block that wraps USeparator, so that I can add horizontal or vertical dividers between content sections. + +#### Acceptance Criteria + +1. THE Separator RegistryEntry SHALL set `compileAs` to `'USeparator'` +2. THE Separator RegistryEntry SHALL expose an `orientation` prop of enum type with options `'horizontal'`, `'vertical'` and default `'horizontal'` +3. THE Separator RegistryEntry SHALL expose a `label` prop of string type with default `''` +4. THE Separator RegistryEntry SHALL have category `'layout'` +5. THE Separator RegistryEntry SHALL have `acceptsChildren` set to false +6. WHEN the Separator Block_Component renders, THE Block_Component SHALL pass `orientation` and `label` props through to the `USeparator` component + +### Requirement 12: Custom Gap-Filler Blocks (RichText, Image, Spacer) + +**User Story:** As a visual builder user, I want RichText, Image, and Spacer blocks for functionality that Nuxt UI does not provide, so that I can add arbitrary text/HTML content, standalone images, and vertical spacing to pages. + +#### Acceptance Criteria + +1. THE RichText Block_Component SHALL be a Custom_Block (no `compileAs` to a Nuxt UI component) implementing its own rendering logic +2. THE RichText RegistryEntry SHALL expose a `body` prop of text type with default `'Enter your content here.'` +3. THE RichText RegistryEntry SHALL expose an `align` prop of enum type with options `'left'`, `'center'`, `'right'` and default `'left'` +4. THE RichText RegistryEntry SHALL have category `'content'` +5. THE RichText RegistryEntry SHALL have `acceptsChildren` set to false +6. WHEN the RichText Block_Component renders, THE Block_Component SHALL render the `body` prop value as HTML content within a block-level container, preserving line breaks, and applying the `align` prop as text alignment +7. THE Image Block_Component SHALL be a Custom_Block implementing its own rendering logic +8. THE Image RegistryEntry SHALL expose a `src` prop of image type with default `'https://placehold.co/800x400'` +9. THE Image RegistryEntry SHALL expose an `alt` prop of string type with default `''` +10. THE Image RegistryEntry SHALL expose a `rounded` prop of boolean type with default `false` +11. THE Image RegistryEntry SHALL have category `'media'` +12. THE Image RegistryEntry SHALL have `acceptsChildren` set to false +13. WHEN the Image Block_Component renders, THE Block_Component SHALL output an `` element with `src` bound to the `src` prop, `alt` bound to the `alt` prop, and a maximum width of 100% of its container +14. IF the `rounded` prop is true, THEN THE Image Block_Component SHALL apply a visible border-radius to the rendered `` element +15. THE Spacer Block_Component SHALL be a Custom_Block implementing its own rendering logic +16. THE Spacer RegistryEntry SHALL expose a `size` prop of enum type with options `'sm'`, `'md'`, `'lg'` and default `'md'` +17. THE Spacer RegistryEntry SHALL have category `'layout'` +18. THE Spacer RegistryEntry SHALL have `acceptsChildren` set to false +19. WHEN the Spacer Block_Component renders, THE Block_Component SHALL output an empty block-level element whose height varies by `size` value, where `'sm'` is the shortest and `'lg'` is the tallest +20. THE Custom_Block components SHALL use Tailwind CSS v4 utility classes for styling + +### Requirement 13: Registry Purity Split + +**User Story:** As a developer, I want the registry entries separated from Vue component imports, so that the compiler, CLI, and server routes can import registry metadata without triggering `.vue` file resolution. + +#### Acceptance Criteria + +1. THE Entries_Module at `/registry/entries.ts` SHALL export all RegistryEntry data as plain object literals containing only serializable values and zero function references +2. THE Entries_Module SHALL contain zero `.vue` file imports and zero imports that transitively resolve `.vue` files +3. WHEN the Entries_Module is imported from a plain Node.js or tsx script running outside Vite, THE Entries_Module SHALL load and evaluate without throwing errors within 2 seconds +4. THE Registry_Module at `/registry/index.ts` SHALL import entries from the Entries_Module and Vue components from `/app/components/blocks/`, and SHALL export a `registry` object re-exporting all entries keyed by type string +5. THE Registry_Module SHALL export a `componentMap` of type `Record` mapping each entry `type` to its Vue component, where every key in `componentMap` has a corresponding entry in the registry and every entry in the registry has a corresponding key in `componentMap` +6. THE Registry_Module SHALL export helper functions: `getEntry(type: string)` returning `RegistryEntry | undefined`, `defaultPropsFor(type: string)` returning `Record`, and `entriesByCategory()` returning `Record` +7. WHEN `defaultPropsFor` is called with a valid type, THE Registry_Module SHALL return an object with one key per entry in the entry's `props` record, each set to its PropSchema `default` value if defined, or `undefined` if the PropSchema declares no default +8. IF `defaultPropsFor` or `getEntry` is called with a type string that does not match any registered entry, THEN THE Registry_Module SHALL return `undefined` +9. WHEN `entriesByCategory` is called, THE Registry_Module SHALL return an object keyed by category string (from the set: `layout`, `content`, `media`, `form`) with each value being an array of all RegistryEntry objects belonging to that category + +### Requirement 14: useRegistry Composable + +**User Story:** As a Vue component developer, I want a composable that exposes the registry, component map, and helpers, so that I can access registry data reactively in Vue components. + +#### Acceptance Criteria + +1. THE UseRegistry_Composable SHALL be located at `/app/composables/useRegistry.ts` +2. WHEN a Vue component calls `useRegistry()`, THE UseRegistry_Composable SHALL return an object containing the properties: `registry`, `componentMap`, `getEntry`, `defaultPropsFor`, and `entriesByCategory`, each re-exported from the `/registry/index.ts` module +3. THE UseRegistry_Composable SHALL expose `registry` as the complete `Registry` record (all registered `RegistryEntry` objects keyed by type string) +4. THE UseRegistry_Composable SHALL expose `componentMap` as a `Record` mapping each registered type string to its corresponding Vue component +5. THE UseRegistry_Composable SHALL expose `getEntry`, `defaultPropsFor`, and `entriesByCategory` as callable functions that retain the same signatures and behavior as their `/registry/index.ts` counterparts +6. WHEN the UseRegistry_Composable is called within a Vue component's `setup` context, THE UseRegistry_Composable SHALL return successfully without throwing an error +7. IF `getEntry` is called via the UseRegistry_Composable with a type string that does not exist in the registry, THEN THE UseRegistry_Composable SHALL return `undefined` for that entry rather than throwing an error + +### Requirement 15: Registry Consistency Verification + +**User Story:** As a developer, I want automated tests verifying that every component map entry has a matching registry entry and vice-versa, so that mismatches are caught before runtime. + +#### Acceptance Criteria + +1. WHEN the test suite runs, THE test SHALL verify that every key in `componentMap` has a corresponding key with an identical string in the `registry`, and report each unmatched key as a distinct assertion failure +2. WHEN the test suite runs, THE test SHALL verify that every key in the `registry` has a corresponding key with an identical string in `componentMap`, and report each unmatched key as a distinct assertion failure +3. WHEN `defaultPropsFor('PageHero')` is called, THE test SHALL verify the returned object is a plain object keyed by prop name (title, description, headline, orientation) where each value equals the `default` field declared in that prop's `PropSchema` within the PageHero registry entry +4. WHEN a RegistryEntry missing the required `label` field is passed to the Zod validator, THE test SHALL verify that the validator throws an error indicating the validation failure +5. WHEN the test reads the source text of `/registry/entries.ts`, THE test SHALL verify that the file contains zero import statements referencing any path ending in `.vue` +6. IF a new key is added to `componentMap` without a matching `registry` entry, THEN THE test SHALL fail with an assertion identifying the orphaned componentMap key +7. IF a new key is added to the `registry` without a matching `componentMap` entry, THEN THE test SHALL fail with an assertion identifying the orphaned registry key +8. WHEN the test suite runs, THE test SHALL verify that every Nuxt_UI_Block RegistryEntry has a non-empty `compileAs` field that starts with `'U'` (indicating a Nuxt UI component tag) From 93aad5dd55935f7fe8d654ec7ac22d55b3b83cff Mon Sep 17 00:00:00 2001 From: Kevin Strom Date: Wed, 22 Jul 2026 17:03:42 -0700 Subject: [PATCH 2/7] spec(02): component registry design and implementation tasks --- .kiro/specs/02-component-registry/design.md | 537 ++++++++++++++++++++ .kiro/specs/02-component-registry/tasks.md | 207 ++++++++ 2 files changed, 744 insertions(+) create mode 100644 .kiro/specs/02-component-registry/design.md create mode 100644 .kiro/specs/02-component-registry/tasks.md diff --git a/.kiro/specs/02-component-registry/design.md b/.kiro/specs/02-component-registry/design.md new file mode 100644 index 0000000..9f8298a --- /dev/null +++ b/.kiro/specs/02-component-registry/design.md @@ -0,0 +1,537 @@ +# Design Document: Component Registry + +## Overview + +The Component Registry is the central metadata store for all placeable building blocks in the Nuxt Visual Builder. It provides a typed, validated, single-source-of-truth that the render engine, property panel, and compiler all consume without hardcoding component knowledge. + +The registry architecture follows a **purity split** pattern: a pure-data module (`/registry/entries.ts`) contains all metadata as plain serializable objects — importable from Node.js, CLI tools, or server routes — while a Vue-aware module (`/registry/index.ts`) marries that metadata with actual `.vue` component references for runtime rendering. + +Key design decisions: +- **Nuxt UI as primary block set**: The builder wraps Nuxt UI page-building components (`UPageHero`, `UPageSection`, etc.) as first-class blocks, ensuring WYSIWYG fidelity between editor and production. +- **Custom gap-fillers for gaps**: `RichText`, `Image`, and `Spacer` cover functionality Nuxt UI doesn't provide. +- **Zod validation in dev only**: Registry entries are schema-validated at module load time during development, but validation is tree-shaken in production for zero overhead. +- **Composable access pattern**: A `useRegistry()` composable re-exports registry data for convenient access in Vue components. + +## Architecture + +```mermaid +graph TD + subgraph "Pure Data Layer (No .vue imports)" + A["/types/shared.ts
PropSchema, PropType"] --> B["/types/registry.ts
RegistryEntry, Registry"] + B --> C["/registry/entries.ts
Plain object entries"] + D["/registry/validation.ts
Zod schemas"] --> C + end + + subgraph "Vue-Aware Layer" + C --> E["/registry/index.ts
registry, componentMap, helpers"] + F["/app/components/blocks/*.vue
Block components"] --> E + E --> G["/app/composables/useRegistry.ts
useRegistry()"] + end + + subgraph "Consumers" + G --> H["Editor Canvas"] + G --> I["Property Panel"] + E --> J["Compiler"] + C --> K["CLI / Server Routes"] + end +``` + +### Purity Split Rationale + +The split into `entries.ts` (pure) and `index.ts` (Vue-aware) serves two purposes: + +1. **Tooling compatibility**: The compiler, CLI scripts, and server routes need registry metadata but run in plain Node.js without Vite's `.vue` file resolution. They import directly from `entries.ts`. +2. **Testability**: The pure-data layer can be tested without a Vue/Nuxt environment, enabling faster unit tests and property-based testing. + +### Dev-Time Validation Flow + +```mermaid +sequenceDiagram + participant App as Application Boot + participant Reg as registry/index.ts + participant Val as registry/validation.ts + participant Zod as Zod Schema + + App->>Reg: import registry + Reg->>Reg: Check import.meta.dev + alt Development Mode + Reg->>Val: validateRegistry(entries) + Val->>Zod: Parse each entry + alt Valid + Zod-->>Val: Success + Val-->>Reg: OK + else Invalid + Zod-->>Val: ZodError + Val-->>Reg: throw Error (type + field details) + end + else Production Mode + Note over Reg: Skip validation entirely + end + Reg-->>App: Export registry, componentMap, helpers +``` + +## Components and Interfaces + +### Type Definitions (`/types/registry.ts`) + +```typescript +import type { PropSchema } from './shared' + +export type Category = 'layout' | 'content' | 'media' | 'form' + +export interface RegistryEntry { + type: string // PascalCase, matches /^[A-Z][a-zA-Z0-9]*$/, max 50 chars + label: string // Human-readable name, max 50 chars + category: Category + icon?: string // Lucide icon name for palette display + props: Record + slots?: string[] // Max 20 elements + acceptsChildren: boolean + allowedChildren?: string[] // Max 50 elements; only valid if acceptsChildren is true + allowedParents?: string[] // Max 50 elements + compileAs?: string // Nuxt UI tag name (e.g., 'UPageHero') +} + +export type Registry = Record +``` + +### Validation Module (`/registry/validation.ts`) + +```typescript +import { z } from 'zod' + +// Zod schema for PropSchema validation +export const propSchemaZod: z.ZodType = z.discriminatedUnion('type', [ + z.object({ type: z.literal('string'), label: z.string().optional(), default: z.string().optional() }), + z.object({ type: z.literal('text'), label: z.string().optional(), default: z.string().optional() }), + z.object({ type: z.literal('number'), label: z.string().optional(), default: z.number().optional(), min: z.number().optional(), max: z.number().optional(), step: z.number().optional() }), + z.object({ type: z.literal('boolean'), label: z.string().optional(), default: z.boolean().optional() }), + z.object({ type: z.literal('enum'), label: z.string().optional(), default: z.string().optional(), options: z.array(z.string()).min(1) }), + z.object({ type: z.literal('color'), label: z.string().optional(), default: z.string().optional() }), + z.object({ type: z.literal('url'), label: z.string().optional(), default: z.string().optional() }), + z.object({ type: z.literal('image'), label: z.string().optional(), default: z.string().optional() }), +]) + +// Zod schema for RegistryEntry validation +export const registryEntryZod = z.object({ + type: z.string().min(1).max(50).regex(/^[A-Z][a-zA-Z0-9]*$/), + label: z.string().min(1).max(50), + category: z.enum(['layout', 'content', 'media', 'form']), + icon: z.string().optional(), + props: z.record(z.string(), propSchemaZod), + slots: z.array(z.string()).max(20).optional(), + acceptsChildren: z.boolean(), + allowedChildren: z.array(z.string()).max(50).optional(), + allowedParents: z.array(z.string()).max(50).optional(), + compileAs: z.string().optional(), +}).superRefine((entry, ctx) => { + // Constraint: allowedChildren requires acceptsChildren: true + if (!entry.acceptsChildren && entry.allowedChildren !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `[${entry.type}] allowedChildren requires acceptsChildren to be true`, + }) + } + // Constraint: acceptsChildren:false conflicts with "default" slot + if (!entry.acceptsChildren && entry.slots?.includes('default')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `[${entry.type}] acceptsChildren:false conflicts with declaring a "default" slot`, + }) + } +}) + +export function validateRegistry(registry: Record): void { + for (const [key, entry] of Object.entries(registry)) { + const result = registryEntryZod.safeParse(entry) + if (!result.success) { + const issues = result.error.issues.map(i => i.message).join('; ') + throw new Error(`Registry validation failed for "${key}": ${issues}`) + } + // Verify type field matches key + if ((entry as { type?: string }).type !== key) { + throw new Error(`Registry key "${key}" does not match entry.type "${(entry as { type?: string }).type}"`) + } + } +} +``` + +### Entries Module (`/registry/entries.ts`) + +Pure-data module exporting all registry entries. Zero `.vue` imports. Example structure: + +```typescript +import type { Registry } from '../types/registry' + +export const entries: Registry = { + PageHero: { + type: 'PageHero', + label: 'Page Hero', + category: 'layout', + icon: 'i-lucide-layout-template', + props: { + title: { type: 'string', default: 'Your Page Title' }, + description: { type: 'text', default: 'A compelling description for your hero section' }, + headline: { type: 'string', default: '' }, + orientation: { type: 'enum', options: ['vertical', 'horizontal'], default: 'vertical' }, + }, + acceptsChildren: false, + compileAs: 'UPageHero', + }, + // ... all other entries +} +``` + +### Registry Module (`/registry/index.ts`) + +Vue-aware module that combines entries with component imports: + +```typescript +import type { Component } from 'vue' +import type { RegistryEntry, Registry } from '../types/registry' +import { entries } from './entries' +import { validateRegistry } from './validation' + +// Dev-time validation +if (import.meta.dev) { + validateRegistry(entries) +} + +// Import block components +import BlockPageHero from '~/components/blocks/BlockPageHero.vue' +// ... all other component imports + +export const registry: Registry = entries + +export const componentMap: Record = { + PageHero: BlockPageHero, + // ... all other mappings +} + +export function getEntry(type: string): RegistryEntry | undefined { + return registry[type] +} + +export function defaultPropsFor(type: string): Record | undefined { + const entry = registry[type] + if (!entry) return undefined + const defaults: Record = {} + for (const [key, schema] of Object.entries(entry.props)) { + defaults[key] = schema.default ?? undefined + } + return defaults +} + +export function entriesByCategory(): Record { + const grouped: Record = {} + for (const entry of Object.values(registry)) { + if (!grouped[entry.category]) grouped[entry.category] = [] + grouped[entry.category].push(entry) + } + return grouped +} +``` + +### useRegistry Composable (`/app/composables/useRegistry.ts`) + +```typescript +import { registry, componentMap, getEntry, defaultPropsFor, entriesByCategory } from '../../registry' + +export function useRegistry() { + return { + registry, + componentMap, + getEntry, + defaultPropsFor, + entriesByCategory, + } +} +``` + +### Block Components (`/app/components/blocks/`) + +**Nuxt UI Blocks** are thin pass-through wrappers: + +```vue + + + + +``` + +**Custom Blocks** implement their own rendering: + +```vue + + + + +``` + +## Data Models + +### RegistryEntry Field Constraints + +| Field | Type | Constraints | +|-------|------|-------------| +| `type` | `string` | PascalCase (`/^[A-Z][a-zA-Z0-9]*$/`), max 50 chars, must equal its registry key | +| `label` | `string` | Non-empty, max 50 chars | +| `category` | `Category` | One of: `'layout'`, `'content'`, `'media'`, `'form'` | +| `icon` | `string?` | Optional Lucide icon class | +| `props` | `Record` | Each value is a valid PropSchema discriminated union | +| `slots` | `string[]?` | Optional, max 20 elements | +| `acceptsChildren` | `boolean` | Required | +| `allowedChildren` | `string[]?` | Only valid when `acceptsChildren: true`, max 50 elements | +| `allowedParents` | `string[]?` | Optional, max 50 elements | +| `compileAs` | `string?` | Nuxt UI tag name (e.g., `'UPageHero'`), starts with `'U'` for Nuxt UI blocks | + +### PropSchema Variants + +| Variant | Fields | Usage | +|---------|--------|-------| +| `string` | `default?: string` | Single-line text input | +| `text` | `default?: string` | Multi-line textarea | +| `number` | `default?: number, min?, max?, step?` | Numeric input with bounds | +| `boolean` | `default?: boolean` | Toggle switch | +| `enum` | `default?: string, options: string[]` | Dropdown select (min 1 option) | +| `color` | `default?: string` | Color picker | +| `url` | `default?: string` | URL input with validation | +| `image` | `default?: string` | Image picker/URL | + +### Complete Block Registry (13 entries) + +| Type | Category | compileAs | acceptsChildren | Props | +|------|----------|-----------|-----------------|-------| +| PageHero | layout | UPageHero | false | title, description, headline, orientation | +| PageSection | layout | UPageSection | true | title, description, headline, orientation, reverse | +| PageColumns | layout | UPageColumns | true | *(none)* | +| PageGrid | layout | UPageGrid | true | *(none)* | +| PageCTA | content | UPageCTA | false | title, description, headline | +| PageFeature | content | UPageFeature | false | title, description, icon, orientation | +| PageCard | content | UPageCard | false | title, description, to | +| Card | content | UCard | true | *(none)* | +| Button | content | UButton | false | label, to, color, variant, size | +| Separator | layout | USeparator | false | orientation, label | +| RichText | content | *(none)* | false | body, align | +| Image | media | *(none)* | false | src, alt, rounded | +| Spacer | layout | *(none)* | false | size | + +### Component Map Structure + +The `componentMap` is a `Record` with exactly one entry per registry entry: + +```typescript +{ + PageHero: BlockPageHero, // ~/components/blocks/BlockPageHero.vue + PageSection: BlockPageSection, // ~/components/blocks/BlockPageSection.vue + PageColumns: BlockPageColumns, // ~/components/blocks/BlockPageColumns.vue + PageGrid: BlockPageGrid, // ~/components/blocks/BlockPageGrid.vue + PageCTA: BlockPageCTA, // ~/components/blocks/BlockPageCTA.vue + PageFeature: BlockPageFeature, // ~/components/blocks/BlockPageFeature.vue + PageCard: BlockPageCard, // ~/components/blocks/BlockPageCard.vue + Card: BlockCard, // ~/components/blocks/BlockCard.vue + Button: BlockButton, // ~/components/blocks/BlockButton.vue + Separator: BlockSeparator, // ~/components/blocks/BlockSeparator.vue + RichText: BlockRichText, // ~/components/blocks/BlockRichText.vue + Image: BlockImage, // ~/components/blocks/BlockImage.vue + Spacer: BlockSpacer, // ~/components/blocks/BlockSpacer.vue +} +``` + + + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Validator rejects invalid entries with identifying information + +*For any* RegistryEntry object that violates one or more schema constraints (missing/empty label, invalid prop type, invalid category, allowedChildren with acceptsChildren:false, "default" slot with acceptsChildren:false, missing/empty type), the Zod validator SHALL throw an error whose message includes the entry's type or registry key, enabling identification of the malformed entry. + +**Validates: Requirements 1.5, 2.2, 2.3, 2.4, 2.5, 2.8, 2.9** + +### Property 2: Valid entries pass validation without errors + +*For any* RegistryEntry object that satisfies all schema constraints (non-empty PascalCase type, non-empty label, valid category, valid PropSchema types, no allowedChildren when acceptsChildren is false, no "default" slot when acceptsChildren is false), the Zod validator SHALL accept the entry without throwing. + +**Validates: Requirements 2.6** + +### Property 3: Registry and componentMap bijection + +*For any* state of the registry module, the set of keys in `registry` SHALL be exactly equal to the set of keys in `componentMap` — every registry entry has a corresponding component and every component has a corresponding registry entry. + +**Validates: Requirements 13.5, 15.1, 15.2, 15.6, 15.7** + +### Property 4: defaultPropsFor returns correct defaults for all entries + +*For any* registered entry type, calling `defaultPropsFor(type)` SHALL return an object whose keys are exactly the keys of that entry's `props` record, and whose values equal the `default` field of each PropSchema (or `undefined` if no default is declared). + +**Validates: Requirements 13.7, 15.3** + +### Property 5: entriesByCategory groups all entries correctly + +*For any* entry in the registry, that entry SHALL appear in `entriesByCategory()[entry.category]` and SHALL NOT appear in any other category's array. The union of all category arrays SHALL contain every registry entry exactly once. + +**Validates: Requirements 13.9** + +### Property 6: Non-existent type strings return undefined + +*For any* string that does not match any key in the registry, both `getEntry(string)` and `defaultPropsFor(string)` SHALL return `undefined` rather than throwing. + +**Validates: Requirements 13.8, 14.7** + +### Property 7: Entries module produces serializable data + +*For any* entry in the entries module, `JSON.parse(JSON.stringify(entry))` SHALL produce an object deeply equal to the original entry — confirming zero function references, zero non-serializable values. + +**Validates: Requirements 13.1** + +### Property 8: Nuxt UI blocks have valid compileAs and icon + +*For any* registry entry whose `compileAs` field is defined, that field SHALL be a non-empty string starting with `'U'`. Additionally, every entry with a defined `compileAs` field SHALL have a non-empty `icon` field. + +**Validates: Requirements 3.2, 3.6, 15.8** + +## Error Handling + +### Validation Errors (Dev-Time Only) + +| Scenario | Error Message Format | Recovery | +|----------|---------------------|----------| +| Missing/empty `type` | `Registry validation failed for "{key}": ...` | Fix entry, hot-reload picks up change | +| Missing/empty `label` | `Registry validation failed for "{type}": ...` | Fix entry | +| Invalid `category` | `Registry validation failed for "{type}": invalid category "{value}"` | Fix to valid category | +| Invalid prop `type` | `Registry validation failed for "{type}": invalid prop type on "{propKey}"` | Fix prop schema | +| `allowedChildren` with `acceptsChildren:false` | `[{type}] allowedChildren requires acceptsChildren to be true` | Remove allowedChildren or set acceptsChildren:true | +| `"default"` slot with `acceptsChildren:false` | `[{type}] acceptsChildren:false conflicts with declaring a "default" slot` | Remove default from slots or set acceptsChildren:true | +| Key/type mismatch | `Registry key "{key}" does not match entry.type "{type}"` | Align key and type field | + +### Runtime Error Handling + +- **`getEntry(unknownType)`**: Returns `undefined` (no throw). Consumers must handle the undefined case. +- **`defaultPropsFor(unknownType)`**: Returns `undefined` (no throw). +- **`entriesByCategory()`**: Always succeeds — returns empty arrays for categories with no entries. +- **Component render failures**: Block components use Vue's error boundary. If a Nuxt UI component fails to render (e.g., due to an invalid prop combination), the error is caught at the component level — not the registry level. + +### Production Mode + +All Zod validation is skipped via `import.meta.dev` guard. Registry entries are assumed valid in production. If corrupted data reaches production, the system fails gracefully at the component render level rather than crashing the entire app. + +## Testing Strategy + +### Test Framework & Libraries + +- **Test runner**: Vitest (already configured via `vitest.config.ts`) +- **Property-based testing**: fast-check (already in devDependencies) +- **Environment**: `node` for pure-data tests, `nuxt` for composable/component tests +- **Test location**: `/tests/registry.property.test.ts` for property tests, `/tests/registry.test.ts` for example-based tests + +### Dual Testing Approach + +**Property-Based Tests** (fast-check, 100+ iterations each): +- Validate universal properties across generated inputs +- Each test tagged with: `Feature: 02-component-registry, Property {N}: {title}` +- Cover Properties 1–8 from the Correctness Properties section +- Use fast-check arbitraries to generate random RegistryEntry objects, prop schemas, and type strings + +**Example-Based Unit Tests**: +- Verify specific entry field values (PageHero has correct defaults, Button has correct enum options, etc.) +- Verify concrete validation error messages for known bad entries +- Verify `.vue`-free source text of `entries.ts` +- Verify `entriesByCategory()` output structure for current entries + +**Integration Tests** (Nuxt environment): +- Verify `useRegistry()` composable works in Vue setup context +- Verify block components render their expected Nuxt UI component +- Verify dev-mode validation fires on module load + +### Property Test Configuration + +```typescript +// Each property test uses at minimum 100 iterations +fc.assert(fc.property(...), { numRuns: 100 }) +``` + +### Test Arbitraries (Generators) + +Key generators needed for property tests: + +```typescript +// Valid PascalCase string generator +const pascalCase = fc.stringMatching(/^[A-Z][a-zA-Z0-9]{0,49}$/) + +// Valid category generator +const category = fc.constantFrom('layout', 'content', 'media', 'form') + +// Valid PropSchema generator +const propSchema = fc.oneof( + fc.record({ type: fc.constant('string'), default: fc.option(fc.string()) }), + fc.record({ type: fc.constant('text'), default: fc.option(fc.string()) }), + fc.record({ type: fc.constant('number'), default: fc.option(fc.integer()) }), + fc.record({ type: fc.constant('boolean'), default: fc.option(fc.boolean()) }), + fc.record({ type: fc.constant('enum'), options: fc.array(fc.string(), { minLength: 1 }), default: fc.option(fc.string()) }), + // ... color, url, image variants +) + +// Valid RegistryEntry generator +const validEntry = fc.record({ + type: pascalCase, + label: fc.string({ minLength: 1, maxLength: 50 }), + category: category, + props: fc.dictionary(fc.string(), propSchema), + acceptsChildren: fc.boolean(), +}).chain(entry => { + // Conditionally add allowedChildren only when acceptsChildren is true + if (entry.acceptsChildren) { + return fc.record({ ...entry, allowedChildren: fc.option(fc.array(fc.string())) }) + } + return fc.constant(entry) +}) +``` + +### Test File Organization + +``` +tests/ +├── registry.property.test.ts # Property-based tests (Properties 1-8) +├── registry.test.ts # Example-based unit tests +├── registry.integration.test.ts # Nuxt-environment integration tests +└── ...existing tests... +``` diff --git a/.kiro/specs/02-component-registry/tasks.md b/.kiro/specs/02-component-registry/tasks.md new file mode 100644 index 0000000..6d68776 --- /dev/null +++ b/.kiro/specs/02-component-registry/tasks.md @@ -0,0 +1,207 @@ +# Implementation Plan: Component Registry + +## Overview + +Build the Component Registry — the single source of truth for all placeable building blocks in the Nuxt Visual Builder. The implementation follows a purity split architecture: a pure-data entries module (no `.vue` imports) for tooling compatibility, a Vue-aware registry module that combines entries with component references, Zod validation in dev mode, 13 block components (10 Nuxt UI wrappers + 3 custom gap-fillers), and a `useRegistry()` composable for Vue access. + +## Tasks + +- [ ] 1. Set up type definitions and validation schema + - [ ] 1.1 Create RegistryEntry interface and Registry type in `/types/registry.ts` + - Define `Category` type as union of `'layout' | 'content' | 'media' | 'form'` + - Define `RegistryEntry` interface with all fields: type, label, category, icon, props, slots, acceptsChildren, allowedChildren, allowedParents, compileAs + - Define `Registry` type as `Record` + - Import `PropSchema` from `/types/shared.ts` + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_ + + - [ ] 1.2 Create Zod validation module at `/registry/validation.ts` + - Define `propSchemaZod` as a discriminated union covering all 8 PropSchema types (string, text, number, boolean, enum, color, url, image) + - Define `registryEntryZod` schema with all field constraints (PascalCase regex, max lengths, valid categories) + - Add `.superRefine` for cross-field constraints: allowedChildren requires acceptsChildren:true, "default" slot conflicts with acceptsChildren:false + - Implement `validateRegistry()` function that iterates entries, validates each against the schema, and throws descriptive errors including entry type/key + - Verify type field matches its registry key + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_ + +- [ ] 2. Implement registry entries (pure-data module) + - [ ] 2.1 Create `/registry/entries.ts` with all 13 registry entries + - Export `entries` as a `Registry` object with zero `.vue` imports + - Include PageHero entry: category layout, compileAs UPageHero, props (title/string, description/text, headline/string, orientation/enum), acceptsChildren false + - Include PageSection entry: category layout, compileAs UPageSection, props (title/string, description/text, headline/string, orientation/enum, reverse/boolean), acceptsChildren true + - Include PageColumns entry: category layout, compileAs UPageColumns, empty props, acceptsChildren true + - Include PageGrid entry: category layout, compileAs UPageGrid, empty props, acceptsChildren true + - Include PageCTA entry: category content, compileAs UPageCTA, props (title/string, description/text, headline/string), acceptsChildren false + - Include PageFeature entry: category content, compileAs UPageFeature, props (title/string, description/text, icon/string, orientation/enum), acceptsChildren false + - Include PageCard entry: category content, compileAs UPageCard, props (title/string, description/text, to/url), acceptsChildren false + - Include Card entry: category content, compileAs UCard, empty props, acceptsChildren true + - Include Button entry: category content, compileAs UButton, props (label/string, to/url, color/enum, variant/enum, size/enum), acceptsChildren false + - Include Separator entry: category layout, compileAs USeparator, props (orientation/enum, label/string), acceptsChildren false + - Include RichText entry: category content, no compileAs, props (body/text, align/enum), acceptsChildren false + - Include Image entry: category media, no compileAs, props (src/image, alt/string, rounded/boolean), acceptsChildren false + - Include Spacer entry: category layout, no compileAs, props (size/enum), acceptsChildren false + - All entries must include icon field with appropriate Lucide icon names + - All prop defaults must match the values specified in requirements 4–12 + - _Requirements: 3.1, 3.2, 3.3, 3.6, 4.1–4.7, 5.1–5.8, 6.1–6.8, 7.1–7.5, 8.1–8.6, 9.1–9.10, 10.1–10.7, 11.1–11.4, 12.1–12.19, 13.1, 13.2_ + +- [ ] 3. Implement Nuxt UI block components + - [ ] 3.1 Create BlockPageHero.vue in `/app/components/blocks/` + - Thin wrapper passing title, description, headline, orientation props to UPageHero + - Define props with `withDefaults` using defaults from registry entry + - _Requirements: 4.8, 4.9, 3.4, 3.5_ + + - [ ] 3.2 Create BlockPageSection.vue in `/app/components/blocks/` + - Wrapper passing title, description, headline, orientation, reverse props to UPageSection + - Include default `` for child content + - _Requirements: 5.9, 5.10, 3.4, 3.5_ + + - [ ] 3.3 Create BlockPageColumns.vue and BlockPageGrid.vue in `/app/components/blocks/` + - PageColumns: render UPageColumns with default slot for children + - PageGrid: render UPageGrid with default slot for children + - _Requirements: 6.9, 6.10, 3.4, 3.5_ + + - [ ] 3.4 Create BlockPageCTA.vue in `/app/components/blocks/` + - Wrapper passing title, description, headline props to UPageCTA + - _Requirements: 7.7, 7.8, 3.4, 3.5_ + + - [ ] 3.5 Create BlockPageFeature.vue in `/app/components/blocks/` + - Wrapper passing title, description, icon, orientation props to UPageFeature + - _Requirements: 8.8, 3.4, 3.5_ + + - [ ] 3.6 Create BlockPageCard.vue and BlockCard.vue in `/app/components/blocks/` + - PageCard: wrapper passing title, description, to props to UPageCard + - Card: render UCard with default slot for children + - _Requirements: 9.11, 9.12, 3.4, 3.5_ + + - [ ] 3.7 Create BlockButton.vue in `/app/components/blocks/` + - Wrapper passing label, to, color, variant, size props to UButton + - _Requirements: 10.9, 3.4, 3.5_ + + - [ ] 3.8 Create BlockSeparator.vue in `/app/components/blocks/` + - Wrapper passing orientation and label props to USeparator + - _Requirements: 11.6, 3.4, 3.5_ + +- [ ] 4. Implement custom gap-filler block components + - [ ] 4.1 Create BlockRichText.vue in `/app/components/blocks/` + - Render body prop as HTML content using `v-html` in a block-level container + - Apply text alignment from align prop using Tailwind CSS v4 classes + - Use `prose max-w-none` class for content styling + - _Requirements: 12.1, 12.6, 12.20_ + + - [ ] 4.2 Create BlockImage.vue in `/app/components/blocks/` + - Render `` element with src and alt props bound + - Apply `max-w-full` for responsive sizing + - Conditionally apply border-radius class when rounded prop is true + - _Requirements: 12.7, 12.13, 12.14, 12.20_ + + - [ ] 4.3 Create BlockSpacer.vue in `/app/components/blocks/` + - Render empty block-level element with height varying by size prop + - sm = shortest, md = medium, lg = tallest using Tailwind height classes + - _Requirements: 12.15, 12.19, 12.20_ + +- [ ] 5. Checkpoint - Ensure entries and block components are complete + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 6. Implement Vue-aware registry module and composable + - [ ] 6.1 Create `/registry/index.ts` with registry, componentMap, and helper functions + - Import entries from `./entries` and all 13 block components from `~/components/blocks/` + - Add dev-time validation guard: `if (import.meta.dev) { validateRegistry(entries) }` + - Export `registry` (re-export entries) + - Export `componentMap` mapping all 13 type strings to their Vue component references + - Implement and export `getEntry(type)` returning `RegistryEntry | undefined` + - Implement and export `defaultPropsFor(type)` returning object with each prop's default value, or undefined for unknown types + - Implement and export `entriesByCategory()` returning entries grouped by category + - _Requirements: 13.4, 13.5, 13.6, 13.7, 13.8, 13.9, 2.1, 2.7_ + + - [ ] 6.2 Create `/app/composables/useRegistry.ts` + - Import registry, componentMap, getEntry, defaultPropsFor, entriesByCategory from `/registry/index.ts` + - Export `useRegistry()` function returning all imported values + - _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_ + +- [ ] 7. Checkpoint - Ensure registry module loads and composable works + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 8. Write property-based tests + - [ ]* 8.1 Write property test: Validator rejects invalid entries with identifying information + - **Property 1: Validator rejects invalid entries with identifying information** + - Use fast-check to generate RegistryEntry objects that violate various constraints + - Assert that the Zod validator throws errors containing the entry's type or registry key + - **Validates: Requirements 1.5, 2.2, 2.3, 2.4, 2.5, 2.8, 2.9** + + - [ ]* 8.2 Write property test: Valid entries pass validation without errors + - **Property 2: Valid entries pass validation without errors** + - Use fast-check to generate fully valid RegistryEntry objects + - Assert that the Zod validator accepts them without throwing + - **Validates: Requirements 2.6** + + - [ ]* 8.3 Write property test: Registry and componentMap bijection + - **Property 3: Registry and componentMap bijection** + - Assert that keys of registry and componentMap are identical sets + - **Validates: Requirements 13.5, 15.1, 15.2, 15.6, 15.7** + + - [ ]* 8.4 Write property test: defaultPropsFor returns correct defaults for all entries + - **Property 4: defaultPropsFor returns correct defaults for all entries** + - For each registered entry, verify defaultPropsFor returns keys matching the entry's props record with correct default values + - **Validates: Requirements 13.7, 15.3** + + - [ ]* 8.5 Write property test: entriesByCategory groups all entries correctly + - **Property 5: entriesByCategory groups all entries correctly** + - Verify each entry appears in its category group and nowhere else, and the union covers all entries + - **Validates: Requirements 13.9** + + - [ ]* 8.6 Write property test: Non-existent type strings return undefined + - **Property 6: Non-existent type strings return undefined** + - Use fast-check to generate random strings not matching any registry key + - Assert getEntry and defaultPropsFor return undefined without throwing + - **Validates: Requirements 13.8, 14.7** + + - [ ]* 8.7 Write property test: Entries module produces serializable data + - **Property 7: Entries module produces serializable data** + - For each entry, verify JSON.parse(JSON.stringify(entry)) deep-equals the original + - **Validates: Requirements 13.1** + + - [ ]* 8.8 Write property test: Nuxt UI blocks have valid compileAs and icon + - **Property 8: Nuxt UI blocks have valid compileAs and icon** + - For entries with compileAs defined, verify it starts with 'U' and icon is non-empty + - **Validates: Requirements 3.2, 3.6, 15.8** + +- [ ] 9. Write example-based unit tests + - [ ]* 9.1 Write unit tests for registry consistency and validation + - Verify componentMap and registry key sets are identical (report each mismatch) + - Verify defaultPropsFor('PageHero') returns correct defaults + - Verify Zod validator throws for entry with missing label + - Verify `/registry/entries.ts` source has zero `.vue` import statements + - Verify all Nuxt UI block entries have non-empty compileAs starting with 'U' + - _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5, 15.6, 15.7, 15.8_ + + - [ ]* 9.2 Write unit tests for entries module purity + - Verify entries.ts loads in a plain Node.js context without errors + - Verify all entries are serializable (no function references) + - _Requirements: 13.1, 13.2, 13.3_ + +- [ ] 10. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties from the design document +- Unit tests validate specific examples and edge cases +- The implementation uses TypeScript throughout, matching the design document +- Block components use Vue 3 ` diff --git a/app/components/blocks/BlockCard.vue b/app/components/blocks/BlockCard.vue new file mode 100644 index 0000000..6d7a97a --- /dev/null +++ b/app/components/blocks/BlockCard.vue @@ -0,0 +1,8 @@ + + + diff --git a/app/components/blocks/BlockImage.vue b/app/components/blocks/BlockImage.vue new file mode 100644 index 0000000..869cd38 --- /dev/null +++ b/app/components/blocks/BlockImage.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/components/blocks/BlockPageCTA.vue b/app/components/blocks/BlockPageCTA.vue new file mode 100644 index 0000000..bce49ef --- /dev/null +++ b/app/components/blocks/BlockPageCTA.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/components/blocks/BlockPageCard.vue b/app/components/blocks/BlockPageCard.vue new file mode 100644 index 0000000..301f833 --- /dev/null +++ b/app/components/blocks/BlockPageCard.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/components/blocks/BlockPageColumns.vue b/app/components/blocks/BlockPageColumns.vue new file mode 100644 index 0000000..737b7ba --- /dev/null +++ b/app/components/blocks/BlockPageColumns.vue @@ -0,0 +1,8 @@ + + + diff --git a/app/components/blocks/BlockPageFeature.vue b/app/components/blocks/BlockPageFeature.vue new file mode 100644 index 0000000..b62b744 --- /dev/null +++ b/app/components/blocks/BlockPageFeature.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/components/blocks/BlockPageGrid.vue b/app/components/blocks/BlockPageGrid.vue new file mode 100644 index 0000000..35df0d3 --- /dev/null +++ b/app/components/blocks/BlockPageGrid.vue @@ -0,0 +1,8 @@ + + + diff --git a/app/components/blocks/BlockPageHero.vue b/app/components/blocks/BlockPageHero.vue new file mode 100644 index 0000000..0e07ffa --- /dev/null +++ b/app/components/blocks/BlockPageHero.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/components/blocks/BlockPageSection.vue b/app/components/blocks/BlockPageSection.vue new file mode 100644 index 0000000..07fbd08 --- /dev/null +++ b/app/components/blocks/BlockPageSection.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/components/blocks/BlockRichText.vue b/app/components/blocks/BlockRichText.vue new file mode 100644 index 0000000..7651081 --- /dev/null +++ b/app/components/blocks/BlockRichText.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/app/components/blocks/BlockSeparator.vue b/app/components/blocks/BlockSeparator.vue new file mode 100644 index 0000000..11f9fe0 --- /dev/null +++ b/app/components/blocks/BlockSeparator.vue @@ -0,0 +1,16 @@ + + + diff --git a/app/components/blocks/BlockSpacer.vue b/app/components/blocks/BlockSpacer.vue new file mode 100644 index 0000000..584a470 --- /dev/null +++ b/app/components/blocks/BlockSpacer.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/composables/useRegistry.ts b/app/composables/useRegistry.ts new file mode 100644 index 0000000..b5f4c2f --- /dev/null +++ b/app/composables/useRegistry.ts @@ -0,0 +1,11 @@ +import { registry, componentMap, getEntry, defaultPropsFor, entriesByCategory } from '../../registry' + +export function useRegistry() { + return { + registry, + componentMap, + getEntry, + defaultPropsFor, + entriesByCategory, + } +} diff --git a/registry/entries.ts b/registry/entries.ts new file mode 100644 index 0000000..22405ba --- /dev/null +++ b/registry/entries.ts @@ -0,0 +1,172 @@ +import type { Registry } from '../types/registry' + +export const entries: Registry = { + PageHero: { + type: 'PageHero', + label: 'Page Hero', + category: 'layout', + icon: 'i-lucide-layout-template', + compileAs: 'UPageHero', + acceptsChildren: false, + props: { + title: { type: 'string', default: 'Your Page Title' }, + description: { type: 'text', default: 'A compelling description for your hero section' }, + headline: { type: 'string', default: '' }, + orientation: { type: 'enum', options: ['vertical', 'horizontal'], default: 'vertical' }, + }, + }, + + PageSection: { + type: 'PageSection', + label: 'Page Section', + category: 'layout', + icon: 'i-lucide-panel-top', + compileAs: 'UPageSection', + acceptsChildren: true, + props: { + title: { type: 'string', default: 'Section Title' }, + description: { type: 'text', default: '' }, + headline: { type: 'string', default: '' }, + orientation: { type: 'enum', options: ['vertical', 'horizontal'], default: 'vertical' }, + reverse: { type: 'boolean', default: false }, + }, + }, + + PageColumns: { + type: 'PageColumns', + label: 'Page Columns', + category: 'layout', + icon: 'i-lucide-columns-3', + compileAs: 'UPageColumns', + acceptsChildren: true, + props: {}, + }, + + PageGrid: { + type: 'PageGrid', + label: 'Page Grid', + category: 'layout', + icon: 'i-lucide-grid-3x3', + compileAs: 'UPageGrid', + acceptsChildren: true, + props: {}, + }, + + PageCTA: { + type: 'PageCTA', + label: 'Page CTA', + category: 'content', + icon: 'i-lucide-megaphone', + compileAs: 'UPageCTA', + acceptsChildren: false, + props: { + title: { type: 'string', default: 'Ready to Get Started?' }, + description: { type: 'text', default: 'Start building your next project today.' }, + headline: { type: 'string', default: '' }, + }, + }, + + PageFeature: { + type: 'PageFeature', + label: 'Page Feature', + category: 'content', + icon: 'i-lucide-sparkles', + compileAs: 'UPageFeature', + acceptsChildren: false, + props: { + title: { type: 'string', default: 'Feature Title' }, + description: { type: 'text', default: 'Describe this feature and its benefits.' }, + icon: { type: 'string', default: 'i-lucide-star' }, + orientation: { type: 'enum', options: ['vertical', 'horizontal'], default: 'vertical' }, + }, + }, + + PageCard: { + type: 'PageCard', + label: 'Page Card', + category: 'content', + icon: 'i-lucide-credit-card', + compileAs: 'UPageCard', + acceptsChildren: false, + props: { + title: { type: 'string', default: 'Card Title' }, + description: { type: 'text', default: 'Card description goes here.' }, + to: { type: 'url', default: '' }, + }, + }, + + Card: { + type: 'Card', + label: 'Card', + category: 'content', + icon: 'i-lucide-square', + compileAs: 'UCard', + acceptsChildren: true, + props: {}, + }, + + Button: { + type: 'Button', + label: 'Button', + category: 'content', + icon: 'i-lucide-mouse-pointer-click', + compileAs: 'UButton', + acceptsChildren: false, + props: { + label: { type: 'string', default: 'Click Me' }, + to: { type: 'url', default: '#' }, + color: { type: 'enum', options: ['primary', 'secondary', 'neutral', 'success', 'warning', 'error'], default: 'primary' }, + variant: { type: 'enum', options: ['solid', 'outline', 'soft', 'subtle', 'ghost', 'link'], default: 'solid' }, + size: { type: 'enum', options: ['xs', 'sm', 'md', 'lg', 'xl'], default: 'md' }, + }, + }, + + Separator: { + type: 'Separator', + label: 'Separator', + category: 'layout', + icon: 'i-lucide-minus', + compileAs: 'USeparator', + acceptsChildren: false, + props: { + orientation: { type: 'enum', options: ['horizontal', 'vertical'], default: 'horizontal' }, + label: { type: 'string', default: '' }, + }, + }, + + RichText: { + type: 'RichText', + label: 'Rich Text', + category: 'content', + icon: 'i-lucide-text', + acceptsChildren: false, + props: { + body: { type: 'text', default: 'Enter your content here.' }, + align: { type: 'enum', options: ['left', 'center', 'right'], default: 'left' }, + }, + }, + + Image: { + type: 'Image', + label: 'Image', + category: 'media', + icon: 'i-lucide-image', + acceptsChildren: false, + props: { + src: { type: 'image', default: 'https://placehold.co/800x400' }, + alt: { type: 'string', default: '' }, + rounded: { type: 'boolean', default: false }, + }, + }, + + Spacer: { + type: 'Spacer', + label: 'Spacer', + category: 'layout', + icon: 'i-lucide-move-vertical', + acceptsChildren: false, + props: { + size: { type: 'enum', options: ['sm', 'md', 'lg'], default: 'md' }, + }, + }, +} diff --git a/registry/index.ts b/registry/index.ts new file mode 100644 index 0000000..175fc1c --- /dev/null +++ b/registry/index.ts @@ -0,0 +1,71 @@ +import type { Component } from 'vue' +import type { RegistryEntry } from '../types/registry' +import { entries } from './entries' +import { validateRegistry } from './validation' + +// Block component imports +import BlockPageHero from '~/components/blocks/BlockPageHero.vue' +import BlockPageSection from '~/components/blocks/BlockPageSection.vue' +import BlockPageColumns from '~/components/blocks/BlockPageColumns.vue' +import BlockPageGrid from '~/components/blocks/BlockPageGrid.vue' +import BlockPageCTA from '~/components/blocks/BlockPageCTA.vue' +import BlockPageFeature from '~/components/blocks/BlockPageFeature.vue' +import BlockPageCard from '~/components/blocks/BlockPageCard.vue' +import BlockCard from '~/components/blocks/BlockCard.vue' +import BlockButton from '~/components/blocks/BlockButton.vue' +import BlockSeparator from '~/components/blocks/BlockSeparator.vue' +import BlockRichText from '~/components/blocks/BlockRichText.vue' +import BlockImage from '~/components/blocks/BlockImage.vue' +import BlockSpacer from '~/components/blocks/BlockSpacer.vue' + +// Dev-time validation guard +if (import.meta.dev) { + validateRegistry(entries) +} + +// Re-export entries as registry +export const registry = entries + +// Component map linking type strings to Vue component references +export const componentMap: Record = { + PageHero: BlockPageHero, + PageSection: BlockPageSection, + PageColumns: BlockPageColumns, + PageGrid: BlockPageGrid, + PageCTA: BlockPageCTA, + PageFeature: BlockPageFeature, + PageCard: BlockPageCard, + Card: BlockCard, + Button: BlockButton, + Separator: BlockSeparator, + RichText: BlockRichText, + Image: BlockImage, + Spacer: BlockSpacer, +} + +// Helper: get a single registry entry by type +export function getEntry(type: string): RegistryEntry | undefined { + if (!Object.hasOwn(registry, type)) return undefined + return registry[type] +} + +// Helper: get default prop values for a given type +export function defaultPropsFor(type: string): Record | undefined { + if (!Object.hasOwn(registry, type)) return undefined + const entry = registry[type]! + const defaults: Record = {} + for (const [key, schema] of Object.entries(entry.props)) { + defaults[key] = schema.default ?? undefined + } + return defaults +} + +// Helper: group entries by category +export function entriesByCategory(): Record { + const grouped: Record = {} + for (const entry of Object.values(registry)) { + if (!grouped[entry.category]) grouped[entry.category] = [] + grouped[entry.category]!.push(entry) + } + return grouped +} diff --git a/registry/validation.ts b/registry/validation.ts new file mode 100644 index 0000000..8e19dbc --- /dev/null +++ b/registry/validation.ts @@ -0,0 +1,114 @@ +import { z } from 'zod' + +// ─── PropSchema Zod Schemas ───────────────────────────────────────────────── + +/** + * Zod schema for PropSchema validation — a discriminated union covering + * all 8 prop types: string, text, number, boolean, enum, color, url, image. + */ +export const propSchemaZod = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('string'), + label: z.string().optional(), + default: z.string().optional(), + }), + z.object({ + type: z.literal('text'), + label: z.string().optional(), + default: z.string().optional(), + }), + z.object({ + type: z.literal('number'), + label: z.string().optional(), + default: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + step: z.number().optional(), + }), + z.object({ + type: z.literal('boolean'), + label: z.string().optional(), + default: z.boolean().optional(), + }), + z.object({ + type: z.literal('enum'), + label: z.string().optional(), + default: z.string().optional(), + options: z.array(z.string()).min(1), + }), + z.object({ + type: z.literal('color'), + label: z.string().optional(), + default: z.string().optional(), + }), + z.object({ + type: z.literal('url'), + label: z.string().optional(), + default: z.string().optional(), + }), + z.object({ + type: z.literal('image'), + label: z.string().optional(), + default: z.string().optional(), + }), +]) + +// ─── RegistryEntry Zod Schema ──────────────────────────────────────────────── + +/** + * Zod schema for RegistryEntry validation with all field constraints + * and cross-field refinements. + */ +export const registryEntryZod = z.object({ + type: z.string().min(1).max(50).regex(/^[A-Z][a-zA-Z0-9]*$/), + label: z.string().min(1).max(50), + category: z.enum(['layout', 'content', 'media', 'form']), + icon: z.string().optional(), + props: z.record(z.string(), propSchemaZod), + slots: z.array(z.string()).max(20).optional(), + acceptsChildren: z.boolean(), + allowedChildren: z.array(z.string()).max(50).optional(), + allowedParents: z.array(z.string()).max(50).optional(), + compileAs: z.string().optional(), +}).superRefine((entry, ctx) => { + // Cross-field constraint: allowedChildren requires acceptsChildren to be true + if (!entry.acceptsChildren && entry.allowedChildren !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `[${entry.type}] allowedChildren requires acceptsChildren to be true`, + }) + } + + // Cross-field constraint: acceptsChildren:false conflicts with "default" slot + if (!entry.acceptsChildren && entry.slots?.includes('default')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `[${entry.type}] acceptsChildren:false conflicts with declaring a "default" slot`, + }) + } +}) + +// ─── validateRegistry ──────────────────────────────────────────────────────── + +/** + * Iterates all entries in a registry record, validates each against the + * Zod schema, and throws descriptive errors including the entry type/key. + * Also verifies that each entry's `type` field matches its registry key. + */ +export function validateRegistry(registry: Record): void { + for (const [key, entry] of Object.entries(registry)) { + const result = registryEntryZod.safeParse(entry) + if (!result.success) { + const issues = result.error.issues + .map(i => `${i.path.join('.') || ''}: ${i.message}`) + .join('; ') + throw new Error(`Registry validation failed for "${key}": ${issues}`) + } + // Verify type field matches its registry key + if ((entry as { type?: string }).type !== key) { + throw new Error( + `Registry key "${key}" does not match entry.type "${(entry as { type?: string }).type}"`, + ) + } + } +} diff --git a/tests/registry.property.test.ts b/tests/registry.property.test.ts new file mode 100644 index 0000000..3a7aeb5 --- /dev/null +++ b/tests/registry.property.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from 'vitest' +import fc from 'fast-check' +import { validateRegistry, registryEntryZod } from '../registry/validation' +import { entries } from '../registry/entries' +import { registry, componentMap, getEntry, defaultPropsFor, entriesByCategory } from '../registry' + +describe('Feature: 02-component-registry', () => { + /** + * Property 1: Validator rejects invalid entries with identifying information + * Validates: Requirements 1.5, 2.2, 2.3, 2.4, 2.5, 2.8, 2.9 + */ + it('Property 1: Validator rejects invalid entries with identifying information', () => { + // Generator for a valid base entry + const validType = fc.stringMatching(/^[A-Z][a-zA-Z0-9]{0,9}$/) + const validLabel = fc.string({ minLength: 1, maxLength: 50 }) + const validCategory = fc.constantFrom('layout' as const, 'content' as const, 'media' as const, 'form' as const) + + // Strategy: create entries that are almost valid but break one constraint at a time + const invalidEntry = fc.oneof( + // Missing label (empty string) + validType.map(type => ({ + key: type, + entry: { + type, + label: '', + category: 'layout', + props: {}, + acceptsChildren: false, + }, + })), + // Invalid prop type + validType.chain(type => + fc.string({ minLength: 1, maxLength: 10 }).map(propKey => ({ + key: type, + entry: { + type, + label: 'Valid Label', + category: 'content', + props: { [propKey]: { type: 'invalid_type' } }, + acceptsChildren: false, + }, + })), + ), + // Invalid category + validType.map(type => ({ + key: type, + entry: { + type, + label: 'Valid Label', + category: 'not_a_category', + props: {}, + acceptsChildren: false, + }, + })), + // allowedChildren with acceptsChildren:false + validType.map(type => ({ + key: type, + entry: { + type, + label: 'Valid Label', + category: 'layout', + props: {}, + acceptsChildren: false, + allowedChildren: ['SomeChild'], + }, + })), + // "default" slot with acceptsChildren:false + validType.map(type => ({ + key: type, + entry: { + type, + label: 'Valid Label', + category: 'media', + props: {}, + acceptsChildren: false, + slots: ['default'], + }, + })), + ) + + fc.assert( + fc.property(invalidEntry, ({ key, entry }) => { + // validateRegistry should throw with identifying info (the key or type) + expect(() => validateRegistry({ [key]: entry })).toThrow(key) + }), + { numRuns: 100 }, + ) + }) + + /** + * Property 2: Valid entries pass validation without errors + * Validates: Requirements 2.6 + */ + it('Property 2: Valid entries pass validation without errors', () => { + const validType = fc.stringMatching(/^[A-Z][a-zA-Z0-9]{0,9}$/) + const validLabel = fc.string({ minLength: 1, maxLength: 50 }) + const validCategory = fc.constantFrom('layout' as const, 'content' as const, 'media' as const, 'form' as const) + + const propSchema = fc.oneof( + fc.record({ type: fc.constant('string' as const), default: fc.option(fc.string(), { nil: undefined }) }), + fc.record({ type: fc.constant('text' as const), default: fc.option(fc.string(), { nil: undefined }) }), + fc.record({ type: fc.constant('number' as const), default: fc.option(fc.integer(), { nil: undefined }) }), + fc.record({ type: fc.constant('boolean' as const), default: fc.option(fc.boolean(), { nil: undefined }) }), + fc.record({ type: fc.constant('enum' as const), options: fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 5 }), default: fc.option(fc.string(), { nil: undefined }) }), + fc.record({ type: fc.constant('color' as const), default: fc.option(fc.string(), { nil: undefined }) }), + fc.record({ type: fc.constant('url' as const), default: fc.option(fc.string(), { nil: undefined }) }), + fc.record({ type: fc.constant('image' as const), default: fc.option(fc.string(), { nil: undefined }) }), + ) + + const validProps = fc.dictionary( + fc.string({ minLength: 1, maxLength: 20 }), + propSchema, + { minKeys: 0, maxKeys: 5 }, + ) + + const validEntry = fc.tuple(validType, validLabel, validCategory, validProps, fc.boolean()).map( + ([type, label, category, props, acceptsChildren]) => { + const entry: Record = { + type, + label, + category, + props, + acceptsChildren, + } + // Only add allowedChildren when acceptsChildren is true + // Never add "default" slot when acceptsChildren is false + return entry + }, + ) + + fc.assert( + fc.property(validEntry, (entry) => { + const result = registryEntryZod.safeParse(entry) + expect(result.success).toBe(true) + }), + { numRuns: 100 }, + ) + }) + + /** + * Property 3: Registry and componentMap bijection + * Validates: Requirements 13.5, 15.1, 15.2, 15.6, 15.7 + */ + it('Property 3: Registry and componentMap bijection', () => { + fc.assert( + fc.property(fc.constant(null), () => { + const registryKeys = Object.keys(registry).sort() + const componentMapKeys = Object.keys(componentMap).sort() + expect(registryKeys).toEqual(componentMapKeys) + }), + { numRuns: 100 }, + ) + }) + + /** + * Property 4: defaultPropsFor returns correct defaults for all entries + * Validates: Requirements 13.7, 15.3 + */ + it('Property 4: defaultPropsFor returns correct defaults for all entries', () => { + fc.assert( + fc.property(fc.constantFrom(...Object.keys(registry)), (type) => { + const defaults = defaultPropsFor(type) + const entry = registry[type] + expect(defaults).toBeDefined() + // Keys must match exactly + expect(Object.keys(defaults!).sort()).toEqual(Object.keys(entry.props).sort()) + // Values must equal the default field of each PropSchema + for (const [key, schema] of Object.entries(entry.props)) { + expect(defaults![key]).toEqual(schema.default ?? undefined) + } + }), + { numRuns: 100 }, + ) + }) + + /** + * Property 5: entriesByCategory groups all entries correctly + * Validates: Requirements 13.9 + */ + it('Property 5: entriesByCategory groups all entries correctly', () => { + fc.assert( + fc.property(fc.constant(null), () => { + const grouped = entriesByCategory() + const allEntries = Object.values(registry) + + // Every entry appears in its category group + for (const entry of allEntries) { + expect(grouped[entry.category]).toBeDefined() + expect(grouped[entry.category]).toContainEqual(entry) + } + + // No entry appears in a wrong category + for (const [category, categoryEntries] of Object.entries(grouped)) { + for (const entry of categoryEntries) { + expect(entry.category).toBe(category) + } + } + + // Union of all groups covers all entries + const totalGrouped = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0) + expect(totalGrouped).toBe(allEntries.length) + }), + { numRuns: 100 }, + ) + }) + + /** + * Property 6: Non-existent type strings return undefined + * Validates: Requirements 13.8, 14.7 + */ + it('Property 6: Non-existent type strings return undefined', () => { + const existingKeys = new Set(Object.keys(registry)) + + fc.assert( + fc.property( + fc.string({ minLength: 1, maxLength: 50 }).filter(s => !existingKeys.has(s)), + (randomType) => { + expect(getEntry(randomType)).toBeUndefined() + expect(defaultPropsFor(randomType)).toBeUndefined() + }, + ), + { numRuns: 100 }, + ) + }) + + /** + * Property 7: Entries module produces serializable data + * Validates: Requirements 13.1 + */ + it('Property 7: Entries module produces serializable data', () => { + fc.assert( + fc.property(fc.constantFrom(...Object.keys(entries)), (key) => { + const entry = entries[key] + const serialized = JSON.parse(JSON.stringify(entry)) + expect(serialized).toEqual(entry) + }), + { numRuns: 100 }, + ) + }) + + /** + * Property 8: Nuxt UI blocks have valid compileAs and icon + * Validates: Requirements 3.2, 3.6, 15.8 + */ + it('Property 8: Nuxt UI blocks have valid compileAs and icon', () => { + const entriesWithCompileAs = Object.values(entries).filter(e => e.compileAs !== undefined) + + fc.assert( + fc.property(fc.constantFrom(...entriesWithCompileAs), (entry) => { + // compileAs starts with 'U' + expect(entry.compileAs).toBeDefined() + expect(entry.compileAs!.startsWith('U')).toBe(true) + expect(entry.compileAs!.length).toBeGreaterThan(0) + // icon must be non-empty + expect(entry.icon).toBeDefined() + expect(entry.icon!.length).toBeGreaterThan(0) + }), + { numRuns: 100 }, + ) + }) +}) diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 0000000..7176b21 --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { validateRegistry } from '../registry/validation' +import { entries } from '../registry/entries' +import { registry, componentMap, defaultPropsFor } from '../registry' + +describe('Feature: 02-component-registry - Unit Tests', () => { + describe('Registry consistency and validation', () => { + it('componentMap and registry key sets are identical', () => { + const registryKeys = Object.keys(registry).sort() + const componentKeys = Object.keys(componentMap).sort() + + // Report each mismatch + const missingInComponentMap = registryKeys.filter(k => !componentKeys.includes(k)) + const missingInRegistry = componentKeys.filter(k => !registryKeys.includes(k)) + + expect(missingInComponentMap, `Keys in registry but missing from componentMap: ${missingInComponentMap.join(', ')}`).toEqual([]) + expect(missingInRegistry, `Keys in componentMap but missing from registry: ${missingInRegistry.join(', ')}`).toEqual([]) + expect(registryKeys).toEqual(componentKeys) + }) + + it('defaultPropsFor("PageHero") returns correct defaults', () => { + const defaults = defaultPropsFor('PageHero') + expect(defaults).toEqual({ + title: 'Your Page Title', + description: 'A compelling description for your hero section', + headline: '', + orientation: 'vertical', + }) + }) + + it('Zod validator throws for entry with missing label', () => { + const invalidRegistry = { + TestEntry: { + type: 'TestEntry', + label: '', + category: 'layout', + props: {}, + acceptsChildren: false, + }, + } + + expect(() => validateRegistry(invalidRegistry)).toThrow(/TestEntry/) + }) + + it('entries.ts source has zero .vue import statements', () => { + const source = readFileSync(resolve(__dirname, '../registry/entries.ts'), 'utf-8') + const vueImports = source.match(/import .+\.vue['"]/g) + expect(vueImports).toBeNull() + }) + + it('all Nuxt UI block entries have non-empty compileAs starting with U', () => { + for (const [key, entry] of Object.entries(entries)) { + if (entry.compileAs !== undefined) { + expect(entry.compileAs.length, `${key} has empty compileAs`).toBeGreaterThan(0) + expect(entry.compileAs[0], `${key} compileAs does not start with U`).toBe('U') + } + } + }) + }) + + describe('Entries module purity', () => { + it('entries.ts loads without errors', () => { + // The import at the top of this file already proves entries.ts loads. + // We verify the imported object is a non-empty record. + expect(entries).toBeDefined() + expect(typeof entries).toBe('object') + expect(Object.keys(entries).length).toBeGreaterThan(0) + }) + + it('all entries are serializable (no function references)', () => { + for (const [key, entry] of Object.entries(entries)) { + const serialized = JSON.parse(JSON.stringify(entry)) + expect(serialized, `Entry "${key}" is not serializable`).toEqual(entry) + } + }) + }) +}) diff --git a/types/registry.ts b/types/registry.ts new file mode 100644 index 0000000..73d91a3 --- /dev/null +++ b/types/registry.ts @@ -0,0 +1,44 @@ +import type { PropSchema } from './shared' + +// ─── Category ──────────────────────────────────────────────────────────────── + +/** + * The allowed categories for placeable building blocks. + */ +export type Category = 'layout' | 'content' | 'media' | 'form' + +// ─── RegistryEntry ─────────────────────────────────────────────────────────── + +/** + * Describes a single placeable building block's metadata, editable props, + * slots, child acceptance rules, and compile hints. + */ +export interface RegistryEntry { + /** PascalCase identifier matching /^[A-Z][a-zA-Z0-9]*$/, max 50 chars. Must equal its registry key. */ + type: string + /** Human-readable display name, non-empty, max 50 chars. */ + label: string + /** Block category for palette grouping. */ + category: Category + /** Optional Lucide icon class for palette display. */ + icon?: string + /** Editable properties exposed to the property panel. */ + props: Record + /** Named slots the component exposes, max 20 elements. */ + slots?: string[] + /** Whether this block can contain child blocks. */ + acceptsChildren: boolean + /** Allowed child type strings, max 50 elements. Only valid when acceptsChildren is true. */ + allowedChildren?: string[] + /** Allowed parent type strings, max 50 elements. */ + allowedParents?: string[] + /** Nuxt UI component tag name for compilation (e.g., 'UPageHero'). */ + compileAs?: string +} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +/** + * A record mapping component type strings to their RegistryEntry definitions. + */ +export type Registry = Record From 01f22d109bb80a5e0637bd7cd3b51505e2a069e8 Mon Sep 17 00:00:00 2001 From: Kevin Strom Date: Wed, 22 Jul 2026 17:57:46 -0700 Subject: [PATCH 7/7] fix: resolve lint errors (unused vars, self-closing img, v-html suppress) --- app/components/blocks/BlockImage.vue | 2 +- app/components/blocks/BlockRichText.vue | 1 + tests/registry.property.test.ts | 2 -- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/components/blocks/BlockImage.vue b/app/components/blocks/BlockImage.vue index 869cd38..d8bfa19 100644 --- a/app/components/blocks/BlockImage.vue +++ b/app/components/blocks/BlockImage.vue @@ -3,7 +3,7 @@ :src="src" :alt="alt" :class="['max-w-full', { 'rounded-lg': rounded }]" - /> + >