openvibers/openvibe.design Commits

/icons on a phone: 13,900 px to 4,800, by folds and rails, nothing hidden (#82)

OP OpenVibers committed 12 days ago · 7ad66c6561 · 4 files, +75 −8

Plan L51 step 3 (the owner, 10-31: short pages by sideways rails and counted folds, never by hiding).
- The 48 glyphs the network uses most (its real usage) are in view; the other 228 are in "All 276 glyphs, 228 more,
  A to Z", open by itself on a wide screen; the finder searches every glyph and opens the fold for a match inside,
  then puts it back.
- Denser tiles on a phone (four to a row).
- Still Font Awesome: brand logos are drawn now (Shared brands), so they are no longer listed; the top 24 show, the
  rest fold under their count.
- The downloads are a sideways rail on a phone.

Changes

ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex 2da71e3..1749bb5 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -244,3 +244,27 @@
.mv-tile i{position:absolute;top:8px;right:8px;padding:2px 8px;border-radius:999px;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent);font-style:normal;font-size:11px;font-weight:750}
.mv-tile.is-suits{border-color:color-mix(in srgb,var(--accent) 50%,var(--border))}
.mv-tile:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++
++/* /icons: the most used in view, the rest in a counted fold (the owner, 10-31: short pages, never by hiding). */
++.ic-grid-cap{margin:0 0 8px;color:var(--text-muted);font-size:12px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}
++.ic-fold{margin-top:12px;border-radius:14px;border:1px solid var(--border);background:color-mix(in srgb,var(--bg-secondary) 60%,transparent)}
++.ic-fold>summary{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding:14px 16px;cursor:pointer;font-weight:750;list-style:none}
++.ic-fold>summary::-webkit-details-marker{display:none}
++.ic-fold>summary::before{content:'';width:8px;height:8px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:translateY(-2px) rotate(-45deg);transition:transform .18s ease}
++.ic-fold[open]>summary::before{transform:translateY(-4px) rotate(45deg)}
++.ic-fold>summary span{color:var(--text-muted);font-weight:500;font-size:13px}
++.ic-fold>summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:14px}
++.ic-fold>.ic-grid{padding:4px 12px 12px}
++@media (prefers-reduced-motion:reduce){.ic-fold>summary::before{transition:none}}
++/* A phone: denser tiles, so a screen holds five rows of four. */
++@media (max-width:520px){
++ .ic .ic-grid{grid-template-columns:repeat(auto-fill,minmax(74px,1fr));gap:6px}
++ .ic .ic-tile{min-height:80px;padding:12px 4px 8px;gap:7px;border-radius:12px}
++ .ic .ic-tile span{font-size:11px}
++}
++.ic-left-fold>.ic-left{padding:4px 14px 14px}
++/* A phone: the downloads are a sideways rail, not seven stacked cards. */
++@media (max-width:520px){
++ .ic-packs{display:flex;gap:8px;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;padding-bottom:6px;margin-right:-16px;padding-right:16px}
++ .ic-packs li{flex:0 0 min(72%,240px);scroll-snap-align:start}
++}
ddiff --git a/public/js/icons.js b/public/js/icons.js
iindex c1625a3..0e51413 100644
---- a/public/js/icons.js
++++ b/public/js/icons.js
@@@ -6,8 +6,15 @@
(function () {
'use strict';
const $ = (s, el = document) => el.querySelector(s);
-- const grid = $('[data-ic-grid]');
-- if (!grid) return;
++ const first = $('[data-ic-grid]');
++ if (!first) return;
++ // Two grids: the most used, and the rest in a counted fold (data-ic-more). One section holds both.
++ const grid = first.closest('section') || first;
++ const grids = Array.prototype.slice.call(grid.querySelectorAll('[data-ic-grid]'));
++ const more = $('[data-ic-more]');
++ // Wide screens have the room: the fold opens there. A phone keeps it closed until asked.
++ if (more && window.matchMedia && window.matchMedia('(min-width: 900px)').matches) more.open = true;
++ let foldWas = more ? more.open : false;
const tools = $('[data-ic-tools]');
if (tools) tools.hidden = false;
@@@ -25,12 +32,18 @@
t.hidden = !hit; if (hit) shown++;
});
if (none) none.hidden = shown > 0;
++ // A search looks at every glyph: the fold opens while a search is on, and goes back as it was after.
++ if (more) {
++ if (q && !more.hasAttribute('data-searching')) { foldWas = more.open; more.setAttribute('data-searching', ''); }
++ if (q) more.open = !!more.querySelector('.ic-tile:not([hidden])');
++ else if (more.hasAttribute('data-searching')) { more.removeAttribute('data-searching'); more.open = foldWas; }
++ }
if (drawLink && /^[a-z][a-z0-9-]{1,31}$/.test(q)) drawLink.href = `/icons?draw=${encodeURIComponent(q)}#draw`;
});
// Sizes.
document.querySelectorAll('[data-ic-size]').forEach((b) => b.addEventListener('click', () => {
-- grid.style.setProperty('--ic-size', `${b.dataset.icSize}px`);
++ grids.forEach((g) => g.style.setProperty('--ic-size', `${b.dataset.icSize}px`));
document.querySelectorAll('[data-ic-size]').forEach((x) => x.setAttribute('aria-pressed', x === b ? 'true' : 'false'));
}));
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex 50158e2..e45be35 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -27,8 +27,12 @@ function faByGlyph() {
}
/** The names the network asks Font Awesome for that no glyph covers yet, brand logos aside; most used first. */
++// Brand logos are drawn too since Shared 4.72.0 (Simple Icons, CC0): a name the brands draw is not still Font Awesome's.
++let brands = null;
++try { brands = require('openvibe-shared/brands'); } catch { brands = null; }
++const brandDrawn = (name) => !!(brands && brands.fromFa && brands.fromFa(name));
function stillFontAwesome() {
-- return usage.names.filter((n) => !glyphs.fromFa(n.name) && !BRANDS.has(n.name));
++ return usage.names.filter((n) => !glyphs.fromFa(n.name) && !BRANDS.has(n.name) && !brandDrawn(n.name));
}
const inner = (name) => glyphs.path(name);
@@@ -52,13 +56,24 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
<div class="ic-acts">${kit.button({ href: '#get', label: 'Get them all', icon: 'download' })}${kit.button({ href: '#draw', label: 'Draw an icon', kind: 'quiet', icon: 'spark' })}${kit.button({ href: '/icons/font-awesome-alternative', label: 'Switch from Font Awesome', kind: 'quiet', icon: 'code' })}</div>
</header>`;
++ // The most used first, in view; the rest in a fold that says how many it holds (the owner, 10-31: short pages by
++ // counted folds, never by hiding). Wide screens open it (icons.js); the finder searches inside it and opens it.
++ const used = new Map();
++ for (const n of usage.names) { const g = glyphs.fromFa(n.name); if (g) used.set(g, (used.get(g) || 0) + n.uses); }
++ const ranked = [...glyphs.NAMES].sort((a, b) => (used.get(b) || 0) - (used.get(a) || 0) || (a < b ? -1 : 1));
++ const FIRST = 48;
++ const first = ranked.slice(0, FIRST);
++ const rest = glyphs.NAMES.filter((n) => !first.includes(n));
const browse = `<section class="ic-sec" aria-labelledby="ic-all">
<h2 id="ic-all" class="ovk-sr">Every glyph</h2>
<div class="ic-tools" data-ic-tools hidden>
<label class="ic-find">${kit.icon('search')}<span class="ovk-sr">Find a glyph</span><input type="search" placeholder="Find: a name, or a Font Awesome name" autocomplete="off" data-ic-find></label>
<div class="ic-sizes" role="group" aria-label="Size">${[16, 20, 24, 32].map((n) => `<button type="button" class="ic-size" data-ic-size="${n}" aria-pressed="${n === 24 ? 'true' : 'false'}">${n}</button>`).join('')}</div>
</div>
-- <div class="ic-grid" data-ic-grid style="--ic-size:24px">${glyphs.NAMES.map(tile).join('')}</div>
++ <p class="ic-grid-cap">The ${FIRST} the network uses most</p>
++ <div class="ic-grid" data-ic-grid style="--ic-size:24px">${first.map(tile).join('')}</div>
++ <details class="ic-fold" data-ic-more><summary>All ${glyphs.NAMES.length} glyphs <span>${rest.length} more, A to Z</span></summary>
++ <div class="ic-grid" data-ic-grid style="--ic-size:24px">${rest.map(tile).join('')}</div></details>
<p class="ic-none" data-ic-none hidden>No glyph has that name yet. <a href="#draw" data-ic-draw-link>Draw it</a>.</p>
</section>
<dialog class="ic-sheet" data-ic-sheet aria-labelledby="ic-sheet-h"><div class="ic-sheet-in">
@@@ -69,9 +84,9 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
</div></dialog>`;
const leftHtml = left.length ? `<section class="ic-sec" aria-labelledby="ic-left">
-- <div class="ic-sec-head"><h2 id="ic-left">Still Font Awesome</h2><p>The names the network&rsquo;s pages still ask Font Awesome for, most used first (counted ${esc(usage.counted)}). Each one drawn moves a site closer to dropping it. Brand logos stay theirs.</p></div>
-- <ul class="ic-left">${left.slice(0, 48).map((n) => `<li><a href="/icons?draw=${encodeURIComponent(n.name)}#draw">${esc(n.name)}</a><small>${n.uses}</small></li>`).join('')}</ul>
-- ${left.length > 48 ? `<p class="ic-more">And ${left.length - 48} more, each used once or twice.</p>` : ''}
++ <div class="ic-sec-head"><h2 id="ic-left">Still Font Awesome</h2><p>The names the network&rsquo;s pages asked Font Awesome for that no glyph draws yet, most used first (counted ${esc(usage.counted)}). Brand logos are drawn too now. Tap one to draw it.</p></div>
++ <ul class="ic-left">${left.slice(0, 24).map((n) => `<li><a href="/icons?draw=${encodeURIComponent(n.name)}#draw">${esc(n.name)}</a><small>${n.uses}</small></li>`).join('')}</ul>
++ ${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>`;
ddiff --git a/test/icons-find.test.js b/test/icons-find.test.js
iindex bcccc20..ec93fc0 100644
---- a/test/icons-find.test.js
++++ b/test/icons-find.test.js
@@@ -83,6 +83,21 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";`;
assert.deepEqual(names.glyphs.map((g) => [g.asked, g.name]), [['lucide:settings', 'gear'], ['material:delete', 'trash'], ['settings', 'gear'], ['fa-gear', 'gear']]);
});
++ await check('/icons on a phone: the 48 most used in view, the rest in a counted fold the finder opens', async () => {
++ const html = (await t.get('/icons')).text;
++ assert.ok(html.includes('<p class="ic-grid-cap">The 48 the network uses most</p>'));
++ const [inView, folded] = html.split('<details class="ic-fold" data-ic-more>');
++ assert.ok(folded, 'the fold is there');
++ assert.equal((inView.match(/class="ic-tile"/g) || []).length, 48, '48 in view');
++ assert.equal((html.match(/class="ic-tile"/g) || []).length, glyphs.NAMES.length, 'every glyph is on the page, none left out');
++ assert.match(folded, new RegExp(`^<summary>All ${glyphs.NAMES.length} glyphs <span>${glyphs.NAMES.length - 48} more, A to Z</span></summary>`), 'the fold says what it holds');
++ assert.ok(inView.includes('data-name="eye"') && inView.includes('data-name="live"'), 'the most used are the ones in view');
++ const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'icons.js'), 'utf8');
++ assert.match(js, /if \(q\) more\.open = !!more\.querySelector\('\.ic-tile:not\(\[hidden\]\)'\);/, 'a search opens the fold when a match is inside');
++ assert.match(js, /matchMedia\('\(min-width: 900px\)'\)\.matches\) more\.open = true/, 'wide screens open it');
++ assert.ok(!/reddit-alien|kickstarter-k/.test(html.split('id="ic-left"')[1] || ''), 'brands are drawn now: not "still Font Awesome"');
++ });
++
await check('the finder searches the words; /icons, the Font Awesome page, the sitemap and llms.txt lead to the page', async () => {
const icons = (await t.get('/icons')).text;
assert.match(icons, /data-name="gear" data-fa="[^"]*" data-words="[^"]*settings[^"]*"/);