openvibers/openvibe.live Commits
A goal with no picture still looks finished: art drawn from its title (#788)
Changes
@@@ -9132,6 +9132,18 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90degbackground-size: cover; background-position: right center; background-repeat: no-repeat;}.cgw-overlay { position: absolute; inset: 0; }++/* A goal without a picture (ask 289): art from its title (goalArtHTML in app.js): two hues, a light band, its glyph. */++/* :is() outweighs each place's own `background: #000` (dashboard.css and the popover's rules load later). */++:is(.cgw-media, .cgw-pop-media, .cgr-media, .dash-goal-thumb).cgw-art { display: flex; align-items: center; justify-content: flex-end; overflow: hidden; color: #fff;++ background: linear-gradient(115deg, transparent 52%, rgba(255,255,255,.07) 52% 60%, transparent 60%), radial-gradient(140% 160% at 88% 20%, var(--ga1), var(--ga2) 72%); }++.cgw-art i { opacity: .55; transform: rotate(-10deg); text-shadow: 0 4px 18px rgba(0,0,0,.35); }++.cgw-media.cgw-art { padding-right: 64px; }++.cgw-media.cgw-art i { font-size: 32px; opacity: .45; }++.cgw-pop-media.cgw-art { height: 170px; padding-right: 40px; }++.cgw-pop-media.cgw-art i { font-size: 84px; }++:is(.cgr-media, .dash-goal-thumb).cgw-art { justify-content: center; padding: 0; }++.cgr-media.cgw-art i { font-size: 30px; opacity: .8; }++.dash-goal-thumb.cgw-art i { font-size: 24px; opacity: .8; }/* Strong scrim on the LEFT (where the title + amount sit) fading to clear on the right,so the goal image's object — pushed to the right edge — stays fully visible. */.cgw-goal.has-img .cgw-overlay {@@@ -1790,17 +1790,29 @@ function toggleGoalWidgetExpand(btn) {el.classList.toggle('cgw-expanded');_renderGoalWidgetInto(el, _goalWidget.goals || []);}++// A goal with no picture still looks finished (ask 289): art drawn from its title, two hues seeded by the words and a++// large glyph that fits what it is for. The same art in the chat strip, the popover, the dashboard and the reached card.++const _GOAL_GLYPHS = [[/power|level|energy|boost|charge/i, 'fa-bolt'], [/robot|\bbot\b|looi/i, 'fa-robot'], [/cam|camera|webcam|lens/i, 'fa-camera'],++ [/\bmic\b|micro|audio|sound|speaker/i, 'fa-microphone'], [/game|gaming|console|controller/i, 'fa-gamepad'], [/\bpc\b|computer|gpu|setup|desk/i, 'fa-desktop'],++ [/trip|travel|flight|road/i, 'fa-plane'], [/food|pizza|dinner|lunch|snack/i, 'fa-utensils'], [/music|song|guitar|beat/i, 'fa-music'], [/charity|donat|help|support/i, 'fa-heart']];++function goalArtHTML(g, cls) {++ const words = String((g && g.title) || 'Goal');++ let h = 2166136261;++ for (let i = 0; i < words.length; i++) { h ^= words.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }++ const h1 = h % 360, h2 = (h1 + 40 + ((h >>> 9) % 70)) % 360;++ const glyph = (_GOAL_GLYPHS.find(([re]) => re.test(words)) || [null, 'fa-bullseye'])[1];++ return `<div class="${cls} cgw-art" style="--ga1:hsl(${h1} 68% 42%);--ga2:hsl(${h2} 72% 20%)" aria-hidden="true"><i class="fa-solid ${glyph}"></i></div>`;++}function _goalWidgetItemHTML(g) {const pct = g.target_amount ? Math.min(100, Math.round((g.current_amount / g.target_amount) * 100)) : 0;const reached = (!g.is_active && g.reached_at) || pct >= 100;-- const hasImg = !!g.image_url;-- // Image/video fills the whole card (cover); a gradient keeps the text readable.-- const media = hasImg++ // Image/video fills the whole card (cover); a goal without one gets its drawn art; a gradient keeps the text readable.++ const media = g.image_url? (g.media_type === 'video'? `<video class="cgw-media" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`: `<div class="cgw-media" style="background-image:url('${esc(g.image_url)}')"></div>`)-- : '';-- return `<div class="cgw-goal ${reached ? 'reached' : ''} ${hasImg ? 'has-img' : ''}" data-goal-id="${g.id}" onclick="openGoalPopover(${g.id})" title="View goal">++ : goalArtHTML(g, 'cgw-media');++ return `<div class="cgw-goal ${reached ? 'reached' : ''} has-img" data-goal-id="${g.id}" onclick="openGoalPopover(${g.id})" title="View goal">${media}<div class="cgw-overlay"></div><div class="cgw-body">@@@ -1826,7 +1838,7 @@ function openGoalPopover(id) {? (g.media_type === 'video'? `<video class="cgw-pop-media" src="${esc(g.image_url)}" autoplay muted loop playsinline></video>`: `<img class="cgw-pop-media" src="${esc(g.image_url)}" alt="">`)-- : '';++ : goalArtHTML(g, 'cgw-pop-media');const ov = document.createElement('div');ov.className = 'cgw-pop-overlay';ov.id = 'cgw-pop-overlay';@@@ -3121,7 +3121,7 @@ function addDonationGoalMessage(msg) {? (g.media_type === 'video'? `<video class="cgr-media" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`: `<img class="cgr-media" src="${esc(g.image_url)}" alt="">`)-- : '';++ : (typeof goalArtHTML === 'function' ? goalArtHTML(g, 'cgr-media') : '');const el = document.createElement('div');el.className = 'chat-msg goal-reached-msg';el.innerHTML = `@@@ -421,7 +421,7 @@ function dashGoalCardHTML(g) {? (g.media_type === 'video'? `<video class="dash-goal-thumb" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`: `<img class="dash-goal-thumb" src="${esc(g.image_url)}" alt="">`)-- : '<div class="dash-goal-thumb dash-goal-thumb-ph"><i class="fa-solid fa-bullseye"></i></div>';++ : (typeof goalArtHTML === 'function' ? goalArtHTML(g, 'dash-goal-thumb') : '<div class="dash-goal-thumb dash-goal-thumb-ph"><i class="fa-solid fa-bullseye"></i></div>');return `<div class="dash-goal ${reached ? 'reached' : ''}">${media}nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++// A goal without a picture gets art drawn from its title (ask 289): the same title always draws the same art, the++// glyph fits what the goal is for, and nothing from the title reaches the markup.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++const vm = require('node:vm');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');++const start = src.indexOf('const _GOAL_GLYPHS');++const end = src.indexOf('function _goalWidgetItemHTML');++assert.ok(start > 0 && end > start, 'goalArtHTML sits before the goal widget in app.js');++const ctx = {};++vm.runInNewContext(src.slice(start, end) + '\nthis.goalArtHTML = goalArtHTML;', ctx);++const art = ctx.goalArtHTML;++++const a = art({ title: 'POWERLEVEL' }, 'cgw-media');++assert.equal(a, art({ title: 'POWERLEVEL' }, 'cgw-media'), 'the same title draws the same art');++assert.notEqual(a, art({ title: 'Pizza night' }, 'cgw-media'), 'another title draws other art');++assert.match(a, /^<div class="cgw-media cgw-art" style="--ga1:hsl\(\d+ 68% 42%\);--ga2:hsl\(\d+ 72% 20%\)" aria-hidden="true"><i class="fa-solid fa-bolt"><\/i><\/div>$/);++assert.match(art({ title: 'LOOI robot upgrades' }, 'x'), /fa-robot/);++assert.match(art({ title: 'New webcam' }, 'x'), /fa-camera/);++assert.match(art({ title: 'Something else' }, 'x'), /fa-bullseye/, 'a title with no known word gets the target');++assert.match(art({}, 'x'), /fa-bullseye/, 'a goal with no title still draws');++const evil = art({ title: '<img src=x onerror=alert(1)>"' }, 'x');++assert.ok(!/<img|onerror|"\s*>/.test(evil.replace(/^<div class="x cgw-art" style="[^"]*" aria-hidden="true"><i class="fa-solid fa-[a-z-]+"><\/i><\/div>$/, '')), 'nothing from the title reaches the markup');++console.log('goal art: all checks passed');
@@ -9132,6 +9132,18 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
@@ -9132,6 +9132,18 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
background-size: cover; background-position: right center; background-repeat: no-repeat;
background-size: cover; background-position: right center; background-repeat: no-repeat;
}
}
.cgw-overlay { position: absolute; inset: 0; }
.cgw-overlay { position: absolute; inset: 0; }
+/* A goal without a picture (ask 289): art from its title (goalArtHTML in app.js): two hues, a light band, its glyph. */
+/* :is() outweighs each place's own `background: #000` (dashboard.css and the popover's rules load later). */
+:is(.cgw-media, .cgw-pop-media, .cgr-media, .dash-goal-thumb).cgw-art { display: flex; align-items: center; justify-content: flex-end; overflow: hidden; color: #fff;
+ background: linear-gradient(115deg, transparent 52%, rgba(255,255,255,.07) 52% 60%, transparent 60%), radial-gradient(140% 160% at 88% 20%, var(--ga1), var(--ga2) 72%); }
+.cgw-art i { opacity: .55; transform: rotate(-10deg); text-shadow: 0 4px 18px rgba(0,0,0,.35); }
+.cgw-media.cgw-art { padding-right: 64px; }
+.cgw-media.cgw-art i { font-size: 32px; opacity: .45; }
+.cgw-pop-media.cgw-art { height: 170px; padding-right: 40px; }
+.cgw-pop-media.cgw-art i { font-size: 84px; }
+:is(.cgr-media, .dash-goal-thumb).cgw-art { justify-content: center; padding: 0; }
+.cgr-media.cgw-art i { font-size: 30px; opacity: .8; }
+.dash-goal-thumb.cgw-art i { font-size: 24px; opacity: .8; }
/* Strong scrim on the LEFT (where the title + amount sit) fading to clear on the right,
/* Strong scrim on the LEFT (where the title + amount sit) fading to clear on the right,
so the goal image's object — pushed to the right edge — stays fully visible. */
so the goal image's object — pushed to the right edge — stays fully visible. */
.cgw-goal.has-img .cgw-overlay {
.cgw-goal.has-img .cgw-overlay {
@@ -1790,17 +1790,29 @@ function toggleGoalWidgetExpand(btn) {
@@ -1790,17 +1790,29 @@ function toggleGoalWidgetExpand(btn) {
el.classList.toggle('cgw-expanded');
el.classList.toggle('cgw-expanded');
_renderGoalWidgetInto(el, _goalWidget.goals || []);
_renderGoalWidgetInto(el, _goalWidget.goals || []);
}
}
+// A goal with no picture still looks finished (ask 289): art drawn from its title, two hues seeded by the words and a
+// large glyph that fits what it is for. The same art in the chat strip, the popover, the dashboard and the reached card.
+const _GOAL_GLYPHS = [[/power|level|energy|boost|charge/i, 'fa-bolt'], [/robot|\bbot\b|looi/i, 'fa-robot'], [/cam|camera|webcam|lens/i, 'fa-camera'],
+ [/\bmic\b|micro|audio|sound|speaker/i, 'fa-microphone'], [/game|gaming|console|controller/i, 'fa-gamepad'], [/\bpc\b|computer|gpu|setup|desk/i, 'fa-desktop'],
+ [/trip|travel|flight|road/i, 'fa-plane'], [/food|pizza|dinner|lunch|snack/i, 'fa-utensils'], [/music|song|guitar|beat/i, 'fa-music'], [/charity|donat|help|support/i, 'fa-heart']];
+function goalArtHTML(g, cls) {
+ const words = String((g && g.title) || 'Goal');
+ let h = 2166136261;
+ for (let i = 0; i < words.length; i++) { h ^= words.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
+ const h1 = h % 360, h2 = (h1 + 40 + ((h >>> 9) % 70)) % 360;
+ const glyph = (_GOAL_GLYPHS.find(([re]) => re.test(words)) || [null, 'fa-bullseye'])[1];
+ return `<div class="${cls} cgw-art" style="--ga1:hsl(${h1} 68% 42%);--ga2:hsl(${h2} 72% 20%)" aria-hidden="true"><i class="fa-solid ${glyph}"></i></div>`;
+}
function _goalWidgetItemHTML(g) {
function _goalWidgetItemHTML(g) {
const pct = g.target_amount ? Math.min(100, Math.round((g.current_amount / g.target_amount) * 100)) : 0;
const pct = g.target_amount ? Math.min(100, Math.round((g.current_amount / g.target_amount) * 100)) : 0;
const reached = (!g.is_active && g.reached_at) || pct >= 100;
const reached = (!g.is_active && g.reached_at) || pct >= 100;
- const hasImg = !!g.image_url;
- // Image/video fills the whole card (cover); a gradient keeps the text readable.
- const media = hasImg
+ // Image/video fills the whole card (cover); a goal without one gets its drawn art; a gradient keeps the text readable.
+ const media = g.image_url
? (g.media_type === 'video'
? (g.media_type === 'video'
? `<video class="cgw-media" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`
? `<video class="cgw-media" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`
: `<div class="cgw-media" style="background-image:url('${esc(g.image_url)}')"></div>`)
: `<div class="cgw-media" style="background-image:url('${esc(g.image_url)}')"></div>`)
- : '';
- return `<div class="cgw-goal ${reached ? 'reached' : ''} ${hasImg ? 'has-img' : ''}" data-goal-id="${g.id}" onclick="openGoalPopover(${g.id})" title="View goal">
+ : goalArtHTML(g, 'cgw-media');
+ return `<div class="cgw-goal ${reached ? 'reached' : ''} has-img" data-goal-id="${g.id}" onclick="openGoalPopover(${g.id})" title="View goal">
${media}
${media}
<div class="cgw-overlay"></div>
<div class="cgw-overlay"></div>
<div class="cgw-body">
<div class="cgw-body">
@@ -1826,7 +1838,7 @@ function openGoalPopover(id) {
@@ -1826,7 +1838,7 @@ function openGoalPopover(id) {
? (g.media_type === 'video'
? (g.media_type === 'video'
? `<video class="cgw-pop-media" src="${esc(g.image_url)}" autoplay muted loop playsinline></video>`
? `<video class="cgw-pop-media" src="${esc(g.image_url)}" autoplay muted loop playsinline></video>`
: `<img class="cgw-pop-media" src="${esc(g.image_url)}" alt="">`)
: `<img class="cgw-pop-media" src="${esc(g.image_url)}" alt="">`)
- : '';
+ : goalArtHTML(g, 'cgw-pop-media');
const ov = document.createElement('div');
const ov = document.createElement('div');
ov.className = 'cgw-pop-overlay';
ov.className = 'cgw-pop-overlay';
ov.id = 'cgw-pop-overlay';
ov.id = 'cgw-pop-overlay';
@@ -3121,7 +3121,7 @@ function addDonationGoalMessage(msg) {
@@ -3121,7 +3121,7 @@ function addDonationGoalMessage(msg) {
? (g.media_type === 'video'
? (g.media_type === 'video'
? `<video class="cgr-media" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`
? `<video class="cgr-media" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`
: `<img class="cgr-media" src="${esc(g.image_url)}" alt="">`)
: `<img class="cgr-media" src="${esc(g.image_url)}" alt="">`)
- : '';
+ : (typeof goalArtHTML === 'function' ? goalArtHTML(g, 'cgr-media') : '');
const el = document.createElement('div');
const el = document.createElement('div');
el.className = 'chat-msg goal-reached-msg';
el.className = 'chat-msg goal-reached-msg';
el.innerHTML = `
el.innerHTML = `
@@ -421,7 +421,7 @@ function dashGoalCardHTML(g) {
@@ -421,7 +421,7 @@ function dashGoalCardHTML(g) {
? (g.media_type === 'video'
? (g.media_type === 'video'
? `<video class="dash-goal-thumb" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`
? `<video class="dash-goal-thumb" src="${esc(g.image_url)}" muted loop autoplay playsinline></video>`
: `<img class="dash-goal-thumb" src="${esc(g.image_url)}" alt="">`)
: `<img class="dash-goal-thumb" src="${esc(g.image_url)}" alt="">`)
- : '<div class="dash-goal-thumb dash-goal-thumb-ph"><i class="fa-solid fa-bullseye"></i></div>';
+ : (typeof goalArtHTML === 'function' ? goalArtHTML(g, 'dash-goal-thumb') : '<div class="dash-goal-thumb dash-goal-thumb-ph"><i class="fa-solid fa-bullseye"></i></div>');
return `
return `
<div class="dash-goal ${reached ? 'reached' : ''}">
<div class="dash-goal ${reached ? 'reached' : ''}">
${media}
${media}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+// A goal without a picture gets art drawn from its title (ask 289): the same title always draws the same art, the
+// glyph fits what the goal is for, and nothing from the title reaches the markup.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
+const start = src.indexOf('const _GOAL_GLYPHS');
+const end = src.indexOf('function _goalWidgetItemHTML');
+assert.ok(start > 0 && end > start, 'goalArtHTML sits before the goal widget in app.js');
+const ctx = {};
+vm.runInNewContext(src.slice(start, end) + '\nthis.goalArtHTML = goalArtHTML;', ctx);
+const art = ctx.goalArtHTML;
+
+const a = art({ title: 'POWERLEVEL' }, 'cgw-media');
+assert.equal(a, art({ title: 'POWERLEVEL' }, 'cgw-media'), 'the same title draws the same art');
+assert.notEqual(a, art({ title: 'Pizza night' }, 'cgw-media'), 'another title draws other art');
+assert.match(a, /^<div class="cgw-media cgw-art" style="--ga1:hsl\(\d+ 68% 42%\);--ga2:hsl\(\d+ 72% 20%\)" aria-hidden="true"><i class="fa-solid fa-bolt"><\/i><\/div>$/);
+assert.match(art({ title: 'LOOI robot upgrades' }, 'x'), /fa-robot/);
+assert.match(art({ title: 'New webcam' }, 'x'), /fa-camera/);
+assert.match(art({ title: 'Something else' }, 'x'), /fa-bullseye/, 'a title with no known word gets the target');
+assert.match(art({}, 'x'), /fa-bullseye/, 'a goal with no title still draws');
+const evil = art({ title: '<img src=x onerror=alert(1)>"' }, 'x');
+assert.ok(!/<img|onerror|"\s*>/.test(evil.replace(/^<div class="x cgw-art" style="[^"]*" aria-hidden="true"><i class="fa-solid fa-[a-z-]+"><\/i><\/div>$/, '')), 'nothing from the title reaches the markup');
+console.log('goal art: all checks passed');