From 51751afb760c0be1941483edcc18b6a2e7174a67 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 17:34:17 -0400 Subject: [PATCH 01/78] feat: playground --- packages/cli/build.config.ts | 2 +- packages/cli/package.json | 10 + packages/cli/src/commands/init.ts | 11 +- packages/cli/src/lib.ts | 15 + packages/cli/src/utils/constants.ts | 42 +++ packages/cli/src/utils/plugin-routes.ts | 5 + packages/cli/src/utils/render-template.ts | 3 + .../src/plugins/route-docs/client/index.ts | 3 + .../src/plugins/route-docs/client/plugin.tsx | 57 ++++ playground/next-env.d.ts | 6 + playground/next.config.mjs | 9 + playground/package.json | 27 ++ playground/postcss.config.mjs | 5 + playground/src/app/actions.ts | 47 +++ playground/src/app/globals.css | 1 + playground/src/app/layout.tsx | 22 ++ playground/src/app/page.tsx | 63 ++++ .../src/components/playground-client.tsx | 291 ++++++++++++++++++ playground/src/components/plugin-selector.tsx | 110 +++++++ playground/src/components/route-list.tsx | 82 +++++ .../src/components/stackblitz-embed.tsx | 138 +++++++++ playground/src/lib/stackblitz-template.ts | 187 +++++++++++ playground/tsconfig.json | 31 ++ pnpm-lock.yaml | 51 ++- pnpm-workspace.yaml | 1 + tsconfig.json | 2 +- 26 files changed, 1215 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/lib.ts create mode 100644 packages/cli/src/utils/plugin-routes.ts create mode 100644 playground/next-env.d.ts create mode 100644 playground/next.config.mjs create mode 100644 playground/package.json create mode 100644 playground/postcss.config.mjs create mode 100644 playground/src/app/actions.ts create mode 100644 playground/src/app/globals.css create mode 100644 playground/src/app/layout.tsx create mode 100644 playground/src/app/page.tsx create mode 100644 playground/src/components/playground-client.tsx create mode 100644 playground/src/components/plugin-selector.tsx create mode 100644 playground/src/components/route-list.tsx create mode 100644 playground/src/components/stackblitz-embed.tsx create mode 100644 playground/src/lib/stackblitz-template.ts create mode 100644 playground/tsconfig.json diff --git a/packages/cli/build.config.ts b/packages/cli/build.config.ts index c4e7b6ab..335ae60a 100644 --- a/packages/cli/build.config.ts +++ b/packages/cli/build.config.ts @@ -4,7 +4,7 @@ export default defineBuildConfig({ declaration: true, clean: true, outDir: "dist", - entries: ["./src/index.ts"], + entries: ["./src/index.ts", "./src/lib.ts"], rollup: { emitCJS: true, esbuild: { diff --git a/packages/cli/package.json b/packages/cli/package.json index b4d0775d..f7cf332f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,6 +24,16 @@ "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./lib": { + "import": "./dist/lib.mjs", + "require": "./dist/lib.cjs" + } + }, "files": [ "dist", "src", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4b3eb3b8..2a109c8e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -15,6 +15,7 @@ import { ADAPTERS, DEFAULT_PLUGIN_SELECTION, PLUGINS, + PLUGIN_ROUTES, } from "../utils/constants"; import { detectAlias } from "../utils/detect-alias"; import { detectCssFile } from "../utils/detect-css-file"; @@ -346,12 +347,20 @@ export function createInitCommand() { ? "yes" : "manual action may be needed"; + const scaffoldedRoutes = selectedPlugins.flatMap( + (p) => PLUGIN_ROUTES[p] ?? [], + ); + const routesList = + scaffoldedRoutes.length > 0 + ? `\nAvailable routes:\n${scaffoldedRoutes.map((r) => ` ${r}`).join("\n")}\n` + : ""; + outro(`BTST init complete. Files written: ${writeResult.written.length} Files skipped: ${writeResult.skipped.length} CSS updated: ${cssPatch.updated ? "yes" : "no"} Layout patched: ${layoutStatus} - +${routesList} Next steps: - Verify routes under /pages/* - Run your build diff --git a/packages/cli/src/lib.ts b/packages/cli/src/lib.ts new file mode 100644 index 00000000..5a8dbf6b --- /dev/null +++ b/packages/cli/src/lib.ts @@ -0,0 +1,15 @@ +/** + * Programmatic API for @btst/codegen scaffold utilities. + * Use this when consuming the CLI as a library (e.g. in the playground). + */ +export { buildScaffoldPlan } from "./utils/scaffold-plan"; +export { PLUGINS, ADAPTERS } from "./utils/constants"; +export { PLUGIN_ROUTES } from "./utils/plugin-routes"; +export type { + PluginKey, + Adapter, + Framework, + FileWritePlanItem, + ScaffoldPlan, +} from "./types"; +export type { PluginMeta, AdapterMeta } from "./utils/constants"; diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 377fdde7..970cf4a4 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -158,3 +158,45 @@ export const PLUGINS: readonly PluginMeta[] = [ ]; export const DEFAULT_PLUGIN_SELECTION: PluginKey[] = []; + +/** + * Maps each plugin key to the list of /pages/* route paths it registers. + * Paths are verified against each plugin's client/plugin.tsx createRoute() calls. + * All page routes are prefixed with /pages (matching siteBasePath="/pages"). + * Non-page routes (API-only plugins) are listed separately. + */ +export const PLUGIN_ROUTES: Record = { + blog: [ + "/pages/blog", + "/pages/blog/drafts", + "/pages/blog/new", + "/pages/blog/:slug/edit", + "/pages/blog/tag/:tagSlug", + "/pages/blog/:slug", + ], + "ai-chat": ["/pages/chat", "/pages/chat/:id"], + cms: [ + "/pages/cms", + "/pages/cms/:typeSlug", + "/pages/cms/:typeSlug/new", + "/pages/cms/:typeSlug/:id", + ], + "form-builder": [ + "/pages/forms", + "/pages/forms/new", + "/pages/forms/:id/edit", + "/pages/forms/:id/submissions", + ], + "ui-builder": [ + "/pages/ui-builder", + "/pages/ui-builder/new", + "/pages/ui-builder/:id/edit", + ], + kanban: ["/pages/kanban", "/pages/kanban/new", "/pages/kanban/:boardId"], + comments: ["/pages/comments/moderation", "/pages/comments"], + media: ["/pages/media"], + "route-docs": ["/pages/route-docs"], + /** open-api registers an API route, not a page route */ + "open-api": ["/api/data/reference"], + "better-auth-ui": ["/pages/auth", "/pages/account/settings", "/pages/org"], +}; diff --git a/packages/cli/src/utils/plugin-routes.ts b/packages/cli/src/utils/plugin-routes.ts new file mode 100644 index 00000000..a74f7c64 --- /dev/null +++ b/packages/cli/src/utils/plugin-routes.ts @@ -0,0 +1,5 @@ +/** + * Re-export PLUGIN_ROUTES from constants so it can be imported + * from either location without bundling issues. + */ +export { PLUGIN_ROUTES } from "./constants"; diff --git a/packages/cli/src/utils/render-template.ts b/packages/cli/src/utils/render-template.ts index 0f42de92..dd47d059 100644 --- a/packages/cli/src/utils/render-template.ts +++ b/packages/cli/src/utils/render-template.ts @@ -19,6 +19,9 @@ export async function renderTemplate( join(__dirname, "..", "templates"), join(__dirname, "..", "src", "templates"), join(__dirname, "src", "templates"), + // When bundled into dist/shared/, go up two levels to reach package root src/templates + join(__dirname, "..", "..", "src", "templates"), + join(__dirname, "..", "..", "templates"), ]; let source: string | null = null; diff --git a/packages/stack/src/plugins/route-docs/client/index.ts b/packages/stack/src/plugins/route-docs/client/index.ts index a513a656..e2647a97 100644 --- a/packages/stack/src/plugins/route-docs/client/index.ts +++ b/packages/stack/src/plugins/route-docs/client/index.ts @@ -4,4 +4,7 @@ export { ROUTE_DOCS_QUERY_KEY, generateSchema, getStoredContext, + getRegisteredRoutes, + useRegisteredRoutes, + type RegisteredRoute, } from "./plugin"; diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 178c6f92..1eabb6d1 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -42,6 +42,63 @@ export function getStoredContext(): ClientStackContext | null { return moduleStoredContext; } +/** + * A registered route entry + */ +export interface RegisteredRoute { + /** The route path pattern (e.g., "/blog/:slug") */ + path: string; + /** The plugin this route belongs to (e.g., "blog") */ + plugin: string; + /** The route key within the plugin (e.g., "detail") */ + key: string; +} + +/** + * Returns all registered client route paths from the stored ClientStackContext. + * The context is populated when `createStackClient` is called (i.e. on first render). + * Returns an empty array if called before the stack client has been initialised. + */ +export function getRegisteredRoutes(): RegisteredRoute[] { + if (!moduleStoredContext) return []; + const result: RegisteredRoute[] = []; + for (const [pluginKey, plugin] of Object.entries( + moduleStoredContext.plugins, + )) { + if (pluginKey === "routeDocs" || plugin.name === "route-docs") continue; + try { + const routes = plugin.routes(moduleStoredContext); + for (const [routeKey, route] of Object.entries(routes)) { + const path = (route as any)?.path; + if (path) { + result.push({ + path, + plugin: plugin.name || pluginKey, + key: routeKey, + }); + } + } + } catch { + // silently skip plugins whose routes() throws during introspection + } + } + return result; +} + +/** + * React hook that returns all registered client route paths. + * Updates whenever the component mounts (after client hydration). + */ +export function useRegisteredRoutes(): RegisteredRoute[] { + const [routes, setRoutes] = useState(() => + getRegisteredRoutes(), + ); + useEffect(() => { + setRoutes(getRegisteredRoutes()); + }, []); + return routes; +} + /** * Generate the route docs schema from the stored context * This can be called from both server and client diff --git a/playground/next-env.d.ts b/playground/next-env.d.ts new file mode 100644 index 00000000..c4b7818f --- /dev/null +++ b/playground/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/playground/next.config.mjs b/playground/next.config.mjs new file mode 100644 index 00000000..874b24fd --- /dev/null +++ b/playground/next.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const config = { + reactStrictMode: true, + basePath: "/playground", + assetPrefix: "/playground", + serverExternalPackages: ["handlebars"], +}; + +export default config; diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 00000000..6736437d --- /dev/null +++ b/playground/package.json @@ -0,0 +1,27 @@ +{ + "name": "btst-playground", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "next build", + "dev": "next dev --port 3002", + "start": "next start --port 3002", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btst/codegen": "workspace:*", + "@stackblitz/sdk": "^1.9.0", + "next": "16.0.10", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.10", + "@types/node": "^24.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.10", + "typescript": "^5.8.3" + } +} diff --git a/playground/postcss.config.mjs b/playground/postcss.config.mjs new file mode 100644 index 00000000..017b34b9 --- /dev/null +++ b/playground/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/playground/src/app/actions.ts b/playground/src/app/actions.ts new file mode 100644 index 00000000..d0a42cd5 --- /dev/null +++ b/playground/src/app/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { buildScaffoldPlan, PLUGINS, PLUGIN_ROUTES } from "@btst/codegen/lib"; +import type { PluginKey, FileWritePlanItem } from "@btst/codegen/lib"; + +export interface GenerateResult { + files: FileWritePlanItem[]; + routes: string[]; + cssImports: string[]; +} + +export async function generateProject( + plugins: PluginKey[], +): Promise { + // ui-builder requires cms + const selectedPlugins: PluginKey[] = + plugins.includes("ui-builder") && !plugins.includes("cms") + ? ["cms", ...plugins] + : plugins; + + // Always include route-docs so users can see all available routes + const withRouteDocs: PluginKey[] = selectedPlugins.includes("route-docs") + ? selectedPlugins + : [...selectedPlugins, "route-docs"]; + + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: withRouteDocs, + alias: "@/", + cssFile: "app/globals.css", + }); + + const cssImports = PLUGINS.filter((p) => + withRouteDocs.includes(p.key as PluginKey), + ) + .map((p) => p.cssImport) + .filter((c): c is string => Boolean(c)); + + const routes = withRouteDocs.flatMap((p) => PLUGIN_ROUTES[p] ?? []); + + return { + files: plan.files, + routes, + cssImports, + }; +} diff --git a/playground/src/app/globals.css b/playground/src/app/globals.css new file mode 100644 index 00000000..f1d8c73c --- /dev/null +++ b/playground/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/playground/src/app/layout.tsx b/playground/src/app/layout.tsx new file mode 100644 index 00000000..420be6b4 --- /dev/null +++ b/playground/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: { + default: "BTST Playground", + template: "%s | BTST Playground", + }, + description: + "Build a BTST project in your browser — select plugins and see them live in a StackBlitz WebContainer.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/playground/src/app/page.tsx b/playground/src/app/page.tsx new file mode 100644 index 00000000..5cf09f18 --- /dev/null +++ b/playground/src/app/page.tsx @@ -0,0 +1,63 @@ +import { PlaygroundClient } from "@/components/playground-client"; +import { PLUGINS } from "@btst/codegen/lib"; + +export default function PlaygroundPage() { + return ( +
+ {/* Header */} +
+
+
+
+ BTST + / + + Playground + +
+ + WebContainer + +
+ +
+
+ + {/* Main */} +
+ +
+ + {/* Footer */} +
+ Powered by{" "} + + BTST + {" "} + · StackBlitz WebContainer +
+
+ ); +} diff --git a/playground/src/components/playground-client.tsx b/playground/src/components/playground-client.tsx new file mode 100644 index 00000000..e8984166 --- /dev/null +++ b/playground/src/components/playground-client.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useState, useTransition, useCallback } from "react"; +import type { + PluginMeta, + PluginKey, + FileWritePlanItem, +} from "@btst/codegen/lib"; +import { generateProject } from "@/app/actions"; +import { PluginSelector } from "./plugin-selector"; +import { RouteList } from "./route-list"; +import { StackBlitzEmbed } from "./stackblitz-embed"; + +interface PlaygroundClientProps { + plugins: readonly PluginMeta[]; +} + +type View = "configure" | "preview"; + +interface GeneratedState { + files: FileWritePlanItem[]; + routes: string[]; + cssImports: string[]; +} + +export function PlaygroundClient({ plugins }: PlaygroundClientProps) { + const [selected, setSelected] = useState(["blog"]); + const [view, setView] = useState("configure"); + const [generated, setGenerated] = useState(null); + const [isPending, startTransition] = useTransition(); + + const handleLaunch = useCallback(() => { + startTransition(async () => { + const result = await generateProject(selected); + setGenerated(result); + setView("preview"); + }); + }, [selected]); + + const handleBack = useCallback(() => { + setView("configure"); + }, []); + + // Preview routes are either from the generated state or derived from selection + const previewRoutes = generated?.routes ?? []; + + return ( +
+ {view === "configure" ? ( + <> + {/* Hero */} +
+

+ Build a BTST project + in your browser +

+

+ Select the plugins you want, then launch a live preview powered by + StackBlitz WebContainers — no install required. +

+
+ + {/* Two-column layout: selector + route preview */} +
+ {/* Plugin selector */} +
+
+
+
+

Select plugins

+

+ {selected.length} selected · route-docs always included +

+
+
+ + | + +
+
+ +
+
+ + {/* Route preview sidebar */} +
+
+

+ Available routes +

+ +
+ + {/* Launch button */} + + + {/* CLI hint */} +
+

+ Or scaffold locally with the CLI: +

+ + npx @btst/codegen@latest init + +
+
+
+ + ) : ( + <> + {/* Preview header */} +
+
+

Live preview

+

+ Running in StackBlitz WebContainer · next dev +

+
+
+ +
+
+ + {/* Embed + route list */} +
+ {/* StackBlitz embed */} +
+ {generated && ( + + )} +
+ + {/* Route list sidebar */} +
+

Available routes

+ +
+

+ Navigate inside the preview to: +

+ + /pages/route-docs + +

+ to see all routes live. +

+
+
+
+ + )} +
+ ); +} + +// Derive routes from the current plugin selection before generating +function getRoutesForSelection( + selected: PluginKey[], + plugins: readonly PluginMeta[], +): string[] { + // Inline the route map here so it works client-side without the server action + const ROUTES: Record = { + blog: [ + "/pages/blog", + "/pages/blog/drafts", + "/pages/blog/new", + "/pages/blog/:slug/edit", + "/pages/blog/tag/:tagSlug", + "/pages/blog/:slug", + ], + "ai-chat": ["/pages/chat", "/pages/chat/:id"], + cms: [ + "/pages/cms", + "/pages/cms/:typeSlug", + "/pages/cms/:typeSlug/new", + "/pages/cms/:typeSlug/:id", + ], + "form-builder": [ + "/pages/forms", + "/pages/forms/new", + "/pages/forms/:id/edit", + "/pages/forms/:id/submissions", + ], + "ui-builder": [ + "/pages/ui-builder", + "/pages/ui-builder/new", + "/pages/ui-builder/:id/edit", + ], + kanban: ["/pages/kanban", "/pages/kanban/new", "/pages/kanban/:boardId"], + comments: ["/pages/comments/moderation", "/pages/comments"], + media: ["/pages/media"], + "route-docs": ["/pages/route-docs"], + "open-api": ["/api/data/reference"], + "better-auth-ui": ["/pages/auth", "/pages/account/settings", "/pages/org"], + }; + + const withRouteDocs = selected.includes("route-docs") + ? selected + : [...selected, "route-docs" as PluginKey]; + return withRouteDocs.flatMap((p) => ROUTES[p] ?? []); +} diff --git a/playground/src/components/plugin-selector.tsx b/playground/src/components/plugin-selector.tsx new file mode 100644 index 00000000..e9aa9cf0 --- /dev/null +++ b/playground/src/components/plugin-selector.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { PluginMeta } from "@btst/codegen/lib"; +import type { PluginKey } from "@btst/codegen/lib"; + +interface PluginSelectorProps { + plugins: readonly PluginMeta[]; + selected: PluginKey[]; + onChange: (plugins: PluginKey[]) => void; + disabled?: boolean; +} + +const PLUGIN_DESCRIPTIONS: Record = { + blog: "Full-featured blog with posts, drafts, and tags", + "ai-chat": "AI chat interface with conversation history", + cms: "Content management system with custom content types", + "form-builder": "Drag-and-drop form builder with submissions", + "ui-builder": "Visual page builder (requires CMS)", + kanban: "Kanban board with drag-and-drop columns and cards", + comments: "Nested comments with moderation dashboard", + media: "Media library with file upload and management", + "route-docs": "Auto-generated route documentation page", + "open-api": "OpenAPI spec endpoint at /api/data/reference", + "better-auth-ui": "Authentication UI (sign in, account, org)", +}; + +export function PluginSelector({ + plugins, + selected, + onChange, + disabled, +}: PluginSelectorProps) { + function toggle(key: PluginKey) { + if (selected.includes(key)) { + onChange(selected.filter((k) => k !== key)); + } else { + onChange([...selected, key]); + } + } + + return ( +
+ {plugins.map((plugin) => { + const isSelected = selected.includes(plugin.key as PluginKey); + const isRouteDocs = plugin.key === "route-docs"; + return ( + + ); + })} +
+ ); +} diff --git a/playground/src/components/route-list.tsx b/playground/src/components/route-list.tsx new file mode 100644 index 00000000..a5ae74b1 --- /dev/null +++ b/playground/src/components/route-list.tsx @@ -0,0 +1,82 @@ +"use client"; + +interface RouteListProps { + routes: string[]; +} + +const ROUTE_ICONS: Record = { + "/pages/blog": "📝", + "/pages/chat": "💬", + "/pages/cms": "📁", + "/pages/forms": "📋", + "/pages/kanban": "🗂️", + "/pages/media": "🖼️", + "/pages/comments": "💬", + "/pages/route-docs": "📚", + "/pages/ui-builder": "🎨", + "/api/data/reference": "🔌", +}; + +function getIcon(route: string): string { + for (const [prefix, icon] of Object.entries(ROUTE_ICONS)) { + if (route.startsWith(prefix)) return icon; + } + if (route.startsWith("/api/")) return "🔌"; + return "📄"; +} + +function isApiRoute(route: string): boolean { + return route.startsWith("/api/"); +} + +export function RouteList({ routes }: RouteListProps) { + if (routes.length === 0) { + return ( +

+ Select plugins above to see available routes. +

+ ); + } + + const pageRoutes = routes.filter((r) => !isApiRoute(r)); + const apiRoutes = routes.filter((r) => isApiRoute(r)); + + return ( +
+ {pageRoutes.length > 0 && ( +
+

+ Page Routes +

+
    + {pageRoutes.map((route) => ( +
  • + {getIcon(route)} + + {route} + +
  • + ))} +
+
+ )} + {apiRoutes.length > 0 && ( +
+

+ API Routes +

+
    + {apiRoutes.map((route) => ( +
  • + {getIcon(route)} + + {route} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx new file mode 100644 index 00000000..0422873d --- /dev/null +++ b/playground/src/components/stackblitz-embed.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { FileWritePlanItem } from "@btst/codegen/lib"; +import { buildProjectFiles, toSdkFiles } from "@/lib/stackblitz-template"; + +interface StackBlitzEmbedProps { + generatedFiles: FileWritePlanItem[]; + cssImports: string[]; +} + +export function StackBlitzEmbed({ + generatedFiles, + cssImports, +}: StackBlitzEmbedProps) { + const containerRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const vmRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + let cancelled = false; + + async function embed() { + setLoading(true); + setError(null); + + try { + // Dynamically import StackBlitz SDK (client-only) + const sdk = await import("@stackblitz/sdk"); + + if (cancelled || !containerRef.current) return; + + const projectFiles = buildProjectFiles(generatedFiles, cssImports); + const sdkFiles = toSdkFiles(projectFiles); + + // Destroy previous VM if any + if (vmRef.current) { + try { + vmRef.current = null; + } catch {} + } + + // Clear container + containerRef.current.innerHTML = ""; + + const vm = await sdk.default.embedProject( + containerRef.current, + { + title: "BTST Playground Demo", + description: "Generated by BTST Playground", + template: "node", + files: sdkFiles, + }, + { + height: "100%", + hideNavigation: false, + openFile: "app/page.tsx", + terminalHeight: 30, + startScript: "dev", + theme: "dark", + }, + ); + + if (!cancelled) { + vmRef.current = vm; + setLoading(false); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : "Failed to load StackBlitz embed", + ); + setLoading(false); + } + } + } + + embed(); + + return () => { + cancelled = true; + }; + }, [generatedFiles, cssImports]); + + return ( +
+ {loading && ( +
+ + + + +

Starting WebContainer…

+

+ This may take 30–60 seconds on first load +

+
+ )} + {error && ( +
+

+ Failed to load StackBlitz +

+

{error}

+

+ Make sure you have cross-origin isolation headers enabled, or try + opening in Chrome. +

+
+ )} +
+
+ ); +} diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts new file mode 100644 index 00000000..b021906c --- /dev/null +++ b/playground/src/lib/stackblitz-template.ts @@ -0,0 +1,187 @@ +import type { FileWritePlanItem } from "@btst/codegen/lib"; + +export interface ProjectFile { + content: string; +} + +export type ProjectFiles = Record; + +/** + * Build the complete StackBlitz project file tree. + * + * The base skeleton is a minimal Next.js app wired with the memory adapter. + * The generated files from buildScaffoldPlan are merged on top (overwriting + * any skeleton file with the same path). + */ +export function buildProjectFiles( + generatedFiles: FileWritePlanItem[], + cssImports: string[], +): ProjectFiles { + const cssImportLines = cssImports.map((c) => `@import "${c}";`).join("\n"); + + const files: ProjectFiles = { + // ── package.json ──────────────────────────────────────────────────────── + "package.json": { + content: JSON.stringify( + { + name: "btst-playground-demo", + version: "0.0.0", + private: true, + scripts: { + dev: "next dev", + build: "next build", + start: "next start", + }, + dependencies: { + "@btst/stack": "latest", + "@btst/adapter-memory": "latest", + "@tanstack/react-query": "^5.0.0", + next: "15.3.4", + react: "^19.0.0", + "react-dom": "^19.0.0", + }, + devDependencies: { + "@tailwindcss/postcss": "^4.1.10", + "@types/node": "^24.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + postcss: "^8.5.0", + tailwindcss: "^4.1.10", + typescript: "^5.8.0", + }, + }, + null, + 2, + ), + }, + + // ── next.config.ts ─────────────────────────────────────────────────────── + "next.config.ts": { + content: `import type { NextConfig } from "next" + +const config: NextConfig = { + reactStrictMode: true, +} + +export default config +`, + }, + + // ── tsconfig.json ──────────────────────────────────────────────────────── + "tsconfig.json": { + content: JSON.stringify( + { + compilerOptions: { + baseUrl: ".", + target: "ESNext", + lib: ["dom", "dom.iterable", "esnext"], + allowJs: true, + skipLibCheck: true, + strict: true, + forceConsistentCasingInFileNames: true, + noEmit: true, + esModuleInterop: true, + module: "esnext", + moduleResolution: "bundler", + resolveJsonModule: true, + isolatedModules: true, + jsx: "preserve", + incremental: true, + paths: { "@/*": ["./*"] }, + plugins: [{ name: "next" }], + }, + include: [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ], + exclude: ["node_modules"], + }, + null, + 2, + ), + }, + + // ── postcss.config.mjs ────────────────────────────────────────────────── + "postcss.config.mjs": { + content: `export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} +`, + }, + + // ── app/globals.css ────────────────────────────────────────────────────── + "app/globals.css": { + content: `@import "tailwindcss"; +${cssImportLines ? `\n${cssImportLines}\n` : ""}`, + }, + + // ── app/layout.tsx ─────────────────────────────────────────────────────── + "app/layout.tsx": { + content: `import "./globals.css" +import type { ReactNode } from "react" + +export const metadata = { title: "BTST Playground" } + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} +`, + }, + + // ── app/page.tsx ───────────────────────────────────────────────────────── + "app/page.tsx": { + content: `import Link from "next/link" + +export default function Home() { + return ( +
+

+ BTST Playground +

+

+ A demo project generated by the{" "} + + BTST Playground + + . Navigate to a plugin route below to see it in action. +

+ +
+ ) +} +`, + }, + }; + + // Merge generated files on top of the skeleton + for (const file of generatedFiles) { + files[file.path] = { content: file.content }; + } + + return files; +} + +/** + * Convert project files to the format expected by @stackblitz/sdk embedProject() + */ +export function toSdkFiles(projectFiles: ProjectFiles): Record { + return Object.fromEntries( + Object.entries(projectFiles).map(([path, { content }]) => [path, content]), + ); +} diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 00000000..12d07021 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [{ "name": "next" }] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe92229..d05085d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -993,6 +993,46 @@ importers: specifier: 'catalog:' version: 5.9.3 + playground: + dependencies: + '@btst/codegen': + specifier: workspace:* + version: link:../packages/cli + '@stackblitz/sdk': + specifier: ^1.9.0 + version: 1.11.0 + next: + specifier: 16.0.10 + version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.10 + version: 4.1.17 + '@types/node': + specifier: ^24.0.0 + version: 24.10.1 + '@types/react': + specifier: ^19.2.2 + version: 19.2.6 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.3(@types/react@19.2.6) + postcss: + specifier: ^8.5.6 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.10 + version: 4.1.17 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages: '@ai-sdk/gateway@2.0.10': @@ -4207,6 +4247,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@stackblitz/sdk@1.11.0': + resolution: {integrity: sha512-DFQGANNkEZRzFk1/rDP6TcFdM82ycHE+zfl9C/M/jXlH68jiqHWHFMQURLELoD8koxvu/eW5uhg94NSAZlYrUQ==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -14616,6 +14659,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stackblitz/sdk@1.11.0': {} + '@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -15498,7 +15543,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 20.19.25 + '@types/node': 24.10.1 '@types/hast@3.0.4': dependencies: @@ -15583,7 +15628,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 20.19.25 + '@types/node': 24.10.1 '@types/tinycolor2@1.4.6': {} @@ -16418,7 +16463,7 @@ snapshots: bun-types@1.3.2(@types/react@19.2.6): dependencies: - '@types/node': 20.19.25 + '@types/node': 24.10.1 '@types/react': 19.2.6 bytes@3.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 95a67539..00393864 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/** - docs + - playground - examples/* - e2e diff --git a/tsconfig.json b/tsconfig.json index fed1535e..aecc3527 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "noErrorTruncation": true, "types": ["node"] }, - "exclude": ["**/dist/**", "**/node_modules/**"] + "exclude": ["**/dist/**", "**/node_modules/**", "playground"] } From acfd7a83dac8001bb4e8923aa55adea1b43a5e77 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:04:48 -0400 Subject: [PATCH 02/78] refactor: improve StackBlitz embed functionality and project file handling, including new copy script for Tailwind compatibility --- .../src/components/stackblitz-embed.tsx | 250 +++++++++++------- playground/src/lib/stackblitz-template.ts | 78 +++++- 2 files changed, 216 insertions(+), 112 deletions(-) diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx index 0422873d..bc441228 100644 --- a/playground/src/components/stackblitz-embed.tsx +++ b/playground/src/components/stackblitz-embed.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useCallback } from "react"; +import type { EmbedOptions, Project } from "@stackblitz/sdk"; import type { FileWritePlanItem } from "@btst/codegen/lib"; import { buildProjectFiles, toSdkFiles } from "@/lib/stackblitz-template"; @@ -9,129 +10,178 @@ interface StackBlitzEmbedProps { cssImports: string[]; } +const EMBED_HEIGHT = 700; + export function StackBlitzEmbed({ generatedFiles, cssImports, }: StackBlitzEmbedProps) { const containerRef = useRef(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const vmRef = useRef(null); + const projectRef = useRef(null); useEffect(() => { - if (!containerRef.current) return; + const container = containerRef.current; + if (!container) return; let cancelled = false; - async function embed() { - setLoading(true); - setError(null); - - try { - // Dynamically import StackBlitz SDK (client-only) - const sdk = await import("@stackblitz/sdk"); - - if (cancelled || !containerRef.current) return; - - const projectFiles = buildProjectFiles(generatedFiles, cssImports); - const sdkFiles = toSdkFiles(projectFiles); - - // Destroy previous VM if any - if (vmRef.current) { - try { - vmRef.current = null; - } catch {} - } - - // Clear container - containerRef.current.innerHTML = ""; - - const vm = await sdk.default.embedProject( - containerRef.current, - { - title: "BTST Playground Demo", - description: "Generated by BTST Playground", - template: "node", - files: sdkFiles, - }, - { - height: "100%", - hideNavigation: false, - openFile: "app/page.tsx", - terminalHeight: 30, - startScript: "dev", - theme: "dark", - }, - ); - - if (!cancelled) { - vmRef.current = vm; - setLoading(false); - } - } catch (err) { - if (!cancelled) { - setError( - err instanceof Error - ? err.message - : "Failed to load StackBlitz embed", + const project: Project = { + title: "BTST Playground Demo", + description: "Generated by BTST Playground", + template: "node", + files: toSdkFiles(buildProjectFiles(generatedFiles, cssImports)), + }; + + projectRef.current = project; + + const embedOptions: EmbedOptions = { + height: EMBED_HEIGHT, + openFile: "app/page.tsx", + terminalHeight: 30, + theme: "dark", + showSidebar: false, + view: "default", + }; + + const log = (...args: unknown[]) => + console.log(`[StackBlitz ${new Date().toISOString()}]`, ...args); + + log("Starting embed — loading SDK..."); + + import("@stackblitz/sdk").then((mod) => { + if (cancelled || !containerRef.current) return; + + container.innerHTML = ""; + + // Create a fresh target for the SDK to replace with its iframe. + // Never pass containerRef.current itself — the SDK replaces the target + // element in the DOM, which would break React's reconciliation. + const target = document.createElement("div"); + container.appendChild(target); + + log("SDK loaded — calling embedProject", { + template: project.template, + fileCount: Object.keys(project.files ?? {}).length, + embedOptions, + }); + + mod.default + .embedProject(target, project, embedOptions) + .then((vm) => { + if (cancelled) return; + log( + "VM connected ✓ — WebContainers booting, starting diagnostics...", ); - setLoading(false); - } - } - } - embed(); + // Snapshot deps to confirm package.json was parsed correctly. + vm.getDependencies() + .then((deps) => log("Resolved dependencies:", deps)) + .catch((e) => log("getDependencies failed:", e)); + + // Poll preview URL until the dev server is up (or we give up). + // NOTE: SDK_GET_PREVIEW_URL_FAILURE is thrown when COEP prevents the + // SDK from reading the cross-origin preview iframe URL. This does NOT + // mean the VM died — continue polling through it. + let attempts = 0; + const maxAttempts = 60; // 5 min — Next.js install takes a while + const pollInterval = setInterval(async () => { + if (cancelled) { + clearInterval(pollInterval); + return; + } + attempts++; + try { + const url = await vm.preview.getUrl(); + log( + `Preview poll #${attempts}: url=${url ?? "null (server not ready yet)"}`, + ); + if (url) { + clearInterval(pollInterval); + log("Dev server is up ✓", { url }); + } else if (attempts >= maxAttempts) { + clearInterval(pollInterval); + log( + `Dev server never came up after ${maxAttempts} polls — likely crashed.`, + ); + vm.getFsSnapshot() + .then((files) => { + const paths = Object.keys(files ?? {}); + log("FS snapshot on crash:", { + totalFiles: paths.length, + hasNodeModules: paths.some((p) => + p.startsWith("node_modules/"), + ), + hasNextDir: paths.some((p) => p.startsWith(".next/")), + samplePaths: paths.slice(0, 20), + }); + }) + .catch((e) => log("getFsSnapshot failed:", e)); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("SDK_GET_PREVIEW_URL_FAILURE")) { + // COEP restricts reading cross-origin preview URL — VM is alive. + log( + `Preview poll #${attempts}: COEP blocks preview URL read (VM still alive)`, + ); + } else { + clearInterval(pollInterval); + log( + "Preview poll threw unexpected error — VM may have died:", + e, + ); + } + } + }, 5_000); + }) + .catch((err: unknown) => { + if (cancelled) return; + // VM timeout during boot is common — the iframe still renders. + // Log it anyway so we can distinguish timeout from a real error. + log("embedProject rejected (VM timeout or boot failure):", err); + }); + }); return () => { cancelled = true; + container.innerHTML = ""; + log("Embed unmounted / deps changed — cleaned up."); }; }, [generatedFiles, cssImports]); + const handleOpenInStackBlitz = useCallback(() => { + if (!projectRef.current) return; + import("@stackblitz/sdk").then((mod) => { + mod.default.openProject(projectRef.current!, { + openFile: "app/page.tsx", + }); + }); + }, []); + return ( -
- {loading && ( -
+
+
+
- )} - {error && ( -
-

- Failed to load StackBlitz -

-

{error}

-

- Make sure you have cross-origin isolation headers enabled, or try - opening in Chrome. -

-
- )} + Open in StackBlitz + +
); diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts index b021906c..5e2d724c 100644 --- a/playground/src/lib/stackblitz-template.ts +++ b/playground/src/lib/stackblitz-template.ts @@ -28,8 +28,11 @@ export function buildProjectFiles( version: "0.0.0", private: true, scripts: { - dev: "next dev", - build: "next build", + // copy-stack-src.mjs must run before next dev/build so Tailwind's + // WASM oxide scanner can find @btst/stack source outside node_modules. + // See: https://github.com/tailwindlabs/tailwindcss/issues/18418 + dev: "node copy-stack-src.mjs && next dev", + build: "node copy-stack-src.mjs && next build", start: "next start", }, dependencies: { @@ -37,17 +40,21 @@ export function buildProjectFiles( "@btst/adapter-memory": "latest", "@tanstack/react-query": "^5.0.0", next: "15.3.4", - react: "^19.0.0", - "react-dom": "^19.0.0", + react: "19.2.4", + "react-dom": "19.2.4", }, devDependencies: { - "@tailwindcss/postcss": "^4.1.10", - "@types/node": "^24.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - postcss: "^8.5.0", - tailwindcss: "^4.1.10", - typescript: "^5.8.0", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + postcss: "^8", + tailwindcss: "^4", + typescript: "^5", + }, + stackblitz: { + installDependencies: false, + startCommand: "pnpm install && pnpm dev", }, }, null, @@ -55,6 +62,47 @@ export function buildProjectFiles( ), }, + // ── copy-stack-src.mjs ─────────────────────────────────────────────────── + // Tailwind's WASM oxide scanner cannot traverse node_modules inside + // WebContainers (https://github.com/tailwindlabs/tailwindcss/issues/18418). + // This script copies @btst/stack/src outside node_modules so Tailwind + // can scan it. Mirrors the same script used in the demo projects. + "copy-stack-src.mjs": { + content: `#!/usr/bin/env node +import { cp, mkdir, rm } from "fs/promises"; +import { existsSync } from "fs"; + +const src = "node_modules/@btst/stack/src"; +const dest = "app/.btst-stack-src"; +const uiSrc = "node_modules/@btst/stack/dist/packages/ui"; +const uiDest = "app/.btst-stack-ui"; + +if (!existsSync(src)) { + console.log("[copy-stack-src] node_modules/@btst/stack/src not found, skipping"); + process.exit(0); +} + +await rm(dest, { recursive: true, force: true }); +await mkdir(dest, { recursive: true }); +await cp(src, dest, { recursive: true }); +console.log(\`[copy-stack-src] copied \${src} → \${dest}\`); + +if (existsSync(uiSrc)) { + await rm(uiDest, { recursive: true, force: true }); + await mkdir(uiDest, { recursive: true }); + await cp(uiSrc, uiDest, { recursive: true }); + console.log(\`[copy-stack-src] copied \${uiSrc} → \${uiDest}\`); +} +`, + }, + + // ── .npmrc ────────────────────────────────────────────────────────────── + // Needed in StackBlitz WebContainers: prevents native module build + // failures and engine version mismatch errors during npm install. + ".npmrc": { + content: `legacy-peer-deps=true\nengine-strict=false\n`, + }, + // ── next.config.ts ─────────────────────────────────────────────────────── "next.config.ts": { content: `import type { NextConfig } from "next" @@ -116,7 +164,13 @@ export default config // ── app/globals.css ────────────────────────────────────────────────────── "app/globals.css": { content: `@import "tailwindcss"; -${cssImportLines ? `\n${cssImportLines}\n` : ""}`, +${cssImportLines ? `\n${cssImportLines}\n` : ""} +/* WebContainers: Tailwind's WASM scanner can't traverse node_modules */ +/* (https://github.com/tailwindlabs/tailwindcss/issues/18418). */ +/* copy-stack-src.mjs copies @btst/stack source here before dev/build runs. */ +@source "./.btst-stack-src/**/*.{ts,tsx}"; +@source "./.btst-stack-ui/**/*.{ts,tsx}"; +`, }, // ── app/layout.tsx ─────────────────────────────────────────────────────── From e355eb7b4a211c4a14319c40f362493e21d11f9a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:04:59 -0400 Subject: [PATCH 03/78] feat: add custom headers for cross-origin isolation in Next.js configuration --- playground/next.config.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/playground/next.config.mjs b/playground/next.config.mjs index 874b24fd..f24f265c 100644 --- a/playground/next.config.mjs +++ b/playground/next.config.mjs @@ -4,6 +4,20 @@ const config = { basePath: "/playground", assetPrefix: "/playground", serverExternalPackages: ["handlebars"], + async headers() { + return [ + { + // COOP: same-origin + COEP: credentialless = cross-origin isolation. + // Required for SharedArrayBuffer, which WebContainers (template: "node") + // needs to boot. same-origin-allow-popups does NOT provide isolation. + source: "/(.*)", + headers: [ + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + { key: "Cross-Origin-Embedder-Policy", value: "credentialless" }, + ], + }, + ]; + }, }; export default config; From 28f0127cd77eb3f60e60b147fe5dc444a2ad9e2c Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:18:56 -0400 Subject: [PATCH 04/78] feat: enhance stackblitz-template to handle workspace-built plugins and improve error logging for missing source files --- playground/src/lib/stackblitz-template.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts index 5e2d724c..b07cd5cd 100644 --- a/playground/src/lib/stackblitz-template.ts +++ b/playground/src/lib/stackblitz-template.ts @@ -92,6 +92,21 @@ if (existsSync(uiSrc)) { await mkdir(uiDest, { recursive: true }); await cp(uiSrc, uiDest, { recursive: true }); console.log(\`[copy-stack-src] copied \${uiSrc} → \${uiDest}\`); +} else { + console.log(\`[copy-stack-src] \${uiSrc} not found, skipping\`); +} + +// When running inside the monorepo, workspace-built dist/plugins/ has +// @workspace/ui imports already inlined by postbuild.cjs. Overlay these +// files onto the npm-installed ones so plugin CSS stays self-contained. +// In StackBlitz/WebContainers this path won't exist, so this is a no-op. +const workspacePluginsDist = "../../packages/stack/dist/plugins"; +const npmPluginsDist = "node_modules/@btst/stack/dist/plugins"; +if (existsSync(workspacePluginsDist)) { + await cp(workspacePluginsDist, npmPluginsDist, { recursive: true }); + console.log( + \`[copy-stack-src] overlaid \${workspacePluginsDist} → \${npmPluginsDist}\`, + ); } `, }, From ba90e8f19e90722811278aab634e36778f3e73d6 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:32:21 -0400 Subject: [PATCH 05/78] feat: add active route preview functionality in PlaygroundClient and enhance RouteList component for route selection --- .../src/plugins/route-docs/client/plugin.tsx | 2 +- .../src/components/playground-client.tsx | 19 ++++++++++- playground/src/components/route-list.tsx | 27 ++++++++++++--- .../src/components/stackblitz-embed.tsx | 34 +++++++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 1eabb6d1..8ae76cfa 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -1,4 +1,4 @@ -import { lazy } from "react"; +import { lazy, useEffect, useState } from "react"; import { defineClientPlugin } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; diff --git a/playground/src/components/playground-client.tsx b/playground/src/components/playground-client.tsx index e8984166..5631afcf 100644 --- a/playground/src/components/playground-client.tsx +++ b/playground/src/components/playground-client.tsx @@ -27,22 +27,34 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { const [selected, setSelected] = useState(["blog"]); const [view, setView] = useState("configure"); const [generated, setGenerated] = useState(null); + const [activePreviewRoute, setActivePreviewRoute] = useState( + null, + ); const [isPending, startTransition] = useTransition(); const handleLaunch = useCallback(() => { startTransition(async () => { const result = await generateProject(selected); setGenerated(result); + const firstPageRoute = result.routes.find((route) => + route.startsWith("/pages/"), + ); + setActivePreviewRoute(firstPageRoute ?? null); setView("preview"); }); }, [selected]); const handleBack = useCallback(() => { setView("configure"); + setActivePreviewRoute(null); }, []); // Preview routes are either from the generated state or derived from selection const previewRoutes = generated?.routes ?? []; + const handlePreviewRouteClick = useCallback((route: string) => { + if (!route.startsWith("/pages/")) return; + setActivePreviewRoute(route); + }, []); return (
@@ -216,6 +228,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { )}
@@ -223,7 +236,11 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { {/* Route list sidebar */}

Available routes

- +

Navigate inside the preview to: diff --git a/playground/src/components/route-list.tsx b/playground/src/components/route-list.tsx index a5ae74b1..92842135 100644 --- a/playground/src/components/route-list.tsx +++ b/playground/src/components/route-list.tsx @@ -2,6 +2,8 @@ interface RouteListProps { routes: string[]; + onPageRouteClick?: (route: string) => void; + activePageRoute?: string | null; } const ROUTE_ICONS: Record = { @@ -29,7 +31,11 @@ function isApiRoute(route: string): boolean { return route.startsWith("/api/"); } -export function RouteList({ routes }: RouteListProps) { +export function RouteList({ + routes, + onPageRouteClick, + activePageRoute, +}: RouteListProps) { if (routes.length === 0) { return (

@@ -52,9 +58,22 @@ export function RouteList({ routes }: RouteListProps) { {pageRoutes.map((route) => (

  • {getIcon(route)} - - {route} - + {onPageRouteClick ? ( + + ) : ( + + {route} + + )}
  • ))} diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx index bc441228..39e9cc24 100644 --- a/playground/src/components/stackblitz-embed.tsx +++ b/playground/src/components/stackblitz-embed.tsx @@ -8,6 +8,7 @@ import { buildProjectFiles, toSdkFiles } from "@/lib/stackblitz-template"; interface StackBlitzEmbedProps { generatedFiles: FileWritePlanItem[]; cssImports: string[]; + previewPath?: string | null; } const EMBED_HEIGHT = 700; @@ -15,9 +16,16 @@ const EMBED_HEIGHT = 700; export function StackBlitzEmbed({ generatedFiles, cssImports, + previewPath, }: StackBlitzEmbedProps) { const containerRef = useRef(null); const projectRef = useRef(null); + const vmRef = useRef(null); + const previewPathRef = useRef(previewPath); + + useEffect(() => { + previewPathRef.current = previewPath; + }, [previewPath]); useEffect(() => { const container = containerRef.current; @@ -69,9 +77,21 @@ export function StackBlitzEmbed({ .embedProject(target, project, embedOptions) .then((vm) => { if (cancelled) return; + vmRef.current = vm; log( "VM connected ✓ — WebContainers booting, starting diagnostics...", ); + if (previewPathRef.current) { + vm.preview + .setUrl(previewPathRef.current) + .then(() => + log( + "Applied initial preview path request:", + previewPathRef.current, + ), + ) + .catch((e) => log("Initial preview path set failed:", e)); + } // Snapshot deps to confirm package.json was parsed correctly. vm.getDependencies() @@ -144,11 +164,25 @@ export function StackBlitzEmbed({ return () => { cancelled = true; + vmRef.current = null; container.innerHTML = ""; log("Embed unmounted / deps changed — cleaned up."); }; }, [generatedFiles, cssImports]); + useEffect(() => { + if (!previewPath || !vmRef.current) return; + const vm = vmRef.current; + vm.preview + .setUrl(previewPath) + .catch((e) => + console.log( + `[StackBlitz ${new Date().toISOString()}] Failed to set preview URL`, + { previewPath, error: e }, + ), + ); + }, [previewPath]); + const handleOpenInStackBlitz = useCallback(() => { if (!projectRef.current) return; import("@stackblitz/sdk").then((mod) => { From de0c24b2d3779c1d0d89e5098f539587ba261a68 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:34:34 -0400 Subject: [PATCH 06/78] refactor: streamline StackBlitz SDK loading and error handling in embed functionality --- .../src/components/stackblitz-embed.tsx | 216 +++++++++--------- 1 file changed, 114 insertions(+), 102 deletions(-) diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx index 39e9cc24..058300d7 100644 --- a/playground/src/components/stackblitz-embed.tsx +++ b/playground/src/components/stackblitz-embed.tsx @@ -56,111 +56,116 @@ export function StackBlitzEmbed({ log("Starting embed — loading SDK..."); - import("@stackblitz/sdk").then((mod) => { - if (cancelled || !containerRef.current) return; - - container.innerHTML = ""; - - // Create a fresh target for the SDK to replace with its iframe. - // Never pass containerRef.current itself — the SDK replaces the target - // element in the DOM, which would break React's reconciliation. - const target = document.createElement("div"); - container.appendChild(target); - - log("SDK loaded — calling embedProject", { - template: project.template, - fileCount: Object.keys(project.files ?? {}).length, - embedOptions, - }); + void import("@stackblitz/sdk") + .then((mod) => { + if (cancelled || !containerRef.current) return; + + container.innerHTML = ""; + + // Create a fresh target for the SDK to replace with its iframe. + // Never pass containerRef.current itself — the SDK replaces the target + // element in the DOM, which would break React's reconciliation. + const target = document.createElement("div"); + container.appendChild(target); + + log("SDK loaded — calling embedProject", { + template: project.template, + fileCount: Object.keys(project.files ?? {}).length, + embedOptions, + }); - mod.default - .embedProject(target, project, embedOptions) - .then((vm) => { - if (cancelled) return; - vmRef.current = vm; - log( - "VM connected ✓ — WebContainers booting, starting diagnostics...", - ); - if (previewPathRef.current) { - vm.preview - .setUrl(previewPathRef.current) - .then(() => - log( - "Applied initial preview path request:", - previewPathRef.current, - ), - ) - .catch((e) => log("Initial preview path set failed:", e)); - } - - // Snapshot deps to confirm package.json was parsed correctly. - vm.getDependencies() - .then((deps) => log("Resolved dependencies:", deps)) - .catch((e) => log("getDependencies failed:", e)); - - // Poll preview URL until the dev server is up (or we give up). - // NOTE: SDK_GET_PREVIEW_URL_FAILURE is thrown when COEP prevents the - // SDK from reading the cross-origin preview iframe URL. This does NOT - // mean the VM died — continue polling through it. - let attempts = 0; - const maxAttempts = 60; // 5 min — Next.js install takes a while - const pollInterval = setInterval(async () => { - if (cancelled) { - clearInterval(pollInterval); - return; + mod.default + .embedProject(target, project, embedOptions) + .then((vm) => { + if (cancelled) return; + vmRef.current = vm; + log( + "VM connected ✓ — WebContainers booting, starting diagnostics...", + ); + if (previewPathRef.current) { + vm.preview + .setUrl(previewPathRef.current) + .then(() => + log( + "Applied initial preview path request:", + previewPathRef.current, + ), + ) + .catch((e) => log("Initial preview path set failed:", e)); } - attempts++; - try { - const url = await vm.preview.getUrl(); - log( - `Preview poll #${attempts}: url=${url ?? "null (server not ready yet)"}`, - ); - if (url) { - clearInterval(pollInterval); - log("Dev server is up ✓", { url }); - } else if (attempts >= maxAttempts) { + + // Snapshot deps to confirm package.json was parsed correctly. + vm.getDependencies() + .then((deps) => log("Resolved dependencies:", deps)) + .catch((e) => log("getDependencies failed:", e)); + + // Poll preview URL until the dev server is up (or we give up). + // NOTE: SDK_GET_PREVIEW_URL_FAILURE is thrown when COEP prevents the + // SDK from reading the cross-origin preview iframe URL. This does NOT + // mean the VM died — continue polling through it. + let attempts = 0; + const maxAttempts = 60; // 5 min — Next.js install takes a while + const pollInterval = setInterval(async () => { + if (cancelled) { clearInterval(pollInterval); - log( - `Dev server never came up after ${maxAttempts} polls — likely crashed.`, - ); - vm.getFsSnapshot() - .then((files) => { - const paths = Object.keys(files ?? {}); - log("FS snapshot on crash:", { - totalFiles: paths.length, - hasNodeModules: paths.some((p) => - p.startsWith("node_modules/"), - ), - hasNextDir: paths.some((p) => p.startsWith(".next/")), - samplePaths: paths.slice(0, 20), - }); - }) - .catch((e) => log("getFsSnapshot failed:", e)); + return; } - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("SDK_GET_PREVIEW_URL_FAILURE")) { - // COEP restricts reading cross-origin preview URL — VM is alive. - log( - `Preview poll #${attempts}: COEP blocks preview URL read (VM still alive)`, - ); - } else { - clearInterval(pollInterval); + attempts++; + try { + const url = await vm.preview.getUrl(); log( - "Preview poll threw unexpected error — VM may have died:", - e, + `Preview poll #${attempts}: url=${url ?? "null (server not ready yet)"}`, ); + if (url) { + clearInterval(pollInterval); + log("Dev server is up ✓", { url }); + } else if (attempts >= maxAttempts) { + clearInterval(pollInterval); + log( + `Dev server never came up after ${maxAttempts} polls — likely crashed.`, + ); + vm.getFsSnapshot() + .then((files) => { + const paths = Object.keys(files ?? {}); + log("FS snapshot on crash:", { + totalFiles: paths.length, + hasNodeModules: paths.some((p) => + p.startsWith("node_modules/"), + ), + hasNextDir: paths.some((p) => p.startsWith(".next/")), + samplePaths: paths.slice(0, 20), + }); + }) + .catch((e) => log("getFsSnapshot failed:", e)); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("SDK_GET_PREVIEW_URL_FAILURE")) { + // COEP restricts reading cross-origin preview URL — VM is alive. + log( + `Preview poll #${attempts}: COEP blocks preview URL read (VM still alive)`, + ); + } else { + clearInterval(pollInterval); + log( + "Preview poll threw unexpected error — VM may have died:", + e, + ); + } } - } - }, 5_000); - }) - .catch((err: unknown) => { - if (cancelled) return; - // VM timeout during boot is common — the iframe still renders. - // Log it anyway so we can distinguish timeout from a real error. - log("embedProject rejected (VM timeout or boot failure):", err); - }); - }); + }, 5_000); + }) + .catch((err: unknown) => { + if (cancelled) return; + // VM timeout during boot is common — the iframe still renders. + // Log it anyway so we can distinguish timeout from a real error. + log("embedProject rejected (VM timeout or boot failure):", err); + }); + }) + .catch((err: unknown) => { + if (cancelled) return; + log("Failed to load StackBlitz SDK:", err); + }); return () => { cancelled = true; @@ -185,11 +190,18 @@ export function StackBlitzEmbed({ const handleOpenInStackBlitz = useCallback(() => { if (!projectRef.current) return; - import("@stackblitz/sdk").then((mod) => { - mod.default.openProject(projectRef.current!, { - openFile: "app/page.tsx", + void import("@stackblitz/sdk") + .then((mod) => { + mod.default.openProject(projectRef.current!, { + openFile: "app/page.tsx", + }); + }) + .catch((err: unknown) => { + console.error( + `[StackBlitz ${new Date().toISOString()}] Failed to load SDK`, + err, + ); }); - }); }, []); return ( From 6b5ae389a36958c754bb6af6d8daaefaf9c5141e Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 27 Mar 2026 12:08:16 -0400 Subject: [PATCH 07/78] feat: implement useRegisteredRoutes hook for retrieving registered client route paths --- .../skills/btst-client-plugin-dev/SKILL.md | 18 +++++++++++++++++ .../src/plugins/route-docs/client/hooks.ts | 20 +++++++++++++++++++ .../src/plugins/route-docs/client/index.ts | 2 +- .../src/plugins/route-docs/client/plugin.tsx | 16 +-------------- 4 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 packages/stack/src/plugins/route-docs/client/hooks.ts diff --git a/.agents/skills/btst-client-plugin-dev/SKILL.md b/.agents/skills/btst-client-plugin-dev/SKILL.md index b70c07da..5e10f7b9 100644 --- a/.agents/skills/btst-client-plugin-dev/SKILL.md +++ b/.agents/skills/btst-client-plugin-dev/SKILL.md @@ -11,6 +11,7 @@ description: Patterns for writing BTST client plugins inside the monorepo, inclu src/plugins/{name}/ client/ plugin.tsx ← defineClientPlugin entry + hooks.ts ← "use client" React hooks only components/ pages/ my-page.tsx ← wrapper: ComposedRoute + lazy import @@ -18,6 +19,22 @@ src/plugins/{name}/ query-keys.ts ← React Query key factory ``` +## Server/client module boundary + +`client/plugin.tsx` must stay import-safe on the server. Next.js (including SSG build) +can execute `createStackClient()` on the server, which calls each `*ClientPlugin()` +factory. If that module is marked `"use client"` or imports a client-only module, build +can fail with "Attempted to call ... from the server". + +Rules: + +- Do **not** add `"use client"` to `client/plugin.tsx`. +- Keep `client/plugin.tsx` free of React hooks (`useState`, `useEffect`, etc.). +- Put hook utilities in a separate client-only module (`client/hooks.ts`) with + `"use client"`, and re-export them from `client/index.ts`. +- UI components can remain client components as needed; only the plugin factory entry + must stay server-import-safe. + ## Route anatomy Each route returns exactly three things: @@ -110,6 +127,7 @@ type PluginOverrides = { - **Next.js Link href undefined** — use `href={href || "#"}` pattern. - **Suspense errors not caught** — add `if (error && !isFetching) throw error` in every suspense hook. - **Missing ComposedRoute wrapper** — without it, errors crash the entire app instead of hitting ErrorBoundary. +- **Client directive on `client/plugin.tsx`** — can break SSG/SSR when plugin factories are invoked server-side. ## Full code patterns diff --git a/packages/stack/src/plugins/route-docs/client/hooks.ts b/packages/stack/src/plugins/route-docs/client/hooks.ts new file mode 100644 index 00000000..dbf97426 --- /dev/null +++ b/packages/stack/src/plugins/route-docs/client/hooks.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getRegisteredRoutes, type RegisteredRoute } from "./plugin"; + +/** + * React hook that returns all registered client route paths. + * Updates whenever the component mounts (after client hydration). + */ +export function useRegisteredRoutes(): RegisteredRoute[] { + const [routes, setRoutes] = useState(() => + getRegisteredRoutes(), + ); + + useEffect(() => { + setRoutes(getRegisteredRoutes()); + }, []); + + return routes; +} diff --git a/packages/stack/src/plugins/route-docs/client/index.ts b/packages/stack/src/plugins/route-docs/client/index.ts index e2647a97..fbfa7e2b 100644 --- a/packages/stack/src/plugins/route-docs/client/index.ts +++ b/packages/stack/src/plugins/route-docs/client/index.ts @@ -5,6 +5,6 @@ export { generateSchema, getStoredContext, getRegisteredRoutes, - useRegisteredRoutes, type RegisteredRoute, } from "./plugin"; +export { useRegisteredRoutes } from "./hooks"; diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 8ae76cfa..38a4674c 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -1,4 +1,4 @@ -import { lazy, useEffect, useState } from "react"; +import { lazy } from "react"; import { defineClientPlugin } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; @@ -85,20 +85,6 @@ export function getRegisteredRoutes(): RegisteredRoute[] { return result; } -/** - * React hook that returns all registered client route paths. - * Updates whenever the component mounts (after client hydration). - */ -export function useRegisteredRoutes(): RegisteredRoute[] { - const [routes, setRoutes] = useState(() => - getRegisteredRoutes(), - ); - useEffect(() => { - setRoutes(getRegisteredRoutes()); - }, []); - return routes; -} - /** * Generate the route docs schema from the stored context * This can be called from both server and client From 1435d401179dce4211472cd00db6ca2d61c36897 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 27 Mar 2026 12:08:53 -0400 Subject: [PATCH 08/78] feat: add extraPackages support in project generation and enhance StackBlitz embed functionality --- playground/src/app/actions.ts | 9 ++++ .../src/components/playground-client.tsx | 53 ++++--------------- playground/src/components/plugin-selector.tsx | 9 ++-- .../src/components/stackblitz-embed.tsx | 8 ++- playground/src/lib/stackblitz-template.ts | 27 +++++++--- 5 files changed, 50 insertions(+), 56 deletions(-) diff --git a/playground/src/app/actions.ts b/playground/src/app/actions.ts index d0a42cd5..a10b068c 100644 --- a/playground/src/app/actions.ts +++ b/playground/src/app/actions.ts @@ -7,6 +7,7 @@ export interface GenerateResult { files: FileWritePlanItem[]; routes: string[]; cssImports: string[]; + extraPackages: string[]; } export async function generateProject( @@ -38,10 +39,18 @@ export async function generateProject( .filter((c): c is string => Boolean(c)); const routes = withRouteDocs.flatMap((p) => PLUGIN_ROUTES[p] ?? []); + const extraPackages = Array.from( + new Set( + PLUGINS.filter((p) => withRouteDocs.includes(p.key as PluginKey)).flatMap( + (p) => p.extraPackages ?? [], + ), + ), + ); return { files: plan.files, routes, cssImports, + extraPackages, }; } diff --git a/playground/src/components/playground-client.tsx b/playground/src/components/playground-client.tsx index 5631afcf..e085d99a 100644 --- a/playground/src/components/playground-client.tsx +++ b/playground/src/components/playground-client.tsx @@ -6,6 +6,7 @@ import type { PluginKey, FileWritePlanItem, } from "@btst/codegen/lib"; +import { PLUGIN_ROUTES } from "@btst/codegen/lib"; import { generateProject } from "@/app/actions"; import { PluginSelector } from "./plugin-selector"; import { RouteList } from "./route-list"; @@ -21,6 +22,7 @@ interface GeneratedState { files: FileWritePlanItem[]; routes: string[]; cssImports: string[]; + extraPackages: string[]; } export function PlaygroundClient({ plugins }: PlaygroundClientProps) { @@ -31,6 +33,9 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { null, ); const [isPending, startTransition] = useTransition(); + const selectedCount = selected.includes("route-docs") + ? selected.length + : selected.length + 1; const handleLaunch = useCallback(() => { startTransition(async () => { @@ -81,7 +86,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) {

    Select plugins

    - {selected.length} selected · route-docs always included + {selectedCount} selected · route-docs always included

    @@ -122,7 +127,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) {

    Available routes

    - +
    {/* Launch button */} @@ -228,6 +233,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { )} @@ -261,48 +267,9 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { } // Derive routes from the current plugin selection before generating -function getRoutesForSelection( - selected: PluginKey[], - plugins: readonly PluginMeta[], -): string[] { - // Inline the route map here so it works client-side without the server action - const ROUTES: Record = { - blog: [ - "/pages/blog", - "/pages/blog/drafts", - "/pages/blog/new", - "/pages/blog/:slug/edit", - "/pages/blog/tag/:tagSlug", - "/pages/blog/:slug", - ], - "ai-chat": ["/pages/chat", "/pages/chat/:id"], - cms: [ - "/pages/cms", - "/pages/cms/:typeSlug", - "/pages/cms/:typeSlug/new", - "/pages/cms/:typeSlug/:id", - ], - "form-builder": [ - "/pages/forms", - "/pages/forms/new", - "/pages/forms/:id/edit", - "/pages/forms/:id/submissions", - ], - "ui-builder": [ - "/pages/ui-builder", - "/pages/ui-builder/new", - "/pages/ui-builder/:id/edit", - ], - kanban: ["/pages/kanban", "/pages/kanban/new", "/pages/kanban/:boardId"], - comments: ["/pages/comments/moderation", "/pages/comments"], - media: ["/pages/media"], - "route-docs": ["/pages/route-docs"], - "open-api": ["/api/data/reference"], - "better-auth-ui": ["/pages/auth", "/pages/account/settings", "/pages/org"], - }; - +function getRoutesForSelection(selected: PluginKey[]): string[] { const withRouteDocs = selected.includes("route-docs") ? selected : [...selected, "route-docs" as PluginKey]; - return withRouteDocs.flatMap((p) => ROUTES[p] ?? []); + return withRouteDocs.flatMap((pluginKey) => PLUGIN_ROUTES[pluginKey] ?? []); } diff --git a/playground/src/components/plugin-selector.tsx b/playground/src/components/plugin-selector.tsx index e9aa9cf0..cc2b6cba 100644 --- a/playground/src/components/plugin-selector.tsx +++ b/playground/src/components/plugin-selector.tsx @@ -41,8 +41,9 @@ export function PluginSelector({ return (
    {plugins.map((plugin) => { - const isSelected = selected.includes(plugin.key as PluginKey); const isRouteDocs = plugin.key === "route-docs"; + const isSelected = + isRouteDocs || selected.includes(plugin.key as PluginKey); return (
    - {/* Two-column layout: selector + route preview */} -
    - {/* Plugin selector */} -
    -
    -
    -
    -

    Select plugins

    -

    - {selectedCount} selected · route-docs always included -

    -
    -
    - - | - -
    + {/* Plugin selector */} +
    +
    +
    +
    +

    Select plugins

    +

    + {selectedCount} selected · route-docs always included +

    +
    +
    + + | +
    -
    +
    +
    - {/* Route preview sidebar */} -
    -
    -

    - Available routes -

    - -
    + {/* Action row: routes drawer + launch button + CLI hint */} +
    + - {/* Launch button */} - + strokeWidth="4" + /> + + + Generating project… + + ) : ( + <> + + + + Open in Editor + + )} + - {/* CLI hint */} -
    -

    - Or scaffold locally with the CLI: -

    - - npx @btst/codegen@latest init - -
    + {/* CLI hint */} +
    + + CLI: + + + npx @btst/codegen@latest init +
    @@ -199,6 +195,24 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) {

    + +

    + Navigate inside the preview to: +

    + + /pages/route-docs + +

    + to see all routes live. +

    + + } + />
    - {/* Embed + route list */} -
    - {/* StackBlitz embed */} -
    - {generated && ( - - )} -
    - - {/* Route list sidebar */} -
    -

    Available routes

    - + {generated && ( + -
    -

    - Navigate inside the preview to: -

    - - /pages/route-docs - -

    - to see all routes live. -

    -
    -
    + )}
    )} @@ -267,7 +254,10 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { } // Derive routes from the current plugin selection before generating -function getRoutesForSelection(selected: PluginKey[]): string[] { +function getRoutesForSelection( + selected: PluginKey[], + pluginRoutes: Record, +): string[] { const selectedPlugins: PluginKey[] = selected.includes("ui-builder") && !selected.includes("cms") ? ["cms", ...selected] @@ -275,5 +265,5 @@ function getRoutesForSelection(selected: PluginKey[]): string[] { const withRouteDocs = selectedPlugins.includes("route-docs") ? selectedPlugins : [...selectedPlugins, "route-docs" as PluginKey]; - return withRouteDocs.flatMap((pluginKey) => PLUGIN_ROUTES[pluginKey] ?? []); + return withRouteDocs.flatMap((pluginKey) => pluginRoutes[pluginKey] ?? []); } diff --git a/playground/src/components/route-drawer.tsx b/playground/src/components/route-drawer.tsx new file mode 100644 index 00000000..178d15b3 --- /dev/null +++ b/playground/src/components/route-drawer.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { RouteList } from "./route-list"; + +function useIsDesktop() { + const [isDesktop, setIsDesktop] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(min-width: 640px)"); + setIsDesktop(mq.matches); + const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + return isDesktop; +} + +interface RouteDrawerProps { + routes: string[]; + onPageRouteClick?: (route: string) => void; + activePageRoute?: string | null; + footer?: React.ReactNode; +} + +export function RouteDrawer({ + routes, + onPageRouteClick, + activePageRoute, + footer, +}: RouteDrawerProps) { + const isDesktop = useIsDesktop(); + + return ( + + + + + + {isDesktop ? ( + /* Right panel on desktop — no handle bar */ + + + + Available routes + + + + + +
    + + {footer && ( +
    + {footer} +
    + )} +
    +
    + ) : ( + /* Bottom sheet on mobile — handle bar rendered by DrawerContent */ + + + + Available routes + + + + + +
    + + {footer && ( +
    + {footer} +
    + )} +
    +
    + )} +
    + ); +} diff --git a/playground/src/components/ui/drawer.tsx b/playground/src/components/ui/drawer.tsx new file mode 100644 index 00000000..1228ed08 --- /dev/null +++ b/playground/src/components/ui/drawer.tsx @@ -0,0 +1,121 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; +import { cn } from "@/lib/utils"; + +function Drawer({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; +const DrawerPortal = DrawerPrimitive.Portal; +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
    + {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +function DrawerHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ); +} +DrawerHeader.displayName = "DrawerHeader"; + +function DrawerFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ); +} +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts index 905e9183..c0070bc9 100644 --- a/playground/src/lib/stackblitz-template.ts +++ b/playground/src/lib/stackblitz-template.ts @@ -26,6 +26,7 @@ export function buildProjectFiles( next: "15.3.4", react: "19.2.4", "react-dom": "19.2.4", + "tw-animate-css": "latest", }; const pluginDependencies = Object.fromEntries( Array.from(new Set(extraPackages)).map((pkgName) => [pkgName, "latest"]), @@ -190,12 +191,137 @@ export default config // ── app/globals.css ────────────────────────────────────────────────────── "app/globals.css": { content: `@import "tailwindcss"; -${cssImportLines ? `\n${cssImportLines}\n` : ""} +@import "tw-animate-css"; +${cssImportLines ? `${cssImportLines}\n` : ""} + /* WebContainers: Tailwind's WASM scanner can't traverse node_modules */ /* (https://github.com/tailwindlabs/tailwindcss/issues/18418). */ /* copy-stack-src.mjs copies @btst/stack source here before dev/build runs. */ @source "./.btst-stack-src/**/*.{ts,tsx}"; @source "./.btst-stack-ui/**/*.{ts,tsx}"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} `, }, diff --git a/playground/src/lib/utils.ts b/playground/src/lib/utils.ts new file mode 100644 index 00000000..d682f5f1 --- /dev/null +++ b/playground/src/lib/utils.ts @@ -0,0 +1,3 @@ +export function cn(...inputs: (string | undefined | null | false | 0)[]) { + return inputs.filter(Boolean).join(" "); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d05085d5..4e23695f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1001,6 +1001,9 @@ importers: '@stackblitz/sdk': specifier: ^1.9.0 version: 1.11.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 next: specifier: 16.0.10 version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1010,6 +1013,12 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@tailwindcss/postcss': specifier: ^4.1.10 @@ -10042,6 +10051,12 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^19.2.0 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -22108,6 +22123,15 @@ snapshots: vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 From b861e1107f8369793b7741e6f78eb958f836e1a4 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Mon, 30 Mar 2026 16:44:18 -0400 Subject: [PATCH 11/78] docs: add shadcn ai skill --- .agents/skills/shadcn/SKILL.md | 242 ++++++++++++++ .agents/skills/shadcn/agents/openai.yml | 5 + .agents/skills/shadcn/assets/shadcn-small.png | Bin 0 -> 1049 bytes .agents/skills/shadcn/assets/shadcn.png | Bin 0 -> 3852 bytes .agents/skills/shadcn/cli.md | 257 +++++++++++++++ .agents/skills/shadcn/customization.md | 202 ++++++++++++ .agents/skills/shadcn/evals/evals.json | 47 +++ .agents/skills/shadcn/mcp.md | 94 ++++++ .agents/skills/shadcn/rules/base-vs-radix.md | 306 ++++++++++++++++++ .agents/skills/shadcn/rules/composition.md | 195 +++++++++++ .agents/skills/shadcn/rules/forms.md | 192 +++++++++++ .agents/skills/shadcn/rules/icons.md | 101 ++++++ .agents/skills/shadcn/rules/styling.md | 162 ++++++++++ skills-lock.json | 5 + 14 files changed, 1808 insertions(+) create mode 100644 .agents/skills/shadcn/SKILL.md create mode 100644 .agents/skills/shadcn/agents/openai.yml create mode 100644 .agents/skills/shadcn/assets/shadcn-small.png create mode 100644 .agents/skills/shadcn/assets/shadcn.png create mode 100644 .agents/skills/shadcn/cli.md create mode 100644 .agents/skills/shadcn/customization.md create mode 100644 .agents/skills/shadcn/evals/evals.json create mode 100644 .agents/skills/shadcn/mcp.md create mode 100644 .agents/skills/shadcn/rules/base-vs-radix.md create mode 100644 .agents/skills/shadcn/rules/composition.md create mode 100644 .agents/skills/shadcn/rules/forms.md create mode 100644 .agents/skills/shadcn/rules/icons.md create mode 100644 .agents/skills/shadcn/rules/styling.md diff --git a/.agents/skills/shadcn/SKILL.md b/.agents/skills/shadcn/SKILL.md new file mode 100644 index 00000000..39d1e481 --- /dev/null +++ b/.agents/skills/shadcn/SKILL.md @@ -0,0 +1,242 @@ +--- +name: shadcn +description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset". +user-invocable: false +allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) +--- + +# shadcn/ui + +A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI. + +> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project. + +## Current Project Context + +```json +!`npx shadcn@latest info --json` +``` + +The JSON above contains the project config and installed components. Use `npx shadcn@latest docs ` to get documentation and example URLs for any component. + +## Principles + +1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too. +2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table. +3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc. +4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`. + +## Critical Rules + +These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs. + +### Styling & Tailwind → [styling.md](./rules/styling.md) + +- **`className` for layout, not styling.** Never override component colors or typography. +- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`. +- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`. +- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`. +- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`). +- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries. +- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking. + +### Forms & Inputs → [forms.md](./rules/forms.md) + +- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout. +- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`. +- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.** +- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state. +- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading. +- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control. + +### Component Structure → [composition.md](./rules/composition.md) + +- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`. +- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md) +- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden. +- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`. +- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`. +- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`. +- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load. + +### Use Components, Not Custom Markup → [composition.md](./rules/composition.md) + +- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`. +- **Callouts use `Alert`.** Don't build custom styled divs. +- **Empty states use `Empty`.** Don't build custom empty state markup. +- **Toast via `sonner`.** Use `toast()` from `sonner`. +- **Use `Separator`** instead of `
    ` or `
    `. +- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs. +- **Use `Badge`** instead of custom styled spans. + +### Icons → [icons.md](./rules/icons.md) + +- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon. +- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`. +- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup. + +### CLI + +- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset `. + +## Key Patterns + +These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above. + +```tsx +// Form layout: FieldGroup + Field, not div + Label. + + + Email + + + + +// Validation: data-invalid on Field, aria-invalid on the control. + + Email + + Invalid email. + + +// Icons in buttons: data-icon, no sizing classes. + + +// Spacing: gap-*, not space-y-*. +
    // correct +
    // wrong + +// Equal dimensions: size-*, not w-* h-*. + // correct + // wrong + +// Status colors: Badge variants or semantic tokens, not raw colors. ++20.1% // correct ++20.1% // wrong +``` + +## Component Selection + +| Need | Use | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| Button/action | `Button` with appropriate variant | +| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` | +| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` | +| Data display | `Table`, `Card`, `Badge`, `Avatar` | +| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` | +| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) | +| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` | +| Command palette | `Command` inside `Dialog` | +| Charts | `Chart` (wraps Recharts) | +| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` | +| Empty states | `Empty` | +| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` | +| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` | + +## Key Fields + +The injected project context contains these key fields: + +- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode. +- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive. +- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`. +- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one. +- **`style`** → component visual treatment (e.g. `nova`, `vega`). +- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props. +- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`. +- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc. +- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA). +- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`). + +See [cli.md — `info` command](./cli.md) for the full field reference. + +## Component Docs, Examples, and Usage + +Run `npx shadcn@latest docs ` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content. + +```bash +npx shadcn@latest docs button dialog select +``` + +**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing. + +## Workflow + +1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh. +2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed. +3. **Find components** — `npx shadcn@latest search`. +4. **Get docs and examples** — run `npx shadcn@latest docs ` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`. +5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below). +6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project. +7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on. +8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user. +9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**? + - **Reinstall**: `npx shadcn@latest init --preset --force --reinstall`. Overwrites all components. + - **Merge**: `npx shadcn@latest init --preset --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually. + - **Skip**: `npx shadcn@latest init --preset --force --no-reinstall`. Only updates config and CSS, leaves components as-is. + - **Important**: Always run preset commands inside the user's project directory. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base ` explicitly — preset codes do not encode the base. + +## Updating Components + +When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.** + +1. Run `npx shadcn@latest add --dry-run` to see all files that would be affected. +2. For each file, run `npx shadcn@latest add --diff ` to see what changed upstream vs local. +3. Decide per file based on the diff: + - No local changes → safe to overwrite. + - Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications. + - User says "just update everything" → use `--overwrite`, but confirm first. +4. **Never use `--overwrite` without the user's explicit approval.** + +## Quick Reference + +```bash +# Create a new project. +npx shadcn@latest init --name my-app --preset base-nova +npx shadcn@latest init --name my-app --preset a2r6bw --template vite + +# Create a monorepo project. +npx shadcn@latest init --name my-app --preset base-nova --monorepo +npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo + +# Initialize existing project. +npx shadcn@latest init --preset base-nova +npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova + +# Add components. +npx shadcn@latest add button card dialog +npx shadcn@latest add @magicui/shimmer-button +npx shadcn@latest add --all + +# Preview changes before adding/updating. +npx shadcn@latest add button --dry-run +npx shadcn@latest add button --diff button.tsx +npx shadcn@latest add @acme/form --view button.tsx + +# Search registries. +npx shadcn@latest search @shadcn -q "sidebar" +npx shadcn@latest search @tailark -q "stats" + +# Get component docs and example URLs. +npx shadcn@latest docs button dialog select + +# View registry item details (for items not yet installed). +npx shadcn@latest view @shadcn/button +``` + +**Named presets:** `base-nova`, `radix-nova` +**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo) +**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com). + +## Detailed References + +- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states +- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading +- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects +- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index +- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion +- [cli.md](./cli.md) — Commands, flags, presets, templates +- [customization.md](./customization.md) — Theming, CSS variables, extending components diff --git a/.agents/skills/shadcn/agents/openai.yml b/.agents/skills/shadcn/agents/openai.yml new file mode 100644 index 00000000..ab636da8 --- /dev/null +++ b/.agents/skills/shadcn/agents/openai.yml @@ -0,0 +1,5 @@ +interface: + display_name: "shadcn/ui" + short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI." + icon_small: "./assets/shadcn-small.png" + icon_large: "./assets/shadcn.png" diff --git a/.agents/skills/shadcn/assets/shadcn-small.png b/.agents/skills/shadcn/assets/shadcn-small.png new file mode 100644 index 0000000000000000000000000000000000000000..547154b97f2298335159c23eec1dac0d147a1246 GIT binary patch literal 1049 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{x0%imor0saV%ts)_S>O>_%)r1c48n{Iv*t(uO^eJ7i71Ki^|4CM&(%vz$xlkv ztH>0+>{umUo3Q%e#RDspr3imfVamB1>jfNYSkzLEl1NlCV?QiN}Sf^&XRs)CuG zfu4bq9hZWFf=y9MnpKdC8&o@xXRDM^Qc_^0uU}qXu2*iXmtT~wZ)j<0sc&GUZ)Btk zRH0j3nOBlnp_^B%3^4>|j!SBBa#3bMNoIbY0?6FNr2NtnTO}osMQ{LdXGvxn!lt}p zsJDO~)CbAv8|oS8!_5Y2wE>A*`4?rT0&NDFZ)a!&R*518wZ}#uWI2*!AU*|)0=;U- zWup%dHajlKxQFb(K%V94;uvBfm>Yb$>yU#$kCbgiC(9RwrlwA(rsW(``(6H=zq@!* zUB_~Trk_lTdxPh$yxDTsVY&L;b+6{tRm^+d+gl^OQcCIBok=%y)Vhx@dR1ceL3r{0 z9=2nMXx8DxEkhL{v?(>~8{qDBkfB!u-iDB`| z-5d%Ae82YI%n8Zg9$mRN?o-|V)V*=5b-R>a>}P2{=pcK^B(&B-W{C#Z!Q%b*ueK>n zK6ymYLRneebCKDSzcqHEHFopo-mkH{zIkt2Z0^086Tko7%N)NxoZYSDM1jSUwPpuS zSeq*wzrC2D(tq4pZ#s9QP@Cd8Q^wW3*2cL_519?*;#4`B;+ei#o;C{Kkamm3Jooa; zE$3K-+c(6ShTjelW_B*W5o zcS6z34IR5C-SIF8m(lui^IOy!-~We?b1?PSGniCYR=ZyR^%<1lJzf1=);T3K0RU@N BSy=!8 literal 0 HcmV?d00001 diff --git a/.agents/skills/shadcn/assets/shadcn.png b/.agents/skills/shadcn/assets/shadcn.png new file mode 100644 index 0000000000000000000000000000000000000000..b7b6814acc25073e5f48099b1fd3f70c47bfb1c3 GIT binary patch literal 3852 zcmY*c2{@Ep*nY=Y1|x&SAZEs{m}!)<%wS9yONAB@M)rMZgo-eODf<#aqEe(qiK0X# zjeV<7Sz@e_J-dH=Uw_~KpZ7ZNbD!tj&;6X|T<3bv^oi2VQ{31CN!jQ|snzFQu@Q;0Du?| zfb$Q>hHZD6F}v@?{A7+^3dW)SVQ8`OjvI|a z{6nF7;ZWA*ClCgH0WJs)HH;bth37*c5IOtVpZ7;)e9lMRL5Xd-wU$;iu|t*(dB|ufCq)@;pdCk(RDoU7f8jS zP&Fw%scw{13LdO|DW$q z9v$>f^8d4#e=GgR%Ptkqr-S~xZFoMo{SXfTK;ub7{gc641VugDjPn7=AJBO!*mUKucc{7ey~li!vybaIkF z8OboYVX%y|2%P`k*m&i}s=eW8)0q&YJ<-thLrc)HZ?6uc7Fd!$(lWSvH;?Y^+|N2q z(lz!>Ip|<{_a4CnLN%GIxW|*Sl|;4c3?=Q>z5fWEEtp(&BzeAccRS{zf9!64+SAA{ zhe+CT7U8Ox1UUnilJqweOM{d}_w6-|yz}dA=-R7yHrxs|(MLR_`U=AC%0V#PpIuxs z`__xodV3Bl2ayP^m8isMu4iD<`=4UURtHBPsaLp81r-f3$(4vZbRPd}*>wTo@@?zp zjl1KkifF5p%-V<}MTk3`()wm7qcMd8<14WE>Y_YwjB;{Zt!@vJbH&pu!Ye!p{1kAj zlRg2pkl)WBbyj>3e<$)@mP?WW}W-c;_(UQ(}=MJLED9F^U*P1KWxu z1h@&s1?M0%ax_P5Qb>kkPKEH=OKGZn z&-@5MD^|kk?Hzkfz8h$8#{$$OjAyr!+uGtp>ns1^oS7Nlv6k$^tzTdIb%jUtI8F3t zL;LuqJ7-@4UI|Q)HN=Ap;dAmuZY4c&XphavC{kl z)mMY6L)S-94;V|YT3TA7U^;uvS0W!$W`JIb@kNAm0@IKVWJ@R_jgX6wlUd5V%pHIn;=Z~px z-B_MFLx+ldzTQUp5C zkXaznhH18TytzOgD%$iMnLiAtjvnHLkM;?P*3ZFb#cbY&ZF*|R;4V4$85DZMQ#T@| zxuMMPg?cl|hNJ$2S&%fLErMQSzCnzc_QGh3x@I8V&Omf&=_n+EaC}tft^`)^W5-=1 z0bbJ#wK8+=zNaz-V{U?NtJp&%IDd9-xIut--L}@gU{E>B&ym$vT2@wEZq- zzDqmDf)Cr-aZ-5KIYF!Elcq_X%?qXsg}Yhp=^bebcZ-Ucd$p+US;P}(@VrR4_fy6!qx?X)%SIi+LRsDR` zQ8+xRd{In4WLlVjoy5ElKt%?Ykr)cH*G}0^tE=$(#0W?l)_9MV89|sem>Y}t&?fGt zo3o0sdsG9?JhC1-KM}e*&zeb*H$7JoD&0z#=2sH725JmGV`WoVb86exi5p^Lm{B5BlZRd6(bKH`QmJ*u8#(j6IH1 zFf;Li5iqsV=V^irlzzwVJ$xXwz?I8y`i+t-g0?n77Ie}-fBp1{)&Dm1Yk^Fb=7rI4 zAD)SQRv9=Xr%G{qI!)}>rf;lzG))%89I~zF)R~&_Ur)%0>4+7bxbBknHT`^+jNFe%p=t<=q}3W1Y-u+ zi-cN{V#+HsEEB8UQsUt1uE991MvN%&V?(0Mcb~82H!ori&rLm~Xyf>i#u_t=rzHA3 zu?NRljnv7YU;T&V#(OVE$+=^@kaXvs%C3f78E(k$IyZ`)N$NYtLMfphBkV|oSI}7N zEpFrKnA*jL!k5P(%)+Sl^e>hI@Y}jqmvb~@RD`9$!9Jpu-Wf2Cq?hfV{ghuo&AG>9 zcvC>&o43J*WfyXuRWV)Y0~9;i68pPyv&~-%Ysp}xCY)|oF5?x{w~aS zfx&5VFU)gcnMk*q%=jkB9w_qQ)x=v#1Q^_3`uyEfKiD4y$vaOR4n4IpvQmyNXK%#R zU|XV~4Bq=Kr7Zp3#hdF5_MgA#yL-jJ4E5*j2?mhMq17{mD#6f?!#-b>@HA&~uECub zF2reQA9f>OHLzqeZhi$%7fW!n^Y3_Y{C0vfQs0?a(Pcz7Opuz;(9n>$6)c9L`((v~ z@R1+M2kRN}mDuKCk=od(UwR?y%hUbEv$dC0BD&Ir;03Mf&|A#ZFtn&*mnkH8>2w0+imbwLLU77q!A=W ztt$OR$b#$dkIAwM*ZWG{5*zUPkBN$42Kxe)AYe9DV+?(A^_|IoJLzRGKlbpb%n=PVmB`nxX< z)_}Tpg&IoX;4<0!ksDY9f>*dl`c}zSl!(+ujG#<&yu3AEKR)zVKTaK88*+ma)}3)G zx460(7TNMTz(t&DUv)`7lj(r=`!?>~Q}XW3w-sipV!{Trr8`zsvu9NEK9)zO0>v3D zOs=r|`OyjAS{Ea@UIRrr2Y7A?5+5}R4RpF)ee5^F-dhvP6MKyO`kNB4jGC{-$x}`x zXmpAxVV=WR{>!zDxoq7#DN+_BWG_PRr?$Sa(XT@zUL`HuM{I4bZSuo97aF&7tL@4P zO59)SaLA2ZMU3dO5>=P)GiqcIBUy4A2h_IC-7+IROG<+Qqczgo-JyHw-!h2%h8#$y zr;y4>z2J1R4+`7o>#OWA2iiS1m8)q6A}u3d7VBk **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project. + +> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag. + +## Contents + +- Commands: init, add (dry-run, smart merge), search, view, docs, info, build +- Templates: next, vite, start, react-router, astro +- Presets: named, code, URL formats and fields +- Switching presets + +--- + +## Commands + +### `init` — Initialize or create a project + +```bash +npx shadcn@latest init [components...] [options] +``` + +Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step. + +| Flag | Short | Description | Default | +| ----------------------- | ----- | --------------------------------------------------------- | ------- | +| `--template