openvibers/openvibe.design Commits
Icons anywhere: <ov-icon>, every copy format, Android, favicon sets (#75)
Changes
@@@ -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}@@@ -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);}nnew file mode 100644@@@ -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);++})();nnew file mode 100644@@@ -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 };@@@ -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' },@@@ -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.');@@@ -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’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’s colour: <code><span class="icon-${esc(name)}"></span></code>.'),++ way('Data URI', formats.dataUri(name, at24), 'gp-data', 'For an <code><img></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 “Preserve Vector Data” 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’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’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&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>`;@@@ -100,10 +100,11 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], rconst 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’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’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 };@@@ -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('<ov-icon name="heart">'), '/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);
@@ -85,6 +85,19 @@
@@ -85,6 +85,19 @@
.ic-use>div,.gp-use>div{min-width:0}
.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 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}
.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. */
/* 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}
.ic-packs{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
@@ -51,6 +51,9 @@
@@ -51,6 +51,9 @@
pngLink.setAttribute('download', `${name}-${px}.png`);
pngLink.setAttribute('download', `${name}-${px}.png`);
pngLink.lastChild.textContent = ` PNG, ${px} px`;
pngLink.lastChild.textContent = ` PNG, ${px} px`;
svgLink.href = `/icons/${name}.svg${q}`;
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`);
history.replaceState(null, '', `${location.pathname}${q}#png`);
}, 120);
}, 120);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -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);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -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 };
@@ -83,7 +83,7 @@ function createDiscoveryRoutes(ctx) {
@@ -83,7 +83,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ 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: '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: '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: '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 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' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
@@ -6,6 +6,10 @@
@@ -6,6 +6,10 @@
* GET /icons/<name>.svg one glyph as a standalone SVG (anyone may fetch it); with the PNG maker's
* 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
* 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>.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
* GET /icons/pack/<file> the fonts (WOFF2, TTF, OTF), the sprite, the CSS, the code points, and
* ov-glyphs-svg.zip with every glyph
* ov-glyphs-svg.zip with every glyph
* GET /icons/font-awesome-alternative how to switch, and what keeps working
* GET /icons/font-awesome-alternative how to switch, and what keeps working
@@ -13,7 +17,7 @@
@@ -13,7 +17,7 @@
* GET /icons/kit.zip?names=&name=&fa= the same kit from a link
* 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/draft signed in: OpenVibe.AI drafts candidates (same-origin form)
* POST /icons/drafts/:id/review an admin approves or rejects one (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)
* GET /api/v1/glyphs/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
*/
*/
const express = require('express');
const express = require('express');
@@ -27,6 +31,14 @@ const { glyphPage, faAlternativePage, kitPage } = require('../views/icon-page');
@@ -27,6 +31,14 @@ const { glyphPage, faAlternativePage, kitPage } = require('../views/icon-page');
const kits = require('../glyphs/kits');
const kits = require('../glyphs/kits');
const ex = require('../glyphs/export');
const ex = require('../glyphs/export');
const { createGlyphDrafts } = require('../glyphs/drafts');
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) {
function createIconRoutes(ctx) {
const { config, s, ai, log = console } = ctx;
const { config, s, ai, log = console } = ctx;
@@ -79,13 +91,27 @@ function createIconRoutes(ctx) {
@@ -79,13 +91,27 @@ function createIconRoutes(ctx) {
res.type(p.type).attachment(file).sendFile(p.path, (err) => { if (err && !res.headersSent) next(); });
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) => {
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();
if (!m || !glyphs.path(m[1])) return next();
const [, name, kind] = m;
const [, name, kind] = m;
const opts = ex.options(req.query);
const opts = ex.options(req.query);
if (!kind) return send(res, 200, glyphPage({ base: baseUrl(), name, opts }), { viewer: req.viewer });
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));
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) {
try { res.type('image/png').send(ex.png(name, opts)); } catch (err) {
log.warn('[OpenVibe.Design] glyph PNG failed:', err && err.message);
log.warn('[OpenVibe.Design] glyph PNG failed:', err && err.message);
@@ -115,7 +141,18 @@ function createIconRoutes(ctx) {
@@ -115,7 +141,18 @@ function createIconRoutes(ctx) {
} catch (err) { back(res, { error: err.message }); }
} 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) => {
r.get('/api/v1/glyphs/drafts', async (req, res) => {
const status = String(req.query.status || 'approved');
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.');
if (status !== 'approved' && !isAdmin(req)) return problem(res, req, 403, 'design.glyph_drafts_private', 'Only approved drafts are public.');
@@ -13,6 +13,7 @@ const kit = require('openvibe-shared/kit');
@@ -13,6 +13,7 @@ const kit = require('openvibe-shared/kit');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
const ex = require('../glyphs/export');
const ex = require('../glyphs/export');
+const formats = require('../glyphs/formats');
const usage = require('../glyphs/fa-usage.json');
const usage = require('../glyphs/fa-usage.json');
const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
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()
@@ -100,14 +101,35 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
</section>`;
</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>`;
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’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’s colour: <code><span class="icon-${esc(name)}"></span></code>.'),
+ way('Data URI', formats.dataUri(name, at24), 'gp-data', 'For an <code><img></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 “Preserve Vector Data” on).`),
+ ].filter(Boolean);
const use = `<section class="ic-sec" aria-labelledby="gp-use">
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’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’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&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>
</div>
</section>`;
</section>`;
@@ -100,10 +100,11 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -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">
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’s (the navbar loads the drop-in). Anyone else can too; it is open source (MIT).</p></div>
<div class="ic-sec-head"><h2 id="ic-use">Use them</h2><p>Every OpenVibe site already shows them in place of Font Awesome’s (the navbar loads the drop-in). Anyone else can too; it is open source (MIT).</p></div>
<div class="ic-use">
<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’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>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>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>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>
</div>
</section>`;
</section>`;
@@ -118,7 +119,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -118,7 +119,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
/** The registry as data (GET /api/v1/glyphs). */
/** The registry as data (GET /api/v1/glyphs). */
function glyphsJson() {
function glyphsJson() {
const fa = faByGlyph();
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 };
module.exports = { iconsPage, glyphsJson, stillFontAwesome };
@@ -123,6 +123,64 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
@@ -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');
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');
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('<ov-icon name="heart">'), '/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 () => {
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);