Vox Web: Minimal React Interop — Implementation Plan
Research foundation:
react-interop-research-findings-2026.md
Supersedes:tanstack-start-codegen-spec.md(archived, not deleted)
Backlog (250+ tasks):react-interop-backlog-2026.md
Strategic Principle
Vox is a component engine and API contract generator, not a framework bundler.
Vox emits:
- Pure named-export React functional components (stable forever)
- A route manifest array (consumed by any router)
- A typed
fetchAPI client (consumed by any data layer) - Axum HTTP endpoint handlers (Rust, framework-free)
- Typed TypeScript interfaces from Vox ADT declarations
Vox does NOT emit:
- Framework-specific file routing conventions (
__root.tsx,page.tsx) - Framework-specific RSC directives (
"use server","use client") - Framework-specific server function calls (
createServerFn) - Routing configuration files (TanStack
routes.ts, Next.jsapp/structure)
These belong in user-owned scaffold files that Vox generates once and never overwrites.
Architecture Overview
Vox Source (.vox)
│
▼ vox build
┌──────────────────────────────────────────────────────────────┐
│ dist/ (regenerated every build) │
│ │
│ *.tsx ← Named-export React components │
│ routes.manifest.ts ← VoxRoute[] array (path, component, │
│ loader?, pendingComponent?) │
│ vox-client.ts ← Typed fetch SDK for @query/@mutation │
│ types.ts ← TypeScript interfaces from @table │
│ vox-islands-meta.ts ← Island registry for hydration │
└──────────────────────────────────────────────────────────────┘
app/ (scaffold — written once, never overwritten)
│ main.tsx ← ReactDOM.createRoot entry point
│ App.tsx ← Router adapter (user customizes this)
│ globals.css ← Tailwind v4 import
│ components.json ← shadcn/ui registry configuration
│ vite.config.ts ← Vite config with /api proxy
│ package.json ← React + react-router + lucide-react
│ tsconfig.json ← jsx, paths, moduleResolution
└── islands/ ← @island TypeScript implementations
Key design decision: App.tsx is the adapter. It imports voxRoutes from dist/routes.manifest.ts and wires them into whatever router the user prefers. Vox ships a default using react-router library mode, which works everywhere.
What Changes vs. The Old Plan
| Area | Old Plan (TanStack-specific) | New Plan (Framework-agnostic) |
|---|---|---|
| Routes output | __root.tsx + *.route.tsx + app/routes.ts | Single routes.manifest.ts array |
| Server functions | createServerFn({ method: "GET" }) | fetch(/api/query/${fn}) typed SDK |
| Scaffold router | TanStack-specific app/router.tsx + app/client.tsx + app/ssr.tsx | Standard app/App.tsx + main.tsx |
| Routing dep | @tanstack/react-router | react-router (library mode) |
| Maintenance risk | High (TanStack API changes frequently) | Very Low (fetch + plain React are stable) |
| v0 compatibility | Requires TanStack cognizance | Perfect: v0 emits named-export React |
| SSR | Requires TanStack Start + Nitro | Optional: user chooses (Next.js, RR7 framework, none) |
Decorator Fate Table (Final)
| Decorator | Status | New Behavior |
|---|---|---|
component Name() { view: ... } | KEEP — canonical | Emits named-export .tsx |
@component fn (classic) | RETIRE → hard Error | Migration: component Name() { } |
@island Name { prop: T } | KEEP — core | Emits island registry entry |
@v0 Name | KEEP | Emits island stub with v0 install comment |
routes { } | KEEP + SIMPLIFY | Emits routes.manifest.ts VoxRoute[] |
loading: fn Name() | REPURPOSE | Route manifest: pendingComponent field |
layout: fn Name() | REPURPOSE | Route manifest: children grouping |
not_found: fn Name() | REPURPOSE | Route manifest: registered in App.tsx scaffold |
error_boundary: fn Name() | REPURPOSE | Route manifest: registered in App.tsx scaffold |
@query fn | KEEP + FIX | vox-client.ts: typed fetch GET |
@mutation fn | KEEP + FIX | vox-client.ts: typed fetch POST |
@server fn | KEEP + FIX | vox-client.ts: typed fetch POST |
context: Name { } | RETIRE → hard Error | No output. Migration: use React Context manually in App.tsx |
@hook fn | RETIRE → hard Error | No output. Migration: use hooks in @island TypeScript files |
@provider fn | RETIRE → hard Error | No output. Migration: add providers in scaffold App.tsx |
page: "path" { } | RETIRE → hard Error | No output. Migration: use routes { } |
New Codegen Output Specification
1. Component: component Name() { } → Name.tsx
No change. Path C emission is canonical. Named export, pure React TSX.
// vox:skip
export function PostList(): React.ReactElement {
return <div className="posts">...</div>
}
2. Routes: routes { } → routes.manifest.ts
Before (broken TanStack virtual files):
// vox:skip
// __root.tsx ← framework-specific, brittle
export const Route = createRootRoute({ ... })
// posts.route.tsx ← framework-specific
export const Route = createFileRoute("/posts")({ ... })
After (stable manifest):
// generated/routes.manifest.ts
import type { ComponentType } from "react"
import { Home } from "./Home"
import { PostList } from "./PostList"
import { PostDetail } from "./PostDetail"
import { Spinner } from "./Spinner"
import { NotFoundPage } from "./NotFoundPage"
export type VoxRoute = {
path: string
component: ComponentType<any>
loader?: (ctx: { params: Record<string, string> }) => Promise<unknown>
pendingComponent?: ComponentType
errorComponent?: ComponentType<{ error: Error }>
children?: VoxRoute[]
index?: boolean
}
export const notFoundComponent = NotFoundPage
export const globalPendingComponent = Spinner
export const voxRoutes: VoxRoute[] = [
{
path: "/",
component: Home,
index: true,
},
{
path: "/posts",
component: PostList,
loader: () => voxFetch("GET", "/api/query/getPosts"),
pendingComponent: Spinner,
},
{
path: "/posts/:id",
component: PostDetail,
loader: ({ params }) => voxFetch("GET", `/api/query/getPost?id=${params.id}`),
},
]
// Internal fetch primitive — do not use directly; use vox-client.ts
function voxFetch(method: string, path: string, body?: unknown) {
const base = import.meta.env.VITE_API_URL ?? "http://localhost:4000"
return fetch(`${base}${path}`, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(r => { if (!r.ok) throw new Error(`${path} ${r.status}`); return r.json() })
}
3. Data: @query / @mutation → vox-client.ts
Before (broken TanStack createServerFn):
export const getPosts = createServerFn({ method: "POST" })
.handler(async (data) => fetch("/api/...").then(r => r.json()))
After (stable typed fetch client):
// generated/vox-client.ts
// Generated by Vox. Regenerated on every vox build. Do not edit.
const BASE = import.meta.env.VITE_API_URL ?? "http://localhost:4000"
async function $get<T>(path: string): Promise<T> {
const r = await fetch(`${BASE}${path}`)
if (!r.ok) throw new Error(`GET ${path} failed: ${r.status}`)
return r.json()
}
async function $post<T>(path: string, body: unknown): Promise<T> {
const r = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!r.ok) throw new Error(`POST ${path} failed: ${r.status}`)
return r.json()
}
// @query fn getPosts() -> list[Post]
export async function getPosts(): Promise<Post[]> {
return $get<Post[]>("/api/query/getPosts")
}
// @mutation fn createPost(title: str, body: str) -> Post
export async function createPost(data: { title: string; body: string }): Promise<Post> {
return $post<Post>("/api/mutation/createPost", data)
}
4. Scaffold: New Files (written once, never overwritten)
app/main.tsx
// vox:skip
import React from "react"
import ReactDOM from "react-dom/client"
import { App } from "./App"
import "./globals.css"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
)
app/App.tsx — The Adapter
// vox:skip
// This file is yours to modify. Vox generated it once and will never overwrite it.
// To use a different router (TanStack Router, Next.js, etc.), replace the body of this file.
import { BrowserRouter, Routes, Route, Navigate } from "react-router"
import { Suspense } from "react"
import {
voxRoutes,
notFoundComponent: NotFound,
globalPendingComponent: GlobalSpinner,
type VoxRoute,
} from "../dist/routes.manifest"
function renderRoutes(routes: VoxRoute[]) {
return routes.map(r => (
<Route
key={r.path}
path={r.path}
index={r.index}
element={
<Suspense fallback={r.pendingComponent ? <r.pendingComponent /> : <GlobalSpinner />}>
<r.component />
</Suspense>
}
>
{r.children && renderRoutes(r.children)}
</Route>
))
}
export function App() {
return (
<BrowserRouter>
<Routes>
{renderRoutes(voxRoutes)}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
)
}
app/globals.css
/* Tailwind v4 */
@import "tailwindcss";
app/components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
Note: rsc: false ensures v0.dev generates client-compatible components (no "use server"/"use client" directives). This is the critical v0 compatibility flag.
vite.config.ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import path from "path"
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "./app") },
},
server: {
port: 3000,
proxy: {
"/api": {
target: process.env.VITE_API_URL ?? "http://localhost:4000",
changeOrigin: true,
},
},
},
})
package.json
{
"name": "vox-app",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.0.0",
"lucide-react": "^0.400.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ES2022",
"skipLibCheck": true,
"strictNullChecks": true,
"paths": { "@/*": ["./app/*"] }
},
"include": ["app", "dist"]
}
Vox Source Syntax: New Route Entry Forms
Current (must still parse):
// vox:skip
routes {
"/" to Home
"/posts" to PostList
}
Extended (implemented in compiler; layout as syntax is future work)
Parser status:
with loader/with pending/ nested{ ... }child routes /not_found:/error:parse and emit intoroutes.manifest.ts."/path" as layout Name { ... }, HTTP redirects, and wildcard route lines are not implemented yet (seeRouteEntry.redirect/is_wildcardplaceholders in the AST).
// vox:skip
@loading fn GlobalSpinner() to Element {
ret <div class="spinner">"Loading…"</div>
}
component Home() { state n: int = 0 view: <span>"home"</span> }
component PostList() { state n: int = 0 view: <span>"posts"</span> }
component NotFoundPage() { state n: int = 0 view: <span>"404"</span> }
component ErrorFallback() { state n: int = 0 view: <span>"err"</span> }
@query fn getPosts() -> int { ret 0 }
routes {
"/" to Home {
"/posts" to PostList with loader: getPosts
}
not_found: NotFoundPage
error: ErrorFallback
}
Future (not in the grammar today): "/app" as layout AppShell { "/dashboard" to Dashboard } — tracked as a parser/WebIR extension, not a normative example.
Execution Waves
Wave 0 — AST/Parser Extensions
Goal: Support the new routes { } sub-syntax.
Tasks:
RouteEntry.loader: Option<String>— name of a @query fnRouteEntry.pending_component: Option<String>— name of a loading: fnRouteEntry.layout_name: Option<String>— name of a layout groupRoutesDecl.not_found_component: Option<String>RoutesDecl.error_component: Option<String>- Parser:
with loader: fnNameclause afterto ComponentName - Parser:
with (loader: fnName, pending: SpinnerName)variant - Parser (deferred):
"/path" as layout Name { ... }sub-block — not implemented; use nested string paths under a parent route instead - Parser:
not_found: ComponentNameterminal in routes body - Parser:
error: ComponentNameterminal in routes body - Parser: hard error on
@hook fn— message + docs link - Parser: hard error on
@provider fn— message + docs link - Parser: hard error on
page: "path" { }— message + docs link - Parser: deprecation warning on
context: Name { }— message + docs link cargo checkgate
Wave 1 — HIR De-deprecation
Goal: Remove #[deprecated] from HIR fields that are canonical AppContract items.
Tasks:
- Remove
#[deprecated]fromHirModule::client_routes - Remove
#[deprecated]fromHirModule::islands - Remove
#[deprecated]fromHirModule::loadings - Remove
#[deprecated]fromHirModule::layouts - Remove
#[deprecated]fromHirModule::not_founds - Remove
#[deprecated]fromHirModule::error_boundaries - Change all 6 fields from
MigrationOnly→AppContractinfield_ownership_map() - Add
layouts,loadings,not_founds,error_boundariestoSemanticHirModule - Remove
#[allow(deprecated)]fromgenerate_with_optionsfor these 6 fields cargo checkgate
Wave 2 — Retire True Legacy Codegen
Goal: Remove the code paths that generate stale, broken output.
Tasks:
- Upgrade
@component fnlint from Warning → Error intypeck/ast_decl_lints.rs - Add hard Error lint for
Decl::Context - Add Error lint for
Decl::Hook(belt+suspenders behind parser error) - Add Error lint for
Decl::Page - Remove
hir.componentsloop fromcodegen_ts/emitter.rs - Remove
hir.v0_componentsstandalone loop (keep @v0 as island) - Remove
hir.componentsCSS loop fromemitter.rs - Removed
VoxTanStackRouter.tsxprogrammatic emitter (module retired; manifest + adapter is current) - Remove
App.tsx(SPA RouterProvider) emission path - Keep
routeTree.gen.tsre-export emission as a no-op / delete - Remove
#[allow(deprecated)]forcomponents,v0_components,pagesingenerate_with_options - Update
web_projection_cachecondition: usereactive_components.is_empty() && loadings.is_empty() cargo checkgate +cargo test(many snapshot failures expected — update snapshots)
Wave 3 — Route Manifest Emitter (New)
Goal: Replace the broken virtual file route emitter with the stable manifest emitter.
Tasks:
- Create
crates/vox-compiler/src/codegen_ts/route_manifest.rs[NEW FILE] - Add
pub fn emit_route_manifest(hir: &HirModule) -> String - Emit
VoxRouteTypeScript type definition at top of manifest - Emit
notFoundComponentexport ifRoutesDecl.not_found_componentis set - Emit
globalPendingComponentexport from module-levelloading:fn if set - Emit
voxRoutes: VoxRoute[]array - For each
RouteEntry:- Emit
{ path, component }minimum - If
loader: emitloader: (ctx) => voxFetch(...)orloader: () => voxFetch(...)depending on whether path has:params - If
pending_component: emitpendingComponent: SpinnerName - If
layout_name: group children under parent{ path: layoutPath, component: LayoutComp, children: [...] }
- Emit
- Emit
voxFetchinternal helper at bottom - Import all referenced component names at top of manifest
- Emit
index: truefor root/route when path is""or"/" - Register module in
codegen_ts/mod.rs - Wire into
emitter.rs::generate_with_options: replacepush_route_tree_filescall withpush_route_manifest_file cargo checkgate
Wave 4 — vox-client.ts Emitter (Fix)
Goal: Replace broken createServerFn emission with stable typed fetch emission.
Tasks:
- Add
fn emit_server_fn_client(hir: &HirModule) -> Stringtoemitter.rsor new file - Emit
$get<T>and$post<T>private helpers usingimport.meta.env.VITE_API_URL - For each
@queryfn: emitasync function fnName(params): Promise<ReturnType>that calls$get - For each
@mutationfn: emitasync function fnName(params): Promise<ReturnType>that calls$post - For each
@serverfn: emit same as mutation - For
@queryfns with 0 params: URL is/api/query/fnNamewith no query string - For
@queryfns with params: URL is/api/query/fnName+ serialize params as query string - For
@mutation/@serverwith params: URL is/api/mutation/fnNameor/api/server/fnName, body is JSON - Remove old
serverFns.tsemission (was usingcreateServerFn) - Output file is now
vox-client.ts(rename fromserverFns.ts) - Update all tests that reference
serverFns.ts→vox-client.ts - Update
vox-tanstack-query.tsximport fromserverFns→vox-client cargo check+ tests
Wave 5 — Scaffold Emitter (New)
Goal: Generate one-time scaffold files that the user owns permanently.
Tasks:
- Create
crates/vox-compiler/src/codegen_ts/scaffold.rs[NEW FILE] fn emit_main_tsx() -> &'static str— returnsapp/main.tsxcontentfn emit_app_tsx(not_found: Option<&str>, error: Option<&str>, pending: Option<&str>) -> String— returnsapp/App.tsxadaptingvoxRoutesfn emit_globals_css() -> &'static str— returnsapp/globals.csswith Tailwind v4@importfn emit_components_json(project_name: &str) -> String— returnsapp/components.jsonwithrsc: falsefn emit_vite_config() -> &'static str— returnsvite.config.tswith proxy +@aliasfn emit_package_json(project_name: &str) -> String— returnspackage.json(React 19, RR7, Tailwind v4)fn emit_tsconfig() -> &'static str— returnstsconfig.jsonfn generate_scaffold_files(hir: &HirModule, project_name: &str) -> Vec<(String, String)>— assembles all- Register in
codegen_ts/mod.rs - Wire into
vox build --scaffoldCLI flag: loop over files, if file exists → skip, else write - Wire into
vox init --web: call scaffold + print instructions cargo checkgate
Wave 6 — CLI + Templates Update
Goal: Align templates and CLI entry points with new outputs.
Tasks:
- Remove
tanstack.rstemplate references to@tanstack/react-start,vinxi,createServerFn - Update
templates/package_json()to emit React 19 + react-router + lucide-react deps - Update
templates/vite_config()to emit proxy-based config (not tanstackStart plugin) - Update
templates/tsconfig()to Tailwind v4 compatible - Update
frontend.rs::find_component_nameor equivalent — entry point is nowapp/main.tsx, notApp.tsx - Update
npm_install_and_buildto not runtsr generate(no TanStack Router CLI needed) - Update
build_islands_if_present— island package.json does not needreact-routerdep - Update
vox init --webtemplate vox file to use canonical Path C syntax - Update
vox runorchestration: in dev, start Vite on port 3000 + Axum on port 4000 (simplified from 4-process TanStack Start) cargo check -p vox-cligate
Wave 7 — Documentation Updates
Goal: Bring all docs into sync with the manifest + vox-client.ts model.
Done (verify / maintain):
tanstack-web-backlog.mdPhase 7 wave verdicts + Phase 5 Query note (useVoxServerQueryemitted; optional component auto-wrap).vox-web-stack.md— SPA vs Start, GET@query, links tovox-codegen-ts.md+vox-fullstack-artifacts.md.ref-web-model.md— route / loader /not_found/error(nested paths; noas layout/ redirect / wildcard until implemented).tanstack-ssr-with-axum.md— Start as user adapter; Axum proxy env.- API docs:
query.md,mutation.md,server.md,v0.md,component.md,deprecated.md. Route-levelloading/not_found/error/ nestedroutessyntax:ref-web-model.md(per-decoratorloading.md/layout.mdfiles are optional future splits). architecture-index.mdlinks to interop research when touching navigation.
Deferred / optional:
- Dedicated
v0-shadcn-vox.mdcookbook (covered today byv0.md, doctor, scaffoldcomponents.json; add how-to when we want one narrative page). tanstack-web-roadmap.mdPhase 8 archive line — editorial when roadmap is next revised.
Ongoing: mdbook build in CI / local when editing docs/src/.
Wave 8 — Golden Examples
Goal: Update examples to use canonical, new syntax.
Status:
-
examples/golden/web_routing_fullstack.vox— nestedroutes,@queryloader,@loading,not_found/error(guarded bycargo test -p vox-compiler all_golden_vox_examples_parse_and_lower). -
examples/golden/blog_fullstack.vox—@table+@query+@mutation+ nested routes; pipeline:cargo test -p vox-integration-tests --test pipeline golden_blog_fullstack_codegen_emits_manifest_get_and_post. -
examples/golden/v0_shadcn_island.vox—@v0chat-id stub +routes; pipeline:golden_v0_shadcn_island_codegen_includes_routes_manifest. -
examples/golden/layout_groups.vox— blocked until"/path" as layout Name { }is implemented; use nested string paths today.
Wave 9 — Tests
Goal: Codegen and scaffold coverage.
Coverage today (names may differ from original sketch): codegen_routes_produces_route_manifest_ts, codegen_routes_with_loading_emits_pending_component, codegen_tanstack_start_flag_does_not_emit_separate_router_file, golden_web_routing_fullstack_codegen_emits_manifest_and_client in crates/vox-integration-tests/tests/pipeline/includes/include_01.rs; codegen_nested_route_manifest_…, codegen_output_never_includes_vox_tanstack_router_or_server_fns, emitter_source_orders_validate_gate_before_route_manifest in crates/vox-compiler/tests/web_ir_lower_emit.rs; axum_emit_contract.rs for GET query routes + mutation transaction error JSON.
Deferred: layout-group snapshot until as layout parsing exists.
v0.dev / shadcn Compatibility Checklist
Scaffold vs compiler vs doctor — [scaffold] items are written by scaffold_react_app; [compiler] from vox build output; [doctor] optional vox doctor checks when files exist.
-
[scaffold]
components.jsonincludes"rsc": false(minimal shadcn-style manifest) -
[scaffold]
vite.config.tsresolve.alias:@→./src(pairs withtsconfigpaths; seespa.rsvite_config) -
[scaffold]
tsconfig.jsonincludes"baseUrl": "."and"paths": { "@/*": ["./src/*"] } -
[compiler] JSX uses
className=/ named exports — see WebIR +hir_emit -
[compiler] No
"use server"/"use client"in generated manifest -
[compiler] No
createServerFninvox-client.ts—web_ir_lower_emit/ CI guards -
[workflow]
@islandimplementations underislands/src/ -
[compiler]
@v0stub includes shadcn install hint comment in generated placeholder TSX -
[scaffold] Tailwind v4 — policy: default scaffold keeps Vox theme baseline CSS (
index_css); charter “interop target” means CLI + docs align with shadcn/Tailwind v4 when authors add Tailwind (see charter). Optional: add@import "tailwindcss"in a follow-on template toggle. -
[scaffold]
lucide-reactinpackage.jsondependencies
Migration Guide for Existing .vox Files
@component fn → component Name() { }
// vox:skip
// BEFORE (error after migration)
@component fn MyButton(label: str) {
view: <button>{{ label }}</button>
}
// AFTER (canonical Path C)
component MyButton(label: str) {
view: <button>{{ label }}</button>
}
Run vox migrate web (with optional --write / --check) to auto-migrate .vox sources in the repo.
context: AuthContext { user: User } → Delete
Not emitted. Replace with React Context in @island TypeScript or pass via props.
@hook fn useCounter() → Move to island TypeScript
// islands/src/Counter/Counter.tsx
import { useState } from "react"
function useCounter(initial: number) {
const [count, setCount] = useState(initial)
return { count, increment: () => setCount(c => c + 1) }
}
export function Counter({ initial }: { initial: number }) {
const { count, increment } = useCounter(initial)
return <button onClick={increment}>{count}</button>
}
@provider fn ThemeProvider() → Move to scaffold App.tsx
// vox:skip
// app/App.tsx — add your providers here
import { ThemeProvider } from "./providers/theme"
...
export function App() {
return (
<ThemeProvider>
<BrowserRouter>...</BrowserRouter>
</ThemeProvider>
)
}
Done Criteria (machine gates + manual polish)
| Gate | Command / artifact | Notes |
|---|---|---|
| Compile | cargo check -p vox-compiler -p vox-cli -p vox-integration-tests | CI gate |
| Compiler tests | cargo test -p vox-compiler | Includes web_ir_lower_emit, axum_emit_contract, golden parse |
| Integration | cargo test -p vox-integration-tests golden_web_routing_fullstack_codegen_emits_manifest_and_client | Manifest + client smoke (include_01.rs); add filters for new goldens as they land |
| Forbidden strings | web_ir_lower_emit / pipeline | No VoxTanStackRouter, createServerFn in generated TS (see compiler tests) |
| Optional E2E | vox build + pnpm install && vite dev on a scaffolded app | Manual / smoke job (VOX_WEB_VITE_SMOKE); not blocking on blog_fullstack.vox until golden exists |
| shadcn CLI | npx shadcn@latest add … | Validates components.json when authors run it; doctor warns on rsc |
| v0 drop-in | Islands + named exports | v0 decorator doc, v0_tsx_normalize tests |
Optional goldens: blog_fullstack.vox, v0_shadcn_island.vox — tutorial narrative; web_routing_fullstack.vox already covers nested routes + loader + pending + not_found / error.