openvibers/openvibe.design Commits
Icons: every size by itself (style=auto|line|solid) (#74)
Changes
@@@ -110,11 +110,20 @@.gp-facts div{display:grid;gap:2px}.gp-facts dt{color:var(--text-muted);font-size:11.5px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}.gp-facts dd{margin:0;font-size:14px}--.gp-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 26px;padding:18px 20px;margin-bottom:6px;border-radius:16px;border:1px solid var(--border);background:var(--bg-secondary)}--.gp-sizes span{display:inline-flex;align-items:center;gap:.25em;line-height:1;color:var(--text-primary)}++/* Every size by itself (L50): the ladder on a light and a dark ground, drawn as the sites draw it. */++.gp-ladder{display:grid;grid-template-columns:minmax(0,1fr);gap:8px;margin-bottom:6px}++.gp-ladder-h{grid-column:1/-1;margin:0;font-size:15px;font-weight:750}++.gp-ladder-p{grid-column:1/-1;margin:0 0 4px;max-width:70ch;color:var(--text-secondary);font-size:14px;line-height:1.55}++.gp-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px clamp(14px,3vw,30px);padding:16px 20px;border-radius:16px;border:1px solid var(--border);background:var(--bg-secondary)}++.gp-sizes.is-light{--gp-ink:#141a2b;--gp-muted:#5d6577;background:#fbfcfe;border-color:#e2e6ee}++.gp-sizes.is-dark{--gp-ink:#eef1f8;--gp-muted:#99a2b5;background:#0d111c;border-color:#232a3a}++.gp-sizes span{display:inline-flex;align-items:center;gap:.25em;line-height:1;color:var(--gp-ink,var(--text-primary))}++.ic .gp-sizes svg.is-small{stroke-width:2.4}++.ic .gp-sizes svg.is-solid{fill:currentColor;stroke:none}++.ic .gp-sizes svg.is-solid .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}.gp-sizes svg{width:1.15em;height:1.15em}.gp-sizes i{font-style:normal;font-weight:650}--.gp-sizes small{align-self:flex-end;margin-left:4px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}++.gp-sizes small{align-self:flex-end;margin-left:4px;color:var(--gp-muted,var(--text-muted));font-size:11px;font-variant-numeric:tabular-nums}/* The PNG maker. */.gp-maker{display:grid;grid-template-columns:minmax(0,320px) minmax(0,1fr);gap:18px;align-items:start}@@@ -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', fx: 'flat' };++ const DEFAULT = { size: '256', scale: '1', color: '0f172a', stroke: '2', pad: '0', ground: 'none', bg: 'ffffff', fx: 'flat', style: 'auto' };function query() {const p = new URLSearchParams();@@@ -3,10 +3,13 @@* 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, 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.++ * in its own colour, a finish (L44: flat; depth, light to deep in its own colour as the mark is drawn; a glow++ * behind it; or both), and a style (L50: the line drawing, the solid one, or auto: solid at 18 px and under when the++ * glyph has one, as the sites show it). 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, fx }, and isDefault(opts)++ * options(query) → { size, scale, color, stroke, pad, ground, bg, fx, style }, and isDefault(opts)++ * drawnAs(name, opts) → 'line' or 'solid': what svg() draws for those choices* 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@@@ -22,7 +25,10 @@ const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));const MARK = ['#4f7cff', '#8b5cf6'];const GROUNDS = ['none', 'circle', 'square', 'squircle'];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 STYLES = ['auto', 'line', 'solid'];++// The size at and under which "auto" draws the solid drawing: openvibe-shared's ov-glyphs.js switches at the same one.++const SOLID_BELOW = 18;++const DEFAULT = Object.freeze({ size: 256, scale: 1, color: '0f172a', stroke: 2, pad: 0, ground: 'none', bg: 'ffffff', fx: 'flat', style: 'auto' });const SIZES = [16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024];/** A hex colour (no #) mixed toward `to` by t, as #rrggbb. */@@@ -47,8 +53,22 @@ function options(q = {}) {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,++ style: STYLES.includes(String(q.style)) ? String(q.style) : DEFAULT.style,};}++/** What a file with these choices shows: the solid drawing when asked (or small, on auto) and the glyph has one. */++function drawnAs(name, o = options()) {++ if (!glyphs.solid(name)) return 'line';++ return o.style === 'solid' || (o.style === 'auto' && o.size <= SOLID_BELOW) ? 'solid' : 'line';++}++/**++ * The solid drawing as plain SVG: what a page styles through the class "ln" (the parts that stay lines) written out as++ * attributes, so the file looks the same in an <img>, a design tool or a PNG.++ */++function solidMarkup(name, paint, width) {++ const line = `fill="none" stroke="${paint}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`;++ return glyphs.solid(name).replace(/class="ln"/g, line);++}const isDefault = (o) => Object.keys(DEFAULT).every((k) => o[k] === DEFAULT[k]);function query(o) {const p = new URLSearchParams();@@@ -78,9 +98,17 @@ function svg(name, o = options()) {// 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 head = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${box} ${box}" width="${px}" height="${px}">`++ + `<title>${name}</title>${defs}${ground}`;++ if (drawnAs(name, o) === 'solid') {++ // Filled in the line's paint; the parts that stay lines get the solid set's heavier line (2.6 at weight 2).++ const solid = solidMarkup(name, stroke, +(1.3 * o.stroke).toFixed(2));++ return head++ + (glow ? `<g transform="translate(${off} ${off})" fill="${stroke}" stroke="${stroke}" stroke-width="${+(o.stroke * 0.8).toFixed(2)}" stroke-linejoin="round" filter="url(#ovg-glow)" opacity=".6">${solid}</g>` : '')++ + `<g transform="translate(${off} ${off})" fill="${stroke}" stroke="none">${solid}</g></svg>`;++ }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}`++ return head+ (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>`;}@@@ -172,4 +200,4 @@ function codepointOf(name) {return codepoints[name] || null;}--module.exports = { options, isDefault, query, svg, png, zip, zipOf, crc32, PACK, codepointOf, DEFAULT, SIZES, GROUNDS, MARK };++module.exports = { options, isDefault, query, drawnAs, svg, png, zip, zipOf, crc32, PACK, codepointOf, DEFAULT, SIZES, GROUNDS, STYLES, SOLID_BELOW, MARK };@@@ -19,6 +19,11 @@ const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/const faOf = (name) => Object.entries(glyphs.FA).filter(([, g]) => g === name).map(([fa]) => fa).sort();const drawn = (name) => (glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit');const glyph = (name, size = 24, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${glyphs.path(name)}</svg>`;++/** The glyph as the sites show it at that size (L50): its solid drawing at 18 px and under when it has one. */++const glyphAt = (name, size) => (glyphs.solid(name) && size <= ex.SOLID_BELOW++ ? `<svg class="is-solid" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${glyphs.solid(name)}</svg>`++ : glyph(name, size, size <= ex.SOLID_BELOW ? 'is-small' : ''));++const LADDER = [12, 16, 20, 24, 32, 48];/** The glyphs that share a word with this one (its name or its Font Awesome names), then its neighbours. */function related(name, n = 12) {@@@ -63,7 +68,13 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()</div></div></header>--<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-ladder" aria-labelledby="gp-ladder-h">++ <h2 class="gp-ladder-h" id="gp-ladder-h">Every size by itself</h2>++ <p class="gp-ladder-p">${glyphs.solid(name)++ ? `At ${ex.SOLID_BELOW} px and under it switches to its solid drawing, which stays clear when small; above that it is the line. Sites do it with no code, and the files below do it on <code>style=auto</code>.`++ : 'Its lines grow a little heavier when small, so it stays clear; it has no solid drawing because it is made of lines.'}</p>++ ${['light', 'dark'].map((t) => `<div class="gp-sizes is-${t}" role="img" aria-label="${esc(name)} at ${LADDER.join(', ')} pixels, on ${t}">${LADDER.map((sz) => `<span style="font-size:${sz}px">${glyphAt(name, sz)}<i>Aa</i><small>${sz}</small></span>`).join('')}</div>`).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, 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>@@@ -75,6 +86,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('style', [['auto', `Auto: ${glyphs.solid(name) ? `solid at ${ex.SOLID_BELOW} px and under` : 'the line (it has no solid)'}`], ['line', 'Line'], ['solid', glyphs.solid(name) ? 'Solid' : 'Solid (it has none: the line)']], opts.style, 'Style')}${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>@@@ -95,7 +107,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()${cp ? `<div><h3>The font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-${name}"></i>`, id: 'gp-font', label: 'Copy' })}</div>` : ''}<div><h3>The sprite</h3>${kit.snippet({ code: `<svg width="24" height="24"><use href="${base}/shared/ov-glyphs.svg#ovg-${name}"/></svg>`, id: 'gp-sprite', label: 'Copy' })}</div><div><h3>Node</h3>${kit.snippet({ code: `require('openvibe-shared/glyphs').svg('${name}', { size: 24 })`, id: 'gp-node', label: 'Copy' })}</div>-- <div><h3>A link</h3>${kit.snippet({ code: `${base}/icons/${name}.svg\n${base}/icons/${name}.png?size=512&color=mark`, id: 'gp-url', label: 'Copy' })}</div>++ <div><h3>A link</h3>${kit.snippet({ code: `${base}/icons/${name}.svg\n${base}/icons/${name}.png?size=512&color=mark${glyphs.solid(name) ? `\n${base}/icons/${name}.svg?style=solid` : ''}`, id: 'gp-url', label: 'Copy' })}</div></div></section>`;@@@ -74,8 +74,30 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"assert.deepEqual(pngSize((await t.get('/icons/heart.png?size=99999')).buffer), [1024, 1024], 'clamped to 1024');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', fx: 'flat' });++ const o = ex.options({ size: '3', color: 'javascript:', stroke: '9', pad: '-4', ground: '<x>', bg: 'zz', style: 'bold' });++ assert.deepEqual(o, { size: 16, scale: 1, color: '0f172a', stroke: 3, pad: 0, ground: 'none', bg: 'ffffff', fx: 'flat', style: 'auto' });++ });++++ await check('every size by itself (L50): style=auto draws the solid drawing at 18 px and under, line and solid on ask', async () => {++ assert.ok(glyphs.solid('mic') && !glyphs.solid('check'), 'mic has a solid drawing; check is only lines');++ const small = (await t.get('/icons/mic.svg?size=16')).text;++ assert.ok(small.includes('<g transform="translate(0 0)" fill="#0f172a" stroke="none">'), 'small and auto: filled');++ assert.ok(small.includes('fill="none" stroke="#0f172a" stroke-width="2.6" stroke-linecap="round"'), 'its lines written out, no class left');++ assert.ok(!small.includes('class='), 'the file needs no page styles');++ assert.ok((await t.get('/icons/mic.svg?size=16&style=line')).text.includes('fill="none" stroke="#0f172a" stroke-width="2"'), 'line on ask');++ assert.ok((await t.get('/icons/mic.svg?size=64&style=solid')).text.includes('fill="#0f172a" stroke="none"'), 'solid on ask, any size');++ assert.ok((await t.get('/icons/mic.svg?size=24')).text.includes('fill="none" stroke="#0f172a"'), 'above 18 px auto is the line');++ assert.ok((await t.get('/icons/check.svg?size=16&style=solid')).text.includes('fill="none" stroke="#0f172a"'), 'a glyph of lines stays lines');++ assert.equal(ex.query(ex.options({ size: 16 })), '?size=16', 'auto is the default and stays out of links');++ assert.equal(ex.query(ex.options({ style: 'solid' })), '?style=solid');++ const png = (await t.get('/icons/mic.png?size=16&scale=3')).buffer;++ assert.deepEqual(pngSize(png), [48, 48], 'a small PNG at 3x, drawn solid');++ const page = (await t.get('/icons/mic')).text;++ assert.ok(page.includes('<h2 class="gp-ladder-h" id="gp-ladder-h">Every size by itself</h2>'));++ assert.equal((page.match(/<div class="gp-sizes is-(light|dark)"/g) || []).length, 2, 'the ladder on light and on dark');++ assert.equal((page.match(/<svg class="is-solid"/g) || []).length, 4, '12 and 16 px solid, on each ground');++ assert.ok(page.includes('<select name="style" data-gp="style">'), 'the maker offers the style');++ assert.ok(/its lines grow a little heavier when small/i.test((await t.get('/icons/check')).text), 'a glyph of lines says why it has no solid');});await check('the finish (L44): flat by default, depth light to deep, a glow behind, or both', async () => {@@@ -99,7 +121,7 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"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');++ assert.match(js, /fx: 'flat', style: 'auto' \}/, 'the maker\'s live link leaves a flat finish and the auto style 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');
@@ -110,11 +110,20 @@
@@ -110,11 +110,20 @@
.gp-facts div{display:grid;gap:2px}
.gp-facts div{display:grid;gap:2px}
.gp-facts dt{color:var(--text-muted);font-size:11.5px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}
.gp-facts dt{color:var(--text-muted);font-size:11.5px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}
.gp-facts dd{margin:0;font-size:14px}
.gp-facts dd{margin:0;font-size:14px}
-.gp-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 26px;padding:18px 20px;margin-bottom:6px;border-radius:16px;border:1px solid var(--border);background:var(--bg-secondary)}
-.gp-sizes span{display:inline-flex;align-items:center;gap:.25em;line-height:1;color:var(--text-primary)}
+/* Every size by itself (L50): the ladder on a light and a dark ground, drawn as the sites draw it. */
+.gp-ladder{display:grid;grid-template-columns:minmax(0,1fr);gap:8px;margin-bottom:6px}
+.gp-ladder-h{grid-column:1/-1;margin:0;font-size:15px;font-weight:750}
+.gp-ladder-p{grid-column:1/-1;margin:0 0 4px;max-width:70ch;color:var(--text-secondary);font-size:14px;line-height:1.55}
+.gp-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px clamp(14px,3vw,30px);padding:16px 20px;border-radius:16px;border:1px solid var(--border);background:var(--bg-secondary)}
+.gp-sizes.is-light{--gp-ink:#141a2b;--gp-muted:#5d6577;background:#fbfcfe;border-color:#e2e6ee}
+.gp-sizes.is-dark{--gp-ink:#eef1f8;--gp-muted:#99a2b5;background:#0d111c;border-color:#232a3a}
+.gp-sizes span{display:inline-flex;align-items:center;gap:.25em;line-height:1;color:var(--gp-ink,var(--text-primary))}
+.ic .gp-sizes svg.is-small{stroke-width:2.4}
+.ic .gp-sizes svg.is-solid{fill:currentColor;stroke:none}
+.ic .gp-sizes svg.is-solid .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}
.gp-sizes svg{width:1.15em;height:1.15em}
.gp-sizes svg{width:1.15em;height:1.15em}
.gp-sizes i{font-style:normal;font-weight:650}
.gp-sizes i{font-style:normal;font-weight:650}
-.gp-sizes small{align-self:flex-end;margin-left:4px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}
+.gp-sizes small{align-self:flex-end;margin-left:4px;color:var(--gp-muted,var(--text-muted));font-size:11px;font-variant-numeric:tabular-nums}
/* The PNG maker. */
/* The PNG maker. */
.gp-maker{display:grid;grid-template-columns:minmax(0,320px) minmax(0,1fr);gap:18px;align-items:start}
.gp-maker{display:grid;grid-template-columns:minmax(0,320px) minmax(0,1fr);gap:18px;align-items:start}
@@ -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', fx: 'flat' };
+ const DEFAULT = { size: '256', scale: '1', color: '0f172a', stroke: '2', pad: '0', ground: 'none', bg: 'ffffff', fx: 'flat', style: 'auto' };
function query() {
function query() {
const p = new URLSearchParams();
const p = new URLSearchParams();
@@ -3,10 +3,13 @@
@@ -3,10 +3,13 @@
* 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, a ground (none, a circle, a square or a squircle)
* 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.
+ * in its own colour, a finish (L44: flat; depth, light to deep in its own colour as the mark is drawn; a glow
+ * behind it; or both), and a style (L50: the line drawing, the solid one, or auto: solid at 18 px and under when the
+ * glyph has one, as the sites show it). 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, fx }, and isDefault(opts)
+ * options(query) → { size, scale, color, stroke, pad, ground, bg, fx, style }, and isDefault(opts)
+ * drawnAs(name, opts) → 'line' or 'solid': what svg() draws for those choices
* 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
@@ -22,7 +25,10 @@ const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));
@@ -22,7 +25,10 @@ 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 FINISHES = ['flat', 'depth', 'glow', 'depth-glow'];
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 STYLES = ['auto', 'line', 'solid'];
+// The size at and under which "auto" draws the solid drawing: openvibe-shared's ov-glyphs.js switches at the same one.
+const SOLID_BELOW = 18;
+const DEFAULT = Object.freeze({ size: 256, scale: 1, color: '0f172a', stroke: 2, pad: 0, ground: 'none', bg: 'ffffff', fx: 'flat', style: 'auto' });
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. */
/** A hex colour (no #) mixed toward `to` by t, as #rrggbb. */
@@ -47,8 +53,22 @@ function options(q = {}) {
@@ -47,8 +53,22 @@ function options(q = {}) {
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,
fx: FINISHES.includes(String(q.fx)) ? String(q.fx) : DEFAULT.fx,
+ style: STYLES.includes(String(q.style)) ? String(q.style) : DEFAULT.style,
};
};
}
}
+/** What a file with these choices shows: the solid drawing when asked (or small, on auto) and the glyph has one. */
+function drawnAs(name, o = options()) {
+ if (!glyphs.solid(name)) return 'line';
+ return o.style === 'solid' || (o.style === 'auto' && o.size <= SOLID_BELOW) ? 'solid' : 'line';
+}
+/**
+ * The solid drawing as plain SVG: what a page styles through the class "ln" (the parts that stay lines) written out as
+ * attributes, so the file looks the same in an <img>, a design tool or a PNG.
+ */
+function solidMarkup(name, paint, width) {
+ const line = `fill="none" stroke="${paint}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`;
+ return glyphs.solid(name).replace(/class="ln"/g, line);
+}
const isDefault = (o) => Object.keys(DEFAULT).every((k) => o[k] === DEFAULT[k]);
const isDefault = (o) => Object.keys(DEFAULT).every((k) => o[k] === DEFAULT[k]);
function query(o) {
function query(o) {
const p = new URLSearchParams();
const p = new URLSearchParams();
@@ -78,9 +98,17 @@ function svg(name, o = options()) {
@@ -78,9 +98,17 @@ function svg(name, o = options()) {
// The glow: the same lines, wider and blurred, behind the glyph (resvg draws feGaussianBlur).
// 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>`;
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 defs = extra ? `<defs>${extra}</defs>` : '';
+ const head = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${box} ${box}" width="${px}" height="${px}">`
+ + `<title>${name}</title>${defs}${ground}`;
+ if (drawnAs(name, o) === 'solid') {
+ // Filled in the line's paint; the parts that stay lines get the solid set's heavier line (2.6 at weight 2).
+ const solid = solidMarkup(name, stroke, +(1.3 * o.stroke).toFixed(2));
+ return head
+ + (glow ? `<g transform="translate(${off} ${off})" fill="${stroke}" stroke="${stroke}" stroke-width="${+(o.stroke * 0.8).toFixed(2)}" stroke-linejoin="round" filter="url(#ovg-glow)" opacity=".6">${solid}</g>` : '')
+ + `<g transform="translate(${off} ${off})" fill="${stroke}" stroke="none">${solid}</g></svg>`;
+ }
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}">`
- + `<title>${name}</title>${defs}${ground}`
+ return head
+ (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>` : '')
+ (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>`;
}
}
@@ -172,4 +200,4 @@ function codepointOf(name) {
@@ -172,4 +200,4 @@ function codepointOf(name) {
return codepoints[name] || null;
return codepoints[name] || null;
}
}
-module.exports = { options, isDefault, query, svg, png, zip, zipOf, crc32, PACK, codepointOf, DEFAULT, SIZES, GROUNDS, MARK };
+module.exports = { options, isDefault, query, drawnAs, svg, png, zip, zipOf, crc32, PACK, codepointOf, DEFAULT, SIZES, GROUNDS, STYLES, SOLID_BELOW, MARK };
@@ -19,6 +19,11 @@ const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/
@@ -19,6 +19,11 @@ const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/
const faOf = (name) => Object.entries(glyphs.FA).filter(([, g]) => g === name).map(([fa]) => fa).sort();
const faOf = (name) => Object.entries(glyphs.FA).filter(([, g]) => g === name).map(([fa]) => fa).sort();
const drawn = (name) => (glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit');
const drawn = (name) => (glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit');
const glyph = (name, size = 24, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${glyphs.path(name)}</svg>`;
const glyph = (name, size = 24, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${glyphs.path(name)}</svg>`;
+/** The glyph as the sites show it at that size (L50): its solid drawing at 18 px and under when it has one. */
+const glyphAt = (name, size) => (glyphs.solid(name) && size <= ex.SOLID_BELOW
+ ? `<svg class="is-solid" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${glyphs.solid(name)}</svg>`
+ : glyph(name, size, size <= ex.SOLID_BELOW ? 'is-small' : ''));
+const LADDER = [12, 16, 20, 24, 32, 48];
/** The glyphs that share a word with this one (its name or its Font Awesome names), then its neighbours. */
/** The glyphs that share a word with this one (its name or its Font Awesome names), then its neighbours. */
function related(name, n = 12) {
function related(name, n = 12) {
@@ -63,7 +68,13 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -63,7 +68,13 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
</div>
</div>
</div>
</div>
</header>
</header>
-<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-ladder" aria-labelledby="gp-ladder-h">
+ <h2 class="gp-ladder-h" id="gp-ladder-h">Every size by itself</h2>
+ <p class="gp-ladder-p">${glyphs.solid(name)
+ ? `At ${ex.SOLID_BELOW} px and under it switches to its solid drawing, which stays clear when small; above that it is the line. Sites do it with no code, and the files below do it on <code>style=auto</code>.`
+ : 'Its lines grow a little heavier when small, so it stays clear; it has no solid drawing because it is made of lines.'}</p>
+ ${['light', 'dark'].map((t) => `<div class="gp-sizes is-${t}" role="img" aria-label="${esc(name)} at ${LADDER.join(', ')} pixels, on ${t}">${LADDER.map((sz) => `<span style="font-size:${sz}px">${glyphAt(name, sz)}<i>Aa</i><small>${sz}</small></span>`).join('')}</div>`).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, 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>
<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>
@@ -75,6 +86,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -75,6 +86,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('style', [['auto', `Auto: ${glyphs.solid(name) ? `solid at ${ex.SOLID_BELOW} px and under` : 'the line (it has no solid)'}`], ['line', 'Line'], ['solid', glyphs.solid(name) ? 'Solid' : 'Solid (it has none: the line)']], opts.style, 'Style')}
${sel('fx', [['flat', 'Flat'], ['depth', 'Depth: light to deep'], ['glow', 'A glow behind it'], ['depth-glow', 'Depth and a glow']], opts.fx, 'Finish')}
${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>
@@ -95,7 +107,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -95,7 +107,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
${cp ? `<div><h3>The font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-${name}"></i>`, id: 'gp-font', label: 'Copy' })}</div>` : ''}
${cp ? `<div><h3>The font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-${name}"></i>`, id: 'gp-font', label: 'Copy' })}</div>` : ''}
<div><h3>The sprite</h3>${kit.snippet({ code: `<svg width="24" height="24"><use href="${base}/shared/ov-glyphs.svg#ovg-${name}"/></svg>`, id: 'gp-sprite', label: 'Copy' })}</div>
<div><h3>The sprite</h3>${kit.snippet({ code: `<svg width="24" height="24"><use href="${base}/shared/ov-glyphs.svg#ovg-${name}"/></svg>`, id: 'gp-sprite', label: 'Copy' })}</div>
<div><h3>Node</h3>${kit.snippet({ code: `require('openvibe-shared/glyphs').svg('${name}', { size: 24 })`, id: 'gp-node', label: 'Copy' })}</div>
<div><h3>Node</h3>${kit.snippet({ code: `require('openvibe-shared/glyphs').svg('${name}', { size: 24 })`, id: 'gp-node', label: 'Copy' })}</div>
- <div><h3>A link</h3>${kit.snippet({ code: `${base}/icons/${name}.svg\n${base}/icons/${name}.png?size=512&color=mark`, id: 'gp-url', label: 'Copy' })}</div>
+ <div><h3>A link</h3>${kit.snippet({ code: `${base}/icons/${name}.svg\n${base}/icons/${name}.png?size=512&color=mark${glyphs.solid(name) ? `\n${base}/icons/${name}.svg?style=solid` : ''}`, id: 'gp-url', label: 'Copy' })}</div>
</div>
</div>
</section>`;
</section>`;
@@ -74,8 +74,30 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
@@ -74,8 +74,30 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
assert.deepEqual(pngSize((await t.get('/icons/heart.png?size=99999')).buffer), [1024, 1024], 'clamped to 1024');
assert.deepEqual(pngSize((await t.get('/icons/heart.png?size=99999')).buffer), [1024, 1024], 'clamped to 1024');
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' });
- assert.deepEqual(o, { size: 16, scale: 1, color: '0f172a', stroke: 3, pad: 0, ground: 'none', bg: 'ffffff', fx: 'flat' });
+ const o = ex.options({ size: '3', color: 'javascript:', stroke: '9', pad: '-4', ground: '<x>', bg: 'zz', style: 'bold' });
+ assert.deepEqual(o, { size: 16, scale: 1, color: '0f172a', stroke: 3, pad: 0, ground: 'none', bg: 'ffffff', fx: 'flat', style: 'auto' });
+ });
+
+ await check('every size by itself (L50): style=auto draws the solid drawing at 18 px and under, line and solid on ask', async () => {
+ assert.ok(glyphs.solid('mic') && !glyphs.solid('check'), 'mic has a solid drawing; check is only lines');
+ const small = (await t.get('/icons/mic.svg?size=16')).text;
+ assert.ok(small.includes('<g transform="translate(0 0)" fill="#0f172a" stroke="none">'), 'small and auto: filled');
+ assert.ok(small.includes('fill="none" stroke="#0f172a" stroke-width="2.6" stroke-linecap="round"'), 'its lines written out, no class left');
+ assert.ok(!small.includes('class='), 'the file needs no page styles');
+ assert.ok((await t.get('/icons/mic.svg?size=16&style=line')).text.includes('fill="none" stroke="#0f172a" stroke-width="2"'), 'line on ask');
+ assert.ok((await t.get('/icons/mic.svg?size=64&style=solid')).text.includes('fill="#0f172a" stroke="none"'), 'solid on ask, any size');
+ assert.ok((await t.get('/icons/mic.svg?size=24')).text.includes('fill="none" stroke="#0f172a"'), 'above 18 px auto is the line');
+ assert.ok((await t.get('/icons/check.svg?size=16&style=solid')).text.includes('fill="none" stroke="#0f172a"'), 'a glyph of lines stays lines');
+ assert.equal(ex.query(ex.options({ size: 16 })), '?size=16', 'auto is the default and stays out of links');
+ assert.equal(ex.query(ex.options({ style: 'solid' })), '?style=solid');
+ const png = (await t.get('/icons/mic.png?size=16&scale=3')).buffer;
+ assert.deepEqual(pngSize(png), [48, 48], 'a small PNG at 3x, drawn solid');
+ const page = (await t.get('/icons/mic')).text;
+ assert.ok(page.includes('<h2 class="gp-ladder-h" id="gp-ladder-h">Every size by itself</h2>'));
+ assert.equal((page.match(/<div class="gp-sizes is-(light|dark)"/g) || []).length, 2, 'the ladder on light and on dark');
+ assert.equal((page.match(/<svg class="is-solid"/g) || []).length, 4, '12 and 16 px solid, on each ground');
+ assert.ok(page.includes('<select name="style" data-gp="style">'), 'the maker offers the style');
+ assert.ok(/its lines grow a little heavier when small/i.test((await t.get('/icons/check')).text), 'a glyph of lines says why it has no solid');
});
});
await check('the finish (L44): flat by default, depth light to deep, a glow behind, or both', async () => {
await check('the finish (L44): flat by default, depth light to deep, a glow behind, or both', async () => {
@@ -99,7 +121,7 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
@@ -99,7 +121,7 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
const page = (await t.get('/icons/heart?fx=glow')).text;
const page = (await t.get('/icons/heart?fx=glow')).text;
assert.ok(page.includes('<option value="glow" selected'), 'the maker remembers the finish');
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');
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');
+ assert.match(js, /fx: 'flat', style: 'auto' \}/, 'the maker\'s live link leaves a flat finish and the auto style 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');