' + html.escape(category['rule']) + '
Return to review. The binding specification is assets/icons/ui/STYLE_GUIDE.md.
+Read it before editing. These are original SolidCraft bases and finished visual references, shown directly from the current SVG masters.
64-unit grid · 2.55 outlines · 4.25 action strokes · round caps and joins · standard 30° isometric axes · top light, left medium, right dark.
+@@EXAMPLES@@ +Reference lists do not control discovery. New SVGs and aliases appear in Open automatically; acceptance is stored in design/review.json.
@@WARNINGS@@
+Every discovered icon without a current acceptance is Open, including unused masters and icons with notes. ✓ accepts the current artwork. ✎ adds feedback.
+No icons match these filters.
+ diff --git a/assets/icons/ui/design/tools/review/review.css b/assets/icons/ui/design/tools/review/review.css new file mode 100644 index 00000000..f6aa119e --- /dev/null +++ b/assets/icons/ui/design/tools/review/review.css @@ -0,0 +1,62 @@ +:root{--bg:#f4f6f9;--fg:#1d2733;--card:#fff;--line:#d5dbe3;--mut:#6b7a8a;--sz:48px} +body.dark{--bg:#1e2530;--fg:#dbe4ee;--card:#262f3c;--line:#3a4657;--mut:#8a9db0} +body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)} +header{position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--line);padding:6px 12px;z-index:5;display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center} +h1{font-size:14px;margin:0}.sum{color:var(--mut)} +button,input,select{font:inherit;color:inherit;padding:2px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);cursor:pointer} +button.on{background:var(--review-accent);color:var(--review-accent-ink);border-color:var(--review-accent)} +.grp{display:flex;gap:3px;align-items:center}.grp small{color:var(--mut)} +main{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;align-items:stretch} +.gm{flex:0 0 11px;writing-mode:vertical-rl;transform:rotate(180deg);font:600 8px/11px system-ui;letter-spacing:.04em;color:var(--mut);background:var(--line);border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-transform:uppercase;font-style:normal;max-height:120px;align-self:stretch} +.gm.tab{background:var(--review-accent);color:var(--review-accent-ink)} +.card{position:relative;flex:0 0 118px;background:var(--card);border:1px solid var(--line);border-radius:7px} +.art{display:flex;justify-content:center;align-items:center;padding:8px 0 2px;min-height:calc(var(--sz) + 10px)} +.art svg{width:var(--sz);height:var(--sz)} +.art .ad,.art .sd,.art .sl{display:none} +body.dark .art .al{display:none}body.dark .art .ad{display:inline} +body.small .art.hs .al,body.small .art.hs .ad{display:none} +body.small:not(.dark) .art.hs .sl{display:inline}body.small.dark .art.hs .sd{display:inline} +.meta{padding:2px 6px 5px;line-height:1.35;word-break:break-all}.meta small{color:var(--mut);font-size:10px} +.src,.acc,.st{font-size:10px;color:var(--mut);font-style:normal}.src{color:var(--mut)}.st{color:var(--review-error)} +.ck,.nt{position:absolute;top:3px;width:20px;height:20px;padding:0;line-height:18px;border-radius:50%;font-size:12px;color:var(--mut);opacity:.55} +.ck{right:3px}.nt{left:3px;border-radius:5px} +.card:hover .ck,.card:hover .nt{opacity:1} +.card.final{border-color:var(--review-success)}.card.final .art,.card.final .meta{opacity:.6} +.card.final .ck{background:var(--review-success);color:var(--review-accent-ink);border-color:var(--review-success);opacity:1} +.card.final .nt{display:none} +.card.hasnote{border-color:var(--review-warning)}.card.hasnote .nt{background:var(--review-warning);color:var(--review-accent-ink);border-color:var(--review-warning);opacity:1} +.pop{position:absolute;left:0;top:100%;z-index:9;width:250px;background:var(--card);border:1px solid var(--review-accent);border-radius:7px;padding:6px;box-shadow:0 6px 18px #0004;display:none} +.card.open .pop{display:block}.card.open{z-index:9} +.pop textarea{width:100%;box-sizing:border-box;height:90px;font:12px system-ui;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:5px} +#msg{color:var(--review-error)} +[hidden]{display:none!important} +header .detail{width:100%;display:flex;gap:14px;align-items:center;font-size:11px} +header a{color:var(--fg);text-decoration:underline;text-underline-offset:2px} +.help{color:var(--mut);font-size:11px;margin:8px 12px 0} +.warning:empty{display:none}.warning,.error{color:var(--review-error)} +.warning{width:100%;margin:0;font-size:11px} +#msg{color:var(--mut)}#msg.error{color:var(--review-error)} +button:disabled{cursor:not-allowed;opacity:.3} +button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--review-accent);outline-offset:2px} +#empty{padding:30px;color:var(--mut)} + +.shared{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--mut);font-size:10px;word-break:normal} + +.vision{display:none}body[data-vision="off"] .vision-off,body[data-vision="red-green"] .vision-red-green,body[data-vision="blue-yellow"] .vision-blue-yellow{display:contents} + +.acc{color:var(--icon-accent,var(--mut))} + +/* Temporary palette tuning controls; keep them separate from review/acceptance. */ +#palette-editor{flex-basis:100%;border-bottom:1px solid var(--line);padding:2px 0 8px} +.palette-heading{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin-bottom:6px} +#palette-status{color:var(--mut);font-size:11px}#palette-status.error{color:var(--review-error)} +#palette-tokens{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:3px 12px;max-height:180px;overflow:auto} +.palette-token{display:flex;align-items:center;gap:5px;min-width:0;line-height:20px} +.palette-token span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.palette-token input[type=color]{appearance:none;-webkit-appearance:none;box-sizing:border-box;width:19px;height:19px;border:1px solid var(--line);border-radius:50%;padding:0;background:transparent;flex-shrink:0;overflow:hidden} +.palette-token input[type=color]::-webkit-color-swatch-wrapper{padding:0} +.palette-token input[type=color]::-webkit-color-swatch{border:0;border-radius:50%} +.palette-token input[type=color]::-moz-color-swatch{border:0;border-radius:50%} +.palette-token input:disabled{cursor:default;opacity:1} +.palette-knockout{box-sizing:border-box;width:19px;height:19px;flex-shrink:0;border:1px solid var(--line);border-radius:50%;background:repeating-conic-gradient(#8993a0 0% 25%,transparent 0% 50%) 0 0/8px 8px} +body.palette-preview .card.final .art{opacity:1} diff --git a/assets/icons/ui/design/tools/review/review.js b/assets/icons/ui/design/tools/review/review.js new file mode 100644 index 00000000..d71ae4b0 --- /dev/null +++ b/assets/icons/ui/design/tools/review/review.js @@ -0,0 +1,285 @@ +// No framework, build step, external resources or model integration. +const S = @@STATE@@; +const fin = new Set(S.final); +const notes = {...S.notes}; +let base = {final: [...S.final].sort(), notes: {...notes}}; +let filter = 'open', tab = '', dirty = false, timer; +let saves = Promise.resolve(); +const query = document.getElementById('q'); +const message = document.getElementById('msg'); +const cards = [...document.querySelectorAll('.card')]; +const markers = [...document.querySelectorAll('.gm')]; +const staticKey = 'icon-review:' + S.rev; +const prefsKey = 'icon-review-preferences'; +const reloadKey = 'icon-review-reload-view'; + +function state() { + return {final: [...fin].sort(), notes: Object.fromEntries( + Object.entries(notes).filter(([, value]) => value.trim()))}; +} +function payload(snapshot) { return JSON.stringify({base, ...snapshot, rev: S.rev}); } +function showMessage(text, error = false) { + message.textContent = text; + message.classList.toggle('error', error); +} +function run() { + let finals = 0, noted = 0, visible = 0; + for (const card of cards) { + const id = card.dataset.id; + const accepted = fin.has(id), hasNote = Boolean((notes[id] || '').trim()); + card.classList.toggle('final', accepted); + card.classList.toggle('hasnote', hasNote && !accepted); + card.querySelector('.ck').title = accepted ? 'Reopen for review' : 'Accept as final'; + card.querySelector('.ck').setAttribute('aria-label', (accepted ? 'Reopen ' : 'Accept ') + id + (accepted ? '' : ' as final')); + if (accepted) finals++; + if (hasNote && !accepted) noted++; + const matches = (filter === 'all' || (filter === 'open' && !accepted) || + (filter === 'notes' && hasNote && !accepted) || (filter === 'final' && accepted)) && + (!tab || card.dataset.tab === tab) && card.dataset.k.includes(query.value.toLowerCase()); + card.hidden = !matches; + if (matches) visible++; + } + for (const marker of markers) { + let next = marker.nextElementSibling, any = false; + while (next && !next.classList.contains('gm')) { + if (!next.hidden) any = true; + next = next.nextElementSibling; + } + marker.hidden = !any; + } + document.getElementById('cnt').textContent = `${finals} final · ${cards.length - finals} open · ${noted} with notes`; + document.getElementById('empty').hidden = visible > 0; +} +function scheduleSave() { + dirty = true; + clearTimeout(timer); + timer = setTimeout(flush, 250); +} +async function flush() { + clearTimeout(timer); + const snapshot = state(); + saves = saves.catch(() => {}).then(async () => { + if (!S.serving) { + localStorage.setItem(staticKey, JSON.stringify(snapshot)); + dirty = JSON.stringify(snapshot) !== JSON.stringify(state()); + showMessage('Static preview: Export downloads the review file. Use --serve to save to the repository.'); + return; + } + const response = await fetch('/api/review', {method: 'POST', body: payload(snapshot)}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Save failed'); + base = JSON.parse(JSON.stringify(snapshot)); + dirty = JSON.stringify(snapshot) !== JSON.stringify(state()); + showMessage('Saved'); + }); + try { await saves; return true; } + catch (error) { showMessage(error.message, true); return false; } +} +function view() { + return {filter, tab, q: query.value, y: scrollY}; +} +function preferences() { + return {dark: document.body.classList.contains('dark'), vision: document.body.dataset.vision, size: document.documentElement.style.getPropertyValue('--sz')}; +} +function savePreferences() { try { localStorage.setItem(prefsKey, JSON.stringify(preferences())); } catch {} } +function setSize(size) { + document.documentElement.style.setProperty('--sz', size + 'px'); + document.body.classList.toggle('small', size <= 24); + document.querySelectorAll('.sz').forEach(button => button.classList.toggle('on', +button.dataset.s === size)); +} +function selectFilter(value) { + filter = value; + document.querySelectorAll('[data-f]').forEach(button => button.classList.toggle('on', button.dataset.f === value)); +} +function selectTab(value) { + tab = value; + document.querySelectorAll('.chip').forEach(button => button.classList.toggle('on', (button.dataset.tab || '') === value)); +} + +// Temporary palette editor: CSS variables recolour every rendered theme, size +// and vision variant without touching SVGs, notes or acceptance hashes. +const paletteDraftKey = 'icon-review-palette-draft'; +const paletteStatus = document.getElementById('palette-status'); +const paletteApply = document.getElementById('palette-apply'); +const paletteReset = document.getElementById('palette-reset'); +const palettePaints = Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout) + .map(token => [token.id, {light: token.light, dark: token.dark}])); +const paletteInputs = []; +let paletteDirty = false, paletteSaving = false; +function paletteVariable(id, theme) { return '--icon-palette-' + id.replaceAll('.', '-') + '-' + theme; } +function paletteChanges() { + return Object.fromEntries(S.palette.tokens.filter(token => !token.linked && !token.knockout && + ['light', 'dark'].some(theme => palettePaints[token.id][theme] !== token[theme])) + .map(token => [token.id, {...palettePaints[token.id]}])); +} +function updatePalette() { + for (const [id, paints] of Object.entries(palettePaints)) { + for (const theme of ['light', 'dark']) document.documentElement.style.setProperty(paletteVariable(id, theme), paints[theme]); + } + for (const {input, token, theme} of paletteInputs) { + input.value = palettePaints[token.linked || token.id][theme]; + input.title = `${token.name} · ${theme}: ${input.value}${token.linked ? ' (follows construction stroke)' : ''}`; + } + paletteDirty = Object.keys(paletteChanges()).length > 0; + paletteApply.disabled = !paletteDirty || paletteSaving; + paletteReset.disabled = !paletteDirty || paletteSaving; + try { + if (paletteDirty) sessionStorage.setItem(paletteDraftKey, JSON.stringify({revision: S.palette.revision, changes: paletteChanges()})); + else sessionStorage.removeItem(paletteDraftKey); + } catch {} +} +for (const token of S.palette.tokens) { + const row = document.createElement('div'); row.className = 'palette-token'; + for (const theme of ['light', 'dark']) { + if (token.knockout) { + const swatch = document.createElement('span'); swatch.className = 'palette-knockout'; + swatch.title = token.name + ' · ' + theme + ': transparent cutout'; + swatch.setAttribute('aria-label', swatch.title); row.appendChild(swatch); continue; + } + const input = document.createElement('input'); input.type = 'color'; input.value = token[theme]; + input.disabled = Boolean(token.linked); + input.setAttribute('aria-label', token.name + ' ' + theme + ' colour'); + input.oninput = () => { + palettePaints[token.id][theme] = input.value; + updatePalette(); paletteStatus.textContent = 'Preview only · Apply saves both themes to palette.toml'; + paletteStatus.classList.remove('error'); + }; + paletteInputs.push({input, token, theme}); row.appendChild(input); + } + const label = document.createElement('span'); label.textContent = token.name + (token.linked ? ' ↗' : ''); + row.appendChild(label); document.getElementById('palette-tokens').appendChild(row); +} +document.body.classList.add('palette-preview'); +document.getElementById('palette-all').onclick = () => { selectFilter('all'); selectTab(''); query.value = ''; run(); }; +paletteReset.onclick = () => { + for (const token of S.palette.tokens.filter(token => !token.linked && !token.knockout)) palettePaints[token.id] = {light: token.light, dark: token.dark}; + updatePalette(); paletteStatus.textContent = 'Preview reset · saved palette unchanged'; paletteStatus.classList.remove('error'); +}; +paletteApply.onclick = async () => { + if (!paletteDirty || paletteSaving) return; + if (dirty && !await flush()) return; + if (!S.serving) { + paletteStatus.textContent = 'Preview only · run the review server to apply colours to palette.toml'; return; + } + paletteSaving = true; updatePalette(); + try { + const response = await fetch('/api/palette', {method: 'POST', body: JSON.stringify({revision: S.palette.revision, changes: paletteChanges()})}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Palette save failed'); + S.palette = {revision: result.revision, tokens: result.tokens}; + paletteStatus.textContent = 'Saved to palette.toml · rebuild the app to use these colours'; + paletteStatus.classList.remove('error'); + } catch (error) { paletteStatus.textContent = error.message; paletteStatus.classList.add('error'); } + finally { paletteSaving = false; updatePalette(); } +}; +try { + const draft = JSON.parse(sessionStorage.getItem(paletteDraftKey) || 'null'); + if (draft?.revision === S.palette.revision) { + for (const [id, paints] of Object.entries(draft.changes)) { + if (palettePaints[id] && ['light','dark'].every(theme => /^#[0-9a-f]{6}$/i.test(paints[theme] || ''))) palettePaints[id] = {...paints}; + } + paletteStatus.textContent = 'Unsaved palette preview restored'; + } else if (draft) { paletteStatus.textContent = 'Saved palette changed · older preview was not restored'; } +} catch {} +updatePalette(); +for (const card of cards) { + const id = card.dataset.id; + const pop = document.createElement('div'); + pop.className = 'pop'; + const textarea = document.createElement('textarea'); + textarea.placeholder = 'What should change in this icon?'; + textarea.setAttribute('aria-label', 'Notes for ' + id); + textarea.value = notes[id] || ''; + pop.appendChild(textarea); card.appendChild(pop); + textarea.oninput = () => { notes[id] = textarea.value; run(); scheduleSave(); }; + card.querySelector('.ck').onclick = () => { + if (card.dataset.missing === 'true') return; + fin.has(id) ? fin.delete(id) : fin.add(id); + card.classList.remove('open'); run(); scheduleSave(); + }; + card.querySelector('.nt').onclick = event => { + const open = !card.classList.contains('open'); + cards.forEach(other => other.classList.remove('open')); + card.classList.toggle('open', open); + if (open) textarea.focus(); + event.stopPropagation(); + }; + pop.onclick = event => event.stopPropagation(); +} +document.addEventListener('click', () => cards.forEach(card => card.classList.remove('open'))); +document.addEventListener('keydown', event => { + if (event.key === 'Escape') cards.forEach(card => card.classList.remove('open')); +}); +document.querySelectorAll('[data-f]').forEach(button => button.onclick = () => { selectFilter(button.dataset.f); run(); }); +document.querySelectorAll('.chip').forEach(button => button.onclick = () => { selectTab(button.dataset.tab || ''); run(); }); +document.querySelectorAll('.sz').forEach(button => button.onclick = () => { setSize(+button.dataset.s); savePreferences(); }); +document.getElementById('dk').onclick = function () { + document.body.classList.toggle('dark'); this.classList.toggle('on'); savePreferences(); +}; +document.getElementById('vision').onchange = event => { + document.body.dataset.vision = event.target.value; savePreferences(); +}; +query.oninput = run; +document.getElementById('rescan').onclick = async () => { + if (!dirty || await flush()) location.reload(); +}; +document.getElementById('exp').onclick = async () => { + if (dirty && !await flush()) return; + if (!S.serving) { + const snapshot = state(); + const accepted = Object.fromEntries(snapshot.final.map(id => [id, S.hashes[id]])); + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([JSON.stringify({version: 1, final: accepted, notes: snapshot.notes}, null, 1)], {type: 'application/json'})); + link.download = 'review.json'; link.click(); URL.revokeObjectURL(link.href); + return; + } + try { + await saves; + const snapshot = state(); + const response = await fetch('/api/export', {method: 'POST', body: payload(snapshot)}); + const result = await response.json(); + if (!response.ok || !result.ok) throw new Error(result.error || 'Export failed'); + base = JSON.parse(JSON.stringify(snapshot)); + showMessage(result.sent ? `Queued ${result.sent} note${result.sent === 1 ? '' : 's'} in the local review inbox` : 'No open notes to export'); + } catch (error) { showMessage(error.message, true); } +}; +try { + if (!S.serving) { + const saved = JSON.parse(localStorage.getItem(staticKey) || 'null'); + if (saved) { fin.clear(); saved.final.forEach(id => fin.add(id)); Object.assign(notes, saved.notes); } + } + const prefs = JSON.parse(localStorage.getItem(prefsKey) || 'null'); + if (prefs) { + if (prefs.vision && ['off', 'red-green', 'blue-yellow'].includes(prefs.vision)) { document.body.dataset.vision = prefs.vision; document.getElementById('vision').value = prefs.vision; } + if (prefs.dark) document.getElementById('dk').click(); + const size = parseInt(prefs.size); + if ([16,24,48,64].includes(size)) setSize(size); + } + // Only an automatic artwork reload restores filters. A normal opening always + // starts at Open / Everything, so yesterday's filter cannot hide new artwork. + const restored = JSON.parse(sessionStorage.getItem(reloadKey) || 'null'); + sessionStorage.removeItem(reloadKey); + if (restored) { + selectFilter(restored.filter); selectTab(restored.tab); query.value = restored.q; + requestAnimationFrame(() => scrollTo(0, restored.y)); + } +} catch {} +run(); +if (S.serving) setInterval(async () => { + if (dirty || paletteDirty || paletteSaving || document.activeElement?.tagName === 'TEXTAREA') return; + try { + const response = await fetch('/api/review'); + const current = await response.json(); + if (current.rev && current.rev !== S.rev) { + sessionStorage.setItem(reloadKey, JSON.stringify(view())); + location.reload(); return; + } + if (JSON.stringify([current.final, current.notes]) !== JSON.stringify([[...fin].sort(), notes])) { + fin.clear(); current.final.forEach(id => fin.add(id)); + Object.keys(notes).forEach(id => delete notes[id]); Object.assign(notes, current.notes); + base = {final: [...current.final].sort(), notes: {...notes}}; + cards.forEach(card => { card.querySelector('textarea').value = notes[card.dataset.id] || ''; }); + run(); + } + } catch { showMessage('Review server unavailable; reconnect before saving.', true); } +}, 3000); diff --git a/assets/icons/ui/design/tools/review_state.py b/assets/icons/ui/design/tools/review_state.py new file mode 100644 index 00000000..2b78ae2a --- /dev/null +++ b/assets/icons/ui/design/tools/review_state.py @@ -0,0 +1,76 @@ +"""Shared icon review state: read and write design/review.json (standard library only). + + {"version": 1, "final": {"