From 55defaa695dd7a0156e8a593d75c96d277f0c064 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 11 Jul 2026 00:08:36 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Z3=20=E2=80=94=20vibes:=20build=20a?= =?UTF-8?q?=20feeling,=20save=20it,=20share=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vibe = a small bundle of cables {v:1,name,emoji,routes[โ‰ค32]}. Distinct from a dimension (the whole scene): a vibe is a feeling you can hand a friend. - apply is additive + reversible: routes a vibe creates are tagged (route._vibes) so toggling it off removes exactly those; a route two vibes share survives until both are off; a hand-built cable (route._base) a vibe merely rides is NEVER deleted. Tags live in-memory only โ€” they don't leak into saved patches. - save from a zero orb (right-click โ†’ ๐Ÿ’พ), from any node's right-click in the full field (๐Ÿ’พ save these as a vibe), or 'save current wiring' in the panel (โ‰ค32 cap). - โœจ vibes panel (zero pill + โš™ gear): list, toggle on/off, โง‰ copy share code, ร— delete, paste-to-import. localStorage library (gs_vibes). - share: GSV1. clipboard code + godstrument.pro/#vibe=โ€ฆ fragment that OFFERS on load (add it / just save) and never auto-applies. - trust boundary: sanitizeVibe hard-validates every import โ€” unknown source/dest skipped (not thrown), amounts/roots/octaves clamped, curves/scales whitelisted, 32-route cap, try/catch the base64/JSON. Garbage codes are rejected cleanly. - per-account cloud storage deferred: the code + local library already deliver build/save/share across devices and friends. (add a vibes table if users ask.) Verified live (z0test): built a 2-cable orb โ†’ saved 'sunrise duo' (quantize preserved) โ†’ panel listed it โ—‰ โ†’ share code round-tripped through import โ†’ garbage code rejected โ†’ hard-reload kept the library โ†’ toggle ON created exactly 2 routes (lead.note lit), toggle OFF removed them โ†’ #vibe= fragment offered 'shared gift' in the full app, hash cleared, not auto-applied. Console clean. Co-Authored-By: Claude Opus 4.8 --- viz/index.html | 252 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 2 deletions(-) diff --git a/viz/index.html b/viz/index.html index 23d2833..2985aa7 100644 --- a/viz/index.html +++ b/viz/index.html @@ -2786,7 +2786,9 @@ + ".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)}"; + + ".zstorm:hover{transform:translateY(-2px);border-color:rgba(180,160,255,.85)}" + + ".zvibes{position:absolute;left:216px;bottom:150px;padding:8px 15px;border-radius:20px;background:rgba(18,32,44,.8);border:1px solid rgba(120,190,220,.4);color:#c8ecf5;font-size:12px;cursor:pointer;pointer-events:auto;z-index:9;transition:transform .12s,border-color .12s}" + + ".zvibes:hover{transform:translateY(-2px);border-color:rgba(150,220,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"; @@ -2823,7 +2825,10 @@ 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); + const vibes = document.createElement("div"); vibes.className = "zvibes"; vibes.textContent = "โœจ vibes"; + vibes.title = "save & share small route bundles"; + vibes.onclick = () => openVibesPanel(); + w.appendChild(colL); w.appendChild(colR); w.appendChild(stage); w.appendChild(rack); w.appendChild(drawer); w.appendChild(storm); w.appendChild(vibes); w.addEventListener("click", (e) => { // tap empty space โ†’ disarm (C3) if (this.armed && e.target && !e.target.closest(".zorb") && !e.target.closest(".zsock")) this.disarm(); }); @@ -2891,6 +2896,18 @@ if (this.armed === gk && !this.nodes[gk]) this.disarm(); this.renderStage(); this.refreshSockets(); this.persist(); }, + saveVibe(gk) { // one orb's routes โ†’ a shareable vibe (Z3) + const node = this.nodes[gk]; if (!node) return; + const members = [...node.members]; + const routes = routesArr.filter(r => members.includes(r.source)); + if (!routes.length) { eventTicker.unshift({ text: "๐Ÿ’พ patch this orb to a voice first, then save it as a vibe", tAdded: now(), src: "sys" }); return; } + nameVibeCard(GROUP_META[gk].emoji + " " + gk, (name) => { + const vibe = vibeFromRoutes(routes, name, GROUP_META[gk].emoji); + if (!vibe) { eventTicker.unshift({ text: "๐Ÿ’พ couldn't save that vibe", tAdded: now(), src: "sys" }); return; } + vibeStore.save(vibe); applyVibe(vibe); // snapshot + mark live (its cables are already on) + eventTicker.unshift({ text: "๐Ÿ’พ saved vibe โ€œ" + vibe.name + "โ€ ยท " + vibe.routes.length + " cable" + (vibe.routes.length > 1 ? "s" : "") + " โ€” open โœจ vibes to share the code", tAdded: now(), src: "sys" }); + }); + }, openDrawer(key) { this.build(); const meta = GROUP_META[key], d = this.drawer; @@ -3052,6 +3069,212 @@ }, }; + // ---- VIBES (Z3) โ€” small, shareable route bundles ---------------------------- + // A vibe = { v:1, name, emoji, routes:[{source,dest,amount,curve,quantize?}] }. + // Applying is ADDITIVE and reversible: routes a vibe creates are tagged + // (route._vibes) so the vibe can be toggled off cleanly; a route two vibes share + // survives until both are off; a hand-built route (route._base) a vibe rides is + // NEVER deleted by toggling. Stored in localStorage; shared as a GSV1. + // code. Per-account cloud storage is deferred โ€” the code + local library already + // deliver build/save/share across devices and friends. + // ponytail: add a vibes table (additive auth.py) only if users ask for a cloud library. + const VIBE_CURVES = new Set(["lin", "exp", "exp3", "log", "scurve", "inv"]); + function vibeKnownSource(k) { + if (sources.has(k)) return true; + for (const g in groupsInfo) if ((groupsInfo[g].members || []).includes(k)) return true; + return false; + } + const vibeKnownDest = (k) => dests.has(k); + function sanitizeVibe(raw) { // trust boundary: user-pasted / URL data + if (!raw || typeof raw !== "object") return null; + const name = (typeof raw.name === "string" && raw.name.trim()) ? raw.name.trim().slice(0, 48) : "shared vibe"; + const emoji = (typeof raw.emoji === "string" && raw.emoji) ? [...raw.emoji].slice(0, 2).join("") : "๐ŸŽ"; + if (!Array.isArray(raw.routes)) return null; + const routes = []; + for (const r of raw.routes.slice(0, 32)) { // hard cap + if (!r || typeof r.source !== "string" || typeof r.dest !== "string") continue; + if (!vibeKnownSource(r.source) || !vibeKnownDest(r.dest)) continue; // skip unknown, don't throw + const route = { source: r.source, dest: r.dest, + amount: (typeof r.amount === "number" && isFinite(r.amount)) ? clamp(r.amount, -4, 4) : 0.5, + curve: VIBE_CURVES.has(r.curve) ? r.curve : "lin" }; + if (r.quantize && typeof r.quantize === "object") { + const q = r.quantize; + route.quantize = { + scale: (typeof q.scale === "string" && SCALES[q.scale]) ? q.scale : "minor_pent", + root: (typeof q.root === "number" && isFinite(q.root)) ? clamp(Math.round(q.root), 0, 127) : 60, + octaves: (typeof q.octaves === "number" && isFinite(q.octaves)) ? clamp(Math.round(q.octaves), 1, 4) : 2, + }; + } + routes.push(route); + } + return routes.length ? { v: 1, name, emoji, routes } : null; + } + function b64urlEncode(str) { + return btoa(unescape(encodeURIComponent(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + function b64urlDecode(s) { + s = String(s).replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; + return decodeURIComponent(escape(atob(s))); + } + const vibeToCode = (vibe) => "GSV1." + b64urlEncode(JSON.stringify({ v: 1, name: vibe.name, emoji: vibe.emoji, routes: vibe.routes })); + function codeToVibe(code) { + if (typeof code !== "string") return null; + const m = /GSV1\.([A-Za-z0-9_\-]+)/.exec(code.trim()); + if (!m) return null; + try { return sanitizeVibe(JSON.parse(b64urlDecode(m[1]))); } catch (e) { return null; } + } + const vibeStore = { + KEY: "gs_vibes", + all() { try { const a = JSON.parse(localStorage.getItem(this.KEY) || "[]"); return Array.isArray(a) ? a : []; } catch (e) { return []; } }, + save(vibe) { const a = this.all().filter(x => x.name !== vibe.name); a.push(vibe); this._write(a); }, + remove(name) { this._write(this.all().filter(x => x.name !== name)); }, + _write(a) { try { localStorage.setItem(this.KEY, JSON.stringify(a.slice(-64))); } catch (e) {} }, + }; + const activeVibes = new Set(); // names currently applied โ€” drives the panel toggles + function applyVibe(vibe) { + const v = sanitizeVibe(vibe); if (!v) return 0; + let n = 0; + for (const r of v.routes) { + const id = r.source + "|" + r.dest; + let route = routeIndex.get(id); + if (!route) { registerRoute(r); route = routeIndex.get(id); if (!route) continue; route._base = false; } + if (!route._vibes) { route._vibes = new Set(); if (route._base === undefined) route._base = true; } // pre-existing = a hand-built base route + route._vibes.add(v.name); n++; + } + activeVibes.add(v.name); layoutDirty = true; + if (zeroMode && zeroUI.root) { zeroUI.refreshSockets(); zeroUI.persist(); } + return n; + } + function unapplyVibe(name) { + for (const route of [...routesArr]) { + if (route._vibes && route._vibes.has(name)) { + route._vibes.delete(name); + if (route._vibes.size === 0 && !route._base) removeRouteLocal(route.source, route.dest); // never removes a hand-built cable + } + } + activeVibes.delete(name); layoutDirty = true; + if (zeroMode && zeroUI.root) { zeroUI.refreshSockets(); zeroUI.persist(); } + } + function vibeFromRoutes(routes, name, emoji) { + const rs = routes.slice(0, 32).map(r => { + const o = { source: r.source, dest: r.dest, amount: r.amount, curve: r.curve || "lin" }; + if (r.quantize) o.quantize = { scale: r.quantize.scale, root: r.quantize.root, octaves: r.quantize.octaves }; + return o; + }); + return sanitizeVibe({ v: 1, name, emoji, routes: rs }); + } + function nameVibeCard(defName, onName) { // small centered naming card (matches the app's dark cards) + const old = document.getElementById("znamecard"); if (old) old.remove(); + const wrap = document.createElement("div"); wrap.id = "znamecard"; + Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "95", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,.66)", backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" }); + const card = document.createElement("div"); + Object.assign(card.style, { background: "rgba(14,20,34,.98)", border: "1px solid rgba(120,150,200,.3)", borderRadius: "14px", padding: "20px 22px", width: "320px", maxWidth: "88%", textAlign: "center", color: "#dbe6f8", boxShadow: "0 12px 44px rgba(0,0,0,.5)" }); + card.innerHTML = '
๐Ÿ’พ name this vibe
'; + const inp = document.createElement("input"); inp.type = "text"; inp.value = defName || ""; inp.maxLength = 48; + Object.assign(inp.style, { width: "100%", padding: "9px 11px", borderRadius: "9px", background: "rgba(8,12,22,.8)", border: "1px solid rgba(120,150,200,.3)", color: "#eaf1ff", fontSize: "13px", fontFamily: "inherit", boxSizing: "border-box" }); + card.appendChild(inp); + const row = document.createElement("div"); row.style.cssText = "display:flex;gap:10px;justify-content:center;margin-top:14px"; + const ok = document.createElement("button"); ok.textContent = "save"; ok.className = "midibtn"; + const no = document.createElement("button"); no.textContent = "cancel"; no.className = "midibtn"; no.style.opacity = ".7"; + row.appendChild(ok); row.appendChild(no); card.appendChild(row); + wrap.appendChild(card); document.body.appendChild(wrap); + inp.focus(); inp.select(); + const done = (val) => { wrap.remove(); if (val && val.trim()) onName(val.trim().slice(0, 48)); }; + ok.onclick = () => done(inp.value); + no.onclick = () => done(null); + inp.onkeydown = (e) => { if (e.key === "Enter") done(inp.value); if (e.key === "Escape") done(null); }; + wrap.onclick = (e) => { if (e.target === wrap) done(null); }; + } + function saveCurrentAsVibe() { // "save current wiring" โ€” small builds only + if (!routesArr.length) { eventTicker.unshift({ text: "โœจ nothing wired yet โ€” build a cable or two first", tAdded: now(), src: "sys" }); return; } + if (routesArr.length > 32) { eventTicker.unshift({ text: "โœจ that's " + routesArr.length + " cables โ€” too big for a vibe (max 32). save the whole thing as a dimension instead (โš™ โ†’ DIMENSIONS)", tAdded: now(), src: "sys" }); return; } + nameVibeCard("my vibe", (name) => { + const vibe = vibeFromRoutes(routesArr, name, "โœจ"); + if (!vibe) return; + vibeStore.save(vibe); applyVibe(vibe); + eventTicker.unshift({ text: "๐Ÿ’พ saved vibe โ€œ" + vibe.name + "โ€ ยท " + vibe.routes.length + " cables", tAdded: now(), src: "sys" }); + if (document.getElementById("vibepanel")) openVibesPanel(); + }); + } + function copyVibeCode(vibe) { + const code = vibeToCode(vibe); + const say = (ok) => eventTicker.unshift({ text: ok ? ("โง‰ copied โ€œ" + vibe.name + "โ€ โ€” paste the code to a friend, or into โœจ vibes โ†’ import") : "โง‰ copy failed โ€” the code is in the console", tAdded: now(), src: "sys" }); + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(() => say(true), () => { console.log("vibe code:", code); say(false); }); + else { console.log("vibe code:", code); say(false); } + } + function importVibeCode(code) { // returns the sanitized vibe (or null) โ€” caller messages + const vibe = codeToVibe(code); if (!vibe) return null; + vibeStore.save(vibe); return vibe; + } + function openVibesPanel() { + const old = document.getElementById("vibepanel"); if (old) old.remove(); + const wrap = document.createElement("div"); wrap.id = "vibepanel"; + Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "92", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,.6)", backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" }); + const card = document.createElement("div"); + Object.assign(card.style, { background: "rgba(12,18,32,.98)", border: "1px solid rgba(120,150,200,.28)", borderRadius: "16px", padding: "20px 22px", width: "440px", maxWidth: "92%", maxHeight: "82vh", overflow: "auto", color: "#dbe6f8", boxShadow: "0 14px 50px rgba(0,0,0,.55)" }); + card.innerHTML = '
โœจ vibes
' + + '
build a feeling, save it, hand it to a friend. a vibe is a small bundle of cables โ€” toggling one adds or removes its cables; anything you built by hand stays.
'; + // import row + const imp = document.createElement("div"); imp.style.cssText = "display:flex;gap:8px;margin-bottom:6px"; + const impIn = document.createElement("input"); impIn.type = "text"; impIn.placeholder = "paste a GSV1โ€ฆ vibe code"; + Object.assign(impIn.style, { flex: "1", padding: "8px 10px", borderRadius: "8px", background: "rgba(8,12,22,.8)", border: "1px solid rgba(120,150,200,.3)", color: "#eaf1ff", fontSize: "12px", fontFamily: "inherit", boxSizing: "border-box" }); + const impBtn = document.createElement("button"); impBtn.textContent = "import"; impBtn.className = "midibtn"; + const impMsg = document.createElement("div"); impMsg.style.cssText = "font-size:11px;color:rgba(150,170,205,.7);min-height:14px;margin-bottom:10px"; + impBtn.onclick = () => { const v = importVibeCode(impIn.value); if (v) { impIn.value = ""; impMsg.textContent = "added โ€œ" + v.name + "โ€ ยท " + v.routes.length + " cables โ€” toggle it on below"; render(); } else impMsg.textContent = "that's not a valid vibe code."; }; + imp.appendChild(impIn); imp.appendChild(impBtn); card.appendChild(imp); card.appendChild(impMsg); + // save-current + const saveCur = document.createElement("button"); saveCur.textContent = "๏ผ‹ save current wiring as a vibe"; saveCur.className = "midibtn"; saveCur.style.cssText += "margin-bottom:12px"; + saveCur.onclick = () => saveCurrentAsVibe(); + card.appendChild(saveCur); + // list + const list = document.createElement("div"); card.appendChild(list); + const render = () => { + list.innerHTML = ""; + const vibes = vibeStore.all(); + if (!vibes.length) { const e = document.createElement("div"); e.style.cssText = "color:rgba(150,170,205,.55);font-size:12px;font-style:italic;padding:10px 0"; e.textContent = "no vibes yet โ€” save an orb in zero (right-click it), or your current wiring above."; list.appendChild(e); return; } + for (const vibe of vibes.slice().reverse()) { + const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;gap:10px;padding:9px 4px;border-top:1px solid rgba(140,170,230,.1)"; + const on = activeVibes.has(vibe.name); + const tog = document.createElement("div"); tog.textContent = on ? "โ—‰" : "โ—‹"; tog.title = on ? "applied โ€” click to remove its cables" : "click to add its cables"; + tog.style.cssText = "cursor:pointer;font-size:18px;color:" + (on ? "#7cffb2" : "rgba(150,180,255,.6)"); + tog.onclick = () => { if (activeVibes.has(vibe.name)) unapplyVibe(vibe.name); else applyVibe(vibe); render(); }; + const nm = document.createElement("div"); nm.style.cssText = "flex:1;min-width:0"; + nm.innerHTML = '
' + (vibe.emoji || "๐ŸŽ") + " " + (vibe.name || "vibe").replace(/' + + '
' + vibe.routes.length + " cable" + (vibe.routes.length > 1 ? "s" : "") + '
'; + const cp = document.createElement("div"); cp.textContent = "โง‰"; cp.title = "copy share code"; cp.style.cssText = "cursor:pointer;font-size:15px;color:#9fd0ff;padding:2px 6px"; + cp.onclick = () => copyVibeCode(vibe); + const del = document.createElement("div"); del.textContent = "ร—"; del.title = "delete"; del.style.cssText = "cursor:pointer;font-size:16px;color:#ff9a9a;padding:2px 6px"; + del.onclick = () => { if (activeVibes.has(vibe.name)) unapplyVibe(vibe.name); vibeStore.remove(vibe.name); render(); }; + row.appendChild(tog); row.appendChild(nm); row.appendChild(cp); row.appendChild(del); list.appendChild(row); + } + }; + render(); + const foot = document.createElement("div"); foot.style.cssText = "text-align:right;margin-top:14px"; + const close = document.createElement("button"); close.textContent = "done"; close.className = "midibtn"; close.onclick = () => wrap.remove(); + foot.appendChild(close); card.appendChild(foot); + wrap.appendChild(card); document.body.appendChild(wrap); + wrap.onclick = (e) => { if (e.target === wrap) wrap.remove(); }; + } + function offerVibeImport(vibe) { // #vibe= fragment / shared code โ€” never auto-applies + const wrap = document.createElement("div"); wrap.id = "vibeoffer"; + Object.assign(wrap.style, { position: "fixed", inset: "0", zIndex: "96", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,10,20,.7)", backdropFilter: "blur(7px)", WebkitBackdropFilter: "blur(7px)" }); + const card = document.createElement("div"); + Object.assign(card.style, { background: "rgba(14,20,34,.98)", border: "1px solid rgba(120,150,200,.3)", borderRadius: "16px", padding: "22px 24px", width: "360px", maxWidth: "90%", textAlign: "center", color: "#dbe6f8", boxShadow: "0 12px 44px rgba(0,0,0,.5)" }); + card.innerHTML = '
' + (vibe.emoji || "๐ŸŽ") + '
' + + '
someone shared a vibe
' + + '
โ€œ' + (vibe.name || "vibe").replace(/ 1 ? "s" : "") + '
' + + '
saved to your โœจ vibes. add its cables now?
'; + const row = document.createElement("div"); row.style.cssText = "display:flex;gap:10px;justify-content:center;margin-top:16px"; + const apply = document.createElement("button"); apply.textContent = "add it"; apply.className = "midibtn"; + const later = document.createElement("button"); later.textContent = "just save"; later.className = "midibtn"; later.style.opacity = ".7"; + apply.onclick = () => { applyVibe(vibe); wrap.remove(); eventTicker.unshift({ text: "โœจ added โ€œ" + vibe.name + "โ€ โ€” press โ™ช to hear it", tAdded: now(), src: "sys" }); }; + later.onclick = () => wrap.remove(); + row.appendChild(apply); row.appendChild(later); card.appendChild(row); + wrap.appendChild(card); document.body.appendChild(wrap); + wrap.onclick = (e) => { if (e.target === wrap) wrap.remove(); }; + vibeStore.save(vibe); // saved either way; applying is opt-in + } + function makeGroupLocal(name, members) { if (!name || !members || !members.length) return; groupsInfo[name] = { label: name, members: members.slice() }; @@ -3335,6 +3558,11 @@ } else { applyMood(saved); } + try { // #vibe=GSV1โ€ฆ โ€” a shared vibe in the URL; offer, never auto-apply (Z3) + const m = /[#&]vibe=([^&]+)/.exec(location.hash || ""); + if (m) { const v = codeToVibe(decodeURIComponent(m[1])); if (v) setTimeout(() => offerVibeImport(v), 400); + history.replaceState(null, "", location.pathname + location.search); } + } catch (e) {} }, 900); } } @@ -6010,6 +6238,15 @@ row.appendChild(lbl); row.appendChild(sl); row.appendChild(x); cb.appendChild(row); } + if (rel.length) ctxItem("๐Ÿ’พ save these as a vibe", () => { // this node's cables โ†’ a shareable vibe (Z3) + closeCtx(); + nameVibeCard(isSrc ? (node.label || node.key) : prettyDest(node.key), (name) => { + const vibe = vibeFromRoutes(rel, name, "โœจ"); + if (!vibe) return; + vibeStore.save(vibe); applyVibe(vibe); + eventTicker.unshift({ text: "๐Ÿ’พ saved vibe โ€œ" + vibe.name + "โ€ ยท " + vibe.routes.length + " cable" + (vibe.routes.length > 1 ? "s" : "") + " โ€” โš™ โ†’ โœจ vibes to share it", tAdded: now(), src: "sys" }); + }); + }); // lay a new cable from here const pb = ctxSub(isSrc ? "patch to โ†’" : "add driver โ†"); @@ -7265,6 +7502,17 @@ }; loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow); refreshTemplates(); + + // Vibes โ€” small, shareable route bundles (Z3). A dimension is everything; + // a vibe is a feeling you can hand a friend. + const vh = document.createElement("h3"); vh.textContent = "โœจ VIBES"; vh.style.marginTop = "12px"; + panelEl.appendChild(vh); + const vMsg = document.createElement("div"); vMsg.className = "hint"; vMsg.style.margin = "2px 0 6px"; + vMsg.textContent = "build a feeling, save it, share the code."; + panelEl.appendChild(vMsg); + const vBtn = document.createElement("button"); vBtn.textContent = "open โœจ vibes"; vBtn.className = "midibtn"; + vBtn.onclick = () => openVibesPanel(); + panelEl.appendChild(vBtn); } async function refreshTemplates() {