✨ 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:
parent
00503b14c3
commit
55defaa695
252
viz/index.html
252
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.<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, "<") + '</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, "<") + '” · ' + 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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user