openvibers/openvibe.design Commits

Icon drafts come with their solid twin, checked like the line drawing (#96)

OP OpenVibers committed 12 days ago · 87fb147cb3 · 6 files, +73 −18

The drafting prompt asks for each candidate's solid twin (filled shapes, evenodd holes, class="ln" lines) and
shows six of the set's own solids as the hand to follow. validate.cleanSolid checks it: the same shapes and grid,
fill-rule="evenodd" on a path, class="ln", nothing else, and at least one filled shape. A failing twin is dropped
and the line drawing kept. Migration 0009 adds glyph_drafts.solid_svg; a draft card shows the line drawing at 40 px
and the solid at 20 px, as the sites would draw it.

Changes

ddiff --git a/migrations/0009_glyph_draft_solids.sql b/migrations/0009_glyph_draft_solids.sql
nnew file mode 100644
iindex 0000000..94b760a
---- /dev/null
++++ b/migrations/0009_glyph_draft_solids.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Each glyph draft keeps its solid twin (plan L50: the drawing the sites show at 18 px and under), checked by
++-- server/glyphs/validate.js cleanSolid. NULL when the model drew none or its solid failed the checks; the line drawing
++-- is kept either way.
++ALTER TABLE glyph_drafts ADD COLUMN solid_svg text;
ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex 1749bb5..0f5481f 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -71,6 +71,8 @@
.ic-draft{display:flex;flex-wrap:wrap;align-items:center;gap:10px 12px;padding:10px 12px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary)}
.ic-draft-art{display:flex;align-items:flex-end;gap:10px;padding:8px;border-radius:10px;background:var(--bg-primary)}
.ic-draft-art svg:first-child{width:40px;height:40px}.ic-draft-art svg:last-child{width:20px;height:20px}
++/* 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-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}
ddiff --git a/server/glyphs/drafts.js b/server/glyphs/drafts.js
iindex 7afd557..9858862 100644
---- a/server/glyphs/drafts.js
++++ b/server/glyphs/drafts.js
@@@ -5,6 +5,10 @@
* each one. Approved drafts are what openvibe-shared's scripts/pull-glyph-drafts.js takes into the registry; nothing
* reaches a page without that review and a Shared release.
*
++ * Each candidate comes with its solid twin (plan L50: the drawing the sites show at 18 px and under): filled shapes,
++ * evenodd holes, lines kept as class="ln", checked by validate.cleanSolid. A solid that fails is dropped and the line
++ * drawing kept without one (solid_svg null), so a weak solid never costs a good drawing.
++ *
* draft({ subject, name, sentence, guest }) → { kept: [draft…], refused: [why…] } (at most PER_DAY a person per day)
* guest: { id, ipHash } for someone not signed in (L50 step 5): subject is then guest:<id>, and the guest caps
* (per guest, per address, all guests, a day; config.glyphGuests) are checked and the attempt recorded first.
@@@ -12,7 +16,7 @@
* review({ id, action: 'approve' | 'reject', reviewer })
*/
const crypto = require('crypto');
--const { clean } = require('./validate');
++const { clean, cleanSolid } = require('./validate');
const PER_DAY = 12;
const CANDIDATES = 4;
@@@ -25,7 +29,9 @@ function examples() {
let glyphs = null;
try { glyphs = require('openvibe-shared/glyphs'); } catch { return ''; }
const lines = EXAMPLES.map((n) => [n, glyphs.path(n)]).filter(([, d]) => d).map(([n, d]) => `${n}: ${d}`);
-- return lines.length ? `\n\nThe set you are drawing for (match this hand: weights, corner radii, how much of the 24 box it fills, how few lines):\n${lines.join('\n')}` : '';
++ const solids = ['bell', 'user', 'calendar', 'mic', 'info', 'pin'].map((n) => [n, glyphs.solid ? glyphs.solid(n) : '']).filter(([, d]) => d).map(([n, d]) => `${n}: ${d}`);
++ return (lines.length ? `\n\nThe set you are drawing for (match this hand: weights, corner radii, how much of the 24 box it fills, how few lines):\n${lines.join('\n')}` : '')
++ + (solids.length ? `\n\nTheir solid twins, the way to draw yours (filled shapes, holes cut with fill-rule="evenodd", a part that must stay a line as class="ln"):\n${solids.join('\n')}` : '');
}
const SYSTEM = `You draw icons for OpenVibe's glyph set. Every glyph is the inside of an SVG with viewBox "0 0 24 24":
@@@ -33,8 +39,10 @@ strokes only (the page draws them 2 units wide, round caps and joins, in the tex
path, circle, ellipse, rect, line and polyline, every coordinate between 1 and 23, nothing filled, no transforms.
Simple, centred on (12, 12), filling about 3 to 21 of the box, with at least 2.5 units between parallel lines so it
reads at 16 px, in the hand of a clean line icon set. A dot is a zero-length line ("M12 12h.01"). Each candidate is a
--different idea, not the same drawing moved. Answer with JSON only:
--{"candidates": ["<path d=\\"…\\"/>", …]} with ${CANDIDATES} different candidates.${examples()}`;
++different idea, not the same drawing moved. Each candidate also has its solid twin for small sizes (16 px): the same
++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:
++{"candidates": [{"line": "<path d=\\"…\\"/>", "solid": "<path d=\\"…\\"/>"}, …]} with ${CANDIDATES} different candidates.${examples()}`;
function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 } }) {
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
@@@ -86,22 +94,25 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
};
let { answer, got: list } = await ask();
// Fewer than two that pass the checks: ask once more, saying why the last ones failed (more reliable drafts).
-- const passing = list.filter((c) => clean(typeof c === 'string' ? c : '').ok).length;
++ 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;
if (passing < 2) {
-- const whys = list.map((c) => clean(typeof c === 'string' ? c : '')).filter((r) => !r.ok).map((r) => r.why).slice(0, 4);
++ const whys = list.map((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(typeof c === 'string' ? c : '');
++ const r = clean(lineOf(c));
if (!r.ok) { refused.push(r.why); continue; }
if (seen.has(r.svg)) continue;
seen.add(r.svg);
-- const row = { id: id(), subject: who, name: n, sentence: what, svg: r.svg, provider: answer.provider, model: answer.model, status: 'draft', created_at: iso() };
-- await db.exec('INSERT INTO glyph_drafts (id, subject, name, sentence, svg, provider, model, status, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)',
-- [row.id, row.subject, row.name, row.sentence, row.svg, row.provider, row.model, row.status, row.created_at]);
++ 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]);
kept.push(row);
}
if (!list.length) refused.push('the model answered no candidates');
@@@ -113,7 +124,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, 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, 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 }) {
ddiff --git a/server/glyphs/validate.js b/server/glyphs/validate.js
iindex 78a9d67..a1df072 100644
---- a/server/glyphs/validate.js
++++ b/server/glyphs/validate.js
@@@ -5,7 +5,10 @@
* shapes, nothing outside the grid. Whatever a model returns is parsed shape by shape and rebuilt from the attributes
* that are allowed, with numbers only; anything else (a fill, a style, a script, a link, a transform) is refused.
*
-- * clean(markup) → { ok: true, svg } with the rebuilt drawing, or { ok: false, why } in words
++ * clean(markup) → { ok: true, svg } with the rebuilt drawing, or { ok: false, why } in words
++ * cleanSolid(markup) → the same for a glyph's solid twin (L50, the small-size drawing openvibe-shared shows at 18 px
++ * and under): filled shapes, a hole cut with fill-rule="evenodd" on a path, a part that has to
++ * stay a line marked class="ln". Nothing else: no colour, no style, no stroke attributes.
*/
const SHAPES = {
path: ['d'],
@@@ -24,7 +27,14 @@ const PATH_D = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s-]+$/;
function numbersOf(s) { return (String(s).match(/-?\d*\.?\d+(?:e-?\d+)?/gi) || []).map(Number); }
const onGrid = (n) => Number.isFinite(n) && n >= -1 && n <= 25;
--function clean(markup) {
++function clean(markup) { return rebuild(markup, false); }
++function cleanSolid(markup) {
++ const r = rebuild(markup, true);
++ if (r.ok && !/<(?:path|circle|ellipse|rect)(?![^>]*class="ln")[^>]*\/>/.test(r.svg)) return { ok: false, why: 'a solid drawing fills at least one shape' };
++ return r;
++}
++
++function rebuild(markup, solid) {
const src = String(markup || '').trim()
.replace(/^<svg\b[^>]*>/i, '').replace(/<\/svg>\s*$/i, '') // a whole <svg> is accepted for its inside
.replace(/<!--[\s\S]*?-->/g, '').trim();
@@@ -42,6 +52,11 @@ function clean(markup) {
for (const a of m[2].matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[a[1].toLowerCase()] = a[2];
for (const k of Object.keys(attrs)) {
if (SHAPES[name].includes(k)) continue;
++ if (solid) {
++ if (k === 'fill-rule' && name === 'path' && attrs[k] === 'evenodd') continue; // a hole
++ if (k === 'class' && attrs[k] === 'ln') continue; // a part that stays a line
++ return { ok: false, why: `${name} ${k}="…" is not allowed in a solid drawing: shapes, evenodd holes and class="ln" lines only` };
++ }
if (k === 'stroke-width' && NUM.test(attrs[k]) && Number(attrs[k]) >= 1 && Number(attrs[k]) <= 4) continue; // dots may be heavier
if (['fill', 'stroke', 'stroke-linecap', 'stroke-linejoin'].includes(k) && /^(none|currentColor|round)$/i.test(attrs[k])) continue; // the page's own
return { ok: false, why: `${name} ${k}="…" is not allowed: strokes only, in the page's colour` };
@@@ -65,6 +80,8 @@ function clean(markup) {
keep.push(`${k}="${v.replace(/\s+/g, ' ')}"`);
}
if (!keep.length) return { ok: false, why: `a ${name} with nothing to draw` };
++ if (solid && attrs['fill-rule'] === 'evenodd' && name === 'path') keep.push('fill-rule="evenodd"');
++ if (solid && attrs.class === 'ln') keep.push('class="ln"');
out.push(`<${name} ${keep.join(' ')}/>`);
if (out.length > MAX_SHAPES) return { ok: false, why: `at most ${MAX_SHAPES} shapes: a glyph is simple` };
rest = rest.slice(m[0].length);
@@@ -72,4 +89,4 @@ function clean(markup) {
return { ok: true, svg: out.join('') };
}
--module.exports = { clean, SHAPES, MAX_SHAPES };
++module.exports = { clean, cleanSolid, SHAPES, MAX_SHAPES };
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex 497b3d4..4284fdd 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -37,6 +37,8 @@ function stillFontAwesome() {
const inner = (name) => glyphs.path(name);
const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${d}</svg>`;
++// 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 } = {}) {
const fa = faByGlyph();
@@@ -89,7 +91,7 @@ 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">${svg(d.svg, 40)}${svg(d.svg, 20)}</div><div class="ic-draft-t"><b>${esc(d.name)}</b>${actions && d.sentence ? `<span>${esc(d.sentence)}</span>` : ''}<small>${esc(d.status === 'draft' ? 'waiting for review' : d.status)}${d.model ? ` · ${esc(d.model)}` : ''}</small></div>${actions}</li>`;
++ const 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 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>
ddiff --git a/test/icons.test.js b/test/icons.test.js
iindex 871b7c0..54aee1e 100644
---- a/test/icons.test.js
++++ b/test/icons.test.js
@@@ -6,7 +6,7 @@
const assert = require('node:assert/strict');
const { boot, check, done } = require('./helpers/boot');
const glyphs = require('openvibe-shared/glyphs');
--const { clean } = require('../server/glyphs/validate');
++const { clean, cleanSolid } = require('../server/glyphs/validate');
const { stillFontAwesome } = require('../server/views/icons');
const ORIGIN = 'https://openvibe.design';
@@@ -88,6 +88,20 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
]) assert.equal(clean(bad).ok, false, what);
});
++ await check('a solid twin: filled shapes, evenodd holes and class="ln" lines, nothing else', async () => {
++ const ok = cleanSolid('<path fill-rule="evenodd" d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM11 10h2v7h-2Z"/><path class="ln" d="M8 21h8"/>');
++ assert.ok(ok.ok, ok.why);
++ assert.equal(ok.svg, '<path d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM11 10h2v7h-2Z" fill-rule="evenodd"/><path d="M8 21h8" class="ln"/>');
++ for (const [bad, what] of [
++ ['<path class="ln" d="M8 20h8"/>', 'lines only: nothing filled'],
++ ['<path d="M4 4h4v4H4Z" fill="red"/>', 'a colour'],
++ ['<path d="M4 4h4v4H4Z" class="x"/>', 'another class'],
++ ['<circle cx="12" cy="12" r="8" fill-rule="evenodd"/>', 'evenodd on a circle'],
++ ['<path d="M4 4h4v4H4Z" style="opacity:.5"/>', 'a style'],
++ ]) assert.equal(cleanSolid(bad).ok, false, what);
++ assert.equal(clean('<path fill-rule="evenodd" d="M4 4h4"/>').ok, false, 'a line drawing takes no fill rule');
++ });
++
await check('drawing takes this site\'s own form, signed in or not', async () => {
const cross = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
assert.equal(cross.status, 403);
@@@ -137,8 +151,8 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
await check('OpenVibe.AI drafts candidates; the checked ones are kept, the rest refused', async () => {
answer = '```json\n' + JSON.stringify({ candidates: [
-- '<path d="M5 10h14v8H5z"/><path d="M9 6c0-1 1-1 1-2M14 6c0-1 1-1 1-2"/>',
-- '<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>',
++ { line: '<path d="M5 10h14v8H5z"/><path d="M9 6c0-1 1-1 1-2M14 6c0-1 1-1 1-2"/>', solid: '<path d="M4.5 9.5h15v9h-15Z"/><path class="ln" d="M9 6c0-1 1-1 1-2M14 6c0-1 1-1 1-2"/>' },
++ { line: '<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>', solid: '<rect x="4" y="10" width="16" height="8" rx="2" fill="red"/>' },
'<path d="M5 10h14v8H5z" onclick="steal()"/>',
'<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>',
] }) + '\n```';
@@@ -155,6 +169,10 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
assert.ok(page.text.includes('value="hot-tub"'), 'the form keeps the name');
assert.ok(!page.text.includes('onclick="steal()"'), 'the refused one is nowhere');
assert.ok(!page.text.includes('Waiting for review'), 'not a reviewer');
++ // 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');
++ 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');
});
await check('a bad name or an empty answer says so, without a stored draft', async () => {