openvibers/openvibe.design Commits

Icons: suggest one for a button, the draft price, and an element icon picker (#294)

OP OpenVibers committed 1 days ago · dff4f45505 · 16 files, +519 −12

Changes

ddiff --git a/public/css/design.css b/public/css/design.css
iindex f234959..771f460 100644
---- a/public/css/design.css
++++ b/public/css/design.css
@@@ -1132,3 +1132,14 @@ html[data-ov-motion=off] .ar-vote-pair[data-pick] .ar-pickable{transform: none}
.dv-canvas[data-kind="element"] .dv-card { flex: 0 0 min(100%, 420px); min-width: 0; }
.dv-canvas[data-kind="element"] .dv-frame--element { height: auto; min-height: 170px; display: grid; place-items: center; }
.dv-canvas[data-kind="element"] .dv-toolbar { display: none; }
++
++/* The element editor's icon picker (L32 I2): a field plus the glyphs suggested by the label's words. */
++.dv-elx-icon{display:inline-grid;place-items:center;width:1.1em;height:1.1em;margin-right:.35em;vertical-align:-.15em}
++.dv-elx-icon svg{width:100%;height:100%}
++.el-tweak--icon{grid-template-columns:minmax(0,1fr) minmax(0,2fr)}
++.el-icon-row{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
++.el-icon-pick{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:4px 10px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;font-size:12px;cursor:pointer}
++.el-icon-pick:hover,.el-icon-pick.is-on{border-color:var(--accent);color:var(--accent)}
++.el-icon-pick svg{width:18px;height:18px}
++.el-icon-pick span{color:var(--text-muted);font-size:11px}
++@media (max-width:760px){.el-tweak--icon{grid-template-columns:minmax(0,1fr)}}
ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex db1fcff..3362a03 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -268,3 +268,35 @@ html[data-ov-motion=off] .ic-tile:hover{transform:none}
.ic-packs{display:flex;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;padding-bottom:6px;margin-right:-16px;padding-right:16px}
.ic-packs li{flex:0 0 min(72%,240px);scroll-snap-align:start}
}
++
++/* Suggest an icon for a button's words (L32 I2): the free keyword answer, the model's ordering, and Draw when none fits. */
++.ic-suggest{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-suggest label{display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary)}
++.ic-suggest .ic-grow{flex:1 1 280px}
++.ic-suggest 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-suggest input:focus{outline:none;border-color:var(--accent)}
++.ic-suggest-out{margin-top:12px}
++.ic-suggs,.gp-suggs{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
++.ic-sugg,.gp-sugg{display:grid;justify-items:center;gap:4px;padding:12px 8px;border-radius:12px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);text-decoration:none;text-align:center}
++.ic-sugg:hover,.gp-sugg:hover{border-color:var(--accent)}
++.ic-sugg-ic svg,.gp-sugg svg{width:28px;height:28px;margin-bottom:2px}
++.ic-sugg b,.gp-sugg b{font-size:12.5px}
++.ic-sugg small,.gp-sugg small{color:var(--text-muted);font-size:11px;line-height:1.3}
++.ic-price{margin:0 0 10px;padding:12px 14px;border-radius:12px;border:1px solid color-mix(in srgb,var(--accent) 28%,var(--border));background:color-mix(in srgb,var(--accent) 7%,transparent);font-weight:700}
++.ic-price small{display:block;margin-top:2px;color:var(--text-muted);font-weight:400;font-size:12.5px}
++.gp-styles{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}
++.gp-style{margin:0;display:grid;justify-items:center;gap:4px;padding:12px 10px;min-width:76px;border-radius:12px;border:1px solid var(--border);background:var(--bg-secondary)}
++.gp-style span{display:grid;place-items:center;width:40px;height:40px}
++.gp-style figcaption{font-size:11.5px;color:var(--text-muted);text-transform:capitalize}
++.gp-style.is-missing{opacity:.6}
++.gp-tags{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 14px}
++.gp-tags span{padding:3px 9px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-muted);font-size:12px}
++.gp-find{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px;margin:0}
++.gp-find label{display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary);flex:1 1 240px}
++.gp-find input{min-height:40px;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}
++.gp-suggest{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)}
++.gp-suggest label{display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary)}
++.gp-suggest .ic-grow{flex:1 1 280px}
++.gp-suggest 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}
++.gp-suggest-out{margin-top:12px}
++@media (max-width:520px){.gp-styles{gap:6px}.gp-style{min-width:64px;padding:10px 6px}}
ddiff --git a/public/js/element-icon.js b/public/js/element-icon.js
nnew file mode 100644
iindex 0000000..7a8172a
---- /dev/null
++++ b/public/js/element-icon.js
@@@ -0,0 +1,38 @@
++/**
++ * The element editor's icon picker: as the label changes, ask GET /api/v1/glyphs/suggest for the best glyphs by
++ * meaning and draw them as one-tap choices. The field still takes any glyph or Font Awesome name; this only helps.
++ * Without JavaScript the server-rendered suggestions for the current label stay, and typing a name works.
++ */
++(function () {
++ 'use strict';
++ const label = document.querySelector('[data-el-label]');
++ const field = document.querySelector('[data-el-icon]');
++ const row = document.querySelector('[data-el-icon-row]');
++ if (!label || !field || !row) return;
++ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
++ let timer = null, seq = 0;
++ const render = (glyphs) => {
++ row.innerHTML = glyphs.length
++ ? glyphs.map((g) => `<button type="button" class="el-icon-pick" data-el-icon-pick="${esc(g.name)}"><b>${esc(g.name)}</b>${g.tags && g.tags.length ? `<span>${esc(g.tags.slice(0, 2).join(' · '))}</span>` : ''}</button>`).join('')
++ : '<span class="dv-fine">No close glyph yet: <a href="/icons#draw">draw one</a>.</span>';
++ };
++ const ask = async () => {
++ const words = label.value.trim();
++ if (!words) { row.innerHTML = '<span class="dv-fine">Type a label for suggestions.</span>'; return; }
++ const mine = ++seq;
++ try {
++ const r = await fetch(`/api/v1/glyphs/suggest?label=${encodeURIComponent(words)}&limit=5`, { headers: { accept: 'application/json' } });
++ if (mine !== seq) return;
++ const data = r.ok ? await r.json() : { glyphs: [] };
++ if (mine === seq) render(data.glyphs || []);
++ } catch { /* the field still works */ }
++ };
++ label.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(ask, 250); });
++ row.addEventListener('click', (e) => {
++ const b = e.target.closest('[data-el-icon-pick]');
++ if (!b) return;
++ field.value = b.dataset.elIconPick;
++ field.focus();
++ row.querySelectorAll('[data-el-icon-pick]').forEach((x) => x.classList.toggle('is-on', x === b));
++ });
++})();
ddiff --git a/public/js/icon.js b/public/js/icon.js
iindex ba39839..eeeb050 100644
---- a/public/js/icon.js
++++ b/public/js/icon.js
@@@ -88,3 +88,40 @@
});
}
})();
++
++// Suggest an icon for a button's words (L32 I2), on the glyph page too: GET /api/v1/glyphs/suggest,
++// then the drawings for the names it returns in one request. A weak answer is a door to Draw on /icons.
++(function () {
++ 'use strict';
++ const form = document.querySelector('[data-gp-suggest]');
++ if (!form) return;
++ const out = document.querySelector('[data-gp-suggest-out]');
++ const input = form.querySelector('input[name="label"]');
++ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
++ const icon = (d) => `<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
++ async function run() {
++ const words = input.value.trim();
++ if (!words) { out.hidden = true; return; }
++ out.hidden = false;
++ out.innerHTML = '<p class="ic-more">Looking for the nearest icons…</p>';
++ try {
++ const r = await fetch(`/api/v1/glyphs/suggest?label=${encodeURIComponent(words)}&limit=8&rank=1`, { headers: { accept: 'application/json' } });
++ const data = await r.json();
++ if (!r.ok) throw new Error(data.detail || 'Suggestions are unavailable.');
++ const names = (data.glyphs || []).map((g) => g.name);
++ let drawings = {};
++ if (names.length) {
++ const gr = await fetch(`/api/v1/glyphs?names=${encodeURIComponent(names.join(','))}`, { headers: { accept: 'application/json' } });
++ if (gr.ok) drawings = Object.fromEntries(((await gr.json()).glyphs || []).map((g) => [g.name, g.svg]));
++ }
++ const tiles = (data.glyphs || []).map((g) => `<a class="gp-sugg" href="/icons/${encodeURIComponent(g.name)}">${icon(drawings[g.name] || '')}<b>${esc(g.name)}</b><small>${esc((g.tags || []).slice(0, 3).join(' · '))}</small></a>`).join('');
++ const slug = words.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
++ out.innerHTML = `${data.ranked ? `<p class="ic-more">Ranked by a small model${data.model ? ` (${esc(data.model)})` : ''} for “${esc(words)}”.</p>` : ''}`
++ + (tiles ? `<div class="gp-suggs">${tiles}</div>` : '<p class="ic-more">No glyph has these words yet.</p>')
++ + `<p class="ic-more">${data.new ? 'Nothing fits well. ' : ''}<a href="${/^[a-z][a-z0-9-]{1,31}$/.test(slug) ? `/icons?draw=${encodeURIComponent(slug)}#draw` : '/icons#draw'}">${data.new ? 'Draw one' : 'Draw a different one'}</a>${data.draft_price && data.draft_price.usd != null ? ` · about $${Number(data.draft_price.usd) < 0.01 ? Number(data.draft_price.usd).toFixed(4) : Number(data.draft_price.usd).toFixed(2)} a draft` : ''}.</p>`;
++ } catch (err) {
++ out.innerHTML = `<p class="ic-more">${esc(err.message)}</p>`;
++ }
++ }
++ form.addEventListener('submit', (e) => { e.preventDefault(); run(); });
++})();
ddiff --git a/public/js/icons.js b/public/js/icons.js
iindex 0e51413..430c413 100644
---- a/public/js/icons.js
++++ b/public/js/icons.js
@@@ -41,6 +41,38 @@
if (drawLink && /^[a-z][a-z0-9-]{1,31}$/.test(q)) drawLink.href = `/icons?draw=${encodeURIComponent(q)}#draw`;
});
++ // The label used by a search from another page (?q=) filters as if it had been typed.
++ if (find && find.value.trim()) find.dispatchEvent(new Event('input'));
++
++ // Choose an icon for a button's words: GET /api/v1/glyphs/suggest. The free keyword answer comes at once; rank=1
++ // lets a small model order the shortlist when the words are new. A name with no good match is a door to Draw.
++ const suggest = $('[data-ic-suggest-form]');
++ if (suggest) {
++ const out = $('[data-ic-suggest-out]');
++ const label = $('[data-ic-suggest-label]', suggest);
++ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
++ const run = async () => {
++ const words = label.value.trim();
++ if (!words) { out.hidden = true; return; }
++ out.hidden = false;
++ out.innerHTML = '<p class="ic-more">Looking for the nearest icons…</p>';
++ try {
++ const r = await fetch(`/api/v1/glyphs/suggest?label=${encodeURIComponent(words)}&limit=8&rank=1`, { headers: { accept: 'application/json' } });
++ const data = await r.json();
++ if (!r.ok) throw new Error(data.detail || 'Suggestions are unavailable.');
++ const tiles = (data.glyphs || []).map((g) => `<a class="ic-sugg" href="/icons/${encodeURIComponent(g.name)}" title="${esc((g.tags || []).join(', '))}"><span class="ic-sugg-ic">${svgOf(g.name)}</span><b>${esc(g.name)}</b><small>${esc((g.tags || []).slice(0, 3).join(' · '))}</small></a>`).join('');
++ const draw = /^[a-z][a-z0-9-]{1,31}$/.test(words.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) ? words.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') : '';
++ out.innerHTML = `${data.ranked ? `<p class="ic-more">Ranked by a small model${data.model ? ` (${esc(data.model)})` : ''} for “${esc(words)}”.</p>` : ''}`
++ + (tiles ? `<div class="ic-suggs">${tiles}</div>` : '<p class="ic-more">No glyph has these words yet.</p>')
++ + `<p class="ic-more">${data.new ? 'Nothing fits well. ' : ''}<a href="${draw ? `/icons?draw=${encodeURIComponent(draw)}#draw` : '#draw'}">${data.new ? 'Draw one' : 'Draw a different one'}</a>${data.draft_price && data.draft_price.usd != null ? ` · about $${Number(data.draft_price.usd) < 0.01 ? Number(data.draft_price.usd).toFixed(4) : Number(data.draft_price.usd).toFixed(2)} a draft` : ''}.</p>`;
++ } catch (err) {
++ out.innerHTML = `<p class="ic-more">${esc(err.message)}</p>`;
++ }
++ };
++ suggest.addEventListener('submit', (e) => { e.preventDefault(); run(); });
++ label.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); run(); } });
++ }
++
// Sizes.
document.querySelectorAll('[data-ic-size]').forEach((b) => b.addEventListener('click', () => {
grids.forEach((g) => g.style.setProperty('--ic-size', `${b.dataset.icSize}px`));
ddiff --git a/server/create/element.js b/server/create/element.js
iindex fb57999..4664618 100644
---- a/server/create/element.js
++++ b/server/create/element.js
@@@ -14,6 +14,7 @@
*/
const { vocabulary: designVocabulary } = require('../components/registry');
++const glyphs = require('openvibe-shared/glyphs');
// The control components an element's look may name. Each is data: the component page draws it, the contract
// only checks the shape. `props` are the values the generated sliders move.
@@@ -236,6 +237,7 @@ function tweaksFor(doc, variables = {}) {
tweaks.push({ name: 'tint', label: 'Tint', kind: 'range', min: 0, max: choices.length - 1, step: 1, value: Math.max(0, choices.indexOf(current)), options: choices });
}
tweaks.push({ name: 'label', label: 'Label', kind: 'text', value: look.label || '', maxlength: 40 });
++ tweaks.push({ name: 'icon', label: 'Icon', kind: 'text', value: look.icon && glyphs.path(look.icon) ? look.icon : '', maxlength: 32 });
tweaks.push({ name: 'show_label', label: 'Show label', kind: 'checkbox', value: !(look.style && look.style.show_label === false) });
return tweaks;
}
@@@ -267,6 +269,15 @@ function applyTweaks(doc, values = {}, variables = {}) {
if (/https?:\/\//i.test(label) || /<[a-z!/]/i.test(label)) throw fail('The label cannot carry a link or markup.');
look.label = label;
}
++ if (values.icon != null) {
++ const raw = textOf(values.icon).trim().toLowerCase();
++ if (!raw) delete look.icon;
++ else {
++ const icon = glyphs.path(raw) ? raw : glyphs.fromFa(raw);
++ if (!icon) throw fail('Choose an icon from the OpenVibe glyphs, or leave it empty.');
++ look.icon = icon;
++ }
++ }
if (values.show_label != null) look.style.show_label = values.show_label === true || values.show_label === 'on' || values.show_label === '1';
return out;
}
@@@ -279,6 +290,7 @@ function renderLook(doc) {
const shape = style.shape === 'round' ? '50%' : style.shape === 'square' ? '8px' : '999px';
const opacity = style.opacity != null ? Math.max(0.4, Math.min(1, Number(style.opacity))) : 1;
const label = style.show_label === false ? '' : String(look.label || '');
++ const icon = look.icon && glyphs.path(look.icon) ? `<span class="dv-elx-icon" aria-hidden="true">${glyphs.svg(look.icon, { size: 18 })}</span>` : '';
const component = String(look.component || 'button');
const props = look.props || {};
let inner = '';
@@@ -299,7 +311,7 @@ function renderLook(doc) {
case 'sound-pad': inner = '<span class="dv-elx-pad" aria-hidden="true">♪</span>'; break;
default: inner = '';
}
-- return `<span class="dv-elx" data-component="${component}" style="--el-tint:${tint};--el-radius:${shape};opacity:${opacity}">${inner}${label ? `<span class="dv-elx-label">${esc(label)}</span>` : ''}<small>${component}</small></span>`;
++ return `<span class="dv-elx" data-component="${component}" style="--el-tint:${tint};--el-radius:${shape};opacity:${opacity}">${inner}${icon}${label ? `<span class="dv-elx-label">${esc(label)}</span>` : ''}<small>${component}</small></span>`;
}
function esc(value) {
ddiff --git a/server/glyphs/price.js b/server/glyphs/price.js
nnew file mode 100644
iindex 0000000..76882bc
---- /dev/null
++++ b/server/glyphs/price.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * What a glyph draft costs, before it is drawn (plan L32 I2). A draft is one OpenVibe.AI chat run (a second only when
++ * the first answer fails the checks), so the estimate is the cheapest chat offer's price for about 900 input and 1,800
++ * output tokens — the same maxTokens drafts.js asks for. Prices are per million tokens, as Exchange publishes them.
++ *
++ * Nothing here spends: it reads the catalogue and caches the answer for ten minutes. When the catalogue is
++ * unavailable the page says the charge is shown on the person's Balance rather than inventing a number.
++ *
++ * createGlyphPrice({ ai, log, ttlMs }).get() → { usd, model, provider, tokens, note } | { usd: null, note }
++ */
++const TTL = 10 * 60 * 1000;
++const TOKENS = { in: 900, out: 1800 };
++
++function createGlyphPrice({ ai = null, log = console, now = () => Date.now(), ttlMs = TTL } = {}) {
++ let cached = { at: 0, value: null };
++ const empty = () => ({ usd: null, model: null, provider: null, tokens: { ...TOKENS }, note: 'The charge is shown on your Balance before the run.' });
++
++ async function get() {
++ if (cached.value && now() - cached.at < ttlMs) return cached.value;
++ if (!ai || typeof ai.exchangeCatalog !== 'function') return empty();
++ let docs;
++ try { docs = await ai.exchangeCatalog({ kind: 'chat' }); }
++ catch (err) { log.warn('[OpenVibe.Design] glyph price unavailable:', err && err.message); return empty(); }
++ const models = Array.isArray(docs && docs.models) ? docs.models.filter((m) => !m.kind || m.kind === 'chat') : [];
++ const priced = [];
++ for (const doc of models) {
++ for (const offer of (Array.isArray(doc.offers) ? doc.offers : [])) {
++ const pin = offer && offer.price ? Number(offer.price.in) : NaN;
++ const pout = offer && offer.price ? Number(offer.price.out) : NaN;
++ if (!Number.isFinite(pin) || !Number.isFinite(pout)) continue;
++ priced.push({ usd: (pin * TOKENS.in + pout * TOKENS.out) / 1e6, model: String(doc.name || offer.model || 'a model').slice(0, 80), provider: String(offer.provider_name || offer.provider || '').slice(0, 60), pin, pout });
++ }
++ }
++ priced.sort((a, b) => a.usd - b.usd);
++ const best = priced[0];
++ const value = best
++ ? { usd: best.usd, model: best.model, provider: best.provider || null, tokens: { ...TOKENS }, note: `About ${TOKENS.in.toLocaleString('en-US')} in and ${TOKENS.out.toLocaleString('en-US')} out tokens at the cheapest model. A signed-in person's own key or Balance pays; signed out, the free guest allowance does.` }
++ : empty();
++ cached = { at: now(), value };
++ return value;
++ }
++
++ /** The last answer, without fetching: for a suggest response that should not wait on the catalogue. */
++ const peek = () => cached.value || null;
++
++ return { get, peek, TOKENS };
++}
++
++module.exports = { createGlyphPrice, TOKENS };
ddiff --git a/server/glyphs/suggest.js b/server/glyphs/suggest.js
nnew file mode 100644
iindex 0000000..bb73865
---- /dev/null
++++ b/server/glyphs/suggest.js
@@@ -0,0 +1,174 @@
++'use strict';
++/**
++ * Choosing an icon for a button's words (plan L32 I2): `GET /api/v1/glyphs/suggest?label=&context=`.
++ *
++ * The first pass is free and instant: every glyph is scored by its own name, the keywords in openvibe-shared's
++ * META, its Font Awesome names and Design's synonyms (aliases.js). That is enough for "Deep dive" (compass, search,
++ * binoculars), "Add to cart" (cart, plus, store) and most labels a person writes.
++ *
++ * When the words are new ("hot tub", "gpu benchmark") and the free pass is weak, the caller may ask a small model to
++ * rank the shortlist by meaning (`rank=1`). Only names that exist are returned; a model answer that invents one is
++ * ignored. A signed-in person's allowance pays (OpenVibe.AI's `on_behalf_of`); signed out, Design's service does,
++ * and OpenVibe.AI picks the cheapest route. Answers are cached for half an hour.
++ *
++ * suggest(label, { context, limit, rank, ai, person, clock }) →
++ * { label, context, glyphs: [{ name, score, tags, why }], new: bool, ranked: bool, model }
++ */
++const glyphs = require('openvibe-shared/glyphs');
++const aliases = require('./aliases');
++
++const STOP = new Set(('a an the to for of and or in on at by with from into over under my your our their this that these those ' +
++ 'button btn icon icons action link page tab menu open new add more go start').split(' '));
++// Phrases whose meaning is not in a glyph's own words ("deep dive" is explore + research, not a dive), kept short:
++// the shared META keywords do the rest once a release carries them.
++const CONCEPTS = [
++ [/deep[ -]?dive|research|investigat/, ['compass', 'search', 'binoculars', 'telescope', 'microscope']],
++ [/hot[ -]?tub|spa\b/, ['hot-tub', 'water', 'drop']],
++ [/gpu|graphics card/, ['gpu', 'cpu', 'chip', 'memory']],
++ [/benchmark|performance|speed test/, ['gauge', 'chart', 'clock', 'cpu']],
++ [/sign[ -]?in|log[ -]?in/, ['enter', 'key', 'user']],
++ [/sign[ -]?up|join|register/, ['invite', 'user', 'plus']],
++ [/sign[ -]?out|log[ -]?out/, ['leave', 'exit', 'door']],
++ [/add to (cart|bag)|buy\b|checkout/, ['cart', 'plus', 'coin']],
++ [/watch|live\b|stream/, ['live', 'play', 'eye']],
++ [/settings|preferences/, ['gear', 'sliders']],
++ [/profile|account/, ['user', 'user-circle']],
++ [/notification|alert/, ['bell', 'alert']],
++ [/delete|remove|trash/, ['trash', 'minus']],
++ [/edit|rename|write/, ['pencil', 'text']],
++ [/favorite|like\b|love\b/, ['heart', 'star', 'thumbs-up']],
++ [/share|send\b/, ['share', 'open', 'send']],
++ [/message|chat|comment/, ['chat', 'mail', 'reply']],
++ [/price|cost\b|pay\b/, ['dollar', 'coin', 'wallet', 'money']],
++ [/refresh|reload|again/, ['retry', 'history']],
++];
++const TTL = 30 * 60 * 1000;
++const cache = new Map();
++
++/** The words in a label or a context: camelCase split, lower case, no punctuation, no filler. */
++function tokens(text) {
++ return [...new Set(String(text || '')
++ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
++ .toLowerCase()
++ .split(/[^a-z0-9]+/)
++ .filter((t) => t.length > 1 && !STOP.has(t)))].slice(0, 24);
++}
++
++// How much a match is worth: an exact name word beats a keyword, which beats another set's name.
++function points(token, words) {
++ let best = 0;
++ for (const w of words) {
++ if (!w) continue;
++ if (w === token) best = Math.max(best, 40);
++ else if (w.startsWith(token)) best = Math.max(best, 22);
++ else if (token.length > 2 && w.includes(token)) best = Math.max(best, 8);
++ else if (w.length > 3 && token.includes(w)) best = Math.max(best, 6);
++ }
++ return best;
++}
++
++/** The free score: no model, no network. Exported for the tests and for pages that draw as a person types. */
++function score(label, context = '') {
++ const raw = String(label || '').toLowerCase();
++ const labels = tokens(label);
++ const around = tokens(context);
++ const joined = labels.join(' ');
++ const out = [];
++ for (const name of glyphs.NAMES) {
++ const nameWords = name.split('-');
++ const tags = (glyphs.META[name] && glyphs.META[name].tags || []).flatMap((t) => String(t).split(/[^a-z0-9]+/));
++ const fa = Object.entries(glyphs.FA).filter(([, g]) => g === name).flatMap(([f]) => f.split('-'));
++ const syn = aliases.words(name).flatMap((w) => String(w).split(/[^a-z0-9]+/));
++ const why = [];
++ let total = 0;
++ for (const t of labels) {
++ const p = Math.max(points(t, nameWords) * 1.5, points(t, tags), points(t, syn), points(t, fa) * 0.85);
++ if (p) { total += p; why.push(t); }
++ }
++ for (const t of around) {
++ const p = Math.max(points(t, nameWords), points(t, tags), points(t, syn), points(t, fa) * 0.8);
++ if (p) total += p * 0.45;
++ }
++ for (const [re, names] of CONCEPTS) {
++ if (!re.test(raw) && !re.test(String(context || '').toLowerCase()) && !re.test(around.join(' '))) continue;
++ const at = names.indexOf(name);
++ if (at >= 0) { total += Math.max(60 - at * 10, 18); why.push('phrase'); }
++ }
++ if (joined && (name === joined || name === joined.replace(/\s+/g, '-'))) { total += 90; why.unshift(name); }
++ else if (joined && name.includes(joined.replace(/\s+/g, '-'))) { total += 28; }
++ if (total > 0) out.push({ name, score: Math.round(total * 10) / 10, tags: ((glyphs.META[name] && glyphs.META[name].tags || []).length ? glyphs.META[name].tags : aliases.words(name)).slice(0, 6), why: [...new Set(why)].slice(0, 4) });
++ }
++ return out.sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : 1));
++}
++
++const KEY = (label, context, person, rank) => `${String(person || '')}|${rank ? 1 : 0}|${String(label || '').toLowerCase().slice(0, 80)}|${String(context || '').toLowerCase().slice(0, 80)}`;
++
++function cached(key, now) {
++ const hit = cache.get(key);
++ if (!hit) return null;
++ if (hit.at + TTL < now) { cache.delete(key); return null; }
++ return hit.value;
++}
++function remember(key, value, now) {
++ if (cache.size > 800) cache.clear();
++ cache.set(key, { at: now, value });
++}
++
++const SYSTEM = 'You choose icons for buttons. You are given a label, optional context and a short list of icon names with their keywords. Reply with JSON only: {"names":["<glyph-name>", …]} of up to six names from the list, best first. Never invent a name; when nothing fits, reply {"names":[]}.';
++
++async function askModel({ label, context, shortlist, ai, person }) {
++ const list = shortlist.map((g) => `${g.name}: ${g.tags.concat(g.why).join(', ')}`).join('\n');
++ const answer = await ai.text({
++ system: SYSTEM,
++ prompt: `Label: "${String(label).slice(0, 120)}"${context ? `\nWhere it sits: "${String(context).slice(0, 160)}"` : ''}\n\nThe icons:\n${list}`,
++ maxTokens: 220,
++ task: 'design.icon_suggest',
++ target: { service: 'design', type: 'glyph_suggest', id: String(label).slice(0, 60).replace(/[^a-z0-9]+/gi, '-').toLowerCase() || 'suggest' },
++ ...(person ? { person } : {}),
++ });
++ let names = [];
++ 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));
++ names = Array.isArray(j.names) ? j.names : Array.isArray(j.glyphs) ? j.glyphs.map((g) => typeof g === 'string' ? g : g && g.name) : [];
++ } catch { names = []; }
++ const byName = new Map(shortlist.map((g) => [g.name, g]));
++ const ranked = [];
++ for (const raw of names) {
++ const name = String(raw || '').trim().toLowerCase();
++ if (byName.has(name) && !ranked.some((g) => g.name === name)) ranked.push(byName.get(name));
++ }
++ return { ranked, model: [answer.provider, answer.model].filter(Boolean).join(' · ') || null };
++}
++
++async function suggest(label, { context = '', limit = 8, rank = false, ai = null, person = null, now = Date.now() } = {}) {
++ const clean = String(label || '').trim().slice(0, 120);
++ const around = String(context || '').trim().slice(0, 160);
++ const n = Math.min(Math.max(Number(limit) || 8, 1), 24);
++ const key = KEY(clean, around, person, rank);
++ const was = cached(key, now);
++ if (was) return { ...was, cached: true };
++ const scored = score(clean, around);
++ const labelWords = tokens(clean);
++ const direct = scored.some((g) => labelWords.some((t) => g.name.split('-').includes(t) || (g.tags || []).some((tag) => String(tag).split(/[^a-z0-9]+/).includes(t))));
++ const weak = !scored.length || !direct || scored[0].score < 26;
++ let glyphsOut = scored.slice(0, Math.max(n, 12));
++ let ranked = false, model = null;
++ if (rank && weak && ai && ai.enabled) {
++ try {
++ const aiOut = await askModel({ label: clean, context: around, shortlist: glyphsOut.slice(0, 20), ai, person });
++ if (aiOut.ranked.length) {
++ const got = new Map(aiOut.ranked.map((g) => [g.name, g]));
++ glyphsOut = aiOut.ranked.map((g) => ({ ...g, why: [...new Set([...(g.why || []), 'model'])], score: g.score + 5 }));
++ for (const g of scored) if (!got.has(g.name) && glyphsOut.length < n) glyphsOut.push(g);
++ ranked = true;
++ model = aiOut.model;
++ }
++ } catch { /* the free answer is still useful */ }
++ }
++ const value = { label: clean, context: around, glyphs: glyphsOut.slice(0, n), new: !glyphsOut.length || glyphsOut[0].score < 26, ranked, model };
++ remember(key, value, now);
++ return value;
++}
++
++module.exports = { suggest, score, tokens, SYSTEM };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex c0a48d8..79c25d7 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -120,7 +120,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Shared releases', url: abs('/changes'), note: 'every openvibe-shared release in the pinned changelog, newest first, with the component pages and rule numbers it touches and the screenshots the repo holds. JSON: GET /api/v1/changes' },
{ title: 'The design system', url: abs('/system'), note: 'every reference page in four groups: foundations, components, guidance, platforms' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
-- { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
++ { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); GET /api/v1/glyphs/suggest?label=&context=&rank=1 proposes the best glyphs for the words on a button (rank=1 asks a small model when the words are new); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
{ title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs', html: true },
{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex cfddc41..da824f9 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -38,6 +38,8 @@ const kits = require('../glyphs/kits');
const ex = require('../glyphs/export');
const { createGlyphDrafts } = require('../glyphs/drafts');
const { createReferenceSearch } = require('../glyphs/references');
++const { suggest } = require('../glyphs/suggest');
++const { createGlyphPrice } = require('../glyphs/price');
const formats = require('../glyphs/formats');
const fs = require('fs');
const path = require('path');
@@@ -52,6 +54,7 @@ function createIconRoutes(ctx) {
const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };
const referenceSearch = ctx.referenceSearch || createReferenceSearch({ config });
const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps, referenceSearch });
++ const glyphPrice = ctx.glyphPrice || createGlyphPrice({ ai, log });
// A guest (L50 step 5): a random id in an httpOnly cookie, stored only as a keyed hash; the address only as a hash
// salted with the day. The key is the service's own secret (a new one each start where none is set: development).
const GUEST_COOKIE = 'ovd_guest';
@@@ -139,13 +142,15 @@ function createIconRoutes(ctx) {
const subject = subjectOf(req);
const reviewer = isAdmin(req);
const guest = subject ? null : guestOf(req, res);
-- const [mine, waiting] = await Promise.all([
++ const [mine, waiting, price] = await Promise.all([
subject ? drafts.list({ subject, limit: 24 }).catch(() => []) : guest ? drafts.list({ subject: `guest:${guest.id}`, limit: 24 }).catch(() => []) : [],
reviewer ? drafts.list({ status: 'draft', limit: 60 }).catch(() => []) : [],
++ glyphPrice.get().catch(() => null),
]);
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, guest: !subject ? { perDay: guestCaps.perGuest, turnstile: turnstileOn ? guestCaps.turnstileSiteKey : '' } : null }), { viewer: req.viewer, ...(guest ? { cacheControl: 'private, no-store' } : {}) });
++ const q = String(req.query.q || '').slice(0, 100);
++ send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw, q, price, guest: !subject ? { perDay: guestCaps.perGuest, turnstile: turnstileOn ? guestCaps.turnstileSiteKey : '' } : null }), { viewer: req.viewer, ...(guest ? { cacheControl: 'private, no-store' } : {}) });
});
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
@@@ -321,6 +326,25 @@ function createIconRoutes(ctx) {
}).filter(Boolean);
res.json({ count: out.length, glyphs: out });
});
++ // The icons for a button's words (plan L32 I2): free keyword ranking first; ?rank=1 asks a small model when the
++ // words are new. The answer is cached for half an hour, so a page can ask as a person types.
++ r.get('/api/v1/glyphs/suggest', async (req, res) => {
++ const label = String(req.query.label || '').trim().slice(0, 120);
++ if (!label) return problem(res, req, 422, 'design.suggest_label_required', 'Send ?label= the words on the button, up to 120 characters.');
++ const context = String(req.query.context || '').trim().slice(0, 160);
++ const limit = Math.min(Math.max(Number(req.query.limit) || 8, 1), 24);
++ // A model call costs: only this site's own pages may ask for one (the free keyword answer stays public).
++ const rank = (req.query.rank === '1' || req.query.rank === 'true') && sameOrigin(req, config);
++ const person = subjectOf(req);
++ try {
++ const out = await suggest(label, { context, limit, rank, ai, person });
++ const price = glyphPrice.peek();
++ res.set('Cache-Control', rank || person ? 'private, no-store' : 'public, max-age=120').set(OPEN).json({ ...out, ...(price ? { draft_price: price } : {}) });
++ } catch (err) {
++ log.warn('[OpenVibe.Design] glyph suggest failed:', err && err.message);
++ return problem(res, req, 503, 'design.suggest_unavailable', 'Suggestions are unavailable for a moment.');
++ }
++ });
r.get('/api/v1/glyphs/search', (req, res) => {
const q = String(req.query.q || '').slice(0, 100);
const names = aliases.search(q, Math.min(Math.max(Number(req.query.limit) || 24, 1), 60));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex df3e68e..8641c95 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -221,7 +221,7 @@ function createPageRoutes(ctx) {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
try {
await ctx.designs.tweakElement({ id: req.params.id, values: { size: req.body.size, tint: req.body.tint,
-- label: req.body.label, show_label: req.body.show_label === '1' }, owner: subjectOf(req), key: req.body.key || '', viewer: req.viewer });
++ label: req.body.label, icon: req.body.icon, show_label: req.body.show_label === '1' }, owner: subjectOf(req), key: req.body.key || '', viewer: req.viewer });
return elementBack(req, res, req.body.key ? `?key=${encodeURIComponent(req.body.key)}` : '');
} catch (err) { return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not apply the tweak.'); }
});
ddiff --git a/server/views/element-preview.js b/server/views/element-preview.js
iindex 8f43035..3abf8d3 100644
---- a/server/views/element-preview.js
++++ b/server/views/element-preview.js
@@@ -82,13 +82,16 @@ function tweakForm({ design, variant, editKey }) {
const tweaks = (variant && variant.tweaks) || [];
const ranges = tweaks.filter((t) => t.kind === 'range');
const label = tweaks.find((t) => t.name === 'label');
++ const icon = tweaks.find((t) => t.name === 'icon');
const show = tweaks.find((t) => t.name === 'show_label');
++ const suggested = icon ? require('../glyphs/suggest').score(label ? label.value : '').slice(0, 5) : [];
if (!tweaks.length) return '';
return `<form class="el-tweaks" method="post" action="/d/${encodeURIComponent(design.id)}/element/tweak">
<input type="hidden" name="key" value="${esc(editKey)}">
<fieldset><legend>Make it yours</legend>
${ranges.map((t) => `<label class="el-tweak"><span>${esc(t.label)}</span><input type="range" name="${esc(t.name)}" min="${Number(t.min)}" max="${Number(t.max)}" step="${Number(t.step)}" value="${Number(t.value)}" list="el-${esc(t.name)}-values"><datalist id="el-${esc(t.name)}-values">${(t.options || []).map((o, i) => `<option value="${i}">${esc(o)}</option>`).join('')}</datalist><small>${esc((t.options || [])[Number(t.value)] || '')}</small></label>`).join('')}
--${label ? `<label class="el-tweak"><span>${esc(label.label)}</span><input type="text" name="label" maxlength="${Number(label.maxlength) || 40}" value="${esc(label.value)}"></label>` : ''}
++${label ? `<label class="el-tweak"><span>${esc(label.label)}</span><input type="text" name="label" maxlength="${Number(label.maxlength) || 40}" value="${esc(label.value)}" data-el-label></label>` : ''}
++${icon ? `<label class="el-tweak el-tweak--icon"><span>${esc(icon.label)}</span><input type="text" name="icon" maxlength="${Number(icon.maxlength) || 32}" value="${esc(icon.value)}" placeholder="compass" autocomplete="off" spellcheck="false" data-el-icon><small>Suggested for “${esc(label ? label.value : '')}”:</small><span class="el-icon-row" data-el-icon-row>${suggested.map((g) => `<button type="button" class="el-icon-pick" data-el-icon-pick="${esc(g.name)}">${kit.icon(g.name)}<span>${esc(g.name)}</span></button>`).join('') || '<span class="dv-fine">Type a label for suggestions.</span>'}</span><small>Any OpenVibe glyph name, or a Font Awesome name. Leave it empty for no icon.</small></label>` : ''}
${show ? `<label class="el-tweak el-tweak--check"><input type="checkbox" name="show_label" value="1"${show.value ? ' checked' : ''}> ${esc(show.label)}</label>` : ''}
${kit.button({ label: 'Apply', type: 'submit', kind: 'quiet' })}
</fieldset></form>`;
@@@ -153,7 +156,7 @@ ${design ? buildWithCodes({ element, design }) : ''}
title: `${element.name} · Element`, description: element.description || `A free ${element.look.component} element made with OpenVibe.Design.`,
path: o.path || (design ? `/d/${design.id}/element/preview` : `/elements/${o.listingId || ''}/try`),
robots: o.robots || (design ? 'noindex, nofollow' : 'index, follow'),
-- body, scripts: ['js/sim-world.js', 'js/element-sim.js'], active: design ? '/create' : '/gallery',
++ body, scripts: ['js/sim-world.js', 'js/element-sim.js', 'js/element-icon.js'], active: design ? '/create' : '/gallery',
});
}
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
iindex fc0ca9f..b6f239f 100644
---- a/server/views/icon-page.js
++++ b/server/views/icon-page.js
@@@ -49,6 +49,23 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
const sel = (field, list, cur, label) => `<label class="gp-field"><span>${label}</span><select name="${field}" data-gp="${field}">${list.map(([v, l]) => `<option value="${esc(v)}"${String(v) === String(cur) ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
const range = (field, min, max, step, cur, label, unit) => `<label class="gp-field"><span>${label} <output data-gp-out="${field}">${cur}${unit}</output></span><input type="range" name="${field}" min="${min}" max="${max}" step="${step}" value="${cur}" data-gp="${field}"></label>`;
++ const styles = [['thin', 1], ['light', 1.5], ['regular', 2], ['bold', 2.5], ['solid', 2], ['duotone', 2]];
++ const styleRow = styles.map(([style, width]) => {
++ const solidMissing = style === 'solid' && !glyphs.solid(name);
++ return `<figure class="gp-style${solidMissing ? ' is-missing' : ''}"><span>${glyphs.svg(name, { size: 40, style: style === 'solid' && solidMissing ? 'regular' : style })}</span><figcaption>${style}${solidMissing ? ' (line)' : ''}</figcaption></figure>`;
++ }).join('');
++ const tags = (glyphs.META[name] && glyphs.META[name].tags || []).slice(0, 16);
++ const keywords = `<section class="ic-sec gp-keywords" aria-labelledby="gp-keywords">
++ <div class="ic-sec-head"><h2 id="gp-keywords">Names and keywords</h2><p>The names it answers to and the words people search with. <code>${esc(name)}</code> is its registry name${fa.length ? `; it stands in for ${fa.slice(0, 8).map((f) => `<code>fa-${esc(f)}</code>`).join(', ')}${fa.length > 8 ? ` and ${fa.length - 8} more` : ''}` : ''}.</p></div>
++ <div class="gp-styles" aria-label="Every style">${styleRow}</div>
++ <p class="gp-tags">${tags.map((t) => `<span>${esc(t)}</span>`).join('')}</p>
++ <form class="gp-find" action="/icons" method="get" role="search"><label for="gp-q">Find another glyph</label><input id="gp-q" type="search" name="q" placeholder="a name, a keyword or a Font Awesome name" autocomplete="off"><button type="submit" class="ic-btn">Search the set</button></form>
++</section>
++<section class="ic-sec" id="gp-suggest" aria-labelledby="gp-suggest-h">
++ <div class="ic-sec-head"><h2 id="gp-suggest-h">Suggest one for a button</h2><p>Type what the button says. The set&rsquo;s names and keywords answer first; a small model orders the shortlist when the words are new. The API is <code>GET /api/v1/glyphs/suggest?label=</code>.</p></div>
++ <form class="gp-suggest" data-gp-suggest><label class="ic-grow"><span>Button label</span><input name="label" maxlength="120" placeholder="Deep dive, add to cart, go live…" autocomplete="off"></label><button type="submit" class="ic-btn is-go">${kit.icon('spark')} Suggest</button></form>
++ <div class="gp-suggest-out" data-gp-suggest-out hidden></div>
++</section>`;
const hero = `<nav class="gp-crumbs" aria-label="Breadcrumb"><a href="/icons">Icons</a><span aria-hidden="true">/</span><span>${esc(name)}</span></nav>
<header class="gp-hero">
<figure class="gp-stage">${glyph(name, 168, 'gp-big')}<figcaption>On the 24-unit grid, strokes two units wide</figcaption></figure>
@@@ -76,7 +93,8 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
? `At ${ex.SOLID_BELOW} px and under it switches to its solid drawing, which stays clear when small; above that it is the line. Sites do it with no code, and the files below do it on <code>style=auto</code>.`
: 'Its lines grow a little heavier when small, so it stays clear; it has no solid drawing because it is made of lines.'}</p>
${['light', 'dark'].map((t) => `<div class="gp-sizes is-${t}" role="img" aria-label="${esc(name)} at ${LADDER.join(', ')} pixels, on ${t}">${LADDER.map((sz) => `<span style="font-size:${sz}px">${glyphAt(name, sz)}<i>Aa</i><small>${sz}</small></span>`).join('')}</div>`).join('')}
--</section>`;
++</section>
++${keywords}`;
const maker = `<section class="ic-sec" id="png" aria-labelledby="gp-png">
<div class="ic-sec-head"><h2 id="gp-png">Make a PNG</h2><p>Any size up to 1024 pixels at 1×, 2× or 3×, in any colour or the mark&rsquo;s two, a lighter or heavier line, flat or with the mark&rsquo;s depth and glow, room around it and a ground behind it. The link rebuilds the same file every time.</p></div>
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex 6acaad1..574b705 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -40,7 +40,7 @@ const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="
// A draft's solid twin (L50): filled, its class="ln" parts drawn as lines (icons.css .is-solid).
const solidSvg = (d, size = 24) => `<svg class="is-solid" 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 = '', guest = null } = {}) {
++function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '', q = '', price = null, guest = null } = {}) {
const fa = faByGlyph();
const left = stillFontAwesome();
const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);
@@@ -69,7 +69,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
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>
++ <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 value="${esc(q)}"></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>
<p class="ic-grid-cap">The ${FIRST} the network uses most</p>
@@@ -110,6 +110,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
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 before review. House rules: centre on the 24-unit grid, long side 18–20 units, 1/1.5/2/2.5-unit line weights, and 2.5-unit gaps so shapes stay clear at 16 px. Solids should stay under 2.6 times the line ink.</p></div>
${notice ? kit.notice({ kind: 'ok', text: notice }) : ''}${error ? kit.notice({ kind: 'error', text: error }) : ''}
++ ${price ? `<p class="ic-price">${price.usd != null ? `About $${Number(price.usd) < 0.01 ? Number(price.usd).toFixed(4) : Number(price.usd).toFixed(2)} a draft` : 'One AI run a draft'}<small>${esc(price.note || '')}${price.model ? ` Cheapest now: ${esc(price.model)}.` : ''} Prices before the draft, never after.</small></p>` : ''}
<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>
@@@ -140,15 +141,23 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
<div><h3>As a font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-eye"></i>`, id: 'ic-use-font', label: 'Copy the HTML' })}</div>
<div><h3>On a server</h3>${kit.snippet({ code: "const glyphs = require('openvibe-shared/glyphs');\n\nglyphs.svg('live', { size: 20 }); // a standalone SVG\nglyphs.fromFa('fa-eye'); // 'eye'", id: 'ic-use-node', label: 'Copy the code' })}</div>
<div><h3>Switching from another set?</h3><p class="ic-more">Font Awesome, Lucide, Material or MUI: <a href="/icons/migrate">paste your page</a> and see every icon with its OpenVibe twin. Their names work in <code>&lt;ov-icon name="lucide:settings"&gt;</code> too.</p></div>
-- <div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/api/v1/glyphs?names=heart,fa-gear\nGET ${base}/api/v1/glyphs/search?q=heart\nGET ${base}/api/v1/glyphs/<name>?style=thin|light|regular|bold|solid|duotone\nPOST ${base}/api/v1/glyphs/draft\nGET ${base}/icons/<name>.svg?style=auto|line|solid`, id: 'ic-use-api', label: 'Copy' })}</div>
++ <div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/api/v1/glyphs?names=heart,fa-gear\nGET ${base}/api/v1/glyphs/search?q=heart\nGET ${base}/api/v1/glyphs/suggest?label=deep%20dive\nGET ${base}/api/v1/glyphs/<name>?style=thin|light|regular|bold|solid|duotone\nPOST ${base}/api/v1/glyphs/draft\nGET ${base}/icons/<name>.svg?style=auto|line|solid`, id: 'ic-use-api', label: 'Copy' })}</div>
</div>
</section>`;
++ const suggestSec = `<section class="ic-sec" id="suggest" aria-labelledby="ic-suggest">
++ <div class="ic-sec-head"><h2 id="ic-suggest">Choose an icon for a button</h2><p>Type what the button says. The set&rsquo;s names and keywords answer first; for words that are new, a small model ranks the shortlist, and when nothing fits, draw one. The same API is <code>GET /api/v1/glyphs/suggest?label=</code>.</p></div>
++ <form class="ic-suggest" data-ic-suggest-form>
++ <label class="ic-grow"><span>Button label</span><input name="label" placeholder="Deep dive, add to cart, go live…" maxlength="120" autocomplete="off" data-ic-suggest-label></label>
++ <button type="submit" class="ic-btn is-go">${kit.icon('spark')} Suggest</button>
++ </form>
++ <div class="ic-suggest-out" data-ic-suggest-out hidden></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}${get}${leftHtml}${drawSec}${use}</article>`,
++ body: `<article class="ic">${hero}${suggestSec}${browse}${get}${leftHtml}${drawSec}${use}</article>`,
});
}
ddiff --git a/test/element.test.js b/test/element.test.js
iindex d906899..27fdd5e 100644
---- a/test/element.test.js
++++ b/test/element.test.js
@@@ -112,6 +112,15 @@ const fakeAi = (state, doc = ELEMENT) => ({
assert.strictEqual(v.element.look.props.size, 'small');
assert.notStrictEqual(v.element.look.style.tint, first.element.look.style.tint);
assert.ok(v.tweaks.find((x) => x.name === 'size'));
++ assert.ok(v.tweaks.find((x) => x.name === 'icon'), 'the editor offers an icon');
++ const iconTweak = await t.get(`/api/v1/designs/${t.designId}/element/tweak`, { headers: token(alice), json: { icon: 'compass' } });
++ assert.strictEqual(iconTweak.status, 200, iconTweak.text);
++ assert.strictEqual(iconTweak.json().variants[0].element.look.icon, 'compass');
++ const badIcon = await t.get(`/api/v1/designs/${t.designId}/element/tweak`, { headers: token(alice), json: { icon: 'not-a-glyph' } });
++ assert.strictEqual(badIcon.status, 422, 'an unknown icon is refused');
++ const preview = await t.get(`/d/${t.designId}/element/preview`, { as: alice });
++ assert.ok(preview.text.includes('data-el-icon') && preview.text.includes('data-el-icon-row'), 'the picker and its suggestions are on the preview');
++ assert.ok(preview.text.includes('Suggested for'), 'suggestions are named by the label');
});
await check('the preview runs the recipe on sample data: roles, cost, cooldown and a simulated robot', async () => {
ddiff --git a/test/glyph-suggest.test.js b/test/glyph-suggest.test.js
nnew file mode 100644
iindex 0000000..05d2aec
---- /dev/null
++++ b/test/glyph-suggest.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// Choosing an icon for a button's words (plan L32 I2): GET /api/v1/glyphs/suggest scores the registry's names and
++// keywords for free, and ?rank=1 lets a small model order the shortlist when the words are new. The icons page and
++// each glyph page carry the box, and the draft price is shown before a draft.
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++
++const ai = {
++ enabled: true,
++ text: async () => ({ text: '{"names":["gauge","chart"]}', provider: 'fake', model: 'small-1', run_id: 'run_1' }),
++ exchangeCatalog: async () => ({ models: [{ name: 'Cheap', offers: [{ provider: 'fake', price: { in: 1, out: 2 } }] }] }),
++};
++
++(async () => {
++ const t = await boot({ ai });
++ try {
++ await check('Deep dive suggests the compass and the search, with CORS and no model', async () => {
++ const r = await t.get('/api/v1/glyphs/suggest?label=Deep%20dive');
++ assert.equal(r.status, 200, r.text);
++ assert.equal(r.headers.get('access-control-allow-origin'), '*');
++ const j = r.json();
++ assert.equal(j.label, 'Deep dive');
++ assert.equal(j.glyphs[0].name, 'compass', JSON.stringify(j.glyphs));
++ assert.ok(j.glyphs.some((g) => g.name === 'search'));
++ assert.equal(j.new, false);
++ assert.equal(j.ranked, false);
++ assert.ok(j.glyphs[0].tags.length, 'the answer carries keywords');
++ });
++
++ await check('new words ask a small model to rank the shortlist', async () => {
++ const r = await t.get('/api/v1/glyphs/suggest?label=gpu%20benchmark&rank=1', { headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.equal(r.status, 200, r.text);
++ const j = r.json();
++ assert.equal(j.ranked, true);
++ assert.equal(j.model, 'fake · small-1');
++ assert.deepEqual(j.glyphs.slice(0, 2).map((g) => g.name), ['gauge', 'chart']);
++ });
++
++ await check('a missing label is a 422; the page carries the box and the price before a draft', async () => {
++ assert.equal((await t.get('/api/v1/glyphs/suggest')).status, 422);
++ const page = await t.get('/icons');
++ assert.equal(page.status, 200);
++ assert.ok(page.text.includes('data-ic-suggest-form') && page.text.includes('Choose an icon for a button'));
++ assert.ok(page.text.includes('About $0.0045 a draft'), 'the estimate is on the page');
++ assert.ok(page.text.includes('names and keywords answer first'));
++ });
++
++ await check('a glyph page shows every style, its keywords, a search box and the suggest box', async () => {
++ const page = await t.get('/icons/compass');
++ assert.equal(page.status, 200, page.text);
++ for (const word of ['gp-styles', 'Names and keywords', 'Suggest one for a button', 'data-gp-suggest', 'action="/icons"']) assert.ok(page.text.includes(word), word);
++ assert.ok(page.text.includes('data-el-icon') === false, 'the element picker is not on the glyph page');
++ });
++ done();
++ } catch (err) {
++ done(err);
++ }
++})();