From 083ba688923ceb01f3d22e4f6f63d233961994ae Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 19 May 2026 09:46:02 -0500 Subject: [PATCH 01/34] chore(builder): scaffold builder app Bootstrap apps/builder workspace package modeled on apps/playground but without the playground-specific deps (vue-repl, shiki, markdown). Same tsconfig + vite config patterns so the oxc transformer resolves tsconfig cleanly. --- apps/builder/index.html | 12 ++++++ apps/builder/package.json | 28 ++++++++++++ apps/builder/tsconfig.app.json | 36 ++++++++++++++++ apps/builder/tsconfig.json | 7 +++ apps/builder/tsconfig.node.json | 17 ++++++++ apps/builder/uno.config.ts | 35 +++++++++++++++ apps/builder/vite.config.ts | 44 +++++++++++++++++++ pnpm-lock.yaml | 75 ++++++++++++++++++++------------- 8 files changed, 225 insertions(+), 29 deletions(-) create mode 100644 apps/builder/index.html create mode 100644 apps/builder/package.json create mode 100644 apps/builder/tsconfig.app.json create mode 100644 apps/builder/tsconfig.json create mode 100644 apps/builder/tsconfig.node.json create mode 100644 apps/builder/uno.config.ts create mode 100644 apps/builder/vite.config.ts diff --git a/apps/builder/index.html b/apps/builder/index.html new file mode 100644 index 000000000..8a7ea986d --- /dev/null +++ b/apps/builder/index.html @@ -0,0 +1,12 @@ + + + + + + v0 Framework Builder + + +
+ + + diff --git a/apps/builder/package.json b/apps/builder/package.json new file mode 100644 index 000000000..41ab5e0b8 --- /dev/null +++ b/apps/builder/package.json @@ -0,0 +1,28 @@ +{ + "name": "@vuetify-private/builder", + "version": "1.0.0-alpha.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/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/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..e44ec05a5 --- /dev/null +++ b/apps/builder/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "include": [ + "vite.config.*", + "build/**/*.ts" + ], + "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..941a57190 --- /dev/null +++ b/apps/builder/uno.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, presetWind4 } from 'unocss' + +export default defineConfig({ + presets: [presetWind4()], + preflights: [ + { + getCSS: () => ` + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } + + *:focus-visible { + outline: 2px solid var(--v0-primary); + outline-offset: 2px; + } + `, + }, + ], + theme: { + colors: { + 'primary': 'var(--v0-primary)', + 'secondary': 'var(--v0-secondary)', + 'accent': 'var(--v0-accent)', + 'error': 'var(--v0-error)', + 'background': 'var(--v0-background)', + 'surface': 'var(--v0-surface)', + 'surface-variant': 'var(--v0-surface-variant)', + 'divider': 'var(--v0-divider)', + 'on-primary': 'var(--v0-on-primary)', + 'on-surface': 'var(--v0-on-surface)', + 'on-surface-variant': 'var(--v0-on-surface-variant)', + }, + }, +}) 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/pnpm-lock.yaml b/pnpm-lock.yaml index a97f28389..b72d637db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -314,6 +314,46 @@ importers: specifier: 'catalog:' version: 3.2.7(typescript@6.0.3) + apps/builder: + dependencies: + '@mdi/js': + specifier: 'catalog:' + version: mdi-js-es@7.4.47 + '@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.3)(vue@3.5.33(typescript@6.0.3)) + vue: + specifier: 'catalog:' + version: 3.5.33(typescript@6.0.3) + devDependencies: + tsx: + specifier: 'catalog:' + version: 4.21.0 + unocss: + specifier: 'catalog:' + version: 66.6.8(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + unplugin-vue: + specifier: 'catalog:' + version: 7.2.0(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(vue@3.5.33(typescript@6.0.3))(yaml@2.8.3) + unplugin-vue-components: + specifier: 'catalog:' + version: 32.0.0(vue@3.5.33(typescript@6.0.3)) + vite: + specifier: 'catalog:' + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.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.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + vue-router: + specifier: 'catalog:' + version: 5.0.6(@vue/compiler-sfc@3.5.33)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3)))(vue@3.5.33(typescript@6.0.3)) + apps/docs: dependencies: '@js-temporal/polyfill': @@ -3216,18 +3256,12 @@ packages: '@vue/compiler-sfc@3.5.31': resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==} - '@vue/compiler-sfc@3.5.32': - resolution: {integrity: sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==} - '@vue/compiler-sfc@3.5.33': resolution: {integrity: sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==} '@vue/compiler-ssr@3.5.31': resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==} - '@vue/compiler-ssr@3.5.32': - resolution: {integrity: sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==} - '@vue/compiler-ssr@3.5.33': resolution: {integrity: sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==} @@ -9572,7 +9606,7 @@ snapshots: '@vue-macros/common@3.1.2(vue@3.5.33(typescript@6.0.3))': dependencies: - '@vue/compiler-sfc': 3.5.32 + '@vue/compiler-sfc': 3.5.33 ast-kit: 2.2.0 local-pkg: 1.1.2 magic-string-ast: 1.0.3 @@ -9631,18 +9665,6 @@ snapshots: postcss: 8.5.8 source-map-js: 1.2.1 - '@vue/compiler-sfc@3.5.32': - dependencies: - '@babel/parser': 7.29.2 - '@vue/compiler-core': 3.5.32 - '@vue/compiler-dom': 3.5.32 - '@vue/compiler-ssr': 3.5.32 - '@vue/shared': 3.5.32 - estree-walker: 2.0.2 - magic-string: 0.30.21 - postcss: 8.5.8 - source-map-js: 1.2.1 - '@vue/compiler-sfc@3.5.33': dependencies: '@babel/parser': 7.29.2 @@ -9660,11 +9682,6 @@ snapshots: '@vue/compiler-dom': 3.5.31 '@vue/shared': 3.5.31 - '@vue/compiler-ssr@3.5.32': - dependencies: - '@vue/compiler-dom': 3.5.32 - '@vue/shared': 3.5.32 - '@vue/compiler-ssr@3.5.33': dependencies: '@vue/compiler-dom': 3.5.33 @@ -9949,7 +9966,7 @@ snapshots: domhandler: 5.0.3 htmlparser2: 10.1.0 picocolors: 1.1.1 - postcss: 8.5.8 + postcss: 8.5.14 postcss-media-query-parser: 0.2.3 optional: true @@ -13232,7 +13249,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.4 - get-tsconfig: 4.13.7 + get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 @@ -13335,7 +13352,7 @@ snapshots: unhead@2.1.12: dependencies: - hookable: 6.1.0 + hookable: 6.1.1 unhead@2.1.13: dependencies: @@ -13453,7 +13470,7 @@ snapshots: mlly: 1.8.2 obug: 2.1.1 picomatch: 4.0.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 unplugin: 3.0.0 unplugin-utils: 0.3.1 vue: 3.5.33(typescript@6.0.3) @@ -13728,7 +13745,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.4 scule: 1.3.0 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 unplugin: 3.0.0 unplugin-utils: 0.3.1 vue: 3.5.33(typescript@6.0.3) From 55c619a61068da759dee8a1029840f61be9f9c81 Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 19 May 2026 09:46:12 -0500 Subject: [PATCH 02/34] chore(builder): add feature catalog and dependency resolver Carried forward from the deleted feature/framework-builder branch via docs/builder-seed/: - data/features.ts: curated catalog with name, summary, useCases, tags, icon over the generated dep graph - data/dependencies.json: generated by build/generate-dependencies.ts on every `pnpm generate` (runs before vite via the dev script) - data/questions.ts: 11 plugins across 5 categories (appearance, i18n, infrastructure, access, utilities) - the only surface upfront config needs to ask about - engine/resolve.ts: pure function that walks the dep graph and reports { selected, autoIncluded, reasons, warnings } - engine/manifest.ts: encodes selection into a fflate-compressed hash for handoff to apps/playground --- apps/builder/build/generate-dependencies.ts | 70 ++ apps/builder/src/data/dependencies.json | 554 ++++++++++++ apps/builder/src/data/features.ts | 746 +++++++++++++++++ apps/builder/src/data/questions.ts | 145 ++++ apps/builder/src/data/types.ts | 52 ++ apps/builder/src/engine/manifest.test.ts | 156 ++++ apps/builder/src/engine/manifest.ts | 882 ++++++++++++++++++++ apps/builder/src/engine/resolve.test.ts | 71 ++ apps/builder/src/engine/resolve.ts | 51 ++ 9 files changed, 2727 insertions(+) create mode 100644 apps/builder/build/generate-dependencies.ts create mode 100644 apps/builder/src/data/dependencies.json create mode 100644 apps/builder/src/data/features.ts create mode 100644 apps/builder/src/data/questions.ts create mode 100644 apps/builder/src/data/types.ts create mode 100644 apps/builder/src/engine/manifest.test.ts create mode 100644 apps/builder/src/engine/manifest.ts create mode 100644 apps/builder/src/engine/resolve.test.ts create mode 100644 apps/builder/src/engine/resolve.ts 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/src/data/dependencies.json b/apps/builder/src/data/dependencies.json new file mode 100644 index 000000000..eef4d0c0e --- /dev/null +++ b/apps/builder/src/data/dependencies.json @@ -0,0 +1,554 @@ +{ + "composables": { + "createBreadcrumbs": [ + "createContext", + "createSingle", + "createTrinity" + ], + "createCombobox": [ + "createContext", + "createSelection", + "createTrinity", + "toArray", + "usePopover", + "useVirtualFocus" + ], + "createContext": [], + "createDataTable": [ + "createContext", + "createFilter", + "createGroup", + "createPagination", + "createRegistry", + "createTrinity", + "useLocale" + ], + "createFilter": [ + "createContext", + "createTrinity", + "toArray" + ], + "createFocusTraversal": [], + "createForm": [ + "createContext", + "createRegistry", + "createTrinity", + "createValidation", + "toArray" + ], + "createGroup": [ + "createContext", + "createSelection", + "createTrinity", + "toArray", + "useProxyRegistry" + ], + "createInput": [ + "createForm", + "createRegistry", + "createValidation", + "toArray", + "useRules" + ], + "createKanban": [ + "createRegistry", + "createSortable", + "useLogger" + ], + "createModel": [ + "createRegistry" + ], + "createNested": [ + "createContext", + "createGroup", + "createTrinity", + "toArray", + "useLogger" + ], + "createNumberField": [ + "createInput", + "createNumeric" + ], + "createNumeric": [], + "createObserver": [ + "toElement", + "useHydration" + ], + "createOverflow": [ + "createContext", + "createTrinity", + "useResizeObserver" + ], + "createPagination": [ + "createContext", + "createTrinity" + ], + "createPlugin": [ + "createContext", + "createTrinity", + "useStorage" + ], + "createProgress": [ + "createContext", + "createModel", + "createTrinity" + ], + "createQueue": [ + "createContext", + "createRegistry", + "createTrinity", + "useTimer" + ], + "createRating": [ + "createContext", + "createTrinity" + ], + "createRegistry": [ + "createContext", + "createTrinity", + "useLogger" + ], + "createSelection": [ + "createContext", + "createModel", + "createTrinity" + ], + "createSingle": [ + "createContext", + "createSelection", + "createTrinity" + ], + "createSlider": [ + "createModel", + "createNumeric" + ], + "createSortable": [ + "createModel", + "createRegistry", + "useLogger" + ], + "createStep": [ + "createContext", + "createSingle", + "createTrinity" + ], + "createTimeline": [ + "createContext", + "createRegistry", + "createTrinity" + ], + "createTokens": [ + "createContext", + "createRegistry", + "createTrinity", + "useLogger" + ], + "createTrinity": [ + "createContext" + ], + "createValidation": [ + "createForm", + "createGroup", + "useRules" + ], + "createVirtual": [ + "createContext", + "createTrinity", + "useResizeObserver" + ], + "toArray": [], + "toElement": [], + "toHighlight": [ + "toArray" + ], + "toReactive": [], + "useBreakpoints": [ + "createPlugin", + "useEventListener", + "useHydration" + ], + "useClickOutside": [ + "toArray", + "useEventListener" + ], + "useDate": [ + "createContext", + "createPlugin", + "createTrinity", + "useLocale" + ], + "useDelay": [ + "useTimer" + ], + "useDragDrop": [ + "createRegistry", + "useLogger", + "useMutationObserver", + "useResizeObserver" + ], + "useEventListener": [], + "useFeatures": [ + "createGroup", + "createPlugin", + "createRegistry", + "createTokens" + ], + "useHotkey": [ + "useEventListener", + "useLogger" + ], + "useHydration": [ + "createPlugin" + ], + "useImage": [], + "useIntersectionObserver": [ + "createObserver", + "toElement" + ], + "useLazy": [ + "useTimer" + ], + "useLocale": [ + "createPlugin", + "createSingle", + "createTokens", + "useStorage" + ], + "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", + "useLogger" + ], + "useStack": [ + "createContext", + "createPlugin", + "createSelection", + "createTrinity" + ], + "useStorage": [ + "createPlugin", + "useEventListener" + ], + "useTheme": [ + "createPlugin", + "createRegistry", + "createSingle", + "createTokens", + "useStorage" + ], + "useTimer": [], + "useToggleScope": [], + "useVirtualFocus": [ + "createFocusTraversal", + "useEventListener" + ] + }, + "components": { + "AlertDialog": [ + "Atom", + "createContext", + "useClickOutside", + "useLocale", + "useStack", + "useToggleScope" + ], + "AspectRatio": [ + "Atom" + ], + "Atom": [], + "Avatar": [ + "Atom", + "createContext", + "createSelection", + "useImage" + ], + "Breadcrumbs": [ + "Atom", + "createBreadcrumbs", + "createContext", + "createGroup", + "createOverflow", + "useLocale" + ], + "Button": [ + "Atom", + "createContext", + "createSelection", + "createSingle", + "useLocale", + "useProxyModel", + "useTimer" + ], + "Carousel": [ + "Atom", + "createContext", + "createRegistry", + "createStep", + "toElement", + "useEventListener", + "useLocale", + "useProxyModel", + "useResizeObserver", + "useTimer", + "useToggleScope" + ], + "Checkbox": [ + "Atom", + "createContext", + "createGroup", + "useProxyModel" + ], + "Collapsible": [ + "Atom", + "createContext", + "createSingle", + "useProxyModel" + ], + "Combobox": [ + "Atom", + "createCombobox", + "createContext", + "useClickOutside", + "useLazy", + "useProxyModel" + ], + "Dialog": [ + "Atom", + "createContext", + "useClickOutside", + "useLocale", + "useStack", + "useToggleScope" + ], + "ExpansionPanel": [ + "Atom", + "createContext", + "createSelection", + "useProxyModel" + ], + "Form": [ + "Atom", + "createContext", + "createForm", + "createValidation" + ], + "Group": [ + "createContext", + "createGroup", + "useProxyModel" + ], + "Image": [ + "Atom", + "createContext", + "toElement", + "useImage", + "useIntersectionObserver", + "useLogger" + ], + "Input": [ + "Atom", + "createContext", + "createForm", + "createInput", + "createRegistry", + "useRules" + ], + "Locale": [ + "createContext", + "useLocale" + ], + "NumberField": [ + "Atom", + "Input", + "createContext", + "createForm", + "createInput", + "createNumberField", + "createRegistry", + "useEventListener", + "useLocale", + "useRaf", + "useRules", + "useTimer" + ], + "Overflow": [ + "Atom", + "createContext", + "createOverflow", + "createRegistry", + "toElement", + "useResizeObserver" + ], + "Pagination": [ + "Atom", + "createContext", + "createOverflow", + "createPagination", + "createRegistry", + "useLocale" + ], + "Popover": [ + "Atom", + "createContext", + "usePopover" + ], + "Portal": [ + "useStack" + ], + "Presence": [ + "usePresence" + ], + "Progress": [ + "Atom", + "createContext", + "createProgress", + "useProxyModel" + ], + "Radio": [ + "Atom", + "createContext", + "createSingle", + "toElement", + "useProxyModel" + ], + "Rating": [ + "Atom", + "createContext", + "createRating", + "useLocale" + ], + "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", + "useLocale", + "useNotifications", + "useStack" + ], + "Splitter": [ + "Atom", + "createContext", + "createRegistry", + "createSelection", + "toElement", + "useEventListener", + "useRaf", + "useResizeObserver", + "useToggleScope" + ], + "Step": [ + "createContext", + "createStep", + "useProxyModel" + ], + "Switch": [ + "Atom", + "createContext", + "createGroup", + "useProxyModel" + ], + "Tabs": [ + "Atom", + "createContext", + "createStep", + "toElement", + "useProxyModel" + ], + "Theme": [ + "Atom", + "createContext", + "useTheme" + ], + "Toggle": [ + "Atom", + "createContext", + "createGroup", + "createSingle", + "useProxyModel" + ], + "Treeview": [ + "Atom", + "createContext", + "createNested", + "toElement", + "useProxyModel", + "useRovingFocus" + ] + } +} diff --git a/apps/builder/src/data/features.ts b/apps/builder/src/data/features.ts new file mode 100644 index 000000000..06c85e1c2 --- /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' + +import dependencyGraph from './dependencies.json' + +// Types +import type { DependencyGraph, Feature, FeatureMeta } from './types' + +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..4927a491e --- /dev/null +++ b/apps/builder/src/data/questions.ts @@ -0,0 +1,145 @@ +// Types +import type { Intent } from './types' + +export interface Question { + id: string + title: string + description: string + feature: string + category: string +} + +export interface QuestionCategory { + id: string + title: string + description: string + questions: Question[] +} + +const COMPONENT_LIBRARY: QuestionCategory[] = [ + { + id: 'appearance', + title: 'Appearance', + description: 'Theming and responsive behavior', + questions: [ + { + id: 'theme', + title: 'Theme', + description: 'Light/dark mode with custom color tokens and CSS variables', + feature: 'useTheme', + category: 'appearance', + }, + { + id: 'breakpoints', + title: 'Breakpoints', + description: 'Reactive viewport tracking with named breakpoints for responsive logic', + feature: 'useBreakpoints', + category: 'appearance', + }, + ], + }, + { + id: 'i18n', + title: 'Internationalization', + description: 'Language and direction support', + questions: [ + { + id: 'locale', + title: 'Locale', + description: 'Translate component labels and messages with vue-i18n or built-in adapter', + feature: 'useLocale', + category: 'i18n', + }, + { + id: 'rtl', + title: 'Right-to-Left', + description: 'Reactive RTL direction state for mirroring component layouts', + feature: 'useRtl', + category: 'i18n', + }, + ], + }, + { + id: 'infrastructure', + title: 'Infrastructure', + description: 'Storage, rendering, and observability', + questions: [ + { + id: 'storage', + title: 'Storage', + description: 'Persistent localStorage/sessionStorage with auto-serialization', + feature: 'useStorage', + category: 'infrastructure', + }, + { + id: 'ssr', + title: 'SSR / SSG', + description: 'Hydration lifecycle tracking to prevent mismatches and defer browser APIs', + feature: 'useHydration', + category: 'infrastructure', + }, + { + id: 'logger', + title: 'Logger', + description: 'Structured logging with namespaces and pluggable adapters', + feature: 'useLogger', + category: 'infrastructure', + }, + ], + }, + { + id: 'access', + title: 'Access Control', + description: 'Feature gating and permissions', + questions: [ + { + id: 'features', + title: 'Feature Flags', + description: 'Boolean toggles for A/B testing with LaunchDarkly, Flagsmith, or local config', + feature: 'useFeatures', + category: 'access', + }, + { + id: 'permissions', + title: 'Permissions', + description: 'Role-based access control for gating UI elements and routes', + feature: 'usePermissions', + category: 'access', + }, + ], + }, + { + id: 'utilities', + title: 'Utilities', + description: 'Date handling and notifications', + questions: [ + { + id: 'date', + title: 'Date', + description: 'Date manipulation with adapter support for date-fns, dayjs, or luxon', + feature: 'useDate', + category: 'utilities', + }, + { + id: 'notifications', + title: 'Notifications', + description: 'Toast and notification system with auto-dismiss and queue management', + feature: 'useNotifications', + category: 'utilities', + }, + ], + }, +] + +const CATEGORIES: Record = { + 'component-library': COMPONENT_LIBRARY, + 'spa': [], + 'design-system': [], + 'admin-dashboard': [], + 'content-site': [], + 'mobile-first': [], +} + +export function getCategories (intent: Intent): QuestionCategory[] { + return CATEGORIES[intent] ?? [] +} 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..bc5ac179f --- /dev/null +++ b/apps/builder/src/engine/resolve.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { resolve } from './resolve' + +// Types +import type { DependencyGraph } from '@/data/types' + +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, + } +} From 8629157092546e6f822e7e481ec11e552868e5eb Mon Sep 17 00:00:00 2001 From: John Leider Date: Tue, 19 May 2026 09:46:21 -0500 Subject: [PATCH 03/34] chore(builder): add wizard UX and selection store - main.ts wires Pinia + router + v0 plugins (hydration, breakpoints, storage, theme) with light/dark color tokens - pages/index.vue lands users with a one-paragraph explainer and a single CTA to /wizard - pages/wizard.vue runs the wizard in two phases via a plain shallowRef<'plugins' | 'review'>: - plugins phase renders the questions.ts taxonomy as a category- grouped toggle grid - review phase shows selected + auto-included dependencies + any warnings, pulled from the engine/resolve output - stores/builder.ts holds the selection Set and exposes resolved as a toRef derived from the dep graph Deliberately skipped from the previous branch: IntentCard + multi- intent flow (only component-library exists), mode cards, the stepper integration, breadcrumb sync, Free Pick and AI pages. --- apps/builder/src/App.vue | 10 ++ apps/builder/src/components.d.ts | 17 +++ apps/builder/src/main.ts | 71 ++++++++++++ apps/builder/src/pages/index.vue | 40 +++++++ apps/builder/src/pages/wizard.vue | 174 +++++++++++++++++++++++++++++ apps/builder/src/stores/builder.ts | 51 +++++++++ apps/builder/src/typed-router.d.ts | 86 ++++++++++++++ 7 files changed, 449 insertions(+) create mode 100644 apps/builder/src/App.vue create mode 100644 apps/builder/src/components.d.ts create mode 100644 apps/builder/src/main.ts create mode 100644 apps/builder/src/pages/index.vue create mode 100644 apps/builder/src/pages/wizard.vue create mode 100644 apps/builder/src/stores/builder.ts create mode 100644 apps/builder/src/typed-router.d.ts diff --git a/apps/builder/src/App.vue b/apps/builder/src/App.vue new file mode 100644 index 000000000..ff64961ee --- /dev/null +++ b/apps/builder/src/App.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/builder/src/components.d.ts b/apps/builder/src/components.d.ts new file mode 100644 index 000000000..227ab480a --- /dev/null +++ b/apps/builder/src/components.d.ts @@ -0,0 +1,17 @@ +/* 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 { + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } +} diff --git a/apps/builder/src/main.ts b/apps/builder/src/main.ts new file mode 100644 index 000000000..506b156fe --- /dev/null +++ b/apps/builder/src/main.ts @@ -0,0 +1,71 @@ +import { setupLayouts } from 'virtual:generated-layouts' +import { routes } from 'vue-router/auto-routes' + +// Framework +import { createBreakpointsPlugin, createHydrationPlugin, createStoragePlugin, createThemePlugin, IN_BROWSER } from '@vuetify/v0' + +// Context +import App from './App.vue' + +// Utilities +import { createPinia } from 'pinia' +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' + +import 'virtual:uno.css' + +function getSystemTheme (): 'light' | 'dark' { + if (!IN_BROWSER) return 'light' + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +const app = createApp(App) + +app.use(createPinia()) +app.use(createRouter({ + history: createWebHistory(), + routes: setupLayouts(routes), +})) +app.use(createHydrationPlugin()) +app.use(createBreakpointsPlugin({ mobileBreakpoint: 768 })) +app.use(createStoragePlugin()) +app.use(createThemePlugin({ + default: getSystemTheme(), + target: 'html', + themes: { + light: { + dark: false, + colors: { + 'primary': '#3b82f6', + 'secondary': '#64748b', + 'accent': '#6366f1', + 'error': '#ef4444', + 'background': '#f5f5f5', + 'surface': '#ffffff', + 'surface-variant': '#f5f5f5', + 'divider': '#e0e0e0', + 'on-primary': '#ffffff', + 'on-surface': '#212121', + 'on-surface-variant': '#666666', + }, + }, + dark: { + dark: true, + colors: { + 'primary': '#c4b5fd', + 'secondary': '#94a3b8', + 'accent': '#c084fc', + 'error': '#f87171', + 'background': '#121212', + 'surface': '#1a1a1a', + 'surface-variant': '#1e1e1e', + 'divider': '#404040', + 'on-primary': '#1a1a1a', + 'on-surface': '#e0e0e0', + 'on-surface-variant': '#a0a0a0', + }, + }, + }, +})) + +app.mount('#app') diff --git a/apps/builder/src/pages/index.vue b/apps/builder/src/pages/index.vue new file mode 100644 index 000000000..8adcc9e84 --- /dev/null +++ b/apps/builder/src/pages/index.vue @@ -0,0 +1,40 @@ + + + diff --git a/apps/builder/src/pages/wizard.vue b/apps/builder/src/pages/wizard.vue new file mode 100644 index 000000000..bab080928 --- /dev/null +++ b/apps/builder/src/pages/wizard.vue @@ -0,0 +1,174 @@ + + + diff --git a/apps/builder/src/stores/builder.ts b/apps/builder/src/stores/builder.ts new file mode 100644 index 000000000..3cafe29c8 --- /dev/null +++ b/apps/builder/src/stores/builder.ts @@ -0,0 +1,51 @@ +import dependencyGraph from '@/data/dependencies.json' +import { resolve } from '@/engine/resolve' + +// Utilities +import { defineStore } from 'pinia' +import { shallowRef, toRef } from 'vue' + +// Types +import type { DependencyGraph } from '@/data/types' + +const graph = dependencyGraph as DependencyGraph + +export const useBuilderStore = defineStore('builder', () => { + const selected = shallowRef>(new Set()) + + const resolved = toRef(() => resolve([...selected.value], graph)) + + function select (id: string) { + if (selected.value.has(id)) return + selected.value = new Set([...selected.value, id]) + } + + function deselect (id: string) { + if (!selected.value.has(id)) return + const next = new Set(selected.value) + next.delete(id) + selected.value = next + } + + function toggle (id: string) { + selected.value.has(id) ? deselect(id) : select(id) + } + + function isSelected (id: string) { + return selected.value.has(id) + } + + function reset () { + selected.value = new Set() + } + + return { + selected, + resolved, + select, + deselect, + toggle, + isSelected, + reset, + } +}) diff --git a/apps/builder/src/typed-router.d.ts b/apps/builder/src/typed-router.d.ts new file mode 100644 index 000000000..2423473ed --- /dev/null +++ b/apps/builder/src/typed-router.d.ts @@ -0,0 +1,86 @@ +/* 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' +import type { + _ExtractParamParserType, +} from 'vue-router/experimental' + +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 + >, + '/wizard': RouteRecordInfo< + '/wizard', + '/wizard', + 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/wizard.vue': { + routes: + | '/wizard' + 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 {} From 97eb80e1c68b3755db90f750ada2b85dba0fea71 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 20 May 2026 10:54:15 -0500 Subject: [PATCH 04/34] chore(builder): add useStack and useRules to plugin catalog --- apps/builder/src/data/questions.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/builder/src/data/questions.ts b/apps/builder/src/data/questions.ts index 4927a491e..2256c4ce3 100644 --- a/apps/builder/src/data/questions.ts +++ b/apps/builder/src/data/questions.ts @@ -85,6 +85,13 @@ const COMPONENT_LIBRARY: QuestionCategory[] = [ feature: 'useLogger', category: 'infrastructure', }, + { + id: 'stack', + title: 'Stack', + description: 'Z-index management for overlays (Dialog, Drawer, Menu, Popover, Tooltip, Toast)', + feature: 'useStack', + category: 'infrastructure', + }, ], }, { @@ -129,6 +136,20 @@ const COMPONENT_LIBRARY: QuestionCategory[] = [ }, ], }, + { + id: 'forms', + title: 'Forms', + description: 'Validation and form-state management', + questions: [ + { + id: 'rules', + title: 'Rules', + description: 'Reusable validation rules (required, email, min, max, pattern, custom)', + feature: 'useRules', + category: 'forms', + }, + ], + }, ] const CATEGORIES: Record = { From 83a4d5ac9495aaac9515cfd376da86d21943a41e Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 20 May 2026 10:54:37 -0500 Subject: [PATCH 05/34] chore(builder): add canonical plugin order and route metadata --- apps/builder/src/data/plugins.ts | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/builder/src/data/plugins.ts diff --git a/apps/builder/src/data/plugins.ts b/apps/builder/src/data/plugins.ts new file mode 100644 index 000000000..7435ba10c --- /dev/null +++ b/apps/builder/src/data/plugins.ts @@ -0,0 +1,43 @@ +// apps/builder/src/data/plugins.ts + +// Types +import type { Component } from 'vue' + +export interface PluginMeta { + id: string + slug: string + title: string + category: string + hasConfig: boolean + loader: () => Promise<{ default: Component }> +} + +export const PLUGINS: PluginMeta[] = [ + // Appearance + { id: 'useTheme', slug: 'theme', title: 'Theme', category: 'appearance', hasConfig: true, loader: () => import('@/plugins/theme/ThemeConfig.vue') }, + { id: 'useBreakpoints', slug: 'breakpoints', title: 'Breakpoints', category: 'appearance', hasConfig: true, loader: () => import('@/plugins/breakpoints/BreakpointsConfig.vue') }, + // i18n + { id: 'useLocale', slug: 'locale', title: 'Locale', category: 'i18n', hasConfig: true, loader: () => import('@/plugins/locale/LocaleConfig.vue') }, + { id: 'useRtl', slug: 'rtl', title: 'Right-to-Left', category: 'i18n', hasConfig: true, loader: () => import('@/plugins/rtl/RtlConfig.vue') }, + // Infrastructure + { id: 'useStorage', slug: 'storage', title: 'Storage', category: 'infrastructure', hasConfig: true, loader: () => import('@/plugins/storage/StorageConfig.vue') }, + { id: 'useHydration', slug: 'hydration', title: 'SSR / SSG', category: 'infrastructure', hasConfig: false, loader: () => import('@/plugins/hydration/HydrationConfig.vue') }, + { id: 'useLogger', slug: 'logger', title: 'Logger', category: 'infrastructure', hasConfig: true, loader: () => import('@/plugins/logger/LoggerConfig.vue') }, + { id: 'useStack', slug: 'stack', title: 'Stack', category: 'infrastructure', hasConfig: false, loader: () => import('@/plugins/stack/StackConfig.vue') }, + // Access + { id: 'useFeatures', slug: 'features', title: 'Feature Flags', category: 'access', hasConfig: true, loader: () => import('@/plugins/features/FeaturesConfig.vue') }, + { id: 'usePermissions', slug: 'permissions', title: 'Permissions', category: 'access', hasConfig: true, loader: () => import('@/plugins/permissions/PermissionsConfig.vue') }, + // Utilities + { id: 'useDate', slug: 'date', title: 'Date', category: 'utilities', hasConfig: true, loader: () => import('@/plugins/date/DateConfig.vue') }, + { id: 'useNotifications', slug: 'notifications', title: 'Notifications', category: 'utilities', hasConfig: true, loader: () => import('@/plugins/notifications/NotificationsConfig.vue') }, + // Forms + { id: 'useRules', slug: 'rules', title: 'Rules', category: 'forms', hasConfig: true, loader: () => import('@/plugins/rules/RulesConfig.vue') }, +] + +export function getPluginById (id: string): PluginMeta | undefined { + return PLUGINS.find(p => p.id === id) +} + +export function getPluginBySlug (slug: string): PluginMeta | undefined { + return PLUGINS.find(p => p.slug === slug) +} From ed38c5565109d387d9d64faf438c4c6939fd097c Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 20 May 2026 10:55:18 -0500 Subject: [PATCH 06/34] chore(builder): extend store with pluginConfig, selectedComponents, componentConfig --- apps/builder/src/pages/wizard.vue | 14 ++--- apps/builder/src/stores/builder.ts | 86 +++++++++++++++++++++++------- 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/apps/builder/src/pages/wizard.vue b/apps/builder/src/pages/wizard.vue index bab080928..f1f648681 100644 --- a/apps/builder/src/pages/wizard.vue +++ b/apps/builder/src/pages/wizard.vue @@ -63,20 +63,20 @@ v-for="question in category.questions" :key="question.id" class="p-4 rounded-lg border text-left transition-all" - :class="store.isSelected(question.feature) + :class="store.isPluginSelected(question.feature) ? 'border-primary bg-primary/5 ring-1 ring-primary/20' : 'border-divider bg-surface hover:border-on-surface-variant/40'" - :data-selected="store.isSelected(question.feature) || undefined" - @click="store.toggle(question.feature)" + :data-selected="store.isPluginSelected(question.feature) || undefined" + @click="store.togglePlugin(question.feature)" >

{{ question.title }}

- +
@@ -90,12 +90,12 @@
- {{ store.selected.size }} {{ store.selected.size === 1 ? 'plugin' : 'plugins' }} selected + {{ store.selectedPlugins.size }} {{ store.selectedPlugins.size === 1 ? 'plugin' : 'plugins' }} selected