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)
Changes
@@@ -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();@@@ -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>`;}@@@ -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’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’s two, a lighter or heavier line, flat or with the mark’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’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">@@@ -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);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
const field = (n) => form.elements[n];
const field = (n) => form.elements[n];
const pick = $('[data-gp-colour]', form);
const pick = $('[data-gp-colour]', form);
const bgPick = $('[data-gp-bgpick]', 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() {
function query() {
const p = new URLSearchParams();
const p = new URLSearchParams();
@@ -2,10 +2,11 @@
@@ -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
* 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,
* 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)
* 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
* svg(name, opts) → standalone SVG text, or null for an unknown glyph
* png(name, opts) → a PNG Buffer (resvg), or null
* png(name, opts) → a PNG Buffer (resvg), or null
@@ -20,9 +21,16 @@ const glyphs = require('openvibe-shared/glyphs');
@@ -20,9 +21,16 @@ const glyphs = require('openvibe-shared/glyphs');
const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));
const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));
const MARK = ['#4f7cff', '#8b5cf6'];
const MARK = ['#4f7cff', '#8b5cf6'];
const GROUNDS = ['none', 'circle', 'square', 'squircle'];
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];
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 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 num = (v, lo, hi, d, step = 1) => {
const n = Number(v);
const n = Number(v);
@@ -38,6 +46,7 @@ function options(q = {}) {
@@ -38,6 +46,7 @@ function options(q = {}) {
pad: num(q.pad, 0, 40, DEFAULT.pad),
pad: num(q.pad, 0, 40, DEFAULT.pad),
ground: GROUNDS.includes(String(q.ground)) ? String(q.ground) : DEFAULT.ground,
ground: GROUNDS.includes(String(q.ground)) ? String(q.ground) : DEFAULT.ground,
bg: hex(q.bg, DEFAULT.bg),
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]);
const isDefault = (o) => Object.keys(DEFAULT).every((k) => o[k] === DEFAULT[k]);
@@ -58,11 +67,21 @@ function svg(name, o = options()) {
@@ -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}"/>`
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 === '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}"/>` : '';
: 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;
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}">`
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${box} ${box}" width="${px}" height="${px}">`
+ `<title>${name}</title>${defs}${ground}`
+ `<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>`;
+ `<g transform="translate(${off} ${off})" fill="none" stroke="${stroke}" stroke-width="${o.stroke}" stroke-linecap="round" stroke-linejoin="round">${drawn}</g></svg>`;
}
}
@@ -66,7 +66,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -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>`;
<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">
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’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’s two, a lighter or heavier line, flat or with the mark’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>
<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-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">
<div class="gp-controls">
@@ -75,6 +75,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -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’s</button></span></label>
<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’s</button></span></label>
${range('stroke', 1, 3, 0.25, opts.stroke, 'Line', '')}
${range('stroke', 1, 3, 0.25, opts.stroke, 'Line', '')}
${range('pad', 0, 40, 1, opts.pad, 'Room around it', '%')}
${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')}
${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>
<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">
<div class="gp-go">
@@ -4,6 +4,8 @@
@@ -4,6 +4,8 @@
// fonts, the sprite and a ZIP of every SVG, and the Font Awesome alternative page; all in the sitemap.
// 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 assert = require('node:assert/strict');
const zlib = require('zlib');
const zlib = require('zlib');
+const fs = require('fs');
+const path = require('path');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
const ex = require('../server/glyphs/export');
const ex = require('../server/glyphs/export');
@@ -73,9 +75,32 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
@@ -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.deepEqual(pngSize((await t.get('/icons/heart.png')).buffer), [256, 256], 'the default');
assert.equal((await t.get('/icons/nope.png')).status, 404);
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' });
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 () => {
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');
const woff = await t.get('/icons/pack/ov-glyphs.woff2');
assert.equal(woff.status, 200);
assert.equal(woff.status, 200);