diff --git a/apps/builder/build/generate-dependencies.ts b/apps/builder/build/generate-dependencies.ts new file mode 100644 index 000000000..94f7690b7 --- /dev/null +++ b/apps/builder/build/generate-dependencies.ts @@ -0,0 +1,70 @@ +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '../../..') +const V0_SRC = resolve(ROOT, 'packages/0/src') + +interface DependencyGraph { + composables: Record + components: Record +} + +function extractV0Imports (filePath: string): string[] { + let content: string + try { + content = readFileSync(filePath, 'utf8') + } catch { + return [] + } + + const imports: string[] = [] + const pattern = /from\s+['"]#v0\/(composables|components)\/(\w+)['"]/g + let match: RegExpExecArray | null + + while ((match = pattern.exec(content)) !== null) { + imports.push(match[2]) + } + + return [...new Set(imports)] +} + +function scanDirectory (dir: string): Record { + const entries = readdirSync(dir) + const graph: Record = {} + + for (const entry of entries) { + const entryPath = resolve(dir, entry) + if (!statSync(entryPath).isDirectory()) continue + + const indexPath = resolve(entryPath, 'index.ts') + const deps = extractV0Imports(indexPath) + + // Also scan .vue files and non-index .ts files + try { + const files = readdirSync(entryPath) + for (const file of files) { + if (file.endsWith('.vue') || (file.endsWith('.ts') && file !== 'index.ts')) { + deps.push(...extractV0Imports(resolve(entryPath, file))) + } + } + } catch { /* empty */ } + + graph[entry] = [...new Set(deps)].filter(d => d !== entry).toSorted() + } + + return graph +} + +const graph: DependencyGraph = { + composables: scanDirectory(resolve(V0_SRC, 'composables')), + components: scanDirectory(resolve(V0_SRC, 'components')), +} + +const outPath = resolve(__dirname, '../src/data/dependencies.json') +writeFileSync(outPath, JSON.stringify(graph, null, 2) + '\n') + +console.log( + `Generated dependency graph: ${Object.keys(graph.composables).length} composables, ${Object.keys(graph.components).length} components`, +) diff --git a/apps/builder/index.html b/apps/builder/index.html new file mode 100644 index 000000000..0020d62f7 --- /dev/null +++ b/apps/builder/index.html @@ -0,0 +1,13 @@ + + + + + + + v0 Framework Builder + + +
+ + + diff --git a/apps/builder/package.json b/apps/builder/package.json new file mode 100644 index 000000000..98b88b14b --- /dev/null +++ b/apps/builder/package.json @@ -0,0 +1,29 @@ +{ + "name": "@vuetify-private/builder", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "generate": "tsx build/generate-dependencies.ts", + "dev": "pnpm generate && vite", + "build": "pnpm generate && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mdi/js": "catalog:", + "@vuetify/auth": "catalog:", + "@vuetify/v0": "workspace:*", + "fflate": "catalog:", + "pinia": "catalog:", + "vue": "catalog:" + }, + "devDependencies": { + "tsx": "catalog:", + "unocss": "catalog:", + "unplugin-vue": "catalog:", + "unplugin-vue-components": "catalog:", + "vite": "catalog:", + "vite-plugin-vue-layouts-next": "catalog:", + "vue-router": "catalog:" + } +} diff --git a/apps/builder/src/App.vue b/apps/builder/src/App.vue new file mode 100644 index 000000000..c0482986b --- /dev/null +++ b/apps/builder/src/App.vue @@ -0,0 +1,6 @@ + + + diff --git a/apps/builder/src/components.d.ts b/apps/builder/src/components.d.ts new file mode 100644 index 000000000..099064a85 --- /dev/null +++ b/apps/builder/src/components.d.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AppBar: typeof import('./components/app/AppBar.vue')['default'] + AppFooter: typeof import('./components/app/AppFooter.vue')['default'] + FeatureCard: typeof import('./components/FeatureCard.vue')['default'] + IntentCard: typeof import('./components/IntentCard.vue')['default'] + ModeCard: typeof import('./components/ModeCard.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } +} diff --git a/apps/builder/src/components/FeatureCard.vue b/apps/builder/src/components/FeatureCard.vue new file mode 100644 index 000000000..165228a76 --- /dev/null +++ b/apps/builder/src/components/FeatureCard.vue @@ -0,0 +1,106 @@ + + + diff --git a/apps/builder/src/components/IntentCard.vue b/apps/builder/src/components/IntentCard.vue new file mode 100644 index 000000000..2f9cec751 --- /dev/null +++ b/apps/builder/src/components/IntentCard.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/builder/src/components/ModeCard.vue b/apps/builder/src/components/ModeCard.vue new file mode 100644 index 000000000..a223723ef --- /dev/null +++ b/apps/builder/src/components/ModeCard.vue @@ -0,0 +1,47 @@ + + + diff --git a/apps/builder/src/components/app/AppBar.vue b/apps/builder/src/components/app/AppBar.vue new file mode 100644 index 000000000..0f87bfec6 --- /dev/null +++ b/apps/builder/src/components/app/AppBar.vue @@ -0,0 +1,124 @@ + + + diff --git a/apps/builder/src/components/app/AppFooter.vue b/apps/builder/src/components/app/AppFooter.vue new file mode 100644 index 000000000..d7e764a40 --- /dev/null +++ b/apps/builder/src/components/app/AppFooter.vue @@ -0,0 +1,8 @@ + + + diff --git a/apps/builder/src/data/dependencies.json b/apps/builder/src/data/dependencies.json new file mode 100644 index 000000000..c6e9e76c6 --- /dev/null +++ b/apps/builder/src/data/dependencies.json @@ -0,0 +1,446 @@ +{ + "composables": { + "createBreadcrumbs": [ + "createContext", + "createSingle", + "createTrinity" + ], + "createCombobox": [ + "createContext", + "createSelection", + "createTrinity", + "toArray", + "usePopover", + "useVirtualFocus" + ], + "createContext": [], + "createDataTable": [ + "createContext", + "createFilter", + "createGroup", + "createPagination", + "createTrinity", + "useLocale" + ], + "createFilter": [ + "createContext", + "createTrinity", + "toArray" + ], + "createFocusTraversal": [], + "createForm": [ + "createContext", + "createRegistry", + "createTrinity", + "createValidation", + "toArray" + ], + "createGroup": [ + "createContext", + "createSelection", + "createTrinity", + "toArray", + "useProxyRegistry" + ], + "createModel": [ + "createRegistry" + ], + "createNested": [ + "createContext", + "createGroup", + "createTrinity", + "toArray", + "useLogger" + ], + "createObserver": [ + "toElement", + "useHydration" + ], + "createOverflow": [ + "createContext", + "createTrinity", + "useResizeObserver" + ], + "createPagination": [ + "createContext", + "createTrinity" + ], + "createPlugin": [ + "createContext", + "createTrinity", + "useStorage" + ], + "createQueue": [ + "createContext", + "createRegistry", + "createTrinity", + "useTimer" + ], + "createRating": [ + "createContext", + "createTrinity" + ], + "createRegistry": [ + "createContext", + "createTrinity", + "useLogger" + ], + "createSelection": [ + "createContext", + "createModel", + "createTrinity" + ], + "createSingle": [ + "createContext", + "createSelection", + "createTrinity" + ], + "createSlider": [ + "createModel" + ], + "createStep": [ + "createContext", + "createSingle", + "createTrinity" + ], + "createTimeline": [ + "createContext", + "createRegistry", + "createTrinity" + ], + "createTokens": [ + "createContext", + "createRegistry", + "createTrinity", + "useLogger" + ], + "createTrinity": [], + "createValidation": [ + "createForm", + "createGroup", + "useRules" + ], + "createVirtual": [ + "createContext", + "createTrinity", + "useResizeObserver" + ], + "toArray": [], + "toElement": [], + "toReactive": [], + "useBreakpoints": [ + "createPlugin", + "useEventListener", + "useHydration" + ], + "useClickOutside": [ + "toArray", + "useEventListener" + ], + "useDate": [ + "createContext", + "createPlugin", + "createTrinity", + "useLocale" + ], + "useEventListener": [], + "useFeatures": [ + "createGroup", + "createPlugin", + "createRegistry", + "createTokens" + ], + "useHotkey": [ + "useEventListener", + "useLogger" + ], + "useHydration": [ + "createPlugin" + ], + "useIntersectionObserver": [ + "createObserver", + "toElement" + ], + "useLazy": [ + "useTimer" + ], + "useLocale": [ + "createPlugin", + "createSingle", + "createTokens" + ], + "useLogger": [ + "createPlugin" + ], + "useMediaQuery": [ + "useHydration" + ], + "useMutationObserver": [ + "createObserver", + "toElement" + ], + "useNotifications": [ + "createPlugin", + "createQueue", + "createRegistry" + ], + "usePermissions": [ + "createPlugin", + "createTokens", + "toArray" + ], + "usePopover": [ + "useEventListener", + "useTimer" + ], + "usePresence": [], + "useProxyModel": [ + "createSelection", + "toArray" + ], + "useProxyRegistry": [ + "createRegistry" + ], + "useRaf": [], + "useResizeObserver": [ + "createObserver", + "toElement" + ], + "useRovingFocus": [ + "createFocusTraversal", + "useEventListener" + ], + "useRtl": [ + "createPlugin" + ], + "useRules": [ + "createForm", + "createPlugin", + "useLocale" + ], + "useStack": [ + "createContext", + "createPlugin", + "createSelection", + "createTrinity" + ], + "useStorage": [ + "createPlugin", + "useEventListener" + ], + "useTheme": [ + "createPlugin", + "createRegistry", + "createSingle", + "createTokens" + ], + "useTimer": [], + "useToggleScope": [], + "useVirtualFocus": [ + "createFocusTraversal", + "useEventListener" + ] + }, + "components": { + "AlertDialog": [ + "Atom", + "createContext", + "useClickOutside", + "useStack", + "useToggleScope" + ], + "Atom": [], + "Avatar": [ + "Atom", + "createContext", + "createSelection" + ], + "Breadcrumbs": [ + "Atom", + "createBreadcrumbs", + "createContext", + "createGroup", + "createOverflow", + "useLocale" + ], + "Button": [ + "Atom", + "createContext", + "createSelection", + "createSingle", + "useLocale", + "useProxyModel", + "useTimer" + ], + "Checkbox": [ + "Atom", + "createContext", + "createGroup", + "useProxyModel" + ], + "Collapsible": [ + "Atom", + "createContext", + "createSingle", + "useProxyModel" + ], + "Combobox": [ + "Atom", + "createCombobox", + "createContext", + "useClickOutside", + "useLazy", + "useProxyModel" + ], + "Dialog": [ + "Atom", + "createContext", + "useClickOutside", + "useStack", + "useToggleScope" + ], + "ExpansionPanel": [ + "Atom", + "createContext", + "createSelection", + "useProxyModel" + ], + "Form": [ + "Atom", + "createContext", + "createForm", + "createValidation" + ], + "Group": [ + "createContext", + "createGroup", + "useProxyModel" + ], + "Input": [ + "Atom", + "createContext", + "createForm", + "createValidation", + "toArray", + "useRules" + ], + "Locale": [ + "createContext", + "useLocale" + ], + "Pagination": [ + "Atom", + "createContext", + "createOverflow", + "createPagination", + "createRegistry", + "useLocale" + ], + "Popover": [ + "Atom", + "createContext", + "usePopover" + ], + "Portal": [ + "useStack" + ], + "Presence": [ + "usePresence" + ], + "Radio": [ + "Atom", + "createContext", + "createSingle", + "useProxyModel" + ], + "Rating": [ + "Atom", + "createContext", + "createRating" + ], + "Scrim": [ + "Atom", + "useStack" + ], + "Select": [ + "Atom", + "createContext", + "createSelection", + "useLazy", + "usePopover", + "useProxyModel", + "useVirtualFocus" + ], + "Selection": [ + "createContext", + "createSelection", + "useProxyModel" + ], + "Single": [ + "createContext", + "createSingle", + "useProxyModel" + ], + "Slider": [ + "Atom", + "createContext", + "createSlider", + "useEventListener", + "useProxyModel", + "useToggleScope" + ], + "Snackbar": [ + "Atom", + "Portal", + "createContext", + "useNotifications", + "useStack" + ], + "Splitter": [ + "Atom", + "createContext", + "createRegistry", + "createSelection", + "useEventListener", + "useRaf", + "useResizeObserver", + "useToggleScope" + ], + "Step": [ + "createContext", + "createStep", + "useProxyModel" + ], + "Switch": [ + "Atom", + "createContext", + "createGroup", + "useProxyModel" + ], + "Tabs": [ + "Atom", + "createContext", + "createStep", + "useProxyModel" + ], + "Theme": [ + "Atom", + "createContext", + "useTheme" + ], + "Toggle": [ + "Atom", + "createContext", + "createGroup", + "createSingle", + "useProxyModel" + ], + "Treeview": [ + "Atom", + "createContext", + "createNested", + "useProxyModel", + "useRovingFocus" + ] + } +} diff --git a/apps/builder/src/data/features.ts b/apps/builder/src/data/features.ts new file mode 100644 index 000000000..c6e019b3e --- /dev/null +++ b/apps/builder/src/data/features.ts @@ -0,0 +1,746 @@ +// Icons +import { + mdiArchive, + mdiCheckboxMarked, + mdiCog, + mdiCube, + mdiFilterVariant, + mdiNetwork, + mdiPuzzle, + mdiStar, + mdiTable, + mdiTextBox, +} from '@mdi/js' + +// Types +import type { DependencyGraph, Feature, FeatureMeta } from './types' + +import dependencyGraph from './dependencies.json' + +import maturity from '../../../../packages/0/src/maturity.json' + +export const CATEGORY_ICONS: Record = { + foundation: mdiCube, + registration: mdiArchive, + selection: mdiCheckboxMarked, + forms: mdiTextBox, + data: mdiTable, + plugins: mdiPuzzle, + system: mdiNetwork, + reactivity: mdiCog, + transformers: mdiCog, + semantic: mdiStar, + utilities: mdiFilterVariant, +} + +const META: Record = { + // Foundation + createContext: { + name: 'Context', + summary: 'Dependency injection with Vue provide/inject', + description: 'Creates type-safe provide/inject pairs for component communication. Supports optional injection, default values, and nested context overrides for building plugin architectures.', + example: `const [provide, inject] = createContext('tabs') +// Parent: provide({ selected }) +// Child: const { selected } = inject()`, + useCases: ['Component communication', 'Shared state', 'Plugin architecture'], + tags: ['di', 'provide', 'inject'], + icon: mdiCube, + }, + createTrinity: { + name: 'Trinity', + summary: 'Structured tuple factory for composable APIs', + description: 'Enforces a consistent [setup, provide, inject] tuple pattern for composable APIs. Ensures every composable exposes the same three-part interface for predictable consumption.', + example: `const [setup, provide, inject] = createTrinity('tabs') +// setup() returns reactive state +// provide() shares it with children`, + useCases: ['Composable design', 'API consistency'], + tags: ['pattern', 'api'], + icon: mdiCube, + }, + createPlugin: { + name: 'Plugin', + summary: 'Vue plugin wrapper with context handling', + description: 'Wraps composable setup into a standard Vue plugin with app-level configuration. Handles context injection and provides a clean app.use() interface.', + example: `const ThemePlugin = createPlugin('theme', options => { + return createTheme(options) +}) +app.use(ThemePlugin, { dark: true })`, + useCases: ['App-level features', 'Global configuration'], + tags: ['plugin', 'app'], + icon: mdiCube, + }, + createRegistry: { + name: 'Registry', + summary: 'Track and manage child component instances', + description: 'Maintains an ordered registry of child components with ticket-based registration. Supports dynamic add/remove, reordering, and iteration over registered items.', + example: `const registry = createRegistry() +// Child calls: registry.register(id, payload) +// Parent iterates: registry.values()`, + useCases: ['Tab panels', 'Accordion items', 'Carousel slides'], + tags: ['registration', 'children', 'instances'], + icon: mdiArchive, + }, + createSelection: { + name: 'Selection', + summary: 'Single and multi-select state management', + description: 'Manages selected items with support for single-select, multi-select, and mandatory selection. Provides reactive state for each item including isSelected, toggle, and select methods.', + example: `const selection = createSelection() +selection.onboard([ + { id: 'home', value: 'Home' }, + { id: 'about', value: 'About' }, +]) +const items = selection.values()`, + useCases: ['Dropdown menus', 'Tab selection', 'List filtering'], + tags: ['select', 'single', 'multi', 'state'], + icon: mdiCheckboxMarked, + }, + createSingle: { + name: 'Single Select', + summary: 'Exactly-one selection with mandatory support', + description: 'Enforces single-item selection with optional mandatory mode that prevents deselection. Ideal for navigation and tab-style interfaces where exactly one item must be active.', + example: `const single = createSingle({ mandatory: true }) +single.select('tab-1') +// single.selected.value === 'tab-1'`, + useCases: ['Tabs', 'Radio groups', 'Navigation'], + tags: ['select', 'single', 'mandatory'], + icon: mdiCheckboxMarked, + }, + createGroup: { + name: 'Group Select', + summary: 'Multi-select with grouped items', + description: 'Manages multi-select state where multiple items can be active simultaneously. Supports select-all, toggle, and range selection patterns.', + example: `const group = createGroup() +group.select('a') +group.select('b') +// group.selected.value === ['a', 'b']`, + useCases: ['Checkbox groups', 'Multi-tag selection', 'Filter panels'], + tags: ['select', 'multi', 'group'], + icon: mdiCheckboxMarked, + }, + createStep: { + name: 'Stepper', + summary: 'Sequential step navigation', + description: 'Tracks progress through an ordered sequence of steps with next/previous navigation, validation gates, and completion state for each step.', + example: `const stepper = createStep({ steps: 4 }) +stepper.next() +// stepper.current.value === 1`, + useCases: ['Wizards', 'Onboarding flows', 'Multi-step forms'], + tags: ['step', 'wizard', 'sequence'], + icon: mdiCheckboxMarked, + }, + createModel: { + name: 'Model', + summary: 'Reactive value store for selection state', + description: 'Lightweight reactive value container used internally by selection composables. Provides a consistent interface for reading and writing selection state.', + example: `const model = createModel() +model.value = 'active' +// Reactive — triggers watchers`, + useCases: ['Form values', 'Controlled inputs'], + tags: ['model', 'value', 'state'], + icon: mdiCheckboxMarked, + }, + createForm: { + name: 'Form', + summary: 'Form state management with validation', + description: 'Complete form lifecycle management with field registration, validation rules, dirty/touched tracking, and submit handling. Supports async validation and field-level error messages.', + example: `const form = createForm() +form.register('email', { + rules: [v => !!v || 'Required'], +}) +const { valid } = form.validate()`, + useCases: ['Login forms', 'Settings pages', 'Data entry'], + tags: ['form', 'validation', 'submit'], + icon: mdiTextBox, + }, + createCombobox: { + name: 'Combobox', + summary: 'Autocomplete with keyboard navigation', + description: 'Combines text input with a filterable dropdown list. Handles keyboard navigation, highlighting, selection, and custom filtering for typeahead experiences.', + example: `const combobox = createCombobox({ + items: ['Apple', 'Banana', 'Cherry'], +}) +// Provides filtered items, highlight index`, + useCases: ['Search inputs', 'Tag entry', 'Command palettes'], + tags: ['combobox', 'autocomplete', 'search'], + icon: mdiTextBox, + }, + createSlider: { + name: 'Slider', + summary: 'Range input with thumb control', + description: 'Headless slider with thumb positioning, step snapping, min/max bounds, and keyboard accessibility. Supports both single-value and range (two-thumb) modes.', + example: `const slider = createSlider({ + min: 0, max: 100, step: 5, +}) +// slider.value.value === 50`, + useCases: ['Volume controls', 'Price filters', 'Settings'], + tags: ['slider', 'range', 'input'], + icon: mdiTextBox, + }, + createRating: { + name: 'Rating', + summary: 'Star rating input', + description: 'Interactive rating input with configurable scale, half-star support, and hover preview. Provides accessible keyboard navigation and read-only display mode.', + example: `const rating = createRating({ length: 5 }) +// rating.value.value === 3 +// rating.hover.value === 4`, + useCases: ['Reviews', 'Feedback', 'Scoring'], + tags: ['rating', 'stars', 'input'], + icon: mdiTextBox, + }, + createDataTable: { + name: 'Data Table', + summary: 'Sortable, filterable table with pagination', + description: 'Full-featured data table composable with column sorting, multi-column filtering, pagination, and row selection. Supports server-side data loading with reactive query parameters.', + example: `const table = createDataTable({ + items: users, + columns: [ + { key: 'name', sortable: true }, + { key: 'email', filterable: true }, + ], +})`, + useCases: ['Admin dashboards', 'Reports', 'Data management'], + tags: ['table', 'sort', 'filter', 'paginate'], + icon: mdiTable, + }, + createFilter: { + name: 'Filter', + summary: 'Client-side data filtering', + description: 'Reactive filtering engine that applies search queries against item collections. Supports custom filter functions, multiple search keys, and debounced input.', + example: `const filter = createFilter({ + items: products, + keys: ['name', 'category'], +}) +filter.query.value = 'shoes'`, + useCases: ['Search results', 'List filtering', 'Table columns'], + tags: ['filter', 'search', 'data'], + icon: mdiTable, + }, + createPagination: { + name: 'Pagination', + summary: 'Page-based data navigation', + description: 'Manages page state with configurable page size, total item count, and computed page ranges. Provides next/previous/goTo navigation and visible page window.', + example: `const pagination = createPagination({ + total: 100, + size: 10, +}) +// pagination.page.value === 1 +// pagination.pages.value === 10`, + useCases: ['Table pages', 'Gallery pages', 'Search results'], + tags: ['pagination', 'pages', 'navigation'], + icon: mdiTable, + }, + createVirtual: { + name: 'Virtual Scroll', + summary: 'Render only visible items in large lists', + description: 'Renders only the items visible in the viewport for performant handling of large datasets. Supports variable-height items, scroll-to-index, and dynamic content loading.', + example: `const virtual = createVirtual({ + items: largeList, + size: 48, +}) +// virtual.visible.value — rendered slice`, + useCases: ['Long lists', 'Chat logs', 'Data grids'], + tags: ['virtual', 'scroll', 'performance'], + icon: mdiTable, + }, + useTheme: { + name: 'Theme', + summary: 'Light/dark mode with custom color tokens', + description: 'Reactive theme system with light/dark mode toggling, custom color tokens, and CSS variable output. Supports nested theme scopes and system preference detection.', + example: `const theme = useTheme() +theme.global.name.value = 'dark' +// Applies CSS variables to :root`, + useCases: ['Dark mode toggle', 'Brand theming', 'User preferences'], + tags: ['theme', 'dark', 'light', 'colors'], + icon: mdiPuzzle, + }, + useLocale: { + name: 'Locale', + summary: 'Internationalization with adapter support', + description: 'Adapter-based i18n system supporting vue-i18n, custom backends, or a built-in simple adapter. Provides reactive locale switching, RTL detection, and message formatting.', + example: `const locale = useLocale() +locale.current.value = 'fr' +const msg = locale.t('greeting')`, + useCases: ['Multi-language apps', 'RTL support', 'Date formatting'], + tags: ['i18n', 'locale', 'translation'], + icon: mdiPuzzle, + }, + useStorage: { + name: 'Storage', + summary: 'Persistent state with localStorage/sessionStorage', + description: 'Reactive wrapper around Web Storage APIs with automatic serialization, SSR safety, and cross-tab synchronization. Values persist across page reloads.', + example: `const sidebar = useStorage('sidebar', true) +sidebar.value = false +// Persisted to localStorage`, + useCases: ['User preferences', 'Draft saving', 'Cache'], + tags: ['storage', 'persist', 'local'], + icon: mdiNetwork, + }, + useFeatures: { + name: 'Feature Flags', + summary: 'Boolean feature flags with adapter support', + description: 'Runtime feature flag system with adapter support for LaunchDarkly, Flagsmith, or local config. Provides reactive flag values and conditional rendering helpers.', + example: `const features = useFeatures() +if (features.isEnabled('beta-ui')) { + // Show new interface +}`, + useCases: ['A/B testing', 'Progressive rollout', 'Beta features'], + tags: ['features', 'flags', 'toggle'], + icon: mdiPuzzle, + }, + useLogger: { + name: 'Logger', + summary: 'Structured logging with adapter support', + description: 'Structured logging with configurable levels and adapter support for Sentry, DataDog, or console output. Provides scoped loggers with automatic context enrichment.', + example: `const log = useLogger('MyComponent') +log.info('Mounted', { userId: 42 }) +log.warn('Deprecation notice')`, + useCases: ['Debug output', 'Error tracking', 'Analytics'], + tags: ['logging', 'debug', 'console'], + icon: mdiPuzzle, + }, + usePermissions: { + name: 'Permissions', + summary: 'Role-based access control', + description: 'Declarative permission system with role definitions, permission checks, and reactive guards. Integrates with routing for protected pages and conditional UI rendering.', + example: `const perms = usePermissions() +if (perms.can('edit', 'posts')) { + // Show edit button +}`, + useCases: ['Admin panels', 'Feature gating', 'User roles'], + tags: ['permissions', 'rbac', 'access'], + icon: mdiPuzzle, + }, + useBreakpoints: { + name: 'Breakpoints', + summary: 'Reactive viewport breakpoints', + description: 'Tracks viewport dimensions against named breakpoints using matchMedia. Provides reactive booleans for each breakpoint and a current breakpoint name for responsive logic.', + example: `const bp = useBreakpoints() +if (bp.mobile.value) { + // Render mobile layout +}`, + useCases: ['Responsive layouts', 'Mobile detection', 'Adaptive UI'], + tags: ['responsive', 'viewport', 'mobile'], + icon: mdiNetwork, + }, + useDate: { + name: 'Date', + summary: 'Date manipulation with adapter support', + description: 'Adapter-based date utilities supporting date-fns, dayjs, luxon, or a built-in adapter. Provides formatting, parsing, comparison, and locale-aware operations.', + example: `const date = useDate() +const formatted = date.format(new Date(), 'fullDate') +const next = date.addDays(today, 7)`, + useCases: ['Date pickers', 'Calendars', 'Time formatting'], + tags: ['date', 'time', 'calendar'], + icon: mdiPuzzle, + }, + useEventListener: { + name: 'Event Listener', + summary: 'Auto-cleanup event listener binding', + description: 'Attaches DOM event listeners that are automatically cleaned up on component unmount. Supports element refs, window, and document targets with full TypeScript event type inference.', + example: `useEventListener(window, 'resize', () => { + // Automatically removed on unmount +})`, + useCases: ['Keyboard shortcuts', 'Scroll handlers', 'Window events'], + tags: ['events', 'listener', 'cleanup'], + icon: mdiNetwork, + }, + useHotkey: { + name: 'Hotkey', + summary: 'Keyboard shortcut registration', + description: 'Registers keyboard shortcuts with modifier key support (ctrl, shift, alt, meta). Handles key combinations, prevents defaults, and cleans up on unmount.', + example: `useHotkey('ctrl+s', () => { + save() +})`, + useCases: ['App shortcuts', 'Accessibility', 'Power user features'], + tags: ['keyboard', 'shortcut', 'hotkey'], + icon: mdiNetwork, + }, + useClickOutside: { + name: 'Click Outside', + summary: 'Detect clicks outside an element', + description: 'Detects pointer events outside a target element for dismissing overlays. Supports conditional activation, excluded elements, and touch device compatibility.', + example: `useClickOutside(menuRef, () => { + isOpen.value = false +})`, + useCases: ['Dropdown close', 'Modal dismiss', 'Menu collapse'], + tags: ['click', 'outside', 'dismiss'], + icon: mdiNetwork, + }, + usePopover: { + name: 'Popover', + summary: 'Floating UI positioning and visibility', + description: 'Floating element positioning using Floating UI with automatic flip, shift, and arrow middleware. Returns attrs and styles for both activator and content elements.', + example: `const { activator, content } = usePopover({ + placement: 'bottom', +}) +// Bind activator.attrs to trigger element`, + useCases: ['Tooltips', 'Dropdowns', 'Context menus'], + tags: ['popover', 'float', 'position'], + icon: mdiNetwork, + }, + useStack: { + name: 'Stack', + summary: 'Z-index stacking order for overlays', + description: 'Manages z-index stacking order for overlapping UI elements. Ensures modals, drawers, and popovers layer correctly without manual z-index management.', + example: `const stack = useStack() +// stack.zIndex.value — auto-assigned +// stack.isTop.value — true if topmost`, + useCases: ['Modals', 'Dialogs', 'Drawers'], + tags: ['stack', 'zindex', 'overlay'], + icon: mdiNetwork, + }, + useResizeObserver: { + name: 'Resize Observer', + summary: 'Reactive element size tracking', + description: 'Wraps the ResizeObserver API with automatic cleanup and SSR safety. Provides reactive width and height values that update as the observed element resizes.', + example: `const { width, height } = useResizeObserver(el) +// width.value, height.value update live`, + useCases: ['Responsive components', 'Chart resizing', 'Layout shifts'], + tags: ['resize', 'observer', 'size'], + icon: mdiNetwork, + }, + useIntersectionObserver: { + name: 'Intersection Observer', + summary: 'Detect element visibility in viewport', + description: 'Wraps IntersectionObserver with reactive visibility state and configurable thresholds. Ideal for lazy loading images, triggering animations, and infinite scroll.', + example: `const { isIntersecting } = useIntersectionObserver(el) +// isIntersecting.value — true when visible`, + useCases: ['Lazy loading', 'Infinite scroll', 'Analytics'], + tags: ['intersection', 'visibility', 'lazy'], + icon: mdiNetwork, + }, + + // System (missing) + useMutationObserver: { + name: 'Mutation Observer', + summary: 'Watch for DOM changes on elements', + description: 'Wraps the MutationObserver API with lifecycle management, pause/resume controls, and automatic cleanup. SSR-safe and hydration-aware with configurable observation options.', + example: `const { stop } = useMutationObserver(el, records => { + // React to DOM mutations +}, { childList: true, attributes: true })`, + useCases: ['Dynamic content', 'DOM monitoring', 'Attribute tracking'], + tags: ['mutation', 'observer', 'dom'], + icon: mdiNetwork, + }, + useMediaQuery: { + name: 'Media Query', + summary: 'Reactive CSS media query matching', + description: 'Reactive matchMedia integration that returns a boolean ref tracking whether a CSS media query matches. SSR-safe and hydration-aware with automatic listener cleanup.', + example: `const { matches } = useMediaQuery('(prefers-color-scheme: dark)') +// matches.value — true when dark mode preferred`, + useCases: ['Responsive conditionals', 'Preference detection', 'Adaptive rendering'], + tags: ['media', 'query', 'responsive'], + icon: mdiNetwork, + }, + useTimer: { + name: 'Timer', + summary: 'Reactive countdown with pause/resume', + description: 'A reactive timer with start, stop, pause, and resume controls. Tracks remaining time and supports one-shot or repeating modes with automatic scope cleanup.', + example: `const timer = useTimer({ duration: 5000 }) +timer.start() +// timer.remaining.value — ms left`, + useCases: ['Auto-dismiss', 'Countdowns', 'Polling intervals'], + tags: ['timer', 'countdown', 'delay'], + icon: mdiNetwork, + }, + usePresence: { + name: 'Presence', + summary: 'Animation-agnostic mount lifecycle', + description: 'Manages the full DOM presence lifecycle: lazy mount, enter, exit delay, and unmount. Consumers control exit timing via a done() callback, making it compatible with CSS transitions, GSAP, or no animation at all.', + example: `const { isMounted, isLeaving, done } = usePresence({ + present: isOpen, + lazy: true, +}) +// Call done() when exit animation finishes`, + useCases: ['Transition wrappers', 'Lazy mount', 'Exit animations'], + tags: ['presence', 'animation', 'mount'], + icon: mdiNetwork, + }, + useLazy: { + name: 'Lazy', + summary: 'Deferred content rendering for performance', + description: 'Defers content rendering until first activation with optional delay. Supports eager mode bypass and keeps content mounted after first boot for instant re-show.', + example: `const { isBooted, isActive } = useLazy(isOpen, { + delay: 200, +}) +// Content renders only after first open`, + useCases: ['Dialog content', 'Menu panels', 'Tooltip bodies'], + tags: ['lazy', 'defer', 'performance'], + icon: mdiNetwork, + }, + useToggleScope: { + name: 'Toggle Scope', + summary: 'Conditional effect scope management', + description: 'Creates and destroys a Vue effect scope based on a reactive boolean. All reactive effects within the scope are automatically cleaned up when the condition becomes false.', + example: `useToggleScope(isOpen, () => { + // Effects only run while isOpen is true + watch(source, handler) +})`, + useCases: ['Conditional side effects', 'Feature flags', 'Performance optimization'], + tags: ['scope', 'toggle', 'effects'], + icon: mdiNetwork, + }, + useRovingFocus: { + name: 'Roving Focus', + summary: 'Keyboard navigation with roving tabindex', + description: 'Arrow key navigation for composite widgets using the roving tabindex pattern. Supports horizontal, vertical, and grid modes with automatic disabled-item skipping and circular navigation.', + example: `const { focusedId, register } = useRovingFocus({ + orientation: 'horizontal', +}) +// Arrow keys move focus between registered items`, + useCases: ['Toolbars', 'Menu items', 'Grid navigation'], + tags: ['focus', 'keyboard', 'roving'], + icon: mdiNetwork, + }, + useVirtualFocus: { + name: 'Virtual Focus', + summary: 'aria-activedescendant keyboard navigation', + description: 'Virtual focus management where DOM focus stays on a control element while a virtual cursor highlights list items. Sets aria-activedescendant and data-highlighted attributes automatically.', + example: `const { highlightedId, register } = useVirtualFocus({ + control: inputRef, +}) +// Arrow keys move highlight, focus stays on input`, + useCases: ['Comboboxes', 'Autocomplete', 'Listboxes'], + tags: ['virtual', 'focus', 'aria'], + icon: mdiNetwork, + }, + useRaf: { + name: 'Request Animation Frame', + summary: 'Scope-safe requestAnimationFrame wrapper', + description: 'Wraps requestAnimationFrame with a cancel-then-request pattern for deduplicating rapid calls. Automatically cleans up on scope disposal and is SSR-safe.', + example: `const update = useRaf(() => { + // Runs on next animation frame +}) +update() // Request frame +update.cancel() // Cancel pending`, + useCases: ['Smooth updates', 'Frame throttling', 'Visual animations'], + tags: ['raf', 'animation', 'frame'], + icon: mdiNetwork, + }, + + // Reactivity (missing) + useProxyModel: { + name: 'Proxy Model', + summary: 'Bridge selection state to v-model', + description: 'Bidirectional sync between a model context (Selection, Slider, etc.) and a Vue v-model ref. Supports array and single-value modes with automatic cleanup.', + example: `useProxyModel(selection, modelValue, { + multiple: true, +}) +// v-model now syncs with selection state`, + useCases: ['Component v-model', 'Two-way binding', 'Selection bridges'], + tags: ['proxy', 'model', 'vmodel'], + icon: mdiCog, + }, + useProxyRegistry: { + name: 'Proxy Registry', + summary: 'Reactive proxy for registry data', + description: 'Transforms a Map-based registry into reactive refs for keys, values, entries, and size. Updates via registry events with deep or shallow reactivity options.', + example: `const proxy = useProxyRegistry(registry) +// proxy.values — reactive array of tickets +// proxy.size — reactive count`, + useCases: ['Template iteration', 'Reactive lists', 'Registry binding'], + tags: ['proxy', 'registry', 'reactive'], + icon: mdiCog, + }, + + // Transformers (missing) + toArray: { + name: 'To Array', + summary: 'Normalize values into arrays', + description: 'Converts single values into single-element arrays, passes arrays through unchanged, and returns empty arrays for null/undefined. Essential for APIs that accept T | T[].', + example: `toArray('hello') // ['hello'] +toArray([1, 2]) // [1, 2] +toArray(null) // []`, + useCases: ['Input normalization', 'API flexibility', 'Safe iteration'], + tags: ['array', 'normalize', 'transform'], + icon: mdiCog, + }, + toElement: { + name: 'To Element', + summary: 'Resolve refs to DOM elements', + description: 'Resolves refs, getters, raw DOM elements, or Vue component instances to a plain Element. Uses structural typing to avoid cross-version Vue Ref incompatibilities.', + example: `const el = toElement(templateRef) +// Resolves Ref, getter, component, or Element`, + useCases: ['Observer targets', 'DOM operations', 'Component refs'], + tags: ['element', 'ref', 'resolve'], + icon: mdiCog, + }, + toReactive: { + name: 'To Reactive', + summary: 'Convert values to reactive proxies', + description: 'Converts plain objects and refs into deep reactive proxies with automatic ref unwrapping. Supports Map and Set collections with nested reactivity and type preservation.', + example: `const state = toReactive(ref({ count: 0 })) +state.count++ // Reactive, unwrapped`, + useCases: ['State conversion', 'Ref unwrapping', 'Reactive collections'], + tags: ['reactive', 'proxy', 'unwrap'], + icon: mdiCog, + }, + + // Plugins (missing) + useHydration: { + name: 'Hydration', + summary: 'SSR hydration state management', + description: 'Tracks SSR hydration lifecycle with isHydrated and isSettled states. Essential for composables that need to behave differently during server-side rendering vs. client-side execution.', + example: `const { isHydrated, isSettled } = useHydration() +// isHydrated — root component has mounted +// isSettled — safe for animations`, + useCases: ['SSR safety', 'Hydration-aware rendering', 'Animation deferral'], + tags: ['hydration', 'ssr', 'mount'], + icon: mdiPuzzle, + }, + useRtl: { + name: 'RTL', + summary: 'Right-to-left direction management', + description: 'Reactive RTL direction state with adapter pattern for DOM integration. Supports subtree overrides via context provision and is independent from useLocale.', + example: `const { isRtl, toggle } = useRtl() +toggle() // Flip LTR ↔ RTL`, + useCases: ['RTL layouts', 'Bidirectional text', 'Direction-aware components'], + tags: ['rtl', 'direction', 'ltr'], + icon: mdiPuzzle, + }, + useNotifications: { + name: 'Notifications', + summary: 'Push notification lifecycle management', + description: 'Full notification system with push, read, archive, snooze, and bulk operations. Built on createRegistry for persistence and createQueue for toast-style auto-dismiss with pause/resume.', + example: `const notifications = useNotifications() +notifications.send({ + subject: 'Saved', + severity: 'success', + timeout: 3000, +})`, + useCases: ['Toast messages', 'Notification center', 'Alert management'], + tags: ['notifications', 'toast', 'alerts'], + icon: mdiPuzzle, + }, + + // Registration (missing) + createQueue: { + name: 'Queue', + summary: 'Time-based FIFO queue with auto-dismiss', + description: 'Manages a FIFO queue with automatic timeout-based removal, pause/resume, and manual dismissal. Only the first ticket is active at any time; when it expires, the next auto-activates.', + example: `const queue = createQueue({ timeout: 3000 }) +queue.register({ id: 'msg-1' }) +// Auto-dismissed after 3s`, + useCases: ['Toast queues', 'Notification stacks', 'Timed content'], + tags: ['queue', 'fifo', 'timeout'], + icon: mdiArchive, + }, + createTimeline: { + name: 'Timeline', + summary: 'Bounded undo/redo history', + description: 'Bounded undo/redo system with fixed-size history and overflow management. Extends createRegistry with temporal navigation for command patterns and history tracking.', + example: `const timeline = createTimeline({ max: 10 }) +timeline.register({ id: 'action-1' }) +timeline.undo() +timeline.redo()`, + useCases: ['Undo/redo', 'Command history', 'Action replay'], + tags: ['timeline', 'undo', 'redo'], + icon: mdiArchive, + }, + createTokens: { + name: 'Tokens', + summary: 'Design token registry with alias resolution', + description: 'Design token registry supporting W3C Design Tokens format with alias resolution, circular reference detection, and nested flattening. Used internally by useTheme, useLocale, and useFeatures.', + example: `const tokens = createTokens({ + colors: { primary: '{colors.blue.500}' }, +}) +tokens.resolve('{colors.primary}')`, + useCases: ['Design tokens', 'Theme variables', 'Configuration'], + tags: ['tokens', 'design', 'alias'], + icon: mdiArchive, + }, + + // Selection (missing) + createNested: { + name: 'Nested', + summary: 'Hierarchical tree management', + description: 'Extends createGroup with parent-child relationship tracking, open/close state, and tree traversal utilities. Supports single and multiple open strategies for tree views and nested navigation.', + example: `const nested = createNested({ open: 'single' }) +// Tracks parent-child relationships +// getPath, getDescendants, open/close`, + useCases: ['Tree views', 'Nested navigation', 'File explorers'], + tags: ['nested', 'tree', 'hierarchy'], + icon: mdiCheckboxMarked, + }, + + // Forms (missing) + createValidation: { + name: 'Validation', + summary: 'Per-input validation with rule management', + description: 'Per-input validation built on createGroup where each ticket is a rule that can be enabled/disabled. Supports async validation, Standard Schema (Zod, Valibot), and auto-registers with parent forms.', + example: `const validation = createValidation() +validation.register({ value: v => !!v || 'Required' }) +await validation.validate(inputValue)`, + useCases: ['Input validation', 'Field rules', 'Async validation'], + tags: ['validation', 'rules', 'input'], + icon: mdiTextBox, + }, + useRules: { + name: 'Rules', + summary: 'Validation rule resolution with Standard Schema', + description: 'Resolves validation rules from alias strings, functions, or Standard Schema objects (Zod, Valibot, ArkType). Provides a shared rule alias registry via plugin context with locale-aware error messages.', + example: `const rules = useRules() +const resolved = rules.resolve(['required', 'email']) +// Returns FormValidationRule[] array`, + useCases: ['Rule aliases', 'Schema integration', 'Shared rules'], + tags: ['rules', 'schema', 'validation'], + icon: mdiTextBox, + }, + + // Semantic (missing) + createBreadcrumbs: { + name: 'Breadcrumbs', + summary: 'Breadcrumb navigation with path truncation', + description: 'Breadcrumb navigation built on createSingle with depth tracking, root detection, and path truncation. Provides first(), prev(), and select() for navigating hierarchical paths.', + example: `const breadcrumbs = createBreadcrumbs() +// breadcrumbs.depth — path length +// breadcrumbs.prev() — go up one level`, + useCases: ['Breadcrumb trails', 'Path navigation', 'Hierarchical UI'], + tags: ['breadcrumbs', 'navigation', 'path'], + icon: mdiStar, + }, + createOverflow: { + name: 'Overflow', + summary: 'Container capacity measurement', + description: 'Computes how many items fit in a container based on available width using ResizeObserver. Supports variable-width and uniform-width modes with reserved space for navigation elements.', + example: `const overflow = createOverflow({ + container: containerRef, + gap: 8, +}) +// overflow.capacity — items that fit`, + useCases: ['Responsive truncation', 'Pagination sizing', 'Breadcrumb collapse'], + tags: ['overflow', 'capacity', 'responsive'], + icon: mdiStar, + }, +} + +const graph = dependencyGraph as DependencyGraph + +export function buildCatalog (): Feature[] { + const features: Feature[] = [] + + for (const [id, meta] of Object.entries(META)) { + const composable = (maturity.composables as unknown as Record)[id] + const component = (maturity.components as unknown as Record)[id] + const entry = composable ?? component + + if (!entry) continue + + const type = composable ? 'composable' : 'component' + const deps = type === 'composable' + ? (graph.composables[id] ?? []) + : (graph.components[id] ?? []) + + features.push({ + id, + type, + category: entry.category, + maturity: entry.level as Feature['maturity'], + since: entry.since ?? '', + dependencies: deps, + ...meta, + }) + } + + return features +} diff --git a/apps/builder/src/data/questions.ts b/apps/builder/src/data/questions.ts new file mode 100644 index 000000000..2ff1414cb --- /dev/null +++ b/apps/builder/src/data/questions.ts @@ -0,0 +1,78 @@ +// Types +import type { Intent } from './types' + +export interface Question { + id: string + question: string + description: string + feature: string + icon?: string +} + +// Questions per intent type +// For now, all intents share the same core questions +// but the order and descriptions can be customized per intent later +export function getQuestions (_intent: Intent): Question[] { + return [ + { + id: 'theme', + question: 'Do you need light/dark theme support?', + description: 'Adds a theme system with CSS custom properties. Users can switch between light and dark modes, or you can define custom themes with your own color tokens.', + feature: 'useTheme', + }, + { + id: 'locale', + question: 'Do you need to support multiple languages?', + description: 'Adds internationalization with message translation, pluralization, and locale switching. Supports vue-i18n or a built-in adapter.', + feature: 'useLocale', + }, + { + id: 'rtl', + question: 'Do you need RTL (right-to-left) support?', + description: 'Adds bidirectional text support for Arabic, Hebrew, and other RTL languages. Automatically mirrors layouts and components.', + feature: 'useRtl', + }, + { + id: 'ssr', + question: 'Do you need SSR or SSG support?', + description: 'Adds server-side rendering compatibility. Ensures composables hydrate correctly and avoids client-only API calls during server rendering.', + feature: 'useHydration', + }, + { + id: 'breakpoints', + question: 'Do you need responsive breakpoints?', + description: 'Adds reactive viewport tracking with named breakpoints. Detect mobile, tablet, and desktop layouts programmatically.', + feature: 'useBreakpoints', + }, + { + id: 'features', + question: 'Do you need feature flags?', + description: 'Adds boolean feature toggles for A/B testing and progressive rollout. Supports external providers like LaunchDarkly, Flagsmith, and PostHog.', + feature: 'useFeatures', + }, + { + id: 'permissions', + question: 'Do you need permission-based access control?', + description: 'Adds role-based and attribute-based access control. Gate UI elements and routes based on user roles and permissions.', + feature: 'usePermissions', + }, + { + id: 'storage', + question: 'Do you need persistent user preferences?', + description: 'Adds reactive localStorage/sessionStorage with automatic serialization. Persist theme choice, sidebar state, or any user preference.', + feature: 'useStorage', + }, + { + id: 'logger', + question: 'Do you need structured logging?', + description: 'Adds a pluggable logging system with namespaces and log levels. Supports console, Pino, and Consola adapters.', + feature: 'useLogger', + }, + { + id: 'date', + question: 'Do you need date/time utilities?', + description: 'Adds date manipulation and formatting with adapter support. Works with native Date, date-fns, luxon, or dayjs.', + feature: 'useDate', + }, + ] +} diff --git a/apps/builder/src/data/types.ts b/apps/builder/src/data/types.ts new file mode 100644 index 000000000..f63492139 --- /dev/null +++ b/apps/builder/src/data/types.ts @@ -0,0 +1,52 @@ +export interface Feature { + id: string + type: 'composable' | 'component' | 'adapter' + category: string + maturity: 'draft' | 'preview' | 'stable' + since: string + name: string + summary: string + useCases: string[] + dependencies: string[] + tags: string[] + icon?: string + description?: string + example?: string +} + +export interface FeatureMeta { + name: string + summary: string + useCases: string[] + tags: string[] + icon?: string + description?: string + example?: string +} + +export interface DependencyGraph { + composables: Record + components: Record +} + +export interface ResolvedSet { + selected: string[] + autoIncluded: string[] + reasons: Record + warnings: Warning[] +} + +export interface Warning { + featureId: string + type: 'draft' | 'missing' + message: string +} + +export type Intent = 'spa' | 'component-library' | 'design-system' | 'admin-dashboard' | 'content-site' | 'mobile-first' + +export interface FrameworkManifest { + intent?: string + features: string[] + resolved: string[] + adapters: Record +} diff --git a/apps/builder/src/engine/manifest.test.ts b/apps/builder/src/engine/manifest.test.ts new file mode 100644 index 000000000..7480a2d70 --- /dev/null +++ b/apps/builder/src/engine/manifest.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' + +import { generateFiles, generateImports, toHashData } from './manifest' + +describe('generateFiles', () => { + it('generates SelectionDemo when selection features are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext', 'createModel', 'createTrinity'], + adapters: {}, + }) + + expect(files['src/SelectionDemo.vue']).toContain('createSingle') + expect(files['src/App.vue']).toContain('SelectionDemo') + }) + + it('generates FormDemo when form features are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createForm'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(files['src/FormDemo.vue']).toContain('createForm') + expect(files['src/App.vue']).toContain('FormDemo') + }) + + it('generates DataDemo when data features are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createDataTable'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(files['src/DataDemo.vue']).toContain('createDataTable') + expect(files['src/App.vue']).toContain('DataDemo') + }) + + it('generates multiple demos for mixed feature sets', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle', 'createForm', 'useResizeObserver'], + resolved: ['createContext', 'createModel', 'createTrinity'], + adapters: {}, + }) + + expect(files['src/SelectionDemo.vue']).toBeDefined() + expect(files['src/FormDemo.vue']).toBeDefined() + expect(files['src/ObserverDemo.vue']).toBeDefined() + expect(files['src/App.vue']).toContain('SelectionDemo') + expect(files['src/App.vue']).toContain('FormDemo') + expect(files['src/App.vue']).toContain('ObserverDemo') + }) + + it('does not generate main.ts or uno.config.ts', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle', 'useTheme'], + resolved: ['createContext', 'createModel', 'createTrinity'], + adapters: {}, + }) + + expect(files['src/main.ts']).toBeUndefined() + expect(files['src/uno.config.ts']).toBeUndefined() + }) + + it('generates fallback App.vue when only plugins are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['useTheme', 'useLocale'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(Object.keys(files)).toEqual(['src/App.vue']) + expect(files['src/App.vue']).toContain('plugins are ready') + }) + + it('shows correct feature count in App.vue', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle', 'useTheme'], + resolved: ['createContext', 'createModel'], + adapters: {}, + }) + + expect(files['src/App.vue']).toContain('4 features loaded') + }) +}) + +describe('toHashData', () => { + it('sets active to src/App.vue', () => { + const data = toHashData({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.active).toBe('src/App.vue') + }) + + it('sets preset to default', () => { + const data = toHashData({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.preset).toBe('default') + }) + + it('adds pinia addon when useStorage is selected', () => { + const data = toHashData({ + intent: 'spa', + features: ['useStorage'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.addons).toContain('pinia') + }) + + it('adds router addon when createStep is selected', () => { + const data = toHashData({ + intent: 'spa', + features: ['createStep'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.addons).toContain('router') + }) + + it('omits addons when none are needed', () => { + const data = toHashData({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.addons).toBeUndefined() + }) +}) + +describe('generateImports', () => { + it('includes v0 CDN import', () => { + const imports = generateImports() + expect(imports['@vuetify/v0']).toContain('cdn.jsdelivr.net') + }) +}) diff --git a/apps/builder/src/engine/manifest.ts b/apps/builder/src/engine/manifest.ts new file mode 100644 index 000000000..7c13d0b82 --- /dev/null +++ b/apps/builder/src/engine/manifest.ts @@ -0,0 +1,882 @@ +// Types +import type { FrameworkManifest } from '@/data/types' + +interface PlaygroundHashData { + files: Record + active?: string + imports?: Record + settings?: { + preset?: string + addons?: string + } +} + +// Feature → category mapping for demo file generation +const CATEGORY_MAP: Record = { + // Selection + createSelection: 'selection', + createSingle: 'selection', + createGroup: 'selection', + createStep: 'selection', + // Forms + createForm: 'forms', + createCombobox: 'forms', + createSlider: 'forms', + createRating: 'forms', + // Data + createDataTable: 'data', + createFilter: 'data', + createPagination: 'data', + createVirtual: 'data', + // Disclosure / overlay + useStack: 'disclosure', + useClickOutside: 'disclosure', + usePopover: 'disclosure', + // Observers + useResizeObserver: 'observers', + useIntersectionObserver: 'observers', +} + +// Features that imply pinia addon +const PINIA_FEATURES = new Set(['useStorage']) + +// Features that imply router addon +const ROUTER_FEATURES = new Set(['createStep']) + +export function generateImports (): Record { + return { + '@vuetify/v0': 'https://cdn.jsdelivr.net/npm/@vuetify/v0@latest/dist/index.mjs', + '@vue/devtools-api': 'https://esm.sh/@vue/devtools-api@6', + } +} + +// ---- Demo file generators per category ---- + +function generateSelectionDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('createStep')) { + return ` + +` + } + + if (has('createGroup')) { + return ` + +` + } + + if (has('createSingle') || has('createSelection')) { + const factory = has('createSingle') ? 'createSingle' : 'createSelection' + return ` + +` + } + + return '' +} + +function generateFormDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('createForm')) { + return ` + +` + } + + if (has('createSlider')) { + return ` + +` + } + + if (has('createRating')) { + return ` + +` + } + + if (has('createCombobox')) { + return ` + +` + } + + return '' +} + +function generateDataDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('createDataTable')) { + return ` + +` + } + + if (has('createPagination') || has('createFilter') || has('createVirtual')) { + const feature = has('createPagination') ? 'createPagination' : (has('createFilter') ? 'createFilter' : 'createVirtual') + + if (feature === 'createPagination') { + return ` + +` + } + + if (feature === 'createFilter') { + return ` + +` + } + + return ` + +` + } + + return '' +} + +function generateDisclosureDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('usePopover')) { + return ` + +` + } + + if (has('useStack') || has('useClickOutside')) { + return ` + +` + } + + return '' +} + +function generateObserverDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('useResizeObserver')) { + return ` + +` + } + + if (has('useIntersectionObserver')) { + return ` + +` + } + + return '' +} + +// ---- Category to generator + file name mapping ---- + +interface DemoConfig { + file: string + component: string + generator: (features: string[]) => string +} + +const DEMO_CONFIGS: DemoConfig[] = [ + { file: 'src/SelectionDemo.vue', component: 'SelectionDemo', generator: generateSelectionDemo }, + { file: 'src/FormDemo.vue', component: 'FormDemo', generator: generateFormDemo }, + { file: 'src/DataDemo.vue', component: 'DataDemo', generator: generateDataDemo }, + { file: 'src/DialogDemo.vue', component: 'DialogDemo', generator: generateDisclosureDemo }, + { file: 'src/ObserverDemo.vue', component: 'ObserverDemo', generator: generateObserverDemo }, +] + +function categorizeFeatures (features: string[]): Set { + const categories = new Set() + + for (const feature of features) { + const category = CATEGORY_MAP[feature] + if (category) categories.add(category) + } + + return categories +} + +function generateAppVue (demos: Array<{ component: string, file: string }>, featureCount: number): string { + const imports = demos + .map(d => `import ${d.component} from './${d.component}.vue'`) + .join('\n') + + const components = demos + .map(d => ` <${d.component} />`) + .join('\n') + + if (demos.length === 0) { + return ` + +` + } + + return ` + +` +} + +export function generateFiles (manifest: FrameworkManifest): Record { + const allFeatures = [...manifest.features, ...manifest.resolved] + const categories = categorizeFeatures(allFeatures) + + const files: Record = {} + const demos: Array<{ component: string, file: string }> = [] + + for (const config of DEMO_CONFIGS) { + // Check if any features match this demo's category + const categoryKey = config.component + .replace('Demo', '') + .replace('Selection', 'selection') + .replace('Form', 'forms') + .replace('Data', 'data') + .replace('Dialog', 'disclosure') + .replace('Observer', 'observers') + .toLowerCase() + + if (!categories.has(categoryKey)) continue + + const content = config.generator(allFeatures) + if (!content) continue + + files[config.file] = content + demos.push({ component: config.component, file: config.file }) + } + + files['src/App.vue'] = generateAppVue(demos, allFeatures.length) + + return files +} + +function resolveAddons (features: string[]): string | undefined { + const addons: string[] = [] + + if (features.some(f => PINIA_FEATURES.has(f))) addons.push('pinia') + if (features.some(f => ROUTER_FEATURES.has(f))) addons.push('router') + + return addons.length > 0 ? addons.join(',') : undefined +} + +export function toHashData (manifest: FrameworkManifest): PlaygroundHashData { + const allFeatures = [...manifest.features, ...manifest.resolved] + const files = generateFiles(manifest) + const addons = resolveAddons(allFeatures) + + return { + files, + active: 'src/App.vue', + imports: generateImports(), + settings: { + preset: 'default', + ...(addons ? { addons } : {}), + }, + } +} + +export async function encodeHash (data: PlaygroundHashData): Promise { + const { strToU8, strFromU8, zlibSync } = await import('fflate') + const buffer = strToU8(JSON.stringify(data)) + const zipped = zlibSync(buffer, { level: 9 }) + const binary = strFromU8(zipped, true) + return btoa(binary) +} + +export async function toPlaygroundUrl (manifest: FrameworkManifest, baseUrl: string): Promise { + const data = toHashData(manifest) + const hash = await encodeHash(data) + return `${baseUrl}#${hash}` +} diff --git a/apps/builder/src/engine/resolve.test.ts b/apps/builder/src/engine/resolve.test.ts new file mode 100644 index 000000000..df71f6b38 --- /dev/null +++ b/apps/builder/src/engine/resolve.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +// Types +import type { DependencyGraph } from '@/data/types' + +import { resolve } from './resolve' + +const graph: DependencyGraph = { + composables: { + createContext: [], + createTrinity: [], + createModel: ['createContext'], + createSelection: ['createContext', 'createModel', 'createTrinity'], + createSingle: ['createContext', 'createSelection', 'createTrinity'], + createStep: ['createContext', 'createSingle', 'createTrinity'], + }, + components: {}, +} + +describe('resolve', () => { + it('returns empty for empty selection', () => { + const result = resolve([], graph) + expect(result.selected).toEqual([]) + expect(result.autoIncluded).toEqual([]) + expect(result.warnings).toEqual([]) + }) + + it('selects a feature with no dependencies', () => { + const result = resolve(['createContext'], graph) + expect(result.selected).toEqual(['createContext']) + expect(result.autoIncluded).toEqual([]) + }) + + it('auto-includes transitive dependencies', () => { + const result = resolve(['createSelection'], graph) + expect(result.selected).toEqual(['createSelection']) + expect(result.autoIncluded.toSorted()).toEqual(['createContext', 'createModel', 'createTrinity']) + }) + + it('does not duplicate features in selected and autoIncluded', () => { + const result = resolve(['createSelection', 'createContext'], graph) + expect(result.selected.toSorted()).toEqual(['createContext', 'createSelection']) + expect(result.autoIncluded.toSorted()).toEqual(['createModel', 'createTrinity']) + }) + + it('resolves deep transitive chains', () => { + const result = resolve(['createStep'], graph) + expect(result.selected).toEqual(['createStep']) + expect(result.autoIncluded.toSorted()).toEqual([ + 'createContext', + 'createModel', + 'createSelection', + 'createSingle', + 'createTrinity', + ]) + }) + + it('tracks reasons for auto-included dependencies', () => { + const result = resolve(['createSelection'], graph) + expect(result.reasons.createContext).toBe('createSelection') + expect(result.reasons.createModel).toBe('createSelection') + expect(result.reasons.createTrinity).toBe('createSelection') + }) + + it('warns for features not in the graph', () => { + const result = resolve(['nonExistent'], graph) + expect(result.warnings).toEqual([ + { featureId: 'nonExistent', type: 'missing', message: 'Feature "nonExistent" not found in dependency graph' }, + ]) + }) +}) diff --git a/apps/builder/src/engine/resolve.ts b/apps/builder/src/engine/resolve.ts new file mode 100644 index 000000000..d87492751 --- /dev/null +++ b/apps/builder/src/engine/resolve.ts @@ -0,0 +1,51 @@ +// Types +import type { DependencyGraph, ResolvedSet, Warning } from '@/data/types' + +export function resolve (selected: string[], graph: DependencyGraph): ResolvedSet { + const selectedSet = new Set(selected) + const allDeps = new Set() + const reasons: Record = {} + const warnings: Warning[] = [] + + const allFeatures = { ...graph.composables, ...graph.components } + + function walk (id: string, parent?: string) { + if (allDeps.has(id)) return + + const deps = allFeatures[id] + if (!deps) { + warnings.push({ + featureId: id, + type: 'missing', + message: `Feature "${id}" not found in dependency graph`, + }) + return + } + + allDeps.add(id) + + // Track why this dep was pulled in (only for non-selected) + if (parent && !selectedSet.has(id) && !reasons[id]) { + reasons[id] = parent + } + + for (const dep of deps) { + walk(dep, id) + } + } + + for (const id of selected) { + walk(id) + } + + const autoIncluded = [...allDeps] + .filter(id => !selectedSet.has(id)) + .toSorted() + + return { + selected: [...selected], + autoIncluded, + reasons, + warnings, + } +} diff --git a/apps/builder/src/layouts/default.vue b/apps/builder/src/layouts/default.vue new file mode 100644 index 000000000..fb004d2cb --- /dev/null +++ b/apps/builder/src/layouts/default.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/builder/src/main.ts b/apps/builder/src/main.ts new file mode 100644 index 000000000..3a370a2dd --- /dev/null +++ b/apps/builder/src/main.ts @@ -0,0 +1,102 @@ +import { setupLayouts } from 'virtual:generated-layouts' +import { routes } from 'vue-router/auto-routes' + +// Framework +import { createBreakpointsPlugin, createHydrationPlugin, createLoggerPlugin, createStackPlugin, createStoragePlugin, createThemePlugin, IN_BROWSER } from '@vuetify/v0' + +import App from './App.vue' + +// Utilities +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' + +import { createIconPlugin } from './plugins/icons' +import pinia from './plugins/pinia' + +import 'virtual:uno.css' + +const app = createApp(App) + +const router = createRouter({ + history: createWebHistory(), + routes: setupLayouts(routes), +}) + +app.use(pinia) +app.use(router) +app.use(createIconPlugin()) +app.use(createLoggerPlugin()) +app.use(createHydrationPlugin()) +app.use(createBreakpointsPlugin({ mobileBreakpoint: 768 })) +app.use(createStoragePlugin()) +app.use(createStackPlugin()) + +function getSystemTheme (): 'light' | 'dark' { + if (!IN_BROWSER) return 'light' + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +app.use( + createThemePlugin({ + default: getSystemTheme(), + target: 'html', + themes: { + light: { + dark: false, + colors: { + 'primary': '#3b82f6', + 'secondary': '#64748b', + 'accent': '#6366f1', + 'error': '#ef4444', + 'info': '#1867c0', + 'success': '#22c55e', + 'warning': '#f59e0b', + 'background': '#f5f5f5', + 'surface': '#ffffff', + 'surface-tint': '#eeeef0', + 'surface-variant': '#f5f5f5', + 'divider': '#e0e0e0', + 'on-primary': '#ffffff', + 'on-secondary': '#ffffff', + 'on-accent': '#1a1a1a', + 'on-error': '#ffffff', + 'on-info': '#ffffff', + 'on-success': '#1a1a1a', + 'on-warning': '#1a1a1a', + 'on-background': '#212121', + 'on-surface': '#212121', + 'on-surface-variant': '#666666', + }, + }, + dark: { + dark: true, + colors: { + 'primary': '#c4b5fd', + 'secondary': '#94a3b8', + 'accent': '#c084fc', + 'error': '#f87171', + 'info': '#38bdf8', + 'success': '#4ade80', + 'warning': '#fb923c', + 'background': '#121212', + 'surface': '#1a1a1a', + 'surface-tint': '#2a2a2a', + 'surface-variant': '#1e1e1e', + 'divider': '#404040', + 'on-primary': '#1a1a1a', + 'on-secondary': '#ffffff', + 'on-accent': '#ffffff', + 'on-error': '#1a1a1a', + 'on-info': '#1a1a1a', + 'on-success': '#1a1a1a', + 'on-warning': '#1a1a1a', + 'on-background': '#e0e0e0', + 'on-surface': '#e0e0e0', + 'on-surface-variant': '#a0a0a0', + }, + }, + }, + }), +) + +app.mount('#app') diff --git a/apps/builder/src/pages/ai.vue b/apps/builder/src/pages/ai.vue new file mode 100644 index 000000000..46c7fcb8a --- /dev/null +++ b/apps/builder/src/pages/ai.vue @@ -0,0 +1,130 @@ + + + diff --git a/apps/builder/src/pages/free.vue b/apps/builder/src/pages/free.vue new file mode 100644 index 000000000..df3c35947 --- /dev/null +++ b/apps/builder/src/pages/free.vue @@ -0,0 +1,171 @@ + + + diff --git a/apps/builder/src/pages/guided.vue b/apps/builder/src/pages/guided.vue new file mode 100644 index 000000000..fc58f8e25 --- /dev/null +++ b/apps/builder/src/pages/guided.vue @@ -0,0 +1,364 @@ + + + diff --git a/apps/builder/src/pages/index.vue b/apps/builder/src/pages/index.vue new file mode 100644 index 000000000..2fcea7903 --- /dev/null +++ b/apps/builder/src/pages/index.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/builder/src/pages/review.vue b/apps/builder/src/pages/review.vue new file mode 100644 index 000000000..da6023a4f --- /dev/null +++ b/apps/builder/src/pages/review.vue @@ -0,0 +1,118 @@ + + + diff --git a/apps/builder/src/plugins/icons.ts b/apps/builder/src/plugins/icons.ts new file mode 100644 index 000000000..34505be6f --- /dev/null +++ b/apps/builder/src/plugins/icons.ts @@ -0,0 +1,31 @@ +import { mdiArrowLeft, mdiArrowRight, mdiCheck, mdiChevronDown, mdiClose, mdiCog, mdiLock, mdiMagnify, mdiRobot } from '@mdi/js' + +// Framework +import { createPlugin, createTokensContext } from '@vuetify/v0' + +// Types +import type { App } from 'vue' + +export const [useIconContext, provideIconContext, context] = createTokensContext({ + namespace: 'v0:icons', + tokens: { + back: mdiArrowLeft, + forward: mdiArrowRight, + check: mdiCheck, + close: mdiClose, + down: mdiChevronDown, + search: mdiMagnify, + settings: mdiCog, + lock: mdiLock, + ai: mdiRobot, + }, +}) + +export function createIconPlugin () { + return createPlugin({ + namespace: 'v0:icons', + provide: (app: App) => { + provideIconContext(context, app) + }, + }) +} diff --git a/apps/builder/src/plugins/pinia.ts b/apps/builder/src/plugins/pinia.ts new file mode 100644 index 000000000..153625250 --- /dev/null +++ b/apps/builder/src/plugins/pinia.ts @@ -0,0 +1,4 @@ +// Utilities +import { createPinia } from 'pinia' + +export default createPinia() diff --git a/apps/builder/src/stores/builder.ts b/apps/builder/src/stores/builder.ts new file mode 100644 index 000000000..c7e4f4eb1 --- /dev/null +++ b/apps/builder/src/stores/builder.ts @@ -0,0 +1,216 @@ +// Framework +import { createGroup, createSingle, createStep, useStorage } from '@vuetify/v0' + +// Utilities +import { defineStore } from 'pinia' +import { computed, shallowRef, toRef, watch } from 'vue' + +// Types +import type { DependencyGraph, Feature, Intent, ResolvedSet } from '@/data/types' + +import dependencyGraph from '@/data/dependencies.json' +import { buildCatalog } from '@/data/features' +import { toPlaygroundUrl } from '@/engine/manifest' +import { resolve } from '@/engine/resolve' + +export const useBuilderStore = defineStore('builder', () => { + const catalog = buildCatalog() + const graph = dependencyGraph as DependencyGraph + + // Intent — single select for project type + const intent = createSingle() + intent.onboard([ + { id: 'spa', value: 'spa' }, + { id: 'component-library', value: 'component-library' }, + { id: 'design-system', value: 'design-system' }, + { id: 'admin-dashboard', value: 'admin-dashboard' }, + { id: 'content-site', value: 'content-site' }, + { id: 'mobile-first', value: 'mobile-first' }, + ]) + + // Feature selection — multi-select group + const features = createGroup() + features.onboard(catalog.map(f => ({ id: f.id, value: f.id }))) + + // Category order for guided wizard + const categoryOrder = [ + 'foundation', + 'selection', + 'forms', + 'data', + 'plugins', + 'system', + 'registration', + 'reactivity', + 'semantic', + ] + + // Wizard stepper — initialized with steps immediately + const stepper = createStep() + + // Build and populate steps based on available categories + function buildSteps (): string[] { + const cats = new Map() + for (const feature of catalog) { + const list = cats.get(feature.category) ?? [] + list.push(feature) + cats.set(feature.category, list) + } + return ['intent', ...categoryOrder.filter(c => cats.has(c)), 'review'] + } + + const wizardSteps = buildSteps() + stepper.onboard(wizardSteps.map(id => ({ id, value: id }))) + stepper.first() + + // Mode — single select (guided vs free) + const mode = createSingle({ mandatory: 'force' }) + mode.onboard([ + { id: 'guided', value: 'guided' }, + { id: 'free', value: 'free' }, + ]) + mode.select('guided') + + // Derived + const selectedIds = toRef(() => features.selectedIds) + + const resolved = computed(() => { + return resolve([...features.selectedIds] as string[], graph) + }) + + const categories = computed(() => { + const cats = new Map() + for (const feature of catalog) { + const list = cats.get(feature.category) ?? [] + list.push(feature) + cats.set(feature.category, list) + } + return cats + }) + + const selectedCount = toRef(() => features.selectedIds.size) + + // Guided question flow state (exposed for AppBar breadcrumbs) + // -1 = intent step, 0+ = question index, >= questionCount = review + const questionIndex = shallowRef(-1) + const questionCount = shallowRef(0) + + // Actions + function toggle (id: string) { + features.toggle(id) + } + + function select (id: string) { + features.select(id) + } + + function deselect (id: string) { + features.unselect(id) + } + + function isSelected (id: string): boolean { + return features.selected(id) + } + + function reset () { + if (intent.selectedId.value) { + intent.unselect(intent.selectedId.value) + } + features.unselectAll() + stepper.first() + } + + function setIntent (value: Intent) { + intent.select(value) + features.unselectAll() + for (const id of getRecommendations(value)) { + if (features.has(id)) { + features.select(id) + } + } + } + + // Persistence + const storage = useStorage() + const savedIntent = storage.get('builder:intent', '') + const savedFeatures = storage.get('builder:features', []) + + // Restore saved state + if (savedIntent.value) { + setIntent(savedIntent.value as Intent) + } else if (savedFeatures.value.length > 0) { + for (const id of savedFeatures.value) { + if (features.has(id)) { + features.select(id) + } + } + } + + // Watch and persist + watch(() => intent.selectedId.value, id => { + storage.set('builder:intent', id ?? '') + }) + + watch(selectedIds, ids => { + storage.set('builder:features', [...ids]) + }, { deep: true }) + + async function openInPlayground () { + const url = await toPlaygroundUrl( + { + intent: intent.selectedValue.value as string | undefined, + features: [...features.selectedIds] as string[], + resolved: resolved.value.autoIncluded, + adapters: {}, + }, + 'https://v0play.vuetifyjs.com', + ) + window.open(url, '_blank') + } + + return { + // Raw composables + intent, + features, + stepper, + mode, + + // Data + catalog, + + // Derived + selectedIds, + selectedCount, + resolved, + categories, + + // Guided question flow + questionIndex, + questionCount, + + // Actions + toggle, + select, + deselect, + isSelected, + reset, + setIntent, + wizardSteps, + openInPlayground, + } +}) + +function getRecommendations (intent: Intent): string[] { + // Only select differentiating features — foundation composables + // like createContext/createTrinity auto-include as dependencies + const presets: Record = { + 'spa': ['createSelection', 'createSingle', 'useTheme', 'useStorage', 'useBreakpoints'], + 'component-library': ['createRegistry', 'createSelection', 'createGroup', 'useTheme'], + 'design-system': ['createRegistry', 'createSelection', 'createGroup', 'useTheme', 'useLocale', 'useBreakpoints'], + 'admin-dashboard': ['createDataTable', 'createForm', 'createSelection', 'useTheme', 'useStorage', 'useBreakpoints', 'usePermissions'], + 'content-site': ['useTheme', 'useBreakpoints', 'useIntersectionObserver', 'useStorage'], + 'mobile-first': ['createSelection', 'useTheme', 'useBreakpoints', 'useStorage', 'useEventListener'], + } + + return presets[intent] ?? ['useTheme'] +} diff --git a/apps/builder/src/typed-router.d.ts b/apps/builder/src/typed-router.d.ts new file mode 100644 index 000000000..38df26a30 --- /dev/null +++ b/apps/builder/src/typed-router.d.ts @@ -0,0 +1,122 @@ +/* eslint-disable */ +/* prettier-ignore */ +// oxfmt-ignore +// @ts-nocheck +// noinspection ES6UnusedImports +// Generated by vue-router. !! DO NOT MODIFY THIS FILE !! +// It's recommended to commit this file. +// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry. + +import type { + RouteRecordInfo, + ParamValue, + ParamValueOneOrMore, + ParamValueZeroOrMore, + ParamValueZeroOrOne, +} from 'vue-router' + +declare module 'vue-router' { + interface TypesConfig { + ParamParsers: + | never + } +} + +declare module 'vue-router/auto-routes' { + /** + * Route name map generated by vue-router + */ + export interface RouteNamedMap { + '/': RouteRecordInfo< + '/', + '/', + Record, + Record, + | never + >, + '/ai': RouteRecordInfo< + '/ai', + '/ai', + Record, + Record, + | never + >, + '/free': RouteRecordInfo< + '/free', + '/free', + Record, + Record, + | never + >, + '/guided': RouteRecordInfo< + '/guided', + '/guided', + Record, + Record, + | never + >, + '/review': RouteRecordInfo< + '/review', + '/review', + Record, + Record, + | never + >, + } + + /** + * Route file to route info map by vue-router. + * Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`. + * + * Each key is a file path relative to the project root with 2 properties: + * - routes: union of route names of the possible routes when in this page (passed to useRoute<...>()) + * - views: names of nested views (can be passed to ) + * + * @internal + */ + export interface _RouteFileInfoMap { + 'src/pages/index.vue': { + routes: + | '/' + views: + | never + } + 'src/pages/ai.vue': { + routes: + | '/ai' + views: + | never + } + 'src/pages/free.vue': { + routes: + | '/free' + views: + | never + } + 'src/pages/guided.vue': { + routes: + | '/guided' + views: + | never + } + 'src/pages/review.vue': { + routes: + | '/review' + views: + | never + } + } + + /** + * Get a union of possible route names in a certain route component file. + * Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`. + * + * @internal + */ + export type _RouteNamesForFilePath = + _RouteFileInfoMap extends Record + ? Info['routes'] + : keyof RouteNamedMap +} + +export {} diff --git a/apps/builder/src/vite-env.d.ts b/apps/builder/src/vite-env.d.ts new file mode 100644 index 000000000..9ca0786df --- /dev/null +++ b/apps/builder/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __DEV__: boolean diff --git a/apps/builder/tsconfig.app.json b/apps/builder/tsconfig.app.json new file mode 100644 index 000000000..b90448dbd --- /dev/null +++ b/apps/builder/tsconfig.app.json @@ -0,0 +1,36 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": true, + "incremental": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "customConditions": ["development"], + "moduleResolution": "bundler", + "paths": { + "@/*": ["./src/*"], + "#v0": ["../../packages/0/src"], + "#v0/*": ["../../packages/0/src/*"] + }, + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "assumeChangesOnlyAffectDirectDependencies": true, + "verbatimModuleSyntax": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "target": "esnext" + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue" + ], + "exclude": [ + "src/**/__tests__/*" + ], + "references": [ + { "path": "../../packages/0" } + ] +} diff --git a/apps/builder/tsconfig.json b/apps/builder/tsconfig.json new file mode 100644 index 000000000..ba5ccc4a2 --- /dev/null +++ b/apps/builder/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.app.json" } + ] +} diff --git a/apps/builder/tsconfig.node.json b/apps/builder/tsconfig.node.json new file mode 100644 index 000000000..52636e359 --- /dev/null +++ b/apps/builder/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*" + ], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "target": "esnext", + "lib": ["esnext"] + } +} diff --git a/apps/builder/uno.config.ts b/apps/builder/uno.config.ts new file mode 100644 index 000000000..2a21c0134 --- /dev/null +++ b/apps/builder/uno.config.ts @@ -0,0 +1,68 @@ +import { defineConfig, presetWind4 } from 'unocss' + +export default defineConfig({ + presets: [presetWind4()], + shortcuts: { + 'fade-interactive': 'opacity-50 hover:opacity-80 focus-visible:opacity-80 transition-opacity', + 'bg-glass-surface': '[background:var(--v0-glass-surface)] backdrop-blur-12', + }, + preflights: [ + { + getCSS: () => ` + html { + scrollbar-gutter: stable; + } + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } + + *:focus-visible { + outline: 2px solid var(--v0-primary); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } + `, + }, + ], + theme: { + colors: { + 'primary': 'var(--v0-primary)', + 'secondary': 'var(--v0-secondary)', + 'accent': 'var(--v0-accent)', + 'error': 'var(--v0-error)', + 'info': 'var(--v0-info)', + 'success': 'var(--v0-success)', + 'warning': 'var(--v0-warning)', + 'background': 'var(--v0-background)', + 'surface': 'var(--v0-surface)', + 'surface-tint': 'var(--v0-surface-tint)', + 'surface-variant': 'var(--v0-surface-variant)', + 'divider': 'var(--v0-divider)', + 'pre': 'var(--v0-pre)', + 'on-primary': 'var(--v0-on-primary)', + 'on-secondary': 'var(--v0-on-secondary)', + 'on-accent': 'var(--v0-on-accent)', + 'on-error': 'var(--v0-on-error)', + 'on-info': 'var(--v0-on-info)', + 'on-success': 'var(--v0-on-success)', + 'on-warning': 'var(--v0-on-warning)', + 'on-background': 'var(--v0-on-background)', + 'on-surface': 'var(--v0-on-surface)', + 'on-surface-variant': 'var(--v0-on-surface-variant)', + }, + borderColor: { + DEFAULT: 'var(--v0-divider)', + }, + }, +}) diff --git a/apps/builder/vite.config.ts b/apps/builder/vite.config.ts new file mode 100644 index 000000000..f83543757 --- /dev/null +++ b/apps/builder/vite.config.ts @@ -0,0 +1,44 @@ +import { fileURLToPath, URL } from 'node:url' + +import UnocssVitePlugin from 'unocss/vite' +import Components from 'unplugin-vue-components/vite' +import Vue from 'unplugin-vue/rolldown' +import { defineConfig } from 'vite' +import Layouts from 'vite-plugin-vue-layouts-next' +import VueRouter from 'vue-router/vite' + +export default defineConfig({ + plugins: [ + VueRouter({ + dts: './src/typed-router.d.ts', + }), + Vue(), + Components({ + dirs: ['src/components'], + extensions: ['vue'], + include: [/\.vue$/, /\.vue\?vue/], + dts: './src/components.d.ts', + }), + UnocssVitePlugin(), + Layouts(), + ], + define: { + 'process.env': {}, + '__DEV__': process.env.NODE_ENV !== 'production', + '__VUE_OPTIONS_API__': 'true', + '__VUE_PROD_DEVTOOLS__': 'false', + '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false', + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('src', import.meta.url)), + '@vuetify/v0': fileURLToPath(new URL('../../packages/0/src', import.meta.url)), + '#v0': fileURLToPath(new URL('../../packages/0/src', import.meta.url)), + }, + }, + server: { + fs: { + allow: ['../../packages/*', '../../node_modules', '.'], + }, + }, +}) diff --git a/apps/builder/vitest.config.ts b/apps/builder/vitest.config.ts new file mode 100644 index 000000000..f668e056c --- /dev/null +++ b/apps/builder/vitest.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from 'node:url' + +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('src', import.meta.url)), + }, + }, + test: { + environment: 'node', + globals: true, + include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + }, +}) diff --git a/knip.json b/knip.json index 6601aed3e..893a18806 100644 --- a/knip.json +++ b/knip.json @@ -82,6 +82,37 @@ "sass" ] }, + "apps/builder": { + "entry": [ + "src/App.vue", + "src/components/**/*.vue", + "src/layouts/*.vue", + "src/data/*.ts", + "src/engine/*.ts", + "src/plugins/*.ts", + "src/stores/*.ts", + "src/pages/**/*.vue", + "build/**/*.ts", + "vite.config.*" + ], + "ignore": [ + "src/typed-router.d.ts" + ], + "ignoreDependencies": [ + "@vuetify/auth" + ], + "paths": { + "@/*": [ + "src/*" + ], + "@vuetify/v0": [ + "../../packages/0/src" + ], + "#v0": [ + "../../packages/0/src" + ] + } + }, "apps/playground": { "entry": [ "src/App.vue", diff --git a/package.json b/package.json index 7ee22a36b..1dc09b2d1 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build:dev": "pnpm --filter=dev build", "build:play": "pnpm --filter=@vuetify-private/playground build", "build:docs": "pnpm --filter=docs build", - "build:apps": "pnpm --filter=docs --filter=dev --filter=@vuetify-private/playground build", + "build:apps": "pnpm --filter=docs --filter=dev --filter=@vuetify-private/playground --filter=@vuetify-private/builder build", "build:all": "pnpm --filter=\"./packages/*\" --filter=docs --filter=dev --filter=@vuetify-private/playground build", "test": "vitest", "test:run": "vitest run", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e32df5a9e..5701806e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,49 @@ importers: specifier: 'catalog:' version: 3.2.6(typescript@6.0.2) + apps/builder: + dependencies: + '@mdi/js': + specifier: 'catalog:' + version: mdi-js-es@7.4.47 + '@vuetify/auth': + specifier: 'catalog:' + version: 0.1.8(pinia@3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2)))(vue@3.5.31(typescript@6.0.2)) + '@vuetify/v0': + specifier: workspace:* + version: link:../../packages/0 + fflate: + specifier: 'catalog:' + version: 0.8.2 + pinia: + specifier: 'catalog:' + version: 3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2)) + vue: + specifier: 'catalog:' + version: 3.5.31(typescript@6.0.2) + devDependencies: + tsx: + specifier: 'catalog:' + version: 4.21.0 + unocss: + specifier: 'catalog:' + version: 66.6.7(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(vite@8.0.3(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + unplugin-vue: + specifier: 'catalog:' + version: 7.1.1(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(vue@3.5.31(typescript@6.0.2))(yaml@2.8.3) + unplugin-vue-components: + specifier: 'catalog:' + version: 32.0.0(vue@3.5.31(typescript@6.0.2)) + vite: + specifier: 'catalog:' + version: 8.0.3(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-vue-layouts-next: + specifier: 'catalog:' + version: 2.1.0(vite@8.0.3(@emnapi/core@1.9.0)(@emnapi/runtime@1.9.0)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.31)(pinia@3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2)))(vue@3.5.31(typescript@6.0.2)))(vue@3.5.31(typescript@6.0.2)) + vue-router: + specifier: 'catalog:' + version: 5.0.4(@vue/compiler-sfc@3.5.31)(pinia@3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2)))(vue@3.5.31(typescript@6.0.2)) + apps/docs: dependencies: '@js-temporal/polyfill': diff --git a/vitest.config.ts b/vitest.config.ts index 885ebc8c0..0e41c8263 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - projects: ['packages/*', 'apps/docs'], + projects: ['packages/*', 'apps/docs', 'apps/builder'], globals: true, include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'], testTimeout: 20_000,