Z3 — vibes: build a feeling, save it, share it

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.<base64url(json)> 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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-11 00:08:36 +10:00
parent 00503b14c3
commit 55defaa695

View File

@ -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.<b64url>
// 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 = '<div style="font-weight:700;font-size:15px;margin-bottom:10px;color:#eaf1ff">💾 name this vibe</div>';
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 = '<div style="font-weight:800;font-size:17px;letter-spacing:.5px;color:#eaf1ff">✨ vibes</div>'
+ '<div style="color:rgba(165,185,218,.7);font-size:12px;font-style:italic;margin:2px 0 14px">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.</div>';
// 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 = '<div style="color:#eaf1ff;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (vibe.emoji || "🎁") + " " + (vibe.name || "vibe").replace(/</g, "&lt;") + '</div>'
+ '<div style="color:rgba(150,170,205,.55);font-size:10.5px">' + vibe.routes.length + " cable" + (vibe.routes.length > 1 ? "s" : "") + '</div>';
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 = '<div style="font-size:30px;margin-bottom:6px">' + (vibe.emoji || "🎁") + '</div>'
+ '<div style="font-weight:700;color:#eaf1ff;font-size:15px">someone shared a vibe</div>'
+ '<div style="color:#cdd9f0;font-size:13px;margin-top:4px">“' + (vibe.name || "vibe").replace(/</g, "&lt;") + '” · ' + vibe.routes.length + ' cable' + (vibe.routes.length > 1 ? "s" : "") + '</div>'
+ '<div style="color:rgba(165,185,218,.6);font-size:11px;font-style:italic;margin-top:8px">saved to your ✨ vibes. add its cables now?</div>';
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() {