create-zoijs 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to `create-zoijs` are documented here.
4
4
 
5
+ ## 0.1.2 — 2026-06-26
6
+
7
+ Dev-server output polish. No CLI API changes; same zero-dependency server on the
8
+ same ports.
9
+
10
+ - **Cleaner banner:** `npm run dev` now prints a tidy banner with the local URL
11
+ on its own line, and only mentions fallback ports when one was actually busy:
12
+
13
+ ```text
14
+ Zoijs dev server
15
+
16
+ - Local: http://localhost:7310
17
+ ```
18
+
19
+ (Previously a single `ZoiJS dev server: http://localhost:7310` line plus a
20
+ static `If busy: …` note.) Plain ASCII so it renders cleanly in every terminal,
21
+ including the Windows console. Brand casing normalized to **Zoijs**. Still port
22
+ **7310** with **7311 / 7312 / 7313** fallbacks; still Node built-ins only.
23
+
24
+ ## 0.1.1 — 2026-06-25
25
+
26
+ Starter polish. No CLI API changes.
27
+
28
+ - **Dev server:** generated apps now run `npm run dev` on a tiny zero-dependency
29
+ static server (`dev-server.mjs`, Node built-ins only) that uses port **7310**
30
+ and falls back to **7311 / 7312 / 7313** if busy — replacing the old default
31
+ of port 3000. It prints `ZoiJS dev server: http://localhost:<port>`.
32
+ - **Default `app` template redesigned** into a polished "project dashboard":
33
+ a hero header, a reusable `StatCard` (used ×4), filter chips (All / Active /
34
+ Done), an empty state, and a developer-hint footer — demonstrating component
35
+ composition, conditional rendering, and filtering alongside `createState` /
36
+ `computed` / `each` and parent⇄child communication. Still depends only on
37
+ `@zoijs/core`; still no build step.
38
+ - **`basic` template** keeps the minimal counter and adopts the same dev server.
39
+
5
40
  ## 0.1.0 — 2026-06-25
6
41
 
7
42
  Initial release of the Zoijs starter CLI.
package/README.md CHANGED
@@ -20,6 +20,17 @@ npm install
20
20
  npm run dev
21
21
  ```
22
22
 
23
+ `npm run dev` starts a tiny zero-dependency static server (no `serve`, no
24
+ bundler, no build step):
25
+
26
+ ```text
27
+ Zoijs dev server
28
+
29
+ - Local: http://localhost:7310
30
+ ```
31
+
32
+ (It falls back to 7311–7313 if the port is busy.)
33
+
23
34
  Or with `npx`:
24
35
 
25
36
  ```bash
@@ -47,11 +58,12 @@ one.
47
58
  ```bash
48
59
  npm create zoijs@latest my-app # default: the "app" template
49
60
  npm create zoijs@latest my-app --template basic # a minimal counter
50
- npm create zoijs@latest my-app --template app # a small task dashboard
61
+ npm create zoijs@latest my-app --template app # a polished project dashboard
51
62
  ```
52
63
 
53
- - **`app`** (default) — a small task dashboard showing `html`, `mount`,
54
- `createState`, `computed`, `each`, and parent⇄child component communication.
64
+ - **`app`** (default) — a polished project dashboard (hero, stat cards, filters,
65
+ empty state) showing `html`, `mount`, `createState`, `computed`, `each`,
66
+ conditional rendering, and parent⇄child component communication.
55
67
  - **`basic`** — a minimal counter, the smallest possible Zoijs app.
56
68
 
57
69
  ## What it does
@@ -75,7 +75,7 @@ export function ensureTargetAvailable(targetDir) {
75
75
  }
76
76
  }
77
77
 
78
- const TEXT_EXT = new Set([".js", ".html", ".css", ".json", ".md", ".txt", ".svg"]);
78
+ const TEXT_EXT = new Set([".js", ".mjs", ".html", ".css", ".json", ".md", ".txt", ".svg"]);
79
79
  const isText = (file) => file === "_gitignore" || TEXT_EXT.has(path.extname(file));
80
80
 
81
81
  function applyTokens(content, tokens) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zoijs",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Create a new Zoijs app with one command. Plain HTML, CSS, and JavaScript — no build step.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  # {{APP_TITLE}}
2
2
 
3
- A small task dashboard built with [Zoijs](https://zoijs.dev) — plain HTML, CSS,
4
- and JavaScript. **No build step.**
3
+ A small **project dashboard** built with [Zoijs](https://zoijs.dev) — plain HTML,
4
+ CSS, and JavaScript. **No build step.**
5
5
 
6
6
  ## Develop
7
7
 
@@ -10,27 +10,37 @@ npm install
10
10
  npm run dev
11
11
  ```
12
12
 
13
- Then open the printed URL.
13
+ ```text
14
+ Zoijs dev server
15
+
16
+ - Local: http://localhost:7310
17
+ ```
18
+
19
+ Open the printed URL (it falls back to 7311–7313 if the port is busy).
14
20
 
15
21
  ## Project layout
16
22
 
17
23
  ```
18
24
  {{APP_NAME}}/
19
25
  index.html import map + #app mount point
26
+ dev-server.mjs tiny zero-dependency static server (npm run dev)
20
27
  src/
21
- app.js owns the task list; renders the page
22
- style.css plain CSS
28
+ app.js owns all state; composes the dashboard
29
+ style.css plain CSS (light/dark, responsive)
23
30
  components/
24
- Header.js parent → child: receives title + count
31
+ Header.js parent → child: hero + summary stats
32
+ StatCard.js parent → child: a reusable stat card (used ×4)
25
33
  TaskItem.js child → parent: reports toggle/delete via callbacks
26
34
  ```
27
35
 
28
36
  ## What it shows
29
37
 
30
- - **`createState`** — the task list and the input draft.
31
- - **`computed`** — the "tasks left" count, derived from the list.
32
- - **`each`** — keyed rendering of the task list.
33
- - **Parent → child** — `App` passes data down to `Header` as arguments.
38
+ - **`createState`** — the task list, the active filter, and the input draft.
39
+ - **`computed`** — total / completed / active counts and the filtered list.
40
+ - **`each`** — keyed rendering of the visible tasks.
41
+ - **Conditional rendering** — the empty state and the active-filter highlight.
42
+ - **Component composition** — `Header`, `StatCard` (×4), and `TaskItem`.
43
+ - **Parent → child** — `App` passes data down to its components as arguments.
34
44
  - **Child → parent** — `TaskItem` reports events up through callbacks; `App`
35
45
  owns the state and performs the changes.
36
46
 
@@ -0,0 +1,74 @@
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();
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "npx serve ."
7
+ "dev": "node dev-server.mjs"
8
8
  },
9
9
  "dependencies": {
10
10
  "@zoijs/core": "^1.1.0"
@@ -1,54 +1,97 @@
1
1
  import { html, mount, createState, computed, each } from "@zoijs/core";
2
2
  import { Header } from "./components/Header.js";
3
+ import { StatCard } from "./components/StatCard.js";
3
4
  import { TaskItem } from "./components/TaskItem.js";
4
5
 
5
- // The App owns the task list. Children receive data as arguments (parent → child)
6
- // and report events back through callbacks (child → parent). No store, no context.
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.
7
9
  function App() {
10
+ // --- state -----------------------------------------------------------------
8
11
  const tasks = createState([
9
- { id: 1, text: "Read the Zoijs docs", done: true },
10
- { id: 2, text: "Build something small", done: false },
11
- { id: 3, text: "Share it", done: false },
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 },
12
16
  ]);
13
- let nextId = 4;
14
-
15
- // computed: derived, cached, reactive.
16
- const remaining = computed(() => tasks.get().filter((t) => !t.done).length);
17
+ const filter = createState("all"); // "all" | "active" | "done"
18
+ const draft = createState("");
19
+ let nextId = 5;
17
20
 
18
- const addTask = (text) => tasks.set([...tasks.get(), { id: nextId++, text, done: false }]);
19
- const toggle = (id) => tasks.set(tasks.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
20
- const remove = (id) => tasks.set(tasks.get().filter((t) => t.id !== id));
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
+ });
21
29
 
22
- const draft = createState("");
23
- const onSubmit = (e) => {
30
+ // --- actions ---------------------------------------------------------------
31
+ const addTask = (e) => {
24
32
  e.preventDefault();
25
33
  const text = draft.get().trim();
26
34
  if (!text) return;
27
- addTask(text);
35
+ tasks.set([...tasks.get(), { id: nextId++, text, done: false }]);
28
36
  draft.set("");
29
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
+ `;
30
48
 
31
49
  return html`
32
- <main>
33
- ${/* parent → child: pass plain data (title) and a reader (remaining) down */ ""}
34
- ${Header({ title: "{{APP_TITLE}}", remaining: () => remaining.get() })}
35
-
36
- <form onsubmit=${onSubmit}>
37
- <input
38
- placeholder="Add a task…"
39
- value=${() => draft.get()}
40
- oninput=${(e) => draft.set(e.target.value)} />
41
- <button type="submit">Add</button>
42
- </form>
43
-
44
- <ul class="tasks">
45
- ${each(
46
- () => tasks.get(),
47
- (t) => t.id,
48
- (t) => TaskItem({ task: t, onToggle: toggle, onDelete: remove })
49
- )}
50
- </ul>
51
- </main>
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>
52
95
  `;
53
96
  }
54
97
 
@@ -1,13 +1,15 @@
1
1
  import { html } from "@zoijs/core";
2
2
 
3
- // Parent → child communication: Header receives plain data (`title`) and a
4
- // reader function (`remaining`) as arguments. It owns no state of its own.
5
- export function Header({ title, remaining }) {
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
6
  return html`
7
- <header>
8
- <h1>${title}</h1>
9
- <p class="count">
10
- ${() => remaining()} task${() => (remaining() === 1 ? "" : "s")} left
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
11
13
  </p>
12
14
  </header>
13
15
  `;
@@ -0,0 +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,10 +1,10 @@
1
1
  import { html } from "@zoijs/core";
2
2
 
3
- // Child → parent communication: TaskItem owns no state. It reports toggle and
4
- // delete events upward by calling the callbacks the parent passed in.
3
+ // Child -> parent: TaskItem owns no state. It reports toggle and delete events
4
+ // upward by calling the callbacks the parent passed in.
5
5
  export function TaskItem({ task, onToggle, onDelete }) {
6
6
  return html`
7
- <li class=${() => (task.done ? "done" : "")}>
7
+ <li class=${() => "task" + (task.done ? " done" : "")}>
8
8
  <label>
9
9
  <input
10
10
  type="checkbox"
@@ -12,7 +12,7 @@ export function TaskItem({ task, onToggle, onDelete }) {
12
12
  onchange=${() => onToggle(task.id)} />
13
13
  <span>${() => task.text}</span>
14
14
  </label>
15
- <button class="delete" title="Delete" onclick=${() => onDelete(task.id)}>✕</button>
15
+ <button class="delete" title="Delete task" onclick=${() => onDelete(task.id)}>✕</button>
16
16
  </li>
17
17
  `;
18
18
  }
@@ -1,53 +1,168 @@
1
+ /* A small, polished dashboard theme — plain CSS, no framework.
2
+ Adapts to light/dark automatically and stays readable on mobile. */
3
+
1
4
  :root {
2
5
  color-scheme: light dark;
3
- font-family: system-ui, sans-serif;
4
- line-height: 1.5;
6
+ --bg: #f6f7fb;
7
+ --panel: #ffffff;
8
+ --text: #1e2230;
9
+ --muted: #6b7280;
10
+ --border: #e6e8ef;
11
+ --accent: #6366f1;
12
+ --accent-2: #8b5cf6;
13
+ --radius: 14px;
14
+ --shadow: 0 1px 2px rgba(20, 22, 40, 0.04), 0 8px 24px rgba(20, 22, 40, 0.06);
15
+ }
16
+
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --bg: #0e1016;
20
+ --panel: #171a22;
21
+ --text: #e7e9f0;
22
+ --muted: #9aa1b1;
23
+ --border: #262a36;
24
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 10px 30px rgba(0, 0, 0, 0.35);
25
+ }
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
5
30
  }
6
31
 
7
32
  body {
8
33
  margin: 0;
9
- display: grid;
10
- place-items: start center;
11
34
  min-height: 100vh;
35
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
36
+ line-height: 1.5;
37
+ color: var(--text);
38
+ background:
39
+ radial-gradient(1200px 500px at 50% -200px, rgba(99, 102, 241, 0.18), transparent 70%),
40
+ var(--bg);
12
41
  }
13
42
 
14
- main {
15
- width: min(34rem, 92vw);
16
- padding: 2rem 1rem;
43
+ .app {
44
+ width: min(56rem, 92vw);
45
+ margin: 0 auto;
46
+ padding: clamp(1.5rem, 4vw, 3rem) 0 3rem;
17
47
  }
18
48
 
19
- header h1 {
20
- margin: 0 0 0.25rem;
49
+ /* --- hero ------------------------------------------------------------------ */
50
+ .hero {
51
+ text-align: center;
52
+ margin-bottom: 1.75rem;
53
+ }
54
+ .badge {
55
+ display: inline-block;
56
+ font-size: 0.72rem;
57
+ font-weight: 600;
58
+ letter-spacing: 0.04em;
59
+ text-transform: uppercase;
60
+ color: #fff;
61
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
62
+ padding: 0.3rem 0.7rem;
63
+ border-radius: 999px;
64
+ }
65
+ .hero h1 {
66
+ margin: 0.7rem 0 0.25rem;
67
+ font-size: clamp(1.8rem, 5vw, 2.6rem);
68
+ letter-spacing: -0.02em;
69
+ }
70
+ .tagline {
71
+ margin: 0 auto;
72
+ max-width: 34rem;
73
+ color: var(--muted);
74
+ }
75
+ .summary {
76
+ margin: 0.6rem 0 0;
77
+ color: var(--muted);
21
78
  }
22
79
 
23
- .count {
24
- margin: 0 0 1rem;
25
- color: gray;
80
+ /* --- cards & stats --------------------------------------------------------- */
81
+ .card {
82
+ background: var(--panel);
83
+ border: 1px solid var(--border);
84
+ border-radius: var(--radius);
85
+ box-shadow: var(--shadow);
26
86
  }
27
87
 
28
- form {
88
+ .stats {
89
+ display: grid;
90
+ grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
91
+ gap: 0.9rem;
92
+ margin-bottom: 1.25rem;
93
+ }
94
+ .stat {
95
+ padding: 1rem 1.1rem;
96
+ border-left: 3px solid var(--accent);
97
+ }
98
+ .stat-indigo { border-left-color: #6366f1; }
99
+ .stat-green { border-left-color: #10b981; }
100
+ .stat-amber { border-left-color: #f59e0b; }
101
+ .stat-slate { border-left-color: #64748b; }
102
+ .stat-value {
103
+ font-size: 1.7rem;
104
+ font-weight: 700;
105
+ text-transform: capitalize;
106
+ }
107
+ .stat-label {
108
+ font-size: 0.85rem;
109
+ color: var(--muted);
110
+ }
111
+
112
+ /* --- task panel ------------------------------------------------------------ */
113
+ .panel {
114
+ background: var(--panel);
115
+ border: 1px solid var(--border);
116
+ border-radius: var(--radius);
117
+ box-shadow: var(--shadow);
118
+ padding: 1.25rem;
119
+ }
120
+
121
+ .add {
29
122
  display: flex;
30
123
  gap: 0.5rem;
31
- margin-bottom: 1rem;
124
+ margin-bottom: 0.9rem;
32
125
  }
33
-
34
- input[type="text"],
35
- form input {
126
+ .add input {
36
127
  flex: 1;
37
128
  font: inherit;
38
- padding: 0.5rem 0.7rem;
39
- border: 1px solid rgba(127, 127, 127, 0.5);
40
- border-radius: 8px;
129
+ padding: 0.6rem 0.8rem;
130
+ color: var(--text);
41
131
  background: transparent;
132
+ border: 1px solid var(--border);
133
+ border-radius: 10px;
134
+ }
135
+ .add input:focus-visible {
136
+ outline: 2px solid var(--accent);
137
+ outline-offset: 1px;
42
138
  }
43
-
44
139
  button {
45
140
  font: inherit;
46
- padding: 0.5rem 0.9rem;
47
- border: 1px solid currentColor;
48
- border-radius: 8px;
49
- background: transparent;
50
141
  cursor: pointer;
142
+ border-radius: 10px;
143
+ border: 1px solid transparent;
144
+ }
145
+ .add button {
146
+ padding: 0.6rem 1.1rem;
147
+ color: #fff;
148
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
149
+ }
150
+
151
+ .filters {
152
+ display: flex;
153
+ gap: 0.4rem;
154
+ margin-bottom: 0.9rem;
155
+ }
156
+ .chip {
157
+ padding: 0.35rem 0.85rem;
158
+ color: var(--muted);
159
+ background: transparent;
160
+ border: 1px solid var(--border);
161
+ }
162
+ .chip-on {
163
+ color: var(--text);
164
+ border-color: var(--accent);
165
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
51
166
  }
52
167
 
53
168
  .tasks {
@@ -55,37 +170,72 @@ button {
55
170
  margin: 0;
56
171
  padding: 0;
57
172
  display: grid;
58
- gap: 0.4rem;
173
+ gap: 0.45rem;
59
174
  }
60
-
61
- .tasks li {
175
+ .task {
62
176
  display: flex;
63
177
  align-items: center;
64
178
  justify-content: space-between;
65
- gap: 0.5rem;
66
- padding: 0.5rem 0.7rem;
67
- border: 1px solid rgba(127, 127, 127, 0.25);
68
- border-radius: 8px;
69
- }
70
-
71
- .tasks li.done span {
72
- text-decoration: line-through;
73
- opacity: 0.6;
179
+ gap: 0.6rem;
180
+ padding: 0.6rem 0.75rem;
181
+ border: 1px solid var(--border);
182
+ border-radius: 10px;
74
183
  }
75
-
76
- .tasks label {
184
+ .task label {
77
185
  display: flex;
78
186
  align-items: center;
79
- gap: 0.5rem;
187
+ gap: 0.6rem;
80
188
  cursor: pointer;
81
189
  }
82
-
190
+ .task input[type="checkbox"] {
191
+ width: 1.1rem;
192
+ height: 1.1rem;
193
+ accent-color: var(--accent);
194
+ }
195
+ .task.done span {
196
+ text-decoration: line-through;
197
+ color: var(--muted);
198
+ }
83
199
  .delete {
84
200
  border: none;
201
+ background: transparent;
202
+ color: var(--muted);
85
203
  padding: 0.2rem 0.5rem;
86
- opacity: 0.6;
204
+ font-size: 1rem;
87
205
  }
88
-
89
206
  .delete:hover {
90
- opacity: 1;
207
+ color: #ef4444;
208
+ }
209
+
210
+ .empty {
211
+ text-align: center;
212
+ color: var(--muted);
213
+ padding: 1.5rem 0 0.5rem;
214
+ }
215
+
216
+ /* --- dev hint -------------------------------------------------------------- */
217
+ .hint {
218
+ text-align: center;
219
+ color: var(--muted);
220
+ font-size: 0.9rem;
221
+ margin-top: 1.5rem;
222
+ }
223
+ .hint p {
224
+ margin: 0.2rem 0;
225
+ }
226
+ code {
227
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
228
+ font-size: 0.85em;
229
+ padding: 0.1rem 0.35rem;
230
+ border-radius: 6px;
231
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
232
+ }
233
+
234
+ @media (max-width: 30rem) {
235
+ .add {
236
+ flex-direction: column;
237
+ }
238
+ .add button {
239
+ width: 100%;
240
+ }
91
241
  }
@@ -9,11 +9,18 @@ npm install
9
9
  npm run dev
10
10
  ```
11
11
 
12
- Then open the printed URL.
12
+ ```text
13
+ Zoijs dev server
14
+
15
+ - Local: http://localhost:7310
16
+ ```
17
+
18
+ Open the printed URL (it falls back to 7311–7313 if the port is busy).
13
19
 
14
20
  ## How it works
15
21
 
16
22
  - **`index.html`** — an import map points `@zoijs/core` at `node_modules`, then loads `src/app.js`.
23
+ - **`dev-server.mjs`** — a tiny zero-dependency static server (Node built-ins only) that `npm run dev` runs.
17
24
  - **`src/app.js`** — your app: `createState` for state, `html` for markup, `mount` to render.
18
25
  - **`src/style.css`** — plain CSS.
19
26
 
@@ -0,0 +1,74 @@
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();
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "dev": "npx serve ."
7
+ "dev": "node dev-server.mjs"
8
8
  },
9
9
  "dependencies": {
10
10
  "@zoijs/core": "^1.1.0"