openvibers/openvibe.design Commits

Icons get the mark's finish: depth, a glow behind, or both, in the PNG maker and every export link (#51)

OP OpenVibers committed 13 days ago · 004dbc757c · 4 files, +54 −9

Changes

ddiff --git a/public/js/icon.js b/public/js/icon.js
iindex 988bb53..95d2eb5 100644
---- a/public/js/icon.js
++++ b/public/js/icon.js
@@@ -17,7 +17,7 @@
const field = (n) => form.elements[n];
const pick = $('[data-gp-colour]', form);
const bgPick = $('[data-gp-bgpick]', form);
-- const DEFAULT = { size: '256', scale: '1', color: '0f172a', stroke: '2', pad: '0', ground: 'none', bg: 'ffffff' };
++ const DEFAULT = { size: '256', scale: '1', color: '0f172a', stroke: '2', pad: '0', ground: 'none', bg: 'ffffff', fx: 'flat' };
function query() {
const p = new URLSearchParams();
ddiff --git a/server/glyphs/export.js b/server/glyphs/export.js
iindex 6ddbbd3..30a5d72 100644
---- a/server/glyphs/export.js
++++ b/server/glyphs/export.js
@@@ -2,10 +2,11 @@
/**
* A glyph as a file (plan L32 I5): a standalone SVG with the person's choices, a PNG drawn from it, every glyph as a
* ZIP of SVGs, and the font files openvibe-shared builds. The choices are the PNG maker's on /icons/<name>: size,
-- * density, colour (or the mark's two), stroke weight, padding, and a ground (none, a circle, a square or a squircle)
-- * in its own colour. Every value is clamped; anything unknown falls back to the default, so a URL never fails.
++ * density, colour (or the mark's two), stroke weight, padding, a ground (none, a circle, a square or a squircle)
++ * in its own colour, and a finish (L44: flat; depth, light to deep in its own colour as the mark is drawn; a glow
++ * behind it; or both). Every value is clamped; anything unknown falls back to the default, so a URL never fails.
*
-- * options(query) → { size, scale, color, stroke, pad, ground, bg }, and isDefault(opts)
++ * options(query) → { size, scale, color, stroke, pad, ground, bg, fx }, and isDefault(opts)
* query(opts) → the query string that rebuilds the same file (only what differs from the default)
* svg(name, opts) → standalone SVG text, or null for an unknown glyph
* png(name, opts) → a PNG Buffer (resvg), or null
@@@ -20,9 +21,16 @@ const glyphs = require('openvibe-shared/glyphs');
const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));
const MARK = ['#4f7cff', '#8b5cf6'];
const GROUNDS = ['none', 'circle', 'square', 'squircle'];
--const DEFAULT = Object.freeze({ size: 256, scale: 1, color: '0f172a', stroke: 2, pad: 0, ground: 'none', bg: 'ffffff' });
++const FINISHES = ['flat', 'depth', 'glow', 'depth-glow'];
++const DEFAULT = Object.freeze({ size: 256, scale: 1, color: '0f172a', stroke: 2, pad: 0, ground: 'none', bg: 'ffffff', fx: 'flat' });
const SIZES = [16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024];
++/** A hex colour (no #) mixed toward `to` by t, as #rrggbb. */
++function mix(c, to, t) {
++ const full = (h) => (h.length === 3 ? h.split('').map((x) => x + x).join('') : h);
++ const a = parseInt(full(c.replace(/^#/, '')), 16), b = parseInt(full(to), 16);
++ return `#${[16, 8, 0].map((sh) => Math.round(((a >> sh) & 255) * (1 - t) + ((b >> sh) & 255) * t).toString(16).padStart(2, '0')).join('')}`;
++}
const hex = (v, d) => (/^(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(v || '').replace(/^#/, '')) ? String(v).replace(/^#/, '').toLowerCase() : d);
const num = (v, lo, hi, d, step = 1) => {
const n = Number(v);
@@@ -38,6 +46,7 @@ function options(q = {}) {
pad: num(q.pad, 0, 40, DEFAULT.pad),
ground: GROUNDS.includes(String(q.ground)) ? String(q.ground) : DEFAULT.ground,
bg: hex(q.bg, DEFAULT.bg),
++ fx: FINISHES.includes(String(q.fx)) ? String(q.fx) : DEFAULT.fx,
};
}
const isDefault = (o) => Object.keys(DEFAULT).every((k) => o[k] === DEFAULT[k]);
@@@ -58,11 +67,21 @@ function svg(name, o = options()) {
const ground = o.ground === 'circle' ? `<circle cx="${box / 2}" cy="${box / 2}" r="${box / 2}" fill="#${o.bg}"/>`
: o.ground === 'square' ? `<rect width="${box}" height="${box}" fill="#${o.bg}"/>`
: o.ground === 'squircle' ? `<rect width="${box}" height="${box}" rx="${+(box * 0.225).toFixed(3)}" fill="#${o.bg}"/>` : '';
-- const stroke = o.color === 'mark' ? 'url(#ovg-mark)' : `#${o.color}`;
-- const defs = o.color === 'mark' ? `<defs><linearGradient id="ovg-mark" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="24"><stop offset="0" stop-color="${MARK[0]}"/><stop offset="1" stop-color="${MARK[1]}"/></linearGradient></defs>` : '';
++ const depth = o.fx === 'depth' || o.fx === 'depth-glow', glow = o.fx === 'glow' || o.fx === 'depth-glow';
++ let stroke = o.color === 'mark' ? 'url(#ovg-mark)' : `#${o.color}`;
++ let extra = o.color === 'mark' ? `<linearGradient id="ovg-mark" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="24"><stop offset="0" stop-color="${depth ? mix(MARK[0], 'ffffff', 0.32) : MARK[0]}"/><stop offset="1" stop-color="${depth ? mix(MARK[1], '000000', 0.14) : MARK[1]}"/></linearGradient>` : '';
++ if (depth && o.color !== 'mark') {
++ // Light to deep in its own colour, the mixes the mark uses (as openvibe-shared mark.tones, a touch less light: a glyph is thinner), across the 24-unit grid.
++ extra += `<linearGradient id="ovg-depth" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="24"><stop offset="0" stop-color="${mix(o.color, 'ffffff', 0.22)}"/><stop offset="1" stop-color="${mix(o.color, '000000', 0.18)}"/></linearGradient>`;
++ stroke = 'url(#ovg-depth)';
++ }
++ // The glow: the same lines, wider and blurred, behind the glyph (resvg draws feGaussianBlur).
++ if (glow) extra += `<filter id="ovg-glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${+(0.7 * o.stroke).toFixed(2)}"/></filter>`;
++ const defs = extra ? `<defs>${extra}</defs>` : '';
const drawn = name === 'busy' ? inner.replace(/stroke-width="[^"]*"/, `stroke-width="${+(3.4 * o.stroke / 2).toFixed(2)}"`) : inner;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${box} ${box}" width="${px}" height="${px}">`
+ `<title>${name}</title>${defs}${ground}`
++ + (glow ? `<g transform="translate(${off} ${off})" fill="none" stroke="${stroke}" stroke-width="${+(o.stroke * 1.7).toFixed(2)}" stroke-linecap="round" stroke-linejoin="round" filter="url(#ovg-glow)" opacity=".7">${drawn}</g>` : '')
+ `<g transform="translate(${off} ${off})" fill="none" stroke="${stroke}" stroke-width="${o.stroke}" stroke-linecap="round" stroke-linejoin="round">${drawn}</g></svg>`;
}
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
iindex 1b12806..0b9ade8 100644
---- a/server/views/icon-page.js
++++ b/server/views/icon-page.js
@@@ -66,7 +66,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
<section class="gp-sizes" aria-label="At text sizes">${[12, 16, 20, 24, 32, 48].map((s) => `<span style="font-size:${s}px">${glyph(name, s)}<i>Aa</i><small>${s}</small></span>`).join('')}</section>`;
const maker = `<section class="ic-sec" id="png" aria-labelledby="gp-png">
-- <div class="ic-sec-head"><h2 id="gp-png">Make a PNG</h2><p>Any size up to 1024 pixels at 1×, 2× or 3×, in any colour or the mark&rsquo;s two, a lighter or heavier line, room around it and a ground behind it. The link rebuilds the same file every time.</p></div>
++ <div class="ic-sec-head"><h2 id="gp-png">Make a PNG</h2><p>Any size up to 1024 pixels at 1×, 2× or 3×, in any colour or the mark&rsquo;s two, a lighter or heavier line, flat or with the mark&rsquo;s depth and glow, room around it and a ground behind it. The link rebuilds the same file every time.</p></div>
<form class="gp-maker" method="get" action="/icons/${esc(name)}#png" data-gp-form>
<div class="gp-preview" data-gp-preview data-ground="${esc(opts.ground)}"><img src="${esc(pngUrl)}" alt="${esc(T)} icon, ${opts.size} pixels" width="${Math.min(opts.size, 256)}" height="${Math.min(opts.size, 256)}" data-gp-img></div>
<div class="gp-controls">
@@@ -75,6 +75,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
<label class="gp-field"><span>Colour</span><span class="gp-colour"><input type="color" value="#${esc(opts.color === 'mark' ? '4f7cff' : opts.color)}" data-gp-colour aria-label="Line colour"><input type="text" name="color" value="${esc(opts.color)}" maxlength="6" pattern="mark|[0-9a-fA-F]{3}|[0-9a-fA-F]{6}" spellcheck="false" data-gp="color"><button type="button" class="gp-chip" data-gp-mark aria-pressed="${opts.color === 'mark'}">The mark&rsquo;s</button></span></label>
${range('stroke', 1, 3, 0.25, opts.stroke, 'Line', '')}
${range('pad', 0, 40, 1, opts.pad, 'Room around it', '%')}
++ ${sel('fx', [['flat', 'Flat'], ['depth', 'Depth: light to deep'], ['glow', 'A glow behind it'], ['depth-glow', 'Depth and a glow']], opts.fx, 'Finish')}
${sel('ground', [['none', 'None, transparent'], ['circle', 'A circle'], ['squircle', 'A rounded square'], ['square', 'A square']], opts.ground, 'Ground')}
<label class="gp-field" data-gp-bg-field${opts.ground === 'none' ? ' hidden' : ''}><span>Ground colour</span><span class="gp-colour"><input type="color" value="#${esc(opts.bg)}" data-gp-bgpick aria-label="Ground colour"><input type="text" name="bg" value="${esc(opts.bg)}" maxlength="6" pattern="[0-9a-fA-F]{3}|[0-9a-fA-F]{6}" spellcheck="false" data-gp="bg"></span></label>
<div class="gp-go">
ddiff --git a/test/icon-exports.test.js b/test/icon-exports.test.js
iindex 3599bd3..2122508 100644
---- a/test/icon-exports.test.js
++++ b/test/icon-exports.test.js
@@@ -4,6 +4,8 @@
// fonts, the sprite and a ZIP of every SVG, and the Font Awesome alternative page; all in the sitemap.
const assert = require('node:assert/strict');
const zlib = require('zlib');
++const fs = require('fs');
++const path = require('path');
const { boot, check, done } = require('./helpers/boot');
const glyphs = require('openvibe-shared/glyphs');
const ex = require('../server/glyphs/export');
@@@ -73,9 +75,32 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
assert.deepEqual(pngSize((await t.get('/icons/heart.png')).buffer), [256, 256], 'the default');
assert.equal((await t.get('/icons/nope.png')).status, 404);
const o = ex.options({ size: '3', color: 'javascript:', stroke: '9', pad: '-4', ground: '<x>', bg: 'zz' });
-- assert.deepEqual(o, { size: 16, scale: 1, color: '0f172a', stroke: 3, pad: 0, ground: 'none', bg: 'ffffff' });
++ assert.deepEqual(o, { size: 16, scale: 1, color: '0f172a', stroke: 3, pad: 0, ground: 'none', bg: 'ffffff', fx: 'flat' });
});
++ await check('the finish (L44): flat by default, depth light to deep, a glow behind, or both', async () => {
++ assert.equal(ex.options({}).fx, 'flat');
++ assert.equal(ex.options({ fx: 'nonsense' }).fx, 'flat', 'anything unknown is flat');
++ assert.equal(ex.query(ex.options({ fx: 'depth-glow' })), '?fx=depth-glow', 'the link rebuilds it');
++ const flat = ex.svg('heart', ex.options({}));
++ assert.ok(!/ovg-depth|ovg-glow/.test(flat), 'flat is unchanged');
++ const depth = ex.svg('heart', ex.options({ fx: 'depth', color: '22c55e' }));
++ assert.match(depth, /<linearGradient id="ovg-depth" gradientUnits="userSpaceOnUse"[^>]*><stop offset="0" stop-color="#[0-9a-f]{6}"\/><stop offset="1" stop-color="#[0-9a-f]{6}"\/>/, 'light to deep in its own colour');
++ assert.match(depth, /stroke="url\(#ovg-depth\)"/);
++ const glow = ex.svg('heart', ex.options({ fx: 'glow' }));
++ assert.match(glow, /<filter id="ovg-glow"[^>]*><feGaussianBlur/, 'a blur');
++ assert.equal((glow.match(/<g transform=/g) || []).length, 2, 'the glow is a second, wider copy behind');
++ assert.ok(glow.indexOf('filter="url(#ovg-glow)"') < glow.lastIndexOf('<g transform='), 'behind, not on top');
++ const mark = ex.svg('heart', ex.options({ fx: 'depth', color: 'mark' }));
++ assert.ok(mark.includes('url(#ovg-mark)') && !mark.includes('#4f7cff"/>'), 'the mark\'s two colours take the depth too');
++ const res = await t.get('/icons/heart.png?size=64&fx=depth-glow&color=mark');
++ assert.equal(res.status, 200);
++ assert.equal(res.headers.get('content-type'), 'image/png', 'resvg draws the glow');
++ const page = (await t.get('/icons/heart?fx=glow')).text;
++ assert.ok(page.includes('<option value="glow" selected'), 'the maker remembers the finish');
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/icon.js'), 'utf8');
++ assert.match(js, /fx: 'flat' \}/, 'the maker\'s live link leaves a flat finish out, as the server does');
++ });
await check('the pack: fonts, the sprite, the CSS, the code points and a ZIP of every SVG', async () => {
const woff = await t.get('/icons/pack/ov-glyphs.woff2');
assert.equal(woff.status, 200);