openvibers/openvibe.design Commits

/icons: the OpenVibe glyphs, what is still Font Awesome, and Draw an icon with OpenVibe.AI (#43)

OP OpenVibers committed 13 days ago · 7d4e8f7184 · 18 files, +2426 −11

* /icons: the OpenVibe glyphs, what is still Font Awesome, and Draw an icon with OpenVibe.AI (L32 I3)

Every glyph in openvibe-shared 4.61.0 on one page, searchable by its name or any
Font Awesome name it stands for, at 16/20/24/32 px, each one large with Copy the
SVG and Download. Each glyph is a standalone SVG at /icons/<name>.svg and the
registry is data at /api/v1/glyphs.

Still Font Awesome lists the names the network's pages ask for that no glyph
covers yet (counted 2026-10-31), most used first, each linking to Draw an icon.

Draw an icon: a signed-in person names one and says what it is for; OpenVibe.AI
draws four candidates; each is parsed and rebuilt by server/glyphs/validate.js
(strokes only, numbers on the 24-unit grid, at most 8 shapes) before it is
kept in glyph_drafts (migration 0004, expand), at most 12 a day a person. Only
an admin approves or rejects; approved drafts are public at
/api/v1/glyphs/drafts for openvibe-shared to take in.

openvibe-shared v4.61.0 (was v4.59.0).

* Icon drafts in account export and deletion (ADR-033); component counts follow openvibe-shared (4.60.0 added the sidebar)

Changes

ddiff --git a/migrations/0004_glyph_drafts.sql b/migrations/0004_glyph_drafts.sql
nnew file mode 100644
iindex 0000000..cef49c6
---- /dev/null
++++ b/migrations/0004_glyph_drafts.sql
@@@ -0,0 +1,20 @@
++-- phase: expand
++-- Glyph drafts (plan L32 I3): a person names an icon, OpenVibe.AI draws candidates in the OpenVibe hand, each is
++-- checked and rebuilt (server/glyphs/validate.js) before it is kept here, and an admin approves or rejects it.
++-- Approved drafts are what openvibe-shared takes into its glyph registry (GET /api/v1/glyphs/drafts?status=approved).
++
++CREATE TABLE glyph_drafts (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ name text COLLATE "C" NOT NULL,
++ sentence text NOT NULL DEFAULT '',
++ svg text NOT NULL,
++ provider text COLLATE "C",
++ model text COLLATE "C",
++ status text COLLATE "C" NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'rejected')),
++ reviewed_by text COLLATE "C",
++ created_at timestamptz NOT NULL DEFAULT now(),
++ reviewed_at timestamptz
++);
++CREATE INDEX glyph_drafts_status ON glyph_drafts (status, created_at DESC);
++CREATE INDEX glyph_drafts_subject ON glyph_drafts (subject, created_at DESC);
ddiff --git a/package-lock.json b/package-lock.json
iindex 3b36fb8..ca65bbd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.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-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
"pg": "^8.23.1"
},
"devDependencies": {
@@@ -1114,9 +1114,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.59.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
-- "integrity": "sha512-kaJ6Tcz7WCewK/kCx4NzNWRNI4U1Z1lpwCQHWD115YtcaJps8Ulqpu1y2ArLpywARHtYf3ddPH/hL9wkb6MJlw==",
++ "version": "4.61.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
++ "integrity": "sha512-VrDzJxlGvWZEH8O4TrhTli5tv9O9aaT7zllc5o88lSRizTjG2gVNDxDxOjRn38jkb6pkYSRCG9CZ+hFtH6eY3g==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 260dd56..7de9669 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"helmet": "^8.1.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-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
"pg": "^8.23.1"
},
"engines": {
ddiff --git a/public/css/icons.css b/public/css/icons.css
nnew file mode 100644
iindex 0000000..6523c10
---- /dev/null
++++ b/public/css/icons.css
@@@ -0,0 +1,96 @@
++/* /icons: the OpenVibe glyphs (server/views/icons.js). Theme variables from the Frame; names in the brand face. */
++.ic{--ic-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;max-width:1120px;margin:0 auto;padding:clamp(18px,4vw,44px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
++.ic svg{display:block;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;overflow:visible}
++
++.ic-hero{max-width:760px;padding-bottom:clamp(18px,3vw,28px)}
++.ic-eye{margin:0 0 10px;color:var(--accent);font-size:12px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}
++.ic-h1{margin:0;font-family:var(--ic-face);font-weight:900;font-size:clamp(34px,5.5vw,58px);line-height:1.04;letter-spacing:-.02em;text-wrap:balance}
++.ic-lede{margin:14px 0 0;max-width:62ch;color:var(--text-secondary);font-size:clamp(15px,1.6vw,17px);line-height:1.6}
++.ic-acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:20px}
++
++.ic .ic-sec{margin:0;padding:clamp(22px,3.5vw,38px) 0;border-top:1px solid var(--border)}
++.ic-hero+.ic-sec{border-top:0;padding-top:4px}
++.ic h2.ovk-sr{margin:0}
++.ic-sec-head{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:8px 32px;align-items:baseline;margin-bottom:18px}
++.ic-sec-head h2{margin:0;font-family:var(--ic-face);font-weight:900;font-size:clamp(22px,3vw,30px);line-height:1.15}
++.ic-sec-head p{margin:0;max-width:62ch;color:var(--text-secondary);line-height:1.55}
++.ic h3{margin:22px 0 10px;font-size:14px;font-weight:750}
++
++/* The finder: on a computer it stays under the navbar while the grid scrolls. */
++.ic-tools{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px;padding:8px;border-radius:16px;background:color-mix(in srgb,var(--bg-secondary) 88%,transparent);border:1px solid var(--border);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
++.ic-find{position:relative;flex:1 1 220px;display:flex;align-items:center}
++.ic-find .ovk-i{position:absolute;left:12px;color:var(--text-muted);pointer-events:none}
++.ic-find input{width:100%;min-height:42px;box-sizing:border-box;padding:0 12px 0 38px;border-radius:12px;border:1px solid transparent;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:15px}
++.ic-find input:focus{outline:none;border-color:var(--accent)}
++.ic-sizes{display:flex;gap:4px;padding:3px;border-radius:12px;background:var(--bg-primary)}
++.ic-size{min-width:44px;min-height:36px;border:0;border-radius:9px;background:transparent;color:var(--text-secondary);font:inherit;font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;cursor:pointer}
++.ic-size[aria-pressed=true]{background:var(--bg-secondary);color:var(--text-primary);box-shadow:0 0 0 1px var(--border)}
++
++/* The grid. */
++.ic-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px}
++.ic-tile{display:grid;justify-items:center;align-content:center;gap:10px;min-height:104px;padding:14px 6px 10px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;cursor:pointer;transition:border-color .15s,transform .15s,background .15s}
++.ic-tile svg{width:var(--ic-size);height:var(--ic-size)}
++.ic-tile span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:12px}
++.ic-tile:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));transform:translateY(-1px)}
++.ic-tile:hover span{color:var(--text-primary)}
++.ic-tile:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++.ic-none{color:var(--text-secondary)}
++
++/* One glyph, large. */
++.ic-sheet{width:min(440px,calc(100vw - 32px));padding:0;border:1px solid var(--border);border-radius:20px;background:var(--bg-secondary);color:var(--text-primary)}
++.ic-sheet::backdrop{background:rgba(0,0,0,.45)}
++.ic-sheet-in{display:grid;gap:14px;padding:18px}
++.ic-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
++.ic-sheet-head h2{margin:0;font-family:var(--ic-face);font-weight:900;font-size:24px}
++.ic-sheet-big{display:flex;align-items:flex-end;justify-content:center;gap:28px;padding:22px;border-radius:14px;background:var(--bg-primary)}
++.ic-sheet-big svg:nth-child(1){width:16px;height:16px}.ic-sheet-big svg:nth-child(2){width:24px;height:24px}.ic-sheet-big svg:nth-child(3){width:48px;height:48px}.ic-sheet-big svg:nth-child(4){width:96px;height:96px}
++.ic-sheet-meta{margin:0;color:var(--text-secondary);font-size:14px;line-height:1.5;overflow-wrap:anywhere}
++.ic-sheet-meta code{font-size:12.5px}
++.ic-sheet-acts{display:flex;flex-wrap:wrap;gap:8px}
++
++.ic-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:0 16px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-primary);font:inherit;font-size:14px;font-weight:650;text-decoration:none;cursor:pointer}
++.ic-btn:hover{background:var(--bg-primary)}
++.ic-btn.is-go{border-color:transparent;background:var(--accent-strong,var(--accent));color:var(--on-accent-strong,#fff)}
++.ic-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++
++/* What is still Font Awesome. */
++.ic-left{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
++.ic-left li{display:inline-flex;align-items:center;gap:6px;padding:6px 6px 6px 12px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary)}
++.ic-left a{color:var(--text-primary);text-decoration:none;font-size:13.5px;font-weight:600}
++.ic-left a:hover{color:var(--accent)}
++.ic-left small{min-width:24px;padding:1px 7px;border-radius:999px;background:var(--bg-primary);color:var(--text-muted);font-size:11.5px;text-align:center;font-variant-numeric:tabular-nums}
++.ic-more{color:var(--text-muted);font-size:13.5px}
++
++/* Draw an icon, and the drafts. */
++.ic-form{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px;padding:14px;border-radius:16px;border:1px solid var(--border);background:var(--bg-secondary)}
++.ic-form label{display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary)}
++.ic-form .ic-grow{flex:1 1 280px}
++.ic-form 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}
++.ic-form input:focus{outline:none;border-color:var(--accent)}
++.ic-drafts{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:8px}
++.ic-draft{display:flex;flex-wrap:wrap;align-items:center;gap:10px 12px;padding:10px 12px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary)}
++.ic-draft-art{display:flex;align-items:flex-end;gap:10px;padding:8px;border-radius:10px;background:var(--bg-primary)}
++.ic-draft-art svg:first-child{width:40px;height:40px}.ic-draft-art svg:last-child{width:20px;height:20px}
++.ic-draft-t{flex:1;min-width:0;display:grid}
++.ic-draft-t b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.ic-draft-t small{color:var(--text-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.ic-draft-t span{color:var(--text-secondary);font-size:12.5px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
++.ic-draft.is-approved .ic-draft-t small{color:var(--success,#22c55e)}
++.ic-draft.is-rejected{opacity:.6}
++.ic-draft-acts{flex:1 0 100%;display:flex;justify-content:flex-end;gap:6px}
++.ic-draft-acts form{margin:0}
++.ic-draft-acts .ic-btn{min-height:36px;padding:0 12px;font-size:13px}
++
++.ic-use{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:14px}
++.ic-use>div{min-width:0}
++.ic-use>div:first-child{grid-row:span 2}
++.ic-use pre{font-size:12.5px;white-space:pre;overflow-x:auto;padding-right:52px}
++
++@media (min-width:761px){.ic-tools{position:sticky;top:calc(var(--navbar-h,52px) + 8px);z-index:5}}
++@media (max-width:760px){
++ .ic-sec-head,.ic-use{grid-template-columns:minmax(0,1fr)}
++ .ic-use>div:first-child{grid-row:auto}
++ .ic-grid{grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}
++ .ic-tile{min-height:92px}
++}
++@media (prefers-reduced-motion:reduce){.ic-tile{transition:none}.ic-tile:hover{transform:none}}
ddiff --git a/public/js/icons.js b/public/js/icons.js
nnew file mode 100644
iindex 0000000..a69c015
---- /dev/null
++++ b/public/js/icons.js
@@@ -0,0 +1,74 @@
++/**
++ * /icons in the browser (server/views/icons.js): find a glyph by its name or a Font Awesome name as you type, see the
++ * grid at 16, 20, 24 or 32 px, open one large (with what it stands for, Copy the SVG and Download), and Escape or the
++ * close button shuts it. Without this script the grid and the forms still work.
++ */
++(function () {
++ 'use strict';
++ const $ = (s, el = document) => el.querySelector(s);
++ const grid = $('[data-ic-grid]');
++ if (!grid) return;
++ const tools = $('[data-ic-tools]');
++ if (tools) tools.hidden = false;
++
++ // Find as you type: a glyph's name or any Font Awesome name it stands for.
++ const find = $('[data-ic-find]');
++ const none = $('[data-ic-none]');
++ const drawLink = $('[data-ic-draw-link]');
++ if (find) find.addEventListener('input', () => {
++ const q = find.value.trim().toLowerCase().replace(/^fa-/, '');
++ let shown = 0;
++ grid.querySelectorAll('.ic-tile').forEach((t) => {
++ const hit = !q || t.dataset.name.includes(q) || (t.dataset.fa || '').split(' ').some((f) => f.includes(q));
++ t.hidden = !hit; if (hit) shown++;
++ });
++ if (none) none.hidden = shown > 0;
++ if (drawLink && /^[a-z][a-z0-9-]{1,31}$/.test(q)) drawLink.href = `/icons?draw=${encodeURIComponent(q)}#draw`;
++ });
++
++ // Sizes.
++ document.querySelectorAll('[data-ic-size]').forEach((b) => b.addEventListener('click', () => {
++ grid.style.setProperty('--ic-size', `${b.dataset.icSize}px`);
++ document.querySelectorAll('[data-ic-size]').forEach((x) => x.setAttribute('aria-pressed', x === b ? 'true' : 'false'));
++ }));
++
++ // One glyph, large.
++ const sheet = $('[data-ic-sheet]');
++ let current = null;
++ const svgOf = (name) => {
++ const t = grid.querySelector(`.ic-tile[data-name="${CSS.escape(name)}"] svg`);
++ return t ? t.innerHTML : '';
++ };
++ const standalone = (name) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${svgOf(name)}</svg>`;
++ grid.addEventListener('click', (e) => {
++ const t = e.target.closest('.ic-tile');
++ if (!t || !sheet || typeof sheet.showModal !== 'function') return;
++ current = t.dataset.name;
++ $('[data-ic-sheet-name]', sheet).textContent = current;
++ const big = $('[data-ic-sheet-big]', sheet);
++ big.innerHTML = '';
++ for (let i = 0; i < 4; i++) big.insertAdjacentHTML('beforeend', `<svg viewBox="0 0 24 24" aria-hidden="true">${svgOf(current)}</svg>`);
++ const fa = (t.dataset.fa || '').split(' ').filter(Boolean);
++ const meta = $('[data-ic-sheet-meta]', sheet);
++ meta.textContent = '';
++ meta.append(`${t.dataset.by}. `);
++ if (fa.length) {
++ meta.append('In place of Font Awesome\'s ');
++ fa.slice(0, 12).forEach((f, i) => { const c = document.createElement('code'); c.textContent = `fa-${f}`; meta.append(c, i < Math.min(fa.length, 12) - 1 ? ', ' : ''); });
++ if (fa.length > 12) meta.append(` and ${fa.length - 12} more`);
++ meta.append('.');
++ } else meta.append('No Font Awesome name maps to it.');
++ const dl = $('[data-ic-download]', sheet);
++ dl.href = `/icons/${encodeURIComponent(current)}.svg`; dl.setAttribute('download', `${current}.svg`);
++ sheet.showModal();
++ });
++ if (sheet) {
++ $('[data-ic-close]', sheet).addEventListener('click', () => sheet.close());
++ sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); });
++ const copy = $('[data-ic-copy]', sheet);
++ copy.addEventListener('click', () => {
++ if (!current || !navigator.clipboard) return;
++ navigator.clipboard.writeText(standalone(current)).then(() => { copy.textContent = 'Copied'; setTimeout(() => { copy.textContent = 'Copy the SVG'; }, 1400); }, () => {});
++ });
++ }
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 666add4..6d5baaa 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -38,6 +38,7 @@ const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createPageRoutes } = require('./http/pages');
const { createDiscoveryRoutes } = require('./http/discovery');
++const { createIconRoutes } = require('./http/icons');
const { createServiceReadiness } = require('./observability');
const { createArenaStore } = require('./arena/store');
const { createArenaAi } = require('./arena/ai');
@@@ -163,10 +164,11 @@ async function createApp(opts = {}) {
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), { index: false, redirect: false, setHeaders: setAssetHeaders }));
-- // ── API: the Arena, then the theme proxy ────────────────
++ // ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api/v1/arena', createArenaApi(ctx));
++ app.use(createIconRoutes(ctx));
app.use('/api', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
detail: 'No such API route.',
ddiff --git a/server/arena/ai.js b/server/arena/ai.js
iindex cf950dd..b85b0ba 100644
---- a/server/arena/ai.js
++++ b/server/arena/ai.js
@@@ -295,7 +295,21 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
};
}
-- return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt, CATEGORY_BRIEF };
++ /**
++ * Plain text from OpenVibe.AI's routing (the glyph drafts, L32): the same token and run as the Arena, one answer.
++ * → { text, model, provider, run_id }
++ */
++ async function text({ prompt, system, maxTokens = 2000, target = null }) {
++ const startedAt = Date.now();
++ const created = await call('POST', `/api/v1/generate?wait=${Math.min(ai.waitMs, 60_000)}`, {
++ prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}),
++ });
++ const { run } = await waitFor(created && created.run ? created.run : created, startedAt);
++ const prov = run.provenance || {};
++ return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: String(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null };
++ }
++
++ return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
}
module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };
ddiff --git a/server/glyphs/drafts.js b/server/glyphs/drafts.js
nnew file mode 100644
iindex 0000000..9da7410
---- /dev/null
++++ b/server/glyphs/drafts.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * Glyph drafts (plan L32 I3): a person names an icon and says what it is for, OpenVibe.AI draws a few candidates in
++ * the OpenVibe hand, each is checked and rebuilt (validate.js) before it is kept, and an admin approves or rejects
++ * each one. Approved drafts are what openvibe-shared's scripts/pull-glyph-drafts.js takes into the registry; nothing
++ * reaches a page without that review and a Shared release.
++ *
++ * draft({ subject, name, sentence }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)
++ * list({ status, subject, limit }) → drafts, newest first
++ * review({ id, action: 'approve' | 'reject', reviewer })
++ */
++const crypto = require('crypto');
++const { clean } = require('./validate');
++
++const PER_DAY = 12;
++const CANDIDATES = 4;
++const NAME_RE = /^[a-z][a-z0-9-]{1,31}$/;
++
++const SYSTEM = `You draw icons for OpenVibe's glyph set. Every glyph is the inside of an SVG with viewBox "0 0 24 24":
++strokes only (the page draws them 2 units wide, round caps and joins, in the text colour), at most 6 shapes from
++path, circle, ellipse, rect, line and polyline, every coordinate between 1 and 23, nothing filled, no transforms.
++Simple, centred, readable at 16 px, in the hand of a clean line icon set. Answer with JSON only:
++{"candidates": ["<path d=\\"…\\"/>", …]} with ${CANDIDATES} different candidates.`;
++
++function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
++ const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
++ const iso = () => new Date(clock.now()).toISOString();
++
++ async function draft({ subject, name, sentence = '' }) {
++ if (!subject) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
++ const n = String(name || '').trim().toLowerCase();
++ if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });
++ const what = String(sentence || '').trim().slice(0, 200);
++ const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);
++ if ((today && today.n) >= PER_DAY) throw Object.assign(new Error(`${PER_DAY} drafts a day: more tomorrow`), { status: 429 });
++ if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });
++ const answer = await ai.text({
++ system: SYSTEM,
++ prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,
++ maxTokens: 1800,
++ target: { service: 'design', type: 'glyph_draft', id: n },
++ });
++ let list = [];
++ try {
++ const body = String(answer.text || '').replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
++ const j = JSON.parse(body.slice(body.indexOf('{'), body.lastIndexOf('}') + 1));
++ list = Array.isArray(j.candidates) ? j.candidates.slice(0, CANDIDATES) : [];
++ } catch { list = []; }
++ const kept = [], refused = [];
++ const seen = new Set();
++ for (const c of list) {
++ const r = clean(typeof c === 'string' ? c : '');
++ if (!r.ok) { refused.push(r.why); continue; }
++ if (seen.has(r.svg)) continue;
++ seen.add(r.svg);
++ const row = { id: id(), subject, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
++ await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
++ [row.id, row.subject, row.name, row.sentence, row.svg, row.provider, row.model, row.status, row.created_at]);
++ kept.push(row);
++ }
++ if (!list.length) refused.push('the model answered no candidates');
++ return { kept, refused };
++ }
++
++ async function list({ status = null, subject = null, limit = 60 } = {}) {
++ const where = [], args = [];
++ if (status) { args.push(status); where.push(`status = $${args.length}`); }
++ if (subject) { args.push(subject); where.push(`subject = $${args.length}`); }
++ args.push(Math.min(Math.max(Number(limit) || 60, 1), 500));
++ return db.many(`SELECT id, name, sentence, svg, provider, model, status, created_at, reviewed_at FROM glyph_drafts${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT $${args.length}`, args);
++ }
++
++ async function review({ id: draftId, action, reviewer }) {
++ if (!['approve', 'reject'].includes(action)) throw Object.assign(new Error('approve or reject'), { status: 422 });
++ const status = action === 'approve' ? 'approved' : 'rejected';
++ const row = await db.maybe('UPDATE glyph_drafts SET status = $2, reviewed_by = $3, reviewed_at = $4 WHERE id = $1 RETURNING id, name, status', [draftId, status, reviewer, iso()]);
++ if (!row) throw Object.assign(new Error('no such draft'), { status: 404 });
++ return row;
++ }
++
++ return { draft, list, review, PER_DAY };
++}
++
++module.exports = { createGlyphDrafts, SYSTEM, PER_DAY };
ddiff --git a/server/glyphs/fa-usage.json b/server/glyphs/fa-usage.json
nnew file mode 100644
iindex 0000000..d48d301
---- /dev/null
++++ b/server/glyphs/fa-usage.json
@@@ -0,0 +1,1670 @@
++{
++"counted": "2026-10-31",
++"how": "every fa-* name in every OpenVibe repository (git grep), style and size classes left out",
++"names": [
++{
++"name": "wand-magic-sparkles",
++"uses": 96
++},
++{
++"name": "eye",
++"uses": 91
++},
++{
++"name": "tower-broadcast",
++"uses": 90
++},
++{
++"name": "xmark",
++"uses": 84
++},
++{
++"name": "video",
++"uses": 80
++},
++{
++"name": "robot",
++"uses": 76
++},
++{
++"name": "scissors",
++"uses": 73
++},
++{
++"name": "copy",
++"uses": 73
++},
++{
++"name": "bolt",
++"uses": 67
++},
++{
++"name": "check",
++"uses": 66
++},
++{
++"name": "comments",
++"uses": 65
++},
++{
++"name": "play",
++"uses": 64
++},
++{
++"name": "tree",
++"uses": 63
++},
++{
++"name": "spinner",
++"uses": 63
++},
++{
++"name": "map-pin",
++"uses": 61
++},
++{
++"name": "globe",
++"uses": 59
++},
++{
++"name": "code",
++"uses": 58
++},
++{
++"name": "coins",
++"uses": 57
++},
++{
++"name": "clock",
++"uses": 57
++},
++{
++"name": "shield-halved",
++"uses": 56
++},
++{
++"name": "plus",
++"uses": 54
++},
++{
++"name": "lock",
++"uses": 54
++},
++{
++"name": "arrow-right",
++"uses": 50
++},
++{
++"name": "star",
++"uses": 49
++},
++{
++"name": "volume-high",
++"uses": 48
++},
++{
++"name": "triangle-exclamation",
++"uses": 48
++},
++{
++"name": "link",
++"uses": 48
++},
++{
++"name": "image",
++"uses": 44
++},
++{
++"name": "user",
++"uses": 41
++},
++{
++"name": "server",
++"uses": 41
++},
++{
++"name": "magnifying-glass",
++"uses": 41
++},
++{
++"name": "campground",
++"uses": 40
++},
++{
++"name": "utensils",
++"uses": 38
++},
++{
++"name": "train",
++"uses": 37
++},
++{
++"name": "heart",
++"uses": 36
++},
++{
++"name": "water",
++"uses": 35
++},
++{
++"name": "satellite-dish",
++"uses": 35
++},
++{
++"name": "clock-rotate-left",
++"uses": 35
++},
++{
++"name": "key",
++"uses": 34
++},
++{
++"name": "house",
++"uses": 34
++},
++{
++"name": "film",
++"uses": 34
++},
++{
++"name": "download",
++"uses": 34
++},
++{
++"name": "circle",
++"uses": 34
++},
++{
++"name": "sliders",
++"uses": 33
++},
++{
++"name": "leaf",
++"uses": 33
++},
++{
++"name": "gamepad",
++"uses": 33
++},
++{
++"name": "microphone",
++"uses": 31
++},
++{
++"name": "chevron-right",
++"uses": 31
++},
++{
++"name": "paste",
++"uses": 30
++},
++{
++"name": "arrow-up-right-from-square",
++"uses": 30
++},
++{
++"name": "palette",
++"uses": 29
++},
++{
++"name": "bell",
++"uses": 29
++},
++{
++"name": "gauge-high",
++"uses": 28
++},
++{
++"name": "floppy-disk",
++"uses": 28
++},
++{
++"name": "trash",
++"uses": 27
++},
++{
++"name": "terminal",
++"uses": 27
++},
++{
++"name": "chevron-down",
++"uses": 27
++},
++{
++"name": "chart-line",
++"uses": 27
++},
++{
++"name": "plug",
++"uses": 26
++},
++{
++"name": "file-image",
++"uses": 26
++},
++{
++"name": "compress",
++"uses": 26
++},
++{
++"name": "circle-info",
++"uses": 26
++},
++{
++"name": "pen",
++"uses": 25
++},
++{
++"name": "hand-holding-heart",
++"uses": 25
++},
++{
++"name": "comment",
++"uses": 25
++},
++{
++"name": "city",
++"uses": 25
++},
++{
++"name": "ban",
++"uses": 25
++},
++{
++"name": "screwdriver-wrench",
++"uses": 24
++},
++{
++"name": "music",
++"uses": 24
++},
++{
++"name": "gear",
++"uses": 24
++},
++{
++"name": "eye-slash",
++"uses": 24
++},
++{
++"name": "users",
++"uses": 23
++},
++{
++"name": "people-group",
++"uses": 23
++},
++{
++"name": "paper-plane",
++"uses": 23
++},
++{
++"name": "fire",
++"uses": 23
++},
++{
++"name": "circle-nodes",
++"uses": 23
++},
++{
++"name": "bridge",
++"uses": 23
++},
++{
++"name": "envelope",
++"uses": 22
++},
++{
++"name": "circle-check",
++"uses": 22
++},
++{
++"name": "sun",
++"uses": 21
++},
++{
++"name": "photo-film",
++"uses": 21
++},
++{
++"name": "list-check",
++"uses": 21
++},
++{
++"name": "display",
++"uses": 21
++},
++{
++"name": "rotate",
++"uses": 20
++},
++{
++"name": "comment-dots",
++"uses": 20
++},
++{
++"name": "wave-square",
++"uses": 19
++},
++{
++"name": "volume-xmark",
++"uses": 19
++},
++{
++"name": "newspaper",
++"uses": 19
++},
++{
++"name": "network-wired",
++"uses": 19
++},
++{
++"name": "location-dot",
++"uses": 19
++},
++{
++"name": "file-lines",
++"uses": 19
++},
++{
++"name": "camera",
++"uses": 19
++},
++{
++"name": "book",
++"uses": 19
++},
++{
++"name": "user-secret",
++"uses": 18
++},
++{
++"name": "stop",
++"uses": 18
++},
++{
++"name": "rotate-left",
++"uses": 18
++},
++{
++"name": "restroom",
++"uses": 17
++},
++{
++"name": "map",
++"uses": 17
++},
++{
++"name": "crown",
++"uses": 17
++},
++{
++"name": "youtube",
++"uses": 16
++},
++{
++"name": "wallet",
++"uses": 16
++},
++{
++"name": "sitemap",
++"uses": 16
++},
++{
++"name": "moon",
++"uses": 16
++},
++{
++"name": "microchip",
++"uses": 16
++},
++{
++"name": "list",
++"uses": 16
++},
++{
++"name": "id-card",
++"uses": 16
++},
++{
++"name": "house-chimney",
++"uses": 16
++},
++{
++"name": "file-pdf",
++"uses": 16
++},
++{
++"name": "circle-notch",
++"uses": 16
++},
++{
++"name": "brain",
++"uses": 16
++},
++{
++"name": "cloud",
++"uses": 15
++},
++{
++"name": "chevron-left",
++"uses": 15
++},
++{
++"name": "arrows-rotate",
++"uses": 15
++},
++{
++"name": "upload",
++"uses": 14
++},
++{
++"name": "snowflake",
++"uses": 14
++},
++{
++"name": "shield",
++"uses": 14
++},
++{
++"name": "person-swimming",
++"uses": 14
++},
++{
++"name": "mountain-sun",
++"uses": 14
++},
++{
++"name": "microphone-slash",
++"uses": 14
++},
++{
++"name": "database",
++"uses": 14
++},
++{
++"name": "user-astronaut",
++"uses": 13
++},
++{
++"name": "layer-group",
++"uses": 13
++},
++{
++"name": "hashtag",
++"uses": 13
++},
++{
++"name": "font",
++"uses": 13
++},
++{
++"name": "earth-americas",
++"uses": 13
++},
++{
++"name": "cloud-sun",
++"uses": 13
++},
++{
++"name": "user-shield",
++"uses": 12
++},
++{
++"name": "twitch",
++"uses": 12
++},
++{
++"name": "right-from-bracket",
++"uses": 12
++},
++{
++"name": "language",
++"uses": 12
++},
++{
++"name": "gift",
++"uses": 12
++},
++{
++"name": "diagram-project",
++"uses": 12
++},
++{
++"name": "cloud-showers-heavy",
++"uses": 12
++},
++{
++"name": "circle-exclamation",
++"uses": 12
++},
++{
++"name": "video-slash",
++"uses": 11
++},
++{
++"name": "mountain",
++"uses": 11
++},
++{
++"name": "lightbulb",
++"uses": 11
++},
++{
++"name": "images",
++"uses": 11
++},
++{
++"name": "hand-holding-dollar",
++"uses": 11
++},
++{
++"name": "github",
++"uses": 11
++},
++{
++"name": "file-audio",
++"uses": 11
++},
++{
++"name": "dollar-sign",
++"uses": 11
++},
++{
++"name": "discord",
++"uses": 11
++},
++{
++"name": "cloud-rain",
++"uses": 11
++},
++{
++"name": "clipboard-list",
++"uses": 11
++},
++{
++"name": "cart-shopping",
++"uses": 11
++},
++{
++"name": "bullseye",
++"uses": 11
++},
++{
++"name": "arrow-down",
++"uses": 11
++},
++{
++"name": "trophy",
++"uses": 10
++},
++{
++"name": "trash-can",
++"uses": 10
++},
++{
++"name": "tag",
++"uses": 10
++},
++{
++"name": "shirt",
++"uses": 10
++},
++{
++"name": "route",
++"uses": 10
++},
++{
++"name": "reply",
++"uses": 10
++},
++{
++"name": "person-shelter",
++"uses": 10
++},
++{
++"name": "pen-nib",
++"uses": 10
++},
++{
++"name": "pause",
++"uses": 10
++},
++{
++"name": "face-smile",
++"uses": 10
++},
++{
++"name": "expand",
++"uses": 10
++},
++{
++"name": "box-archive",
++"uses": 10
++},
++{
++"name": "user-plus",
++"uses": 9
++},
++{
++"name": "user-check",
++"uses": 9
++},
++{
++"name": "up-right-and-down-left-from-center",
++"uses": 9
++},
++{
++"name": "ticket",
++"uses": 9
++},
++{
++"name": "thumbtack",
++"uses": 9
++},
++{
++"name": "text-height",
++"uses": 9
++},
++{
++"name": "store",
++"uses": 9
++},
++{
++"name": "rotate-right",
++"uses": 9
++},
++{
++"name": "right-to-bracket",
++"uses": 9
++},
++{
++"name": "keyboard",
++"uses": 9
++},
++{
++"name": "hot-tub-person",
++"uses": 9
++},
++{
++"name": "gavel",
++"uses": 9
++},
++{
++"name": "gauge",
++"uses": 9
++},
++{
++"name": "droplet",
++"uses": 9
++},
++{
++"name": "check-circle",
++"uses": 9
++},
++{
++"name": "broadcast-tower",
++"uses": 9
++},
++{
++"name": "book-open",
++"uses": 9
++},
++{
++"name": "bars",
++"uses": 9
++},
++{
++"name": "arrow-left",
++"uses": 9
++},
++{
++"name": "wifi",
++"uses": 8
++},
++{
++"name": "up-right-from-square",
++"uses": 8
++},
++{
++"name": "umbrella",
++"uses": 8
++},
++{
++"name": "square-parking",
++"uses": 8
++},
++{
++"name": "search",
++"uses": 8
++},
++{
++"name": "code-fork",
++"uses": 8
++},
++{
++"name": "code-branch",
++"uses": 8
++},
++{
++"name": "cloud-arrow-up",
++"uses": 8
++},
++{
++"name": "circle-xmark",
++"uses": 8
++},
++{
++"name": "skull-crossbones",
++"uses": 7
++},
++{
++"name": "phone",
++"uses": 7
++},
++{
++"name": "object-group",
++"uses": 7
++},
++{
++"name": "kit-medical",
++"uses": 7
++},
++{
++"name": "info-circle",
++"uses": 7
++},
++{
++"name": "icons",
++"uses": 7
++},
++{
++"name": "headphones",
++"uses": 7
++},
++{
++"name": "hard-drive",
++"uses": 7
++},
++{
++"name": "folder",
++"uses": 7
++},
++{
++"name": "desktop",
++"uses": 7
++},
++{
++"name": "check-double",
++"uses": 7
++},
++{
++"name": "tv",
++"uses": 6
++},
++{
++"name": "toolbox",
++"uses": 6
++},
++{
++"name": "tags",
++"uses": 6
++},
++{
++"name": "table",
++"uses": 6
++},
++{
++"name": "scale-balanced",
++"uses": 6
++},
++{
++"name": "road",
++"uses": 6
++},
++{
++"name": "people-arrows",
++"uses": 6
++},
++{
++"name": "money-bill-transfer",
++"uses": 6
++},
++{
++"name": "heart-pulse",
++"uses": 6
++},
++{
++"name": "heading",
++"uses": 6
++},
++{
++"name": "hand-fist",
++"uses": 6
++},
++{
++"name": "forward",
++"uses": 6
++},
++{
++"name": "file-code",
++"uses": 6
++},
++{
++"name": "dice",
++"uses": 6
++},
++{
++"name": "circle-play",
++"uses": 6
++},
++{
++"name": "circle-half-stroke",
++"uses": 6
++},
++{
++"name": "calendar",
++"uses": 6
++},
++{
++"name": "briefcase",
++"uses": 6
++},
++{
++"name": "arrow-up",
++"uses": 6
++},
++{
++"name": "x-twitter",
++"uses": 5
++},
++{
++"name": "volume-low",
++"uses": 5
++},
++{
++"name": "tower-cell",
++"uses": 5
++},
++{
++"name": "timeline",
++"uses": 5
++},
++{
++"name": "share-nodes",
++"uses": 5
++},
++{
++"name": "seedling",
++"uses": 5
++},
++{
++"name": "rocket",
++"uses": 5
++},
++{
++"name": "right-left",
++"uses": 5
++},
++{
++"name": "person-hiking",
++"uses": 5
++},
++{
++"name": "money-bill-wave",
++"uses": 5
++},
++{
++"name": "microphone-lines",
++"uses": 5
++},
++{
++"name": "list-ol",
++"uses": 5
++},
++{
++"name": "life-ring",
++"uses": 5
++},
++{
++"name": "infinity",
++"uses": 5
++},
++{
++"name": "inbox",
++"uses": 5
++},
++{
++"name": "folder-open",
++"uses": 5
++},
++{
++"name": "faucet-drip",
++"uses": 5
++},
++{
++"name": "face-grin-stars",
++"uses": 5
++},
++{
++"name": "door-open",
++"uses": 5
++},
++{
++"name": "credit-card",
++"uses": 5
++},
++{
++"name": "cloud-bolt",
++"uses": 5
++},
++{
++"name": "clapperboard",
++"uses": 5
++},
++{
++"name": "circle-question",
++"uses": 5
++},
++{
++"name": "church",
++"uses": 5
++},
++{
++"name": "chevron-up",
++"uses": 5
++},
++{
++"name": "chart-pie",
++"uses": 5
++},
++{
++"name": "calendar-days",
++"uses": 5
++},
++{
++"name": "building",
++"uses": 5
++},
++{
++"name": "broom",
++"uses": 5
++},
++{
++"name": "box-open",
++"uses": 5
++},
++{
++"name": "at",
++"uses": 5
++},
++{
++"name": "arrow-rotate-left",
++"uses": 5
++},
++{
++"name": "apple-whole",
++"uses": 5
++},
++{
++"name": "user-tag",
++"uses": 4
++},
++{
++"name": "user-lock",
++"uses": 4
++},
++{
++"name": "stamp",
++"uses": 4
++},
++{
++"name": "sparkles",
++"uses": 4
++},
++{
++"name": "sort",
++"uses": 4
++},
++{
++"name": "smog",
++"uses": 4
++},
++{
++"name": "signal",
++"uses": 4
++},
++{
++"name": "share",
++"uses": 4
++},
++{
++"name": "shapes",
++"uses": 4
++},
++{
++"name": "save",
++"uses": 4
++},
++{
++"name": "puzzle-piece",
++"uses": 4
++},
++{
++"name": "print",
++"uses": 4
++},
++{
++"name": "mobile-screen",
++"uses": 4
++},
++{
++"name": "minus",
++"uses": 4
++},
++{
++"name": "map-location-dot",
++"uses": 4
++},
++{
++"name": "lock-open",
++"uses": 4
++},
++{
++"name": "kickstarter-k",
++"uses": 4
++},
++{
++"name": "icicles",
++"uses": 4
++},
++{
++"name": "hand",
++"uses": 4
++},
++{
++"name": "ghost",
++"uses": 4
++},
++{
++"name": "flag-checkered",
++"uses": 4
++},
++{
++"name": "fish",
++"uses": 4
++},
++{
++"name": "face-smile-beam",
++"uses": 4
++},
++{
++"name": "face-grin-squint-tears",
++"uses": 4
++},
++{
++"name": "crop-simple",
++"uses": 4
++},
++{
++"name": "compass",
++"uses": 4
++},
++{
++"name": "cloud-arrow-down",
++"uses": 4
++},
++{
++"name": "certificate",
++"uses": 4
++},
++{
++"name": "camera-rotate",
++"uses": 4
++},
++{
++"name": "calendar-check",
++"uses": 4
++},
++{
++"name": "arrows-left-right",
++"uses": 4
++},
++{
++"name": "address-card",
++"uses": 4
++},
++{
++"name": "address-book",
++"uses": 4
++},
++{
++"name": "xmark-circle",
++"uses": 3
++},
++{
++"name": "wrench",
++"uses": 3
++},
++{
++"name": "vial",
++"uses": 3
++},
++{
++"name": "user-circle",
++"uses": 3
++},
++{
++"name": "star-half-stroke",
++"uses": 3
++},
++{
++"name": "sort-alpha-down",
++"uses": 3
++},
++{
++"name": "shuffle",
++"uses": 3
++},
++{
++"name": "shower",
++"uses": 3
++},
++{
++"name": "recycle",
++"uses": 3
++},
++{
++"name": "ranking-star",
++"uses": 3
++},
++{
++"name": "plug-circle-check",
++"uses": 3
++},
++{
++"name": "phone-slash",
++"uses": 3
++},
++{
++"name": "person-walking",
++"uses": 3
++},
++{
++"name": "pen-to-square",
++"uses": 3
++},
++{
++"name": "paw",
++"uses": 3
++},
++{
++"name": "node-js",
++"uses": 3
++},
++{
++"name": "message",
++"uses": 3
++},
++{
++"name": "magnifying-glass-chart",
++"uses": 3
++},
++{
++"name": "k",
++"uses": 3
++},
++{
++"name": "heart-crack",
++"uses": 3
++},
++{
++"name": "headset",
++"uses": 3
++},
++{
++"name": "hands-helping",
++"uses": 3
++},
++{
++"name": "hammer",
++"uses": 3
++},
++{
++"name": "gem",
++"uses": 3
++},
++{
++"name": "gas-pump",
++"uses": 3
++},
++{
++"name": "forward-step",
++"uses": 3
++},
++{
++"name": "folder-tree",
++"uses": 3
++},
++{
++"name": "fingerprint",
++"uses": 3
++},
++{
++"name": "eraser",
++"uses": 3
++},
++{
++"name": "diamond-turn-right",
++"uses": 3
++},
++{
++"name": "cubes",
++"uses": 3
++},
++{
++"name": "crosshairs",
++"uses": 3
++},
++{
++"name": "couch",
++"uses": 3
++},
++{
++"name": "comment-slash",
++"uses": 3
++},
++{
++"name": "code-compare",
++"uses": 3
++},
++{
++"name": "code-commit",
++"uses": 3
++},
++{
++"name": "closed-captioning",
++"uses": 3
++},
++{
++"name": "clone",
++"uses": 3
++},
++{
++"name": "clipboard-check",
++"uses": 3
++},
++{
++"name": "circle-dot",
++"uses": 3
++},
++{
++"name": "bus",
++"uses": 3
++},
++{
++"name": "bullhorn",
++"uses": 3
++},
++{
++"name": "bed",
++"uses": 3
++},
++{
++"name": "bars-staggered",
++"uses": 3
++},
++{
++"name": "backward",
++"uses": 3
++},
++{
++"name": "arrow-up-from-bracket",
++"uses": 3
++},
++{
++"name": "window-restore",
++"uses": 2
++},
++{
++"name": "wind",
++"uses": 2
++},
++{
++"name": "wheat-awn",
++"uses": 2
++},
++{
++"name": "user-slash",
++"uses": 2
++},
++{
++"name": "user-pen",
++"uses": 2
++},
++{
++"name": "user-minus",
++"uses": 2
++},
++{
++"name": "user-group",
++"uses": 2
++},
++{
++"name": "toggle-on",
++"uses": 2
++},
++{
++"name": "thumbs-up",
++"uses": 2
++},
++{
++"name": "text-slash",
++"uses": 2
++},
++{
++"name": "temperature-half",
++"uses": 2
++},
++{
++"name": "table-columns",
++"uses": 2
++},
++{
++"name": "stethoscope",
++"uses": 2
++},
++{
++"name": "square",
++"uses": 2
++},
++{
++"name": "solid-900",
++"uses": 2
++},
++{
++"name": "smoking",
++"uses": 2
++},
++{
++"name": "signature",
++"uses": 2
++},
++{
++"name": "shield-heart",
++"uses": 2
++},
++{
++"name": "rss",
++"uses": 2
++},
++{
++"name": "repeat",
++"uses": 2
++},
++{
++"name": "reddit-alien",
++"uses": 2
++},
++{
++"name": "receipt",
++"uses": 2
++},
++{
++"name": "quote-right",
++"uses": 2
++},
++{
++"name": "quote-left",
++"uses": 2
++},
++{
++"name": "question",
++"uses": 2
++},
++{
++"name": "power-off",
++"uses": 2
++},
++{
++"name": "podcast",
++"uses": 2
++},
++{
++"name": "plug-circle-xmark",
++"uses": 2
++},
++{
++"name": "plug-circle-plus",
++"uses": 2
++},
++{
++"name": "paragraph",
++"uses": 2
++},
++{
++"name": "paintbrush",
++"uses": 2
++},
++{
++"name": "oil-can",
++"uses": 2
++},
++{
++"name": "note-sticky",
++"uses": 2
++},
++{
++"name": "not-a-glyph",
++"uses": 2
++},
++{
++"name": "mobile-screen-button",
++"uses": 2
++},
++{
++"name": "mask",
++"uses": 2
++},
++{
++"name": "markdown",
++"uses": 2
++},
++{
++"name": "magnifying-glass-arrow-right",
++"uses": 2
++},
++{
++"name": "location-crosshairs",
++"uses": 2
++},
++{
++"name": "link-slash",
++"uses": 2
++},
++{
++"name": "landmark",
++"uses": 2
++},
++{
++"name": "id-badge",
++"uses": 2
++},
++{
++"name": "hourglass-half",
++"uses": 2
++},
++{
++"name": "hospital",
++"uses": 2
++},
++{
++"name": "hat-wizard",
++"uses": 2
++},
++{
++"name": "hands-holding-child",
++"uses": 2
++},
++{
++"name": "hand-point-right",
++"uses": 2
++},
++{
++"name": "google",
++"uses": 2
++},
++{
++"name": "glass-water",
++"uses": 2
++},
++{
++"name": "flask-vial",
++"uses": 2
++},
++{
++"name": "flask",
++"uses": 2
++},
++{
++"name": "flag",
++"uses": 2
++},
++{
++"name": "filter",
++"uses": 2
++},
++{
++"name": "file-zipper",
++"uses": 2
++},
++{
++"name": "file-pen",
++"uses": 2
++},
++{
++"name": "face-grin-squint",
++"uses": 2
++},
++{
++"name": "ellipsis-vertical",
++"uses": 2
++},
++{
++"name": "ellipsis",
++"uses": 2
++},
++{
++"name": "ear-listen",
++"uses": 2
++},
++{
++"name": "drumstick-bite",
++"uses": 2
++},
++{
++"name": "down-left-and-up-right-to-center",
++"uses": 2
++},
++{
++"name": "cookie-bite",
++"uses": 2
++},
++{
++"name": "cookie",
++"uses": 2
++},
++{
++"name": "code-merge",
++"uses": 2
++},
++{
++"name": "cloud-upload-alt",
++"uses": 2
++},
++{
++"name": "cloud-sun-rain",
++"uses": 2
++},
++{
++"name": "cloud-moon",
++"uses": 2
++},
++{
++"name": "chart-column",
++"uses": 2
++},
++{
++"name": "chart-area",
++"uses": 2
++},
++{
++"name": "charging-station",
++"uses": 2
++},
++{
++"name": "carrot",
++"uses": 2
++},
++{
++"name": "car",
++"uses": 2
++},
++{
++"name": "camera-cctv",
++"uses": 2
++},
++{
++"name": "calculator",
++"uses": 2
++},
++{
++"name": "braille",
++"uses": 2
++},
++{
++"name": "brackets-curly",
++"uses": 2
++},
++{
++"name": "book-skull",
++"uses": 2
++},
++{
++"name": "bluesky",
++"uses": 2
++},
++{
++"name": "binoculars",
++"uses": 2
++},
++{
++"name": "bezier-curve",
++"uses": 2
++},
++{
++"name": "basket-shopping",
++"uses": 2
++},
++{
++"name": "barcode",
++"uses": 2
++},
++{
++"name": "backpack",
++"uses": 2
++},
++{
++"name": "arrow-right-arrow-left",
++"uses": 2
++},
++{
++"name": "angles-right",
++"uses": 2
++},
++{
++"name": "align-left",
++"uses": 2
++}
++]
++}
\\ No newline at end of file
ddiff --git a/server/glyphs/validate.js b/server/glyphs/validate.js
nnew file mode 100644
iindex 0000000..78a9d67
---- /dev/null
++++ b/server/glyphs/validate.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * A glyph drawing, checked and normalised before anyone sees it (plan L32 I3). A drawing is the inside of a 24-unit
++ * SVG in the OpenVibe hand: strokes only (the page draws them in the text's colour, 2 units, round), a handful of
++ * shapes, nothing outside the grid. Whatever a model returns is parsed shape by shape and rebuilt from the attributes
++ * that are allowed, with numbers only; anything else (a fill, a style, a script, a link, a transform) is refused.
++ *
++ * clean(markup) → { ok: true, svg } with the rebuilt drawing, or { ok: false, why } in words
++ */
++const SHAPES = {
++ path: ['d'],
++ circle: ['cx', 'cy', 'r'],
++ ellipse: ['cx', 'cy', 'rx', 'ry'],
++ rect: ['x', 'y', 'width', 'height', 'rx', 'ry'],
++ line: ['x1', 'y1', 'x2', 'y2'],
++ polyline: ['points'],
++};
++const MAX_SHAPES = 8;
++const MAX_D = 700;
++const NUM = /^-?\d*\.?\d+$/;
++// Path data: commands and numbers only.
++const PATH_D = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s-]+$/;
++
++function numbersOf(s) { return (String(s).match(/-?\d*\.?\d+(?:e-?\d+)?/gi) || []).map(Number); }
++const onGrid = (n) => Number.isFinite(n) && n >= -1 && n <= 25;
++
++function clean(markup) {
++ const src = String(markup || '').trim()
++ .replace(/^<svg\b[^>]*>/i, '').replace(/<\/svg>\s*$/i, '') // a whole <svg> is accepted for its inside
++ .replace(/<!--[\s\S]*?-->/g, '').trim();
++ if (!src) return { ok: false, why: 'the drawing is empty' };
++ if (src.length > 3000) return { ok: false, why: 'the drawing is too long' };
++ const out = [];
++ let rest = src;
++ const tag = /^<\s*(\w+)((?:\s+[\w:-]+\s*=\s*"[^"]*")*)\s*\/?>\s*(?:<\/\s*\1\s*>)?\s*/;
++ while (rest.length) {
++ const m = tag.exec(rest);
++ if (!m) return { ok: false, why: 'only shapes, one after another (path, circle, ellipse, rect, line, polyline)' };
++ const name = m[1].toLowerCase();
++ if (!SHAPES[name]) return { ok: false, why: `a ${name} is not a shape a glyph may use` };
++ const attrs = {};
++ for (const a of m[2].matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[a[1].toLowerCase()] = a[2];
++ for (const k of Object.keys(attrs)) {
++ if (SHAPES[name].includes(k)) continue;
++ if (k === 'stroke-width' && NUM.test(attrs[k]) && Number(attrs[k]) >= 1 && Number(attrs[k]) <= 4) continue; // dots may be heavier
++ if (['fill', 'stroke', 'stroke-linecap', 'stroke-linejoin'].includes(k) && /^(none|currentColor|round)$/i.test(attrs[k])) continue; // the page's own
++ return { ok: false, why: `${name} ${k}="…" is not allowed: strokes only, in the page's colour` };
++ }
++ const keep = [];
++ for (const k of SHAPES[name]) {
++ if (attrs[k] == null) continue;
++ const v = attrs[k].trim();
++ if (k === 'd') {
++ if (!PATH_D.test(v) || v.length > MAX_D) return { ok: false, why: 'a path is commands and numbers only, and short' };
++ const nums = numbersOf(v);
++ // Absolute coordinates stay on the grid (relative steps and arc flags are small or 0/1 anyway).
++ if (/^[Mm]/.test(v) === false) return { ok: false, why: 'a path starts with a move (M)' };
++ if (nums.some((n) => !(n >= -25 && n <= 25))) return { ok: false, why: 'a path goes far off the 24-unit grid' };
++ } else if (k === 'points') {
++ const nums = numbersOf(v);
++ if (!nums.length || nums.length % 2 || nums.some((n) => !onGrid(n))) return { ok: false, why: 'polyline points sit on the grid' };
++ } else if (!NUM.test(v) || !onGrid(Number(v))) {
++ return { ok: false, why: `${name} ${k} is a number on the 24-unit grid` };
++ }
++ keep.push(`${k}="${v.replace(/\s+/g, ' ')}"`);
++ }
++ if (!keep.length) return { ok: false, why: `a ${name} with nothing to draw` };
++ out.push(`<${name} ${keep.join(' ')}/>`);
++ if (out.length > MAX_SHAPES) return { ok: false, why: `at most ${MAX_SHAPES} shapes: a glyph is simple` };
++ rest = rest.slice(m[0].length);
++ }
++ return { ok: true, svg: out.join('') };
++}
++
++module.exports = { clean, SHAPES, MAX_SHAPES };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 7197d32..fb3e682 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -33,6 +33,7 @@ function publicPages(themes = []) {
{ path: '/components', changefreq: 'weekly', priority: 0.7, title: 'Components', text: 'The OpenVibe design system\'s components, each with its parts, tokens, states and accessibility, and code for the web, Jetpack Compose and SwiftUI.' },
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
++ { path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
@@@ -73,6 +74,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; the registry as data at GET /api/v1/glyphs; openvibe-shared/glyphs and ov-glyphs.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: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
ddiff --git a/server/http/icons.js b/server/http/icons.js
nnew file mode 100644
iindex 0000000..cbbc9b2
---- /dev/null
++++ b/server/http/icons.js
@@@ -0,0 +1,82 @@
++'use strict';
++/**
++ * The OpenVibe glyphs on openvibe.design (plan L32):
++ * GET /icons the set, what is still Font Awesome, "Draw an icon" and the review list
++ * GET /icons/<name>.svg one glyph as a standalone SVG (anyone may fetch it)
++ * 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/drafts approved drafts, for openvibe-shared to take in (an admin may ask for others)
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const glyphs = require('openvibe-shared/glyphs');
++const { asyncRouter } = require('./router');
++const { send, baseUrl } = require('../render/layout');
++const { writeAllowed } = require('./same-origin');
++const { iconsPage, glyphsJson } = require('../views/icons');
++const { createGlyphDrafts } = require('../glyphs/drafts');
++
++function createIconRoutes(ctx) {
++ const { config, s, ai, log = console } = ctx;
++ const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai });
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '8kb' });
++ const subjectOf = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
++ const isAdmin = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.role === 'admin');
++ const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const back = (res, q) => res.redirect(303, `/icons?${new URLSearchParams(q)}#draw`);
++
++ r.get('/icons', async (req, res) => {
++ const subject = subjectOf(req);
++ const reviewer = isAdmin(req);
++ const [mine, waiting] = await Promise.all([
++ subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : [],
++ reviewer ? drafts.list({ status: 'draft', limit: 60 }).catch(() => []) : [],
++ ]);
++ const notice = req.query.drafted != null ? (Number(req.query.drafted) ? `${Number(req.query.drafted)} candidate${Number(req.query.drafted) === 1 ? '' : 's'} drawn: they wait for review below.` : 'No candidate passed the checks this time: try saying more about it.') : req.query.reviewed ? `Draft ${String(req.query.reviewed).slice(0, 20)}.` : '';
++ const draw = /^[a-z][a-z0-9-]{1,31}$/.test(String(req.query.draw || '')) ? String(req.query.draw) : '';
++ send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw }), { viewer: req.viewer });
++ });
++
++ r.get('/icons/:file', (req, res, next) => {
++ const m = /^([a-z][a-z0-9-]{0,40})\.svg$/.exec(req.params.file);
++ const out = m && glyphs.svg(m[1], { size: 24, title: m[1] });
++ if (!out) return next();
++ res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*').type('image/svg+xml').send(out);
++ });
++
++ r.post('/icons/draft', form, async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
++ const subject = subjectOf(req);
++ if (!subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/icons#draw')}`);
++ try {
++ const out = await drafts.draft({ subject, name: (req.body || {}).name, sentence: (req.body || {}).sentence });
++ back(res, { drafted: String(out.kept.length), draw: String((req.body || {}).name || '') });
++ } catch (err) {
++ if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph draft failed:', err && err.message);
++ back(res, { error: err.status && err.status < 500 ? err.message : 'Drawing did not work this time: try again in a minute.', draw: String((req.body || {}).name || '') });
++ }
++ });
++
++ r.post('/icons/drafts/:id/review', form, async (req, res) => {
++ if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
++ if (!isAdmin(req)) return res.status(403).send('Only an admin reviews drafts.');
++ try {
++ const row = await drafts.review({ id: req.params.id, action: (req.body || {}).action, reviewer: subjectOf(req) });
++ back(res, { reviewed: row.status });
++ } 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/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.');
++ if (!['approved', 'draft', 'rejected'].includes(status)) return problem(res, req, 422, 'design.invalid', 'status is approved, draft or rejected.');
++ const rows = await drafts.list({ status, limit: 500 });
++ res.set('Cache-Control', status === 'approved' ? 'public, max-age=300' : 'private, no-store').json({ drafts: rows.map((d) => ({ id: d.id, name: d.name, sentence: d.sentence, svg: d.svg, model: d.model, status: d.status, created_at: d.created_at, reviewed_at: d.reviewed_at })) });
++ });
++ return r;
++}
++
++module.exports = { createIconRoutes };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 10bf110..a5f9314 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -1,8 +1,8 @@
'use strict';
/**
-- * Account export and deletion → Design's Arena (ADR-033; openvibe-sdk/account-data). The rows a person owns are
-- * the rounds they started and the votes they cast. A round's outputs are exported with it; deleting the round
++ * Account export and deletion → Design (ADR-033; openvibe-sdk/account-data). The rows a person owns are the rounds
++ * they started, the votes they cast, their designs and their icon drafts. A round's outputs are exported with it; deleting the round
* cascades its outputs and every vote on it (including other people's votes on that round, which is the right
* outcome: the round is gone). Ratings are aggregate and name no person, so they stay.
*
@@@ -15,6 +15,9 @@ const TABLES = [
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
{ table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'created_at', 'updated_at'] },
++ // The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
++ // drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
++ { table: 'glyph_drafts', subject: 'subject', file: 'glyph_drafts.json', order: 'created_at', columns: ['id', 'name', 'sentence', 'svg', 'model', 'status', 'created_at', 'reviewed_at'], erase: 'delete' },
];
async function extraExport(db, subject) {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 07e5e71..00b1ad3 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -29,6 +29,7 @@ const NAV = [
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
++ { label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
ddiff --git a/server/views/icons.js b/server/views/icons.js
nnew file mode 100644
iindex 0000000..1e2806d
---- /dev/null
++++ b/server/views/icons.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * /icons: the OpenVibe glyphs (plan L32). Every glyph the network draws, in one hand: search them, see one large with
++ * the Font Awesome names it stands for, copy or download its SVG, and use them on any site. Below: the Font Awesome
++ * names the network still uses that have no glyph yet (most used first), and "Draw an icon", where OpenVibe.AI drafts
++ * candidates that an admin reviews before any of them joins the set.
++ *
++ * Server-rendered and whole without JavaScript: the grid, the drafts and the forms work as they are; icons.js adds the
++ * live search, the sizes, the detail panel and the copy buttons; kit-web.js the snippet copy buttons.
++ *
++ * iconsPage({ base, drafts, mine, reviewer, notice, error, draw })
++ */
++const kit = require('openvibe-shared/kit');
++const glyphs = require('openvibe-shared/glyphs');
++const { layout, esc } = require('../render/layout');
++const usage = require('../glyphs/fa-usage.json');
++
++const BRANDS = new Set(['youtube', 'twitch', 'github', 'discord', 'twitter', 'x-twitter', 'facebook', 'instagram', 'tiktok', 'reddit', 'google', 'apple', 'android', 'windows', 'linux', 'steam', 'spotify', 'kickstarter', 'patreon', 'paypal', 'stripe', 'bitcoin', 'ethereum', 'telegram', 'whatsapp', 'slack', 'linkedin', 'mastodon', 'bluesky', 'vimeo', 'soundcloud', 'itch-io', 'npm', 'node-js', 'docker', 'cloudflare', 'chrome', 'firefox', 'safari', 'edge', 'opera', 'gitlab', 'bitbucket', 'figma', 'openai', 'markdown', 'kick', 'rumble', 'trello']);
++
++/** Each glyph's Font Awesome names, for search and the detail panel. */
++function faByGlyph() {
++ const m = new Map();
++ for (const [fa, g] of Object.entries(glyphs.FA)) { if (!m.has(g)) m.set(g, []); m.get(g).push(fa); }
++ return m;
++}
++
++/** The names the network asks Font Awesome for that no glyph covers yet, brand logos aside; most used first. */
++function stillFontAwesome() {
++ return usage.names.filter((n) => !glyphs.fromFa(n.name) && !BRANDS.has(n.name));
++}
++
++const inner = (name) => glyphs.path(name);
++const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${d}</svg>`;
++
++function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '' } = {}) {
++ const fa = faByGlyph();
++ const left = stillFontAwesome();
++ const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);
++ const total = usage.names.filter((n) => !BRANDS.has(n.name)).reduce((a, n) => a + n.uses, 0);
++ const tile = (name) => {
++ const aliases = fa.get(name) || [];
++ const drawn = glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit';
++ return `<button type="button" class="ic-tile" data-name="${esc(name)}" data-fa="${esc(aliases.join(' '))}" data-by="${esc(drawn)}" aria-label="${esc(name)}">${svg(inner(name))}<span>${esc(name)}</span></button>`;
++ };
++
++ const hero = `<header class="ic-hero">
++ <p class="ic-eye">The OpenVibe glyphs</p>
++ <h1 class="ic-h1">One hand for every icon.</h1>
++ <p class="ic-lede">${glyphs.NAMES.length} icons in the mark&rsquo;s own line: a 24-unit grid, strokes two units wide with round ends, in the colour of the text around them. They stand in for ${Object.keys(glyphs.FA).length} Font Awesome names, ${Math.round((100 * covered) / Math.max(1, total))}% of what the network&rsquo;s pages ask for.</p>
++ <div class="ic-acts">${kit.button({ href: '#draw', label: 'Draw an icon', icon: 'spark' })}${kit.button({ href: '#use', label: 'Use them', kind: 'quiet', icon: 'code' })}</div>
++</header>`;
++
++ const browse = `<section class="ic-sec" aria-labelledby="ic-all">
++ <h2 id="ic-all" class="ovk-sr">Every glyph</h2>
++ <div class="ic-tools" data-ic-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-ic-find></label>
++ <div class="ic-sizes" role="group" aria-label="Size">${[16, 20, 24, 32].map((n) => `<button type="button" class="ic-size" data-ic-size="${n}" aria-pressed="${n === 24 ? 'true' : 'false'}">${n}</button>`).join('')}</div>
++ </div>
++ <div class="ic-grid" data-ic-grid style="--ic-size:24px">${glyphs.NAMES.map(tile).join('')}</div>
++ <p class="ic-none" data-ic-none hidden>No glyph has that name yet. <a href="#draw" data-ic-draw-link>Draw it</a>.</p>
++</section>
++<dialog class="ic-sheet" data-ic-sheet aria-labelledby="ic-sheet-h"><div class="ic-sheet-in">
++ <div class="ic-sheet-head"><h2 id="ic-sheet-h" data-ic-sheet-name></h2><button type="button" class="ovk-icon-btn" data-ic-close aria-label="Close">${kit.icon('close')}</button></div>
++ <div class="ic-sheet-big" data-ic-sheet-big></div>
++ <p class="ic-sheet-meta" data-ic-sheet-meta></p>
++ <div class="ic-sheet-acts"><button type="button" class="ic-btn" data-ic-copy>Copy the SVG</button><a class="ic-btn" data-ic-download href="#" download>Download</a></div>
++</div></dialog>`;
++
++ const leftHtml = left.length ? `<section class="ic-sec" aria-labelledby="ic-left">
++ <div class="ic-sec-head"><h2 id="ic-left">Still Font Awesome</h2><p>The names the network&rsquo;s pages still ask Font Awesome for, most used first (counted ${esc(usage.counted)}). Each one drawn moves a site closer to dropping it. Brand logos stay theirs.</p></div>
++ <ul class="ic-left">${left.slice(0, 48).map((n) => `<li><a href="/icons?draw=${encodeURIComponent(n.name)}#draw">${esc(n.name)}</a><small>${n.uses}</small></li>`).join('')}</ul>
++ ${left.length > 48 ? `<p class="ic-more">And ${left.length - 48} more, each used once or twice.</p>` : ''}
++</section>` : '';
++
++ const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art">${svg(d.svg, 40)}${svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}</li>`;
++ const reviewForm = (d) => `<div class="ic-draft-acts">${['approve', 'reject'].map((a) => `<form method="post" action="/icons/drafts/${esc(d.id)}/review"><input type="hidden" name="action" value="${a}"><button type="submit" class="ic-btn${a === 'approve' ? ' is-go' : ''}">${a === 'approve' ? 'Approve' : 'Reject'}</button></form>`).join('')}</div>`;
++ const drawSec = `<section class="ic-sec" id="draw" aria-labelledby="ic-draw">
++ <div class="ic-sec-head"><h2 id="ic-draw">Draw an icon</h2><p>Name it and say what it stands for. OpenVibe.AI draws a few candidates in this hand; each is checked (strokes only, on the grid, a few shapes) before it is kept, and an admin reviews it before it joins the set.</p></div>
++ ${notice ? kit.notice({ kind: 'ok', text: notice }) : ''}${error ? kit.notice({ kind: 'error', text: error }) : ''}
++ <form class="ic-form" method="post" action="/icons/draft">
++ <label><span>Name</span><input name="name" required pattern="[a-z][a-z0-9-]{1,31}" maxlength="32" placeholder="hot-tub" value="${esc(draw)}" autocomplete="off" spellcheck="false"></label>
++ <label class="ic-grow"><span>What it stands for</span><input name="sentence" maxlength="200" placeholder="A tub with steam over it: a hot tub at a rental"></label>
++ <button type="submit" class="ic-btn is-go">${kit.icon('spark')} Draw it</button>
++ </form>
++ ${mine.length ? `<h3>Your drafts</h3><ul class="ic-drafts">${mine.map((d) => draftCard(d)).join('')}</ul>` : ''}
++ ${reviewer ? `<h3>Waiting for review</h3>${drafts.length ? `<ul class="ic-drafts">${drafts.map((d) => draftCard(d, reviewForm(d))).join('')}</ul>` : '<p class="ic-more">Nothing waiting.</p>'}` : ''}
++</section>`;
++
++ const use = `<section class="ic-sec" id="use" aria-labelledby="ic-use">
++ <div class="ic-sec-head"><h2 id="ic-use">Use them</h2><p>Every OpenVibe site already shows them in place of Font Awesome&rsquo;s (the navbar loads the drop-in). Anyone else can too; it is open source (MIT).</p></div>
++ <div class="ic-use">
++ <div><h3>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' })}</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>
++</section>`;
++
++ return layout({
++ title: 'The OpenVibe glyphs',
++ description: `${glyphs.NAMES.length} icons in one hand, the OpenVibe mark's: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.`,
++ path: '/icons', active: '/icons', styles: ['css/icons.css'], scripts: ['js/icons.js'],
++ body: `<article class="ic">${hero}${browse}${leftHtml}${drawSec}${use}</article>`,
++ });
++}
++
++/** 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 };
++}
++
++module.exports = { iconsPage, glyphsJson, stillFontAwesome };
ddiff --git a/test/icons.test.js b/test/icons.test.js
nnew file mode 100644
iindex 0000000..ff1718b
---- /dev/null
++++ b/test/icons.test.js
@@@ -0,0 +1,180 @@
++'use strict';
++// /icons: the OpenVibe glyphs (plan L32, 2026-10-31). The page shows every glyph with the Font Awesome names it stands
++// for and is whole without JavaScript; each glyph is a standalone SVG anyone may fetch; the registry is data; a signed-in
++// person asks OpenVibe.AI for drafts, which pass the validator before they are kept; only an admin reviews them, and
++// only approved drafts are public.
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const glyphs = require('openvibe-shared/glyphs');
++const { clean } = require('../server/glyphs/validate');
++const { stillFontAwesome } = require('../server/views/icons');
++
++const ORIGIN = 'https://openvibe.design';
++const asked = [];
++let answer = '';
++const ai = {
++ enabled: true,
++ text: async (req) => { asked.push(req); return { text: answer, provider: 'fake', model: 'fake-draw-1', run_id: 'run_1' }; },
++ slotPlan: async () => ({ slots: [] }),
++};
++const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++
++(async () => {
++ const t = await boot({ ai });
++ const ana = t.network.addUser('ana');
++ const boss = t.network.addUser('boss', { role: 'admin' });
++ try {
++ await check('the page shows every glyph with its Font Awesome names, the finder and the sheet', async () => {
++ const res = await t.get('/icons');
++ assert.equal(res.status, 200);
++ const html = res.text;
++ assert.ok(html.includes('<h1 class="ic-h1">One hand for every icon.</h1>'), 'its heading');
++ assert.equal((html.match(/class="ic-tile"/g) || []).length, glyphs.NAMES.length, 'one tile a glyph');
++ const eye = Object.entries(glyphs.FA).filter(([, g]) => g === 'eye').map(([fa]) => fa);
++ assert.ok(eye.length && html.includes(`data-name="eye" data-fa="${eye.join(' ')}"`), 'a tile carries its Font Awesome names for the search');
++ assert.ok(html.includes(`<path d="${glyphs.path('eye').match(/d="([^"]+)"/)[1]}"`), 'the glyph is drawn on the server');
++ assert.ok(html.includes('data-ic-tools hidden'), 'the finder waits for the script');
++ assert.ok(html.includes('<dialog class="ic-sheet" data-ic-sheet'), 'the one-glyph sheet');
++ assert.ok(html.includes('/css/icons.css') && html.includes('/js/icons.js'), 'its style and script');
++ assert.ok(html.includes('id="draw"') && html.includes('action="/icons/draft"'), 'Draw an icon');
++ assert.ok(!html.includes('Waiting for review'), 'no review list for a visitor');
++ assert.ok(html.includes('/shared/ov-glyphs.js'), 'the drop-in snippet');
++ });
++
++ await check('what is still Font Awesome: uncovered names only, brand logos aside, most used first', async () => {
++ const left = stillFontAwesome();
++ assert.ok(left.length > 0);
++ for (const n of left) assert.equal(glyphs.fromFa(n.name), null, `${n.name} has no glyph`);
++ assert.ok(!left.some((n) => n.name === 'youtube' || n.name === 'github'), 'brands stay theirs');
++ for (let i = 1; i < left.length; i++) assert.ok(left[i - 1].uses >= left[i].uses, 'most used first');
++ const html = (await t.get('/icons')).text;
++ assert.ok(html.includes(`href="/icons?draw=${encodeURIComponent(left[0].name)}#draw"`), 'each one links to Draw an icon');
++ });
++
++ await check('one glyph is a standalone SVG anyone may fetch; an unknown name is a 404', async () => {
++ const res = await t.get('/icons/eye.svg');
++ assert.equal(res.status, 200);
++ assert.match(res.headers.get('content-type'), /^image\/svg\+xml/);
++ assert.equal(res.headers.get('access-control-allow-origin'), '*');
++ assert.equal(res.text, glyphs.svg('eye', { size: 24, title: 'eye' }));
++ assert.equal((await t.get('/icons/not-a-glyph.svg')).status, 404);
++ assert.equal((await t.get('/icons/EYE.svg')).status, 404);
++ });
++
++ await check('the registry is data', async () => {
++ const res = await t.get('/api/v1/glyphs');
++ assert.equal(res.status, 200);
++ assert.equal(res.headers.get('access-control-allow-origin'), '*');
++ const j = res.json();
++ assert.equal(j.count, glyphs.NAMES.length);
++ const eye = j.glyphs.find((g) => g.name === 'eye');
++ assert.equal(eye.svg, glyphs.path('eye'));
++ assert.ok(eye.fa.includes('eye'));
++ assert.equal(j.fa['fa-eye'] || j.fa.eye, 'eye');
++ });
++
++ await check('the validator keeps strokes on the grid and refuses anything else', async () => {
++ const ok = clean('<path d="M4 12h16"/><circle cx="12" cy="12" r="8"/>');
++ assert.ok(ok.ok, ok.why);
++ assert.equal(ok.svg, '<path d="M4 12h16"/><circle cx="12" cy="12" r="8"/>');
++ for (const [bad, what] of [
++ ['<script>alert(1)</script>', 'a script'],
++ ['<path d="M4 12h16" onload="x()"/>', 'a handler'],
++ ['<image href="https://x.test/a.png"/>', 'an image'],
++ ['<path d="M4 12h16" style="fill:red"/>', 'a style'],
++ ['<path d="M400 12h16"/>', 'off the grid'],
++ [Array.from({ length: 12 }, (_, i) => `<line x1="2" y1="${i + 2}" x2="22" y2="${i + 2}"/>`).join(''), 'too many shapes'],
++ ['', 'nothing'],
++ ]) assert.equal(clean(bad).ok, false, what);
++ });
++
++ await check('drawing needs a signed-in person and this site\'s own form', async () => {
++ const anon = await t.get('/icons/draft', { method: 'POST', ...form({ name: 'hot-tub' }), origin: ORIGIN });
++ assert.equal(anon.status, 303);
++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);
++ const cross = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
++ assert.equal(cross.status, 403);
++ assert.equal(asked.length, 0, 'no AI call for either');
++ });
++
++ await check('OpenVibe.AI drafts candidates; the checked ones are kept, the rest refused', async () => {
++ answer = '```json\n' + JSON.stringify({ candidates: [
++ '<path d="M5 10h14v8H5z"/><path d="M9 6c0-1 1-1 1-2M14 6c0-1 1-1 1-2"/>',
++ '<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>',
++ '<path d="M5 10h14v8H5z" onclick="steal()"/>',
++ '<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>',
++ ] }) + '\n```';
++ const res = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub', sentence: 'A tub with steam over it' }), origin: ORIGIN });
++ assert.equal(res.status, 303);
++ assert.match(res.headers.get('location'), /^\/icons\?drafted=2&draw=hot-tub#draw$/);
++ assert.equal(asked.length, 1);
++ assert.ok(asked[0].prompt.includes('hot-tub') && asked[0].prompt.includes('A tub with steam over it'));
++ assert.deepEqual(asked[0].target, { service: 'design', type: 'glyph_draft', id: 'hot-tub' });
++ const page = await t.get('/icons?drafted=2&draw=hot-tub', { as: ana });
++ assert.ok(page.text.includes('2 candidates drawn'), 'the notice');
++ assert.ok(page.text.includes('<h3>Your drafts</h3>'), 'their drafts');
++ assert.equal((page.text.match(/class="ic-draft is-draft"/g) || []).length, 2);
++ assert.ok(page.text.includes('value="hot-tub"'), 'the form keeps the name');
++ assert.ok(!page.text.includes('onclick="steal()"'), 'the refused one is nowhere');
++ assert.ok(!page.text.includes('Waiting for review'), 'not a reviewer');
++ });
++
++ await check('a bad name or an empty answer says so, without a stored draft', async () => {
++ const bad = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'Hot Tub!' }), origin: ORIGIN });
++ assert.equal(bad.status, 303);
++ assert.match(new URLSearchParams(bad.headers.get('location').split('?')[1]).get('error'), /^a name is 2 to 32/);
++ answer = 'I cannot draw that.';
++ const none = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'cloud-sync' }), origin: ORIGIN });
++ assert.match(none.headers.get('location'), /drafted=0/);
++ const page = await t.get('/icons?drafted=0', { as: ana });
++ assert.ok(page.text.includes('No candidate passed the checks'));
++ });
++
++ let ids = [];
++ await check('only an admin sees and reviews the waiting drafts', async () => {
++ const asAna = await t.get('/api/v1/glyphs/drafts?status=draft', { as: ana });
++ assert.equal(asAna.status, 403);
++ const waiting = await t.get('/api/v1/glyphs/drafts?status=draft', { as: boss });
++ assert.equal(waiting.status, 200);
++ ids = waiting.json().drafts.map((d) => d.id);
++ assert.equal(ids.length, 2);
++ const page = await t.get('/icons', { as: boss });
++ assert.ok(page.text.includes('Waiting for review'));
++ assert.equal((page.text.match(/action="\/icons\/drafts\/gd_[0-9a-f]+\/review"/g) || []).length, 4, 'approve and reject on each');
++ const notAdmin = await t.get(`/icons/drafts/${ids[0]}/review`, { method: 'POST', as: ana, ...form({ action: 'approve' }), origin: ORIGIN });
++ assert.equal(notAdmin.status, 403);
++ const cross = await t.get(`/icons/drafts/${ids[0]}/review`, { method: 'POST', as: boss, ...form({ action: 'approve' }), origin: 'https://evil.test' });
++ assert.equal(cross.status, 403);
++ const ok = await t.get(`/icons/drafts/${ids[0]}/review`, { method: 'POST', as: boss, ...form({ action: 'approve' }), origin: ORIGIN });
++ assert.equal(ok.status, 303);
++ assert.match(ok.headers.get('location'), /reviewed=approved/);
++ const no = await t.get(`/icons/drafts/${ids[1]}/review`, { method: 'POST', as: boss, ...form({ action: 'reject' }), origin: ORIGIN });
++ assert.match(no.headers.get('location'), /reviewed=rejected/);
++ const missing = await t.get('/icons/drafts/gd_0000000000000000/review', { method: 'POST', as: boss, ...form({ action: 'approve' }), origin: ORIGIN });
++ assert.match(new URLSearchParams(missing.headers.get('location').split('?')[1]).get('error'), /^no such draft/);
++ });
++
++ await check('approved drafts are public data for openvibe-shared to take in; the others are not', async () => {
++ const res = await t.get('/api/v1/glyphs/drafts');
++ assert.equal(res.status, 200);
++ const list = res.json().drafts;
++ assert.equal(list.length, 1);
++ assert.equal(list[0].id, ids[0]);
++ assert.equal(list[0].name, 'hot-tub');
++ assert.equal(list[0].status, 'approved');
++ assert.ok(clean(list[0].svg).ok, 'what is served passed the validator');
++ assert.ok(!('subject' in list[0]), 'who asked stays private');
++ assert.equal((await t.get('/api/v1/glyphs/drafts?status=rejected')).status, 403);
++ assert.equal((await t.get('/api/v1/glyphs/drafts?status=nope', { as: boss })).status, 422);
++ });
++
++ await check('it is in the navbar, the sitemap and llms.txt', async () => {
++ assert.ok((await t.get('/sitemap.xml')).text.includes('/icons</loc>'), 'sitemap');
++ assert.ok((await t.get('/llms.txt')).text.includes('/icons'), 'llms.txt');
++ assert.ok(/"href":"\/icons"[^}]*"active":true/.test((await t.get('/icons')).text), 'the navbar marks it');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
iindex d0eaa20..978856b 100644
---- a/test/mcp.test.js
++++ b/test/mcp.test.js
@@@ -50,7 +50,7 @@ const components = require('openvibe-shared/components');
});
await check('component list and detail good and bad arguments', async () => {
const list = await call('openvibe_design_list_components');
-- assert.strictEqual(list.structuredContent.components.length, 11);
++ assert.strictEqual(list.structuredContent.components.length, Object.keys(components.SPECS).length);
assert.deepStrictEqual(Object.keys(list.structuredContent.components[0]), ['id', 'name', 'summary']);
assert.strictEqual((await call('openvibe_design_list_components', { extra: true })).isError, true);
const detail = await call('openvibe_design_get_component', { name: 'button' });
ddiff --git a/test/tokens.test.js b/test/tokens.test.js
iindex 9ebd544..901c7d7 100644
---- a/test/tokens.test.js
++++ b/test/tokens.test.js
@@@ -123,7 +123,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('the components API: the contract as JSON, and kit documents checked against it', async () => {
const spec = (await t.get('/api/v1/components')).json();
-- assert.strictEqual(spec.components.length, 11);
++ assert.strictEqual(spec.components.length, Object.keys(require('openvibe-shared/components').SPECS).length, 'every component openvibe-shared has');
assert.ok(spec.components.find((c) => c.id === 'card').native.swiftui.includes('struct OvCard'));
const good = await t.get('/api/v1/components/validate', { json: { doc: { type: 'rail', props: { title: 'Live' }, children: [{ type: 'card', props: { href: '/c/1', title: 'x' } }] } } });
assert.strictEqual(good.status, 200);