create-zoijs 0.1.0 → 0.1.1
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 +16 -0
- package/README.md +12 -3
- package/bin/create-zoijs.js +1 -1
- package/package.json +1 -1
- package/templates/app/README.md +19 -10
- package/templates/app/dev-server.mjs +71 -0
- package/templates/app/package.json +1 -1
- package/templates/app/src/app.js +78 -35
- package/templates/app/src/components/Header.js +9 -7
- package/templates/app/src/components/StatCard.js +13 -0
- package/templates/app/src/components/TaskItem.js +4 -4
- package/templates/app/src/style.css +194 -44
- package/templates/basic/README.md +7 -1
- package/templates/basic/dev-server.mjs +71 -0
- package/templates/basic/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-zoijs` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.1 — 2026-06-25
|
|
6
|
+
|
|
7
|
+
Starter polish. No CLI API changes.
|
|
8
|
+
|
|
9
|
+
- **Dev server:** generated apps now run `npm run dev` on a tiny zero-dependency
|
|
10
|
+
static server (`dev-server.mjs`, Node built-ins only) that uses port **7310**
|
|
11
|
+
and falls back to **7311 / 7312 / 7313** if busy — replacing the old default
|
|
12
|
+
of port 3000. It prints `ZoiJS dev server: http://localhost:<port>`.
|
|
13
|
+
- **Default `app` template redesigned** into a polished "project dashboard":
|
|
14
|
+
a hero header, a reusable `StatCard` (used ×4), filter chips (All / Active /
|
|
15
|
+
Done), an empty state, and a developer-hint footer — demonstrating component
|
|
16
|
+
composition, conditional rendering, and filtering alongside `createState` /
|
|
17
|
+
`computed` / `each` and parent⇄child communication. Still depends only on
|
|
18
|
+
`@zoijs/core`; still no build step.
|
|
19
|
+
- **`basic` template** keeps the minimal counter and adopts the same dev server.
|
|
20
|
+
|
|
5
21
|
## 0.1.0 — 2026-06-25
|
|
6
22
|
|
|
7
23
|
Initial release of the Zoijs starter CLI.
|
package/README.md
CHANGED
|
@@ -20,6 +20,14 @@ 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: http://localhost:7310
|
|
28
|
+
If busy: 7311, 7312, 7313
|
|
29
|
+
```
|
|
30
|
+
|
|
23
31
|
Or with `npx`:
|
|
24
32
|
|
|
25
33
|
```bash
|
|
@@ -47,11 +55,12 @@ one.
|
|
|
47
55
|
```bash
|
|
48
56
|
npm create zoijs@latest my-app # default: the "app" template
|
|
49
57
|
npm create zoijs@latest my-app --template basic # a minimal counter
|
|
50
|
-
npm create zoijs@latest my-app --template app # a
|
|
58
|
+
npm create zoijs@latest my-app --template app # a polished project dashboard
|
|
51
59
|
```
|
|
52
60
|
|
|
53
|
-
- **`app`** (default) — a
|
|
54
|
-
`createState`, `computed`, `each`,
|
|
61
|
+
- **`app`** (default) — a polished project dashboard (hero, stat cards, filters,
|
|
62
|
+
empty state) showing `html`, `mount`, `createState`, `computed`, `each`,
|
|
63
|
+
conditional rendering, and parent⇄child component communication.
|
|
55
64
|
- **`basic`** — a minimal counter, the smallest possible Zoijs app.
|
|
56
65
|
|
|
57
66
|
## What it does
|
package/bin/create-zoijs.js
CHANGED
|
@@ -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
package/templates/app/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# {{APP_TITLE}}
|
|
2
2
|
|
|
3
|
-
A small
|
|
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,36 @@ npm install
|
|
|
10
10
|
npm run dev
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
```text
|
|
14
|
+
ZoiJS dev server: http://localhost:7310
|
|
15
|
+
If busy: 7311, 7312, 7313
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Open the printed URL.
|
|
14
19
|
|
|
15
20
|
## Project layout
|
|
16
21
|
|
|
17
22
|
```
|
|
18
23
|
{{APP_NAME}}/
|
|
19
24
|
index.html import map + #app mount point
|
|
25
|
+
dev-server.mjs tiny zero-dependency static server (npm run dev)
|
|
20
26
|
src/
|
|
21
|
-
app.js owns
|
|
22
|
-
style.css plain CSS
|
|
27
|
+
app.js owns all state; composes the dashboard
|
|
28
|
+
style.css plain CSS (light/dark, responsive)
|
|
23
29
|
components/
|
|
24
|
-
Header.js parent → child:
|
|
30
|
+
Header.js parent → child: hero + summary stats
|
|
31
|
+
StatCard.js parent → child: a reusable stat card (used ×4)
|
|
25
32
|
TaskItem.js child → parent: reports toggle/delete via callbacks
|
|
26
33
|
```
|
|
27
34
|
|
|
28
35
|
## What it shows
|
|
29
36
|
|
|
30
|
-
- **`createState`** — the task list and the input draft.
|
|
31
|
-
- **`computed`** —
|
|
32
|
-
- **`each`** — keyed rendering of the
|
|
33
|
-
- **
|
|
37
|
+
- **`createState`** — the task list, the active filter, and the input draft.
|
|
38
|
+
- **`computed`** — total / completed / active counts and the filtered list.
|
|
39
|
+
- **`each`** — keyed rendering of the visible tasks.
|
|
40
|
+
- **Conditional rendering** — the empty state and the active-filter highlight.
|
|
41
|
+
- **Component composition** — `Header`, `StatCard` (×4), and `TaskItem`.
|
|
42
|
+
- **Parent → child** — `App` passes data down to its components as arguments.
|
|
34
43
|
- **Child → parent** — `TaskItem` reports events up through callbacks; `App`
|
|
35
44
|
owns the state and performs the changes.
|
|
36
45
|
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
console.log(`\nZoiJS dev server: http://localhost:${PORTS[i]}\n`);
|
|
65
|
+
};
|
|
66
|
+
server.once("error", onError);
|
|
67
|
+
server.once("listening", onListening);
|
|
68
|
+
server.listen(PORTS[i]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
start();
|
package/templates/app/src/app.js
CHANGED
|
@@ -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
|
|
6
|
-
// and report events back through callbacks (child
|
|
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
|
|
10
|
-
{ id: 2, text: "
|
|
11
|
-
{ id: 3, text: "
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
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
|
-
|
|
23
|
-
const
|
|
30
|
+
// --- actions ---------------------------------------------------------------
|
|
31
|
+
const addTask = (e) => {
|
|
24
32
|
e.preventDefault();
|
|
25
33
|
const text = draft.get().trim();
|
|
26
34
|
if (!text) return;
|
|
27
|
-
|
|
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
|
-
<
|
|
33
|
-
${
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
export function Header({
|
|
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
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
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
|
|
4
|
-
//
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
15
|
-
width: min(
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
39
|
-
|
|
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.
|
|
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.
|
|
66
|
-
padding: 0.
|
|
67
|
-
border: 1px solid
|
|
68
|
-
border-radius:
|
|
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.
|
|
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
|
-
|
|
204
|
+
font-size: 1rem;
|
|
87
205
|
}
|
|
88
|
-
|
|
89
206
|
.delete:hover {
|
|
90
|
-
|
|
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,17 @@ npm install
|
|
|
9
9
|
npm run dev
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
```text
|
|
13
|
+
ZoiJS dev server: http://localhost:7310
|
|
14
|
+
If busy: 7311, 7312, 7313
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open the printed URL.
|
|
13
18
|
|
|
14
19
|
## How it works
|
|
15
20
|
|
|
16
21
|
- **`index.html`** — an import map points `@zoijs/core` at `node_modules`, then loads `src/app.js`.
|
|
22
|
+
- **`dev-server.mjs`** — a tiny zero-dependency static server (Node built-ins only) that `npm run dev` runs.
|
|
17
23
|
- **`src/app.js`** — your app: `createState` for state, `html` for markup, `mount` to render.
|
|
18
24
|
- **`src/style.css`** — plain CSS.
|
|
19
25
|
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
console.log(`\nZoiJS dev server: http://localhost:${PORTS[i]}\n`);
|
|
65
|
+
};
|
|
66
|
+
server.once("error", onError);
|
|
67
|
+
server.once("listening", onListening);
|
|
68
|
+
server.listen(PORTS[i]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
start();
|