🌱 C1-C3 — keep-your-build exit, reload persistence, orb tap/menu/storm

C1: leaving zero with a build no longer silently discards it — a compact
    three-way card (keep my build / bring the old world back / weave them
    together); esc stays in zero. 0-route exits stay quiet (restore prezero).
C2: the zero build survives a reload — picks + cables persist to
    gs_zeronodes/gs_zerobuild; enterZero(true) on the reload path restores them.
C3: tap-to-patch (arm an orb, tap a voice) as a touch fallback for drag;
    right-click an orb → member list (× to drop a feed + its cables),
    unpatch-this-orb, and a save-as-vibe stub (Z3); a ⛈ enter-the-storm pill.
Nit: the chakra-view key/menu is a no-op in zero (nothing to draw there).

Verified live (z0test): pick→orb→tap-patch lit lead.note; hard reload restored
the ☀ orb + lit socket; the exit card's keep-my-build landed in a healthy full
field on the storm mood with zero-storage cleared; orb menu drops members.
Console clean, syntax clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-10 23:57:14 +10:00
parent b6f1c59e15
commit 00503b14c3

View File

@ -2573,12 +2573,19 @@
// (routesArr/tweaks/synth), just started from nothing; leaving zero restores
// the world you had. Client-local, like moods — the hub/other users untouched.
let zeroMode = false;
function enterZero() {
if (!zeroMode) { // snapshot what you had, so exiting can restore it
function enterZero(restore) { // restore=true only on a reload back into an existing zero build
if (!zeroMode && !restore) { // fresh enter: snapshot what you had, so exiting can restore it
try { localStorage.setItem("gs_prezero", JSON.stringify(serializePatch())); } catch (e) {}
}
clientMatrix = true; // own the matrix so the hub can't re-populate the cables
loadPatch({ routes: [], tweaks: {}, seqs: {} }); // empty the field
if (restore) { // reload — bring the zero build's cables back (C2)
let build = null; try { build = JSON.parse(localStorage.getItem("gs_zerobuild") || "null"); } catch (e) {}
loadPatch(build && build.routes ? build : { routes: [], tweaks: {}, seqs: {} });
} else { // fresh — empty the field and forget any old build
loadPatch({ routes: [], tweaks: {}, seqs: {} });
zeroUI.nodes = {};
try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); } catch (e) {}
}
zeroMode = true;
try { localStorage.setItem("gs_mood", "0 zero"); } catch (e) {}
eventTicker.unshift({ text: "🌱 zero — the field is empty. pick a feed from a ring, or right-click a source → patch to → a voice, then press ♪", tAdded: now(), src: "sys" });
@ -2586,12 +2593,65 @@
layoutDirty = true;
}
function exitZeroToMood(name) { // leave zero back into the full world, then a mood
if (zeroMode) {
zeroMode = false;
zeroUI.close();
if (!zeroMode) { applyMood(name); return; }
if (routesArr.length === 0) { finishZeroExit("old", name); return; } // nothing built — quiet restore (C1)
zeroExitChoice(name); // you built something — ask what to keep with it
}
function finishZeroExit(mode, name) { // mode: keep | old | weave (C1)
const build = serializePatch(); // the zero-built patch, captured before we touch routes
zeroMode = false; zeroUI.close();
try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); } catch (e) {}
if (mode === "old" || mode === "weave") {
try { const pz = localStorage.getItem("gs_prezero"); if (pz) loadPatch(JSON.parse(pz)); } catch (e) {}
}
applyMood(name);
if (mode === "weave") for (const r of (build.routes || [])) registerRoute(r); // graft build atop the old world
applyMood(name); // keep: build stays as-is and the mood just rides it
layoutDirty = true;
}
function zeroExitChoice(name) { // compact three-way card (same language as the door card)
if (document.getElementById("zexit")) return;
const n = routesArr.length;
const wrap = document.createElement("div"); wrap.id = "zexit";
Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "90", display: "flex",
alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,0.72)",
backdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)" });
const card = document.createElement("div");
Object.assign(card.style, { maxWidth: "600px", width: "88%", textAlign: "center", color: "#d6e2f4" });
const h = document.createElement("div"); h.textContent = "leaving zero";
Object.assign(h.style, { fontWeight: "800", fontSize: "clamp(20px,3.5vw,28px)", letterSpacing: "2px", marginBottom: "6px" });
const sub = document.createElement("div");
sub.textContent = "you built " + n + " cable" + (n === 1 ? "" : "s") + " here. keep it?";
Object.assign(sub.style, { color: "rgba(165,185,218,0.75)", fontStyle: "italic", marginBottom: "24px", fontSize: "14px" });
card.appendChild(h); card.appendChild(sub);
const row = document.createElement("div");
Object.assign(row.style, { display: "flex", gap: "12px", flexWrap: "wrap", justifyContent: "center" });
const doors = [
["🌱", "keep my build", "this becomes the whole instrument. the world you left stays gone.", "keep"],
["🌌", "bring the old world back", "drop what you built, return to the field you had before zero.", "old"],
["🕸", "weave them together", "your build layered on top of the old world — both at once.", "weave"],
];
for (const [emoji, title, desc, mode] of doors) {
const b = document.createElement("div");
Object.assign(b.style, { flex: "1 1 160px", minWidth: "150px", maxWidth: "190px", cursor: "pointer",
padding: "18px 14px", borderRadius: "16px", background: "rgba(20,28,44,0.7)",
border: "1px solid rgba(120,150,200,0.28)", transition: "transform .12s, border-color .12s" });
b.onmouseenter = () => { b.style.transform = "translateY(-3px)"; b.style.borderColor = "rgba(150,200,255,0.7)"; };
b.onmouseleave = () => { b.style.transform = ""; b.style.borderColor = "rgba(120,150,200,0.28)"; };
b.innerHTML = '<div style="font-size:30px;margin-bottom:8px">' + emoji + '</div>'
+ '<div style="font-weight:700;color:#eaf1ff;margin-bottom:6px">' + title + '</div>'
+ '<div style="font-size:12px;color:rgba(180,195,225,0.75);line-height:1.5">' + desc + '</div>';
b.onclick = () => { wrap.remove(); document.removeEventListener("keydown", onEsc, true); finishZeroExit(mode, name); };
row.appendChild(b);
}
card.appendChild(row);
const foot = document.createElement("div"); foot.textContent = "esc — stay in zero";
Object.assign(foot.style, { marginTop: "20px", fontSize: "11px", color: "rgba(150,170,205,0.55)" });
card.appendChild(foot);
wrap.appendChild(card);
const onEsc = (e) => { if (e.key === "Escape") { wrap.remove(); document.removeEventListener("keydown", onEsc, true); } }; // stay in zero
wrap.onclick = (e) => { if (e.target === wrap) { wrap.remove(); document.removeEventListener("keydown", onEsc, true); } };
document.addEventListener("keydown", onEsc, true);
document.body.appendChild(wrap);
}
// The three doors, shown once on a first-ever visit (replaces the silent drop
@ -2722,7 +2782,11 @@
+ ".zfeed .bar{width:44px;height:4px;border-radius:2px;background:rgba(120,150,200,.15);overflow:hidden}"
+ ".zfeed .bar i{display:block;height:100%;border-radius:2px}"
+ ".zadd{width:22px;height:22px;border-radius:6px;border:1px solid rgba(150,180,255,.4);color:#9fd0ff;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;background:rgba(120,150,255,.12)}"
+ ".zadd.on{background:rgba(120,255,178,.18);border-color:rgba(120,255,178,.5);color:#7cffb2}";
+ ".zadd.on{background:rgba(120,255,178,.18);border-color:rgba(120,255,178,.5);color:#7cffb2}"
+ ".zorb.armed{outline:2px solid rgba(150,200,255,.85);outline-offset:5px;border-radius:16px}"
+ ".zrack.aiming .zsock{border-color:rgba(150,200,255,.5);cursor:pointer}"
+ ".zstorm{position:absolute;right:216px;bottom:150px;padding:8px 15px;border-radius:20px;background:rgba(30,26,52,.8);border:1px solid rgba(150,130,220,.4);color:#d8cbff;font-size:12px;cursor:pointer;pointer-events:auto;z-index:9;transition:transform .12s,border-color .12s}"
+ ".zstorm:hover{transform:translateY(-2px);border-color:rgba(180,160,255,.85)}";
document.head.appendChild(st);
const w = document.createElement("div"); w.id = "zerowrap"; this.root = w;
const colL = document.createElement("div"); colL.className = "zcol left";
@ -2740,7 +2804,7 @@
this.rings[key] = { ring, dot, lbl };
}
const stage = document.createElement("div"); stage.className = "zstage"; this.stage = stage;
const rack = document.createElement("div"); rack.className = "zrack"; this.socks = {};
const rack = document.createElement("div"); rack.className = "zrack"; this.socks = {}; this.rack = rack;
for (const sect of ZERO_RACK) {
const g = document.createElement("div"); g.className = "grp"; g.textContent = sect.grp; rack.appendChild(g);
const box = document.createElement("div");
@ -2750,17 +2814,83 @@
s.ondragleave = () => s.classList.remove("hot");
s.ondrop = (e) => { e.preventDefault(); s.classList.remove("hot"); this.routeGroup(e.dataTransfer.getData("text/plain"), key); };
s.oncontextmenu = (e) => { e.preventDefault(); this.assignMenu(e, key); };
s.onclick = () => { if (this.armed) { this.routeGroup(this.armed, key); this.disarm(); } }; // tap-to-patch (C3)
this.socks[key] = s; box.appendChild(s);
}
rack.appendChild(box);
}
const drawer = document.createElement("div"); drawer.className = "zdrawer"; this.drawer = drawer;
w.appendChild(colL); w.appendChild(colR); w.appendChild(stage); w.appendChild(rack); w.appendChild(drawer);
const storm = document.createElement("div"); storm.className = "zstorm"; storm.textContent = "⛈ enter the storm";
storm.title = "leave zero — step into the full world";
storm.onclick = () => exitZeroToMood("the full storm");
w.appendChild(colL); w.appendChild(colR); w.appendChild(stage); w.appendChild(rack); w.appendChild(drawer); w.appendChild(storm);
w.addEventListener("click", (e) => { // tap empty space → disarm (C3)
if (this.armed && e.target && !e.target.closest(".zorb") && !e.target.closest(".zsock")) this.disarm();
});
document.body.appendChild(w);
this.renderStage();
},
open() { this.build(); this.root.classList.add("on"); this.renderStage(); this.refreshSockets(); },
close() { if (this.root) { this.root.classList.remove("on"); this.closeDrawer(); } },
open() { this.build(); this.restoreNodes(); this.root.classList.add("on"); this.renderStage(); this.refreshSockets(); },
close() { if (this.root) { this.root.classList.remove("on"); this.closeDrawer(); this.disarm(); } },
persist() { // zero build survives a reload: picks + cables (C2)
try {
const nd = {}; for (const gk in this.nodes) nd[gk] = [...this.nodes[gk].members];
localStorage.setItem("gs_zeronodes", JSON.stringify(nd));
localStorage.setItem("gs_zerobuild", JSON.stringify(serializePatch()));
} catch (e) {}
},
restoreNodes() { // rebuild the picked orbs from storage (reload path)
try {
const nd = JSON.parse(localStorage.getItem("gs_zeronodes") || "null");
if (nd) { this.nodes = {}; for (const gk in nd) if (GROUP_META[gk]) this.nodes[gk] = { members: new Set(nd[gk]) }; }
} catch (e) {}
},
_dropSource(src) { for (const r of routesArr.filter(x => x.source === src)) removeRouteLocal(r.source, r.dest); },
armOrb(gk) { // tap-to-patch (touch fallback for drag) (C3)
this.armed = (this.armed === gk) ? null : gk;
for (const orb of (this.orbs || [])) orb.classList.toggle("armed", orb._group === this.armed);
if (this.rack) this.rack.classList.toggle("aiming", !!this.armed);
if (this.armed) eventTicker.unshift({ text: "🌱 armed " + GROUP_META[gk].emoji + " " + gk + " — tap a voice in the rack to patch it (or tap the orb again to cancel)", tAdded: now(), src: "sys" });
},
disarm() { if (!this.armed) return; this.armed = null;
for (const orb of (this.orbs || [])) orb.classList.remove("armed");
if (this.rack) this.rack.classList.remove("aiming"); },
orbMenu(e, gk) { // right-click an orb → members / unpatch / save-as-vibe (C3)
const old = document.getElementById("zorbmenu"); if (old) old.remove();
const node = this.nodes[gk]; if (!node) return;
const meta = GROUP_META[gk];
const m = document.createElement("div"); m.id = "zorbmenu";
Object.assign(m.style, { position: "fixed", left: Math.min(e.clientX, innerWidth - 250) + "px",
top: Math.min(e.clientY, innerHeight - 120 - node.members.size * 24) + "px",
zIndex: "80", background: "rgba(14,20,34,.97)", border: "1px solid rgba(120,150,200,.35)", borderRadius: "12px",
padding: "12px 14px", minWidth: "220px", color: "#dbe6f8", fontSize: "12px", boxShadow: "0 8px 30px rgba(0,0,0,.5)" });
const title = document.createElement("div"); title.textContent = meta.emoji + " " + gk; title.style.cssText = "font-weight:700;color:#eaf1ff;margin-bottom:8px";
m.appendChild(title);
for (const src of [...node.members]) { // each feed, with × to drop it (and its cables)
const r = document.createElement("div"); r.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;margin:5px 0";
const l = document.createElement("span"); l.textContent = (sources.get(src) || {}).label || src; l.style.cssText = "color:rgba(190,205,235,.85);overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
const x = document.createElement("span"); x.textContent = "×"; x.style.cssText = "color:#ff9a9a;cursor:pointer;flex:0 0 auto;font-size:15px";
x.onclick = () => { m.remove(); this.removeMember(gk, src); if (this.nodes[gk]) this.orbMenu(e, gk); };
r.appendChild(l); r.appendChild(x); m.appendChild(r);
}
const sep = document.createElement("div"); sep.style.cssText = "border-top:1px solid rgba(140,170,230,.14);margin:8px 0 6px"; m.appendChild(sep);
const unp = document.createElement("div"); unp.textContent = "⌫ unpatch this orb (keep the feeds)"; unp.style.cssText = "color:#ffbf87;cursor:pointer;margin:5px 0";
unp.onclick = () => { for (const src of node.members) this._dropSource(src); this.refreshSockets(); this.persist(); m.remove(); };
m.appendChild(unp);
const vibe = document.createElement("div"); vibe.textContent = "💾 save as vibe"; vibe.style.cssText = "color:#9fd0ff;cursor:pointer;margin:5px 0";
vibe.onclick = () => { m.remove(); if (this.saveVibe) this.saveVibe(gk); else eventTicker.unshift({ text: "💾 vibes land in the next update — build a feeling, save it, hand it to a friend", tAdded: now(), src: "sys" }); };
m.appendChild(vibe);
document.body.appendChild(m);
const off = (ev) => { if (!m.contains(ev.target)) { m.remove(); document.removeEventListener("mousedown", off, true); } };
setTimeout(() => document.addEventListener("mousedown", off, true), 0);
},
removeMember(gk, src) {
const node = this.nodes[gk]; if (!node) return;
node.members.delete(src); this._dropSource(src); // the feed and its cables leave together
if (!node.members.size) delete this.nodes[gk];
if (this.armed === gk && !this.nodes[gk]) this.disarm();
this.renderStage(); this.refreshSockets(); this.persist();
},
openDrawer(key) {
this.build();
const meta = GROUP_META[key], d = this.drawer;
@ -2799,7 +2929,7 @@
if (!node) node = this.nodes[groupKey] = { members: new Set() };
if (node.members.has(srcKey)) node.members.delete(srcKey); else node.members.add(srcKey);
if (!node.members.size) delete this.nodes[groupKey];
this.renderStage();
this.renderStage(); this.persist();
},
renderStage() {
if (!this.stage) return;
@ -2807,7 +2937,7 @@
const keys = Object.keys(this.nodes);
if (!keys.length) {
const hint = document.createElement("div"); hint.className = "zhint";
hint.textContent = "click a ring on either side → pick a feed → it becomes a spinning orb here. gather more and the orbs multiply. (drag to a voice — soon.)";
hint.textContent = "click a ring on either side → pick a feed → it becomes a spinning orb here. gather more and the orbs multiply. then drag an orb onto a voice (or tap it, then tap a voice) to wire it up.";
this.stage.appendChild(hint); return;
}
for (const gk of keys) { // each built group → a spinning orb; they accumulate
@ -2815,6 +2945,8 @@
const orb = document.createElement("div"); orb.className = "zorb"; orb.title = "drag me onto a voice → · " + mem.map(k => (sources.get(k) || {}).label || k).join(" · ");
orb.draggable = true;
orb.ondragstart = (e) => { e.dataTransfer.setData("text/plain", gk); e.dataTransfer.effectAllowed = "copy"; };
orb.onclick = (e) => { e.stopPropagation(); this.armOrb(gk); }; // tap-to-patch arm (C3)
orb.oncontextmenu = (e) => { e.preventDefault(); this.orbMenu(e, gk); }; // members / unpatch / vibe (C3)
const orbit = document.createElement("div"); orbit.className = "orbit";
const R = 44 + Math.min(mem.length, 8) * 1.5; // more feeds → a slightly wider ring
mem.forEach((k, i) => {
@ -2829,6 +2961,7 @@
cap.innerHTML = meta.emoji + " " + gk + " <small>· " + mem.length + "</small>";
orb.appendChild(orbit); orb.appendChild(core); orb.appendChild(cap);
orb._group = gk; orb._core = core;
if (this.armed === gk) orb.classList.add("armed"); // keep the arm visible across a rebuild
this.stage.appendChild(orb); this.orbs.push(orb);
}
},
@ -2842,7 +2975,7 @@
if (isNote) r.quantize = { scale: gScale.name || "minor_pent", root: /bass/.test(destKey) ? 36 : 60, octaves: 2 };
registerRoute(r); n++;
}
this.refreshSockets();
this.refreshSockets(); this.persist();
eventTicker.unshift({ text: "🌱 patched " + GROUP_META[gk].emoji + " " + gk + " → " + prettyDest(destKey)
+ " (" + n + " cable" + (n > 1 ? "s" : "") + "). press ♪ to hear it" + (isNote ? " · right-click the voice to pick a scale" : ""), tAdded: now(), src: "sys" });
},
@ -2875,7 +3008,7 @@
const rtRow = row("root"); const rt = document.createElement("input"); rt.type = "number"; rt.min = "24"; rt.max = "96"; rt.value = q.root; rt.style.width = "56px"; rtRow.appendChild(rt); m.appendChild(rtRow);
const ocRow = row("octaves"); const oc = document.createElement("input"); oc.type = "number"; oc.min = "1"; oc.max = "3"; oc.value = q.octaves; oc.style.width = "56px"; ocRow.appendChild(oc); m.appendChild(ocRow);
const apply = () => { const nq = { scale: sc.value, root: parseInt(rt.value) || 60, octaves: parseInt(oc.value) || 2 };
for (const r of into) r.quantize = nq; };
for (const r of into) r.quantize = nq; this.persist(); };
sc.onchange = apply; rt.onchange = apply; oc.onchange = apply;
} else {
const amRow = row("amount"); const am = document.createElement("input"); am.type = "range"; am.min = "0"; am.max = "1"; am.step = "0.01"; am.value = into[0].amount != null ? into[0].amount : 0.5; amRow.appendChild(am); m.appendChild(amRow);
@ -2883,10 +3016,11 @@
for (const c of ["lin", "exp", "exp3", "log", "scurve", "inv"]) { const o = document.createElement("option"); o.value = c; o.textContent = c; if (c === (into[0].curve || "lin")) o.selected = true; cv.appendChild(o); }
cvRow.appendChild(cv); m.appendChild(cvRow);
am.oninput = () => { for (const r of into) r.amount = parseFloat(am.value); };
cv.onchange = () => { for (const r of into) r.curve = cv.value; };
am.onchange = () => this.persist();
cv.onchange = () => { for (const r of into) r.curve = cv.value; this.persist(); };
}
const unp = document.createElement("div"); unp.textContent = "× unpatch all"; unp.style.cssText = "margin-top:10px;color:#ff9a9a;cursor:pointer;font-size:11px";
unp.onclick = () => { for (const r of [...into]) removeRouteLocal(r.source, r.dest); this.refreshSockets(); m.remove(); };
unp.onclick = () => { for (const r of [...into]) removeRouteLocal(r.source, r.dest); this.refreshSockets(); this.persist(); m.remove(); };
m.appendChild(unp);
document.body.appendChild(m);
const off = (ev) => { if (!m.contains(ev.target)) { m.remove(); document.removeEventListener("mousedown", off, true); } };
@ -3197,7 +3331,7 @@
try { localStorage.setItem("gs_seen", "1"); } catch (e) {}
showFirstVisitCard(); // three doors: zero / first light / storm
} else if (saved === "0 zero") {
enterZero(); // a returning zero-builder comes back to zero
enterZero(true); // a returning zero-builder comes back to zero, build intact (C2)
} else {
applyMood(saved);
}
@ -5733,7 +5867,7 @@
performMode = !performMode; // pure visuals: the CSS hides every
document.body.classList.toggle("perform", performMode); // DOM chrome, the frame loop
} // skips header/godtime/footer
if (ev.key === "c" || ev.key === "C") {
if ((ev.key === "c" || ev.key === "C") && !zeroMode) {
chakraMode = !chakraMode;
if (!chakraMode) { layoutDirty = true; exitMind(); } // sources glide home
else clearTrails();
@ -5925,7 +6059,7 @@
const sp = document.createElement("span"); sp.textContent = "view";
hd.appendChild(b); hd.appendChild(sp); ctxEl.appendChild(hd);
ctxItem(chakraMode ? "leave chakra view" : "chakra view",
() => { chakraMode = !chakraMode; if (!chakraMode) layoutDirty = true; }, "C");
() => { if (zeroMode) return; chakraMode = !chakraMode; if (!chakraMode) layoutDirty = true; }, "C");
const skinSub = ctxSub("skin · " + activeSkin.label);
for (const sk of Object.keys(SKINS)) {
const it2 = document.createElement("div"); it2.className = "it";
@ -6519,7 +6653,7 @@
const KEY_ACTIONS = {
grimoire: { label: "open the grimoire (help)", run: () => { const g = document.getElementById("grimoire"); if (g) g.classList.add("open"); } },
play: { label: "play / stop the synth", run: () => { if (typeof Synth !== "undefined") Synth.toggle(); } },
chakraView:{ label: "chakra / skin view", run: () => { chakraMode = !chakraMode; if (!chakraMode) { layoutDirty = true; exitMind(); } else clearTrails(); } },
chakraView:{ label: "chakra / skin view", run: () => { if (zeroMode) return; chakraMode = !chakraMode; if (!chakraMode) { layoutDirty = true; exitMind(); } else clearTrails(); } },
skinNext: { label: "next skin", run: () => { setSkin(nextSkinKey()); if (!chakraMode) { chakraMode = true; clearTrails(); } } },
skinPrev: { label: "previous skin", run: () => { setSkin(prevSkinKey()); if (!chakraMode) { chakraMode = true; clearTrails(); } } },
moodCycle: { label: "cycle mood (first light / drift / storm)", run: () => cycleMood() },