openvibers/openvibe.design Commits
Fit and reference generated glyph styles (#203)
Changes
nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- Keep the requested style family and the licensed reference cards alongside each reviewed glyph draft.++ALTER TABLE glyph_drafts ADD COLUMN styles_json jsonb NOT NULL DEFAULT '["thin","light","regular","bold","solid","duotone"]'::jsonb;++ALTER TABLE glyph_drafts ADD COLUMN references_json jsonb NOT NULL DEFAULT '[]'::jsonb;@@@ -73,6 +73,7 @@.ic-draft-art svg:first-child{width:40px;height:40px}.ic-draft-art svg:last-child{width:20px;height:20px}/* A draft's solid twin, as the sites draw small icons: filled, its class="ln" parts as lines (L50). */.ic .ic-draft-art svg.is-solid{fill:currentColor;stroke:none}.ic .ic-draft-art svg.is-solid .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}++.ic-compare{flex:1 0 100%;min-width:0;border-top:1px solid var(--border);padding-top:8px}.ic-compare summary{cursor:pointer;color:var(--accent);font-size:12px;font-weight:700}.ic-compare-row{display:grid;grid-template-columns:64px minmax(0,1fr);gap:6px;align-items:start;margin-top:10px}.ic-compare-row>b{text-transform:capitalize;font-size:12px}.ic-compare-size{display:grid;grid-template-columns:32px minmax(0,1fr);align-items:center;gap:8px;margin-bottom:5px}.ic-compare-size small{color:var(--text-muted);font-size:11px}.ic-compare-size>div{display:flex;align-items:center;gap:12px;min-height:34px;padding:4px 8px;border-radius:8px;background:var(--bg-primary)}.ic .ic-compare-size svg{flex:0 0 auto}.ic .ic-compare-solid{fill:currentColor;stroke:none}.ic .ic-compare-solid .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}.ic .ic-compare-duo{opacity:.2}.ic-compare-refs{display:flex;flex-wrap:wrap;gap:4px 10px;color:var(--text-muted);font-size:11px}.ic-compare-refs a{color:var(--text-secondary)}.ic-draft-t{flex:1;min-width:0;display:grid}.ic-draft-t b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ic-draft-t small{color:var(--text-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@@@ -17,6 +17,8 @@*/const crypto = require('crypto');const { clean, cleanSolid } = require('./validate');++const { fitDrawing } = require('./fit');++const { searchReferences } = require('./references');const PER_DAY = 12;const CLAUDE = Object.freeze({ provider: 'anthropic', model: 'claude-opus-5-5' });@@@ -44,8 +46,9 @@ different idea, not the same drawing moved. Each candidate also has its solid twidea as filled shapes, a hole cut with fill-rule="evenodd" on a path, a part that has to stay a line (a handle, astem, a slash) as class="ln"; no other attributes, at most 6 shapes. Answer with JSON only:{"candidates": [{"line": "<path d=\\"…\\"/>", "solid": "<path d=\\"…\\"/>"}, …]} with ${CANDIDATES} different candidates.${examples()}`;++const SOLID_SYSTEM = `You make filled twins for existing OpenVibe line glyphs. Keep the idea and outer silhouette identical to the provided line drawing. Turn closed regions into filled shapes, preserve holes with fill-rule="evenodd", and keep handles, stems, slashes and other open strokes as class="ln". Return JSON only as {"candidates":[{"solid":"<path d=\\"…\\"/>"},…]}. Use only path, circle, ellipse and rect shapes; no transforms, styles, colours or scripts.`;--function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 } }) {++function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 }, referenceSearch = searchReferences }) {const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;const iso = () => new Date(clock.now()).toISOString();@@@ -61,13 +64,18 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});}-- async function draft({ subject, name, sentence = '', guest = null }) {++ async function draft({ subject, name, sentence = '', guest = null, styles = null, sourceSvg = '' }) {if (!subject && !guest) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });if (guest && !/^[0-9a-f]{16,64}$/.test(String(guest.id || ''))) throw Object.assign(new Error('this browser cannot draw without signing in'), { status: 400 });const who = subject || `guest:${guest.id}`;const n = String(name || '').trim().toLowerCase();if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });const what = String(sentence || '').trim().slice(0, 200);++ const sourceLine = sourceSvg ? clean(sourceSvg) : null;++ if (sourceLine && !sourceLine.ok) throw Object.assign(new Error(`sourceSvg: ${sourceLine.why}`), { status: 422 });++ const allowedStyles = new Set(['thin', 'light', 'regular', 'bold', 'solid', 'duotone']);++ const requestedStyles = Array.isArray(styles) ? [...new Set(styles.map((x) => String(x).toLowerCase()).filter((x) => allowedStyles.has(x)))].slice(0, 6) : [...allowedStyles];++ if (!requestedStyles.length) throw Object.assign(new Error('styles must include thin, light, regular, bold, solid or duotone'), { status: 422 });if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });if (subject) {const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);@@@ -75,10 +83,13 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =} else {await admitGuest(guest);}++ let references = [];++ if (!sourceLine) { try { references = await referenceSearch(`${n.replace(/-/g, ' ')} ${what}`, { limit: 5 }); } catch { references = []; } }++ const referenceWords = references.map((r) => `${r.name.replace(/-/g, ' ')} (${r.setName}; ${r.license})`).join(', ');const ask = async (extra = '') => {const answer = await ai.text({-- system: SYSTEM,-- prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}${extra}`,++ system: sourceLine ? SOLID_SYSTEM : SYSTEM,++ prompt: `${sourceLine ? `Make a solid twin of this existing line drawing for "${n}": ${sourceLine.svg}` : `The icon: "${n}".`}${what ? ` What it stands for: ${what}` : ''}${referenceWords ? `\nRelated open icon concepts for vocabulary only: ${referenceWords}. Draw an original OpenVibe glyph; do not copy a reference.` : ''}${extra}`,maxTokens: 1800,target: { service: 'design', type: 'glyph_draft', id: n },// Two tiers (owner, 11-09: "the free one should use the cheapest or most cost-effective model if people@@@ -99,23 +110,32 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =// Fewer than two that pass the checks: ask once more, saying why the last ones failed (more reliable drafts).const lineOf = (c) => (typeof c === 'string' ? c : (c && typeof c.line === 'string' ? c.line : ''));const solidOf = (c) => (c && typeof c === 'object' && typeof c.solid === 'string' ? c.solid : '');-- const passing = list.filter((c) => clean(lineOf(c)).ok).length;++ const passing = list.filter((c) => sourceLine ? cleanSolid(solidOf(c)).ok : clean(lineOf(c)).ok).length;if (passing < 2) {-- const whys = list.map((c) => clean(lineOf(c))).filter((r) => !r.ok).map((r) => r.why).slice(0, 4);++ const whys = list.map((c) => sourceLine ? cleanSolid(solidOf(c)) : clean(lineOf(c))).filter((r) => !r.ok).map((r) => r.why).slice(0, 4);const again = await ask(`\nThe last answer ${list.length ? `failed the checks: ${whys.join('; ') || 'too few usable drawings'}` : 'had no candidates'}. Keep strictly to the rules.`).catch(() => null);if (again && again.got.length) { list = list.concat(again.got).slice(0, CANDIDATES * 2); answer = again.answer; }}const kept = [], refused = [];const seen = new Set();for (const c of list) {-- const r = clean(lineOf(c));++ const r = sourceLine ? sourceLine : clean(lineOf(c));if (!r.ok) { refused.push(r.why); continue; }-- if (seen.has(r.svg)) continue;-- seen.add(r.svg);-- const sol = solidOf(c) ? cleanSolid(solidOf(c)) : { ok: false };-- const row = { id: id(), subject: who, name: n, sentence: what, svg: r.svg, solid_svg: sol.ok ? sol.svg : null, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };-- await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, solid_svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',-- [row.id, row.subject, row.name, row.sentence, row.svg, row.solid_svg, row.provider, row.model, row.status, row.created_at]);++ const fitted = fitDrawing(r.svg);++ if (!fitted.ok) { refused.push(fitted.why); continue; }++ if (seen.has(fitted.svg)) continue;++ seen.add(fitted.svg);++ let sol = solidOf(c) ? cleanSolid(solidOf(c)) : { ok: false };++ if (sol.ok) {++ // The solid twin has different filled extents from the line drawing (especially where a++ // stem or slash remains class="ln"). Fit it to the same house box independently.++ const solidFit = fitDrawing(sol.svg);++ if (solidFit.ok) sol = solidFit; else { refused.push(`${n} solid: ${solidFit.why}`); sol = { ok: false }; }++ }++ const row = { id: id(), subject: who, name: n, sentence: what, svg: fitted.svg, solid_svg: sol.ok ? sol.svg : null,++ styles: requestedStyles, references, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };++ await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, solid_svg, provider, model, status, created_at, styles_json, references_json) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',++ [row.id, row.subject, row.name, row.sentence, row.svg, row.solid_svg, row.provider, row.model, row.status, row.created_at, JSON.stringify(row.styles), JSON.stringify(row.references)]);kept.push(row);}if (!list.length) refused.push('the model answered no candidates');@@@ -127,7 +147,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =if (status) { args.push(status); where.push(`status = $${args.length}`); }if (subject) { args.push(subject); where.push(`subject = $${args.length}`); }args.push(Math.min(Math.max(Number(limit) || 60, 1), 500));-- return db.many(`SELECT id, name, sentence, svg, solid_svg, provider, model, status, created_at, reviewed_at FROM glyph_drafts${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT $${args.length}`, args);++ return db.many(`SELECT id, name, sentence, svg, solid_svg, styles_json, references_json, provider, model, status, created_at, reviewed_at FROM glyph_drafts${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT $${args.length}`, args);}async function review({ id: draftId, action, reviewer }) {nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++++const COMMANDS = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };++const fmt = (n) => String(Math.round((n + 1e-8) * 100) / 100);++const nums = (s) => (String(s).match(/[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) || []).map(Number);++++function pathPoints(d) {++ const tokens = String(d).match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) || [];++ const points = []; let i = 0, cmd = null, x = 0, y = 0, sx = 0, sy = 0;++ const point = (a, b, rel) => { const px = rel ? x + a : a, py = rel ? y + b : b; points.push([px, py]); return [px, py]; };++ while (i < tokens.length) {++ if (/^[a-z]$/i.test(tokens[i])) { cmd = tokens[i++]; if (cmd.toUpperCase() === 'Z') { x = sx; y = sy; continue; } }++ if (!cmd) return null;++ const u = cmd.toUpperCase(), rel = cmd === cmd.toLowerCase(), n = COMMANDS[u];++ if (n == null || !n) continue;++ const a = tokens.slice(i, i + n).map(Number); if (a.length !== n || a.some((v) => !Number.isFinite(v))) return null; i += n;++ if (u === 'H') { x = rel ? x + a[0] : a[0]; points.push([x, y]); }++ else if (u === 'V') { y = rel ? y + a[0] : a[0]; points.push([x, y]); }++ else if (u === 'A') { points.push([x - a[0], y - a[1]], [x + a[0], y + a[1]]); [x, y] = point(a[5], a[6], rel); }++ else {++ const ox = x, oy = y;++ for (let j = 0; j < n; j += 2) {++ const p = [rel ? ox + a[j] : a[j], rel ? oy + a[j + 1] : a[j + 1]];++ points.push(p); if (u === 'M' && j === 0) [sx, sy] = p;++ }++ x = points[points.length - 1][0]; y = points[points.length - 1][1];++ }++ if (u === 'M') cmd = rel ? 'l' : 'L';++ }++ return points;++}++++function measure(markup) {++ const pts = [];++ for (const m of String(markup).matchAll(/<(path|circle|ellipse|rect|line|polyline)\b([^>]*)\/>/g)) {++ const tag = m[1], a = Object.fromEntries([...m[2].matchAll(/([\w-]+)="([^"]*)"/g)].map((x) => [x[1], x[2]]));++ const add = (x, y) => { if (Number.isFinite(x) && Number.isFinite(y)) pts.push([x, y]); };++ if (tag === 'path') pts.push(...(pathPoints(a.d) || []));++ else if (tag === 'circle') { const x = +a.cx, y = +a.cy, r = +a.r; add(x - r, y - r); add(x + r, y + r); }++ else if (tag === 'ellipse') { const x = +a.cx, y = +a.cy, rx = +a.rx, ry = +a.ry; add(x - rx, y - ry); add(x + rx, y + ry); }++ else if (tag === 'rect') { const x = +a.x, y = +a.y; add(x, y); add(x + +a.width, y + +a.height); }++ else if (tag === 'line') { add(+a.x1, +a.y1); add(+a.x2, +a.y2); }++ else if (tag === 'polyline') { const n = nums(a.points); for (let i = 0; i < n.length; i += 2) add(n[i], n[i + 1]); }++ }++ if (!pts.length) return null;++ const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);++ const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);++ return { x0, y0, x1, y1, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, width: x1 - x0, height: y1 - y0 };++}++++function fittedPath(d, t) {++ const tokens = String(d).match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) || [];++ let i = 0, cmd = null, out = '', first = true;++ const absolute = (v, c) => 12 + (v - c) * t.scale;++ while (i < tokens.length) {++ if (/^[a-z]$/i.test(tokens[i])) { cmd = tokens[i++]; out += cmd; if (cmd.toUpperCase() === 'Z') cmd = null; continue; }++ if (!cmd) throw new Error('missing path command');++ const u = cmd.toUpperCase(), rel = cmd === cmd.toLowerCase(), n = COMMANDS[u], a = tokens.slice(i, i + n).map(Number); i += n;++ if (u === 'H') a[0] = rel ? a[0] * t.scale : absolute(a[0], t.cx);++ else if (u === 'V') a[0] = rel ? a[0] * t.scale : absolute(a[0], t.cy);++ else if (u === 'A') { a[0] *= t.scale; a[1] *= t.scale; a[5] = rel ? (first ? absolute(a[5], t.cx) : a[5] * t.scale) : absolute(a[5], t.cx); a[6] = rel ? (first ? absolute(a[6], t.cy) : a[6] * t.scale) : absolute(a[6], t.cy); }++ else for (let j = 0; j < n; j += 2) {++ if (rel && first) { a[j] = absolute(a[j], t.cx); a[j + 1] = absolute(a[j + 1], t.cy); }++ else { a[j] = rel ? a[j] * t.scale : absolute(a[j], t.cx); a[j + 1] = rel ? a[j + 1] * t.scale : absolute(a[j + 1], t.cy); }++ }++ out += a.map(fmt).join(' '); first = false; if (u === 'M') cmd = rel ? 'l' : 'L'; if (i < tokens.length && !/^[a-z]$/i.test(tokens[i])) out += ' ';++ }++ return out;++}++++function transform(markup, t) {++ return String(markup).replace(/<(path|circle|ellipse|rect|line|polyline)\b([^>]*)\/>/g, (_, tag, attrs) => `<${tag}${attrs.replace(/([\w-]+)="([^"]*)"/g, (_, k, v) => {++ let z = v;++ if (tag === 'path' && k === 'd') z = fittedPath(v, t);++ else if (tag === 'polyline' && k === 'points') z = nums(v).map((n, i) => fmt(12 + (n - (i % 2 ? t.cy : t.cx)) * t.scale)).join(' ');++ else if (['cx', 'x', 'x1', 'x2'].includes(k)) z = fmt(12 + (+v - t.cx) * t.scale);++ else if (['cy', 'y', 'y1', 'y2'].includes(k)) z = fmt(12 + (+v - t.cy) * t.scale);++ else if (['r', 'rx', 'ry', 'width', 'height'].includes(k)) z = fmt(+v * t.scale);++ return `${k}="${z}"`;++ })}/>`);++}++++function fitDrawing(markup, transformSpec = null) {++ const before = measure(markup);++ if (!before || !Math.max(before.width, before.height)) return { ok: false, why: 'the drawing has no measurable ink' };++ const t = transformSpec || { cx: before.cx, cy: before.cy, scale: 19 / Math.max(before.width, before.height) };++ const svg = transform(markup, t), after = measure(svg);++ const long = after && Math.max(after.width, after.height);++ if (!after || Math.abs(after.cx - 12) > 0.75 || Math.abs(after.cy - 12) > 0.75 || long < 18 || long > 20) return { ok: false, why: 'the fitted drawing must be centred within 0.75 units and 18–20 units on its long side' };++ return { ok: true, svg, transform: t, metrics: after };++}++++module.exports = { measure, fitDrawing };nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++++// Iconify is the temporary reference source until Exchange exposes /v1/search/icons.++// The model receives names and concepts only; SVG paths are never sent to it.++const API = 'https://api.iconify.design';++++async function searchReferences(query, { fetchImpl = globalThis.fetch, limit = 5 } = {}) {++ const q = String(query || '').trim().slice(0, 120);++ if (!q) return [];++ const url = new URL('/search', API);++ url.searchParams.set('query', q);++ url.searchParams.set('limit', String(Math.min(Math.max(Number(limit) || 5, 1), 8)));++ const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });++ if (!response.ok) throw new Error(`Icon references returned ${response.status}`);++ const body = await response.json();++ return (Array.isArray(body.icons) ? body.icons : []).slice(0, 8).flatMap((fullName) => {++ const [prefix, name] = String(fullName).split(':');++ const collection = body.collections && body.collections[prefix];++ const license = collection && collection.license;++ if (!/^[a-z0-9-]+$/.test(prefix || '') || !/^[a-z0-9-]+$/.test(name || '') || !license || !license.spdx) return [];++ return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),++ author: String((collection.author && collection.author.name) || '').slice(0, 100),++ license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),++ preview: `${API}/${prefix}/${name}.svg` }];++ });++}++++module.exports = { searchReferences };@@@ -236,6 +236,22 @@ function createIconRoutes(ctx) {}});++ // SDK/API entry point for a full style family. API guests use the same-origin form and its Turnstile caps;++ // programmatic drafts require a signed-in Exchange/Network identity.++ r.post('/api/v1/glyphs/draft', express.json({ limit: '8kb' }), async (req, res) => {++ const subject = subjectOf(req);++ if (!subject) return problem(res, req, 401, 'design.sign_in_required', 'Sign in to draft glyphs through the API.');++ if (!writeAllowed(req, config)) return problem(res, req, 403, 'design.same_origin_required', 'Open this request from an authorized OpenVibe client.');++ try {++ const body = req.body || {};++ const out = await drafts.draft({ subject, name: body.name, sentence: body.sentence, styles: body.styles, sourceSvg: body.sourceSvg });++ return res.status(201).set('Cache-Control', 'private, no-store').json({ kept: out.kept, refused: out.refused });++ } catch (err) {++ if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph API draft failed:', err && err.message);++ return problem(res, req, err.status || 500, 'design.glyph_draft_failed', err.status && err.status < 500 ? err.message : 'The glyph could not be drafted.');++ }++ });++r.post('/icons/drafts/:id/review', form, async (req, res) => {if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');if (!isAdmin(req)) return res.status(403).send('Only an admin reviews drafts.');@@@ -258,12 +274,29 @@ function createIconRoutes(ctx) {}).filter(Boolean);res.json({ count: out.length, glyphs: out });});++ 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));++ res.set('Cache-Control', 'public, max-age=300').set(OPEN).json({ query: q, glyphs: names.map((name) => ({ name, svg: glyphs.path(name), solid: glyphs.solid(name) || '' })) });++ });++ r.get('/api/v1/glyphs/:name', (req, res, next) => {++ const name = aliases.resolve(String(req.params.name || '').toLowerCase());++ if (!name) return next();++ const style = String(req.query.style || 'regular').toLowerCase();++ const widths = { thin: 1, light: 1.5, regular: 2, bold: 2.5, line: 2, solid: null, duotone: 2 };++ if (!Object.prototype.hasOwnProperty.call(widths, style)) return problem(res, req, 422, 'design.invalid_style', 'style is thin, light, regular, bold, solid or duotone.');++ const line = glyphs.path(name), solid = glyphs.solid(name) || '';++ if (style === 'solid' && !solid) return problem(res, req, 404, 'design.glyph_style_missing', 'This glyph does not have a solid drawing yet.');++ const drawing = style === 'solid' ? solid : line;++ res.set('Cache-Control', 'public, max-age=300').set(OPEN).json({ name, style, svg: drawing, solid,++ strokeWidth: widths[style], underlay: style === 'duotone' ? solid : '', underlayOpacity: style === 'duotone' && solid ? 0.2 : 0 });++ });r.get('/api/v1/glyphs/drafts', async (req, res) => {const status = String(req.query.status || 'approved');if (status !== 'approved' && !isAdmin(req)) return problem(res, req, 403, 'design.glyph_drafts_private', 'Only approved drafts are public.');if (!['approved', 'draft', 'rejected'].includes(status)) return problem(res, req, 422, 'design.invalid', 'status is approved, draft or rejected.');const rows = await drafts.list({ status, limit: 500 });-- res.set('Cache-Control', status === 'approved' ? 'public, max-age=300' : 'private, no-store').json({ drafts: rows.map((d) => ({ id: d.id, name: d.name, sentence: d.sentence, svg: d.svg, solid_svg: d.solid_svg || null, model: d.model, status: d.status, created_at: d.created_at, reviewed_at: d.reviewed_at })) });++ res.set('Cache-Control', status === 'approved' ? 'public, max-age=300' : 'private, no-store').json({ drafts: rows.map((d) => ({ id: d.id, name: d.name, sentence: d.sentence, svg: d.svg, solid_svg: d.solid_svg || null, styles: d.styles_json || [], references: d.references_json || [], model: d.model, status: d.status, created_at: d.created_at, reviewed_at: d.reviewed_at })) });});return r;}@@@ -91,10 +91,24 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r${left.length > 24 ? `<details class="ic-fold ic-left-fold"><summary>${left.length - 24} more <span>each used a few times</span></summary><ul class="ic-left">${left.slice(24).map((n) => `<li><a href="/icons?draw=${encodeURIComponent(n.name)}#draw">${esc(n.name)}</a><small>${n.uses}</small></li>`).join('')}</ul></details>` : ''}</section>` : '';-- const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art"${d.solid_svg ? ' title="The line drawing, and its solid twin as the sites show it at 18 px and under"' : ''}>${svg(d.svg, 40)}${d.solid_svg ? solidSvg(d.solid_svg, 20) : svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}</li>`;++ const compare = (d) => {++ const all = [...glyphs.NAMES].sort();++ const at = all.findIndex((n) => n > d.name);++ const neighbours = [all[(at + all.length - 2) % all.length], all[(at + all.length - 1) % all.length], all[at % all.length], all[(at + 1) % all.length]].filter(Boolean);++ const styles = [['thin', 1], ['light', 1.5], ['regular', 2], ['bold', 2.5], ['solid', 2], ['duotone', 2]];++ const sample = (path, size, width, style) => style === 'solid'++ ? `<svg class="ic-compare-solid" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${path}</svg>`++ : `<svg viewBox="0 0 24 24" width="${size}" height="${size}" style="stroke-width:${width}" aria-hidden="true">${style === 'duotone' && d.solid_svg ? `<g class="ic-compare-duo">${d.solid_svg}</g>` : ''}${path}</svg>`;++ const samples = (name, size, width, style, candidate) => {++ const path = candidate ? (style === 'solid' && d.solid_svg ? d.solid_svg : d.svg) : (glyphs.path(name) || '');++ return sample(path, size, width, style);++ };++ return `<details class="ic-compare"><summary>Compare styles with four neighbours</summary>${styles.map(([style, width]) => `<div class="ic-compare-row"><b>${style}</b>${[16, 20, 32].map((size) => `<div class="ic-compare-size"><small>${size}px</small><div>${samples(d.name, size, width, style, true)}${neighbours.map((n) => samples(n, size, width, style, false)).join('')}</div></div>`).join('')}</div>`).join('')}${(d.references_json || d.references || []).length ? `<p class="ic-compare-refs">References: ${(d.references_json || d.references).map((r) => `<a href="${esc(r.preview)}" target="_blank" rel="noopener">${esc(r.setName)} · ${esc(r.name)} · ${esc(r.license)}</a>`).join(' · ')}</p>` : ''}</details>`;++ };++ const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art"${d.solid_svg ? ' title="The line drawing, and its solid twin as the sites show it at 18 px and under"' : ''}>${svg(d.svg, 40)}${d.solid_svg ? solidSvg(d.solid_svg, 20) : svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}${actions ? compare(d) : ''}</li>`;const reviewForm = (d) => `<div class="ic-draft-acts">${['approve', 'reject'].map((a) => `<form method="post" action="/icons/drafts/${esc(d.id)}/review"><input type="hidden" name="action" value="${a}"><button type="submit" class="ic-btn${a === 'approve' ? ' is-go' : ''}">${a === 'approve' ? 'Approve' : 'Reject'}</button></form>`).join('')}</div>`;const drawSec = `<section class="ic-sec" id="draw" aria-labelledby="ic-draw">-- <div class="ic-sec-head"><h2 id="ic-draw">Draw an icon</h2><p>Name it and say what it stands for. OpenVibe.AI draws a few candidates in this hand; each is checked (strokes only, on the grid, a few shapes) before it is kept, and an admin reviews it before it joins the set.</p></div>++ <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 }) : ''}<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>@@@ -126,7 +140,7 @@ 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}/icons/<name>.svg?style=auto|line|solid\nGET ${base}/icons/<name>.mjs | .xml | /favicon.zip`, 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/<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>`;@@@ -39,7 +39,7 @@ function fakeAi(calls) {{const calls = [];const db = fakeDb();-- const d = createGlyphDrafts({ db, ai: fakeAi(calls) });++ const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });const out = await d.draft({ subject: 'usr_1', name: 'hot-tub', sentence: 'a round tub with steam' });assert.ok(out.kept.length >= 1, 'drafts are kept');assert.strictEqual(calls.length, 1);@@@ -52,13 +52,24 @@ function fakeAi(calls) {{const calls = [];const db = fakeDb();-- const d = createGlyphDrafts({ db, ai: fakeAi(calls) });++ const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });const out = await d.draft({ subject: null, guest: { id: 'a'.repeat(32), ipHash: 'h1' }, name: 'hot-tub' });assert.ok(out.kept.length >= 1, 'a guest gets drafts too');assert.deepStrictEqual(calls[0].preset, { name: 'cheapest' }, 'a guest runs on the cheapest offer');assert.strictEqual(calls[0].prefer, undefined, 'a guest never asks for Claude');assert.strictEqual(calls[0].person, undefined, 'a guest is nobody\'s key');}++ // A missing solid twin keeps the exact reviewed line as its source while the model supplies filled geometry.++ {++ const calls = [], db = fakeDb();++ const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });++ const sourceSvg = '<path d="M5 12h14"/>';++ const out = await d.draft({ subject: 'usr_2', name: 'hot-tub', sourceSvg, styles: ['solid'] });++ assert.ok(out.kept.length && out.kept[0].solid_svg, 'the model solid is fitted and kept');++ assert.ok(calls[0].system.includes('filled twins for existing'), 'solid work uses the solid-specific instructions');++ assert.ok(calls[0].prompt.includes(sourceSvg), 'the existing line drawing is supplied to match');++ assert.ok(out.kept[0].svg.includes('M2.5 12h19'), 'the original line geometry is fitted, not redrawn');++ }// The AI client carries the preset into the run it creates.const aiSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'arena', 'ai.js'), 'utf8');assert.match(aiSrc, /async function text\(\{[^}]*preset = null/, 'text() takes a preset');nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++const assert = require('node:assert/strict');++const { fitDrawing, measure } = require('../server/glyphs/fit');++++const cases = [++ '<path d="M5 12h14"/>',++ '<path d="m6 15 6-6 6 6"/>',++ '<circle cx="8" cy="10" r="3"/><path d="M10 10h5"/>',++];++for (const svg of cases) {++ const result = fitDrawing(svg);++ assert.equal(result.ok, true, result.why);++ const box = measure(result.svg);++ assert.ok(Math.abs(box.cx - 12) <= 0.75 && Math.abs(box.cy - 12) <= 0.75, 'the fitted geometry is centered');++ assert.ok(Math.max(box.width, box.height) >= 18 && Math.max(box.width, box.height) <= 20, 'the long side fits the target');++}++const fit = fitDrawing(cases[0]);++assert.equal(fitDrawing('<path d="M12 12"/>').ok, false, 'a dot without measurable area is refused');++assert.equal(fitDrawing('<path d="M5 12h14"/>', fit.transform).ok, true, 'the solid twin reuses its line transform');++console.log('glyph fit: candidates are normalized to centre and 18–20 units before review');nnew file mode 100644@@@ -0,0 +1,19 @@++'use strict';++const assert = require('node:assert/strict');++const { searchReferences } = require('../server/glyphs/references');++++(async () => {++ let requested;++ const refs = await searchReferences('hot tub', { fetchImpl: async (url) => {++ requested = new URL(url);++ return { ok: true, async json() { return {++ icons: ['lucide:hot-tub', 'unknown:no-license'],++ collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },++ }; } };++ } });++ assert.equal(requested.pathname, '/search');++ assert.equal(requested.searchParams.get('query'), 'hot tub');++ assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',++ licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);++ console.log('glyph references: Iconify search keeps attribution and filters unlicensed results');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -6,6 +6,7 @@const assert = require('node:assert/strict');const { boot, check, done } = require('./helpers/boot');const glyphs = require('openvibe-shared/glyphs');++const { createIconRoutes } = require('../server/http/icons');const { clean, cleanSolid } = require('../server/glyphs/validate');const { stillFontAwesome } = require('../server/views/icons');@@@ -20,6 +21,7 @@ const ai = {const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });(async () => {++ assert.equal(typeof createIconRoutes, 'function');const t = await boot({ ai });const ana = t.network.addUser('ana');const boss = t.network.addUser('boss', { role: 'admin' });@@@ -73,6 +75,19 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headerassert.equal(j.fa['fa-eye'] || j.fa.eye, 'eye');});++ await check('style and name search API returns the selected glyph drawing', async () => {++ const thin = await t.get('/api/v1/glyphs/eye?style=thin');++ assert.equal(thin.status, 200);++ assert.equal(thin.json().style, 'thin');++ assert.equal(thin.json().svg, glyphs.path('eye'));++ assert.equal(thin.json().strokeWidth, 1);++ const bad = await t.get('/api/v1/glyphs/eye?style=unknown');++ assert.equal(bad.status, 422);++ const search = await t.get('/api/v1/glyphs/search?q=eye');++ assert.equal(search.status, 200);++ assert.ok(search.json().glyphs.some((g) => g.name === 'eye'));++ });++await check('the validator keeps strokes on the grid and refuses anything else', async () => {const ok = clean('<path d="M4 12h16"/><circle cx="12" cy="12" r="8"/>');assert.ok(ok.ok, ok.why);@@@ -170,7 +185,8 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headerassert.ok(!page.text.includes('onclick="steal()"'), 'the refused one is nowhere');assert.ok(!page.text.includes('Waiting for review'), 'not a reviewer');// Each kept drawing has its solid twin when that passed its own checks; a failing twin costs the drawing nothing.-- assert.ok(page.text.includes('<svg class="is-solid" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M4.5 9.5h15v9h-15Z"/><path d="M9 6c0-1 1-1 1-2M14 6c0-1 1-1 1-2" class="ln"/></svg>'), 'the solid twin is drawn beside its line drawing');++ const twin = page.text.match(/<svg class="is-solid"[^>]*>[\s\S]*?<\/svg>/);++ assert.ok(twin && twin[0].includes('class="ln"') && twin[0].includes('<path'), 'the fitted solid twin is drawn beside its line drawing');assert.ok(!page.text.includes('fill="red"'), 'a twin that failed its checks is not kept');assert.ok(asked[0].system.includes('solid twin'), 'the model is asked for the twins');});
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- Keep the requested style family and the licensed reference cards alongside each reviewed glyph draft.
+ALTER TABLE glyph_drafts ADD COLUMN styles_json jsonb NOT NULL DEFAULT '["thin","light","regular","bold","solid","duotone"]'::jsonb;
+ALTER TABLE glyph_drafts ADD COLUMN references_json jsonb NOT NULL DEFAULT '[]'::jsonb;
@@ -73,6 +73,7 @@
@@ -73,6 +73,7 @@
.ic-draft-art svg:first-child{width:40px;height:40px}.ic-draft-art svg:last-child{width:20px;height:20px}
.ic-draft-art svg:first-child{width:40px;height:40px}.ic-draft-art svg:last-child{width:20px;height:20px}
/* A draft's solid twin, as the sites draw small icons: filled, its class="ln" parts as lines (L50). */
/* A draft's solid twin, as the sites draw small icons: filled, its class="ln" parts as lines (L50). */
.ic .ic-draft-art svg.is-solid{fill:currentColor;stroke:none}.ic .ic-draft-art svg.is-solid .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}
.ic .ic-draft-art svg.is-solid{fill:currentColor;stroke:none}.ic .ic-draft-art svg.is-solid .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}
+.ic-compare{flex:1 0 100%;min-width:0;border-top:1px solid var(--border);padding-top:8px}.ic-compare summary{cursor:pointer;color:var(--accent);font-size:12px;font-weight:700}.ic-compare-row{display:grid;grid-template-columns:64px minmax(0,1fr);gap:6px;align-items:start;margin-top:10px}.ic-compare-row>b{text-transform:capitalize;font-size:12px}.ic-compare-size{display:grid;grid-template-columns:32px minmax(0,1fr);align-items:center;gap:8px;margin-bottom:5px}.ic-compare-size small{color:var(--text-muted);font-size:11px}.ic-compare-size>div{display:flex;align-items:center;gap:12px;min-height:34px;padding:4px 8px;border-radius:8px;background:var(--bg-primary)}.ic .ic-compare-size svg{flex:0 0 auto}.ic .ic-compare-solid{fill:currentColor;stroke:none}.ic .ic-compare-solid .ln{fill:none;stroke:currentColor;stroke-width:2.6;stroke-linecap:round;stroke-linejoin:round}.ic .ic-compare-duo{opacity:.2}.ic-compare-refs{display:flex;flex-wrap:wrap;gap:4px 10px;color:var(--text-muted);font-size:11px}.ic-compare-refs a{color:var(--text-secondary)}
.ic-draft-t{flex:1;min-width:0;display:grid}
.ic-draft-t{flex:1;min-width:0;display:grid}
.ic-draft-t b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ic-draft-t b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ic-draft-t small{color:var(--text-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ic-draft-t small{color:var(--text-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -17,6 +17,8 @@
@@ -17,6 +17,8 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { clean, cleanSolid } = require('./validate');
const { clean, cleanSolid } = require('./validate');
+const { fitDrawing } = require('./fit');
+const { searchReferences } = require('./references');
const PER_DAY = 12;
const PER_DAY = 12;
const CLAUDE = Object.freeze({ provider: 'anthropic', model: 'claude-opus-5-5' });
const CLAUDE = Object.freeze({ provider: 'anthropic', model: 'claude-opus-5-5' });
@@ -44,8 +46,9 @@ different idea, not the same drawing moved. Each candidate also has its solid tw
@@ -44,8 +46,9 @@ different idea, not the same drawing moved. Each candidate also has its solid tw
idea as filled shapes, a hole cut with fill-rule="evenodd" on a path, a part that has to stay a line (a handle, a
idea as filled shapes, a hole cut with fill-rule="evenodd" on a path, a part that has to stay a line (a handle, a
stem, a slash) as class="ln"; no other attributes, at most 6 shapes. Answer with JSON only:
stem, a slash) as class="ln"; no other attributes, at most 6 shapes. Answer with JSON only:
{"candidates": [{"line": "<path d=\\"…\\"/>", "solid": "<path d=\\"…\\"/>"}, …]} with ${CANDIDATES} different candidates.${examples()}`;
{"candidates": [{"line": "<path d=\\"…\\"/>", "solid": "<path d=\\"…\\"/>"}, …]} with ${CANDIDATES} different candidates.${examples()}`;
+const SOLID_SYSTEM = `You make filled twins for existing OpenVibe line glyphs. Keep the idea and outer silhouette identical to the provided line drawing. Turn closed regions into filled shapes, preserve holes with fill-rule="evenodd", and keep handles, stems, slashes and other open strokes as class="ln". Return JSON only as {"candidates":[{"solid":"<path d=\\"…\\"/>"},…]}. Use only path, circle, ellipse and rect shapes; no transforms, styles, colours or scripts.`;
-function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 } }) {
+function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 }, referenceSearch = searchReferences }) {
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
const iso = () => new Date(clock.now()).toISOString();
const iso = () => new Date(clock.now()).toISOString();
@@ -61,13 +64,18 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -61,13 +64,18 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});
db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});
}
}
- async function draft({ subject, name, sentence = '', guest = null }) {
+ async function draft({ subject, name, sentence = '', guest = null, styles = null, sourceSvg = '' }) {
if (!subject && !guest) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
if (!subject && !guest) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
if (guest && !/^[0-9a-f]{16,64}$/.test(String(guest.id || ''))) throw Object.assign(new Error('this browser cannot draw without signing in'), { status: 400 });
if (guest && !/^[0-9a-f]{16,64}$/.test(String(guest.id || ''))) throw Object.assign(new Error('this browser cannot draw without signing in'), { status: 400 });
const who = subject || `guest:${guest.id}`;
const who = subject || `guest:${guest.id}`;
const n = String(name || '').trim().toLowerCase();
const n = String(name || '').trim().toLowerCase();
if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });
if (!NAME_RE.test(n)) throw Object.assign(new Error('a name is 2 to 32 lowercase letters, digits and dashes, like "hot-tub"'), { status: 422 });
const what = String(sentence || '').trim().slice(0, 200);
const what = String(sentence || '').trim().slice(0, 200);
+ const sourceLine = sourceSvg ? clean(sourceSvg) : null;
+ if (sourceLine && !sourceLine.ok) throw Object.assign(new Error(`sourceSvg: ${sourceLine.why}`), { status: 422 });
+ const allowedStyles = new Set(['thin', 'light', 'regular', 'bold', 'solid', 'duotone']);
+ const requestedStyles = Array.isArray(styles) ? [...new Set(styles.map((x) => String(x).toLowerCase()).filter((x) => allowedStyles.has(x)))].slice(0, 6) : [...allowedStyles];
+ if (!requestedStyles.length) throw Object.assign(new Error('styles must include thin, light, regular, bold, solid or duotone'), { status: 422 });
if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });
if (!ai || !ai.enabled) throw Object.assign(new Error('drawing needs OpenVibe.AI, which is not configured here'), { status: 503 });
if (subject) {
if (subject) {
const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);
const today = await db.maybe("SELECT count(*)::int AS n FROM glyph_drafts WHERE subject = $1 AND created_at > now() - interval '1 day'", [subject]);
@@ -75,10 +83,13 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -75,10 +83,13 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
} else {
} else {
await admitGuest(guest);
await admitGuest(guest);
}
}
+ let references = [];
+ if (!sourceLine) { try { references = await referenceSearch(`${n.replace(/-/g, ' ')} ${what}`, { limit: 5 }); } catch { references = []; } }
+ const referenceWords = references.map((r) => `${r.name.replace(/-/g, ' ')} (${r.setName}; ${r.license})`).join(', ');
const ask = async (extra = '') => {
const ask = async (extra = '') => {
const answer = await ai.text({
const answer = await ai.text({
- system: SYSTEM,
- prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}${extra}`,
+ system: sourceLine ? SOLID_SYSTEM : SYSTEM,
+ prompt: `${sourceLine ? `Make a solid twin of this existing line drawing for "${n}": ${sourceLine.svg}` : `The icon: "${n}".`}${what ? ` What it stands for: ${what}` : ''}${referenceWords ? `\nRelated open icon concepts for vocabulary only: ${referenceWords}. Draw an original OpenVibe glyph; do not copy a reference.` : ''}${extra}`,
maxTokens: 1800,
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
target: { service: 'design', type: 'glyph_draft', id: n },
// Two tiers (owner, 11-09: "the free one should use the cheapest or most cost-effective model if people
// Two tiers (owner, 11-09: "the free one should use the cheapest or most cost-effective model if people
@@ -99,23 +110,32 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -99,23 +110,32 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
// Fewer than two that pass the checks: ask once more, saying why the last ones failed (more reliable drafts).
// Fewer than two that pass the checks: ask once more, saying why the last ones failed (more reliable drafts).
const lineOf = (c) => (typeof c === 'string' ? c : (c && typeof c.line === 'string' ? c.line : ''));
const lineOf = (c) => (typeof c === 'string' ? c : (c && typeof c.line === 'string' ? c.line : ''));
const solidOf = (c) => (c && typeof c === 'object' && typeof c.solid === 'string' ? c.solid : '');
const solidOf = (c) => (c && typeof c === 'object' && typeof c.solid === 'string' ? c.solid : '');
- const passing = list.filter((c) => clean(lineOf(c)).ok).length;
+ const passing = list.filter((c) => sourceLine ? cleanSolid(solidOf(c)).ok : clean(lineOf(c)).ok).length;
if (passing < 2) {
if (passing < 2) {
- const whys = list.map((c) => clean(lineOf(c))).filter((r) => !r.ok).map((r) => r.why).slice(0, 4);
+ const whys = list.map((c) => sourceLine ? cleanSolid(solidOf(c)) : clean(lineOf(c))).filter((r) => !r.ok).map((r) => r.why).slice(0, 4);
const again = await ask(`\nThe last answer ${list.length ? `failed the checks: ${whys.join('; ') || 'too few usable drawings'}` : 'had no candidates'}. Keep strictly to the rules.`).catch(() => null);
const again = await ask(`\nThe last answer ${list.length ? `failed the checks: ${whys.join('; ') || 'too few usable drawings'}` : 'had no candidates'}. Keep strictly to the rules.`).catch(() => null);
if (again && again.got.length) { list = list.concat(again.got).slice(0, CANDIDATES * 2); answer = again.answer; }
if (again && again.got.length) { list = list.concat(again.got).slice(0, CANDIDATES * 2); answer = again.answer; }
}
}
const kept = [], refused = [];
const kept = [], refused = [];
const seen = new Set();
const seen = new Set();
for (const c of list) {
for (const c of list) {
- const r = clean(lineOf(c));
+ const r = sourceLine ? sourceLine : clean(lineOf(c));
if (!r.ok) { refused.push(r.why); continue; }
if (!r.ok) { refused.push(r.why); continue; }
- if (seen.has(r.svg)) continue;
- seen.add(r.svg);
- const sol = solidOf(c) ? cleanSolid(solidOf(c)) : { ok: false };
- const row = { id: id(), subject: who, name: n, sentence: what, svg: r.svg, solid_svg: sol.ok ? sol.svg : null, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
- await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, solid_svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
- [row.id, row.subject, row.name, row.sentence, row.svg, row.solid_svg, row.provider, row.model, row.status, row.created_at]);
+ const fitted = fitDrawing(r.svg);
+ if (!fitted.ok) { refused.push(fitted.why); continue; }
+ if (seen.has(fitted.svg)) continue;
+ seen.add(fitted.svg);
+ let sol = solidOf(c) ? cleanSolid(solidOf(c)) : { ok: false };
+ if (sol.ok) {
+ // The solid twin has different filled extents from the line drawing (especially where a
+ // stem or slash remains class="ln"). Fit it to the same house box independently.
+ const solidFit = fitDrawing(sol.svg);
+ if (solidFit.ok) sol = solidFit; else { refused.push(`${n} solid: ${solidFit.why}`); sol = { ok: false }; }
+ }
+ const row = { id: id(), subject: who, name: n, sentence: what, svg: fitted.svg, solid_svg: sol.ok ? sol.svg : null,
+ styles: requestedStyles, references, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
+ await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, solid_svg, provider, model, status, created_at, styles_json, references_json) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
+ [row.id, row.subject, row.name, row.sentence, row.svg, row.solid_svg, row.provider, row.model, row.status, row.created_at, JSON.stringify(row.styles), JSON.stringify(row.references)]);
kept.push(row);
kept.push(row);
}
}
if (!list.length) refused.push('the model answered no candidates');
if (!list.length) refused.push('the model answered no candidates');
@@ -127,7 +147,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -127,7 +147,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
if (status) { args.push(status); where.push(`status = $${args.length}`); }
if (status) { args.push(status); where.push(`status = $${args.length}`); }
if (subject) { args.push(subject); where.push(`subject = $${args.length}`); }
if (subject) { args.push(subject); where.push(`subject = $${args.length}`); }
args.push(Math.min(Math.max(Number(limit) || 60, 1), 500));
args.push(Math.min(Math.max(Number(limit) || 60, 1), 500));
- return db.many(`SELECT id, name, sentence, svg, solid_svg, provider, model, status, created_at, reviewed_at FROM glyph_drafts${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT $${args.length}`, args);
+ return db.many(`SELECT id, name, sentence, svg, solid_svg, styles_json, references_json, provider, model, status, created_at, reviewed_at FROM glyph_drafts${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT $${args.length}`, args);
}
}
async function review({ id: draftId, action, reviewer }) {
async function review({ id: draftId, action, reviewer }) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+
+const COMMANDS = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
+const fmt = (n) => String(Math.round((n + 1e-8) * 100) / 100);
+const nums = (s) => (String(s).match(/[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) || []).map(Number);
+
+function pathPoints(d) {
+ const tokens = String(d).match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) || [];
+ const points = []; let i = 0, cmd = null, x = 0, y = 0, sx = 0, sy = 0;
+ const point = (a, b, rel) => { const px = rel ? x + a : a, py = rel ? y + b : b; points.push([px, py]); return [px, py]; };
+ while (i < tokens.length) {
+ if (/^[a-z]$/i.test(tokens[i])) { cmd = tokens[i++]; if (cmd.toUpperCase() === 'Z') { x = sx; y = sy; continue; } }
+ if (!cmd) return null;
+ const u = cmd.toUpperCase(), rel = cmd === cmd.toLowerCase(), n = COMMANDS[u];
+ if (n == null || !n) continue;
+ const a = tokens.slice(i, i + n).map(Number); if (a.length !== n || a.some((v) => !Number.isFinite(v))) return null; i += n;
+ if (u === 'H') { x = rel ? x + a[0] : a[0]; points.push([x, y]); }
+ else if (u === 'V') { y = rel ? y + a[0] : a[0]; points.push([x, y]); }
+ else if (u === 'A') { points.push([x - a[0], y - a[1]], [x + a[0], y + a[1]]); [x, y] = point(a[5], a[6], rel); }
+ else {
+ const ox = x, oy = y;
+ for (let j = 0; j < n; j += 2) {
+ const p = [rel ? ox + a[j] : a[j], rel ? oy + a[j + 1] : a[j + 1]];
+ points.push(p); if (u === 'M' && j === 0) [sx, sy] = p;
+ }
+ x = points[points.length - 1][0]; y = points[points.length - 1][1];
+ }
+ if (u === 'M') cmd = rel ? 'l' : 'L';
+ }
+ return points;
+}
+
+function measure(markup) {
+ const pts = [];
+ for (const m of String(markup).matchAll(/<(path|circle|ellipse|rect|line|polyline)\b([^>]*)\/>/g)) {
+ const tag = m[1], a = Object.fromEntries([...m[2].matchAll(/([\w-]+)="([^"]*)"/g)].map((x) => [x[1], x[2]]));
+ const add = (x, y) => { if (Number.isFinite(x) && Number.isFinite(y)) pts.push([x, y]); };
+ if (tag === 'path') pts.push(...(pathPoints(a.d) || []));
+ else if (tag === 'circle') { const x = +a.cx, y = +a.cy, r = +a.r; add(x - r, y - r); add(x + r, y + r); }
+ else if (tag === 'ellipse') { const x = +a.cx, y = +a.cy, rx = +a.rx, ry = +a.ry; add(x - rx, y - ry); add(x + rx, y + ry); }
+ else if (tag === 'rect') { const x = +a.x, y = +a.y; add(x, y); add(x + +a.width, y + +a.height); }
+ else if (tag === 'line') { add(+a.x1, +a.y1); add(+a.x2, +a.y2); }
+ else if (tag === 'polyline') { const n = nums(a.points); for (let i = 0; i < n.length; i += 2) add(n[i], n[i + 1]); }
+ }
+ if (!pts.length) return null;
+ const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
+ const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
+ return { x0, y0, x1, y1, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, width: x1 - x0, height: y1 - y0 };
+}
+
+function fittedPath(d, t) {
+ const tokens = String(d).match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) || [];
+ let i = 0, cmd = null, out = '', first = true;
+ const absolute = (v, c) => 12 + (v - c) * t.scale;
+ while (i < tokens.length) {
+ if (/^[a-z]$/i.test(tokens[i])) { cmd = tokens[i++]; out += cmd; if (cmd.toUpperCase() === 'Z') cmd = null; continue; }
+ if (!cmd) throw new Error('missing path command');
+ const u = cmd.toUpperCase(), rel = cmd === cmd.toLowerCase(), n = COMMANDS[u], a = tokens.slice(i, i + n).map(Number); i += n;
+ if (u === 'H') a[0] = rel ? a[0] * t.scale : absolute(a[0], t.cx);
+ else if (u === 'V') a[0] = rel ? a[0] * t.scale : absolute(a[0], t.cy);
+ else if (u === 'A') { a[0] *= t.scale; a[1] *= t.scale; a[5] = rel ? (first ? absolute(a[5], t.cx) : a[5] * t.scale) : absolute(a[5], t.cx); a[6] = rel ? (first ? absolute(a[6], t.cy) : a[6] * t.scale) : absolute(a[6], t.cy); }
+ else for (let j = 0; j < n; j += 2) {
+ if (rel && first) { a[j] = absolute(a[j], t.cx); a[j + 1] = absolute(a[j + 1], t.cy); }
+ else { a[j] = rel ? a[j] * t.scale : absolute(a[j], t.cx); a[j + 1] = rel ? a[j + 1] * t.scale : absolute(a[j + 1], t.cy); }
+ }
+ out += a.map(fmt).join(' '); first = false; if (u === 'M') cmd = rel ? 'l' : 'L'; if (i < tokens.length && !/^[a-z]$/i.test(tokens[i])) out += ' ';
+ }
+ return out;
+}
+
+function transform(markup, t) {
+ return String(markup).replace(/<(path|circle|ellipse|rect|line|polyline)\b([^>]*)\/>/g, (_, tag, attrs) => `<${tag}${attrs.replace(/([\w-]+)="([^"]*)"/g, (_, k, v) => {
+ let z = v;
+ if (tag === 'path' && k === 'd') z = fittedPath(v, t);
+ else if (tag === 'polyline' && k === 'points') z = nums(v).map((n, i) => fmt(12 + (n - (i % 2 ? t.cy : t.cx)) * t.scale)).join(' ');
+ else if (['cx', 'x', 'x1', 'x2'].includes(k)) z = fmt(12 + (+v - t.cx) * t.scale);
+ else if (['cy', 'y', 'y1', 'y2'].includes(k)) z = fmt(12 + (+v - t.cy) * t.scale);
+ else if (['r', 'rx', 'ry', 'width', 'height'].includes(k)) z = fmt(+v * t.scale);
+ return `${k}="${z}"`;
+ })}/>`);
+}
+
+function fitDrawing(markup, transformSpec = null) {
+ const before = measure(markup);
+ if (!before || !Math.max(before.width, before.height)) return { ok: false, why: 'the drawing has no measurable ink' };
+ const t = transformSpec || { cx: before.cx, cy: before.cy, scale: 19 / Math.max(before.width, before.height) };
+ const svg = transform(markup, t), after = measure(svg);
+ const long = after && Math.max(after.width, after.height);
+ if (!after || Math.abs(after.cx - 12) > 0.75 || Math.abs(after.cy - 12) > 0.75 || long < 18 || long > 20) return { ok: false, why: 'the fitted drawing must be centred within 0.75 units and 18–20 units on its long side' };
+ return { ok: true, svg, transform: t, metrics: after };
+}
+
+module.exports = { measure, fitDrawing };
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+
+// Iconify is the temporary reference source until Exchange exposes /v1/search/icons.
+// The model receives names and concepts only; SVG paths are never sent to it.
+const API = 'https://api.iconify.design';
+
+async function searchReferences(query, { fetchImpl = globalThis.fetch, limit = 5 } = {}) {
+ const q = String(query || '').trim().slice(0, 120);
+ if (!q) return [];
+ const url = new URL('/search', API);
+ url.searchParams.set('query', q);
+ url.searchParams.set('limit', String(Math.min(Math.max(Number(limit) || 5, 1), 8)));
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
+ if (!response.ok) throw new Error(`Icon references returned ${response.status}`);
+ const body = await response.json();
+ return (Array.isArray(body.icons) ? body.icons : []).slice(0, 8).flatMap((fullName) => {
+ const [prefix, name] = String(fullName).split(':');
+ const collection = body.collections && body.collections[prefix];
+ const license = collection && collection.license;
+ if (!/^[a-z0-9-]+$/.test(prefix || '') || !/^[a-z0-9-]+$/.test(name || '') || !license || !license.spdx) return [];
+ return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),
+ author: String((collection.author && collection.author.name) || '').slice(0, 100),
+ license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),
+ preview: `${API}/${prefix}/${name}.svg` }];
+ });
+}
+
+module.exports = { searchReferences };
@@ -236,6 +236,22 @@ function createIconRoutes(ctx) {
@@ -236,6 +236,22 @@ function createIconRoutes(ctx) {
}
}
});
});
+ // SDK/API entry point for a full style family. API guests use the same-origin form and its Turnstile caps;
+ // programmatic drafts require a signed-in Exchange/Network identity.
+ r.post('/api/v1/glyphs/draft', express.json({ limit: '8kb' }), async (req, res) => {
+ const subject = subjectOf(req);
+ if (!subject) return problem(res, req, 401, 'design.sign_in_required', 'Sign in to draft glyphs through the API.');
+ if (!writeAllowed(req, config)) return problem(res, req, 403, 'design.same_origin_required', 'Open this request from an authorized OpenVibe client.');
+ try {
+ const body = req.body || {};
+ const out = await drafts.draft({ subject, name: body.name, sentence: body.sentence, styles: body.styles, sourceSvg: body.sourceSvg });
+ return res.status(201).set('Cache-Control', 'private, no-store').json({ kept: out.kept, refused: out.refused });
+ } catch (err) {
+ if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph API draft failed:', err && err.message);
+ return problem(res, req, err.status || 500, 'design.glyph_draft_failed', err.status && err.status < 500 ? err.message : 'The glyph could not be drafted.');
+ }
+ });
+
r.post('/icons/drafts/:id/review', form, async (req, res) => {
r.post('/icons/drafts/:id/review', form, async (req, res) => {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!isAdmin(req)) return res.status(403).send('Only an admin reviews drafts.');
if (!isAdmin(req)) return res.status(403).send('Only an admin reviews drafts.');
@@ -258,12 +274,29 @@ function createIconRoutes(ctx) {
@@ -258,12 +274,29 @@ function createIconRoutes(ctx) {
}).filter(Boolean);
}).filter(Boolean);
res.json({ count: out.length, glyphs: out });
res.json({ count: out.length, glyphs: out });
});
});
+ 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));
+ res.set('Cache-Control', 'public, max-age=300').set(OPEN).json({ query: q, glyphs: names.map((name) => ({ name, svg: glyphs.path(name), solid: glyphs.solid(name) || '' })) });
+ });
+ r.get('/api/v1/glyphs/:name', (req, res, next) => {
+ const name = aliases.resolve(String(req.params.name || '').toLowerCase());
+ if (!name) return next();
+ const style = String(req.query.style || 'regular').toLowerCase();
+ const widths = { thin: 1, light: 1.5, regular: 2, bold: 2.5, line: 2, solid: null, duotone: 2 };
+ if (!Object.prototype.hasOwnProperty.call(widths, style)) return problem(res, req, 422, 'design.invalid_style', 'style is thin, light, regular, bold, solid or duotone.');
+ const line = glyphs.path(name), solid = glyphs.solid(name) || '';
+ if (style === 'solid' && !solid) return problem(res, req, 404, 'design.glyph_style_missing', 'This glyph does not have a solid drawing yet.');
+ const drawing = style === 'solid' ? solid : line;
+ res.set('Cache-Control', 'public, max-age=300').set(OPEN).json({ name, style, svg: drawing, solid,
+ strokeWidth: widths[style], underlay: style === 'duotone' ? solid : '', underlayOpacity: style === 'duotone' && solid ? 0.2 : 0 });
+ });
r.get('/api/v1/glyphs/drafts', async (req, res) => {
r.get('/api/v1/glyphs/drafts', async (req, res) => {
const status = String(req.query.status || 'approved');
const status = String(req.query.status || 'approved');
if (status !== 'approved' && !isAdmin(req)) return problem(res, req, 403, 'design.glyph_drafts_private', 'Only approved drafts are public.');
if (status !== 'approved' && !isAdmin(req)) return problem(res, req, 403, 'design.glyph_drafts_private', 'Only approved drafts are public.');
if (!['approved', 'draft', 'rejected'].includes(status)) return problem(res, req, 422, 'design.invalid', 'status is approved, draft or rejected.');
if (!['approved', 'draft', 'rejected'].includes(status)) return problem(res, req, 422, 'design.invalid', 'status is approved, draft or rejected.');
const rows = await drafts.list({ status, limit: 500 });
const rows = await drafts.list({ status, limit: 500 });
- res.set('Cache-Control', status === 'approved' ? 'public, max-age=300' : 'private, no-store').json({ drafts: rows.map((d) => ({ id: d.id, name: d.name, sentence: d.sentence, svg: d.svg, solid_svg: d.solid_svg || null, model: d.model, status: d.status, created_at: d.created_at, reviewed_at: d.reviewed_at })) });
+ res.set('Cache-Control', status === 'approved' ? 'public, max-age=300' : 'private, no-store').json({ drafts: rows.map((d) => ({ id: d.id, name: d.name, sentence: d.sentence, svg: d.svg, solid_svg: d.solid_svg || null, styles: d.styles_json || [], references: d.references_json || [], model: d.model, status: d.status, created_at: d.created_at, reviewed_at: d.reviewed_at })) });
});
});
return r;
return r;
}
}
@@ -91,10 +91,24 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -91,10 +91,24 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
${left.length > 24 ? `<details class="ic-fold ic-left-fold"><summary>${left.length - 24} more <span>each used a few times</span></summary><ul class="ic-left">${left.slice(24).map((n) => `<li><a href="/icons?draw=${encodeURIComponent(n.name)}#draw">${esc(n.name)}</a><small>${n.uses}</small></li>`).join('')}</ul></details>` : ''}
${left.length > 24 ? `<details class="ic-fold ic-left-fold"><summary>${left.length - 24} more <span>each used a few times</span></summary><ul class="ic-left">${left.slice(24).map((n) => `<li><a href="/icons?draw=${encodeURIComponent(n.name)}#draw">${esc(n.name)}</a><small>${n.uses}</small></li>`).join('')}</ul></details>` : ''}
</section>` : '';
</section>` : '';
- const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art"${d.solid_svg ? ' title="The line drawing, and its solid twin as the sites show it at 18 px and under"' : ''}>${svg(d.svg, 40)}${d.solid_svg ? solidSvg(d.solid_svg, 20) : svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}</li>`;
+ const compare = (d) => {
+ const all = [...glyphs.NAMES].sort();
+ const at = all.findIndex((n) => n > d.name);
+ const neighbours = [all[(at + all.length - 2) % all.length], all[(at + all.length - 1) % all.length], all[at % all.length], all[(at + 1) % all.length]].filter(Boolean);
+ const styles = [['thin', 1], ['light', 1.5], ['regular', 2], ['bold', 2.5], ['solid', 2], ['duotone', 2]];
+ const sample = (path, size, width, style) => style === 'solid'
+ ? `<svg class="ic-compare-solid" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${path}</svg>`
+ : `<svg viewBox="0 0 24 24" width="${size}" height="${size}" style="stroke-width:${width}" aria-hidden="true">${style === 'duotone' && d.solid_svg ? `<g class="ic-compare-duo">${d.solid_svg}</g>` : ''}${path}</svg>`;
+ const samples = (name, size, width, style, candidate) => {
+ const path = candidate ? (style === 'solid' && d.solid_svg ? d.solid_svg : d.svg) : (glyphs.path(name) || '');
+ return sample(path, size, width, style);
+ };
+ return `<details class="ic-compare"><summary>Compare styles with four neighbours</summary>${styles.map(([style, width]) => `<div class="ic-compare-row"><b>${style}</b>${[16, 20, 32].map((size) => `<div class="ic-compare-size"><small>${size}px</small><div>${samples(d.name, size, width, style, true)}${neighbours.map((n) => samples(n, size, width, style, false)).join('')}</div></div>`).join('')}</div>`).join('')}${(d.references_json || d.references || []).length ? `<p class="ic-compare-refs">References: ${(d.references_json || d.references).map((r) => `<a href="${esc(r.preview)}" target="_blank" rel="noopener">${esc(r.setName)} · ${esc(r.name)} · ${esc(r.license)}</a>`).join(' · ')}</p>` : ''}</details>`;
+ };
+ const draftCard = (d, actions = '') => `<li class="ic-draft is-${esc(d.status)}"><div class="ic-draft-art"${d.solid_svg ? ' title="The line drawing, and its solid twin as the sites show it at 18 px and under"' : ''}>${svg(d.svg, 40)}${d.solid_svg ? solidSvg(d.solid_svg, 20) : svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}${actions ? compare(d) : ''}</li>`;
const reviewForm = (d) => `<div class="ic-draft-acts">${['approve', 'reject'].map((a) => `<form method="post" action="/icons/drafts/${esc(d.id)}/review"><input type="hidden" name="action" value="${a}"><button type="submit" class="ic-btn${a === 'approve' ? ' is-go' : ''}">${a === 'approve' ? 'Approve' : 'Reject'}</button></form>`).join('')}</div>`;
const reviewForm = (d) => `<div class="ic-draft-acts">${['approve', 'reject'].map((a) => `<form method="post" action="/icons/drafts/${esc(d.id)}/review"><input type="hidden" name="action" value="${a}"><button type="submit" class="ic-btn${a === 'approve' ? ' is-go' : ''}">${a === 'approve' ? 'Approve' : 'Reject'}</button></form>`).join('')}</div>`;
const drawSec = `<section class="ic-sec" id="draw" aria-labelledby="ic-draw">
const drawSec = `<section class="ic-sec" id="draw" aria-labelledby="ic-draw">
- <div class="ic-sec-head"><h2 id="ic-draw">Draw an icon</h2><p>Name it and say what it stands for. OpenVibe.AI draws a few candidates in this hand; each is checked (strokes only, on the grid, a few shapes) before it is kept, and an admin reviews it before it joins the set.</p></div>
+ <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 }) : ''}
<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>
@@ -126,7 +140,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -126,7 +140,7 @@ 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}/icons/<name>.svg?style=auto|line|solid\nGET ${base}/icons/<name>.mjs | .xml | /favicon.zip`, 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/<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>`;
@@ -39,7 +39,7 @@ function fakeAi(calls) {
@@ -39,7 +39,7 @@ function fakeAi(calls) {
{
{
const calls = [];
const calls = [];
const db = fakeDb();
const db = fakeDb();
- const d = createGlyphDrafts({ db, ai: fakeAi(calls) });
+ const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });
const out = await d.draft({ subject: 'usr_1', name: 'hot-tub', sentence: 'a round tub with steam' });
const out = await d.draft({ subject: 'usr_1', name: 'hot-tub', sentence: 'a round tub with steam' });
assert.ok(out.kept.length >= 1, 'drafts are kept');
assert.ok(out.kept.length >= 1, 'drafts are kept');
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls.length, 1);
@@ -52,13 +52,24 @@ function fakeAi(calls) {
@@ -52,13 +52,24 @@ function fakeAi(calls) {
{
{
const calls = [];
const calls = [];
const db = fakeDb();
const db = fakeDb();
- const d = createGlyphDrafts({ db, ai: fakeAi(calls) });
+ const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });
const out = await d.draft({ subject: null, guest: { id: 'a'.repeat(32), ipHash: 'h1' }, name: 'hot-tub' });
const out = await d.draft({ subject: null, guest: { id: 'a'.repeat(32), ipHash: 'h1' }, name: 'hot-tub' });
assert.ok(out.kept.length >= 1, 'a guest gets drafts too');
assert.ok(out.kept.length >= 1, 'a guest gets drafts too');
assert.deepStrictEqual(calls[0].preset, { name: 'cheapest' }, 'a guest runs on the cheapest offer');
assert.deepStrictEqual(calls[0].preset, { name: 'cheapest' }, 'a guest runs on the cheapest offer');
assert.strictEqual(calls[0].prefer, undefined, 'a guest never asks for Claude');
assert.strictEqual(calls[0].prefer, undefined, 'a guest never asks for Claude');
assert.strictEqual(calls[0].person, undefined, 'a guest is nobody\'s key');
assert.strictEqual(calls[0].person, undefined, 'a guest is nobody\'s key');
}
}
+ // A missing solid twin keeps the exact reviewed line as its source while the model supplies filled geometry.
+ {
+ const calls = [], db = fakeDb();
+ const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });
+ const sourceSvg = '<path d="M5 12h14"/>';
+ const out = await d.draft({ subject: 'usr_2', name: 'hot-tub', sourceSvg, styles: ['solid'] });
+ assert.ok(out.kept.length && out.kept[0].solid_svg, 'the model solid is fitted and kept');
+ assert.ok(calls[0].system.includes('filled twins for existing'), 'solid work uses the solid-specific instructions');
+ assert.ok(calls[0].prompt.includes(sourceSvg), 'the existing line drawing is supplied to match');
+ assert.ok(out.kept[0].svg.includes('M2.5 12h19'), 'the original line geometry is fitted, not redrawn');
+ }
// The AI client carries the preset into the run it creates.
// The AI client carries the preset into the run it creates.
const aiSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'arena', 'ai.js'), 'utf8');
const aiSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'arena', 'ai.js'), 'utf8');
assert.match(aiSrc, /async function text\(\{[^}]*preset = null/, 'text() takes a preset');
assert.match(aiSrc, /async function text\(\{[^}]*preset = null/, 'text() takes a preset');
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { fitDrawing, measure } = require('../server/glyphs/fit');
+
+const cases = [
+ '<path d="M5 12h14"/>',
+ '<path d="m6 15 6-6 6 6"/>',
+ '<circle cx="8" cy="10" r="3"/><path d="M10 10h5"/>',
+];
+for (const svg of cases) {
+ const result = fitDrawing(svg);
+ assert.equal(result.ok, true, result.why);
+ const box = measure(result.svg);
+ assert.ok(Math.abs(box.cx - 12) <= 0.75 && Math.abs(box.cy - 12) <= 0.75, 'the fitted geometry is centered');
+ assert.ok(Math.max(box.width, box.height) >= 18 && Math.max(box.width, box.height) <= 20, 'the long side fits the target');
+}
+const fit = fitDrawing(cases[0]);
+assert.equal(fitDrawing('<path d="M12 12"/>').ok, false, 'a dot without measurable area is refused');
+assert.equal(fitDrawing('<path d="M5 12h14"/>', fit.transform).ok, true, 'the solid twin reuses its line transform');
+console.log('glyph fit: candidates are normalized to centre and 18–20 units before review');
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { searchReferences } = require('../server/glyphs/references');
+
+(async () => {
+ let requested;
+ const refs = await searchReferences('hot tub', { fetchImpl: async (url) => {
+ requested = new URL(url);
+ return { ok: true, async json() { return {
+ icons: ['lucide:hot-tub', 'unknown:no-license'],
+ collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },
+ }; } };
+ } });
+ assert.equal(requested.pathname, '/search');
+ assert.equal(requested.searchParams.get('query'), 'hot tub');
+ assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',
+ licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);
+ console.log('glyph references: Iconify search keeps attribution and filters unlicensed results');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const glyphs = require('openvibe-shared/glyphs');
const glyphs = require('openvibe-shared/glyphs');
+const { createIconRoutes } = require('../server/http/icons');
const { clean, cleanSolid } = require('../server/glyphs/validate');
const { clean, cleanSolid } = require('../server/glyphs/validate');
const { stillFontAwesome } = require('../server/views/icons');
const { stillFontAwesome } = require('../server/views/icons');
@@ -20,6 +21,7 @@ const ai = {
@@ -20,6 +21,7 @@ const ai = {
const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
const form = (fields) => ({ body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
(async () => {
(async () => {
+ assert.equal(typeof createIconRoutes, 'function');
const t = await boot({ ai });
const t = await boot({ ai });
const ana = t.network.addUser('ana');
const ana = t.network.addUser('ana');
const boss = t.network.addUser('boss', { role: 'admin' });
const boss = t.network.addUser('boss', { role: 'admin' });
@@ -73,6 +75,19 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -73,6 +75,19 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
assert.equal(j.fa['fa-eye'] || j.fa.eye, 'eye');
assert.equal(j.fa['fa-eye'] || j.fa.eye, 'eye');
});
});
+ await check('style and name search API returns the selected glyph drawing', async () => {
+ const thin = await t.get('/api/v1/glyphs/eye?style=thin');
+ assert.equal(thin.status, 200);
+ assert.equal(thin.json().style, 'thin');
+ assert.equal(thin.json().svg, glyphs.path('eye'));
+ assert.equal(thin.json().strokeWidth, 1);
+ const bad = await t.get('/api/v1/glyphs/eye?style=unknown');
+ assert.equal(bad.status, 422);
+ const search = await t.get('/api/v1/glyphs/search?q=eye');
+ assert.equal(search.status, 200);
+ assert.ok(search.json().glyphs.some((g) => g.name === 'eye'));
+ });
+
await check('the validator keeps strokes on the grid and refuses anything else', async () => {
await check('the validator keeps strokes on the grid and refuses anything else', async () => {
const ok = clean('<path d="M4 12h16"/><circle cx="12" cy="12" r="8"/>');
const ok = clean('<path d="M4 12h16"/><circle cx="12" cy="12" r="8"/>');
assert.ok(ok.ok, ok.why);
assert.ok(ok.ok, ok.why);
@@ -170,7 +185,8 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -170,7 +185,8 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
assert.ok(!page.text.includes('onclick="steal()"'), 'the refused one is nowhere');
assert.ok(!page.text.includes('onclick="steal()"'), 'the refused one is nowhere');
assert.ok(!page.text.includes('Waiting for review'), 'not a reviewer');
assert.ok(!page.text.includes('Waiting for review'), 'not a reviewer');
// Each kept drawing has its solid twin when that passed its own checks; a failing twin costs the drawing nothing.
// Each kept drawing has its solid twin when that passed its own checks; a failing twin costs the drawing nothing.
- assert.ok(page.text.includes('<svg class="is-solid" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M4.5 9.5h15v9h-15Z"/><path d="M9 6c0-1 1-1 1-2M14 6c0-1 1-1 1-2" class="ln"/></svg>'), 'the solid twin is drawn beside its line drawing');
+ const twin = page.text.match(/<svg class="is-solid"[^>]*>[\s\S]*?<\/svg>/);
+ assert.ok(twin && twin[0].includes('class="ln"') && twin[0].includes('<path'), 'the fitted solid twin is drawn beside its line drawing');
assert.ok(!page.text.includes('fill="red"'), 'a twin that failed its checks is not kept');
assert.ok(!page.text.includes('fill="red"'), 'a twin that failed its checks is not kept');
assert.ok(asked[0].system.includes('solid twin'), 'the model is asked for the twins');
assert.ok(asked[0].system.includes('solid twin'), 'the model is asked for the twins');
});
});