openvibers/openvibe.design Commits
Icon drafts come with their solid twin, checked like the line drawing (#96)
Changes
nnew file mode 100644@@@ -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;@@@ -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}@@@ -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 texpath, 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 itreads 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 }) {@@@ -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 heavierif (['fill', 'stroke', 'stroke-linecap', 'stroke-linejoin'].includes(k) && /^(none|currentColor|round)$/i.test(attrs[k])) continue; // the page's ownreturn { 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 };@@@ -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>@@@ -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(), headerawait 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(), headerassert.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 () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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;
@@ -71,6 +71,8 @@
@@ -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{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{display:flex;align-items:flex-end;gap:10px;padding:8px;border-radius:10px;background:var(--bg-primary)}
.ic-draft-art svg:first-child{width:40px;height:40px}.ic-draft-art svg:last-child{width:20px;height:20px}
.ic-draft-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{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}
@@ -5,6 +5,10 @@
@@ -5,6 +5,10 @@
* each one. Approved drafts are what openvibe-shared's scripts/pull-glyph-drafts.js takes into the registry; nothing
* 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.
* 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)
* 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
* 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.
* (per guest, per address, all guests, a day; config.glyphGuests) are checked and the attempt recorded first.
@@ -12,7 +16,7 @@
@@ -12,7 +16,7 @@
* review({ id, action: 'approve' | 'reject', reviewer })
* review({ id, action: 'approve' | 'reject', reviewer })
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const { clean } = require('./validate');
+const { clean, cleanSolid } = require('./validate');
const PER_DAY = 12;
const PER_DAY = 12;
const CANDIDATES = 4;
const CANDIDATES = 4;
@@ -25,7 +29,9 @@ function examples() {
@@ -25,7 +29,9 @@ function examples() {
let glyphs = null;
let glyphs = null;
try { glyphs = require('openvibe-shared/glyphs'); } catch { return ''; }
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}`);
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":
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
@@ -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.
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
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
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 } }) {
function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests = { perGuest: 2, perAddress: 4, daily: 25 } }) {
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
const id = () => `gd_${crypto.randomBytes(8).toString('hex')}`;
@@ -86,22 +94,25 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -86,22 +94,25 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
};
};
let { answer, got: list } = await ask();
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).
// 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) {
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);
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(typeof c === 'string' ? c : '');
+ const r = 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;
if (seen.has(r.svg)) continue;
seen.add(r.svg);
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);
kept.push(row);
}
}
if (!list.length) refused.push('the model answered no candidates');
if (!list.length) refused.push('the model answered no candidates');
@@ -113,7 +124,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
@@ -113,7 +124,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, 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 }) {
async function review({ id: draftId, action, reviewer }) {
@@ -5,7 +5,10 @@
@@ -5,7 +5,10 @@
* shapes, nothing outside the grid. Whatever a model returns is parsed shape by shape and rebuilt from the attributes
* 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.
* 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 = {
const SHAPES = {
path: ['d'],
path: ['d'],
@@ -24,7 +27,14 @@ const PATH_D = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s-]+$/;
@@ -24,7 +27,14 @@ const PATH_D = /^[MmLlHhVvCcSsQqTtAaZz0-9.,\s-]+$/;
function numbersOf(s) { return (String(s).match(/-?\d*\.?\d+(?:e-?\d+)?/gi) || []).map(Number); }
function numbersOf(s) { return (String(s).match(/-?\d*\.?\d+(?:e-?\d+)?/gi) || []).map(Number); }
const onGrid = (n) => Number.isFinite(n) && n >= -1 && n <= 25;
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()
const src = String(markup || '').trim()
.replace(/^<svg\b[^>]*>/i, '').replace(/<\/svg>\s*$/i, '') // a whole <svg> is accepted for its inside
.replace(/^<svg\b[^>]*>/i, '').replace(/<\/svg>\s*$/i, '') // a whole <svg> is accepted for its inside
.replace(/<!--[\s\S]*?-->/g, '').trim();
.replace(/<!--[\s\S]*?-->/g, '').trim();
@@ -42,6 +52,11 @@ function clean(markup) {
@@ -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 a of m[2].matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[a[1].toLowerCase()] = a[2];
for (const k of Object.keys(attrs)) {
for (const k of Object.keys(attrs)) {
if (SHAPES[name].includes(k)) continue;
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 (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
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` };
return { ok: false, why: `${name} ${k}="…" is not allowed: strokes only, in the page's colour` };
@@ -65,6 +80,8 @@ function clean(markup) {
@@ -65,6 +80,8 @@ function clean(markup) {
keep.push(`${k}="${v.replace(/\s+/g, ' ')}"`);
keep.push(`${k}="${v.replace(/\s+/g, ' ')}"`);
}
}
if (!keep.length) return { ok: false, why: `a ${name} with nothing to draw` };
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(' ')}/>`);
out.push(`<${name} ${keep.join(' ')}/>`);
if (out.length > MAX_SHAPES) return { ok: false, why: `at most ${MAX_SHAPES} shapes: a glyph is simple` };
if (out.length > MAX_SHAPES) return { ok: false, why: `at most ${MAX_SHAPES} shapes: a glyph is simple` };
rest = rest.slice(m[0].length);
rest = rest.slice(m[0].length);
@@ -72,4 +89,4 @@ function clean(markup) {
@@ -72,4 +89,4 @@ function clean(markup) {
return { ok: true, svg: out.join('') };
return { ok: true, svg: out.join('') };
}
}
-module.exports = { clean, SHAPES, MAX_SHAPES };
+module.exports = { clean, cleanSolid, SHAPES, MAX_SHAPES };
@@ -37,6 +37,8 @@ function stillFontAwesome() {
@@ -37,6 +37,8 @@ function stillFontAwesome() {
const inner = (name) => glyphs.path(name);
const inner = (name) => glyphs.path(name);
const svg = (d, size = 24) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${d}</svg>`;
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 } = {}) {
function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], reviewer = false, notice = '', error = '', draw = '', guest = null } = {}) {
const fa = faByGlyph();
const fa = faByGlyph();
@@ -89,7 +91,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
@@ -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>` : ''}
${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">${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 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 (strokes only, on the grid, a few shapes) before it is kept, and an admin reviews it before it joins the set.</p></div>
@@ -6,7 +6,7 @@
@@ -6,7 +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 { clean } = require('../server/glyphs/validate');
+const { clean, cleanSolid } = require('../server/glyphs/validate');
const { stillFontAwesome } = require('../server/views/icons');
const { stillFontAwesome } = require('../server/views/icons');
const ORIGIN = 'https://openvibe.design';
const ORIGIN = 'https://openvibe.design';
@@ -88,6 +88,20 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -88,6 +88,20 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
]) assert.equal(clean(bad).ok, false, what);
]) 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 () => {
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' });
const cross = await t.get('/icons/draft', { method: 'POST', as: ana, ...form({ name: 'hot-tub' }), origin: 'https://evil.test' });
assert.equal(cross.status, 403);
assert.equal(cross.status, 403);
@@ -137,8 +151,8 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -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 () => {
await check('OpenVibe.AI drafts candidates; the checked ones are kept, the rest refused', async () => {
answer = '```json\n' + JSON.stringify({ candidates: [
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()"/>',
'<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"/>',
'<rect x="4" y="10" width="16" height="8" rx="2"/><line x1="8" y1="4" x2="8" y2="7"/>',
] }) + '\n```';
] }) + '\n```';
@@ -155,6 +169,10 @@ const form = (fields) => ({ body: new URLSearchParams(fields).toString(), header
@@ -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('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('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.
+ 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 () => {
await check('a bad name or an empty answer says so, without a stored draft', async () => {