From 00503b14c3ffd4d2ba57461655d18af2eb27b8a5 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 10 Jul 2026 23:57:14 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=B1=20C1-C3=20=E2=80=94=20keep-your-bu?= =?UTF-8?q?ild=20exit,=20reload=20persistence,=20orb=20tap/menu/storm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- viz/index.html | 178 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 156 insertions(+), 22 deletions(-) diff --git a/viz/index.html b/viz/index.html index 5e6e45a..23d2833 100644 --- a/viz/index.html +++ b/viz/index.html @@ -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 = '
' + emoji + '
' + + '
' + title + '
' + + '
' + desc + '
'; + 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 + " · " + mem.length + ""; 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() },