openvibers/openvibe.design Commits
Icons: suggest one for a button, the draft price, and an element icon picker (#294)
Changes
@@@ -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)}}@@@ -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}}nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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));++ });++})();@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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(); });++})();@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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`));@@@ -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) {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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' },@@@ -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));@@@ -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.'); }});@@@ -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',});}@@@ -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’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’s two, a lighter or heavier line, flat or with the mark’s depth and glow, room around it and a ground behind it. The link rebuilds the same file every time.</p></div>@@@ -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 = [], rconst 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 = [], rconst 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><ov-icon name="lucide:settings"></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’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>`,});}@@@ -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 () => {nnew file mode 100644@@@ -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);++ }++})();
@@ -1132,3 +1132,14 @@ html[data-ov-motion=off] .ar-vote-pair[data-pick] .ar-pickable{transform: none}
@@ -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-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-frame--element { height: auto; min-height: 170px; display: grid; place-items: center; }
.dv-canvas[data-kind="element"] .dv-toolbar { display: none; }
.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)}}
@@ -268,3 +268,35 @@ html[data-ov-motion=off] .ic-tile:hover{transform:none}
@@ -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{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}
.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}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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));
+ });
+})();
@@ -88,3 +88,40 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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(); });
+})();
@@ -41,6 +41,38 @@
@@ -41,6 +41,38 @@
if (drawLink && /^[a-z][a-z0-9-]{1,31}$/.test(q)) drawLink.href = `/icons?draw=${encodeURIComponent(q)}#draw`;
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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.
// Sizes.
document.querySelectorAll('[data-ic-size]').forEach((b) => b.addEventListener('click', () => {
document.querySelectorAll('[data-ic-size]').forEach((b) => b.addEventListener('click', () => {
grids.forEach((g) => g.style.setProperty('--ic-size', `${b.dataset.icSize}px`));
grids.forEach((g) => g.style.setProperty('--ic-size', `${b.dataset.icSize}px`));
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
*/
*/
const { vocabulary: designVocabulary } = require('../components/registry');
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
// 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.
// only checks the shape. `props` are the values the generated sliders move.
@@ -236,6 +237,7 @@ function tweaksFor(doc, variables = {}) {
@@ -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: '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: '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) });
tweaks.push({ name: 'show_label', label: 'Show label', kind: 'checkbox', value: !(look.style && look.style.show_label === false) });
return tweaks;
return tweaks;
}
}
@@ -267,6 +269,15 @@ function applyTweaks(doc, values = {}, variables = {}) {
@@ -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.');
if (/https?:\/\//i.test(label) || /<[a-z!/]/i.test(label)) throw fail('The label cannot carry a link or markup.');
look.label = label;
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';
if (values.show_label != null) look.style.show_label = values.show_label === true || values.show_label === 'on' || values.show_label === '1';
return out;
return out;
}
}
@@ -279,6 +290,7 @@ function renderLook(doc) {
@@ -279,6 +290,7 @@ function renderLook(doc) {
const shape = style.shape === 'round' ? '50%' : style.shape === 'square' ? '8px' : '999px';
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 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 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 component = String(look.component || 'button');
const props = look.props || {};
const props = look.props || {};
let inner = '';
let inner = '';
@@ -299,7 +311,7 @@ function renderLook(doc) {
@@ -299,7 +311,7 @@ function renderLook(doc) {
case 'sound-pad': inner = '<span class="dv-elx-pad" aria-hidden="true">♪</span>'; break;
case 'sound-pad': inner = '<span class="dv-elx-pad" aria-hidden="true">♪</span>'; break;
default: inner = '';
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) {
function esc(value) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -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 };
@@ -120,7 +120,7 @@ function createDiscoveryRoutes(ctx) {
@@ -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: '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: '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: '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: '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: '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' },
{ 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' },
@@ -38,6 +38,8 @@ const kits = require('../glyphs/kits');
@@ -38,6 +38,8 @@ const kits = require('../glyphs/kits');
const ex = require('../glyphs/export');
const ex = require('../glyphs/export');
const { createGlyphDrafts } = require('../glyphs/drafts');
const { createGlyphDrafts } = require('../glyphs/drafts');
const { createReferenceSearch } = require('../glyphs/references');
const { createReferenceSearch } = require('../glyphs/references');
+const { suggest } = require('../glyphs/suggest');
+const { createGlyphPrice } = require('../glyphs/price');
const formats = require('../glyphs/formats');
const formats = require('../glyphs/formats');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -52,6 +54,7 @@ function createIconRoutes(ctx) {
@@ -52,6 +54,7 @@ function createIconRoutes(ctx) {
const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };
const guestCaps = config.glyphGuests || { perGuest: 2, perAddress: 4, daily: 25 };
const referenceSearch = ctx.referenceSearch || createReferenceSearch({ config });
const referenceSearch = ctx.referenceSearch || createReferenceSearch({ config });
const drafts = ctx.glyphDrafts || createGlyphDrafts({ db: s.db, ai, guests: guestCaps, referenceSearch });
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
// 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).
// 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';
const GUEST_COOKIE = 'ovd_guest';
@@ -139,13 +142,15 @@ function createIconRoutes(ctx) {
@@ -139,13 +142,15 @@ function createIconRoutes(ctx) {
const subject = subjectOf(req);
const subject = subjectOf(req);
const reviewer = isAdmin(req);
const reviewer = isAdmin(req);
const guest = subject ? null : guestOf(req, res);
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(() => []) : [],
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(() => []) : [],
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 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) : '';
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 }));
r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
@@ -321,6 +326,25 @@ function createIconRoutes(ctx) {
@@ -321,6 +326,25 @@ function createIconRoutes(ctx) {
}).filter(Boolean);
}).filter(Boolean);
res.json({ count: out.length, glyphs: out });
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) => {
r.get('/api/v1/glyphs/search', (req, res) => {
const q = String(req.query.q || '').slice(0, 100);
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));
const names = aliases.search(q, Math.min(Math.max(Number(req.query.limit) || 24, 1), 60));
@@ -221,7 +221,7 @@ function createPageRoutes(ctx) {
@@ -221,7 +221,7 @@ function createPageRoutes(ctx) {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
try {
try {
await ctx.designs.tweakElement({ id: req.params.id, values: { size: req.body.size, tint: req.body.tint,
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)}` : '');
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.'); }
} catch (err) { return res.status(err.status || 500).type('text/plain').send(err.detail || 'Could not apply the tweak.'); }
});
});
@@ -82,13 +82,16 @@ function tweakForm({ design, variant, editKey }) {
@@ -82,13 +82,16 @@ function tweakForm({ design, variant, editKey }) {
const tweaks = (variant && variant.tweaks) || [];
const tweaks = (variant && variant.tweaks) || [];
const ranges = tweaks.filter((t) => t.kind === 'range');
const ranges = tweaks.filter((t) => t.kind === 'range');
const label = tweaks.find((t) => t.name === 'label');
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 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 '';
if (!tweaks.length) return '';
return `<form class="el-tweaks" method="post" action="/d/${encodeURIComponent(design.id)}/element/tweak">
return `<form class="el-tweaks" method="post" action="/d/${encodeURIComponent(design.id)}/element/tweak">
<input type="hidden" name="key" value="${esc(editKey)}">
<input type="hidden" name="key" value="${esc(editKey)}">
<fieldset><legend>Make it yours</legend>
<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('')}
${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>` : ''}
${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' })}
${kit.button({ label: 'Apply', type: 'submit', kind: 'quiet' })}
</fieldset></form>`;
</fieldset></form>`;
@@ -153,7 +156,7 @@ ${design ? buildWithCodes({ element, design }) : ''}
@@ -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.`,
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`),
path: o.path || (design ? `/d/${design.id}/element/preview` : `/elements/${o.listingId || ''}/try`),
robots: o.robots || (design ? 'noindex, nofollow' : 'index, follow'),
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',
});
});
}
}
@@ -49,6 +49,23 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -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 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 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’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>
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">
<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>
<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()
@@ -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>.`
? `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>
: '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('')}
${['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">
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’s two, a lighter or heavier line, flat or with the mark’s depth and glow, room around it and a ground behind it. The link rebuilds the same file every time.</p></div>
<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’s two, a lighter or heavier line, flat or with the mark’s depth and glow, room around it and a ground behind it. The link rebuilds the same file every time.</p></div>
@@ -40,7 +40,7 @@ const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="
@@ -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).
// 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>`;
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 fa = faByGlyph();
const left = stillFontAwesome();
const left = stillFontAwesome();
const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);
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
@@ -69,7 +69,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
const browse = `<section class="ic-sec" aria-labelledby="ic-all">
const browse = `<section class="ic-sec" aria-labelledby="ic-all">
<h2 id="ic-all" class="ovk-sr">Every glyph</h2>
<h2 id="ic-all" class="ovk-sr">Every glyph</h2>
<div class="ic-tools" data-ic-tools hidden>
<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 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>
<p class="ic-grid-cap">The ${FIRST} the network uses most</p>
<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
@@ -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">
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>
<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 }) : ''}
${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">
<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><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>
<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
@@ -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>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>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><ov-icon name="lucide:settings"></code> too.</p></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><ov-icon name="lucide:settings"></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>
</div>
</section>`;
</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’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({
return layout({
title: 'The OpenVibe glyphs',
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.`,
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'],
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>`,
});
});
}
}
@@ -112,6 +112,15 @@ const fakeAi = (state, doc = ELEMENT) => ({
@@ -112,6 +112,15 @@ const fakeAi = (state, doc = ELEMENT) => ({
assert.strictEqual(v.element.look.props.size, 'small');
assert.strictEqual(v.element.look.props.size, 'small');
assert.notStrictEqual(v.element.look.style.tint, first.element.look.style.tint);
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 === '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 () => {
await check('the preview runs the recipe on sample data: roles, cost, cooldown and a simulated robot', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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);
+ }
+})();