create-magnetic-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # create-magnetic-app
2
+
3
+ Scaffold a new Magnetic server-driven UI application.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx create-magnetic-app my-app
9
+ cd my-app
10
+ magnetic dev
11
+ ```
12
+
13
+ ## Templates
14
+
15
+ ### `todo` (default)
16
+
17
+ Full todo app with state management, multiple pages, and components:
18
+
19
+ ```bash
20
+ npx create-magnetic-app my-app --template todo
21
+ ```
22
+
23
+ ### `blank`
24
+
25
+ Minimal app with a single page:
26
+
27
+ ```bash
28
+ npx create-magnetic-app my-app --template blank
29
+ ```
30
+
31
+ ## Generated Structure
32
+
33
+ ```
34
+ my-app/
35
+ pages/ ← TSX page components (filename = route)
36
+ IndexPage.tsx → /
37
+ AboutPage.tsx → /about
38
+ NotFoundPage.tsx → * (catch-all)
39
+ components/ ← shared TSX components
40
+ server/
41
+ state.ts ← business logic (state, reducer, view model)
42
+ public/
43
+ magnetic.js ← client runtime (~1.5KB gzipped)
44
+ style.css ← app styles
45
+ magnetic.json ← app config
46
+ tsconfig.json ← IDE/TypeScript support
47
+ README.md
48
+ ```
49
+
50
+ ## What You Edit
51
+
52
+ - `pages/*.tsx` — page components (auto-routed)
53
+ - `components/*.tsx` — shared components
54
+ - `server/state.ts` — all business logic
55
+ - `public/style.css` — styles
56
+
57
+ ## What You Never Edit
58
+
59
+ - `public/magnetic.js` — client runtime (provided by framework)
60
+ - Generated bridge code (auto-generated by CLI)
61
+ - Bundle output (`dist/app.js`)
62
+
63
+ ## For AI Agents
64
+
65
+ 1. Scaffold: `npx create-magnetic-app <name>`
66
+ 2. Edit only: `pages/`, `components/`, `server/state.ts`, `public/style.css`
67
+ 3. Run: `magnetic dev` → opens at `http://localhost:3003`
68
+ 4. Actions: `onClick="action_name"` dispatches to `reduce()` in `state.ts`
69
+ 5. Forms: `onSubmit="action_name"` collects FormData as payload
70
+ 6. Navigation: `<Link href="/path">` for client-side routing
71
+ 7. State shape: `initialState()` → `reduce(state, action, payload)` → `toViewModel(state)` → passed as page props
72
+
73
+ ## License
74
+
75
+ MIT
package/bin/create.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * create-magnetic-app CLI
4
+ *
5
+ * Usage:
6
+ * create-magnetic-app <project-name> [--template todo|blank] [--dir <target-dir>]
7
+ */
8
+ import { scaffold } from '../src/index.js';
9
+ import { resolve } from 'path';
10
+ import { existsSync } from 'fs';
11
+
12
+ const args = process.argv.slice(2);
13
+ const name = args.find(a => !a.startsWith('--'));
14
+
15
+ if (!name || name === '--help' || name === '-h') {
16
+ console.log(`
17
+ create-magnetic-app — scaffold a new Magnetic project
18
+
19
+ Usage:
20
+ create-magnetic-app <project-name> [options]
21
+
22
+ Options:
23
+ --template <name> Template: "todo" (default) or "blank"
24
+ --dir <path> Target directory (default: current directory)
25
+ `);
26
+ process.exit(name ? 0 : 1);
27
+ }
28
+
29
+ const dirFlag = args.indexOf('--dir');
30
+ const targetDir = dirFlag !== -1 && args[dirFlag + 1]
31
+ ? resolve(args[dirFlag + 1], name)
32
+ : resolve(process.cwd(), name);
33
+
34
+ const tplFlag = args.indexOf('--template');
35
+ const template = tplFlag !== -1 && args[tplFlag + 1] ? args[tplFlag + 1] : 'todo';
36
+
37
+ // Try to find the built client runtime
38
+ const runtimePaths = [
39
+ resolve(import.meta.dirname, '../../../sdk-web-runtime/dist/magnetic.min.js'),
40
+ resolve(import.meta.dirname, '../../../../apps/task-board/public/magnetic.js'),
41
+ resolve(process.cwd(), 'js/packages/sdk-web-runtime/dist/magnetic.min.js'),
42
+ resolve(process.cwd(), 'apps/task-board/public/magnetic.js'),
43
+ ];
44
+ let runtimeSrc = null;
45
+ for (const p of runtimePaths) {
46
+ if (existsSync(p)) { runtimeSrc = p; break; }
47
+ }
48
+
49
+ const dir = scaffold(targetDir, { name, template, runtimeSrc });
50
+ console.log(`\n✓ Created Magnetic app: ${name}`);
51
+ console.log(` ${dir}\n`);
52
+ console.log(` Next steps:`);
53
+ console.log(` cd ${name}`);
54
+ console.log(` magnetic dev\n`);
55
+ if (!runtimeSrc) {
56
+ console.log(` ⚠ Client runtime (magnetic.js) not found — copy it to public/magnetic.js`);
57
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "create-magnetic-app",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a new Magnetic server-driven UI app",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-magnetic-app": "./bin/create.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": ["src", "bin"],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": { "type": "git", "url": "https://github.com/inventhq/magnetic.git", "directory": "js/packages/create-magnetic-app" },
17
+ "homepage": "https://github.com/inventhq/magnetic#readme",
18
+ "keywords": ["magnetic", "create-app", "scaffolder", "server-driven-ui"],
19
+ "license": "MIT"
20
+ }
package/src/index.js ADDED
@@ -0,0 +1,969 @@
1
+ /**
2
+ * create-magnetic-app — scaffolds a new Magnetic project.
3
+ *
4
+ * Generates:
5
+ * <name>/
6
+ * ├── pages/
7
+ * │ ├── IndexPage.tsx (main page component)
8
+ * │ └── NotFoundPage.tsx (404 page)
9
+ * ├── components/
10
+ * │ └── (shared components)
11
+ * ├── server/
12
+ * │ └── state.ts (business logic — state, reducer, viewModel)
13
+ * ├── public/
14
+ * │ ├── magnetic.js (client runtime)
15
+ * │ └── style.css (app styles)
16
+ * ├── magnetic.json (project config)
17
+ * └── README.md
18
+ *
19
+ * Developer writes ONLY pages/components + server/state.ts.
20
+ * The @magnetic/cli handles bundling, bridge generation, and server management.
21
+ */
22
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync } from 'fs';
23
+ import { join, resolve } from 'path';
24
+
25
+ /**
26
+ * @param {string} dest — target directory
27
+ * @param {object} opts
28
+ * @param {string} opts.name — project name
29
+ * @param {string} [opts.runtimeSrc] — path to magnetic.js to copy
30
+ * @param {string} [opts.template] — template name: "blank" | "todo" (default: "todo")
31
+ * @param {object} [opts.files] — extra files to write: { relativePath: content }
32
+ */
33
+ export function scaffold(dest, opts = {}) {
34
+ const name = opts.name || 'my-magnetic-app';
35
+ const template = opts.template || 'todo';
36
+ const dir = resolve(dest);
37
+
38
+ // Create directories
39
+ for (const d of ['pages', 'components', 'server', 'public']) {
40
+ mkdirSync(join(dir, d), { recursive: true });
41
+ }
42
+
43
+ // magnetic.json
44
+ writeFileSync(join(dir, 'magnetic.json'), JSON.stringify({
45
+ name,
46
+ server: 'http://localhost:3003',
47
+ }, null, 2) + '\n');
48
+
49
+ // tsconfig.json for IDE support
50
+ writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({
51
+ compilerOptions: {
52
+ target: 'ES2020',
53
+ module: 'ESNext',
54
+ moduleResolution: 'bundler',
55
+ jsx: 'react-jsx',
56
+ jsxImportSource: '@magneticjs/server',
57
+ strict: true,
58
+ noEmit: true,
59
+ allowImportingTsExtensions: true,
60
+ },
61
+ include: ['pages', 'components', 'server'],
62
+ }, null, 2) + '\n');
63
+
64
+ // Generate template files
65
+ if (template === 'todo') {
66
+ writeTodoTemplate(dir, name);
67
+ } else {
68
+ writeBlankTemplate(dir, name);
69
+ }
70
+
71
+ // README + GUIDE
72
+ writeFileSync(join(dir, 'README.md'), readme(name));
73
+ writeFileSync(join(dir, 'GUIDE.md'), guide(name));
74
+
75
+ // Copy client runtime if available
76
+ if (opts.runtimeSrc && existsSync(opts.runtimeSrc)) {
77
+ copyFileSync(opts.runtimeSrc, join(dir, 'public/magnetic.js'));
78
+ }
79
+
80
+ // Write any extra files
81
+ if (opts.files) {
82
+ for (const [rel, content] of Object.entries(opts.files)) {
83
+ const p = join(dir, rel);
84
+ mkdirSync(join(p, '..'), { recursive: true });
85
+ writeFileSync(p, content);
86
+ }
87
+ }
88
+
89
+ return dir;
90
+ }
91
+
92
+ // ── Todo template ──────────────────────────────────────────────────
93
+
94
+ function writeTodoTemplate(dir, name) {
95
+ // server/state.ts — business logic
96
+ writeFileSync(join(dir, 'server/state.ts'), `// ${name} — Server-side state and reducer
97
+ // This is your business logic. The Magnetic platform runs this on the server.
98
+ // State is never exposed to the client — only the rendered UI (JSON DOM) is sent.
99
+
100
+ export interface Todo {
101
+ id: number;
102
+ title: string;
103
+ completed: boolean;
104
+ }
105
+
106
+ export interface AppState {
107
+ todos: Todo[];
108
+ nextId: number;
109
+ filter: 'all' | 'active' | 'done';
110
+ }
111
+
112
+ export function initialState(): AppState {
113
+ return {
114
+ todos: [],
115
+ nextId: 1,
116
+ filter: 'all',
117
+ };
118
+ }
119
+
120
+ export function reduce(state: AppState, action: string, payload: any): AppState {
121
+ switch (action) {
122
+ case 'add_todo': {
123
+ const title = payload?.title?.trim();
124
+ if (!title) return state;
125
+ return {
126
+ ...state,
127
+ todos: [...state.todos, { id: state.nextId, title, completed: false }],
128
+ nextId: state.nextId + 1,
129
+ };
130
+ }
131
+
132
+ case 'filter_all':
133
+ return { ...state, filter: 'all' };
134
+ case 'filter_active':
135
+ return { ...state, filter: 'active' };
136
+ case 'filter_done':
137
+ return { ...state, filter: 'done' };
138
+
139
+ case 'clear_completed':
140
+ return { ...state, todos: state.todos.filter(t => !t.completed) };
141
+
142
+ default: {
143
+ // Parameterized actions: toggle_1, delete_1
144
+ const toggleMatch = action.match(/^toggle_(\\d+)$/);
145
+ if (toggleMatch) {
146
+ const id = parseInt(toggleMatch[1], 10);
147
+ return {
148
+ ...state,
149
+ todos: state.todos.map(t =>
150
+ t.id === id ? { ...t, completed: !t.completed } : t
151
+ ),
152
+ };
153
+ }
154
+
155
+ const deleteMatch = action.match(/^delete_(\\d+)$/);
156
+ if (deleteMatch) {
157
+ const id = parseInt(deleteMatch[1], 10);
158
+ return { ...state, todos: state.todos.filter(t => t.id !== id) };
159
+ }
160
+
161
+ return state;
162
+ }
163
+ }
164
+ }
165
+
166
+ export function toViewModel(state: AppState) {
167
+ const visibleTodos = state.todos
168
+ .filter(t => {
169
+ if (state.filter === 'active') return !t.completed;
170
+ if (state.filter === 'done') return t.completed;
171
+ return true;
172
+ })
173
+ .map(t => ({
174
+ ...t,
175
+ completedClass: t.completed ? 'completed' : '',
176
+ checkmark: t.completed ? '✓' : '○',
177
+ }));
178
+
179
+ const active = state.todos.filter(t => !t.completed).length;
180
+ const done = state.todos.filter(t => t.completed).length;
181
+ const total = state.todos.length;
182
+
183
+ return {
184
+ ...state,
185
+ visibleTodos,
186
+ activeCount: active,
187
+ doneCount: done,
188
+ totalCount: total,
189
+ summary: total === 0 ? 'No todos yet' : \`\${active} active, \${done} done\`,
190
+ filterAllClass: state.filter === 'all' ? 'active' : '',
191
+ filterActiveClass: state.filter === 'active' ? 'active' : '',
192
+ filterDoneClass: state.filter === 'done' ? 'active' : '',
193
+ isEmpty: visibleTodos.length === 0,
194
+ emptyMessage:
195
+ state.filter === 'active'
196
+ ? 'All done! Nothing active.'
197
+ : state.filter === 'done'
198
+ ? 'No completed todos yet.'
199
+ : 'Add your first todo above!',
200
+ };
201
+ }
202
+ `);
203
+
204
+ // pages/IndexPage.tsx — main page
205
+ writeFileSync(join(dir, 'pages/IndexPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
206
+ import { TodoInput } from '../components/TodoInput.tsx';
207
+ import { TodoFilters } from '../components/TodoFilters.tsx';
208
+ import { TodoItem } from '../components/TodoItem.tsx';
209
+
210
+ export function IndexPage(props: any) {
211
+ return (
212
+ <div class="todo-app" key="app">
213
+ <Head>
214
+ <title>{\`\${props.summary} | ${name}\`}</title>
215
+ <meta name="description" content="A todo app built with Magnetic" />
216
+ </Head>
217
+
218
+ <nav class="topnav" key="nav">
219
+ <Link href="/" class="nav-link active">Todos</Link>
220
+ <Link href="/about" class="nav-link">About</Link>
221
+ </nav>
222
+
223
+ <div class="header" key="header">
224
+ <h1 key="title">${name}</h1>
225
+ <p class="subtitle" key="subtitle">{props.summary}</p>
226
+ </div>
227
+
228
+ <TodoInput />
229
+
230
+ <TodoFilters
231
+ allClass={props.filterAllClass}
232
+ activeClass={props.filterActiveClass}
233
+ doneClass={props.filterDoneClass}
234
+ />
235
+
236
+ <div class="todo-list" key="todo-list">
237
+ {props.visibleTodos.map((todo: any) => (
238
+ <TodoItem todo={todo} />
239
+ ))}
240
+ </div>
241
+
242
+ {props.isEmpty && <p class="empty" key="empty">{props.emptyMessage}</p>}
243
+
244
+ {props.doneCount > 0 && (
245
+ <div class="footer" key="footer">
246
+ <button class="clear-btn" onClick="clear_completed" key="clear">
247
+ Clear completed ({props.doneCount})
248
+ </button>
249
+ </div>
250
+ )}
251
+ </div>
252
+ );
253
+ }
254
+ `);
255
+
256
+ // pages/AboutPage.tsx
257
+ writeFileSync(join(dir, 'pages/AboutPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
258
+
259
+ export function AboutPage(props: any) {
260
+ return (
261
+ <div class="about-page" key="about">
262
+ <Head>
263
+ <title>About | ${name}</title>
264
+ </Head>
265
+
266
+ <nav class="topnav" key="nav">
267
+ <Link href="/" class="nav-link">Todos</Link>
268
+ <Link href="/about" class="nav-link active">About</Link>
269
+ </nav>
270
+
271
+ <div class="content" key="content">
272
+ <h1>About</h1>
273
+ <p>This app was built with Magnetic — a server-driven UI framework.</p>
274
+
275
+ <h2>How it works</h2>
276
+ <ul>
277
+ <li>All state lives on the server (Rust + V8)</li>
278
+ <li>UI is rendered as JSON DOM descriptors on the server</li>
279
+ <li>The client is a thin rendering shell (~1.5KB)</li>
280
+ <li>Real-time updates via Server-Sent Events</li>
281
+ <li>Actions are sent to the server, which re-renders and pushes updates</li>
282
+ </ul>
283
+
284
+ <h2>What the developer writes</h2>
285
+ <ul>
286
+ <li>TSX page components (this page!)</li>
287
+ <li>Business logic in server/state.ts</li>
288
+ <li>That's it. No client-side JS, no state management, no build config.</li>
289
+ </ul>
290
+
291
+ <p><Link href="/">← Back to todos</Link></p>
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+ `);
297
+
298
+ // pages/NotFoundPage.tsx
299
+ writeFileSync(join(dir, 'pages/NotFoundPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
300
+
301
+ export function NotFoundPage(props: any) {
302
+ return (
303
+ <div class="not-found" key="404">
304
+ <Head><title>404 | ${name}</title></Head>
305
+ <h1>404</h1>
306
+ <p>Page not found</p>
307
+ <p><Link href="/">← Back home</Link></p>
308
+ </div>
309
+ );
310
+ }
311
+ `);
312
+
313
+ // components/TodoInput.tsx
314
+ writeFileSync(join(dir, 'components/TodoInput.tsx'), `export function TodoInput() {
315
+ return (
316
+ <form class="add-form" onSubmit="add_todo" key="input">
317
+ <input
318
+ type="text"
319
+ name="title"
320
+ placeholder="What needs to be done?"
321
+ autocomplete="off"
322
+ autofocus
323
+ />
324
+ <button type="submit">Add</button>
325
+ </form>
326
+ );
327
+ }
328
+ `);
329
+
330
+ // components/TodoFilters.tsx
331
+ writeFileSync(join(dir, 'components/TodoFilters.tsx'), `export function TodoFilters(props: {
332
+ allClass: string;
333
+ activeClass: string;
334
+ doneClass: string;
335
+ }) {
336
+ return (
337
+ <div class="filters" key="filters">
338
+ <button class={\`filter-btn \${props.allClass}\`} onClick="filter_all" key="f-all">All</button>
339
+ <button class={\`filter-btn \${props.activeClass}\`} onClick="filter_active" key="f-active">Active</button>
340
+ <button class={\`filter-btn \${props.doneClass}\`} onClick="filter_done" key="f-done">Done</button>
341
+ </div>
342
+ );
343
+ }
344
+ `);
345
+
346
+ // components/TodoItem.tsx
347
+ writeFileSync(join(dir, 'components/TodoItem.tsx'), `export function TodoItem(props: { todo: any }) {
348
+ const { todo } = props;
349
+ return (
350
+ <div class={\`todo-card \${todo.completedClass}\`} key={\`todo-\${todo.id}\`}>
351
+ <button class="check" onClick={\`toggle_\${todo.id}\`} key={\`chk-\${todo.id}\`}>
352
+ {todo.checkmark}
353
+ </button>
354
+ <span class="todo-title" key={\`title-\${todo.id}\`}>{todo.title}</span>
355
+ <button class="delete" onClick={\`delete_\${todo.id}\`} key={\`del-\${todo.id}\`}>
356
+ ×
357
+ </button>
358
+ </div>
359
+ );
360
+ }
361
+ `);
362
+
363
+ // public/style.css
364
+ writeFileSync(join(dir, 'public/style.css'), todoStyles(name));
365
+ }
366
+
367
+ // ── Blank template ─────────────────────────────────────────────────
368
+
369
+ function writeBlankTemplate(dir, name) {
370
+ writeFileSync(join(dir, 'server/state.ts'), `export interface AppState {}
371
+
372
+ export function initialState(): AppState {
373
+ return {};
374
+ }
375
+
376
+ export function reduce(state: AppState, action: string, payload: any): AppState {
377
+ return state;
378
+ }
379
+
380
+ export function toViewModel(state: AppState) {
381
+ return { ...state };
382
+ }
383
+ `);
384
+
385
+ writeFileSync(join(dir, 'pages/IndexPage.tsx'), `import { Head } from '@magneticjs/server/jsx-runtime';
386
+
387
+ export function IndexPage(props: any) {
388
+ return (
389
+ <div class="app" key="app">
390
+ <Head><title>${name}</title></Head>
391
+ <h1 key="title">Welcome to ${name}</h1>
392
+ <p key="desc">Edit pages/IndexPage.tsx to get started.</p>
393
+ </div>
394
+ );
395
+ }
396
+ `);
397
+
398
+ writeFileSync(join(dir, 'pages/NotFoundPage.tsx'), `import { Head, Link } from '@magneticjs/server/jsx-runtime';
399
+
400
+ export function NotFoundPage(props: any) {
401
+ return (
402
+ <div class="not-found" key="404">
403
+ <Head><title>404 | ${name}</title></Head>
404
+ <h1>404</h1>
405
+ <p>Page not found</p>
406
+ <p><Link href="/">← Back home</Link></p>
407
+ </div>
408
+ );
409
+ }
410
+ `);
411
+
412
+ writeFileSync(join(dir, 'public/style.css'), `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
413
+ body {
414
+ font-family: system-ui, -apple-system, sans-serif;
415
+ background: #0a0a0a; color: #e4e4e7;
416
+ display: flex; justify-content: center;
417
+ min-height: 100vh; padding: 2rem;
418
+ }
419
+ .app {
420
+ max-width: 520px; width: 100%;
421
+ background: #141414; border: 1px solid #252525; border-radius: 16px;
422
+ padding: 2rem; text-align: center;
423
+ }
424
+ h1 { font-size: 1.75rem; color: #fff; margin-bottom: .5rem; }
425
+ p { color: #a1a1aa; }
426
+ .not-found { text-align: center; padding: 3rem; }
427
+ .not-found h1 { font-size: 3rem; color: #6366f1; }
428
+ .not-found a { color: #6366f1; text-decoration: none; }
429
+ `);
430
+ }
431
+
432
+ // ── Styles ─────────────────────────────────────────────────────────
433
+
434
+ function todoStyles(name) {
435
+ return `*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
436
+ body {
437
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
438
+ background: #0a0a0a; color: #e4e4e7;
439
+ display: flex; justify-content: center;
440
+ min-height: 100vh; padding: 2rem;
441
+ }
442
+
443
+ /* Navigation */
444
+ .topnav {
445
+ display: flex; gap: 1rem; margin-bottom: 1.25rem;
446
+ justify-content: center;
447
+ }
448
+ .nav-link {
449
+ color: #71717a; text-decoration: none; font-size: .85rem;
450
+ font-weight: 500; padding: .25rem .5rem; border-radius: 6px;
451
+ transition: all .15s;
452
+ }
453
+ .nav-link:hover { color: #e4e4e7; }
454
+ .nav-link.active { color: #6366f1; }
455
+
456
+ /* App container */
457
+ .todo-app, .about-page, .not-found {
458
+ max-width: 520px; width: 100%;
459
+ background: #141414; border: 1px solid #252525; border-radius: 16px;
460
+ padding: 1.5rem; box-shadow: 0 8px 32px rgba(0,0,0,.5);
461
+ }
462
+ .header { text-align: center; margin-bottom: 1.25rem; }
463
+ .header h1 { font-size: 1.75rem; font-weight: 700; color: #fff; margin-bottom: .25rem; }
464
+ .subtitle { font-size: .85rem; color: #71717a; }
465
+
466
+ /* Input form */
467
+ .add-form {
468
+ display: flex; gap: .5rem; margin-bottom: 1rem;
469
+ }
470
+ .add-form input {
471
+ flex: 1; background: #0d0d0d; border: 1px solid #333;
472
+ border-radius: 10px; padding: .6rem .875rem; color: #e4e4e7;
473
+ font-size: .9rem; outline: none; transition: border-color .15s;
474
+ }
475
+ .add-form input:focus { border-color: #6366f1; }
476
+ .add-form button {
477
+ background: #6366f1; color: #fff; border: none;
478
+ border-radius: 10px; padding: .6rem 1.25rem; font-size: .9rem;
479
+ font-weight: 600; cursor: pointer; transition: background .15s;
480
+ }
481
+ .add-form button:hover { background: #4f46e5; }
482
+
483
+ /* Filters */
484
+ .filters {
485
+ display: flex; gap: .375rem; margin-bottom: 1rem;
486
+ justify-content: center;
487
+ }
488
+ .filter-btn {
489
+ background: #1a1a2e; color: #71717a; border: 1px solid #252525;
490
+ border-radius: 8px; padding: .375rem .875rem; font-size: .8rem;
491
+ cursor: pointer; transition: all .15s;
492
+ }
493
+ .filter-btn:hover { color: #e4e4e7; border-color: #333; }
494
+ .filter-btn.active {
495
+ background: #6366f1; color: #fff; border-color: #6366f1;
496
+ }
497
+
498
+ /* Todo list */
499
+ .todo-list { display: flex; flex-direction: column; gap: .5rem; }
500
+ .todo-card {
501
+ display: flex; align-items: center; gap: .625rem;
502
+ background: #0d0d0d; border: 1px solid #252525; border-radius: 10px;
503
+ padding: .625rem .875rem; transition: all .15s;
504
+ }
505
+ .todo-card:hover { border-color: #333; }
506
+ .todo-card.completed { opacity: .5; }
507
+ .todo-card.completed .todo-title { text-decoration: line-through; color: #71717a; }
508
+
509
+ .check {
510
+ width: 28px; height: 28px; flex-shrink: 0;
511
+ display: flex; align-items: center; justify-content: center;
512
+ background: transparent; border: 2px solid #333;
513
+ border-radius: 50%; color: #71717a; font-size: .8rem;
514
+ cursor: pointer; transition: all .15s;
515
+ }
516
+ .check:hover { border-color: #6366f1; color: #6366f1; }
517
+ .todo-card.completed .check {
518
+ background: #6366f1; border-color: #6366f1; color: #fff;
519
+ }
520
+
521
+ .todo-title { flex: 1; font-size: .9rem; }
522
+
523
+ .delete {
524
+ background: transparent; border: none; color: #71717a;
525
+ font-size: 1.1rem; cursor: pointer; padding: .25rem;
526
+ line-height: 1; transition: color .15s;
527
+ }
528
+ .delete:hover { color: #ef4444; }
529
+
530
+ .empty {
531
+ text-align: center; color: #71717a; font-size: .85rem;
532
+ padding: 2rem 0; font-style: italic;
533
+ }
534
+
535
+ .footer {
536
+ margin-top: 1rem; text-align: center;
537
+ }
538
+ .clear-btn {
539
+ background: transparent; border: 1px solid #333; color: #71717a;
540
+ border-radius: 8px; padding: .375rem .875rem; font-size: .8rem;
541
+ cursor: pointer; transition: all .15s;
542
+ }
543
+ .clear-btn:hover { color: #ef4444; border-color: #ef4444; }
544
+
545
+ /* About page */
546
+ .about-page .content { line-height: 1.6; }
547
+ .about-page h1 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: .75rem; }
548
+ .about-page h2 { font-size: 1.1rem; font-weight: 600; color: #e4e4e7; margin: 1.25rem 0 .5rem; }
549
+ .about-page p { color: #a1a1aa; margin-bottom: .5rem; }
550
+ .about-page ul { padding-left: 1.25rem; color: #a1a1aa; }
551
+ .about-page li { margin-bottom: .375rem; }
552
+ .about-page a { color: #6366f1; text-decoration: none; }
553
+ .about-page a:hover { text-decoration: underline; }
554
+
555
+ /* 404 */
556
+ .not-found { text-align: center; padding: 3rem 1.5rem; }
557
+ .not-found h1 { font-size: 3rem; font-weight: 800; color: #6366f1; margin-bottom: .5rem; }
558
+ .not-found p { color: #71717a; margin-bottom: .5rem; }
559
+ .not-found a { color: #6366f1; text-decoration: none; }
560
+ .not-found a:hover { text-decoration: underline; }
561
+ `;
562
+ }
563
+
564
+ // ── GUIDE ──────────────────────────────────────────────────────────
565
+
566
+ function guide(name) {
567
+ return `# Magnetic Developer Guide
568
+
569
+ A complete reference for building components, pages, and business logic.
570
+
571
+ ## Architecture Overview
572
+
573
+ \`\`\`
574
+ Developer writes: Magnetic handles:
575
+ pages/*.tsx → Auto-bridge generation
576
+ components/*.tsx → Bundling (esbuild)
577
+ server/state.ts → Rust V8 server execution
578
+ public/style.css → SSR + SSE + action dispatch
579
+ \`\`\`
580
+
581
+ **Key principle**: All state lives on the server. The client is a ~1.5KB
582
+ rendering shell. Your TSX runs in V8 on the server — never in the browser.
583
+
584
+ ---
585
+
586
+ ## Pages
587
+
588
+ Pages are TSX files in \`pages/\`. The filename determines the route:
589
+
590
+ | File | Route |
591
+ |--------------------------|-------------|
592
+ | \`pages/IndexPage.tsx\` | \`/\` |
593
+ | \`pages/AboutPage.tsx\` | \`/about\` |
594
+ | \`pages/SettingsPage.tsx\` | \`/settings\` |
595
+ | \`pages/[id].tsx\` | \`/:id\` |
596
+ | \`pages/NotFoundPage.tsx\` | \`*\` (404) |
597
+
598
+ ### Page template
599
+
600
+ \`\`\`tsx
601
+ import { Head, Link } from '@magneticjs/server/jsx-runtime';
602
+
603
+ export function MyPage(props: any) {
604
+ return (
605
+ <div key="page">
606
+ <Head>
607
+ <title>Page Title</title>
608
+ <meta name="description" content="Page description" />
609
+ </Head>
610
+
611
+ <h1 key="heading">{props.someValue}</h1>
612
+ <Link href="/other">Go somewhere</Link>
613
+ </div>
614
+ );
615
+ }
616
+ \`\`\`
617
+
618
+ ### Rules for pages
619
+ - **Export a named function** matching the filename (e.g. \`MyPage\`)
620
+ - **\`props\`** = the view model from \`toViewModel()\` in \`server/state.ts\`
621
+ - **\`key\`** attributes help the client efficiently patch the DOM — add them to elements that change
622
+ - **\`<Head>\`** sets \`<title>\` and \`<meta>\` during SSR
623
+ - **\`<Link>\`** does client-side navigation (no full page reload)
624
+
625
+ ---
626
+
627
+ ## Components
628
+
629
+ Components are TSX files in \`components/\`. They are regular functions that
630
+ receive props and return JSX. Import them into pages or other components.
631
+
632
+ ### Example: Button component
633
+
634
+ \`\`\`tsx
635
+ // components/Button.tsx
636
+ export function Button(props: {
637
+ label: string;
638
+ action: string;
639
+ variant?: 'primary' | 'danger' | 'ghost';
640
+ }) {
641
+ const cls = \\\`btn btn-\\\${props.variant || 'primary'}\\\`;
642
+ return (
643
+ <button class={cls} onClick={props.action} key={\\\`btn-\\\${props.action}\\\`}>
644
+ {props.label}
645
+ </button>
646
+ );
647
+ }
648
+ \`\`\`
649
+
650
+ ### Example: Card component
651
+
652
+ \`\`\`tsx
653
+ // components/Card.tsx
654
+ export function Card(props: { title: string; children?: any }) {
655
+ return (
656
+ <div class="card" key={\\\`card-\\\${props.title}\\\`}>
657
+ <h3 class="card-title">{props.title}</h3>
658
+ <div class="card-body">{props.children}</div>
659
+ </div>
660
+ );
661
+ }
662
+ \`\`\`
663
+
664
+ ### Example: List component with iteration
665
+
666
+ \`\`\`tsx
667
+ // components/ItemList.tsx
668
+ export function ItemList(props: { items: Array<{ id: number; name: string }> }) {
669
+ return (
670
+ <ul class="item-list" key="items">
671
+ {props.items.map(item => (
672
+ <li key={\\\`item-\\\${item.id}\\\`}>
673
+ <span>{item.name}</span>
674
+ <button onClick={\\\`delete_\\\${item.id}\\\`}>Remove</button>
675
+ </li>
676
+ ))}
677
+ </ul>
678
+ );
679
+ }
680
+ \`\`\`
681
+
682
+ ### Example: Conditional rendering
683
+
684
+ \`\`\`tsx
685
+ // components/StatusBadge.tsx
686
+ export function StatusBadge(props: { isOnline: boolean }) {
687
+ return (
688
+ <span class={\\\`badge \\\${props.isOnline ? 'online' : 'offline'}\\\`} key="status">
689
+ {props.isOnline ? 'Online' : 'Offline'}
690
+ </span>
691
+ );
692
+ }
693
+ \`\`\`
694
+
695
+ ### Example: Form component
696
+
697
+ \`\`\`tsx
698
+ // components/SearchForm.tsx
699
+ export function SearchForm() {
700
+ return (
701
+ <form onSubmit="search" key="search-form">
702
+ <input type="text" name="query" placeholder="Search..." />
703
+ <button type="submit">Search</button>
704
+ </form>
705
+ );
706
+ }
707
+ \`\`\`
708
+
709
+ When the form is submitted, Magnetic collects all \`<input>\` values by their
710
+ \`name\` attribute and sends them as the action payload:
711
+ \`{ query: "user typed this" }\`
712
+
713
+ ### Example: Live input (debounced)
714
+
715
+ \`\`\`tsx
716
+ // components/LiveSearch.tsx
717
+ export function LiveSearch() {
718
+ return (
719
+ <input
720
+ type="text"
721
+ name="q"
722
+ placeholder="Type to search..."
723
+ onInput="live_search"
724
+ key="live-search"
725
+ />
726
+ );
727
+ }
728
+ \`\`\`
729
+
730
+ \`onInput\` is debounced (300ms). Payload: \`{ value: "current input value" }\`
731
+
732
+ ---
733
+
734
+ ## Business Logic (server/state.ts)
735
+
736
+ All business logic lives in \`server/state.ts\`. This file exports 3 functions:
737
+
738
+ ### 1. \`initialState()\` — starting state
739
+
740
+ \`\`\`ts
741
+ export interface AppState {
742
+ items: Item[];
743
+ searchQuery: string;
744
+ currentUser: string | null;
745
+ }
746
+
747
+ export function initialState(): AppState {
748
+ return {
749
+ items: [],
750
+ searchQuery: '',
751
+ currentUser: null,
752
+ };
753
+ }
754
+ \`\`\`
755
+
756
+ ### 2. \`reduce(state, action, payload)\` — handles actions
757
+
758
+ This is a **pure function**. Given the current state, an action name, and a
759
+ payload, return the new state. Never mutate — always return a new object.
760
+
761
+ \`\`\`ts
762
+ export function reduce(state: AppState, action: string, payload: any): AppState {
763
+ switch (action) {
764
+ // Simple action (no payload)
765
+ case 'reset':
766
+ return initialState();
767
+
768
+ // Action with payload (from form submission)
769
+ case 'add_item': {
770
+ const name = payload?.name?.trim();
771
+ if (!name) return state;
772
+ return {
773
+ ...state,
774
+ items: [...state.items, { id: Date.now(), name, done: false }],
775
+ };
776
+ }
777
+
778
+ // Action with payload (from onInput)
779
+ case 'live_search':
780
+ return { ...state, searchQuery: payload?.value || '' };
781
+
782
+ // Parameterized actions (action name encodes the ID)
783
+ default: {
784
+ const m = action.match(/^toggle_(\\\\d+)$/);
785
+ if (m) {
786
+ const id = parseInt(m[1], 10);
787
+ return {
788
+ ...state,
789
+ items: state.items.map(i =>
790
+ i.id === id ? { ...i, done: !i.done } : i
791
+ ),
792
+ };
793
+ }
794
+ return state;
795
+ }
796
+ }
797
+ }
798
+ \`\`\`
799
+
800
+ ### 3. \`toViewModel(state)\` — prepares data for the UI
801
+
802
+ Transform raw state into the shape your pages and components need.
803
+ This is where you compute derived values, filter lists, format strings, etc.
804
+
805
+ \`\`\`ts
806
+ export function toViewModel(state: AppState) {
807
+ const filtered = state.items.filter(i =>
808
+ i.name.toLowerCase().includes(state.searchQuery.toLowerCase())
809
+ );
810
+
811
+ return {
812
+ items: filtered.map(i => ({
813
+ ...i,
814
+ statusClass: i.done ? 'done' : '',
815
+ statusIcon: i.done ? '✓' : '○',
816
+ })),
817
+ totalCount: state.items.length,
818
+ doneCount: state.items.filter(i => i.done).length,
819
+ hasSearch: state.searchQuery.length > 0,
820
+ };
821
+ }
822
+ \`\`\`
823
+
824
+ The object returned by \`toViewModel()\` is passed as \`props\` to every page component.
825
+
826
+ ---
827
+
828
+ ## Event Reference
829
+
830
+ Events in Magnetic are **action names** (strings), not JavaScript callbacks.
831
+
832
+ | JSX Prop | HTML Behavior | Payload |
833
+ |----------------|-----------------------------------------|----------------------------|
834
+ | \`onClick\` | Click → POST to server | \`{}\` |
835
+ | \`onSubmit\` | Form submit → collect inputs by name | \`{ name: value, ... }\` |
836
+ | \`onInput\` | Keystroke (300ms debounce) | \`{ value: "current text" }\` |
837
+
838
+ ### How actions flow
839
+
840
+ \`\`\`
841
+ User clicks button (onClick="do_thing")
842
+ → magnetic.js POSTs to /actions/do_thing
843
+ → Rust server calls reduce(state, "do_thing", {})
844
+ → New state → toViewModel() → page re-renders
845
+ → New JSON DOM sent back to client
846
+ → magnetic.js patches the DOM in-place
847
+ → SSE broadcasts update to all connected clients
848
+ \`\`\`
849
+
850
+ ### Parameterized actions
851
+
852
+ Encode IDs in the action name:
853
+
854
+ \`\`\`tsx
855
+ <button onClick={\\\`delete_\\\${item.id}\\\`}>Delete</button>
856
+ \`\`\`
857
+
858
+ Then parse in the reducer:
859
+ \`\`\`ts
860
+ const m = action.match(/^delete_(\\\\d+)$/);
861
+ if (m) {
862
+ const id = parseInt(m[1], 10);
863
+ return { ...state, items: state.items.filter(i => i.id !== id) };
864
+ }
865
+ \`\`\`
866
+
867
+ ---
868
+
869
+ ## Navigation
870
+
871
+ Use \`<Link>\` for client-side navigation (no page reload):
872
+
873
+ \`\`\`tsx
874
+ import { Link } from '@magneticjs/server/jsx-runtime';
875
+
876
+ <Link href="/about">About</Link>
877
+ <Link href="/users/42">User Profile</Link>
878
+ \`\`\`
879
+
880
+ Under the hood, \`<Link>\` renders an \`<a>\` with \`onClick="navigate:/about"\`.
881
+ The client runtime intercepts this, does \`pushState\`, and requests the new
882
+ page from the server.
883
+
884
+ ---
885
+
886
+ ## Keys — Important!
887
+
888
+ Every element that **changes between renders** should have a \`key\` attribute.
889
+ Keys help the client runtime efficiently patch the DOM instead of re-creating it.
890
+
891
+ \`\`\`tsx
892
+ // Good: stable keys on dynamic content
893
+ {items.map(item => (
894
+ <div key={\\\`item-\\\${item.id}\\\`}>{item.name}</div>
895
+ ))}
896
+
897
+ // Good: keys on sections that update
898
+ <h1 key="title">{props.title}</h1>
899
+ <p key="count">{props.count} items</p>
900
+
901
+ // Not needed: static content that never changes
902
+ <footer>Made with Magnetic</footer>
903
+ \`\`\`
904
+
905
+ ---
906
+
907
+ ## Styling
908
+
909
+ Put CSS in \`public/style.css\`. It's automatically inlined into the SSR HTML
910
+ (no extra network request). Use standard CSS — no build step needed.
911
+
912
+ ---
913
+
914
+ ## Tips
915
+
916
+ 1. **Think server-first**: Your TSX never runs in the browser. No \`useState\`, no \`useEffect\`, no DOM APIs.
917
+ 2. **State is private**: The client never sees your AppState. Only the JSON DOM is sent.
918
+ 3. **Keep reducers pure**: No side effects, no \`fetch()\`, no timers. Just state → new state.
919
+ 4. **View model is your API**: \`toViewModel()\` shapes exactly what the UI sees.
920
+ 5. **Small components**: Break UI into small components with clear props.
921
+ 6. **Keys matter**: Add \`key\` to any element whose content or presence changes.
922
+ `;
923
+ }
924
+
925
+ // ── README ─────────────────────────────────────────────────────────
926
+
927
+ function readme(name) {
928
+ return `# ${name}
929
+
930
+ A server-driven UI app built with [Magnetic](https://github.com/inventhq/magnetic).
931
+
932
+ ## Quick Start
933
+
934
+ \`\`\`bash
935
+ # Start development server (auto-rebuilds on file changes)
936
+ magnetic dev
937
+
938
+ # Or: build + deploy to a Magnetic platform server
939
+ magnetic build
940
+ magnetic push --server http://your-platform:3003
941
+ \`\`\`
942
+
943
+ Then open http://localhost:3003
944
+
945
+ ## Project Structure
946
+
947
+ \`\`\`
948
+ ${name}/
949
+ ├── pages/ ← Page components (auto-routed by filename)
950
+ │ ├── IndexPage.tsx → /
951
+ │ ├── AboutPage.tsx → /about
952
+ │ └── NotFoundPage.tsx→ * (404)
953
+ ├── components/ ← Shared components
954
+ ├── server/
955
+ │ └── state.ts ← Business logic (state, reducer, viewModel)
956
+ ├── public/ ← Static files (CSS, images, client runtime)
957
+ └── magnetic.json ← Project config
958
+ \`\`\`
959
+
960
+ ## How It Works
961
+
962
+ 1. **You write** pages (TSX) + business logic (state.ts)
963
+ 2. **Magnetic CLI** auto-generates the bridge, bundles, and runs the Rust V8 server
964
+ 3. **The server** renders your TSX to JSON DOM descriptors and pushes updates via SSE
965
+ 4. **The client** (~1.5KB) is a thin rendering shell — no React, no virtual DOM
966
+
967
+ All state lives on the server. The client never sees your business logic.
968
+ `;
969
+ }