openvibers/openvibe.design Commits
/icons: the OpenVibe glyphs, what is still Font Awesome, and Draw an icon with OpenVibe.AI (#43)
Changes
nnew file mode 100644@@@ -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);@@@ -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",@@@ -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": {nnew file mode 100644@@@ -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}}nnew file mode 100644@@@ -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); }, () => {});++ });++ }++})();@@@ -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.',@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 filennew file mode 100644@@@ -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 };@@@ -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' },nnew file mode 100644@@@ -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 };@@@ -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) {@@@ -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 },nnew file mode 100644@@@ -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’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’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’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’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 };nnew file mode 100644@@@ -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); });@@@ -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' });@@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
"devDependencies": {
"devDependencies": {
@@ -1114,9 +1114,9 @@
@@ -1114,9 +1114,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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); }, () => {});
+ });
+ }
+})();
@@ -38,6 +38,7 @@ const { createDesignApi } = require('./http/design-api');
@@ -38,6 +38,7 @@ const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createDesigns } = require('./designs');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
+const { createIconRoutes } = require('./http/icons');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createArenaStore } = require('./arena/store');
const { createArenaStore } = require('./arena/store');
const { createArenaAi } = require('./arena/ai');
const { createArenaAi } = require('./arena/ai');
@@ -163,10 +164,11 @@ async function createApp(opts = {}) {
@@ -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('/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 }));
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/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api', express.json({ limit: '64kb' }));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/arena', createArenaApi(ctx));
+ app.use(createIconRoutes(ctx));
app.use('/api', createApi(ctx));
app.use('/api', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
detail: 'No such API route.',
detail: 'No such API route.',
@@ -295,7 +295,21 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
@@ -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 };
module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,1670 @@
@@ -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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
@@ -33,6 +33,7 @@ function publicPages(themes = []) {
@@ -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: '/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: '/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: '/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.' },
{ 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) => ({
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
@@ -73,6 +74,7 @@ function createDiscoveryRoutes(ctx) {
@@ -73,6 +74,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
+ { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand; 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 OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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 };
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'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
* 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.
* outcome: the round is gone). Ratings are aggregate and name no person, so they stay.
*
*
@@ -15,6 +15,9 @@ const TABLES = [
@@ -15,6 +15,9 @@ const TABLES = [
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
{ 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.
// 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'] },
{ 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) {
async function extraExport(db, subject) {
@@ -29,6 +29,7 @@ const NAV = [
@@ -29,6 +29,7 @@ const NAV = [
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Arena', href: '/arena', icon: 'fa-crown' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'Tokens', href: '/tokens', icon: 'fa-code' },
{ label: 'The mark', href: '/brand', icon: 'fa-star', more: true },
{ 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: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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’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’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’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’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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,180 @@
@@ -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); });
@@ -50,7 +50,7 @@ const components = require('openvibe-shared/components');
@@ -50,7 +50,7 @@ const components = require('openvibe-shared/components');
});
});
await check('component list and detail good and bad arguments', async () => {
await check('component list and detail good and bad arguments', async () => {
const list = await call('openvibe_design_list_components');
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.deepStrictEqual(Object.keys(list.structuredContent.components[0]), ['id', 'name', 'summary']);
assert.strictEqual((await call('openvibe_design_list_components', { extra: true })).isError, true);
assert.strictEqual((await call('openvibe_design_list_components', { extra: true })).isError, true);
const detail = await call('openvibe_design_get_component', { name: 'button' });
const detail = await call('openvibe_design_get_component', { name: 'button' });
@@ -123,7 +123,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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 () => {
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();
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'));
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' } }] } } });
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);
assert.strictEqual(good.status, 200);