openvibers/openvibe.live Commits
Chat's update card for viewers, and the streamer's "Buttons for your viewers" on the dashboard (#845)
Changes
@@@ -529,3 +529,33 @@.dash-bench-form label { margin-top: 6px; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); }.dash-bench-form .btn { justify-self: start; min-height: 40px; margin-top: 10px; }#dash-bench-status:empty { display: none; }++++/* Buttons for your viewers (Controls tab, dashboard.js loadDashElements): the deck switch, each button with its own++ look as a chip, an On switch and Remove, then where to get more. 44 px targets on a phone. */++.dash-elements > p.muted { margin: 0 0 12px; font-size: 0.88rem; }++.dash-el-switch { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); cursor: pointer; }++.dash-el-switch input { flex: none; width: 22px; height: 22px; accent-color: var(--accent); }++.dash-el-switch b { display: block; font-size: 0.95rem; }++.dash-el-switch small { display: block; margin-top: 2px; color: var(--text-muted); font-size: 0.8rem; }++.dash-el-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }++.dash-el-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 12px; }++.dash-el-row.is-off { opacity: 0.6; }++.dash-el-chip { max-width: 9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 7px 12px; border-radius: 999px; background: var(--accent); color: var(--on-accent, #fff); font-size: 0.8rem; font-weight: 700; }++.dash-el-text { min-width: 0; }++.dash-el-text b { display: block; font-size: 0.92rem; overflow-wrap: anywhere; }++.dash-el-text small { display: block; color: var(--text-muted); font-size: 0.8rem; line-height: 1.35; }++.dash-el-on { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; font-size: 0.82rem; color: var(--text-secondary); cursor: pointer; }++.dash-el-on input { width: 18px; height: 18px; accent-color: var(--accent); }++.dash-el-del { display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 10px; background: none; color: var(--text-muted); cursor: pointer; }++.dash-el-del:hover, .dash-el-del:focus-visible { color: #f87171; background: color-mix(in srgb, #f87171 12%, transparent); }++.dash-el-empty { padding: 14px; border: 1px dashed var(--border); border-radius: 12px; color: var(--text-secondary); font-size: 0.88rem; }++.dash-el-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 0; }++.dash-el-actions .btn { min-height: 40px; }++.dash-el-status:empty { display: none; }++.dash-el-status.is-bad { color: #f87171; }++@media (max-width: 520px) {++ .dash-el-row { grid-template-columns: auto minmax(0, 1fr) auto; }++ .dash-el-chip { grid-row: span 2; align-self: start; max-width: 6.5em; }++ .dash-el-on { grid-column: 2; justify-self: start; min-height: 36px; }++ .dash-el-del { grid-column: 3; grid-row: 1 / span 2; }++}@@@ -122,6 +122,24 @@</div><!-- OpenVibe.Bot robot shown on the channel page (LIVE_BOT_EMBED; shown by loadBotEmbedSetting when the flag is on) -->++ <!-- Buttons for your viewers (OpenVibe Elements): the streamer's library, the deck switch, and where to get++ more. Live #842 shipped the API and the viewers' deck with no place for the streamer to see or turn++ them on (the 11-12 review). dashboard.js loadDashElements draws it with DOM nodes. -->++ <div class="dash-card dash-elements" id="dash-card-elements">++ <h3><i class="fa-solid fa-hand-pointer" aria-hidden="true"></i> Buttons for your viewers</h3>++ <p class="muted">Buttons people press under your stream: move your robot, play a sound, start a poll and more. Every press checks who may press, your cooldowns and channel points.</p>++ <label class="dash-el-switch" for="dash-el-enabled">++ <input type="checkbox" id="dash-el-enabled" disabled>++ <span><b>Show them under my stream</b><small id="dash-el-enabled-note">Loading…</small></span>++ </label>++ <ul class="dash-el-list" id="dash-el-list" aria-label="Your buttons"></ul>++ <p class="dash-el-actions">++ <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Make one with AI</a>++ <a class="btn btn-small" href="https://openvibe.design/gallery" target="_blank" rel="noopener"><i class="fa-solid fa-grip" aria-hidden="true"></i> Free ones in the gallery</a>++ <a class="btn btn-small" id="dash-el-see" href="/" hidden><i class="fa-solid fa-eye" aria-hidden="true"></i> See them on your channel</a>++ </p>++ <p class="dash-el-status muted" id="dash-el-status" role="status"></p>++ </div><div class="dash-card" id="dash-card-bot-embed" style="display:none"><h3><i class="fa-solid fa-robot"></i> Your robot</h3><p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera). Then, on <a href="https://openvibe.bot/robots" target="_blank" rel="noopener">its page there</a>, press <strong>Put the buttons under my OpenVibe.Live stream</strong>: it shows the robot's panel under your stream and brings you back here to confirm. Or put its id here.</p>@@@ -3043,7 +3043,12 @@ function renderDeployCard(data, opts = {}) {if (!container) return;_deployCss();const seen = new Set();-- const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject));++ // What changed for the people in chat: a commit that only touched the docs (README, CHANGELOG, docs/) is not news++ // to a viewer, and "(#843)" is our number, not theirs (the 11-12 review: "README: the event list no longer includes++ // merge events (#841)" was in a channel's chat).++ const DOCS_ONLY = /^(readme|changelog|docs?|agents(\.md)?|contributing)\b\s*[:\-]/i;++ const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !DOCS_ONLY.test(c.subject) && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject))++ .map(c => ({ ...c, subject: String(c.subject).replace(/\s*\(#\d+\)\s*$/, '') }));if (!commits.length) return;const fresh = new Set(data.fresh || []);const first = _parseMsgTime(data.first_at) || new Date(), last = _parseMsgTime(data.updated_at) || first;@@@ -14,7 +14,7 @@ const _DASH_TAB_CARDS = {home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes', 'dash-bench-stream', 'dash-card-chatlogs'],moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation-- controls: ['dash-card-bot-embed', 'dash-card-tokens'],++ controls: ['dash-card-elements', 'dash-card-bot-embed', 'dash-card-tokens'],money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)@@@ -129,7 +129,116 @@ async function loadDashVibe() {} catch (err) { status.textContent = (err && err.message) || 'That did not save.'; }});}--window._dashTabLoaders.controls = () => { _call('loadBotEmbedSetting'); _call('loadDashTokens'); };++window._dashTabLoaders.controls = () => { loadDashElements(); _call('loadBotEmbedSetting'); _call('loadDashTokens'); };++++/* ── Buttons for your viewers (OpenVibe Elements) ─────────────────++ The library from GET /api/me/elements, the deck switch (PUT /api/me/elements/settings), each button on or off++ (PATCH active) and removed (DELETE). Made with AI or installed on OpenVibe.Design; nothing here is drawn from++ markup an element carries: names and labels are text nodes. */++function _dashElNode(tag, cls, text) {++ const n = document.createElement(tag);++ if (cls) n.className = cls;++ if (text != null) n.textContent = String(text);++ return n;++}++function _dashElSay(text, bad) {++ const s = document.getElementById('dash-el-status');++ if (s) { s.textContent = text || ''; s.classList.toggle('is-bad', !!bad); }++}++/** One line on what a button does: its own description, else what pressing costs and how often. */++function _dashElLine(e) {++ if (e.description) return e.description;++ const c = (e.recipe && e.recipe.conditions) || {};++ const bits = [];++ if (c.cost && c.cost.points) bits.push(`${Number(c.cost.points).toLocaleString()} points a press`);++ if (c.cooldown && c.cooldown.overall_seconds) bits.push(`once every ${c.cooldown.overall_seconds} s`);++ if (c.role && c.role !== 'everyone') bits.push(`${c.role}s only`);++ return bits.join(' · ') || 'Anyone signed in can press it.';++}++function _dashElRow(e) {++ const li = _dashElNode('li', 'dash-el-row' + (e.active ? '' : ' is-off'));++ const look = (e.look && e.look.label) || e.name || 'Button';++ const chip = _dashElNode('span', 'dash-el-chip', look);++ chip.setAttribute('aria-hidden', 'true');++ const text = _dashElNode('span', 'dash-el-text');++ text.append(_dashElNode('b', '', e.name || look), _dashElNode('small', '', _dashElLine(e)));++ const on = document.createElement('input');++ on.type = 'checkbox';++ on.checked = !!e.active;++ on.setAttribute('aria-label', `${e.name || look}: on your channel`);++ on.addEventListener('change', async () => {++ on.disabled = true;++ try {++ await api(`/me/elements/${encodeURIComponent(e.id)}`, { method: 'PATCH', body: { active: on.checked } });++ li.classList.toggle('is-off', !on.checked);++ _dashElSay(on.checked ? `${e.name} is on your channel.` : `${e.name} is off; viewers do not see it.`);++ } catch (err) { on.checked = !on.checked; _dashElSay(err.message || 'That could not be saved.', true); }++ finally { on.disabled = false; }++ });++ const sw = _dashElNode('label', 'dash-el-on');++ sw.append(on, _dashElNode('span', '', 'On'));++ const del = _dashElNode('button', 'btn-icon dash-el-del');++ del.type = 'button';++ del.title = 'Remove';++ del.setAttribute('aria-label', `Remove ${e.name || look}`);++ del.innerHTML = '<i class="fa-solid fa-trash-can" aria-hidden="true"></i>';++ del.addEventListener('click', async () => {++ if (!confirm(`Remove "${e.name || look}" from your channel? You can install it again from the gallery.`)) return;++ del.disabled = true;++ try {++ await api(`/me/elements/${encodeURIComponent(e.id)}`, { method: 'DELETE' });++ li.remove();++ _dashElSay(`${e.name || look} is removed.`);++ if (!document.querySelector('#dash-el-list .dash-el-row')) _dashElEmpty();++ } catch (err) { del.disabled = false; _dashElSay(err.message || 'That could not be removed.', true); }++ });++ li.append(chip, text, sw, del);++ return li;++}++function _dashElEmpty() {++ const list = document.getElementById('dash-el-list');++ if (!list) return;++ const li = _dashElNode('li', 'dash-el-empty');++ li.append(_dashElNode('b', '', 'No buttons yet.'), _dashElNode('span', '', ' Make one by describing it, or install free ones (there is a pack for the LOOI robot).'));++ list.replaceChildren(li);++}++function _dashElNote(enabled, count) {++ const note = document.getElementById('dash-el-enabled-note');++ if (!note) return;++ note.textContent = enabled++ ? (count ? 'On: viewers see your buttons under the stream.' : 'On, but there are no buttons yet: add one below.')++ : 'Off: viewers see nothing until you turn this on.';++}++async function loadDashElements() {++ const box = document.getElementById('dash-el-enabled');++ const list = document.getElementById('dash-el-list');++ if (!box || !list || !currentUser) return;++ const see = document.getElementById('dash-el-see');++ if (see && currentUser.username) { see.href = `/@${encodeURIComponent(currentUser.username)}`; see.hidden = false; }++ try {++ const out = await api('/me/elements');++ const elements = (out && out.elements) || [];++ box.checked = !!out.enabled;++ box.disabled = false;++ _dashElNote(!!out.enabled, elements.length);++ if (elements.length) list.replaceChildren(...elements.map(_dashElRow)); else _dashElEmpty();++ } catch (err) {++ _dashElSay(err.message || 'Your buttons could not be read just now.', true);++ return;++ }++ if (box.dataset.wired) return;++ box.dataset.wired = '1';++ box.addEventListener('change', async () => {++ box.disabled = true;++ try {++ const out = await api('/me/elements/settings', { method: 'PUT', body: { enabled: box.checked } });++ box.checked = !!(out && out.enabled);++ _dashElNote(box.checked, document.querySelectorAll('#dash-el-list .dash-el-row').length);++ _dashElSay(box.checked ? 'Your buttons show under your stream.' : 'Your buttons are hidden from viewers.');++ } catch (err) { box.checked = !box.checked; _dashElSay(err.message || 'That could not be saved.', true); }++ finally { box.disabled = false; }++ });++}window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadDashTipsPage'); _call('loadPowerchatStatus'); };window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++/**++ * The "updates shipped" card in chat (public/js/chat.js renderDeployCard) says what changed for the people in chat:++ * a docs-only commit is left out, and a subject's "(#843)" PR number is dropped (11-12 review: "README: the event list++ * no longer includes merge events (#841)" was in a channel's chat).++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');++const m = chat.match(/const DOCS_ONLY = (\/.+\/i);/);++assert.ok(m, 'the card has its docs-only rule');++const DOCS_ONLY = new Function(`return ${m[1]};`)();++for (const s of ['README: the event list no longer includes merge events (#841)', 'CHANGELOG: drop the account merge entry (#268)', 'docs: deploy kinds', 'Docs - the WHIP guide'])++ assert.ok(DOCS_ONLY.test(s), `left out: ${s}`);++for (const s of ['Go live: allow the camera kit from openvibe.cam (#843)', 'Elements stage 1: the Viewer Deck (#842)', 'Readmes everywhere get a search box', 'Documentation page loads faster'])++ assert.ok(!DOCS_ONLY.test(s), `kept: ${s}`);++assert.ok(chat.includes(".replace(/\\s*\\(#\\d+\\)\\s*$/, '')"), 'the PR number is dropped from each subject');++const strip = (s) => s.replace(/\s*\(#\d+\)\s*$/, '');++assert.strictEqual(strip('Go live: allow the camera kit from openvibe.cam (it was refused on every browser) (#843)'), 'Go live: allow the camera kit from openvibe.cam (it was refused on every browser)');++console.log('chat-deploy-card: docs-only commits left out, PR numbers dropped');nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++/**++ * Buttons for your viewers on the dashboard (Controls tab): the streamer's Elements library, which Live #842 shipped++ * as an API with no page (the 11-12 review: Design's "Install" put elements in a library nobody could see, and the++ * deck stayed off). The card turns the deck on, turns each button on or off, removes one, and says where to get more.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');++const html = read('public/fragments/dashboard.html');++const js = read('public/js/dashboard.js');++const css = read('public/css/features/dashboard.css');++++assert.ok(html.includes('id="dash-card-elements"') && html.includes('Buttons for your viewers'), 'the card is in the dashboard');++assert.ok(/controls:\s*\['dash-card-elements'/.test(js), 'first on the Controls tab');++assert.ok(/_dashTabLoaders\.controls = \(\) => \{ loadDashElements\(\);/.test(js), 'loaded when Controls opens');++for (const call of ["api('/me/elements')", "api('/me/elements/settings', { method: 'PUT'", "{ method: 'PATCH', body: { active: on.checked } }", "{ method: 'DELETE' }"])++ assert.ok(js.includes(call), `calls ${call}`);++assert.ok(!js.includes("api('/api/me/elements") && !js.includes('api(`/api/me/elements'), 'api() adds /api itself');++assert.ok(html.includes('https://openvibe.design/create?kind=element') && html.includes('https://openvibe.design/gallery'), 'where to get more: made with AI, or free in the gallery');++// Names and labels come from what people made: text nodes, never markup.++const card = js.slice(js.indexOf('function _dashElNode'), js.indexOf('async function loadDashElements'));++assert.ok(!/innerHTML\s*=\s*`[^`]*\$\{e\./.test(card) && !/innerHTML\s*=\s*[^;]*\be\.(name|look|description)/.test(card), 'no element text goes in as markup');++assert.ok(css.includes('.dash-el-row') && css.includes('.dash-el-del { display: grid; place-items: center; width: 44px; height: 44px;'), 'the rows and a 44 px remove button');++console.log('dashboard-elements: the streamer sees, turns on and removes the buttons viewers press');
@@ -529,3 +529,33 @@
@@ -529,3 +529,33 @@
.dash-bench-form label { margin-top: 6px; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); }
.dash-bench-form label { margin-top: 6px; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); }
.dash-bench-form .btn { justify-self: start; min-height: 40px; margin-top: 10px; }
.dash-bench-form .btn { justify-self: start; min-height: 40px; margin-top: 10px; }
#dash-bench-status:empty { display: none; }
#dash-bench-status:empty { display: none; }
+
+/* Buttons for your viewers (Controls tab, dashboard.js loadDashElements): the deck switch, each button with its own
+ look as a chip, an On switch and Remove, then where to get more. 44 px targets on a phone. */
+.dash-elements > p.muted { margin: 0 0 12px; font-size: 0.88rem; }
+.dash-el-switch { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); cursor: pointer; }
+.dash-el-switch input { flex: none; width: 22px; height: 22px; accent-color: var(--accent); }
+.dash-el-switch b { display: block; font-size: 0.95rem; }
+.dash-el-switch small { display: block; margin-top: 2px; color: var(--text-muted); font-size: 0.8rem; }
+.dash-el-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
+.dash-el-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 12px; }
+.dash-el-row.is-off { opacity: 0.6; }
+.dash-el-chip { max-width: 9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 7px 12px; border-radius: 999px; background: var(--accent); color: var(--on-accent, #fff); font-size: 0.8rem; font-weight: 700; }
+.dash-el-text { min-width: 0; }
+.dash-el-text b { display: block; font-size: 0.92rem; overflow-wrap: anywhere; }
+.dash-el-text small { display: block; color: var(--text-muted); font-size: 0.8rem; line-height: 1.35; }
+.dash-el-on { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; font-size: 0.82rem; color: var(--text-secondary); cursor: pointer; }
+.dash-el-on input { width: 18px; height: 18px; accent-color: var(--accent); }
+.dash-el-del { display: grid; place-items: center; width: 44px; height: 44px; border: 0; border-radius: 10px; background: none; color: var(--text-muted); cursor: pointer; }
+.dash-el-del:hover, .dash-el-del:focus-visible { color: #f87171; background: color-mix(in srgb, #f87171 12%, transparent); }
+.dash-el-empty { padding: 14px; border: 1px dashed var(--border); border-radius: 12px; color: var(--text-secondary); font-size: 0.88rem; }
+.dash-el-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 0; }
+.dash-el-actions .btn { min-height: 40px; }
+.dash-el-status:empty { display: none; }
+.dash-el-status.is-bad { color: #f87171; }
+@media (max-width: 520px) {
+ .dash-el-row { grid-template-columns: auto minmax(0, 1fr) auto; }
+ .dash-el-chip { grid-row: span 2; align-self: start; max-width: 6.5em; }
+ .dash-el-on { grid-column: 2; justify-self: start; min-height: 36px; }
+ .dash-el-del { grid-column: 3; grid-row: 1 / span 2; }
+}
@@ -122,6 +122,24 @@
@@ -122,6 +122,24 @@
</div>
</div>
<!-- OpenVibe.Bot robot shown on the channel page (LIVE_BOT_EMBED; shown by loadBotEmbedSetting when the flag is on) -->
<!-- OpenVibe.Bot robot shown on the channel page (LIVE_BOT_EMBED; shown by loadBotEmbedSetting when the flag is on) -->
+ <!-- Buttons for your viewers (OpenVibe Elements): the streamer's library, the deck switch, and where to get
+ more. Live #842 shipped the API and the viewers' deck with no place for the streamer to see or turn
+ them on (the 11-12 review). dashboard.js loadDashElements draws it with DOM nodes. -->
+ <div class="dash-card dash-elements" id="dash-card-elements">
+ <h3><i class="fa-solid fa-hand-pointer" aria-hidden="true"></i> Buttons for your viewers</h3>
+ <p class="muted">Buttons people press under your stream: move your robot, play a sound, start a poll and more. Every press checks who may press, your cooldowns and channel points.</p>
+ <label class="dash-el-switch" for="dash-el-enabled">
+ <input type="checkbox" id="dash-el-enabled" disabled>
+ <span><b>Show them under my stream</b><small id="dash-el-enabled-note">Loading…</small></span>
+ </label>
+ <ul class="dash-el-list" id="dash-el-list" aria-label="Your buttons"></ul>
+ <p class="dash-el-actions">
+ <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Make one with AI</a>
+ <a class="btn btn-small" href="https://openvibe.design/gallery" target="_blank" rel="noopener"><i class="fa-solid fa-grip" aria-hidden="true"></i> Free ones in the gallery</a>
+ <a class="btn btn-small" id="dash-el-see" href="/" hidden><i class="fa-solid fa-eye" aria-hidden="true"></i> See them on your channel</a>
+ </p>
+ <p class="dash-el-status muted" id="dash-el-status" role="status"></p>
+ </div>
<div class="dash-card" id="dash-card-bot-embed" style="display:none">
<div class="dash-card" id="dash-card-bot-embed" style="display:none">
<h3><i class="fa-solid fa-robot"></i> Your robot</h3>
<h3><i class="fa-solid fa-robot"></i> Your robot</h3>
<p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera). Then, on <a href="https://openvibe.bot/robots" target="_blank" rel="noopener">its page there</a>, press <strong>Put the buttons under my OpenVibe.Live stream</strong>: it shows the robot's panel under your stream and brings you back here to confirm. Or put its id here.</p>
<p class="muted" style="font-size:0.85rem">Pair a robot on <a href="https://openvibe.bot/robots/new" target="_blank" rel="noopener">OpenVibe.Bot</a> (a Raspberry Pi car, a Cozmo, a phone or a pan-tilt camera). Then, on <a href="https://openvibe.bot/robots" target="_blank" rel="noopener">its page there</a>, press <strong>Put the buttons under my OpenVibe.Live stream</strong>: it shows the robot's panel under your stream and brings you back here to confirm. Or put its id here.</p>
@@ -3043,7 +3043,12 @@ function renderDeployCard(data, opts = {}) {
@@ -3043,7 +3043,12 @@ function renderDeployCard(data, opts = {}) {
if (!container) return;
if (!container) return;
_deployCss();
_deployCss();
const seen = new Set();
const seen = new Set();
- const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject));
+ // What changed for the people in chat: a commit that only touched the docs (README, CHANGELOG, docs/) is not news
+ // to a viewer, and "(#843)" is our number, not theirs (the 11-12 review: "README: the event list no longer includes
+ // merge events (#841)" was in a channel's chat).
+ const DOCS_ONLY = /^(readme|changelog|docs?|agents(\.md)?|contributing)\b\s*[:\-]/i;
+ const commits = (Array.isArray(data.commits) ? data.commits : []).filter(c => c && c.subject && !DOCS_ONLY.test(c.subject) && !seen.has(c.hash || c.subject) && seen.add(c.hash || c.subject))
+ .map(c => ({ ...c, subject: String(c.subject).replace(/\s*\(#\d+\)\s*$/, '') }));
if (!commits.length) return;
if (!commits.length) return;
const fresh = new Set(data.fresh || []);
const fresh = new Set(data.fresh || []);
const first = _parseMsgTime(data.first_at) || new Date(), last = _parseMsgTime(data.updated_at) || first;
const first = _parseMsgTime(data.first_at) || new Date(), last = _parseMsgTime(data.updated_at) || first;
@@ -14,7 +14,7 @@ const _DASH_TAB_CARDS = {
@@ -14,7 +14,7 @@ const _DASH_TAB_CARDS = {
home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],
home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],
chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes', 'dash-bench-stream', 'dash-card-chatlogs'],
chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes', 'dash-bench-stream', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
- controls: ['dash-card-bot-embed', 'dash-card-tokens'],
+ controls: ['dash-card-elements', 'dash-card-bot-embed', 'dash-card-tokens'],
money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],
money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],
points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],
points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],
content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)
content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)
@@ -129,7 +129,116 @@ async function loadDashVibe() {
@@ -129,7 +129,116 @@ async function loadDashVibe() {
} catch (err) { status.textContent = (err && err.message) || 'That did not save.'; }
} catch (err) { status.textContent = (err && err.message) || 'That did not save.'; }
});
});
}
}
-window._dashTabLoaders.controls = () => { _call('loadBotEmbedSetting'); _call('loadDashTokens'); };
+window._dashTabLoaders.controls = () => { loadDashElements(); _call('loadBotEmbedSetting'); _call('loadDashTokens'); };
+
+/* ── Buttons for your viewers (OpenVibe Elements) ─────────────────
+ The library from GET /api/me/elements, the deck switch (PUT /api/me/elements/settings), each button on or off
+ (PATCH active) and removed (DELETE). Made with AI or installed on OpenVibe.Design; nothing here is drawn from
+ markup an element carries: names and labels are text nodes. */
+function _dashElNode(tag, cls, text) {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = String(text);
+ return n;
+}
+function _dashElSay(text, bad) {
+ const s = document.getElementById('dash-el-status');
+ if (s) { s.textContent = text || ''; s.classList.toggle('is-bad', !!bad); }
+}
+/** One line on what a button does: its own description, else what pressing costs and how often. */
+function _dashElLine(e) {
+ if (e.description) return e.description;
+ const c = (e.recipe && e.recipe.conditions) || {};
+ const bits = [];
+ if (c.cost && c.cost.points) bits.push(`${Number(c.cost.points).toLocaleString()} points a press`);
+ if (c.cooldown && c.cooldown.overall_seconds) bits.push(`once every ${c.cooldown.overall_seconds} s`);
+ if (c.role && c.role !== 'everyone') bits.push(`${c.role}s only`);
+ return bits.join(' · ') || 'Anyone signed in can press it.';
+}
+function _dashElRow(e) {
+ const li = _dashElNode('li', 'dash-el-row' + (e.active ? '' : ' is-off'));
+ const look = (e.look && e.look.label) || e.name || 'Button';
+ const chip = _dashElNode('span', 'dash-el-chip', look);
+ chip.setAttribute('aria-hidden', 'true');
+ const text = _dashElNode('span', 'dash-el-text');
+ text.append(_dashElNode('b', '', e.name || look), _dashElNode('small', '', _dashElLine(e)));
+ const on = document.createElement('input');
+ on.type = 'checkbox';
+ on.checked = !!e.active;
+ on.setAttribute('aria-label', `${e.name || look}: on your channel`);
+ on.addEventListener('change', async () => {
+ on.disabled = true;
+ try {
+ await api(`/me/elements/${encodeURIComponent(e.id)}`, { method: 'PATCH', body: { active: on.checked } });
+ li.classList.toggle('is-off', !on.checked);
+ _dashElSay(on.checked ? `${e.name} is on your channel.` : `${e.name} is off; viewers do not see it.`);
+ } catch (err) { on.checked = !on.checked; _dashElSay(err.message || 'That could not be saved.', true); }
+ finally { on.disabled = false; }
+ });
+ const sw = _dashElNode('label', 'dash-el-on');
+ sw.append(on, _dashElNode('span', '', 'On'));
+ const del = _dashElNode('button', 'btn-icon dash-el-del');
+ del.type = 'button';
+ del.title = 'Remove';
+ del.setAttribute('aria-label', `Remove ${e.name || look}`);
+ del.innerHTML = '<i class="fa-solid fa-trash-can" aria-hidden="true"></i>';
+ del.addEventListener('click', async () => {
+ if (!confirm(`Remove "${e.name || look}" from your channel? You can install it again from the gallery.`)) return;
+ del.disabled = true;
+ try {
+ await api(`/me/elements/${encodeURIComponent(e.id)}`, { method: 'DELETE' });
+ li.remove();
+ _dashElSay(`${e.name || look} is removed.`);
+ if (!document.querySelector('#dash-el-list .dash-el-row')) _dashElEmpty();
+ } catch (err) { del.disabled = false; _dashElSay(err.message || 'That could not be removed.', true); }
+ });
+ li.append(chip, text, sw, del);
+ return li;
+}
+function _dashElEmpty() {
+ const list = document.getElementById('dash-el-list');
+ if (!list) return;
+ const li = _dashElNode('li', 'dash-el-empty');
+ li.append(_dashElNode('b', '', 'No buttons yet.'), _dashElNode('span', '', ' Make one by describing it, or install free ones (there is a pack for the LOOI robot).'));
+ list.replaceChildren(li);
+}
+function _dashElNote(enabled, count) {
+ const note = document.getElementById('dash-el-enabled-note');
+ if (!note) return;
+ note.textContent = enabled
+ ? (count ? 'On: viewers see your buttons under the stream.' : 'On, but there are no buttons yet: add one below.')
+ : 'Off: viewers see nothing until you turn this on.';
+}
+async function loadDashElements() {
+ const box = document.getElementById('dash-el-enabled');
+ const list = document.getElementById('dash-el-list');
+ if (!box || !list || !currentUser) return;
+ const see = document.getElementById('dash-el-see');
+ if (see && currentUser.username) { see.href = `/@${encodeURIComponent(currentUser.username)}`; see.hidden = false; }
+ try {
+ const out = await api('/me/elements');
+ const elements = (out && out.elements) || [];
+ box.checked = !!out.enabled;
+ box.disabled = false;
+ _dashElNote(!!out.enabled, elements.length);
+ if (elements.length) list.replaceChildren(...elements.map(_dashElRow)); else _dashElEmpty();
+ } catch (err) {
+ _dashElSay(err.message || 'Your buttons could not be read just now.', true);
+ return;
+ }
+ if (box.dataset.wired) return;
+ box.dataset.wired = '1';
+ box.addEventListener('change', async () => {
+ box.disabled = true;
+ try {
+ const out = await api('/me/elements/settings', { method: 'PUT', body: { enabled: box.checked } });
+ box.checked = !!(out && out.enabled);
+ _dashElNote(box.checked, document.querySelectorAll('#dash-el-list .dash-el-row').length);
+ _dashElSay(box.checked ? 'Your buttons show under your stream.' : 'Your buttons are hidden from viewers.');
+ } catch (err) { box.checked = !box.checked; _dashElSay(err.message || 'That could not be saved.', true); }
+ finally { box.disabled = false; }
+ });
+}
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadDashTipsPage'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadDashTipsPage'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+/**
+ * The "updates shipped" card in chat (public/js/chat.js renderDeployCard) says what changed for the people in chat:
+ * a docs-only commit is left out, and a subject's "(#843)" PR number is dropped (11-12 review: "README: the event list
+ * no longer includes merge events (#841)" was in a channel's chat).
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
+const m = chat.match(/const DOCS_ONLY = (\/.+\/i);/);
+assert.ok(m, 'the card has its docs-only rule');
+const DOCS_ONLY = new Function(`return ${m[1]};`)();
+for (const s of ['README: the event list no longer includes merge events (#841)', 'CHANGELOG: drop the account merge entry (#268)', 'docs: deploy kinds', 'Docs - the WHIP guide'])
+ assert.ok(DOCS_ONLY.test(s), `left out: ${s}`);
+for (const s of ['Go live: allow the camera kit from openvibe.cam (#843)', 'Elements stage 1: the Viewer Deck (#842)', 'Readmes everywhere get a search box', 'Documentation page loads faster'])
+ assert.ok(!DOCS_ONLY.test(s), `kept: ${s}`);
+assert.ok(chat.includes(".replace(/\\s*\\(#\\d+\\)\\s*$/, '')"), 'the PR number is dropped from each subject');
+const strip = (s) => s.replace(/\s*\(#\d+\)\s*$/, '');
+assert.strictEqual(strip('Go live: allow the camera kit from openvibe.cam (it was refused on every browser) (#843)'), 'Go live: allow the camera kit from openvibe.cam (it was refused on every browser)');
+console.log('chat-deploy-card: docs-only commits left out, PR numbers dropped');
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+/**
+ * Buttons for your viewers on the dashboard (Controls tab): the streamer's Elements library, which Live #842 shipped
+ * as an API with no page (the 11-12 review: Design's "Install" put elements in a library nobody could see, and the
+ * deck stayed off). The card turns the deck on, turns each button on or off, removes one, and says where to get more.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+const html = read('public/fragments/dashboard.html');
+const js = read('public/js/dashboard.js');
+const css = read('public/css/features/dashboard.css');
+
+assert.ok(html.includes('id="dash-card-elements"') && html.includes('Buttons for your viewers'), 'the card is in the dashboard');
+assert.ok(/controls:\s*\['dash-card-elements'/.test(js), 'first on the Controls tab');
+assert.ok(/_dashTabLoaders\.controls = \(\) => \{ loadDashElements\(\);/.test(js), 'loaded when Controls opens');
+for (const call of ["api('/me/elements')", "api('/me/elements/settings', { method: 'PUT'", "{ method: 'PATCH', body: { active: on.checked } }", "{ method: 'DELETE' }"])
+ assert.ok(js.includes(call), `calls ${call}`);
+assert.ok(!js.includes("api('/api/me/elements") && !js.includes('api(`/api/me/elements'), 'api() adds /api itself');
+assert.ok(html.includes('https://openvibe.design/create?kind=element') && html.includes('https://openvibe.design/gallery'), 'where to get more: made with AI, or free in the gallery');
+// Names and labels come from what people made: text nodes, never markup.
+const card = js.slice(js.indexOf('function _dashElNode'), js.indexOf('async function loadDashElements'));
+assert.ok(!/innerHTML\s*=\s*`[^`]*\$\{e\./.test(card) && !/innerHTML\s*=\s*[^;]*\be\.(name|look|description)/.test(card), 'no element text goes in as markup');
+assert.ok(css.includes('.dash-el-row') && css.includes('.dash-el-del { display: grid; place-items: center; width: 44px; height: 44px;'), 'the rows and a 44 px remove button');
+console.log('dashboard-elements: the streamer sees, turns on and removes the buttons viewers press');