openvibers/openvibe.design Commits

Icons anywhere: <ov-icon>, every copy format, Android, favicon sets (#75)

OP OpenVibers committed 12 days ago · ff6c20f70d · 9 files, +435 −14

Plan L50 steps 2 and 3 (the owner: "easy to copy paste… web SDK, API… without login").
- <ov-icon name="heart"> from openvibe.design/ov-icon.js (also /icons.js): any page, any framework, no sign-in.
  1em and currentColor; solid at 18 px and under by a container query on its real size; a person's OpenVibe icon
  choice wins; Font Awesome names work; one request per page for every name it shows.
- /api/v1/glyphs?names=a,fa-b answers just those, with the solid drawings; the registry carries solids too.
- /icons/<name>.mjs (ES module), .xml (Android VectorDrawable, every shape as path data), /icons/<name>/favicon.zip
  (SVG, PNGs at 32/180/192/512, manifest, the <head> lines), all CORS open.
- Each icon page: any page, inline SVG, React and a link in view; eight more ways in a counted fold (Font Awesome
  markup, font, sprite, CSS mask, data URI, ES module, Node, Android); files: SVG, PNG 1-3x, Android XML, favicon set.
- /icons and llms.txt name the component and the URLs.

Changes

ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex f3c3df5..b5592e8 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -85,6 +85,19 @@
.ic-use>div,.gp-use>div{min-width:0}
.ic-use pre,.gp-use pre,.fa-steps pre{font-size:12.5px;white-space:pre;overflow-x:auto;padding-right:52px}
.ic-use h3,.gp-use h3{margin-top:0}
++/* L50 step 3: the ways to use a glyph. The rest fold under a count of what is inside, never behind tabs. */
++.gp-note{margin:-4px 0 8px;color:var(--text-secondary);font-size:13px;line-height:1.5}
++.gp-more{margin-top:14px;border-radius:14px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-secondary) 60%,transparent)}
++.gp-more>summary{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding:14px 16px;cursor:pointer;font-weight:750;list-style:none}
++.gp-more>summary::-webkit-details-marker{display:none}
++.gp-more>summary::before{content:'';width:8px;height:8px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:translateY(-2px) rotate(-45deg);transition:transform .18s ease}
++.gp-more[open]>summary::before{transform:translateY(-4px) rotate(45deg)}
++.gp-more>summary span{min-width:0;color:var(--text-muted);font-weight:500;font-size:13px}
++.gp-more>summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:14px}
++.gp-more>.gp-use{padding:4px 16px 16px}
++.gp-files{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:16px}
++.gp-files>span{color:var(--text-muted);font-size:13px;font-weight:650;margin-right:4px}
++@media (prefers-reduced-motion:reduce){.gp-more>summary::before{transition:none}}
/* Get them all: one card per file. */
.ic-packs{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
ddiff --git a/public/js/icon.js b/public/js/icon.js
iindex e5fda21..b81e889 100644
---- a/public/js/icon.js
++++ b/public/js/icon.js
@@@ -51,6 +51,9 @@
pngLink.setAttribute('download', `${name}-${px}.png`);
pngLink.lastChild.textContent = ` PNG, ${px} px`;
svgLink.href = `/icons/${name}.svg${q}`;
++ // The favicon set follows the maker too (its own defaults when nothing is chosen).
++ const fav = document.querySelector('[data-gp-fav]');
++ if (fav) fav.href = `/icons/${name}/favicon.zip${q}`;
history.replaceState(null, '', `${location.pathname}${q}#png`);
}, 120);
}
ddiff --git a/public/ov-icon.js b/public/ov-icon.js
nnew file mode 100644
iindex 0000000..5330076
---- /dev/null
++++ b/public/ov-icon.js
@@@ -0,0 +1,138 @@
++/*! <ov-icon> · the OpenVibe glyphs on any page · MIT · https://openvibe.design/icons */
++/**
++ * <script src="https://openvibe.design/ov-icon.js" defer></script>
++ * <ov-icon name="heart"></ov-icon> follows the text: 1em, currentColor
++ * <ov-icon name="mic" size="16" label="Microphone"></ov-icon> a size in pixels; a label makes it an image to screen readers
++ * <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too
++ * <ov-icon name="bell" variant="solid"></ov-icon> line | solid | auto (the default)
++ *
++ * Every size by itself: on auto, an icon 18 px or smaller shows its solid drawing when it has one (a container query,
++ * so it follows the real size, not the attribute), and a glyph made only of lines draws them a little heavier there.
++ * A person's own choice on OpenVibe (html[data-ov-icons="line"|"solid"]) wins over auto.
++ *
++ * The drawings come from the server the script came from, the names a page asks for in one request, kept for the
++ * page's life. CSS: --ov-icon-size (1em), --ov-icon-stroke (2). Works in React 19, Vue, Svelte, Solid and plain HTML.
++ */
++(function () {
++ 'use strict';
++ if (typeof window === 'undefined' || !window.customElements || window.customElements.get('ov-icon')) return;
++ var script = document.currentScript;
++ var BASE = 'https://openvibe.design';
++ try { if (script && script.src) BASE = new URL(script.src).origin; } catch (e) { /* the default */ }
++ var NAME = /^(?:fa-)?[a-z][a-z0-9-]{0,47}$/;
++
++ // ── The drawings: asked for together, kept for the page's life ──
++ var known = Object.create(null); // name → Promise<{ line, solid } | null>
++ var waiting = Object.create(null); // name → resolve
++ var timer = 0;
++ function flush() {
++ timer = 0;
++ var names = Object.keys(waiting);
++ var batch = waiting;
++ waiting = Object.create(null);
++ for (var i = 0; i < names.length; i += 60) {
++ (function (part) {
++ fetch(BASE + '/api/v1/glyphs?draw=1&names=' + encodeURIComponent(part.join(',')), { credentials: 'omit' })
++ .then(function (r) { return r.ok ? r.json() : { glyphs: [] }; })
++ .catch(function () { return { glyphs: [] }; })
++ .then(function (j) {
++ var got = Object.create(null);
++ (j.glyphs || []).forEach(function (g) { got[g.asked || g.name] = g; });
++ part.forEach(function (n) {
++ var g = got[n];
++ batch[n](g && g.svg ? { line: String(g.svg), solid: String(g.solid || '') } : null);
++ });
++ });
++ })(names.slice(i, i + 60));
++ }
++ }
++ function load(name) {
++ if (!known[name]) {
++ known[name] = new Promise(function (resolve) { waiting[name] = resolve; });
++ if (!timer) timer = setTimeout(flush, 0);
++ }
++ return known[name];
++ }
++
++ // ── How it looks: the line drawing, or the solid one when small or asked ──
++ var CSS = ':host{display:inline-block;inline-size:var(--ov-icon-size,1em);block-size:var(--ov-icon-size,1em);flex:none;vertical-align:-.125em;line-height:0;contain:layout style;container-type:size}'
++ + 'svg{display:block;inline-size:100%;block-size:100%;overflow:visible}'
++ + '.l{fill:none;stroke:currentColor;stroke-width:var(--ov-icon-stroke,2);stroke-linecap:round;stroke-linejoin:round}'
++ + '.s{display:none;fill:currentColor;stroke:none}'
++ + '.s .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}'
++ + '.v-solid.has-s .l{display:none}.v-solid.has-s .s{display:inline}'
++ + '@container (max-width: 18px){'
++ + '.v-auto.has-s .l{display:none}.v-auto.has-s .s{display:inline}'
++ + '.v-auto:not(.has-s) .l,.v-line .l{stroke-width:var(--ov-icon-stroke-small,2.4)}}';
++ var sheet = null;
++ try { sheet = new CSSStyleSheet(); sheet.replaceSync(CSS); } catch (e) { sheet = null; }
++
++ var live = new Set();
++ function pref() {
++ var p = document.documentElement.getAttribute('data-ov-icons');
++ return p === 'line' || p === 'solid' ? p : 'auto';
++ }
++ new MutationObserver(function () { live.forEach(function (el) { el._look(); }); })
++ .observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-icons'] });
++
++ function OvIcon() { return Reflect.construct(HTMLElement, [], OvIcon); }
++ OvIcon.prototype = Object.create(HTMLElement.prototype);
++ OvIcon.prototype.constructor = OvIcon;
++ Object.setPrototypeOf(OvIcon, HTMLElement);
++ Object.defineProperty(OvIcon, 'observedAttributes', { get: function () { return ['name', 'size', 'variant', 'label']; } });
++
++ OvIcon.prototype.connectedCallback = function () {
++ if (!this.shadowRoot) {
++ var root = this.attachShadow({ mode: 'open' });
++ if (sheet && 'adoptedStyleSheets' in root) root.adoptedStyleSheets = [sheet];
++ else { var st = document.createElement('style'); st.textContent = CSS; root.appendChild(st); }
++ this._svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
++ this._svg.setAttribute('viewBox', '0 0 24 24');
++ this._svg.setAttribute('aria-hidden', 'true');
++ this._svg.setAttribute('focusable', 'false');
++ root.appendChild(this._svg);
++ }
++ live.add(this);
++ this._size();
++ this._label();
++ this._draw();
++ };
++ OvIcon.prototype.disconnectedCallback = function () { live.delete(this); };
++ OvIcon.prototype.attributeChangedCallback = function (attr, was, now) {
++ if (!this._svg || was === now) return;
++ if (attr === 'name') this._draw();
++ else if (attr === 'size') this._size();
++ else if (attr === 'label') this._label();
++ else this._look();
++ };
++ OvIcon.prototype._size = function () {
++ var s = Number(this.getAttribute('size'));
++ if (s > 0 && s <= 1024) this.style.setProperty('--ov-icon-size', s + 'px');
++ else this.style.removeProperty('--ov-icon-size');
++ };
++ OvIcon.prototype._label = function () {
++ var label = (this.getAttribute('label') || '').trim();
++ if (label) { this.setAttribute('role', 'img'); this.setAttribute('aria-label', label); this.removeAttribute('aria-hidden'); }
++ else { this.removeAttribute('role'); this.removeAttribute('aria-label'); this.setAttribute('aria-hidden', 'true'); }
++ };
++ OvIcon.prototype._look = function () {
++ var v = this.getAttribute('variant');
++ if (v !== 'line' && v !== 'solid' && v !== 'auto') v = pref();
++ this._svg.setAttribute('class', 'v-' + v + (this._hasSolid ? ' has-s' : ''));
++ };
++ OvIcon.prototype._draw = function () {
++ var self = this;
++ var name = String(this.getAttribute('name') || '').trim().toLowerCase();
++ this._want = name;
++ if (!NAME.test(name)) { this._svg.innerHTML = ''; return; }
++ load(name).then(function (g) {
++ if (self._want !== name) return;
++ self._hasSolid = !!(g && g.solid);
++ // The drawings are the OpenVibe glyph registry's own shapes (paths, circles, rects), from this script's server.
++ self._svg.innerHTML = g ? '<g class="l">' + g.line + '</g>' + (g.solid ? '<g class="s">' + g.solid + '</g>' : '') : '';
++ self._look();
++ self.toggleAttribute('data-missing', !g);
++ });
++ };
++ window.customElements.define('ov-icon', OvIcon);
++})();
ddiff --git a/server/glyphs/formats.js b/server/glyphs/formats.js
nnew file mode 100644
iindex 0000000..2f89e18
---- /dev/null
++++ b/server/glyphs/formats.js
@@@ -0,0 +1,149 @@
++'use strict';
++/**
++ * A glyph in every form people paste (plan L50 step 3: "easy to copy paste"): inline SVG for HTML, Vue, Svelte and
++ * Solid; JSX for React; a data URI and a CSS mask; an Android VectorDrawable; an ES module; and a favicon set. All of
++ * them are the same drawing as /icons/<name>.svg, built from openvibe-shared/glyphs, so nothing here can drift.
++ *
++ * shapes(markup) → [{ tag, attrs }] the drawing's shapes (path, circle, rect, ellipse, line, polyline)
++ * toPath(shape) → SVG path data for any of those shapes (Android draws paths only)
++ * inlineSvg(name, o) → <svg …> with currentColor, for HTML, Vue, Svelte and Solid
++ * jsx(name, o) → the same as React JSX (camelCase attributes, className)
++ * dataUri(name, o) → data:image/svg+xml,… in a fixed colour (an <img> or a CSS url() has no currentColor)
++ * cssMask(name, o) → a CSS class that paints the glyph in currentColor through a mask
++ * vectorDrawable(name, o) → Android's res/drawable XML
++ * esModule(name) → export default { name, line, solid }
++ * faviconZip(name, o) → favicon.svg, PNGs at 32, 180, 192 and 512, a web manifest and the <head> lines
++ *
++ * o is export.js's options(); `style` picks the line or the solid drawing as it does for the SVG and PNG.
++ */
++const glyphs = require('openvibe-shared/glyphs');
++const ex = require('./export');
++
++const SHAPE = /<(path|circle|rect|ellipse|line|polyline)\b([^>]*?)\/?>/g;
++const ATTR = /([a-zA-Z][\w:-]*)="([^"]*)"/g;
++
++function shapes(markup) {
++ return [...String(markup || '').matchAll(SHAPE)].map((m) => ({ tag: m[1], attrs: Object.fromEntries([...m[2].matchAll(ATTR)].map((a) => [a[1], a[2]])) }));
++}
++
++const n = (v) => +(+v || 0).toFixed(3);
++function toPath({ tag, attrs: a }) {
++ if (tag === 'path') return a.d || '';
++ if (tag === 'circle' || tag === 'ellipse') {
++ const cx = n(a.cx), cy = n(a.cy), rx = n(tag === 'circle' ? a.r : a.rx), ry = n(tag === 'circle' ? a.r : a.ry);
++ return `M${n(cx - rx)} ${cy}a${rx} ${ry} 0 1 0 ${n(2 * rx)} 0a${rx} ${ry} 0 1 0 ${n(-2 * rx)} 0Z`;
++ }
++ if (tag === 'rect') {
++ const x = n(a.x), y = n(a.y), w = n(a.width), h = n(a.height);
++ const rx = Math.min(n(a.rx != null ? a.rx : a.ry), w / 2), ry = Math.min(n(a.ry != null ? a.ry : a.rx), h / 2);
++ if (!rx) return `M${x} ${y}h${w}v${h}h${n(-w)}Z`;
++ return `M${n(x + rx)} ${y}h${n(w - 2 * rx)}a${rx} ${ry} 0 0 1 ${rx} ${ry}v${n(h - 2 * ry)}a${rx} ${ry} 0 0 1 ${n(-rx)} ${ry}`
++ + `h${n(-(w - 2 * rx))}a${rx} ${ry} 0 0 1 ${n(-rx)} ${n(-ry)}v${n(-(h - 2 * ry))}a${rx} ${ry} 0 0 1 ${rx} ${n(-ry)}Z`;
++ }
++ if (tag === 'line') return `M${n(a.x1)} ${n(a.y1)}L${n(a.x2)} ${n(a.y2)}`;
++ if (tag === 'polyline') {
++ const pts = String(a.points || '').trim().split(/[\s,]+/).map(Number);
++ const pairs = [];
++ for (let i = 0; i + 1 < pts.length; i += 2) pairs.push(`${n(pts[i])} ${n(pts[i + 1])}`);
++ return pairs.length ? `M${pairs.join('L')}` : '';
++ }
++ return '';
++}
++
++/** Which drawing these choices show, and its markup. */
++function drawing(name, o) {
++ const solid = ex.drawnAs(name, o) === 'solid';
++ return { solid, markup: solid ? glyphs.solid(name) : glyphs.path(name) };
++}
++const colourOf = (o) => (o.color === 'mark' ? ex.MARK[0] : `#${o.color}`);
++
++/** <svg> with currentColor: what HTML, Vue, Svelte, Solid and Angular take as it is. */
++function inlineSvg(name, o = ex.options({ size: 24 })) {
++ const { solid, markup } = drawing(name, o);
++ if (!markup) return '';
++ const size = o.size;
++ if (solid) {
++ const body = markup.replace(/class="ln"/g, 'fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"');
++ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${body}</svg>`;
++ }
++ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${o.stroke}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${markup}</svg>`;
++}
++
++/** The inline SVG as React JSX: camelCase attributes, className, numbers in braces where React expects them. */
++function jsx(name, o = ex.options({ size: 24 })) {
++ const svg = inlineSvg(name, o);
++ if (!svg) return '';
++ // aria-* and data-* keep their dashes in React; every other dashed SVG attribute is camelCase.
++ const camel = (k) => (k === 'class' ? 'className' : /^(xmlns|aria-|data-)/.test(k) ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()));
++ // React takes these attributes as strings ("2" for strokeWidth), so only the names change.
++ const body = svg.replace(/([a-zA-Z][\w-]*)="([^"]*)"/g, (_, k, v) => `${camel(k)}="${v}"`);
++ const fn = name.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
++ return `export function ${fn}Icon(props) {\n return (\n ${body.replace('aria-hidden="true">', 'aria-hidden="true" {...props}>')}\n );\n}`;
++}
++
++/** An SVG for places with no currentColor (an <img>, a CSS url()): the drawing in a fixed colour. */
++function fixedSvg(name, o, colour) {
++ return inlineSvg(name, o).replace(/currentColor/g, colour).replace(' aria-hidden="true"', '');
++}
++const enc = (svg) => `data:image/svg+xml,${svg.replace(/"/g, "'").replace(/[<>#%{}\n]/g, (c) => encodeURIComponent(c))}`;
++function dataUri(name, o = ex.options({ size: 24 })) {
++ return inlineSvg(name, o) ? enc(fixedSvg(name, o, colourOf(o))) : '';
++}
++
++/** A class that paints the glyph in the text's colour (currentColor) through a mask: no markup, any element. */
++function cssMask(name, o = ex.options({ size: 24 })) {
++ if (!inlineSvg(name, o)) return '';
++ const url = enc(fixedSvg(name, o, '#000'));
++ return `.icon-${name} {\n display: inline-block;\n width: 1em;\n height: 1em;\n vertical-align: -0.125em;\n background-color: currentColor;\n`
++ + ` -webkit-mask: url("${url}") center / contain no-repeat;\n mask: url("${url}") center / contain no-repeat;\n}`;
++}
++
++/** Android's VectorDrawable (res/drawable/ic_<name>.xml): paths only, so every shape becomes path data. */
++function vectorDrawable(name, o = ex.options({ size: 24 })) {
++ const { solid, markup } = drawing(name, o);
++ if (!markup) return '';
++ const argb = (hex) => `#FF${hex.replace('#', '').toUpperCase()}`;
++ const colour = argb(colourOf(o).length === 4 ? colourOf(o).replace(/^#(.)(.)(.)$/, '#$1$1$2$2$3$3') : colourOf(o));
++ const stroke = (w) => `android:fillColor="#00000000" android:strokeColor="${colour}" android:strokeWidth="${w}" android:strokeLineCap="round" android:strokeLineJoin="round"`;
++ const paths = shapes(markup).map((s) => {
++ const d = toPath(s);
++ if (!d) return '';
++ if (!solid) return ` <path android:pathData="${d}" ${stroke(s.attrs['stroke-width'] || o.stroke)}/>`;
++ if (s.attrs.class === 'ln') return ` <path android:pathData="${d}" ${stroke(2.6)}/>`;
++ return ` <path android:pathData="${d}" android:fillColor="${colour}"${s.attrs['fill-rule'] === 'evenodd' ? ' android:fillType="evenOdd"' : ''}/>`;
++ }).filter(Boolean);
++ return `<?xml version="1.0" encoding="utf-8"?>\n<!-- ${name}: the OpenVibe glyphs, MIT. https://openvibe.design/icons/${name} -->\n`
++ + `<vector xmlns:android="http://schemas.android.com/apk/res/android"\n android:width="24dp"\n android:height="24dp"\n android:viewportWidth="24"\n android:viewportHeight="24">\n${paths.join('\n')}\n</vector>\n`;
++}
++
++/** An ES module: the drawings as strings, for a build step or a framework component of your own. */
++function esModule(name) {
++ const line = glyphs.path(name);
++ if (!line) return '';
++ return `// ${name}: the OpenVibe glyphs, MIT. https://openvibe.design/icons/${name}\n`
++ + `// 24-unit grid. line: strokes (fill none, stroke currentColor, width 2, round). solid: fills; class "ln" stays a line.\n`
++ + `export const name = ${JSON.stringify(name)};\nexport const line = ${JSON.stringify(line)};\nexport const solid = ${JSON.stringify(glyphs.solid(name) || '')};\n`
++ + 'export default { name, line, solid };\n';
++}
++
++/** A favicon set from the PNG maker's choices: the files and the lines for <head>. */
++function faviconZip(name, o) {
++ if (!glyphs.path(name)) return null;
++ // A favicon reads best with room and a ground; the maker's choices win when given.
++ const base = { ...o, scale: 1, style: o.style };
++ const png = (size) => ex.png(name, { ...base, size: Math.min(1024, Math.max(16, size)) });
++ const head = '<link rel="icon" href="/favicon.svg" type="image/svg+xml">\n<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png">\n'
++ + '<link rel="apple-touch-icon" href="/apple-touch-icon.png">\n<link rel="manifest" href="/site.webmanifest">\n';
++ const manifest = JSON.stringify({ icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }] }, null, 2) + '\n';
++ return ex.zipOf([
++ { name: 'favicon.svg', data: ex.svg(name, { ...base, size: 32 }) },
++ { name: 'favicon-32.png', data: png(32) },
++ { name: 'apple-touch-icon.png', data: png(180) },
++ { name: 'icon-192.png', data: png(192) },
++ { name: 'icon-512.png', data: png(512) },
++ { name: 'site.webmanifest', data: manifest },
++ { name: 'head.html', data: head },
++ ]);
++}
++
++module.exports = { shapes, toPath, inlineSvg, jsx, dataUri, cssMask, vectorDrawable, esModule, faviconZip };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 0fe752a..d8dc554 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -83,7 +83,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
-- { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); the registry as data at GET /api/v1/glyphs' },
++ { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b for just those). All open, no sign-in, CORS *' },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs' },
{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex 120f299..4f1e0be 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -6,6 +6,10 @@
* GET /icons/<name>.svg one glyph as a standalone SVG (anyone may fetch it); with the PNG maker's
* choices in the query (size, color, stroke, pad, ground, bg), drawn with them
* GET /icons/<name>.png the same as a PNG (resvg), any size up to 1024 at 1-3x
++ * GET /icons/<name>.mjs an ES module with the line and solid drawings (L50)
++ * GET /icons/<name>.xml an Android VectorDrawable, with the maker's colour and style
++ * GET /icons/<name>/favicon.zip a favicon set from the maker's choices: SVG, PNGs, manifest, the <head> lines
++ * GET /ov-icon.js (also /icons.js) the <ov-icon name="…"> web component: any page, no sign-in (L50 step 2)
* GET /icons/pack/<file> the fonts (WOFF2, TTF, OTF), the sprite, the CSS, the code points, and
* ov-glyphs-svg.zip with every glyph
* GET /icons/font-awesome-alternative how to switch, and what keeps working
@@@ -13,7 +17,7 @@
* GET /icons/kit.zip?names=&name=&fa= the same kit from a link
* POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)
* POST /icons/drafts/:id/review an admin approves or rejects one (same-origin form)
-- * GET /api/v1/glyphs the registry as data
++ * GET /api/v1/glyphs the registry as data; ?names=a,b,fa-c (up to 120) for just those, with solids
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
*/
const express = require('express');
@@@ -27,6 +31,14 @@ const { glyphPage, faAlternativePage, kitPage } = require('../views/icon-page');
const kits = require('../glyphs/kits');
const ex = require('../glyphs/export');
const { createGlyphDrafts } = require('../glyphs/drafts');
++const formats = require('../glyphs/formats');
++const fs = require('fs');
++const path = require('path');
++
++// The web component, read once: a stable address other sites put in a <script> tag, so a day's cache, not immutable.
++let ovIconJs = null;
++const ovIcon = () => (ovIconJs = ovIconJs || fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'ov-icon.js'), 'utf8'));
++const OPEN = { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' };
function createIconRoutes(ctx) {
const { config, s, ai, log = console } = ctx;
@@@ -79,13 +91,27 @@ function createIconRoutes(ctx) {
res.type(p.type).attachment(file).sendFile(p.path, (err) => { if (err && !res.headersSent) next(); });
});
++ r.get(['/ov-icon.js', '/icons.js'], (_req, res) => {
++ res.set(OPEN).set('Cache-Control', 'public, max-age=86400').type('application/javascript').send(ovIcon());
++ });
++
++ r.get('/icons/:name/favicon.zip', (req, res, next) => {
++ const name = req.params.name;
++ if (!/^[a-z][a-z0-9-]{0,40}$/.test(name) || !glyphs.path(name)) return next();
++ const q = { ground: 'squircle', bg: '0b1020', color: 'mark', pad: '14', ...req.query };
++ res.set(OPEN).set('Cache-Control', 'public, max-age=86400').type('application/zip').attachment(`${name}-favicon.zip`)
++ .send(formats.faviconZip(name, ex.options(q)));
++ });
++
r.get('/icons/:file', (req, res, next) => {
-- const m = /^([a-z][a-z0-9-]{0,40})(?:\.(svg|png))?$/.exec(req.params.file);
++ const m = /^([a-z][a-z0-9-]{0,40})(?:\.(svg|png|mjs|xml))?$/.exec(req.params.file);
if (!m || !glyphs.path(m[1])) return next();
const [, name, kind] = m;
const opts = ex.options(req.query);
if (!kind) return send(res, 200, glyphPage({ base: baseUrl(), name, opts }), { viewer: req.viewer });
-- res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');
++ res.set('Cache-Control', 'public, max-age=86400').set(OPEN);
++ if (kind === 'mjs') return res.type('text/javascript').send(formats.esModule(name));
++ if (kind === 'xml') return res.type('application/xml').send(formats.vectorDrawable(name, ex.options({ size: 24, ...req.query })));
if (kind === 'svg') return res.type('image/svg+xml').send(ex.isDefault(opts) ? glyphs.svg(name, { size: 24, title: name }) : ex.svg(name, opts));
try { res.type('image/png').send(ex.png(name, opts)); } catch (err) {
log.warn('[OpenVibe.Design] glyph PNG failed:', err && err.message);
@@@ -115,7 +141,18 @@ function createIconRoutes(ctx) {
} catch (err) { back(res, { error: err.message }); }
});
-- r.get('/api/v1/glyphs', (_req, res) => res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(glyphsJson()));
++ r.get('/api/v1/glyphs', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=300').set(OPEN);
++ if (req.query.names == null) return res.json(glyphsJson());
++ // Just the names asked for (<ov-icon> asks for a page's in one go): a glyph or a Font Awesome name, each answered
++ // under the name it was asked by; an unknown one is left out.
++ const asked = [...new Set(String(req.query.names).split(',').map((n) => n.trim().toLowerCase()).filter((n) => /^[a-z][a-z0-9-]{0,47}$/.test(n)))].slice(0, 120);
++ const out = asked.map((a) => {
++ const name = glyphs.path(a) ? a : glyphs.fromFa(a);
++ return name ? { asked: a, name, svg: glyphs.path(name), solid: glyphs.solid(name) || '' } : null;
++ }).filter(Boolean);
++ res.json({ count: out.length, glyphs: out });
++ });
r.get('/api/v1/glyphs/drafts', async (req, res) => {
const status = String(req.query.status || 'approved');
if (status !== 'approved' && !isAdmin(req)) return problem(res, req, 403, 'design.glyph_drafts_private', 'Only approved drafts are public.');
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
iindex 54dcaeb..e275f4d 100644
---- a/server/views/icon-page.js
++++ b/server/views/icon-page.js
@@@ -13,6 +13,7 @@ const kit = require('openvibe-shared/kit');
const glyphs = require('openvibe-shared/glyphs');
const { layout, esc } = require('../render/layout');
const ex = require('../glyphs/export');
++const formats = require('../glyphs/formats');
const usage = require('../glyphs/fa-usage.json');
const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
@@@ -100,14 +101,35 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
</section>`;
const faTag = fa.length ? `<i class="fa-solid fa-${esc(fa[0])}"></i>` : `<svg><use href="${base}/shared/ov-glyphs.svg#ovg-${esc(name)}"/></svg>`;
++ // L50 step 3: every form people paste, built from the same drawing (server/glyphs/formats.js). The four most asked
++ // for show; the rest sit in a counted fold, never behind tabs.
++ const at24 = ex.options({ size: 24 });
++ const way = (h, code, id, note = '') => `<div class="gp-way"><h3>${h}</h3>${note ? `<p class="gp-note">${note}</p>` : ''}${kit.snippet({ code, id, label: 'Copy' })}</div>`;
++ const first = [
++ way('Any page', `<script src="${base}/ov-icon.js" defer></script>\n<ov-icon name="${name}"></ov-icon>`, 'gp-wc', 'No sign-in. It follows the text&rsquo;s size and colour, and turns solid when small.'),
++ way('Inline SVG', formats.inlineSvg(name, at24), 'gp-inline', 'HTML, Vue, Svelte, Solid, Angular.'),
++ way('React', formats.jsx(name, at24), 'gp-jsx'),
++ way('A link', `${base}/icons/${name}.svg\n${base}/icons/${name}.png?size=512&color=mark${glyphs.solid(name) ? `\n${base}/icons/${name}.svg?style=solid` : ''}`, 'gp-url'),
++ ];
++ const more = [
++ fa.length ? way('Font Awesome markup', `<script src="${base}/shared/ov-glyphs.js" defer></script>\n${faTag}`, 'gp-fa', 'Keep your markup; the drop-in draws ours.') : '',
++ cp ? way('The font', `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-${name}"></i>`, 'gp-font') : '',
++ way('The sprite', `<svg width="24" height="24"><use href="${base}/shared/ov-glyphs.svg#ovg-${name}"/></svg>`, 'gp-sprite'),
++ way('CSS, no markup', formats.cssMask(name, at24), 'gp-mask', 'A mask painted in the text&rsquo;s colour: <code>&lt;span class="icon-${esc(name)}"&gt;&lt;/span&gt;</code>.'),
++ way('Data URI', formats.dataUri(name, at24), 'gp-data', 'For an <code>&lt;img&gt;</code> or a CSS <code>url()</code>, in a fixed colour.'),
++ way('ES module', `import ${name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())} from '${base}/icons/${name}.mjs';\n// { name, line, solid }: the drawings, for a component of your own`, 'gp-mjs'),
++ way('Node', `require('openvibe-shared/glyphs').svg('${name}', { size: 24 })`, 'gp-node'),
++ way('Android', `<!-- res/drawable/ic_${name.replace(/-/g, '_')}.xml -->\n${base}/icons/${name}.xml`, 'gp-android', `A VectorDrawable: <a href="/icons/${esc(name)}.xml" download="ic_${esc(name.replace(/-/g, '_'))}.xml">download the XML</a>. iOS: Xcode takes the SVG as it is (keep &ldquo;Preserve Vector Data&rdquo; on).`),
++ ].filter(Boolean);
const use = `<section class="ic-sec" aria-labelledby="gp-use">
-- <div class="ic-sec-head"><h2 id="gp-use">Use it</h2><p>Every way is the same drawing. On an OpenVibe site the navbar already loads the drop-in, so Font Awesome&rsquo;s markup shows it.</p></div>
-- <div class="gp-use">
-- ${fa.length ? `<div><h3>Font Awesome markup</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n${faTag}`, id: 'gp-fa', 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>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${glyphs.solid(name) ? `\n${base}/icons/${name}.svg?style=solid` : ''}`, id: 'gp-url', label: 'Copy' })}</div>
++ <div class="ic-sec-head"><h2 id="gp-use">Use it</h2><p>Every way is the same drawing, free to use with no sign-in. On an OpenVibe site the navbar already loads the drop-in, so Font Awesome&rsquo;s markup shows it.</p></div>
++ <div class="gp-use">${first.join('')}</div>
++ <details class="gp-more"><summary>${more.length} more ways <span>${more.map((m) => /<h3>([^<]+)<\/h3>/.exec(m)[1]).join(' · ')}</span></summary><div class="gp-use">${more.join('')}</div></details>
++ <div class="gp-files"><span>Files:</span>
++ <a class="ic-btn" href="/icons/${esc(name)}.svg" download="${esc(name)}.svg">SVG</a>
++ ${[1, 2, 3].map((x) => `<a class="ic-btn" href="/icons/${esc(name)}.png?size=24&amp;scale=${x}" download="${esc(name)}-24@${x}x.png">PNG ${x}×</a>`).join('')}
++ <a class="ic-btn" href="/icons/${esc(name)}.xml" download="ic_${esc(name.replace(/-/g, '_'))}.xml">Android XML</a>
++ <a class="ic-btn" href="/icons/${esc(name)}/favicon.zip${esc(q)}" data-gp-fav download="${esc(name)}-favicon.zip">Favicon set</a>
</div>
</section>`;
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex 4fbac84..cbe9ea8 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -100,10 +100,11 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
const use = `<section class="ic-sec" id="use" aria-labelledby="ic-use">
<div class="ic-sec-head"><h2 id="ic-use">Use them</h2><p>Every OpenVibe site already shows them in place of Font Awesome&rsquo;s (the navbar loads the drop-in). Anyone else can too; it is open source (MIT).</p></div>
<div class="ic-use">
++ <div><h3>On any page</h3>${kit.snippet({ code: `<script src="${base}/ov-icon.js" defer></script>\n<ov-icon name="heart"></ov-icon>\n<ov-icon name="mic" size="16" label="Microphone"></ov-icon>`, id: 'ic-use-wc', label: 'Copy the HTML' })}<p class="ic-more">A web component: no sign-in, any framework. It follows the text&rsquo;s size and colour and turns solid when small.</p></div>
<div><h3>In place of Font Awesome</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n<!-- your <i class="fa-solid fa-eye"> now shows the glyph -->`, id: 'ic-use-web', label: 'Copy the HTML' })}<p class="ic-more">Sizes, animations and transforms keep working: <a href="/icons/font-awesome-alternative">what keeps working</a>.</p></div>
<div><h3>As a font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-eye"></i>`, id: 'ic-use-font', label: 'Copy the HTML' })}</div>
<div><h3>On a server</h3>${kit.snippet({ code: "const glyphs = require('openvibe-shared/glyphs');\n\nglyphs.svg('live', { size: 20 }); // a standalone SVG\nglyphs.fromFa('fa-eye'); // 'eye'", id: 'ic-use-node', label: 'Copy the code' })}</div>
-- <div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/icons/<name>.svg`, id: 'ic-use-api', label: 'Copy' })}</div>
++ <div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/api/v1/glyphs?names=heart,fa-gear\nGET ${base}/icons/<name>.svg?style=auto|line|solid\nGET ${base}/icons/<name>.mjs | .xml | /favicon.zip`, id: 'ic-use-api', label: 'Copy' })}</div>
</div>
</section>`;
@@@ -118,7 +119,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
/** The registry as data (GET /api/v1/glyphs). */
function glyphsJson() {
const fa = faByGlyph();
-- return { count: glyphs.NAMES.length, glyphs: glyphs.NAMES.map((n) => ({ name: n, svg: glyphs.path(n), fa: fa.get(n) || [], by: glyphs.META[n].by })), fa: glyphs.FA };
++ return { count: glyphs.NAMES.length, glyphs: glyphs.NAMES.map((n) => ({ name: n, svg: glyphs.path(n), solid: glyphs.solid(n) || '', fa: fa.get(n) || [], by: glyphs.META[n].by })), fa: glyphs.FA };
}
module.exports = { iconsPage, glyphsJson, stillFontAwesome };
ddiff --git a/test/icon-exports.test.js b/test/icon-exports.test.js
iindex f93f7d2..4cf6cd3 100644
---- a/test/icon-exports.test.js
++++ b/test/icon-exports.test.js
@@@ -123,6 +123,64 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/icon.js'), 'utf8');
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('use it anywhere (L50 steps 2-3): <ov-icon>, the drawings by name, and every copy format', async () => {
++ const formats = require('../server/glyphs/formats');
++ // The web component: a stable address any site can load, open to every origin.
++ const wc = await t.get('/ov-icon.js');
++ assert.equal(wc.status, 200);
++ assert.match(wc.headers.get('content-type'), /javascript/);
++ assert.equal(wc.headers.get('access-control-allow-origin'), '*');
++ assert.match(wc.headers.get('cache-control'), /max-age=86400/);
++ assert.ok(wc.text.includes("customElements.define('ov-icon', OvIcon)") && wc.text.includes('@container (max-width: 18px)'), 'small icons go solid by their real size');
++ assert.equal((await t.get('/icons.js')).text, wc.text, 'the plan\'s address works too');
++ // The drawings by name, as <ov-icon> asks for them: glyphs and Font Awesome names, unknown ones left out.
++ const some = await t.get('/api/v1/glyphs?names=mic,fa-gear,nope-nope,mic,<x>');
++ assert.equal(some.headers.get('access-control-allow-origin'), '*');
++ const j = some.json();
++ assert.deepEqual(j.glyphs.map((g) => [g.asked, g.name]), [['mic', 'mic'], ['fa-gear', 'gear']]);
++ assert.equal(j.glyphs[0].svg, glyphs.path('mic'));
++ assert.equal(j.glyphs[0].solid, glyphs.solid('mic'));
++ assert.ok((await t.get('/api/v1/glyphs')).json().glyphs.find((g) => g.name === 'mic').solid, 'the whole registry carries the solids');
++ // An ES module and an Android VectorDrawable, by URL.
++ const mjs = await t.get('/icons/mic.mjs');
++ assert.match(mjs.headers.get('content-type'), /javascript/);
++ assert.ok(mjs.text.includes(`export const line = ${JSON.stringify(glyphs.path('mic'))};`) && mjs.text.includes('export default { name, line, solid };'));
++ const xml = await t.get('/icons/robot.xml?color=ff0000');
++ assert.match(xml.headers.get('content-type'), /xml/);
++ assert.ok(xml.text.includes('<vector xmlns:android="http://schemas.android.com/apk/res/android"') && xml.text.includes('android:strokeColor="#FFFF0000"'));
++ assert.ok(!/<(rect|circle)/.test(xml.text), 'Android draws paths only: the rect became path data');
++ assert.ok((await t.get('/icons/mic.xml?style=solid')).text.includes('android:fillColor="#FF0F172A"'), 'the solid drawing is filled');
++ // Shapes become path data that draws the same thing.
++ assert.equal(formats.toPath({ tag: 'circle', attrs: { cx: '12', cy: '12', r: '3' } }), 'M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0Z');
++ assert.equal(formats.toPath({ tag: 'rect', attrs: { x: '2', y: '3', width: '4', height: '5' } }), 'M2 3h4v5h-4Z');
++ assert.equal(formats.toPath({ tag: 'line', attrs: { x1: '1', y1: '2', x2: '3', y2: '4' } }), 'M1 2L3 4');
++ // A favicon set: the files and the <head> lines.
++ const fav = await t.get('/icons/heart/favicon.zip');
++ assert.equal(fav.headers.get('content-type'), 'application/zip');
++ // The central directory's entries: a 46-byte header with the name's length at 28, then the name.
++ const names = [];
++ for (let at = fav.buffer.indexOf('PK\x01\x02', 0, 'latin1'); at >= 0; at = fav.buffer.indexOf('PK\x01\x02', at + 46, 'latin1')) {
++ names.push(fav.buffer.toString('utf8', at + 46, at + 46 + fav.buffer.readUInt16LE(at + 28)));
++ }
++ assert.deepEqual(names, ['favicon.svg', 'favicon-32.png', 'apple-touch-icon.png', 'icon-192.png', 'icon-512.png', 'site.webmanifest', 'head.html']);
++ assert.equal((await t.get('/icons/nope/favicon.zip')).status, 404);
++ // The copy formats are the same drawing in each language's spelling.
++ const o = ex.options({ size: 24 });
++ assert.ok(formats.inlineSvg('mic', o).includes(glyphs.path('mic')) && formats.inlineSvg('mic', o).includes('stroke="currentColor"'));
++ const jsx = formats.jsx('eye-off', o);
++ assert.ok(jsx.startsWith('export function EyeOffIcon(props) {') && jsx.includes('strokeLinecap="round"') && jsx.includes('aria-hidden="true" {...props}>'), 'React: camelCase, aria kept, props spread');
++ assert.ok(!/stroke-|fill-rule|class=/.test(jsx), 'no HTML-only attribute names left');
++ assert.ok(formats.dataUri('heart', o).startsWith("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'") && formats.dataUri('heart', o).includes('%230f172a'), 'a data URI in a fixed colour');
++ assert.ok(formats.cssMask('check', o).includes('background-color: currentColor;') && formats.cssMask('check', o).includes('mask: url("data:image/svg+xml,'));
++ // The page offers them: four in view, the rest in a fold that says what it holds.
++ const page = (await t.get('/icons/mic')).text;
++ ['gp-wc', 'gp-inline', 'gp-jsx', 'gp-url', 'gp-mask', 'gp-data', 'gp-mjs', 'gp-android'].forEach((id) => assert.ok(page.includes(`id="${id}"`), id));
++ assert.match(page, /<details class="gp-more"><summary>\d+ more ways <span>Font Awesome markup · The font · The sprite · CSS, no markup/);
++ assert.ok(page.includes('href="/icons/mic/favicon.zip" data-gp-fav') && page.includes('href="/icons/mic.xml" download="ic_mic.xml"'));
++ assert.ok((await t.get('/icons')).text.includes('&lt;ov-icon name=&quot;heart&quot;&gt;'), '/icons names the component');
++ assert.ok((await t.get('/llms.txt')).text.includes('/ov-icon.js'), 'llms.txt names it for agents');
++ });
++
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);