openvibers/openvibe.design Commits

Icon kits: a font with only the icons you use (#46)

OP OpenVibers committed 13 days ago · acf58262d0 · 9 files, +360 −5

/icons/kit: tick glyphs, name the kit, and get one ZIP with a subset WOFF2 and
TTF (subset-font, harfbuzz), the CSS (ovi classes, and Font Awesome's names if
asked), the code points and a README. Three glyphs are under a kilobyte. The
same kit from a link: /icons/kit.zip?names=&name=&fa=1. A plain form; kit.js
adds find, Clear and a slim bar that says what is picked. Glyph pages offer
'Put it in a kit'; the Font Awesome page's subset row is now yes.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 74506a1..8644b7a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex b9d6d2e..f989343 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex 2a1aa18..aeb2f75 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -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}}
ddiff --git a/public/js/kit.js b/public/js/kit.js
nnew file mode 100644
iindex 0000000..51391a1
---- /dev/null
++++ b/public/js/kit.js
@@@ -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();
++})();
ddiff --git a/server/glyphs/kits.js b/server/glyphs/kits.js
nnew file mode 100644
iindex 0000000..6761a0e
---- /dev/null
++++ b/server/glyphs/kits.js
@@@ -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 };
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex 95c3b40..120f299 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -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', '*');
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
iindex 7e07482..1b12806 100644
---- a/server/views/icon-page.js
++++ b/server/views/icon-page.js
@@@ -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&rsquo;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&rsquo;s class names if you like) and each icon&rsquo;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&rsquo;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&rsquo;s name and whether to include Font Awesome&rsquo;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 };
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex 50ce333..4fbac84 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -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&rsquo;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>`;
ddiff --git a/test/icon-kits.test.js b/test/icon-kits.test.js
nnew file mode 100644
iindex 0000000..04594d6
---- /dev/null
++++ b/test/icon-kits.test.js
@@@ -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); });