From 1eeeded79500eaffdd499c03b1a07a6f38bfb984 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sun, 25 Oct 2026 16:59:16 +0800 Subject: [PATCH 1/5] feat(init): refresh Pages starter templates --- .github/scripts/packed-consumer.mjs | 77 +++- TESTING.md | 4 +- packages/void/package.json | 1 + packages/void/src/cli/starter-ui.ts | 397 ++++++++++++++++++++ packages/void/src/cli/template.ts | 429 ++++++++++++---------- packages/void/templates/assets/icons.svg | 49 +++ packages/void/templates/assets/react.svg | 1 + packages/void/templates/assets/solid.svg | 1 + packages/void/templates/assets/stack.svg | 35 ++ packages/void/templates/assets/svelte.svg | 1 + packages/void/templates/assets/void.svg | 5 + packages/void/templates/assets/vue.svg | 1 + packages/void/test/unit/init.test.ts | 46 ++- 13 files changed, 840 insertions(+), 207 deletions(-) create mode 100644 packages/void/src/cli/starter-ui.ts create mode 100644 packages/void/templates/assets/icons.svg create mode 100644 packages/void/templates/assets/react.svg create mode 100644 packages/void/templates/assets/solid.svg create mode 100644 packages/void/templates/assets/stack.svg create mode 100644 packages/void/templates/assets/svelte.svg create mode 100644 packages/void/templates/assets/void.svg create mode 100644 packages/void/templates/assets/vue.svg diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 94778ffe0..093e784d6 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -116,6 +116,67 @@ async function run(command, args, cwd, env = environment) { } } +async function verifyStarterForm(fixture, cli, vite, framework, label, browser) { + await run(process.execPath, [cli, 'db', 'reset', '--yes'], fixture); + const task = launch( + process.execPath, + [vite, 'dev', '--host', '127.0.0.1', '--port', '0', '--strictPort'], + fixture, + ); + const page = await browser.newPage(); + const diagnostics = collectBrowserDiagnostics(page); + try { + const deadline = Date.now() + 90_000; + let base; + while (Date.now() < deadline) { + if (task.child.exitCode !== null) { + throw new Error(task.failure()); + } + base ??= packedServerUrl(task.output()); + if (base) { + try { + const response = await fetch(base, { signal: AbortSignal.timeout(2000) }); + if (response.ok) { + break; + } + } catch { + /* server is starting */ + } + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + assert(base, task.failure()); + await page.goto(base); + await page.locator('#app[data-hydrated="true"]').waitFor(); + await page.getByRole('heading', { name: 'Welcome to Void', exact: true }).waitFor(); + await page.getByText('No messages yet.', { exact: false }).waitFor(); + const input = page.getByRole('textbox', { name: 'Message', exact: true }); + const submit = page.getByRole('button', { name: 'Add message', exact: true }); + await input.fill(' '); + await submit.click(); + await page.getByRole('alert').waitFor(); + assert.equal(await page.locator('.messages li').count(), 0); + const message = `Message from the ${framework} starter`; + await input.fill(message); + await submit.click(); + await page.locator('.messages').getByText(message, { exact: true }).waitFor(); + await page.reload(); + await page.locator('.messages').getByText(message, { exact: true }).waitFor(); + assert.equal(await page.locator('.messages li').count(), 1); + console.log( + `Passed packed ${label} starter: empty state, validation, form submission, and persisted messages`, + ); + } catch (error) { + throw new Error( + `Packed ${label} starter form failed:\n${String(error.stack ?? error)}\n${diagnostics()}\n${task.failure()}`, + { cause: error }, + ); + } finally { + await page.close(); + await task.stop(); + } +} + const packages = []; for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -308,6 +369,18 @@ try { ); await run(process.execPath, [cli, 'prepare'], fixture); await run(manager, manager === 'npm' ? ['ci'] : ['install', '--frozen-lockfile'], fixture); + const vite = join( + fixture, + useVitePlus ? 'node_modules/vite-plus/bin/vp' : 'node_modules/vite/bin/vite.js', + ); + if (browser) { + await run( + process.execPath, + [join(fixture, 'node_modules/typescript/bin/tsc'), '--noEmit'], + fixture, + ); + await verifyStarterForm(fixture, cli, vite, framework, label, browser); + } const extension = framework === 'vue' ? 'vue' : framework === 'svelte' ? 'svelte' : 'tsx'; writeFileSync(join(fixture, `pages/index.${extension}`), pages[framework]); writeFileSync(join(fixture, `pages/next.${extension}`), pages[framework]); @@ -331,10 +404,6 @@ try { ); mkdirSync(join(fixture, 'public'), { recursive: true }); writeFileSync(join(fixture, 'public/packed.txt'), label); - const vite = join( - fixture, - useVitePlus ? 'node_modules/vite-plus/bin/vp' : 'node_modules/vite/bin/vite.js', - ); await run(process.execPath, [vite, 'build'], fixture); await run( process.execPath, diff --git a/TESTING.md b/TESTING.md index 2708d2bca..ec7406ff2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -214,7 +214,9 @@ workspace links can hide. They do not publish packages or deploy applications. Smoke coverage checks package contents and exports, real initialization, CLI help, a frozen reinstall, public Vite builds, TypeScript, and development HTTP requests for pages, assets, and KV. It uses no browser. Full coverage adds Vue, Svelte, Solid, Markdown, production preview, -and browser hydration/navigation. Superseded PR/main runs are cancelled; release runs are not. +and browser hydration/navigation. It also checks each generated starter's empty state, +validation, form submission, and message persistence against local D1 before replacing its pages +with the broader runtime fixtures. Superseded PR/main runs are cancelled; release runs are not. The selector reads all PR file pages or the complete pushed commit range. Renames consider both paths. Incomplete or unavailable change lists fall back to full coverage. diff --git a/packages/void/package.json b/packages/void/package.json index b69637f34..e7f51bc84 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -20,6 +20,7 @@ "docs", "schema.json", "skills", + "templates", "AGENTS_TEMPLATE.md", "README.md", "sandbox.Dockerfile" diff --git a/packages/void/src/cli/starter-ui.ts b/packages/void/src/cli/starter-ui.ts new file mode 100644 index 000000000..467bc1ab1 --- /dev/null +++ b/packages/void/src/cli/starter-ui.ts @@ -0,0 +1,397 @@ +export const STARTER_STYLES: string = `:root { + --text: rgb(22 23 29 / 70%); + --text-h: #16171d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f2f1f4; + --accent: #6c3bff; + --error: #b42318; + --social-bg: #f2f1f4; + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: rgb(255 255 255 / 70%); + --text-h: #fff; + --bg: #16171d; + --border: #3b3440; + --code-bg: #3b3440; + --accent: #b39aff; + --error: #fda29b; + --social-bg: #3b3440; + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + +} + +.starter { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} + +.hero { + position: relative; + + .base, + .framework, + .void { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .void { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .void { + z-index: 0; + top: 107px; + height: 22px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + padding-bottom: 24px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +.starter-intro { + width: min(720px, 100%); + padding-inline: 20px; + box-sizing: border-box; + + a { + color: var(--accent); + } + + @media (max-width: 1024px) { + padding-inline: 0; + } +} + +.starter-description { + display: grid; + gap: 12px; + + > p:nth-child(2) { + text-wrap: balance; + } +} + +.starter-demo { + border-top: 1px solid var(--border); + padding: 32px; + text-align: left; + + @media (max-width: 1024px) { + padding: 24px 20px; + } +} + +.demo-content { + max-width: 640px; + margin: 0 auto; + display: grid; + gap: 24px; + + h2 { + margin: 0; + } +} + +.messages { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 12px; + + li { + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: 6px; + overflow-wrap: anywhere; + } + + strong { + display: block; + color: var(--text-h); + font-weight: 500; + } + + small { + font-size: 14px; + } +} + +.message-form { + display: grid; + gap: 12px; + + label { + color: var(--text-h); + } + + input, + button { + font: inherit; + color: var(--text-h); + background: var(--social-bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 12px; + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + } + + input { + flex: 1; + min-width: 0; + } + + button { + cursor: pointer; + + &:hover { + border-color: var(--accent); + } + + &:disabled { + cursor: wait; + opacity: 0.6; + } + } +} + +.message-fields { + display: flex; + gap: 8px; + + @media (max-width: 480px) { + flex-direction: column; + } +} + +.message-form [data-void-form-error] { + color: var(--error); + font-size: 14px; +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + color: var(--accent); + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + width: 36px; + height: auto; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} +`; diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index 013aa8ac8..6db68ec8e 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -1,3 +1,8 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'pathslash'; +import { getPackageDir } from './agents.ts'; +import { STARTER_STYLES } from './starter-ui.ts'; + export const SCAFFOLD_GITIGNORE_LINES = [ 'node_modules', 'dist', @@ -59,6 +64,7 @@ export const SCAFFOLD_FRAMEWORK_OPTIONS = [ type ScaffoldFrameworkConfig = { label: string; + docsUrl: string; adapterPackage: string; adapterImport: string; adapterPlugin: string; @@ -71,6 +77,7 @@ type ScaffoldFrameworkConfig = { export const SCAFFOLD_FRAMEWORKS: Record = { react: { label: 'React', + docsUrl: 'https://react.dev/', adapterPackage: '@void/react', adapterImport: 'voidReact', adapterPlugin: '@void/react/plugin', @@ -90,6 +97,7 @@ export const SCAFFOLD_FRAMEWORKS: Record = { + const assets = join(getPackageDir(), 'templates', 'assets'); + const files: PagesStarterTemplate['files'] = { 'vite.config.ts': getViteConfig(framework, config), [`pages/index.${config.pageExtension}`]: getIndexPage(framework, starterMode), + 'pages/style.css': STARTER_STYLES, + 'pages/vite-env.d.ts': '/// \n', + 'public/stack.svg': readFileSync(join(assets, 'stack.svg'), 'utf8'), + 'public/void.svg': readFileSync(join(assets, 'void.svg'), 'utf8'), + [`public/${framework}.svg`]: readFileSync(join(assets, `${framework}.svg`), 'utf8'), + 'public/icons.svg': readFileSync(join(assets, 'icons.svg'), 'utf8'), }; if (starterMode !== 'static') { @@ -322,218 +339,236 @@ export default defineSeed(async ({ db, schema }) => { } function getIndexPage(framework: ScaffoldFramework, starterMode: ScaffoldStarterMode): string { - if (starterMode === 'static') { - switch (framework) { - case 'react': - return `export default function HomePage() { - return ( -
-

Welcome to Void

-

This starter is running in Pages mode with no database or server code yet.

-

- Add pages/*.server.ts, routes/, or void/db when - you want to grow into full-stack features. -

-
- ); -} -`; - case 'vue': - return ` -`; - case 'svelte': - return `
-

Welcome to Void

-

This starter is running in Pages mode with no database or server code yet.

-

- Add pages/*.server.ts, routes/, or void/db when you - want to grow into full-stack features. -

-
-`; - case 'solid': - return `export default function HomePage() { - return ( -
-

Welcome to Void

-

This starter is running in Pages mode with no database or server code yet.

-

- Add pages/*.server.ts, routes/, or void/db when - you want to grow into full-stack features. -

-
- ); -} -`; - } - } + const { label, docsUrl } = SCAFFOLD_FRAMEWORKS[framework]; + const hasDatabase = starterMode !== 'static'; + const description = + starterMode === 'static' + ? `

This starter is running in Pages mode with no database or server code yet.

+

+ Add pages/*.server.ts, routes/, or void/db when + you want to grow into full-stack features. +

` + : `

+ This starter is running in Pages mode. The list below is loaded from your database in + pages/index.server.ts. +

+

API example: /api/hello

`; + const body = `
+
+
+ + ${label} logo + Void logo +
+
+

Welcome to Void

+
+${indent(description, 8)} +
+
+
${hasDatabase ? `\n \n${indent(getMessagesDemo(framework), 2)}` : ''} + +
+
+ +

Documentation

+

Your questions, answered

+ +
+
+ +

Connect with us

+

Join the Void community

+ +
+
+ + +
`; switch (framework) { case 'react': - return `import { Form, useForm } from "${SCAFFOLD_FRAMEWORKS[framework].adapterPackage}"; + case 'solid': { + const jsx = framework === 'react' ? body.replaceAll('class=', 'className=') : body; + const imports = hasDatabase + ? `${framework === 'solid' ? 'import { For, Show } from "solid-js";\n' : ''}import { Form, useForm } from "${SCAFFOLD_FRAMEWORKS[framework].adapterPackage}"; import type { Props } from "./index.server"; - -export default function HomePage({ messages }: Props) { - const form = useForm("/", { text: "" }); - return ( -
-

Welcome to Void

-

- This starter is running in Pages mode. The list below is loaded from your database in - pages/index.server.ts. -

-

- API example: /api/hello -

- {messages.length === 0 ? ( -

- No messages yet. Run void db seed to load the sample row from - db/seed.ts. -

- ) : ( -
    - {messages.map((message) => ( -
  • - {message.text} -
    - {String(message.createdAt)} -
  • - ))} -
- )} -
- - -
-
+` + : ''; + const props = hasDatabase + ? framework === 'react' + ? '{ messages }: Props' + : 'props: Props' + : ''; + return `${imports}import "./style.css"; + +export default function HomePage(${props}) { +${hasDatabase ? ' const form = useForm("/", { text: "" });\n\n' : ''} return ( +${indent(jsx, 4)} ); } `; + } case 'vue': return ` +${hasDatabase ? 'import { Form, useForm } from "@void/vue";\nimport type { Props } from "./index.server";\n' : ''}import "./style.css"; +${hasDatabase ? '\ndefineProps();\nconst form = useForm("/", { text: "" });\n' : ''} `; case 'svelte': return ` - -
-

Welcome to Void

-

- This starter is running in Pages mode. The list below is loaded from your database in - pages/index.server.ts. -

-

- API example: /api/hello -

- {#if messages.length === 0} -

- No messages yet. Run void db seed to load the sample row from - db/seed.ts. -

- {:else} -
    - {#each messages as message (message.id)} -
  • - {message.text} -
    - {String(message.createdAt)} -
  • - {/each} -
- {/if} -
- - -
-
-`; - case 'solid': - return `import { Form, useForm } from "${SCAFFOLD_FRAMEWORKS[framework].adapterPackage}"; -import type { Props } from "./index.server"; +${hasDatabase ? ' import { Form, useForm } from "@void/svelte";\n import type { Props } from "./index.server";\n' : ''} import "./style.css"; +${hasDatabase ? '\n let { messages }: Props = $props();\n const form = useForm("/", { text: "" });\n' : ''} -export default function HomePage(props: Props) { - const form = useForm("/", { text: "" }); - return ( -
-

Welcome to Void

-

- This starter is running in Pages mode. The list below is loaded from your database in - pages/index.server.ts. -

-

- API example: /api/hello -

- {props.messages.length === 0 ? ( -

- No messages yet. Run void db seed to load the sample row from - db/seed.ts. -

- ) : ( -
    - {props.messages.map((message) => ( -
  • - {message.text} -
    - {String(message.createdAt)} -
  • - ))} -
- )} -
- - -
-
- ); -} +${body} `; } } + +function getMessagesDemo(framework: ScaffoldFramework): string { + const empty = `

+ No messages yet. Run void db seed to load the sample row from + db/seed.ts. +

`; + const content = { + react: `{messages.length === 0 ? ( +${indent(empty, 2)} +) : ( +
    + {messages.map((message) => ( +
  • + {message.text} + {String(message.createdAt)} +
  • + ))} +
+)} +
+ +
+ form.setData("text", event.target.value)} required maxLength={1000} /> + +
+
`, + vue: ` +
    +
  • + {{ message.text }} + {{ String(message.createdAt) }} +
  • +
+
+ +
+ + +
+
`, + svelte: `{#if messages.length === 0} +${indent(empty, 2)} +{:else} +
    + {#each messages as message (message.id)} +
  • + {message.text} + {String(message.createdAt)} +
  • + {/each} +
+{/if} +
+ +
+ + +
+
`, + solid: ` 0} fallback={ +${indent(empty, 2)} +}> +
    + {(message) => ( +
  • + {message.text} + {String(message.createdAt)} +
  • + )}
    +
+
+
+ +
+ form.setData("text", event.currentTarget.value)} required maxLength={1000} /> + +
+
`, + }[framework]; + return `
+
+

Messages

+${indent(content, 4)} +
+
`; +} + +function indent(source: string, spaces: number): string { + return source + .split('\n') + .map((line) => ' '.repeat(spaces) + line) + .join('\n'); +} diff --git a/packages/void/templates/assets/icons.svg b/packages/void/templates/assets/icons.svg new file mode 100644 index 000000000..c54550d94 --- /dev/null +++ b/packages/void/templates/assets/icons.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/void/templates/assets/react.svg b/packages/void/templates/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/void/templates/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/templates/assets/solid.svg b/packages/void/templates/assets/solid.svg new file mode 100644 index 000000000..025aa303c --- /dev/null +++ b/packages/void/templates/assets/solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/templates/assets/stack.svg b/packages/void/templates/assets/stack.svg new file mode 100644 index 000000000..cc09ed4b7 --- /dev/null +++ b/packages/void/templates/assets/stack.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/void/templates/assets/svelte.svg b/packages/void/templates/assets/svelte.svg new file mode 100644 index 000000000..c5e08481f --- /dev/null +++ b/packages/void/templates/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/templates/assets/void.svg b/packages/void/templates/assets/void.svg new file mode 100644 index 000000000..7da47181c --- /dev/null +++ b/packages/void/templates/assets/void.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/void/templates/assets/vue.svg b/packages/void/templates/assets/vue.svg new file mode 100644 index 000000000..770e9d333 --- /dev/null +++ b/packages/void/templates/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index b35c3e13d..c6e12f5f3 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1327,7 +1327,32 @@ describe('isScaffoldableEmptyProject', () => { const reactViteConfig = readFileSync(join(root, 'vite.config.ts'), 'utf-8'); expect(reactViteConfig).toContain('from "@void/react/plugin"'); expect(reactViteConfig).toContain('voidReact({ react: { compiler: true } })'); - expect(readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8')).toContain('Welcome to Void'); + const page = readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8'); + expect(page).toContain('Welcome to Void'); + expect(page).toContain('The list below is loaded from your database in'); + expect(page).toContain('href="/api/hello"'); + expect(page).toContain('void db seed'); + expect(page).not.toContain('no database or server code yet'); + expect(page).not.toContain('Void +'); + expect(page).toContain('src="/react.svg"'); + expect(page).toContain('import "./style.css"'); + expect(page).toContain('messages.map'); + expect(page).toContain('useForm'); + expect(page).toContain('
{ const vueViteConfig = readFileSync(join(root, 'vite.config.ts'), 'utf-8'); expect(vueViteConfig).toContain('from "@void/vue/plugin"'); expect(vueViteConfig).toContain('voidVue()'); - expect(readFileSync(join(root, 'pages', 'index.vue'), 'utf-8')).toContain('Welcome to Void'); + const page = readFileSync(join(root, 'pages', 'index.vue'), 'utf-8'); + expect(page).toContain('Welcome to Void'); + expect(page).not.toContain('Void +'); + expect(page).toContain('src="/vue.svg"'); + expect(page).toContain('v-for="message in messages"'); + expect(page).toContain(' { expect(installCalls()[0][0]).toBe('vp'); expect(pkg.scripts.dev).toBe('vp dev'); - expect(readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8')).toContain( - 'no database or server code yet', + const page = readFileSync(join(root, 'pages', 'index.tsx'), 'utf-8'); + expect(page).toContain('Welcome to Void'); + expect(page).toContain( + 'This starter is running in Pages mode with no database or server code yet.', ); + expect(page).toContain('you want to grow into full-stack features.'); + expect(page).not.toContain('href="/api/hello"'); + expect(page).not.toContain('useForm'); + expect(page).not.toContain(' readFileSync(join(root, 'pages', 'index.server.ts'), 'utf-8')).toThrow(); expect(() => readFileSync(join(root, 'routes', 'api', 'hello.ts'), 'utf-8')).toThrow(); @@ -1666,7 +1702,7 @@ describe('isScaffoldableEmptyProject', () => { expect(pkg.devDependencies['vite-plus']).toBe('^1.3.0'); expect(pkg.scripts.dev).toBe('vp dev'); expect(readFileSync(join(appRoot, 'pages', 'index.tsx'), 'utf-8')).toContain( - 'no database or server code yet', + 'Welcome to Void', ); const locationPrompt = mockSelect.mock.calls[0]?.[0]; From 94ad8ddf3a8c82d485cb0ee4769338ef2dfef732 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sun, 25 Oct 2026 17:42:50 +0800 Subject: [PATCH 2/5] chore(init): remove unrelated packed test changes --- .github/scripts/packed-consumer.mjs | 77 ++--------------------------- TESTING.md | 4 +- 2 files changed, 5 insertions(+), 76 deletions(-) diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index 093e784d6..94778ffe0 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -116,67 +116,6 @@ async function run(command, args, cwd, env = environment) { } } -async function verifyStarterForm(fixture, cli, vite, framework, label, browser) { - await run(process.execPath, [cli, 'db', 'reset', '--yes'], fixture); - const task = launch( - process.execPath, - [vite, 'dev', '--host', '127.0.0.1', '--port', '0', '--strictPort'], - fixture, - ); - const page = await browser.newPage(); - const diagnostics = collectBrowserDiagnostics(page); - try { - const deadline = Date.now() + 90_000; - let base; - while (Date.now() < deadline) { - if (task.child.exitCode !== null) { - throw new Error(task.failure()); - } - base ??= packedServerUrl(task.output()); - if (base) { - try { - const response = await fetch(base, { signal: AbortSignal.timeout(2000) }); - if (response.ok) { - break; - } - } catch { - /* server is starting */ - } - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - assert(base, task.failure()); - await page.goto(base); - await page.locator('#app[data-hydrated="true"]').waitFor(); - await page.getByRole('heading', { name: 'Welcome to Void', exact: true }).waitFor(); - await page.getByText('No messages yet.', { exact: false }).waitFor(); - const input = page.getByRole('textbox', { name: 'Message', exact: true }); - const submit = page.getByRole('button', { name: 'Add message', exact: true }); - await input.fill(' '); - await submit.click(); - await page.getByRole('alert').waitFor(); - assert.equal(await page.locator('.messages li').count(), 0); - const message = `Message from the ${framework} starter`; - await input.fill(message); - await submit.click(); - await page.locator('.messages').getByText(message, { exact: true }).waitFor(); - await page.reload(); - await page.locator('.messages').getByText(message, { exact: true }).waitFor(); - assert.equal(await page.locator('.messages li').count(), 1); - console.log( - `Passed packed ${label} starter: empty state, validation, form submission, and persisted messages`, - ); - } catch (error) { - throw new Error( - `Packed ${label} starter form failed:\n${String(error.stack ?? error)}\n${diagnostics()}\n${task.failure()}`, - { cause: error }, - ); - } finally { - await page.close(); - await task.stop(); - } -} - const packages = []; for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) { if (!entry.isDirectory()) { @@ -369,18 +308,6 @@ try { ); await run(process.execPath, [cli, 'prepare'], fixture); await run(manager, manager === 'npm' ? ['ci'] : ['install', '--frozen-lockfile'], fixture); - const vite = join( - fixture, - useVitePlus ? 'node_modules/vite-plus/bin/vp' : 'node_modules/vite/bin/vite.js', - ); - if (browser) { - await run( - process.execPath, - [join(fixture, 'node_modules/typescript/bin/tsc'), '--noEmit'], - fixture, - ); - await verifyStarterForm(fixture, cli, vite, framework, label, browser); - } const extension = framework === 'vue' ? 'vue' : framework === 'svelte' ? 'svelte' : 'tsx'; writeFileSync(join(fixture, `pages/index.${extension}`), pages[framework]); writeFileSync(join(fixture, `pages/next.${extension}`), pages[framework]); @@ -404,6 +331,10 @@ try { ); mkdirSync(join(fixture, 'public'), { recursive: true }); writeFileSync(join(fixture, 'public/packed.txt'), label); + const vite = join( + fixture, + useVitePlus ? 'node_modules/vite-plus/bin/vp' : 'node_modules/vite/bin/vite.js', + ); await run(process.execPath, [vite, 'build'], fixture); await run( process.execPath, diff --git a/TESTING.md b/TESTING.md index ec7406ff2..2708d2bca 100644 --- a/TESTING.md +++ b/TESTING.md @@ -214,9 +214,7 @@ workspace links can hide. They do not publish packages or deploy applications. Smoke coverage checks package contents and exports, real initialization, CLI help, a frozen reinstall, public Vite builds, TypeScript, and development HTTP requests for pages, assets, and KV. It uses no browser. Full coverage adds Vue, Svelte, Solid, Markdown, production preview, -and browser hydration/navigation. It also checks each generated starter's empty state, -validation, form submission, and message persistence against local D1 before replacing its pages -with the broader runtime fixtures. Superseded PR/main runs are cancelled; release runs are not. +and browser hydration/navigation. Superseded PR/main runs are cancelled; release runs are not. The selector reads all PR file pages or the complete pushed commit range. Renames consider both paths. Incomplete or unavailable change lists fall back to full coverage. From 1dca6409bea364266cb992550c55c5c1befa061a Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sun, 25 Oct 2026 18:34:53 +0800 Subject: [PATCH 3/5] refactor(init): keep starter styles in a CSS file --- .../src/cli/{starter-ui.ts => starter-ui.css} | 16 +++++----------- packages/void/src/cli/template.ts | 4 ++-- packages/void/src/css.d.ts | 4 ++++ packages/void/vite.config.ts | 16 +++++++++++++++- vite.config.ts | 2 ++ 5 files changed, 28 insertions(+), 14 deletions(-) rename packages/void/src/cli/{starter-ui.ts => starter-ui.css} (95%) create mode 100644 packages/void/src/css.d.ts diff --git a/packages/void/src/cli/starter-ui.ts b/packages/void/src/cli/starter-ui.css similarity index 95% rename from packages/void/src/cli/starter-ui.ts rename to packages/void/src/cli/starter-ui.css index 467bc1ab1..0d15362d0 100644 --- a/packages/void/src/cli/starter-ui.ts +++ b/packages/void/src/cli/starter-ui.css @@ -1,4 +1,4 @@ -export const STARTER_STYLES: string = `:root { +:root { --text: rgb(22 23 29 / 70%); --text-h: #16171d; --bg: #fff; @@ -7,8 +7,7 @@ export const STARTER_STYLES: string = `:root { --accent: #6c3bff; --error: #b42318; --social-bg: #f2f1f4; - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; @@ -39,10 +38,8 @@ export const STARTER_STYLES: string = `:root { --accent: #b39aff; --error: #fda29b; --social-bg: #3b3440; - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + --shadow: rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; } - } .starter { @@ -126,8 +123,7 @@ code { z-index: 1; top: 34px; height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4); } .void { @@ -135,8 +131,7 @@ code { top: 107px; height: 22px; width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8); } } @@ -394,4 +389,3 @@ code { border-right-color: var(--border); } } -`; diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index 6db68ec8e..1807cbb55 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'pathslash'; import { getPackageDir } from './agents.ts'; -import { STARTER_STYLES } from './starter-ui.ts'; +import starterStyles from './starter-ui.css?raw'; export const SCAFFOLD_GITIGNORE_LINES = [ 'node_modules', @@ -186,7 +186,7 @@ export function createPagesStarterTemplate(options: { const files: PagesStarterTemplate['files'] = { 'vite.config.ts': getViteConfig(framework, config), [`pages/index.${config.pageExtension}`]: getIndexPage(framework, starterMode), - 'pages/style.css': STARTER_STYLES, + 'pages/style.css': starterStyles, 'pages/vite-env.d.ts': '/// \n', 'public/stack.svg': readFileSync(join(assets, 'stack.svg'), 'utf8'), 'public/void.svg': readFileSync(join(assets, 'void.svg'), 'utf8'), diff --git a/packages/void/src/css.d.ts b/packages/void/src/css.d.ts new file mode 100644 index 000000000..4f8dea640 --- /dev/null +++ b/packages/void/src/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.css?raw' { + const content: string; + export default content; +} diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index cc7ea04cf..90217727b 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { defineConfig } from 'vite-plus'; const WORKERS_TYPES_REFERENCE = '/// '; @@ -144,7 +145,20 @@ export default defineConfig({ format: 'esm', dts: true, clean: true, - plugins: [injectWorkersTypesReferencePlugin()], + plugins: [ + { + name: 'void:raw-css', + load: { + filter: { id: /\.css\?raw$/ }, + handler(id) { + const file = id.slice(0, -'?raw'.length); + this.addWatchFile(file); + return `export default ${JSON.stringify(readFileSync(file, 'utf8'))};`; + }, + }, + }, + injectWorkersTypesReferencePlugin(), + ], deps: { resolveDepSubpath: true, neverBundle: [ diff --git a/vite.config.ts b/vite.config.ts index df369179f..25c585a9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -432,6 +432,7 @@ export default defineConfig({ test: { name: 'cli-unit', fsModuleCache: true, + css: { include: [/starter-ui\.css(?:\?|$)/] }, include: cliUnitTests, pool: 'threads', maxWorkers, @@ -443,6 +444,7 @@ export default defineConfig({ test: { name: 'unit', fsModuleCache: true, + css: { include: [/starter-ui\.css(?:\?|$)/] }, include: ['packages/*/test/unit/**/*.test.ts'], exclude: [...cliUnitTests, ...sdkPgliteUnitTests], sequence: { groupOrder: 2 }, From 667446abc61e231745159cf34418348c3929c74b Mon Sep 17 00:00:00 2001 From: Yangshun Date: Mon, 26 Oct 2026 08:47:51 +0800 Subject: [PATCH 4/5] fix(init): update starter X link --- packages/void/src/cli/template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index 1807cbb55..184103ae8 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -414,7 +414,7 @@ ${indent(description, 8)}
  • - + From e979becf4d92580ebf48977521623ba088099b0b Mon Sep 17 00:00:00 2001 From: Yangshun Date: Mon, 26 Oct 2026 11:23:21 +0800 Subject: [PATCH 5/5] fix(init): refine starter styling and inline code spacing --- packages/void/src/cli/starter-ui.css | 29 +++++++++++++++++++--------- packages/void/src/cli/template.ts | 9 +++------ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/void/src/cli/starter-ui.css b/packages/void/src/cli/starter-ui.css index 0d15362d0..f1fe77276 100644 --- a/packages/void/src/cli/starter-ui.css +++ b/packages/void/src/cli/starter-ui.css @@ -7,6 +7,8 @@ --accent: #6c3bff; --error: #b42318; --social-bg: #f2f1f4; + --radius-inner: 4px; + --radius-element: 6px; --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; @@ -28,6 +30,20 @@ } } +@supports (corner-shape: squircle) { + :root { + /* Squircle curves need about twice the radius to read as rounded. */ + --radius-inner: 8px; + --radius-element: 12px; + } + + :where(.starter, .starter *), + .starter *::before, + .starter *::after { + corner-shape: squircle; + } +} + @media (prefers-color-scheme: dark) { :root { --text: rgb(255 255 255 / 70%); @@ -90,7 +106,7 @@ p { code { font-family: var(--mono); display: inline-flex; - border-radius: 4px; + border-radius: var(--radius-inner); color: var(--text-h); font-size: 15px; line-height: 135%; @@ -204,7 +220,7 @@ code { li { padding: 12px 16px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-element); overflow-wrap: anywhere; } @@ -233,7 +249,7 @@ code { color: var(--text-h); background: var(--social-bg); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-element); padding: 10px 12px; &:focus-visible { @@ -317,15 +333,10 @@ code { gap: 8px; margin: 32px 0 0; - .logo { - width: 36px; - height: auto; - } - a { color: var(--text-h); font-size: 16px; - border-radius: 6px; + border-radius: var(--radius-element); background: var(--social-bg); display: flex; padding: 6px 12px; diff --git a/packages/void/src/cli/template.ts b/packages/void/src/cli/template.ts index 184103ae8..0a8e5546a 100644 --- a/packages/void/src/cli/template.ts +++ b/packages/void/src/cli/template.ts @@ -349,8 +349,7 @@ function getIndexPage(framework: ScaffoldFramework, starterMode: ScaffoldStarter you want to grow into full-stack features.

    ` : `

    - This starter is running in Pages mode. The list below is loaded from your database in - pages/index.server.ts. + This starter is running in Pages mode. The list below is loaded from your database in pages/index.server.ts.

    API example: /api/hello

    `; const body = `
    @@ -378,8 +377,7 @@ ${indent(description, 8)}
    • - - Explore + Quickstart
    • @@ -480,8 +478,7 @@ ${body} function getMessagesDemo(framework: ScaffoldFramework): string { const empty = `

      - No messages yet. Run void db seed to load the sample row from - db/seed.ts. + No messages yet. Run void db seed to load the sample row from db/seed.ts.

      `; const content = { react: `{messages.length === 0 ? (