create-volt 0.11.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.13.0] - 2026-06-28
8
+
9
+ ### Added
10
+ - Opt-in **`admin`** add-on: a persistent, role-gated web admin (data browser)
11
+ for browser-only admins. Gated by auth **and** an `ADMIN_EMAILS` allowlist;
12
+ the panel is hidden for non-admins and `/admin/api/*` returns 403. Internal
13
+ collections (auth tokens/sessions) hidden. Wired into the default + starter
14
+ templates and the setup wizard (ADMIN_EMAILS field).
15
+ - `SECURITY.md` documenting the ephemeral-admin model and secure defaults.
16
+
17
+ ### Note
18
+ - Prefer the ephemeral `--studio` (shell-gated) for admins with server access;
19
+ the `admin` add-on is the explicit standing-surface opt-in for non-shell admins.
20
+
21
+ ## [0.12.0] - 2026-06-28
22
+
23
+ ### Added
24
+ - **`--template starter`** — a complete, no-build app shell, fully wired and on
25
+ out of the box: top nav over **Home**, magic-link **Account**, per-user
26
+ **Notes** (auth-gated CRUD, db-backed), and **Chat** (realtime rooms + presence
27
+ + typing). Ships a default `.env` enabling db+mailer+auth+realtime; includes
28
+ the setup wizard (`--edit`) and Studio (`--studio`). The SaaS-style starting
29
+ point.
30
+ - Templates can now ship a default `.env` (as `env`, renamed on scaffold).
31
+
7
32
  ## [0.11.0] - 2026-06-28
8
33
 
9
34
  ### Added
@@ -162,6 +187,8 @@ All notable changes to `create-volt` are documented here. The format follows
162
187
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
163
188
  and auto-detects npm / pnpm / yarn / bun for the install step.
164
189
 
190
+ [0.13.0]: https://github.com/MIR-2025/volt/releases/tag/v0.13.0
191
+ [0.12.0]: https://github.com/MIR-2025/volt/releases/tag/v0.12.0
165
192
  [0.11.0]: https://github.com/MIR-2025/volt/releases/tag/v0.11.0
166
193
  [0.10.0]: https://github.com/MIR-2025/volt/releases/tag/v0.10.0
167
194
  [0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
package/README.md CHANGED
@@ -28,8 +28,9 @@ Pick one with `--template` (default: `default`):
28
28
 
29
29
  | Template | What you get |
30
30
  | ----------- | -------------------------------------------------------------------- |
31
- | `default` | The Counter + Todos demo on the Volt signal engine. Minimal. |
32
- | `guestbook` | A real app: magic-link auth, Socket.io real-time message board, and pluggable **MongoDB / MySQL / Postgres** storage (in-memory by default, so it runs with zero setup). |
31
+ | `default` | The Counter + Todos demo on the Volt signal engine. Minimal. Add-ons off; turn them on in the wizard. |
32
+ | `starter` | A full app shell, everything on out of the box: top nav + Home, magic-link **Account**, per-user **Notes** (CRUD), and **Chat** (realtime). The SaaS-style starting point. |
33
+ | `guestbook` | A focused real app: magic-link auth + a Socket.io message board, over pluggable **MongoDB / MySQL / Postgres** storage (in-memory by default). |
33
34
 
34
35
  Then:
35
36
 
@@ -79,6 +80,18 @@ driver, and is **never** a route in the running app (no standing `/admin` to
79
80
  attack — shell/SSH access is the gate). Needs a persistent driver (MongoDB /
80
81
  MySQL / Postgres) to show data; the memory driver is per-process.
81
82
 
83
+ ## Security & admin model
84
+
85
+ Volt is secure by default, and deliberately has **no standing admin route** —
86
+ the config wizard (`--edit`) and data browser (`--studio`) are ephemeral,
87
+ localhost-only processes; **shell/SSH access is the auth**. Several admins? Give
88
+ each an SSH key — stronger than a shared web panel, with nothing public to
89
+ attack. A persistent, role-gated web admin exists only as an **opt-in** add-on
90
+ (`admin`, gated by auth + an `ADMIN_EMAILS` allowlist) for when you truly need
91
+ browser-only admins. Plus: escaping by default (no XSS), server-side validation
92
+ + caps, security headers, and `HttpOnly`+`SameSite` cookies. See
93
+ [SECURITY.md](https://github.com/MIR-2025/volt/blob/main/SECURITY.md).
94
+
82
95
  ## Updating Volt
83
96
 
84
97
  Volt is vendored as a single file (`public/volt.js`), not an npm dependency.
@@ -0,0 +1,44 @@
1
+ // admin.js — opt-in, role-gated web admin (data browser) for the running app.
2
+ // This is the ONE deliberately-persistent privileged surface in Volt, for
3
+ // browser-only admins. Every route requires a session AND membership in the
4
+ // ADMIN_EMAILS allowlist. Internal collections (auth tokens/sessions) are hidden.
5
+ // Prefer `npm run dev -- --studio` (ephemeral) unless you truly need this.
6
+
7
+ import express from "express";
8
+
9
+ const HIDDEN = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
10
+ const visible = (n) => n && !HIDDEN.has(n);
11
+
12
+ export function adminRouter({ store, requireAuth, adminEmails }) {
13
+ const allow = new Set((adminEmails || []).map((e) => String(e).trim().toLowerCase()).filter(Boolean));
14
+ const isAdmin = (email) => allow.has(String(email || "").toLowerCase());
15
+
16
+ const r = express.Router();
17
+ r.use(requireAuth); // must be signed in for anything under /admin/api
18
+
19
+ // any signed-in user may ask whether *they* are an admin (drives the UI)
20
+ r.get("/admin/api/me", (req, res) => res.json({ email: req.user.email, isAdmin: isAdmin(req.user.email) }));
21
+
22
+ // everything below is admins-only
23
+ r.use((req, res, next) => (isAdmin(req.user.email) ? next() : res.status(403).json({ ok: false, error: "Admins only." })));
24
+
25
+ r.get("/admin/api/collections", async (_req, res) => {
26
+ const all = (await store.collections()) || [];
27
+ res.json({ driver: store.name, collections: all.filter(visible) });
28
+ });
29
+ r.get("/admin/api/collection", async (req, res) => {
30
+ const name = String(req.query.name || "");
31
+ if (!visible(name)) return res.status(403).json({ ok: false, error: "hidden" });
32
+ res.json({ ok: true, name, docs: (await store.collection(name).all()).slice(0, 500) });
33
+ });
34
+ r.delete("/admin/api/doc", async (req, res) => {
35
+ const name = String(req.query.name || "");
36
+ const id = String(req.query.id || "");
37
+ if (!visible(name)) return res.status(403).json({ ok: false, error: "hidden" });
38
+ if (!id) return res.status(400).json({ ok: false, error: "missing id" });
39
+ await store.collection(name).delete(id);
40
+ res.json({ ok: true });
41
+ });
42
+
43
+ return r;
44
+ }
@@ -0,0 +1,62 @@
1
+ // admin-ui.js — the role-gated admin panel (frontend for the admin add-on).
2
+ // Renders nothing for non-admins; for admins, a data browser over /admin/api/*.
3
+ // Mounted by the app when the admin add-on is enabled.
4
+ import { signal, html } from "/volt.js";
5
+
6
+ const j = async (url, opts) => {
7
+ const res = await fetch(url, opts);
8
+ return { status: res.status, body: await res.json().catch(() => ({})) };
9
+ };
10
+
11
+ export function adminPanel() {
12
+ const ready = signal(false);
13
+ const isAdmin = signal(false);
14
+ const driver = signal("");
15
+ const collections = signal([]);
16
+ const current = signal("");
17
+ const docs = signal([]);
18
+ const note = signal("");
19
+
20
+ async function init() {
21
+ const { status, body } = await j("/admin/api/me");
22
+ if (status === 200) isAdmin(!!body.isAdmin);
23
+ ready(true);
24
+ if (isAdmin()) load();
25
+ }
26
+ async function load() {
27
+ const { body } = await j("/admin/api/collections");
28
+ driver(body.driver || "");
29
+ collections(body.collections || []);
30
+ if (!current() && collections().length) open(collections()[0]);
31
+ }
32
+ async function open(name) {
33
+ current(name);
34
+ const { body } = await j(`/admin/api/collection?name=${encodeURIComponent(name)}`);
35
+ docs(body.ok ? body.docs : []);
36
+ }
37
+ async function del(id) {
38
+ await j(`/admin/api/doc?name=${encodeURIComponent(current())}&id=${encodeURIComponent(id)}`, { method: "DELETE" });
39
+ docs(docs().filter((d) => d.id !== id));
40
+ note("Deleted.");
41
+ }
42
+ init();
43
+
44
+ const tab = (name) =>
45
+ html`<button class=${() => "btn btn-sm " + (current() === name ? "btn-primary" : "btn-outline-secondary")} onclick=${() => open(name)}>${name}</button>`;
46
+ const row = (d) =>
47
+ html`<div class="d-flex justify-content-between align-items-start gap-2 py-1" style="border-top:1px solid #232a36">
48
+ <pre class="mb-0 small flex-grow-1" style="white-space:pre-wrap;color:#cfe3ff">${JSON.stringify(d, null, 2)}</pre>
49
+ <button class="btn btn-sm btn-outline-danger" onclick=${() => del(d.id)}>✕</button>
50
+ </div>`;
51
+
52
+ // hidden entirely for non-admins (and until we know)
53
+ return html`${() =>
54
+ !ready() || !isAdmin()
55
+ ? null
56
+ : html`<div class="card-x p-4 mb-4">
57
+ <h2 class="h6 mb-3">Admin <span class="text-muted small">— data ${() => (driver() ? "· " + driver() : "")}</span></h2>
58
+ <div class="d-flex flex-wrap gap-1 mb-2">${() => (collections().length ? collections().map(tab) : html`<span class="text-muted small">No collections yet.</span>`)}</div>
59
+ <div style="max-height:260px;overflow:auto">${() => (docs().length ? docs().map(row) : html`<span class="text-muted small">Empty.</span>`)}</div>
60
+ ${() => (note() ? html`<p class="small text-muted mb-0 mt-2">${note}</p>` : null)}
61
+ </div>`}`;
62
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Opt-in role-gated web admin: a persistent /admin data browser for browser-only admins (auth + ADMIN_EMAILS allowlist). Adds a standing surface — prefer `--studio` unless you need non-shell admins.",
3
+ "dependsOn": ["db", "auth"],
4
+ "sentinel": "lib/admin.js",
5
+ "install": ["express"],
6
+ "optional": {},
7
+ "wiring": "Set ADMIN_EMAILS in .env to a comma-separated allowlist of admin emails. Only those signed-in users see the admin panel and can reach /admin/api/*."
8
+ }
package/index.js CHANGED
@@ -39,7 +39,7 @@ ${bold("Usage")}
39
39
  npx create-volt@latest studio # browse your data — ephemeral, localhost (like Prisma Studio)
40
40
 
41
41
  ${bold("Options")}
42
- --template <name> Starter template: default | guestbook (default: default)
42
+ --template <name> Template: default | starter | guestbook (default: default)
43
43
  --port <number> Dev port for the app (default: derived from today's date)
44
44
  --skip-install Don't run the package manager install step
45
45
  --no-git Don't initialize a git repository
@@ -245,6 +245,11 @@ const shippedGitignore = path.join(targetDir, "gitignore");
245
245
  if (fs.existsSync(shippedGitignore)) {
246
246
  fs.renameSync(shippedGitignore, path.join(targetDir, ".gitignore"));
247
247
  }
248
+ // some templates ship a default .env as "env" (a real .env is stripped from npm)
249
+ const shippedEnv = path.join(targetDir, "env");
250
+ if (fs.existsSync(shippedEnv)) {
251
+ fs.renameSync(shippedEnv, path.join(targetDir, ".env"));
252
+ }
248
253
 
249
254
  // Bundle the add-on sources so the app's setup wizard can enable them later
250
255
  // (only for templates that ship the wizard, i.e. have a setup/ dir).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,4 +86,11 @@ if (enabled.includes("realtime")) {
86
86
  /* realtime UI unavailable */
87
87
  }
88
88
  }
89
+ if (enabled.includes("admin")) {
90
+ try {
91
+ nodes.push((await import("/admin-ui.js")).adminPanel());
92
+ } catch {
93
+ /* admin UI unavailable */
94
+ }
95
+ }
89
96
  mount("#app", ...nodes);
@@ -22,7 +22,7 @@ const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
24
  const PKG_VERSIONS = { mongodb: "^6.8.0", mysql2: "^3.11.0", pg: "^8.12.0", nodemailer: "^6.9.0" };
25
- const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js" };
25
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", admin: "admin.js" };
26
26
 
27
27
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
28
28
  function readEnvFile() {
@@ -101,6 +101,11 @@ async function startApp() {
101
101
  if (enabled.has("db")) store = await (await addonMod("db")).createStore();
102
102
  if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
103
103
  if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
104
+ if (enabled.has("admin") && store && enabled.has("auth")) {
105
+ const adminEmails = String(process.env.ADMIN_EMAILS || "").split(",").map((s) => s.trim()).filter(Boolean);
106
+ const requireAuth = (await addonMod("auth")).requireAuth(store);
107
+ app.use((await addonMod("admin")).adminRouter({ store, requireAuth, adminEmails }));
108
+ }
104
109
 
105
110
  // expose which add-ons are on, and serve each enabled add-on's frontend assets
106
111
  app.get("/__volt/addons", (_req, res) => res.json([...enabled]));
@@ -17,6 +17,7 @@ const state = signal({
17
17
  dbUrl: current.DATABASE_URL || "",
18
18
  smtpUrl: current.SMTP_URL || "",
19
19
  mailFrom: current.MAIL_FROM || "",
20
+ adminEmails: current.ADMIN_EMAILS || "",
20
21
  port: current.PORT || String(defaultPort),
21
22
  });
22
23
  const set = (patch) => state({ ...state(), ...patch });
@@ -53,6 +54,7 @@ function genEnv(s) {
53
54
  else out.push("# SMTP_URL= # unset → emails print to the console");
54
55
  if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
55
56
  }
57
+ if (eff.includes("admin")) out.push(`ADMIN_EMAILS=${clean(s.adminEmails)}`);
56
58
  return out.join("\n") + "\n";
57
59
  }
58
60
  const env = computed(() => genEnv(state()));
@@ -148,6 +150,7 @@ mount(
148
150
  ${field("PORT", "port", String(defaultPort))}
149
151
  ${() => (eff().includes("db") ? dbSettings() : null)}
150
152
  ${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
153
+ ${() => (eff().includes("admin") ? field("ADMIN_EMAILS (comma-separated)", "adminEmails", "you@example.com") : null)}
151
154
  </div>`,
152
155
  html`<div class="card-x p-4 mb-3">
153
156
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -0,0 +1,47 @@
1
+ # ⚡ Volt starter
2
+
3
+ A complete, **no-build** app shell — auth, realtime chat, a per-user notes CRUD,
4
+ and a database, all wired and turned on out of the box. Edit files and save;
5
+ the page hot-reloads.
6
+
7
+ ## Run
8
+
9
+ ```bash
10
+ npm install
11
+ npm run dev # → http://localhost:26628
12
+ ```
13
+
14
+ It ships with `VOLT_ADDONS=db,mailer,auth,realtime` in `.env`, so it runs the
15
+ full stack immediately (in-memory by default — magic-link emails print to the
16
+ console). Sign in from the **Account** tab, then use **Notes** and **Chat**.
17
+
18
+ ## Sections
19
+
20
+ - **Home** — a simple dashboard.
21
+ - **Account** — magic-link sign-in (no passwords).
22
+ - **Notes** — per-user CRUD, auth-gated, stored in the db.
23
+ - **Chat** — Socket.io rooms with presence + typing.
24
+
25
+ ## Configure & inspect
26
+
27
+ ```bash
28
+ npm run dev -- --edit # ephemeral wizard: toggle add-ons, set DB/SMTP, etc.
29
+ npm run dev -- --studio # ephemeral data browser (like Prisma Studio)
30
+ ```
31
+
32
+ Both are localhost-only and disappear when you close them — no standing admin
33
+ surface. Switch to a real database (MongoDB / MySQL / Postgres) anytime in the
34
+ wizard.
35
+
36
+ ## Layout
37
+
38
+ ```
39
+ public/app.js the shell — nav + sections (Home/Notes/Chat/Account)
40
+ public/volt.js the Volt library (no build step)
41
+ views/index.html the HTML shell
42
+ server.js dev server + wizard + studio; wires enabled add-ons + notes
43
+ .volt/addons/ bundled add-on sources (enabled via .env)
44
+ .env which add-ons are on, + their settings
45
+ ```
46
+
47
+ Scaffolded with [`create-volt`](https://www.npmjs.com/package/create-volt) — `--template starter`.
@@ -0,0 +1,2 @@
1
+ VOLT_ADDONS=db,mailer,auth,realtime
2
+ DB_DRIVER=memory
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ npm-debug.log*
3
+ .DS_Store
4
+ .env
5
+ *.local
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "volt-app",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "A Volt app — no-build, signals-based UI with Socket.io hot reload.",
6
+ "type": "module",
7
+ "main": "server.js",
8
+ "scripts": {
9
+ "start": "node server.js",
10
+ "dev": "node server.js"
11
+ },
12
+ "dependencies": {
13
+ "express": "^4.19.2",
14
+ "socket.io": "^4.7.5"
15
+ }
16
+ }
@@ -0,0 +1,169 @@
1
+ // app.js — the starter app shell: a top nav over sections (Home, Notes, Chat,
2
+ // Account). Built on Volt signals. Auth + realtime + db are wired server-side;
3
+ // this is the UI. All dynamic text renders through Volt holes (text nodes,
4
+ // HTML-escaped), so user content can't inject markup.
5
+ import { signal, computed, el, html, mount } from "/volt.js";
6
+
7
+ let enabled = [];
8
+ try {
9
+ enabled = await (await fetch("/__volt/addons")).json();
10
+ } catch {
11
+ /* ignore */
12
+ }
13
+ const hasAuth = enabled.includes("auth");
14
+ const hasChat = enabled.includes("realtime");
15
+
16
+ const api = async (url, body, method) => {
17
+ const res = await fetch(url, {
18
+ method: method || (body ? "POST" : "GET"),
19
+ headers: body ? { "Content-Type": "application/json" } : undefined,
20
+ body: body ? JSON.stringify(body) : undefined,
21
+ });
22
+ return res.json().catch(() => ({}));
23
+ };
24
+ const shortName = (e) => String(e || "").split("@")[0];
25
+
26
+ const me = signal(null);
27
+ const section = signal("home");
28
+ api("/api/me").then((r) => me(r.email || null));
29
+
30
+ // --- Account ---
31
+ function accountSection() {
32
+ const email = signal("");
33
+ const notice = signal("");
34
+ async function sendLink(e) {
35
+ e?.preventDefault?.();
36
+ const addr = email().trim();
37
+ if (!addr) return;
38
+ notice("Sending…");
39
+ const r = await api("/api/login", { email: addr });
40
+ notice(r.ok ? (r.dev ? "Magic link printed to the server console — open it to sign in." : `Link sent to ${addr}.`) : r.error || "Failed.");
41
+ }
42
+ async function logout() {
43
+ await api("/api/logout", {});
44
+ me(null);
45
+ }
46
+ return html`<div class="card-x p-4">
47
+ <h2 class="h6 mb-3">Account</h2>
48
+ ${() =>
49
+ me()
50
+ ? html`<div class="d-flex justify-content-between align-items-center">
51
+ <span class="text-muted small">Signed in as <span class="accent fw-semibold">${() => me()}</span></span>
52
+ <button class="btn btn-sm btn-outline-secondary" onclick=${logout}>Sign out</button>
53
+ </div>`
54
+ : html`<form class="d-flex gap-2" onsubmit=${sendLink}>
55
+ <input class="form-control" type="email" name="email" placeholder="you@example.com" maxlength="320" autocomplete="email" required value=${email} oninput=${(e) => email(e.target.value)} />
56
+ <button class="btn btn-primary" type="submit">Send magic link</button>
57
+ </form>`}
58
+ ${() => (notice() ? html`<p class="small text-muted mb-0 mt-2">${notice}</p>` : null)}
59
+ </div>`;
60
+ }
61
+
62
+ // --- Notes (per-user CRUD, auth-gated) ---
63
+ function notesSection() {
64
+ const notes = signal([]);
65
+ const draft = signal("");
66
+ const err = signal("");
67
+ async function load() {
68
+ if (!me()) return;
69
+ const r = await api("/api/notes");
70
+ notes(r.notes || []);
71
+ }
72
+ async function add() {
73
+ const text = draft().trim();
74
+ if (!text) return;
75
+ const r = await api("/api/notes", { text });
76
+ if (r.ok) {
77
+ draft("");
78
+ load();
79
+ } else err(r.error || "Failed.");
80
+ }
81
+ async function del(id) {
82
+ await api("/api/notes/" + encodeURIComponent(id), null, "DELETE");
83
+ load();
84
+ }
85
+ // reload whenever auth state flips to signed-in
86
+ let loadedFor = null;
87
+ const sync = computed(() => {
88
+ if (me() && loadedFor !== me()) {
89
+ loadedFor = me();
90
+ load();
91
+ }
92
+ return me();
93
+ });
94
+
95
+ return html`<div class="card-x p-4">
96
+ <h2 class="h6 mb-3">Notes</h2>
97
+ ${() =>
98
+ !sync()
99
+ ? html`<p class="text-muted small mb-0">Sign in (Account) to keep notes.</p>`
100
+ : html`<div class="input-group mb-3">
101
+ <input class="form-control" maxlength="2000" placeholder="Write a note…" value=${draft} oninput=${(e) => draft(e.target.value)} onkeydown=${(e) => e.key === "Enter" && add()} />
102
+ <button class="btn btn-primary" onclick=${add}>Add</button>
103
+ </div>
104
+ ${() =>
105
+ notes().length
106
+ ? notes().map(
107
+ (n) => html`<div class="d-flex justify-content-between align-items-start gap-2 py-2" style="border-top:1px solid #232a36">
108
+ <span>${n.text}</span>
109
+ <button class="btn btn-sm btn-outline-danger" onclick=${() => del(n.id)}>✕</button>
110
+ </div>`,
111
+ )
112
+ : html`<span class="text-muted small">No notes yet.</span>`}
113
+ ${() => (err() ? html`<p class="small text-danger mb-0 mt-2">${err}</p>` : null)}`}
114
+ </div>`;
115
+ }
116
+
117
+ // --- Home ---
118
+ function homeSection() {
119
+ return html`<div class="card-x p-4">
120
+ <h2 class="h5 mb-2">Welcome ${() => (me() ? html`back, <span class="accent">${() => shortName(me())}</span>` : "to your Volt app")}</h2>
121
+ <p class="text-muted mb-3">A no-build, signals-based app — auth, realtime, and a database, all wired and configured by file. Edit <code>public/app.js</code> and save; it hot-reloads.</p>
122
+ <div class="d-flex flex-wrap gap-2">
123
+ <button class="btn btn-sm btn-outline-secondary" onclick=${() => section("notes")}>📝 Notes</button>
124
+ ${hasChat ? html`<button class="btn btn-sm btn-outline-secondary" onclick=${() => section("chat")}>💬 Chat</button>` : ""}
125
+ <button class="btn btn-sm btn-outline-secondary" onclick=${() => section("account")}>${() => (me() ? "👤 Account" : "🔑 Sign in")}</button>
126
+ </div>
127
+ </div>`;
128
+ }
129
+
130
+ // --- build sections once; toggle visibility (keeps state + one chat socket) ---
131
+ const sections = { home: homeSection(), account: accountSection() };
132
+ if (hasAuth) sections.notes = notesSection();
133
+ if (hasChat) {
134
+ try {
135
+ sections.chat = (await import("/chat-ui.js")).chatPanel();
136
+ } catch {
137
+ /* chat UI unavailable */
138
+ }
139
+ }
140
+ // Admin tab appears only for users in the ADMIN_EMAILS allowlist.
141
+ if (enabled.includes("admin")) {
142
+ try {
143
+ const r = await (await fetch("/admin/api/me")).json();
144
+ if (r.isAdmin) sections.admin = (await import("/admin-ui.js")).adminPanel();
145
+ } catch {
146
+ /* not signed in / not admin */
147
+ }
148
+ }
149
+
150
+ const TABS = [
151
+ ["home", "Home"],
152
+ ...(hasAuth ? [["notes", "Notes"]] : []),
153
+ ...(sections.chat ? [["chat", "Chat"]] : []),
154
+ ...(sections.admin ? [["admin", "Admin"]] : []),
155
+ ["account", "Account"],
156
+ ];
157
+
158
+ const nav = () =>
159
+ html`<nav class="navx py-2 mb-4">
160
+ <div class="container d-flex align-items-center" style="max-width:760px">
161
+ <span class="brand me-3"><span class="accent">⚡ Volt</span></span>
162
+ ${TABS.map(([key, label]) => html`<button class=${() => "btn btn-link btn-sm " + (section() === key ? "active" : "")} onclick=${() => section(key)}>${label}</button>`)}
163
+ <span class="ms-auto small text-muted">${() => (me() ? shortName(me()) : "guest")}</span>
164
+ </div>
165
+ </nav>`;
166
+
167
+ const panel = (key) => el("div", { class: "container", style: () => "max-width:760px;display:" + (section() === key ? "block" : "none") }, sections[key]);
168
+
169
+ mount("#app", nav(), ...Object.keys(sections).map(panel));