openvibers/openvibe.design Commits
Icon kits: a font with only the icons you use (#46)
Changes
@@@ -18,7 +18,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",-- "pg": "^8.23.1"++ "pg": "^8.23.1",++ "subset-font": "^2.9.0"},"devDependencies": {"@electric-sql/pglite": "^0.5.13"@@@ -313,6 +314,12 @@}}},++ "node_modules/argparse": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",++ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",++ "license": "Python-2.0"++ },"node_modules/array-flatten": {"version": "1.1.1","resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",@@@ -785,6 +792,16 @@"node": ">= 0.8"}},++ "node_modules/fontverter": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz",++ "integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "wawoff2": "^2.0.0",++ "woff2sfnt-sfnt2woff": "^1.0.0"++ }++ },"node_modules/forwarded": {"version": "0.2.0","resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",@@@ -861,6 +878,12 @@"url": "https://github.com/sponsors/ljharb"}},++ "node_modules/harfbuzzjs": {++ "version": "1.6.4",++ "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-1.6.4.tgz",++ "integrity": "sha512-T/PyfhVt1gGR2Tkf5KJUj1DQFkVBXHw552lS1iLEEDhV1Lukb3eq1pk2ov85kgbT2YUiZtJBTak9w+M7YgzUEQ==",++ "license": "MIT"++ },"node_modules/has-symbols": {"version": "1.1.0","resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",@@@ -1354,6 +1377,27 @@"openvibe-build-assets": "scripts/build-assets.js"}},++ "node_modules/p-limit": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",++ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",++ "license": "MIT",++ "dependencies": {++ "yocto-queue": "^0.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/pako": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",++ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",++ "license": "(MIT AND Zlib)"++ },"node_modules/parseurl": {"version": "1.3.3","resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",@@@ -1742,6 +1786,17 @@"node": ">= 0.8"}},++ "node_modules/subset-font": {++ "version": "2.9.0",++ "resolved": "https://registry.npmjs.org/subset-font/-/subset-font-2.9.0.tgz",++ "integrity": "sha512-WJulQeFwbewrXIGcLJbSZFWI44fHW8gNRuPzLywfDL8w38IPRbN1JFxwG76aoohw9kcFdvjVIn2QmID1iy119A==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "fontverter": "^2.0.0",++ "harfbuzzjs": "^1.6.1",++ "p-limit": "^3.1.0"++ }++ },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -1797,6 +1852,28 @@"node": ">= 0.8"}},++ "node_modules/wawoff2": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz",++ "integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==",++ "license": "MIT",++ "dependencies": {++ "argparse": "^2.0.1"++ },++ "bin": {++ "woff2_compress.js": "bin/woff2_compress.js",++ "woff2_decompress.js": "bin/woff2_decompress.js"++ }++ },++ "node_modules/woff2sfnt-sfnt2woff": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz",++ "integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==",++ "license": "MIT",++ "dependencies": {++ "pako": "^1.0.7"++ }++ },"node_modules/xtend": {"version": "4.0.2","resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",@@@ -1805,6 +1882,18 @@"engines": {"node": ">=0.4"}++ },++ "node_modules/yocto-queue": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",++ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }}}}@@@ -24,7 +24,8 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",-- "pg": "^8.23.1"++ "pg": "^8.23.1",++ "subset-font": "^2.9.0"},"engines": {"node": ">=22.0.0"@@@ -166,3 +166,27 @@}@media (max-width:420px){.gp-controls{grid-template-columns:minmax(0,1fr)}}@media (prefers-reduced-motion:reduce){.ic-tile,.ic-packs a{transition:none}.ic-tile:hover{transform:none}}++++/* Make a kit: tick glyphs; the bar holds the name and the download and stays in reach at the bottom. */++.kit-form{display:grid;gap:12px}++.kit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px}++.kit-tile{position:relative;display:grid;justify-items:center;align-content:center;gap:10px;min-height:96px;padding:14px 6px 10px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);cursor:pointer;transition:border-color .15s,background .15s}++.kit-tile input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}++.kit-tile svg{width:24px;height:24px}++.kit-tile span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:12px}++.kit-tile:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}++.kit-tile:has(input:checked){border-color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--bg-secondary))}++.kit-tile:has(input:checked) span{color:var(--text-primary)}++.kit-tile:has(input:checked)::after{content:"";position:absolute;top:8px;right:8px;width:16px;height:16px;border-radius:50%;background:var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5'/%3E%3C/svg%3E") center/11px no-repeat}++.kit-tile:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px}++.kit-opts{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 16px}++.kit-bar{position:sticky;bottom:calc(var(--ovnav-appbar-h,0px) + max(12px,env(safe-area-inset-bottom)));z-index:5;display:flex;align-items:center;gap:12px;padding:8px 8px 8px 16px;border-radius:999px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-secondary) 92%,transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);box-shadow:0 10px 30px rgba(0,0,0,.25)}++.kit-sum{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;color:var(--text-secondary)}++.kit-name{display:grid;gap:4px;flex:1 1 200px;max-width:360px;font-size:12.5px;font-weight:700;color:var(--text-secondary)}++.kit-name input{min-height:42px;box-sizing:border-box;padding:0 12px;border-radius:10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:15px;font-weight:500}++.kit-fa{display:flex;align-items:center;gap:8px;min-height:42px;font-size:14px;color:var(--text-secondary);cursor:pointer}++.kit-fa input{width:18px;height:18px;accent-color:var(--accent)}++.kit-bar .ic-btn{flex:none}++.kit-bar .ic-btn:disabled{opacity:.5;cursor:default}++@media (max-width:760px){.kit-grid{grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}}++@media (prefers-reduced-motion:reduce){.kit-tile{transition:none}}nnew file mode 100644@@@ -0,0 +1,32 @@++/**++ * /icons/kit (server/views/icon-page.js kitPage): find glyphs as you type, say what is picked beside the download++ * button, and clear the picks. Without this script the page is the same form: tick, name, download.++ */++(function () {++ 'use strict';++ const form = document.querySelector('[data-kit-form]');++ if (!form) return;++ const tools = form.querySelector('[data-kit-tools]');++ if (tools) tools.hidden = false;++ const sum = form.querySelector('[data-kit-sum]');++ const go = form.querySelector('[data-kit-go]');++ const boxes = () => form.querySelectorAll('input[name="names"]');++ function update() {++ const picked = [...boxes()].filter((b) => b.checked).map((b) => b.value);++ sum.textContent = picked.length ? `${picked.length} picked: ${picked.slice(0, 4).join(', ')}${picked.length > 4 ? '…' : ''}` : 'Tick the icons you use';++ go.disabled = picked.length === 0;++ }++ form.addEventListener('change', (e) => { if (e.target && e.target.name === 'names') update(); });++ const find = form.querySelector('[data-kit-find]');++ if (find) find.addEventListener('input', () => {++ const q = find.value.trim().toLowerCase().replace(/^fa-/, '');++ form.querySelectorAll('.kit-tile').forEach((t) => {++ t.hidden = !!q && !t.dataset.name.includes(q) && !(t.dataset.fa || '').split(' ').some((f) => f.includes(q));++ });++ });++ // Enter in the search box finds; it never downloads.++ if (find) find.addEventListener('keydown', (e) => { if (e.key === 'Enter') e.preventDefault(); });++ const clear = form.querySelector('[data-kit-clear]');++ if (clear) clear.addEventListener('click', () => { boxes().forEach((b) => { b.checked = false; }); update(); });++ update();++})();nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/**++ * Icon kits (plan L32 I5): a font with only the glyphs a site uses, the way Font Awesome's paid Kits subset theirs.++ * The person picks glyphs on /icons/kit; the kit is cut from openvibe-shared's ov-glyphs.ttf (harfbuzz, through++ * subset-font) and handed back as one ZIP: WOFF2 for the web, TTF for apps, the CSS (Font Awesome's class names too,++ * if asked), the code points and a short README. Three glyphs weigh under a kilobyte. Nothing is stored: the same++ * choices give the same files.++ *++ * parse({ names, name, fa }) → { names: [...known, deduped], slug, fa } or throws { status: 422 }++ * build(choice) → Promise<{ zip: Buffer, files: { name: bytes } }>++ */++const fs = require('fs');++const path = require('path');++const glyphs = require('openvibe-shared/glyphs');++const ex = require('./export');++++const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));++const MAX = 200;++let ttf = null;++let subsetFont = null;++++function parse({ names, name, fa } = {}) {++ const list = (Array.isArray(names) ? names : String(names || '').split(/[\s,]+/))++ .map((n) => String(n).trim().toLowerCase().replace(/^fa-/, '')).filter(Boolean)++ .map((n) => (glyphs.path(n) ? n : glyphs.fromFa(n))) // a Font Awesome name picks its glyph++ .filter(Boolean);++ const unique = [...new Set(list)].slice(0, MAX);++ if (!unique.length) throw Object.assign(new Error('pick at least one glyph'), { status: 422 });++ const slug = String(name || 'my-kit').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'my-kit';++ return { names: unique, slug, fa: fa === true || fa === '1' || fa === 'on' };++}++++const faOf = (g) => Object.entries(glyphs.FA).filter(([, v]) => v === g).map(([k]) => k).sort();++++async function build({ names, slug, fa }) {++ if (!ttf) ttf = fs.readFileSync(path.join(SHARED, 'glyph-fonts', 'ov-glyphs.ttf'));++ if (!subsetFont) subsetFont = require('subset-font');++ const cps = names.map((n) => [n, ex.codepointOf(n)]).filter(([, cp]) => cp);++ const text = cps.map(([, cp]) => String.fromCodePoint(parseInt(cp, 16))).join('');++ const [woff2, truetype] = await Promise.all([++ subsetFont(ttf, text, { targetFormat: 'woff2' }),++ subsetFont(ttf, text, { targetFormat: 'truetype' }),++ ]);++ const family = `OV Glyphs ${slug}`;++ const base = "{display:inline-block;font-family:'" + family + "';font-style:normal;font-weight:400;font-variant:normal;line-height:1;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;speak:never}";++ const rule = (sel, cp) => `${sel}::before{content:"\\${cp.toLowerCase()}"}`;++ const css = [++ `/* ${family}: ${cps.length} OpenVibe glyphs (MIT), made on https://openvibe.design/icons/kit */`,++ `@font-face{font-family:'${family}';src:url(${slug}.woff2) format('woff2'),url(${slug}.ttf) format('truetype');font-display:block}`,++ `.ovi${base}`,++ '.ovi-fw{width:1.25em;text-align:center}',++ ...cps.map(([n, cp]) => rule(`.ovi-${n}`, cp)),++ ...(fa ? [++ '/* Font Awesome\'s class names, for these glyphs only */',++ `.fa,.fas,.far,.fa-solid,.fa-regular${base}`,++ ...cps.flatMap(([n, cp]) => faOf(n).map((f) => rule(`.fa-${f}`, cp))),++ ] : []),++ ].join('\n') + '\n';++ const json = JSON.stringify({ family, glyphs: Object.fromEntries(cps), font_awesome: fa ? Object.fromEntries(cps.map(([n]) => [n, faOf(n)])) : undefined }, null, 2) + '\n';++ const readme = `${family}\n\n${cps.length} glyphs from the OpenVibe glyphs, MIT licence: ${cps.map(([n]) => n).join(', ')}.\n\nOn a page:\n <link rel="stylesheet" href="${slug}.css">\n <i class="ovi ovi-${cps[0][0]}"></i>${fa ? `\n <i class="fa-solid fa-${(faOf(cps[0][0])[0] || cps[0][0])}"></i> (Font Awesome's names work too)` : ''}\n\nKeep ${slug}.css, ${slug}.woff2 and ${slug}.ttf together. In an app, install ${slug}.ttf and type a glyph's character (${slug}.json lists them).\nMake another: https://openvibe.design/icons/kit\n`;++ const files = { [`${slug}.woff2`]: woff2, [`${slug}.ttf`]: truetype, [`${slug}.css`]: css, [`${slug}.json`]: json, 'README.txt': readme };++ const zip = ex.zipOf(Object.entries(files).map(([name, data]) => ({ name: `${slug}/${name}`, data })));++ return { zip, files };++}++++module.exports = { parse, build, MAX };@@@ -9,6 +9,8 @@* 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++ * GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP++ * 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@@@ -21,7 +23,8 @@ const { asyncRouter } = require('./router');const { send, baseUrl } = require('../render/layout');const { writeAllowed } = require('./same-origin');const { iconsPage, glyphsJson } = require('../views/icons');--const { glyphPage, faAlternativePage } = require('../views/icon-page');++const { glyphPage, faAlternativePage, kitPage } = require('../views/icon-page');++const kits = require('../glyphs/kits');const ex = require('../glyphs/export');const { createGlyphDrafts } = require('../glyphs/drafts');@@@ -49,6 +52,24 @@ function createIconRoutes(ctx) {r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));++ // Kits: a font with only the glyphs a site uses (L32 I5). The form posts the picks; the link form is for a build step.++ const sendKit = async (req, res, choice) => {++ let parsed;++ try { parsed = kits.parse(choice); } catch (err) {++ if (req.method === 'POST') return send(res, 422, kitPage({ picked: [], name: choice.name, fa: choice.fa != null, error: err.message }), { viewer: req.viewer });++ return res.status(422).type('text/plain').send(`${err.message}\n`);++ }++ const out = await kits.build(parsed);++ res.set('Cache-Control', req.method === 'GET' ? 'public, max-age=86400' : 'no-store').set('Access-Control-Allow-Origin', '*')++ .type('application/zip').attachment(`ov-glyphs-kit-${parsed.slug}.zip`).send(out.zip);++ };++ r.get('/icons/kit', (req, res) => {++ const picked = String(req.query.names || '').split(',').map((n) => n.trim()).filter((n) => glyphs.path(n));++ send(res, 200, kitPage({ picked, name: String(req.query.name || '').slice(0, 40) }), { viewer: req.viewer });++ });++ r.post('/icons/kit', form, (req, res) => sendKit(req, res, { names: (req.body || {}).names, name: (req.body || {}).name, fa: (req.body || {}).fa }));++ r.get('/icons/kit.zip', (req, res) => sendKit(req, res, { names: req.query.names, name: req.query.name, fa: req.query.fa }));++r.get('/icons/pack/:file', (req, res, next) => {const file = req.params.file;res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');@@@ -59,6 +59,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()<a class="ic-btn is-go" href="${svgUrl}" download="${esc(name)}.svg">${kit.icon('download')} SVG</a><a class="ic-btn" href="#png">${kit.icon('image')} Make a PNG</a><button type="button" class="ic-btn" data-gp-copy="${svgUrl}" hidden>Copy the SVG</button>++ <a class="ic-btn" href="/icons/kit?names=${encodeURIComponent(name)}">Put it in a kit</a></div></div></header>@@@ -142,7 +143,7 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {['Power Transforms', '<code>data-fa-transform</code>: grow, shrink, up, down, left, right, rotate, flip', 'yes'],['Web font', '<code>ov-glyphs.woff2</code>, with Font Awesome’s class names on it (<code>ov-glyphs-fa.css</code>)', 'yes'],['Desktop fonts', 'TTF and OTF, free', 'yes'],-- ['Subset fonts', 'Coming: pick your icons, get a font with only those', 'soon'],++ ['Subset fonts (kits)', '<a href="/icons/kit">Pick your icons, get a font with only those</a>: WOFF2, TTF and the CSS', 'yes'],['Brand logos', 'Not ours to draw: keep Font Awesome Brands for those', 'no'],['Price', 'Free, MIT, for everything above', 'yes'],];@@@ -188,4 +189,32 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {});}--module.exports = { glyphPage, faAlternativePage, related, faOf, title };++/** /icons/kit: pick glyphs, name the kit, get a font with only those (server/glyphs/kits.js). A plain form. */++function kitPage({ picked = [], name = '', fa = true, error = '' } = {}) {++ const on = new Set(picked);++ const tiles = glyphs.NAMES.map((n) => `<label class="kit-tile" data-name="${esc(n)}" data-fa="${esc(faOf(n).join(' '))}"><input type="checkbox" name="names" value="${esc(n)}"${on.has(n) ? ' checked' : ''}>${glyph(n)}<span>${esc(n)}</span></label>`).join('');++ const body = `<article class="ic kit">++<nav class="gp-crumbs" aria-label="Breadcrumb"><a href="/icons">Icons</a><span aria-hidden="true">/</span><span>Make a kit</span></nav>++<header class="ic-hero"><p class="ic-eye">Icon kits</p><h1 class="ic-h1">Only the icons you use.</h1>++<p class="ic-lede">Pick them, name the kit, and get a font with just those: three icons weigh under a kilobyte. WOFF2 for the web, TTF for apps, the CSS (with Font Awesome’s class names if you like) and each icon’s character, in one ZIP. Free, MIT, nothing to sign up for.</p></header>++${error ? kit.notice({ kind: 'error', text: error }) : ''}++<form class="kit-form" method="post" action="/icons/kit" data-kit-form>++ <div class="kit-opts">++ <label class="kit-name"><span>Kit name</span><input name="name" value="${esc(name || 'my-kit')}" maxlength="40" pattern="[A-Za-z0-9 _-]{1,40}" autocomplete="off" spellcheck="false"></label>++ <label class="kit-fa"><input type="checkbox" name="fa" value="1"${fa ? ' checked' : ''}><span>Font Awesome’s class names too</span></label>++ </div>++ <div class="ic-tools" data-kit-tools hidden><label class="ic-find">${kit.icon('search')}<span class="ovk-sr">Find a glyph</span><input type="search" placeholder="Find: a name, or a Font Awesome name" autocomplete="off" data-kit-find></label><button type="button" class="ic-btn" data-kit-clear>Clear</button></div>++ <div class="kit-grid">${tiles}</div>++ <div class="kit-bar"><span class="kit-sum" data-kit-sum>${on.size ? `${on.size} picked` : 'Tick the icons you use'}</span><button type="submit" class="ic-btn is-go" data-kit-go>${kit.icon('download')} Download the kit</button></div>++</form>++<section class="ic-sec" aria-labelledby="kit-api"><div class="ic-sec-head"><h2 id="kit-api">As a link</h2><p>The same kit from a URL, for a build step or an agent: the names, the kit’s name and whether to include Font Awesome’s classes.</p></div>++${kit.snippet({ code: 'https://openvibe.design/icons/kit.zip?names=eye,live,heart&name=my-kit&fa=1', id: 'kit-url', label: 'Copy' })}</section>++</article>`;++ return layout({++ title: 'Make an icon kit: a font with only your icons',++ description: 'Pick the OpenVibe glyphs your site uses and get a font with only those: WOFF2, TTF, CSS with Font Awesome class names, free and MIT.',++ path: '/icons/kit', active: '/icons', styles: ['css/icons.css'], scripts: ['js/kit.js'], body,++ });++}++++module.exports = { glyphPage, faAlternativePage, kitPage, related, faOf, title };@@@ -93,6 +93,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r];const get = `<section class="ic-sec" id="get" aria-labelledby="ic-get"><div class="ic-sec-head"><h2 id="ic-get">Get them all</h2><p>Free, MIT. The fonts carry every glyph at its own character (U+E001 up, never reused), so a document made with them keeps working. Each glyph’s page makes a PNG at any size and colour.</p></div>++ <p class="ic-more"><a href="/icons/kit"><b>Make a kit</b></a>: a font with only the icons your site uses, from a few hundred bytes.</p><ul class="ic-packs">${packs.map(([f, label, about]) => `<li><a href="/icons/pack/${esc(f)}" download><b>${esc(label)}</b><small>${esc(about)}</small><code>${esc(f)}</code></a></li>`).join('')}</ul></section>`;nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++// Icon kits (plan L32 I5, 2026-10-31): pick glyphs on /icons/kit and get a font with only those, as a ZIP with WOFF2,++// TTF, the CSS (Font Awesome's class names when asked), the code points and a README. The same kit comes from a link.++// Unknown names are dropped, Font Awesome names pick their glyph, and a kit with nothing in it says so.++const assert = require('node:assert/strict');++const zlib = require('zlib');++const { boot, check, done } = require('./helpers/boot');++const glyphs = require('openvibe-shared/glyphs');++const kits = require('../server/glyphs/kits');++const ex = require('../server/glyphs/export');++++/** The entries of a ZIP the export writer made: { name: Buffer }. */++function unzip(buf) {++ const out = {};++ const end = buf.length - 22;++ let at = buf.readUInt32LE(end + 16);++ const n = buf.readUInt16LE(end + 10);++ for (let i = 0; i < n; i++) {++ const nameLen = buf.readUInt16LE(at + 28), size = buf.readUInt32LE(at + 20), local = buf.readUInt32LE(at + 42);++ const name = buf.subarray(at + 46, at + 46 + nameLen).toString();++ const lnl = buf.readUInt16LE(local + 26);++ out[name] = zlib.inflateRawSync(buf.subarray(local + 30 + lnl, local + 30 + lnl + size));++ at += 46 + nameLen;++ }++ return out;++}++const form = (fields) => ({ method: 'POST', body: fields, headers: { 'content-type': 'application/x-www-form-urlencoded' }, origin: 'https://openvibe.design' });++++(async () => {++ await check('a choice: known glyphs, Font Awesome names picked, unknowns dropped, a tidy name', async () => {++ const c = kits.parse({ names: 'eye, fa-video, nope, eye, live', name: 'My Site!', fa: '1' });++ assert.deepEqual(c, { names: ['eye', 'video', 'live'], slug: 'my-site', fa: true });++ assert.throws(() => kits.parse({ names: 'nope' }), /pick at least one glyph/);++ });++++ const t = await boot();++ try {++ await check('the page: every glyph a checkbox, picks from the link ticked, a download bar', async () => {++ const res = await t.get('/icons/kit?names=eye,live');++ assert.equal(res.status, 200);++ const html = res.text;++ assert.ok(html.includes('<h1 class="ic-h1">Only the icons you use.</h1>'));++ assert.equal((html.match(/<input type="checkbox" name="names" value=/g) || []).length, glyphs.NAMES.length);++ assert.ok(html.includes('value="eye" checked') && html.includes('value="live" checked') && !html.includes('value="heart" checked'));++ assert.ok(html.includes('method="post" action="/icons/kit"') && html.includes('name="fa" value="1" checked'));++ assert.ok(html.includes('/js/kit.js'));++ });++++ await check('the form gives a ZIP: a small WOFF2 and TTF, the CSS with Font Awesome names, the characters', async () => {++ const res = await t.get('/icons/kit', form('names=eye&names=live&names=heart&name=Shop&fa=1'));++ assert.equal(res.status, 200);++ assert.equal(res.headers.get('content-type'), 'application/zip');++ assert.match(res.headers.get('content-disposition'), /attachment; filename="ov-glyphs-kit-shop\.zip"/);++ const files = unzip(res.buffer);++ assert.deepEqual(Object.keys(files).sort(), ['shop/README.txt', 'shop/shop.css', 'shop/shop.json', 'shop/shop.ttf', 'shop/shop.woff2']);++ assert.equal(files['shop/shop.woff2'].subarray(0, 4).toString('latin1'), 'wOF2');++ assert.ok(files['shop/shop.woff2'].length < 2048, `three glyphs are small (${files['shop/shop.woff2'].length} bytes)`);++ assert.equal(files['shop/shop.ttf'].readUInt32BE(0), 0x00010000);++ const css = files['shop/shop.css'].toString();++ assert.ok(css.includes("@font-face{font-family:'OV Glyphs shop';src:url(shop.woff2) format('woff2'),url(shop.ttf) format('truetype')"));++ assert.ok(css.includes(`.ovi-eye::before{content:"\\${ex.codepointOf('eye').toLowerCase()}"}`));++ assert.ok(css.includes(`.fa-eye::before{content:"\\${ex.codepointOf('eye').toLowerCase()}"}`), "Font Awesome's names, asked for");++ assert.ok(!css.includes('.ovi-video'), 'only what was picked');++ assert.deepEqual(Object.keys(JSON.parse(files['shop/shop.json']).glyphs), ['eye', 'live', 'heart']);++ });++++ await check('without Font Awesome\'s names, the CSS has only ovi classes', async () => {++ const files = unzip((await t.get('/icons/kit', form('names=eye&name=plain'))).buffer);++ assert.ok(!files['plain/plain.css'].toString().includes('.fa-eye'));++ });++++ await check('the same kit from a link, cacheable; nothing picked is a plain 422', async () => {++ const res = await t.get('/icons/kit.zip?names=eye,fa-video&name=link');++ assert.equal(res.status, 200);++ assert.equal(res.headers.get('cache-control'), 'public, max-age=86400');++ assert.ok(Object.keys(unzip(res.buffer)).includes('link/link.woff2'));++ assert.equal((await t.get('/icons/kit.zip?names=nope')).status, 422);++ const empty = await t.get('/icons/kit', form('name=empty'));++ assert.equal(empty.status, 422);++ assert.ok(empty.text.includes('pick at least one glyph'));++ });++++ await check('glyph pages, /icons and the Font Awesome page lead to kits', async () => {++ assert.ok((await t.get('/icons/eye')).text.includes('href="/icons/kit?names=eye"'));++ assert.ok((await t.get('/icons')).text.includes('href="/icons/kit"'));++ assert.ok((await t.get('/icons/font-awesome-alternative')).text.includes('<a href="/icons/kit">'));++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -18,7 +18,8 @@
@@ -18,7 +18,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
- "pg": "^8.23.1"
+ "pg": "^8.23.1",
+ "subset-font": "^2.9.0"
},
},
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.13"
"@electric-sql/pglite": "^0.5.13"
@@ -313,6 +314,12 @@
@@ -313,6 +314,12 @@
}
}
}
}
},
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/array-flatten": {
"node_modules/array-flatten": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -785,6 +792,16 @@
@@ -785,6 +792,16 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/fontverter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz",
+ "integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "wawoff2": "^2.0.0",
+ "woff2sfnt-sfnt2woff": "^1.0.0"
+ }
+ },
"node_modules/forwarded": {
"node_modules/forwarded": {
"version": "0.2.0",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -861,6 +878,12 @@
@@ -861,6 +878,12 @@
"url": "https://github.com/sponsors/ljharb"
"url": "https://github.com/sponsors/ljharb"
}
}
},
},
+ "node_modules/harfbuzzjs": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-1.6.4.tgz",
+ "integrity": "sha512-T/PyfhVt1gGR2Tkf5KJUj1DQFkVBXHw552lS1iLEEDhV1Lukb3eq1pk2ov85kgbT2YUiZtJBTak9w+M7YgzUEQ==",
+ "license": "MIT"
+ },
"node_modules/has-symbols": {
"node_modules/has-symbols": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1354,6 +1377,27 @@
@@ -1354,6 +1377,27 @@
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
}
}
},
},
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parseurl": {
"node_modules/parseurl": {
"version": "1.3.3",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1742,6 +1786,17 @@
@@ -1742,6 +1786,17 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/subset-font": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/subset-font/-/subset-font-2.9.0.tgz",
+ "integrity": "sha512-WJulQeFwbewrXIGcLJbSZFWI44fHW8gNRuPzLywfDL8w38IPRbN1JFxwG76aoohw9kcFdvjVIn2QmID1iy119A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "fontverter": "^2.0.0",
+ "harfbuzzjs": "^1.6.1",
+ "p-limit": "^3.1.0"
+ }
+ },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1797,6 +1852,28 @@
@@ -1797,6 +1852,28 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/wawoff2": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz",
+ "integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "woff2_compress.js": "bin/woff2_compress.js",
+ "woff2_decompress.js": "bin/woff2_decompress.js"
+ }
+ },
+ "node_modules/woff2sfnt-sfnt2woff": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz",
+ "integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.7"
+ }
+ },
"node_modules/xtend": {
"node_modules/xtend": {
"version": "4.0.2",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -1805,6 +1882,18 @@
@@ -1805,6 +1882,18 @@
"engines": {
"engines": {
"node": ">=0.4"
"node": ">=0.4"
}
}
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
}
}
}
}
}
}
@@ -24,7 +24,8 @@
@@ -24,7 +24,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
- "pg": "^8.23.1"
+ "pg": "^8.23.1",
+ "subset-font": "^2.9.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -166,3 +166,27 @@
@@ -166,3 +166,27 @@
}
}
@media (max-width:420px){.gp-controls{grid-template-columns:minmax(0,1fr)}}
@media (max-width:420px){.gp-controls{grid-template-columns:minmax(0,1fr)}}
@media (prefers-reduced-motion:reduce){.ic-tile,.ic-packs a{transition:none}.ic-tile:hover{transform:none}}
@media (prefers-reduced-motion:reduce){.ic-tile,.ic-packs a{transition:none}.ic-tile:hover{transform:none}}
+
+/* Make a kit: tick glyphs; the bar holds the name and the download and stays in reach at the bottom. */
+.kit-form{display:grid;gap:12px}
+.kit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px}
+.kit-tile{position:relative;display:grid;justify-items:center;align-content:center;gap:10px;min-height:96px;padding:14px 6px 10px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);cursor:pointer;transition:border-color .15s,background .15s}
+.kit-tile input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}
+.kit-tile svg{width:24px;height:24px}
+.kit-tile span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:12px}
+.kit-tile:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
+.kit-tile:has(input:checked){border-color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--bg-secondary))}
+.kit-tile:has(input:checked) span{color:var(--text-primary)}
+.kit-tile:has(input:checked)::after{content:"";position:absolute;top:8px;right:8px;width:16px;height:16px;border-radius:50%;background:var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5'/%3E%3C/svg%3E") center/11px no-repeat}
+.kit-tile:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px}
+.kit-opts{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 16px}
+.kit-bar{position:sticky;bottom:calc(var(--ovnav-appbar-h,0px) + max(12px,env(safe-area-inset-bottom)));z-index:5;display:flex;align-items:center;gap:12px;padding:8px 8px 8px 16px;border-radius:999px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-secondary) 92%,transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);box-shadow:0 10px 30px rgba(0,0,0,.25)}
+.kit-sum{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;color:var(--text-secondary)}
+.kit-name{display:grid;gap:4px;flex:1 1 200px;max-width:360px;font-size:12.5px;font-weight:700;color:var(--text-secondary)}
+.kit-name input{min-height:42px;box-sizing:border-box;padding:0 12px;border-radius:10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:15px;font-weight:500}
+.kit-fa{display:flex;align-items:center;gap:8px;min-height:42px;font-size:14px;color:var(--text-secondary);cursor:pointer}
+.kit-fa input{width:18px;height:18px;accent-color:var(--accent)}
+.kit-bar .ic-btn{flex:none}
+.kit-bar .ic-btn:disabled{opacity:.5;cursor:default}
+@media (max-width:760px){.kit-grid{grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}}
+@media (prefers-reduced-motion:reduce){.kit-tile{transition:none}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+/**
+ * /icons/kit (server/views/icon-page.js kitPage): find glyphs as you type, say what is picked beside the download
+ * button, and clear the picks. Without this script the page is the same form: tick, name, download.
+ */
+(function () {
+ 'use strict';
+ const form = document.querySelector('[data-kit-form]');
+ if (!form) return;
+ const tools = form.querySelector('[data-kit-tools]');
+ if (tools) tools.hidden = false;
+ const sum = form.querySelector('[data-kit-sum]');
+ const go = form.querySelector('[data-kit-go]');
+ const boxes = () => form.querySelectorAll('input[name="names"]');
+ function update() {
+ const picked = [...boxes()].filter((b) => b.checked).map((b) => b.value);
+ sum.textContent = picked.length ? `${picked.length} picked: ${picked.slice(0, 4).join(', ')}${picked.length > 4 ? '…' : ''}` : 'Tick the icons you use';
+ go.disabled = picked.length === 0;
+ }
+ form.addEventListener('change', (e) => { if (e.target && e.target.name === 'names') update(); });
+ const find = form.querySelector('[data-kit-find]');
+ if (find) find.addEventListener('input', () => {
+ const q = find.value.trim().toLowerCase().replace(/^fa-/, '');
+ form.querySelectorAll('.kit-tile').forEach((t) => {
+ t.hidden = !!q && !t.dataset.name.includes(q) && !(t.dataset.fa || '').split(' ').some((f) => f.includes(q));
+ });
+ });
+ // Enter in the search box finds; it never downloads.
+ if (find) find.addEventListener('keydown', (e) => { if (e.key === 'Enter') e.preventDefault(); });
+ const clear = form.querySelector('[data-kit-clear]');
+ if (clear) clear.addEventListener('click', () => { boxes().forEach((b) => { b.checked = false; }); update(); });
+ update();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * Icon kits (plan L32 I5): a font with only the glyphs a site uses, the way Font Awesome's paid Kits subset theirs.
+ * The person picks glyphs on /icons/kit; the kit is cut from openvibe-shared's ov-glyphs.ttf (harfbuzz, through
+ * subset-font) and handed back as one ZIP: WOFF2 for the web, TTF for apps, the CSS (Font Awesome's class names too,
+ * if asked), the code points and a short README. Three glyphs weigh under a kilobyte. Nothing is stored: the same
+ * choices give the same files.
+ *
+ * parse({ names, name, fa }) → { names: [...known, deduped], slug, fa } or throws { status: 422 }
+ * build(choice) → Promise<{ zip: Buffer, files: { name: bytes } }>
+ */
+const fs = require('fs');
+const path = require('path');
+const glyphs = require('openvibe-shared/glyphs');
+const ex = require('./export');
+
+const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));
+const MAX = 200;
+let ttf = null;
+let subsetFont = null;
+
+function parse({ names, name, fa } = {}) {
+ const list = (Array.isArray(names) ? names : String(names || '').split(/[\s,]+/))
+ .map((n) => String(n).trim().toLowerCase().replace(/^fa-/, '')).filter(Boolean)
+ .map((n) => (glyphs.path(n) ? n : glyphs.fromFa(n))) // a Font Awesome name picks its glyph
+ .filter(Boolean);
+ const unique = [...new Set(list)].slice(0, MAX);
+ if (!unique.length) throw Object.assign(new Error('pick at least one glyph'), { status: 422 });
+ const slug = String(name || 'my-kit').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'my-kit';
+ return { names: unique, slug, fa: fa === true || fa === '1' || fa === 'on' };
+}
+
+const faOf = (g) => Object.entries(glyphs.FA).filter(([, v]) => v === g).map(([k]) => k).sort();
+
+async function build({ names, slug, fa }) {
+ if (!ttf) ttf = fs.readFileSync(path.join(SHARED, 'glyph-fonts', 'ov-glyphs.ttf'));
+ if (!subsetFont) subsetFont = require('subset-font');
+ const cps = names.map((n) => [n, ex.codepointOf(n)]).filter(([, cp]) => cp);
+ const text = cps.map(([, cp]) => String.fromCodePoint(parseInt(cp, 16))).join('');
+ const [woff2, truetype] = await Promise.all([
+ subsetFont(ttf, text, { targetFormat: 'woff2' }),
+ subsetFont(ttf, text, { targetFormat: 'truetype' }),
+ ]);
+ const family = `OV Glyphs ${slug}`;
+ const base = "{display:inline-block;font-family:'" + family + "';font-style:normal;font-weight:400;font-variant:normal;line-height:1;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;speak:never}";
+ const rule = (sel, cp) => `${sel}::before{content:"\\${cp.toLowerCase()}"}`;
+ const css = [
+ `/* ${family}: ${cps.length} OpenVibe glyphs (MIT), made on https://openvibe.design/icons/kit */`,
+ `@font-face{font-family:'${family}';src:url(${slug}.woff2) format('woff2'),url(${slug}.ttf) format('truetype');font-display:block}`,
+ `.ovi${base}`,
+ '.ovi-fw{width:1.25em;text-align:center}',
+ ...cps.map(([n, cp]) => rule(`.ovi-${n}`, cp)),
+ ...(fa ? [
+ '/* Font Awesome\'s class names, for these glyphs only */',
+ `.fa,.fas,.far,.fa-solid,.fa-regular${base}`,
+ ...cps.flatMap(([n, cp]) => faOf(n).map((f) => rule(`.fa-${f}`, cp))),
+ ] : []),
+ ].join('\n') + '\n';
+ const json = JSON.stringify({ family, glyphs: Object.fromEntries(cps), font_awesome: fa ? Object.fromEntries(cps.map(([n]) => [n, faOf(n)])) : undefined }, null, 2) + '\n';
+ const readme = `${family}\n\n${cps.length} glyphs from the OpenVibe glyphs, MIT licence: ${cps.map(([n]) => n).join(', ')}.\n\nOn a page:\n <link rel="stylesheet" href="${slug}.css">\n <i class="ovi ovi-${cps[0][0]}"></i>${fa ? `\n <i class="fa-solid fa-${(faOf(cps[0][0])[0] || cps[0][0])}"></i> (Font Awesome's names work too)` : ''}\n\nKeep ${slug}.css, ${slug}.woff2 and ${slug}.ttf together. In an app, install ${slug}.ttf and type a glyph's character (${slug}.json lists them).\nMake another: https://openvibe.design/icons/kit\n`;
+ const files = { [`${slug}.woff2`]: woff2, [`${slug}.ttf`]: truetype, [`${slug}.css`]: css, [`${slug}.json`]: json, 'README.txt': readme };
+ const zip = ex.zipOf(Object.entries(files).map(([name, data]) => ({ name: `${slug}/${name}`, data })));
+ return { zip, files };
+}
+
+module.exports = { parse, build, MAX };
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
* 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
+ * GET /icons/kit, POST /icons/kit pick glyphs, get a font with only those (WOFF2, TTF, CSS, code points) as a ZIP
+ * 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
@@ -21,7 +23,8 @@ const { asyncRouter } = require('./router');
@@ -21,7 +23,8 @@ const { asyncRouter } = require('./router');
const { send, baseUrl } = require('../render/layout');
const { send, baseUrl } = require('../render/layout');
const { writeAllowed } = require('./same-origin');
const { writeAllowed } = require('./same-origin');
const { iconsPage, glyphsJson } = require('../views/icons');
const { iconsPage, glyphsJson } = require('../views/icons');
-const { glyphPage, faAlternativePage } = require('../views/icon-page');
+const { glyphPage, faAlternativePage, kitPage } = require('../views/icon-page');
+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');
@@ -49,6 +52,24 @@ function createIconRoutes(ctx) {
@@ -49,6 +52,24 @@ function createIconRoutes(ctx) {
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
+ // Kits: a font with only the glyphs a site uses (L32 I5). The form posts the picks; the link form is for a build step.
+ const sendKit = async (req, res, choice) => {
+ let parsed;
+ try { parsed = kits.parse(choice); } catch (err) {
+ if (req.method === 'POST') return send(res, 422, kitPage({ picked: [], name: choice.name, fa: choice.fa != null, error: err.message }), { viewer: req.viewer });
+ return res.status(422).type('text/plain').send(`${err.message}\n`);
+ }
+ const out = await kits.build(parsed);
+ res.set('Cache-Control', req.method === 'GET' ? 'public, max-age=86400' : 'no-store').set('Access-Control-Allow-Origin', '*')
+ .type('application/zip').attachment(`ov-glyphs-kit-${parsed.slug}.zip`).send(out.zip);
+ };
+ r.get('/icons/kit', (req, res) => {
+ const picked = String(req.query.names || '').split(',').map((n) => n.trim()).filter((n) => glyphs.path(n));
+ send(res, 200, kitPage({ picked, name: String(req.query.name || '').slice(0, 40) }), { viewer: req.viewer });
+ });
+ r.post('/icons/kit', form, (req, res) => sendKit(req, res, { names: (req.body || {}).names, name: (req.body || {}).name, fa: (req.body || {}).fa }));
+ r.get('/icons/kit.zip', (req, res) => sendKit(req, res, { names: req.query.names, name: req.query.name, fa: req.query.fa }));
+
r.get('/icons/pack/:file', (req, res, next) => {
r.get('/icons/pack/:file', (req, res, next) => {
const file = req.params.file;
const file = req.params.file;
res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');
@@ -59,6 +59,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -59,6 +59,7 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
<a class="ic-btn is-go" href="${svgUrl}" download="${esc(name)}.svg">${kit.icon('download')} SVG</a>
<a class="ic-btn is-go" href="${svgUrl}" download="${esc(name)}.svg">${kit.icon('download')} SVG</a>
<a class="ic-btn" href="#png">${kit.icon('image')} Make a PNG</a>
<a class="ic-btn" href="#png">${kit.icon('image')} Make a PNG</a>
<button type="button" class="ic-btn" data-gp-copy="${svgUrl}" hidden>Copy the SVG</button>
<button type="button" class="ic-btn" data-gp-copy="${svgUrl}" hidden>Copy the SVG</button>
+ <a class="ic-btn" href="/icons/kit?names=${encodeURIComponent(name)}">Put it in a kit</a>
</div>
</div>
</div>
</div>
</header>
</header>
@@ -142,7 +143,7 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {
@@ -142,7 +143,7 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {
['Power Transforms', '<code>data-fa-transform</code>: grow, shrink, up, down, left, right, rotate, flip', 'yes'],
['Power Transforms', '<code>data-fa-transform</code>: grow, shrink, up, down, left, right, rotate, flip', 'yes'],
['Web font', '<code>ov-glyphs.woff2</code>, with Font Awesome’s class names on it (<code>ov-glyphs-fa.css</code>)', 'yes'],
['Web font', '<code>ov-glyphs.woff2</code>, with Font Awesome’s class names on it (<code>ov-glyphs-fa.css</code>)', 'yes'],
['Desktop fonts', 'TTF and OTF, free', 'yes'],
['Desktop fonts', 'TTF and OTF, free', 'yes'],
- ['Subset fonts', 'Coming: pick your icons, get a font with only those', 'soon'],
+ ['Subset fonts (kits)', '<a href="/icons/kit">Pick your icons, get a font with only those</a>: WOFF2, TTF and the CSS', 'yes'],
['Brand logos', 'Not ours to draw: keep Font Awesome Brands for those', 'no'],
['Brand logos', 'Not ours to draw: keep Font Awesome Brands for those', 'no'],
['Price', 'Free, MIT, for everything above', 'yes'],
['Price', 'Free, MIT, for everything above', 'yes'],
];
];
@@ -188,4 +189,32 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {
@@ -188,4 +189,32 @@ function faAlternativePage({ base = 'https://openvibe.design' } = {}) {
});
});
}
}
-module.exports = { glyphPage, faAlternativePage, related, faOf, title };
+/** /icons/kit: pick glyphs, name the kit, get a font with only those (server/glyphs/kits.js). A plain form. */
+function kitPage({ picked = [], name = '', fa = true, error = '' } = {}) {
+ const on = new Set(picked);
+ const tiles = glyphs.NAMES.map((n) => `<label class="kit-tile" data-name="${esc(n)}" data-fa="${esc(faOf(n).join(' '))}"><input type="checkbox" name="names" value="${esc(n)}"${on.has(n) ? ' checked' : ''}>${glyph(n)}<span>${esc(n)}</span></label>`).join('');
+ const body = `<article class="ic kit">
+<nav class="gp-crumbs" aria-label="Breadcrumb"><a href="/icons">Icons</a><span aria-hidden="true">/</span><span>Make a kit</span></nav>
+<header class="ic-hero"><p class="ic-eye">Icon kits</p><h1 class="ic-h1">Only the icons you use.</h1>
+<p class="ic-lede">Pick them, name the kit, and get a font with just those: three icons weigh under a kilobyte. WOFF2 for the web, TTF for apps, the CSS (with Font Awesome’s class names if you like) and each icon’s character, in one ZIP. Free, MIT, nothing to sign up for.</p></header>
+${error ? kit.notice({ kind: 'error', text: error }) : ''}
+<form class="kit-form" method="post" action="/icons/kit" data-kit-form>
+ <div class="kit-opts">
+ <label class="kit-name"><span>Kit name</span><input name="name" value="${esc(name || 'my-kit')}" maxlength="40" pattern="[A-Za-z0-9 _-]{1,40}" autocomplete="off" spellcheck="false"></label>
+ <label class="kit-fa"><input type="checkbox" name="fa" value="1"${fa ? ' checked' : ''}><span>Font Awesome’s class names too</span></label>
+ </div>
+ <div class="ic-tools" data-kit-tools hidden><label class="ic-find">${kit.icon('search')}<span class="ovk-sr">Find a glyph</span><input type="search" placeholder="Find: a name, or a Font Awesome name" autocomplete="off" data-kit-find></label><button type="button" class="ic-btn" data-kit-clear>Clear</button></div>
+ <div class="kit-grid">${tiles}</div>
+ <div class="kit-bar"><span class="kit-sum" data-kit-sum>${on.size ? `${on.size} picked` : 'Tick the icons you use'}</span><button type="submit" class="ic-btn is-go" data-kit-go>${kit.icon('download')} Download the kit</button></div>
+</form>
+<section class="ic-sec" aria-labelledby="kit-api"><div class="ic-sec-head"><h2 id="kit-api">As a link</h2><p>The same kit from a URL, for a build step or an agent: the names, the kit’s name and whether to include Font Awesome’s classes.</p></div>
+${kit.snippet({ code: 'https://openvibe.design/icons/kit.zip?names=eye,live,heart&name=my-kit&fa=1', id: 'kit-url', label: 'Copy' })}</section>
+</article>`;
+ return layout({
+ title: 'Make an icon kit: a font with only your icons',
+ description: 'Pick the OpenVibe glyphs your site uses and get a font with only those: WOFF2, TTF, CSS with Font Awesome class names, free and MIT.',
+ path: '/icons/kit', active: '/icons', styles: ['css/icons.css'], scripts: ['js/kit.js'], body,
+ });
+}
+
+module.exports = { glyphPage, faAlternativePage, kitPage, related, faOf, title };
@@ -93,6 +93,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -93,6 +93,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
];
];
const get = `<section class="ic-sec" id="get" aria-labelledby="ic-get">
const get = `<section class="ic-sec" id="get" aria-labelledby="ic-get">
<div class="ic-sec-head"><h2 id="ic-get">Get them all</h2><p>Free, MIT. The fonts carry every glyph at its own character (U+E001 up, never reused), so a document made with them keeps working. Each glyph’s page makes a PNG at any size and colour.</p></div>
<div class="ic-sec-head"><h2 id="ic-get">Get them all</h2><p>Free, MIT. The fonts carry every glyph at its own character (U+E001 up, never reused), so a document made with them keeps working. Each glyph’s page makes a PNG at any size and colour.</p></div>
+ <p class="ic-more"><a href="/icons/kit"><b>Make a kit</b></a>: a font with only the icons your site uses, from a few hundred bytes.</p>
<ul class="ic-packs">${packs.map(([f, label, about]) => `<li><a href="/icons/pack/${esc(f)}" download><b>${esc(label)}</b><small>${esc(about)}</small><code>${esc(f)}</code></a></li>`).join('')}</ul>
<ul class="ic-packs">${packs.map(([f, label, about]) => `<li><a href="/icons/pack/${esc(f)}" download><b>${esc(label)}</b><small>${esc(about)}</small><code>${esc(f)}</code></a></li>`).join('')}</ul>
</section>`;
</section>`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+// Icon kits (plan L32 I5, 2026-10-31): pick glyphs on /icons/kit and get a font with only those, as a ZIP with WOFF2,
+// TTF, the CSS (Font Awesome's class names when asked), the code points and a README. The same kit comes from a link.
+// Unknown names are dropped, Font Awesome names pick their glyph, and a kit with nothing in it says so.
+const assert = require('node:assert/strict');
+const zlib = require('zlib');
+const { boot, check, done } = require('./helpers/boot');
+const glyphs = require('openvibe-shared/glyphs');
+const kits = require('../server/glyphs/kits');
+const ex = require('../server/glyphs/export');
+
+/** The entries of a ZIP the export writer made: { name: Buffer }. */
+function unzip(buf) {
+ const out = {};
+ const end = buf.length - 22;
+ let at = buf.readUInt32LE(end + 16);
+ const n = buf.readUInt16LE(end + 10);
+ for (let i = 0; i < n; i++) {
+ const nameLen = buf.readUInt16LE(at + 28), size = buf.readUInt32LE(at + 20), local = buf.readUInt32LE(at + 42);
+ const name = buf.subarray(at + 46, at + 46 + nameLen).toString();
+ const lnl = buf.readUInt16LE(local + 26);
+ out[name] = zlib.inflateRawSync(buf.subarray(local + 30 + lnl, local + 30 + lnl + size));
+ at += 46 + nameLen;
+ }
+ return out;
+}
+const form = (fields) => ({ method: 'POST', body: fields, headers: { 'content-type': 'application/x-www-form-urlencoded' }, origin: 'https://openvibe.design' });
+
+(async () => {
+ await check('a choice: known glyphs, Font Awesome names picked, unknowns dropped, a tidy name', async () => {
+ const c = kits.parse({ names: 'eye, fa-video, nope, eye, live', name: 'My Site!', fa: '1' });
+ assert.deepEqual(c, { names: ['eye', 'video', 'live'], slug: 'my-site', fa: true });
+ assert.throws(() => kits.parse({ names: 'nope' }), /pick at least one glyph/);
+ });
+
+ const t = await boot();
+ try {
+ await check('the page: every glyph a checkbox, picks from the link ticked, a download bar', async () => {
+ const res = await t.get('/icons/kit?names=eye,live');
+ assert.equal(res.status, 200);
+ const html = res.text;
+ assert.ok(html.includes('<h1 class="ic-h1">Only the icons you use.</h1>'));
+ assert.equal((html.match(/<input type="checkbox" name="names" value=/g) || []).length, glyphs.NAMES.length);
+ assert.ok(html.includes('value="eye" checked') && html.includes('value="live" checked') && !html.includes('value="heart" checked'));
+ assert.ok(html.includes('method="post" action="/icons/kit"') && html.includes('name="fa" value="1" checked'));
+ assert.ok(html.includes('/js/kit.js'));
+ });
+
+ await check('the form gives a ZIP: a small WOFF2 and TTF, the CSS with Font Awesome names, the characters', async () => {
+ const res = await t.get('/icons/kit', form('names=eye&names=live&names=heart&name=Shop&fa=1'));
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get('content-type'), 'application/zip');
+ assert.match(res.headers.get('content-disposition'), /attachment; filename="ov-glyphs-kit-shop\.zip"/);
+ const files = unzip(res.buffer);
+ assert.deepEqual(Object.keys(files).sort(), ['shop/README.txt', 'shop/shop.css', 'shop/shop.json', 'shop/shop.ttf', 'shop/shop.woff2']);
+ assert.equal(files['shop/shop.woff2'].subarray(0, 4).toString('latin1'), 'wOF2');
+ assert.ok(files['shop/shop.woff2'].length < 2048, `three glyphs are small (${files['shop/shop.woff2'].length} bytes)`);
+ assert.equal(files['shop/shop.ttf'].readUInt32BE(0), 0x00010000);
+ const css = files['shop/shop.css'].toString();
+ assert.ok(css.includes("@font-face{font-family:'OV Glyphs shop';src:url(shop.woff2) format('woff2'),url(shop.ttf) format('truetype')"));
+ assert.ok(css.includes(`.ovi-eye::before{content:"\\${ex.codepointOf('eye').toLowerCase()}"}`));
+ assert.ok(css.includes(`.fa-eye::before{content:"\\${ex.codepointOf('eye').toLowerCase()}"}`), "Font Awesome's names, asked for");
+ assert.ok(!css.includes('.ovi-video'), 'only what was picked');
+ assert.deepEqual(Object.keys(JSON.parse(files['shop/shop.json']).glyphs), ['eye', 'live', 'heart']);
+ });
+
+ await check('without Font Awesome\'s names, the CSS has only ovi classes', async () => {
+ const files = unzip((await t.get('/icons/kit', form('names=eye&name=plain'))).buffer);
+ assert.ok(!files['plain/plain.css'].toString().includes('.fa-eye'));
+ });
+
+ await check('the same kit from a link, cacheable; nothing picked is a plain 422', async () => {
+ const res = await t.get('/icons/kit.zip?names=eye,fa-video&name=link');
+ assert.equal(res.status, 200);
+ assert.equal(res.headers.get('cache-control'), 'public, max-age=86400');
+ assert.ok(Object.keys(unzip(res.buffer)).includes('link/link.woff2'));
+ assert.equal((await t.get('/icons/kit.zip?names=nope')).status, 422);
+ const empty = await t.get('/icons/kit', form('name=empty'));
+ assert.equal(empty.status, 422);
+ assert.ok(empty.text.includes('pick at least one glyph'));
+ });
+
+ await check('glyph pages, /icons and the Font Awesome page lead to kits', async () => {
+ assert.ok((await t.get('/icons/eye')).text.includes('href="/icons/kit?names=eye"'));
+ assert.ok((await t.get('/icons')).text.includes('href="/icons/kit"'));
+ assert.ok((await t.get('/icons/font-awesome-alternative')).text.includes('<a href="/icons/kit">'));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });