openvibers/openvibe.live Commits

A goal with no picture still looks finished: art drawn from its title (#788)

OP OpenVibers committed 3 days ago · 488d9ee9c5 · 5 files, +59 −8

Maticus's "POWERLEVEL" goal has no picture, and its strip at the top of chat was a flat bar
beside the goals that fill theirs with an image (ask 289). Now a goal without a picture gets
art: two hues seeded by its title, a light band, and a large glyph for what it is for (power,
a robot, a camera, a mic, games, a computer, travel, food, music, a cause; a target
otherwise), under the same scrim as a picture so the words read the same. The same art in
the chat strip, the goal's popover, the dashboard's goal list and the "goal reached" card.

Nothing from the title reaches the markup (only numbers and a glyph name);
test/goal-art.test.js checks that, and that the same title always draws the same art.

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 88b587e3..5a1f1051 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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;
}
.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 {
ddiff --git a/public/js/app.js b/public/js/app.js
iindex b48d08da..d3a5e74a 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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';
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex e643f20f..fd687e28 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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 = `
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex 76225626..d346a95e 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -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}
ddiff --git a/test/goal-art.test.js b/test/goal-art.test.js
nnew file mode 100644
iindex 00000000..81b829d6
---- /dev/null
++++ b/test/goal-art.test.js
@@@ -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');