create-zoijs 0.1.3 → 0.2.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +127 -74
  2. package/LICENSE +21 -21
  3. package/README.md +98 -97
  4. package/bin/create-zoijs.js +197 -169
  5. package/core-cdn.json +36 -0
  6. package/package.json +44 -43
  7. package/templates/app/README.md +68 -48
  8. package/templates/app/_gitignore +1 -1
  9. package/templates/app/_vscode/extensions.json +7 -0
  10. package/templates/app/dev-server.mjs +124 -74
  11. package/templates/app/index.html +24 -22
  12. package/templates/app/jsconfig.json +16 -0
  13. package/templates/app/package.json +12 -12
  14. package/templates/app/src/app.js +98 -98
  15. package/templates/app/src/components/Header.js +16 -16
  16. package/templates/app/src/components/StatCard.js +13 -13
  17. package/templates/app/src/components/TaskItem.js +18 -18
  18. package/templates/app/src/style.css +241 -241
  19. package/templates/basic/README.md +50 -30
  20. package/templates/basic/_gitignore +1 -1
  21. package/templates/basic/_vscode/extensions.json +7 -0
  22. package/templates/basic/dev-server.mjs +124 -74
  23. package/templates/basic/index.html +24 -22
  24. package/templates/basic/jsconfig.json +16 -0
  25. package/templates/basic/package.json +12 -12
  26. package/templates/basic/src/app.js +18 -18
  27. package/templates/basic/src/style.css +33 -33
  28. package/templates/library/_vscode/extensions.json +7 -0
  29. package/templates/library/package.json +2 -2
  30. package/templates/minimal/README.md +29 -8
  31. package/templates/minimal/dev-server.mjs +124 -0
  32. package/templates/minimal/index.html +5 -3
  33. package/templates/minimal/package.json +9 -0
  34. package/templates/typescript/README.md +20 -0
  35. package/templates/typescript/_gitignore +1 -1
  36. package/templates/typescript/_vscode/extensions.json +7 -0
  37. package/templates/typescript/dev-server.mjs +124 -74
  38. package/templates/typescript/index.html +24 -22
  39. package/templates/typescript/package.json +1 -1
  40. package/templates/typescript/src/style.css +33 -33
@@ -1,74 +1,124 @@
1
- // dev-server.mjs — a tiny static file server for this Zoijs app.
2
- //
3
- // Zero dependencies (Node built-ins only), no build step, no bundler. It serves
4
- // the project files so the import map in index.html can load @zoijs/core from
5
- // node_modules. Tries port 7310, then 7311 / 7312 / 7313 if one is busy.
6
- //
7
- // node dev-server.mjs (this is what `npm run dev` runs)
8
-
9
- import { createServer } from "node:http";
10
- import { readFile } from "node:fs/promises";
11
- import { extname, join, normalize } from "node:path";
12
-
13
- const PORTS = [7310, 7311, 7312, 7313];
14
- const ROOT = process.cwd();
15
-
16
- const TYPES = {
17
- ".html": "text/html; charset=utf-8",
18
- ".js": "text/javascript; charset=utf-8",
19
- ".mjs": "text/javascript; charset=utf-8",
20
- ".css": "text/css; charset=utf-8",
21
- ".json": "application/json; charset=utf-8",
22
- ".svg": "image/svg+xml",
23
- ".ico": "image/x-icon",
24
- ".png": "image/png",
25
- ".woff2": "font/woff2",
26
- };
27
-
28
- const server = createServer(async (req, res) => {
29
- let pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
30
- if (pathname === "/") pathname = "/index.html";
31
-
32
- // Resolve inside ROOT and refuse anything that climbs out of it.
33
- const filePath = join(ROOT, normalize(pathname));
34
- if (!filePath.startsWith(ROOT)) {
35
- res.writeHead(403).end("Forbidden");
36
- return;
37
- }
38
- try {
39
- const body = await readFile(filePath);
40
- res.writeHead(200, { "Content-Type": TYPES[extname(filePath)] || "application/octet-stream" });
41
- res.end(body);
42
- } catch {
43
- res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not found");
44
- }
45
- });
46
-
47
- function start(i = 0) {
48
- if (i >= PORTS.length) {
49
- console.error(`\nAll dev ports are busy (${PORTS.join(", ")}). Free one and try again.\n`);
50
- process.exit(1);
51
- }
52
- // Paired once-listeners: each attempt removes its sibling so a failed bind
53
- // can't leave a stale "listening" handler that prints the wrong port later.
54
- const onError = (err) => {
55
- server.removeListener("listening", onListening);
56
- if (err.code === "EADDRINUSE") start(i + 1);
57
- else {
58
- console.error(err);
59
- process.exit(1);
60
- }
61
- };
62
- const onListening = () => {
63
- server.removeListener("error", onError);
64
- const lines = ["", " Zoijs dev server", "", ` - Local: http://localhost:${PORTS[i]}`];
65
- if (i > 0) lines.push(` (ports ${PORTS.slice(0, i).join(", ")} were busy)`);
66
- lines.push("");
67
- console.log(lines.join("\n"));
68
- };
69
- server.once("error", onError);
70
- server.once("listening", onListening);
71
- server.listen(PORTS[i]);
72
- }
73
-
74
- start();
1
+ // dev-server.mjs — a tiny static file server for this Zoijs app. DEVELOPMENT ONLY.
2
+ //
3
+ // Zero dependencies (Node built-ins only), no build step, no bundler. It serves
4
+ // the project files — index.html, your modules, and whatever its import map points
5
+ // at inside this folder (node_modules/, if you installed). Tries port 7310, then
6
+ // 7311 / 7312 / 7313 if one is busy. The same file ships in every Zoijs template.
7
+ //
8
+ // node dev-server.mjs (this is what `npm run dev` runs)
9
+ //
10
+ // Safe defaults for a dev server:
11
+ // - listens on 127.0.0.1 only — other devices on your network can't reach it.
12
+ // To test from a phone on your LAN, opt in explicitly: ZOIJS_HOST=0.0.0.0 npm run dev
13
+ // (anyone on that network can then read your project files while it runs).
14
+ // - never serves dotfiles or dot-directories (.env, .git/, .npmrc, …);
15
+ // - never serves anything outside this folder (no `..`, no symlinks pointing out —
16
+ // except package-manager links inside node_modules/);
17
+ // - answers malformed URLs with 400 instead of crashing.
18
+ // It is not a production server: deploy the static files to a real host.
19
+
20
+ import { createServer } from "node:http";
21
+ import { readFile, realpath } from "node:fs/promises";
22
+ import { extname, resolve, relative, isAbsolute } from "node:path";
23
+
24
+ const PORTS = [7310, 7311, 7312, 7313];
25
+ const HOST = process.env.ZOIJS_HOST || "127.0.0.1";
26
+ const ROOT = resolve(process.cwd());
27
+
28
+ const TYPES = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".js": "text/javascript; charset=utf-8",
31
+ ".mjs": "text/javascript; charset=utf-8",
32
+ ".css": "text/css; charset=utf-8",
33
+ ".json": "application/json; charset=utf-8",
34
+ ".svg": "image/svg+xml",
35
+ ".ico": "image/x-icon",
36
+ ".png": "image/png",
37
+ ".woff2": "font/woff2",
38
+ };
39
+
40
+ const HEADERS = { "X-Content-Type-Options": "nosniff", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
41
+
42
+ // Is `path` ROOT itself or inside it? (relative() handles separators, case and
43
+ // sibling folders like `app-secrets` correctly — unlike a string-prefix check.)
44
+ const inside = (base, path) => {
45
+ const rel = relative(base, path);
46
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
47
+ };
48
+
49
+ function send(res, status, body, type = "text/plain; charset=utf-8") {
50
+ res.writeHead(status, { ...HEADERS, "Content-Type": type });
51
+ res.end(body);
52
+ }
53
+
54
+ const server = createServer(async (req, res) => {
55
+ try {
56
+ let pathname;
57
+ try {
58
+ // Only the path (never the query string) maps to a file.
59
+ pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
60
+ } catch {
61
+ return send(res, 400, "Bad request");
62
+ }
63
+ if (pathname.includes("\0")) return send(res, 400, "Bad request");
64
+ if (pathname === "/") pathname = "/index.html";
65
+
66
+ const segments = pathname.split(/[\\/]+/).filter(Boolean);
67
+ // Hidden files and folders (.env, .git/, .npmrc, node_modules/.bin, …) are never
68
+ // served. 404, not 403, so it doesn't reveal whether they exist.
69
+ if (segments.some((s) => s.startsWith("."))) return send(res, 404, "Not found");
70
+
71
+ const filePath = resolve(ROOT, ...segments);
72
+ if (!inside(ROOT, filePath)) return send(res, 404, "Not found");
73
+
74
+ // Don't follow symlinks out of the project. Package managers (npm link, pnpm)
75
+ // legitimately link packages into node_modules/, so links there are allowed.
76
+ let real;
77
+ try {
78
+ real = await realpath(filePath);
79
+ } catch {
80
+ return send(res, 404, "Not found");
81
+ }
82
+ if (!inside(ROOT, real) && segments[0] !== "node_modules") return send(res, 404, "Not found");
83
+
84
+ const body = await readFile(real);
85
+ send(res, 200, body, TYPES[extname(filePath)] || "application/octet-stream");
86
+ } catch {
87
+ // Directories, permission errors, files deleted mid-request: a generic answer
88
+ // that never echoes a filesystem path.
89
+ if (!res.headersSent) send(res, 404, "Not found");
90
+ else res.destroy();
91
+ }
92
+ });
93
+
94
+ function start(i = 0) {
95
+ if (i >= PORTS.length) {
96
+ console.error(`\nAll dev ports are busy (${PORTS.join(", ")}). Free one and try again.\n`);
97
+ process.exit(1);
98
+ }
99
+ // Paired once-listeners: each attempt removes its sibling so a failed bind
100
+ // can't leave a stale "listening" handler that prints the wrong port later.
101
+ const onError = (err) => {
102
+ server.removeListener("listening", onListening);
103
+ if (err.code === "EADDRINUSE") start(i + 1);
104
+ else {
105
+ console.error(err);
106
+ process.exit(1);
107
+ }
108
+ };
109
+ const onListening = () => {
110
+ server.removeListener("error", onError);
111
+ const lines = ["", " Zoijs dev server", "", ` - Local: http://localhost:${PORTS[i]}`];
112
+ if (i > 0) lines.push(` (ports ${PORTS.slice(0, i).join(", ")} were busy)`);
113
+ if (HOST !== "127.0.0.1" && HOST !== "localhost" && HOST !== "::1") {
114
+ lines.push(` ⚠ Listening on ${HOST} (ZOIJS_HOST): other devices on your network can read`, ` this project's files while the server runs. Development only.`);
115
+ }
116
+ lines.push("");
117
+ console.log(lines.join("\n"));
118
+ };
119
+ server.once("error", onError);
120
+ server.once("listening", onListening);
121
+ server.listen(PORTS[i], HOST);
122
+ }
123
+
124
+ start();
@@ -1,22 +1,24 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>{{APP_TITLE}}</title>
7
- <link rel="stylesheet" href="./src/style.css" />
8
- <!-- No build step: an import map points the @zoijs/core name at the package
9
- installed in node_modules. `npm install` puts it there. -->
10
- <script type="importmap">
11
- {
12
- "imports": {
13
- "@zoijs/core": "./node_modules/@zoijs/core/src/index.js"
14
- }
15
- }
16
- </script>
17
- </head>
18
- <body>
19
- <div id="app"></div>
20
- <script type="module" src="./src/app.js"></script>
21
- </body>
22
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{APP_TITLE}}</title>
7
+ <link rel="stylesheet" href="./src/style.css" />
8
+ <!-- No build step: an import map points the @zoijs/core name at the package
9
+ installed in node_modules. `npm install` puts it there. -->
10
+ <!-- Development entry (warnings + devtools). When you deploy, point "@zoijs/core"
11
+ at ./node_modules/@zoijs/core/src/prod.js — the production entry. -->
12
+ <script type="importmap">
13
+ {
14
+ "imports": {
15
+ "@zoijs/core": "./node_modules/@zoijs/core/src/index.js"
16
+ }
17
+ }
18
+ </script>
19
+ </head>
20
+ <body>
21
+ <div id="app"></div>
22
+ <script type="module" src="./src/app.js"></script>
23
+ </body>
24
+ </html>
@@ -0,0 +1,16 @@
1
+ {
2
+ // jsconfig.json — gives your editor IntelliSense (autocomplete, hover, go-to-
3
+ // definition) for Zoijs and the DOM, from the types every package ships. No build
4
+ // step, no TypeScript migration. Flip "checkJs" to true to also TYPE-CHECK your
5
+ // JavaScript against those types (or add `// @ts-check` to a single file).
6
+ "compilerOptions": {
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "target": "ES2022",
10
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
11
+ "checkJs": false,
12
+ "strict": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["src"]
16
+ }
@@ -1,12 +1,12 @@
1
- {
2
- "name": "{{APP_NAME}}",
3
- "version": "0.0.0",
4
- "private": true,
5
- "type": "module",
6
- "scripts": {
7
- "dev": "node dev-server.mjs"
8
- },
9
- "dependencies": {
10
- "@zoijs/core": "^1.1.0"
11
- }
12
- }
1
+ {
2
+ "name": "{{APP_NAME}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "node dev-server.mjs"
8
+ },
9
+ "dependencies": {
10
+ "@zoijs/core": "^{{ZOIJS_CORE_VERSION}}"
11
+ }
12
+ }
@@ -1,98 +1,98 @@
1
- import { html, mount, createState, computed, each } from "@zoijs/core";
2
- import { Header } from "./components/Header.js";
3
- import { StatCard } from "./components/StatCard.js";
4
- import { TaskItem } from "./components/TaskItem.js";
5
-
6
- // The App owns all the state. Child components receive data as arguments
7
- // (parent -> child) and report events back through callbacks (child -> parent).
8
- // No store, no context, no providers — just functions and reactive values.
9
- function App() {
10
- // --- state -----------------------------------------------------------------
11
- const tasks = createState([
12
- { id: 1, text: "Read the Zoijs guide", done: true },
13
- { id: 2, text: "Scaffold a project", done: true },
14
- { id: 3, text: "Design the dashboard", done: false },
15
- { id: 4, text: "Ship something small", done: false },
16
- ]);
17
- const filter = createState("all"); // "all" | "active" | "done"
18
- const draft = createState("");
19
- let nextId = 5;
20
-
21
- // --- derived values (computed: cached + reactive) --------------------------
22
- const total = computed(() => tasks.get().length);
23
- const completed = computed(() => tasks.get().filter((t) => t.done).length);
24
- const active = computed(() => total.get() - completed.get());
25
- const visible = computed(() => {
26
- const f = filter.get();
27
- return tasks.get().filter((t) => (f === "all" ? true : f === "done" ? t.done : !t.done));
28
- });
29
-
30
- // --- actions ---------------------------------------------------------------
31
- const addTask = (e) => {
32
- e.preventDefault();
33
- const text = draft.get().trim();
34
- if (!text) return;
35
- tasks.set([...tasks.get(), { id: nextId++, text, done: false }]);
36
- draft.set("");
37
- };
38
- const toggle = (id) => tasks.set(tasks.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
39
- const remove = (id) => tasks.set(tasks.get().filter((t) => t.id !== id));
40
-
41
- const filterChip = (value, label) => html`
42
- <button
43
- class=${() => "chip" + (filter.get() === value ? " chip-on" : "")}
44
- onclick=${() => filter.set(value)}>
45
- ${label}
46
- </button>
47
- `;
48
-
49
- return html`
50
- <div class="app">
51
- ${Header({ completed: () => completed.get(), total: () => total.get() })}
52
-
53
- <section class="stats">
54
- ${StatCard({ label: "Total", value: () => total.get(), accent: "indigo" })}
55
- ${StatCard({ label: "Completed", value: () => completed.get(), accent: "green" })}
56
- ${StatCard({ label: "Active", value: () => active.get(), accent: "amber" })}
57
- ${StatCard({ label: "Filter", value: () => filter.get(), accent: "slate" })}
58
- </section>
59
-
60
- <section class="panel">
61
- <form class="add" onsubmit=${addTask}>
62
- <input
63
- aria-label="Add a task"
64
- placeholder="Add a task…"
65
- value=${() => draft.get()}
66
- oninput=${(e) => draft.set(e.target.value)} />
67
- <button type="submit">Add</button>
68
- </form>
69
-
70
- <div class="filters" role="group" aria-label="Filter tasks">
71
- ${filterChip("all", "All")}
72
- ${filterChip("active", "Active")}
73
- ${filterChip("done", "Done")}
74
- </div>
75
-
76
- <ul class="tasks">
77
- ${each(
78
- () => visible.get(),
79
- (t) => t.id,
80
- (t) => TaskItem({ task: t, onToggle: toggle, onDelete: remove })
81
- )}
82
- </ul>
83
-
84
- ${() =>
85
- visible.get().length === 0
86
- ? html`<p class="empty">Nothing here yet — add a task above. ✨</p>`
87
- : null}
88
- </section>
89
-
90
- <footer class="hint">
91
- <p>This starter uses <code>html()</code>, <code>createState()</code>, <code>computed()</code>, and <code>each()</code>.</p>
92
- <p>Open <code>src/app.js</code> to start editing.</p>
93
- </footer>
94
- </div>
95
- `;
96
- }
97
-
98
- mount(App, "#app");
1
+ import { html, mount, createState, computed, each } from "@zoijs/core";
2
+ import { Header } from "./components/Header.js";
3
+ import { StatCard } from "./components/StatCard.js";
4
+ import { TaskItem } from "./components/TaskItem.js";
5
+
6
+ // The App owns all the state. Child components receive data as arguments
7
+ // (parent -> child) and report events back through callbacks (child -> parent).
8
+ // No store, no context, no providers — just functions and reactive values.
9
+ function App() {
10
+ // --- state -----------------------------------------------------------------
11
+ const tasks = createState([
12
+ { id: 1, text: "Read the Zoijs guide", done: true },
13
+ { id: 2, text: "Scaffold a project", done: true },
14
+ { id: 3, text: "Design the dashboard", done: false },
15
+ { id: 4, text: "Ship something small", done: false },
16
+ ]);
17
+ const filter = createState("all"); // "all" | "active" | "done"
18
+ const draft = createState("");
19
+ let nextId = 5;
20
+
21
+ // --- derived values (computed: cached + reactive) --------------------------
22
+ const total = computed(() => tasks.get().length);
23
+ const completed = computed(() => tasks.get().filter((t) => t.done).length);
24
+ const active = computed(() => total.get() - completed.get());
25
+ const visible = computed(() => {
26
+ const f = filter.get();
27
+ return tasks.get().filter((t) => (f === "all" ? true : f === "done" ? t.done : !t.done));
28
+ });
29
+
30
+ // --- actions ---------------------------------------------------------------
31
+ const addTask = (e) => {
32
+ e.preventDefault();
33
+ const text = draft.get().trim();
34
+ if (!text) return;
35
+ tasks.set([...tasks.get(), { id: nextId++, text, done: false }]);
36
+ draft.set("");
37
+ };
38
+ const toggle = (id) => tasks.set(tasks.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
39
+ const remove = (id) => tasks.set(tasks.get().filter((t) => t.id !== id));
40
+
41
+ const filterChip = (value, label) => html`
42
+ <button
43
+ class=${() => "chip" + (filter.get() === value ? " chip-on" : "")}
44
+ onclick=${() => filter.set(value)}>
45
+ ${label}
46
+ </button>
47
+ `;
48
+
49
+ return html`
50
+ <div class="app">
51
+ ${Header({ completed: () => completed.get(), total: () => total.get() })}
52
+
53
+ <section class="stats">
54
+ ${StatCard({ label: "Total", value: () => total.get(), accent: "indigo" })}
55
+ ${StatCard({ label: "Completed", value: () => completed.get(), accent: "green" })}
56
+ ${StatCard({ label: "Active", value: () => active.get(), accent: "amber" })}
57
+ ${StatCard({ label: "Filter", value: () => filter.get(), accent: "slate" })}
58
+ </section>
59
+
60
+ <section class="panel">
61
+ <form class="add" onsubmit=${addTask}>
62
+ <input
63
+ aria-label="Add a task"
64
+ placeholder="Add a task…"
65
+ value=${() => draft.get()}
66
+ oninput=${(e) => draft.set(e.target.value)} />
67
+ <button type="submit">Add</button>
68
+ </form>
69
+
70
+ <div class="filters" role="group" aria-label="Filter tasks">
71
+ ${filterChip("all", "All")}
72
+ ${filterChip("active", "Active")}
73
+ ${filterChip("done", "Done")}
74
+ </div>
75
+
76
+ <ul class="tasks">
77
+ ${each(
78
+ () => visible.get(),
79
+ (t) => t.id,
80
+ (t) => TaskItem({ task: t, onToggle: toggle, onDelete: remove })
81
+ )}
82
+ </ul>
83
+
84
+ ${() =>
85
+ visible.get().length === 0
86
+ ? html`<p class="empty">Nothing here yet — add a task above. ✨</p>`
87
+ : null}
88
+ </section>
89
+
90
+ <footer class="hint">
91
+ <p>This starter uses <code>html()</code>, <code>createState()</code>, <code>computed()</code>, and <code>each()</code>.</p>
92
+ <p>Open <code>src/app.js</code> to start editing.</p>
93
+ </footer>
94
+ </div>
95
+ `;
96
+ }
97
+
98
+ mount(App, "#app");
@@ -1,16 +1,16 @@
1
- import { html } from "@zoijs/core";
2
-
3
- // Parent -> child: the Header receives derived stats as reader functions and
4
- // renders the hero. It holds no state of its own.
5
- export function Header({ completed, total }) {
6
- return html`
7
- <header class="hero">
8
- <span class="badge">Built with Zoijs</span>
9
- <h1>{{APP_TITLE}}</h1>
10
- <p class="tagline">A tiny project dashboard — plain HTML, CSS, and JavaScript. No build step.</p>
11
- <p class="summary">
12
- <strong>${() => completed()}</strong> of <strong>${() => total()}</strong> tasks done
13
- </p>
14
- </header>
15
- `;
16
- }
1
+ import { html } from "@zoijs/core";
2
+
3
+ // Parent -> child: the Header receives derived stats as reader functions and
4
+ // renders the hero. It holds no state of its own.
5
+ export function Header({ completed, total }) {
6
+ return html`
7
+ <header class="hero">
8
+ <span class="badge">Built with Zoijs</span>
9
+ <h1>{{APP_TITLE}}</h1>
10
+ <p class="tagline">A tiny project dashboard — plain HTML, CSS, and JavaScript. No build step.</p>
11
+ <p class="summary">
12
+ <strong>${() => completed()}</strong> of <strong>${() => total()}</strong> tasks done
13
+ </p>
14
+ </header>
15
+ `;
16
+ }
@@ -1,13 +1,13 @@
1
- import { html } from "@zoijs/core";
2
-
3
- // A small, reusable presentational component used four times in the stats grid.
4
- // Parent -> child: it simply renders the label, value, and accent it is given —
5
- // composition without any shared state.
6
- export function StatCard({ label, value, accent }) {
7
- return html`
8
- <div class="card stat stat-${accent}">
9
- <div class="stat-value">${() => value()}</div>
10
- <div class="stat-label">${label}</div>
11
- </div>
12
- `;
13
- }
1
+ import { html } from "@zoijs/core";
2
+
3
+ // A small, reusable presentational component used four times in the stats grid.
4
+ // Parent -> child: it simply renders the label, value, and accent it is given —
5
+ // composition without any shared state.
6
+ export function StatCard({ label, value, accent }) {
7
+ return html`
8
+ <div class="card stat stat-${accent}">
9
+ <div class="stat-value">${() => value()}</div>
10
+ <div class="stat-label">${label}</div>
11
+ </div>
12
+ `;
13
+ }
@@ -1,18 +1,18 @@
1
- import { html } from "@zoijs/core";
2
-
3
- // Child -> parent: TaskItem owns no state. It reports toggle and delete events
4
- // upward by calling the callbacks the parent passed in.
5
- export function TaskItem({ task, onToggle, onDelete }) {
6
- return html`
7
- <li class=${() => "task" + (task.done ? " done" : "")}>
8
- <label>
9
- <input
10
- type="checkbox"
11
- checked=${() => task.done}
12
- onchange=${() => onToggle(task.id)} />
13
- <span>${() => task.text}</span>
14
- </label>
15
- <button class="delete" title="Delete task" onclick=${() => onDelete(task.id)}>✕</button>
16
- </li>
17
- `;
18
- }
1
+ import { html } from "@zoijs/core";
2
+
3
+ // Child -> parent: TaskItem owns no state. It reports toggle and delete events
4
+ // upward by calling the callbacks the parent passed in.
5
+ export function TaskItem({ task, onToggle, onDelete }) {
6
+ return html`
7
+ <li class=${() => "task" + (task.done ? " done" : "")}>
8
+ <label>
9
+ <input
10
+ type="checkbox"
11
+ checked=${() => task.done}
12
+ onchange=${() => onToggle(task.id)} />
13
+ <span>${() => task.text}</span>
14
+ </label>
15
+ <button class="delete" title="Delete task" onclick=${() => onDelete(task.id)}>✕</button>
16
+ </li>
17
+ `;
18
+ }