[object Object]

← back to CelebritySignatures

Accounts + save-your-placement: zero-dep email/password auth + per-account saved mural placements; injected account.js UI

11d1aad7e20af1a5943b12187d6a51d1a128fcf6 · 2026-06-18 18:59:17 -0700 · Steve

Files touched

Diff

commit 11d1aad7e20af1a5943b12187d6a51d1a128fcf6
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 18 18:59:17 2026 -0700

    Accounts + save-your-placement: zero-dep email/password auth + per-account saved mural placements; injected account.js UI
---
 .gitignore        |   3 +
 public/account.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 160 ++++++++++++++++++++++++++++++++++++++++-------
 3 files changed, 323 insertions(+), 22 deletions(-)

diff --git a/.gitignore b/.gitignore
index fe8c64e..16b3568 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@ tmp_collage_cache/
 .venv/
 data/mural-orders.json
 output/*.tif
+data/users.json
+data/saves.json
+data/sessions.json
diff --git a/public/account.js b/public/account.js
new file mode 100644
index 0000000..91a2873
--- /dev/null
+++ b/public/account.js
@@ -0,0 +1,182 @@
+/* Celebrity Signature Murals — accounts + save-your-placement.
+   Self-contained, injected by server.js into /murals so murals.html stays untouched.
+   Reads the wall-studio's existing globals (cur, pos, wallDims, selectMural, drawScene). */
+(function () {
+  'use strict';
+  const $ = (s, r = document) => r.querySelector(s);
+  const el = (t, props = {}, html) => { const e = document.createElement(t); Object.assign(e, props); if (html != null) e.innerHTML = html; return e; };
+  const api = (p, opt = {}) => fetch(p, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, ...opt }).then(r => r.json());
+  let me = null;
+
+  // ---- styles ----
+  document.head.appendChild(el('style', {}, `
+    .acct-bar{position:fixed;top:14px;right:18px;z-index:60;display:flex;gap:8px;align-items:center;font:14px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+    .acct-bar .pill{background:#fff;border:1px solid #e6e3dc;border-radius:999px;padding:8px 15px;cursor:pointer;color:#1a1a1a;box-shadow:0 1px 4px rgba(0,0,0,.06)}
+    .acct-bar .pill.dark{background:#1a1a1a;color:#fff;border-color:#1a1a1a}
+    .acct-menu{position:absolute;top:42px;right:0;background:#fff;border:1px solid #e6e3dc;border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.14);overflow:hidden;display:none;min-width:170px}
+    .acct-menu.open{display:block}
+    .acct-menu button{display:block;width:100%;text-align:left;border:0;background:#fff;padding:11px 16px;font:inherit;cursor:pointer;color:#1a1a1a}
+    .acct-menu button:hover{background:#f7f5f0}
+    .acct-save{display:inline-flex;align-items:center;gap:7px;border:1px solid #1a1a1a;background:#1a1a1a;color:#fff;font:14px/1 inherit;padding:10px 16px;border-radius:9px;cursor:pointer;margin-top:6px}
+    .acct-save.saved{background:#1d7a36;border-color:#1d7a36}
+    .acct-ovl{position:fixed;inset:0;background:rgba(20,18,14,.45);z-index:70;display:none;align-items:center;justify-content:center}
+    .acct-ovl.open{display:flex}
+    .acct-modal{background:#fff;border-radius:14px;width:min(380px,92vw);padding:24px;font:15px/1.5 -apple-system,sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.3)}
+    .acct-modal h3{font-family:Georgia,serif;margin:0 0 4px;font-size:21px}
+    .acct-modal p{color:#6b6b6b;margin:0 0 16px;font-size:13px}
+    .acct-modal label{display:block;font-size:12px;color:#6b6b6b;margin:0 0 10px}
+    .acct-modal input{width:100%;box-sizing:border-box;margin-top:5px;padding:10px 12px;border:1px solid #e6e3dc;border-radius:8px;font:inherit}
+    .acct-modal .go{width:100%;border:0;background:#1a1a1a;color:#fff;padding:12px;border-radius:9px;font:inherit;font-size:15px;cursor:pointer;margin-top:6px}
+    .acct-modal .alt{text-align:center;margin-top:12px;font-size:13px;color:#6b6b6b}
+    .acct-modal .alt a{color:#1d7a36;cursor:pointer;text-decoration:underline}
+    .acct-msg{font-size:13px;margin-top:10px;min-height:16px}
+    .acct-msg.err{color:#b3261e}.acct-msg.ok{color:#1d7a36}
+    .acct-draw{position:fixed;top:0;right:0;height:100%;width:min(380px,94vw);background:#f7f5f0;z-index:65;box-shadow:-8px 0 30px rgba(0,0,0,.18);transform:translateX(100%);transition:transform .25s;overflow:auto;padding:22px}
+    .acct-draw.open{transform:none}
+    .acct-draw h3{font-family:Georgia,serif;margin:0 0 14px;font-size:20px;display:flex;justify-content:space-between;align-items:center}
+    .acct-draw h3 .x{cursor:pointer;color:#6b6b6b;font-size:22px;border:0;background:0}
+    .save-card{background:#fff;border:1px solid #e6e3dc;border-radius:12px;overflow:hidden;margin-bottom:14px}
+    .save-card img{width:100%;height:120px;object-fit:cover;display:block;background:#fff;border-bottom:1px solid #eee}
+    .save-card .b{padding:11px 13px}
+    .save-card .t{font-family:Georgia,serif;font-size:15px}
+    .save-card .m{font-size:12px;color:#6b6b6b;margin:3px 0 9px}
+    .save-card .row{display:flex;gap:8px}
+    .save-card .row button{flex:1;border:1px solid #e6e3dc;background:#fff;border-radius:7px;padding:7px;font:inherit;font-size:13px;cursor:pointer}
+    .save-card .row button.load{border-color:#1a1a1a;background:#1a1a1a;color:#fff}
+    .acct-empty{color:#6b6b6b;font-size:14px;text-align:center;padding:30px 10px}
+    .acct-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:11px 20px;border-radius:999px;z-index:80;font:14px sans-serif;opacity:0;transition:opacity .2s}
+    .acct-toast.show{opacity:1}
+  `));
+
+  // ---- elements ----
+  const bar = el('div', { className: 'acct-bar' });
+  const acctPill = el('button', { className: 'pill' }, 'Sign in');
+  const menu = el('div', { className: 'acct-menu' });
+  menu.appendChild(el('button', { onclick: openSaves }, '🖼  My saved murals'));
+  menu.appendChild(el('button', { onclick: logout }, 'Sign out'));
+  bar.appendChild(acctPill); bar.appendChild(menu); document.body.appendChild(bar);
+
+  const ovl = el('div', { className: 'acct-ovl' });
+  const modal = el('div', { className: 'acct-modal' });
+  ovl.appendChild(modal); document.body.appendChild(ovl);
+  ovl.addEventListener('click', e => { if (e.target === ovl) ovl.classList.remove('open'); });
+
+  const draw = el('div', { className: 'acct-draw' });
+  document.body.appendChild(draw);
+  const toast = el('div', { className: 'acct-toast' }); document.body.appendChild(toast);
+  const flash = m => { toast.textContent = m; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1900); };
+
+  acctPill.onclick = () => { if (me) menu.classList.toggle('open'); else openAuth('login'); };
+  document.addEventListener('click', e => { if (!bar.contains(e.target)) menu.classList.remove('open'); });
+
+  // ---- save button into the studio ----
+  function mountSaveBtn() {
+    const studio = $('#studio'); if (!studio || $('#acctSaveBtn')) return;
+    const btn = el('button', { id: 'acctSaveBtn', className: 'acct-save' }, '💾 Save this placement to my account');
+    btn.onclick = savePlacement;
+    const anchor = $('#readout') || $('.stage-wrap', studio) || studio;
+    anchor.parentNode.insertBefore(btn, anchor.nextSibling);
+  }
+
+  // ---- read current placement from the studio's globals (resilient) ----
+  function snapshot() {
+    try {
+      if (typeof cur === 'undefined' || !cur) return null;
+      const wd = (typeof wallDims === 'function') ? wallDims() : { W: +($('#wallW') || {}).value || 0, H: +($('#wallH') || {}).value || 0 };
+      const p = (typeof pos !== 'undefined' && pos) ? pos : { x: 0, y: 0 };
+      return { mural: cur.slug, mural_title: cur.title, wall_w: wd.W, wall_h: wd.H,
+        placement: { from_left_ft: +(+p.x).toFixed(2), off_floor_ft: +(+p.y).toFixed(2) },
+        scene: (typeof curScene !== 'undefined') ? curScene : '' };
+    } catch (e) { return null; }
+  }
+
+  async function savePlacement() {
+    const snap = snapshot();
+    if (!snap) return flash('Pick a mural in the studio first');
+    if (!me) { openAuth('login', savePlacement); return; }
+    const r = await api('/api/saves', { method: 'POST', body: JSON.stringify(snap) });
+    if (r.ok) { const b = $('#acctSaveBtn'); b.classList.add('saved'); b.textContent = '✓ Saved to your account'; setTimeout(() => { b.classList.remove('saved'); b.innerHTML = '💾 Save this placement to my account'; }, 2200); flash('Saved to your account'); }
+    else flash(r.error || 'Could not save');
+  }
+
+  // ---- auth modal ----
+  function openAuth(mode, after) {
+    modal.innerHTML = '';
+    const isLogin = mode === 'login';
+    modal.appendChild(el('h3', {}, isLogin ? 'Sign in' : 'Create your account'));
+    modal.appendChild(el('p', {}, 'Save mural placements to your account and find them anytime.'));
+    const f = el('form');
+    if (!isLogin) f.appendChild(el('label', {}, 'Name<input name="name" autocomplete="name">'));
+    f.appendChild(el('label', {}, 'Email<input name="email" type="email" required autocomplete="email">'));
+    f.appendChild(el('label', {}, 'Password<input name="password" type="password" required minlength="6" autocomplete="' + (isLogin ? 'current-password' : 'new-password') + '">'));
+    const go = el('button', { className: 'go', type: 'submit' }, isLogin ? 'Sign in' : 'Create account');
+    f.appendChild(go);
+    const msg = el('div', { className: 'acct-msg' });
+    f.appendChild(msg);
+    const alt = el('div', { className: 'alt' }, isLogin ? 'No account? <a>Create one</a>' : 'Have an account? <a>Sign in</a>');
+    alt.querySelector('a').onclick = () => openAuth(isLogin ? 'signup' : 'login', after);
+    f.appendChild(alt);
+    f.onsubmit = async e => {
+      e.preventDefault(); msg.className = 'acct-msg'; msg.textContent = 'Working…';
+      const body = JSON.stringify({ name: f.name ? f.name.value : '', email: f.email.value, password: f.password.value });
+      const r = await api('/api/auth/' + (isLogin ? 'login' : 'signup'), { method: 'POST', body });
+      if (r.ok) { me = r.user; refreshBar(); ovl.classList.remove('open'); flash('Signed in as ' + me.email); if (after) after(); }
+      else { msg.className = 'acct-msg err'; msg.textContent = r.error || 'Something went wrong'; }
+    };
+    modal.appendChild(f);
+    ovl.classList.add('open');
+    setTimeout(() => { const em = f.querySelector('input[type=email]'); em && em.focus(); }, 50);
+  }
+
+  async function logout() { await api('/api/auth/logout', { method: 'POST' }); me = null; refreshBar(); menu.classList.remove('open'); flash('Signed out'); }
+
+  // ---- my saved murals drawer ----
+  async function openSaves() {
+    menu.classList.remove('open');
+    draw.innerHTML = '';
+    const h = el('h3', {}, 'My saved murals');
+    const x = el('button', { className: 'x' }, '×'); x.onclick = () => draw.classList.remove('open'); h.appendChild(x);
+    draw.appendChild(h);
+    draw.classList.add('open');
+    const r = await api('/api/saves');
+    if (!r.ok) { draw.appendChild(el('div', { className: 'acct-empty' }, 'Please sign in.')); return; }
+    if (!r.saves.length) { draw.appendChild(el('div', { className: 'acct-empty' }, 'No saved murals yet.<br>Place a mural on your wall and tap “Save”.')); return; }
+    r.saves.forEach(s => {
+      const card = el('div', { className: 'save-card' });
+      card.appendChild(el('img', { src: '/output/portrait-' + s.mural + '.png', loading: 'lazy', onerror: function () { this.src = '/output/collage-' + s.mural + '.png'; } }));
+      const b = el('div', { className: 'b' });
+      b.appendChild(el('div', { className: 't' }, s.mural_title || s.mural));
+      const pl = s.placement ? `${s.placement.from_left_ft} ft from left · ${s.placement.off_floor_ft} ft off floor` : '';
+      b.appendChild(el('div', { className: 'm' }, `${s.wall_w || '?'}×${s.wall_h || '?'} ft wall · ${pl}`));
+      const row = el('div', { className: 'row' });
+      const load = el('button', { className: 'load' }, 'Load'); load.onclick = () => loadSave(s);
+      const del = el('button', {}, 'Delete'); del.onclick = async () => { await api('/api/saves?id=' + s.id, { method: 'DELETE' }); openSaves(); };
+      row.appendChild(load); row.appendChild(del); b.appendChild(row); card.appendChild(b); draw.appendChild(card);
+    });
+  }
+
+  function loadSave(s) {
+    try {
+      const code = (typeof CAT !== 'undefined' && CAT) ? (CAT.murals.find(m => m.slug === s.mural) || {}).code : null;
+      if (code && $('#selMural')) $('#selMural').value = code;
+      if (s.wall_w && $('#wallW')) $('#wallW').value = s.wall_w;
+      if (s.wall_h && $('#wallH')) $('#wallH').value = s.wall_h;
+      if (typeof selectMural === 'function' && code) selectMural(code);
+      if (typeof pos !== 'undefined' && s.placement) { pos.x = s.placement.from_left_ft; pos.y = s.placement.off_floor_ft; }
+      if (typeof drawScene === 'function') drawScene();
+      draw.classList.remove('open');
+      const st = $('#studio'); st && st.scrollIntoView({ behavior: 'smooth' });
+      flash('Loaded “' + (s.mural_title || s.mural) + '”');
+    } catch (e) { flash('Could not load that placement'); }
+  }
+
+  function refreshBar() {
+    if (me) { acctPill.classList.add('dark'); acctPill.textContent = '👤 ' + me.email.split('@')[0]; }
+    else { acctPill.classList.remove('dark'); acctPill.textContent = 'Sign in'; }
+  }
+
+  // ---- init ----
+  api('/api/auth/me').then(r => { me = (r && r.ok) ? r.user : null; refreshBar(); });
+  const tryMount = () => { mountSaveBtn(); if (!$('#acctSaveBtn')) setTimeout(tryMount, 400); };
+  if (document.readyState !== 'loading') tryMount(); else document.addEventListener('DOMContentLoaded', tryMount);
+})();
diff --git a/server.js b/server.js
index d3d6f97..d98b54b 100644
--- a/server.js
+++ b/server.js
@@ -1,12 +1,16 @@
 #!/usr/bin/env node
-// Zero-dependency static server for the CelebritySignatures grid.
+// Zero-dependency server for the CelebritySignatures grid + murals storefront.
+// Adds lightweight email/password accounts (scrypt + cookie sessions, JSON-backed)
+// so a visitor can SAVE a mural placement they set in the wall studio.
 import { createServer } from 'node:http';
 import { readFile, writeFile } from 'node:fs/promises';
 import { extname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
 
 const ROOT = fileURLToPath(new URL('.', import.meta.url));
 const PORT = process.env.PORT || 9920;
+const DATA = join(ROOT, 'data');
 const TYPES = {
   '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
   '.json': 'application/json', '.svg': 'image/svg+xml',
@@ -14,42 +18,154 @@ const TYPES = {
   '.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon',
 };
 
-const ORDERS = join(ROOT, 'data', 'mural-orders.json');
+// ---- tiny JSON store --------------------------------------------------------
+async function load(name, fallback) { try { return JSON.parse(await readFile(join(DATA, name), 'utf8')); } catch { return fallback; } }
+async function store(name, val) { await writeFile(join(DATA, name), JSON.stringify(val, null, 2)); }
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    let raw = '';
+    req.on('data', c => { raw += c; if (raw.length > 2e5) reject(new Error('too large')); });
+    req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('bad json')); } });
+    req.on('error', reject);
+  });
+}
+function sendJSON(res, code, obj, headers = {}) { res.writeHead(code, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); }
+
+// ---- auth helpers -----------------------------------------------------------
+const COOKIE = 'cs_sess';
+function hashPw(pw, salt) { return scryptSync(pw, salt, 64).toString('hex'); }
+function verifyPw(pw, salt, hash) {
+  const a = Buffer.from(hashPw(pw, salt), 'hex'), b = Buffer.from(hash, 'hex');
+  return a.length === b.length && timingSafeEqual(a, b);
+}
+function parseCookies(req) {
+  const out = {}; (req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
+  return out;
+}
+async function currentUser(req) {
+  const sessions = await load('sessions.json', {});
+  const tok = parseCookies(req)[COOKIE];
+  const s = tok && sessions[tok];
+  if (!s) return null;
+  const users = await load('users.json', []);
+  return users.find(u => u.id === s.userId) || null;
+}
+async function newSession(userId) {
+  const sessions = await load('sessions.json', {});
+  const tok = randomBytes(24).toString('hex');
+  sessions[tok] = { userId, at: new Date().toISOString() };
+  await store('sessions.json', sessions);
+  return tok;
+}
+const setCookie = tok => `${COOKIE}=${tok}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000`;
+const clearCookie = `${COOKIE}=; HttpOnly; Path=/; Max-Age=0`;
+const pubUser = u => ({ email: u.email, name: u.name || '' });
 
 createServer(async (req, res) => {
   try {
-    let path = decodeURIComponent(req.url.split('?')[0]);
-
-    // POST: capture a made-to-order mural request → append to data/mural-orders.json
-    if (req.method === 'POST' && path === '/api/mural-order') {
-      let raw = '';
-      for await (const c of req) { raw += c; if (raw.length > 1e5) { res.writeHead(413).end('too large'); return; } }
-      let body; try { body = JSON.parse(raw || '{}'); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }).end('{"ok":false,"error":"bad json"}'); return; }
-      if (!body.name || !body.email) { res.writeHead(400, { 'Content-Type': 'application/json' }).end('{"ok":false,"error":"name and email required"}'); return; }
-      let list = []; try { list = JSON.parse(await readFile(ORDERS, 'utf8')); } catch {}
+    const url = new URL(req.url, 'http://x');
+    let path = decodeURIComponent(url.pathname);
+    const M = req.method;
+
+    // ===== AUTH =====
+    if (path === '/api/auth/signup' && M === 'POST') {
+      const b = await readBody(req);
+      const email = String(b.email || '').trim().toLowerCase();
+      const pw = String(b.password || '');
+      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return sendJSON(res, 400, { ok: false, error: 'invalid email' });
+      if (pw.length < 6) return sendJSON(res, 400, { ok: false, error: 'password must be at least 6 characters' });
+      const users = await load('users.json', []);
+      if (users.some(u => u.email === email)) return sendJSON(res, 409, { ok: false, error: 'account already exists — sign in instead' });
+      const salt = randomBytes(16).toString('hex');
+      const user = { id: createHash('sha256').update(email + Date.now()).digest('hex').slice(0, 16), email, name: String(b.name || '').slice(0, 80), salt, hash: hashPw(pw, salt), at: new Date().toISOString() };
+      users.push(user); await store('users.json', users);
+      const tok = await newSession(user.id);
+      return sendJSON(res, 200, { ok: true, user: pubUser(user) }, { 'Set-Cookie': setCookie(tok) });
+    }
+    if (path === '/api/auth/login' && M === 'POST') {
+      const b = await readBody(req);
+      const email = String(b.email || '').trim().toLowerCase();
+      const users = await load('users.json', []);
+      const u = users.find(x => x.email === email);
+      if (!u || !verifyPw(String(b.password || ''), u.salt, u.hash)) return sendJSON(res, 401, { ok: false, error: 'wrong email or password' });
+      const tok = await newSession(u.id);
+      return sendJSON(res, 200, { ok: true, user: pubUser(u) }, { 'Set-Cookie': setCookie(tok) });
+    }
+    if (path === '/api/auth/logout' && M === 'POST') {
+      const sessions = await load('sessions.json', {});
+      const tok = parseCookies(req)[COOKIE];
+      if (tok) { delete sessions[tok]; await store('sessions.json', sessions); }
+      return sendJSON(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
+    }
+    if (path === '/api/auth/me' && M === 'GET') {
+      const u = await currentUser(req);
+      return sendJSON(res, 200, u ? { ok: true, user: pubUser(u) } : { ok: true, user: null });
+    }
+
+    // ===== SAVED PLACEMENTS (per account) =====
+    if (path === '/api/saves') {
+      const u = await currentUser(req);
+      if (!u) return sendJSON(res, 401, { ok: false, error: 'sign in to save' });
+      const all = await load('saves.json', []);
+      if (M === 'GET') return sendJSON(res, 200, { ok: true, saves: all.filter(s => s.userId === u.id).sort((a, b) => b.at.localeCompare(a.at)) });
+      if (M === 'POST') {
+        const b = await readBody(req);
+        if (!b.mural) return sendJSON(res, 400, { ok: false, error: 'mural required' });
+        const id = randomBytes(8).toString('hex');
+        const rec = { id, userId: u.id, at: new Date().toISOString(),
+          mural: String(b.mural).slice(0, 60), mural_title: String(b.mural_title || '').slice(0, 120),
+          wall_w: +b.wall_w || null, wall_h: +b.wall_h || null,
+          placement: b.placement && typeof b.placement === 'object' ? { from_left_ft: +b.placement.from_left_ft || 0, off_floor_ft: +b.placement.off_floor_ft || 0 } : null,
+          scene: String(b.scene || '').slice(0, 40), label: String(b.label || '').slice(0, 80) };
+        all.push(rec); await store('saves.json', all);
+        return sendJSON(res, 200, { ok: true, save: rec });
+      }
+      if (M === 'DELETE') {
+        const id = url.searchParams.get('id');
+        const kept = all.filter(s => !(s.id === id && s.userId === u.id));
+        await store('saves.json', kept);
+        return sendJSON(res, 200, { ok: true, removed: all.length - kept.length });
+      }
+    }
+
+    // ===== made-to-order request (existing) =====
+    if (path === '/api/mural-order' && M === 'POST') {
+      const b = await readBody(req);
+      if (!b.name || !b.email) return sendJSON(res, 400, { ok: false, error: 'name and email required' });
+      const u = await currentUser(req);
+      const list = await load('mural-orders.json', []);
       const id = (list.at(-1)?.id || 1000) + 1;
-      list.push({ id, at: new Date().toISOString(), ...body });
-      await writeFile(ORDERS, JSON.stringify(list, null, 2));
-      res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok: true, id }));
-      return;
+      list.push({ id, at: new Date().toISOString(), account: u ? u.email : null, ...b });
+      await store('mural-orders.json', list);
+      return sendJSON(res, 200, { ok: true, id });
     }
 
+    // ===== static + page routes =====
     if (path === '/') path = '/public/index.html';
     if (path === '/murals') path = '/public/murals.html';
+    if (path === '/account.js') path = '/public/account.js';
     if (path === '/api/signatures') path = '/data/celebrity_signatures.json';
     if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
-    // Short QR target: /k/<code> -> the named "key" mural. Single-letter codes keep
-    // the encoded URL tiny so the centre QR stays low-density and scans cleanly off a
-    // printed wall. Full-slug (/k/<cat>) still works as a fallback.
     const SHORT = { d: 'declaration-of-independence', p: 'politics', s: 'sports', h: 'hollywood', c: 'movies-classic', t: 'tv' };
-    const k = path.match(/^\/k\/([a-z0-9-]+)$/);
-    if (k) path = `/output/collage-${SHORT[k[1]] || k[1]}-named.png`;
+    const km = path.match(/^\/k\/([a-z0-9-]+)$/);
+    if (km) path = `/output/collage-${SHORT[km[1]] || km[1]}-named.png`;
+
     const file = join(ROOT, path);
     if (!file.startsWith(ROOT)) { res.writeHead(403).end('forbidden'); return; }
+
+    // Inject the account/save widget into the murals page WITHOUT editing murals.html.
+    if (path === '/public/murals.html') {
+      let html = await readFile(file, 'utf8');
+      html = html.replace('</body>', '<script src="/account.js"></script>\n</body>');
+      res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
+      res.end(html); return;
+    }
+
     const body = await readFile(file);
     res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
     res.end(body);
-  } catch {
+  } catch (e) {
+    if (e.message === 'bad json' || e.message === 'too large') { sendJSON(res, 400, { ok: false, error: e.message }); return; }
     res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found');
   }
-}).listen(PORT, () => console.log(`CelebritySignatures grid → http://localhost:${PORT}`));
+}).listen(PORT, () => console.log(`CelebritySignatures → http://localhost:${PORT}`));

← a27e4a9 data: recategorize FDR Sports→Politics + rebuild affected co  ·  back to CelebritySignatures  ·  snapshot before removing alive celebrities 3c6f492 →