openvibers/openvibe.design Commits
Icons: make them move (draw, press, lead, ring, beat, shake no) (#79)
Changes
nnew file mode 100644@@@ -0,0 +1,43 @@++/*! OpenVibe icon motion · MIT · https://openvibe.design/icons (plan L50 step 7)++ * Put a class on an inline SVG icon (or motion="…" on <ov-icon>). Each moves once, when something happens: nothing++ * loops, nothing spins, and reduced motion (the system's, or OpenVibe's calm setting) stops them all.++ * ovm-draw draws itself in, line by line, the first time it shows++ * ovm-pop gives under the finger and springs back++ * ovm-nudge leans toward where it leads when pointed at (--ovm-x, --ovm-y: the way; 2px right by default)++ * ovm-ring swings like a bell, twice, when pointed at++ * ovm-beat beats once, like a heart, when pointed at++ * ovm-shake shakes no, once, when it gets the class is-no (or when pointed at, on the demo)++ */++.ovm-draw :is(path, circle, rect, ellipse, line, polyline) {++ stroke-dasharray: var(--ovm-len, 160);++ stroke-dashoffset: var(--ovm-len, 160);++ animation: ovm-draw var(--ovm-draw-time, .9s) cubic-bezier(.65, 0, .35, 1) forwards;++}++.ovm-draw :is(path, circle, rect, ellipse, line, polyline):nth-child(2) { animation-delay: .12s; }++.ovm-draw :is(path, circle, rect, ellipse, line, polyline):nth-child(3) { animation-delay: .24s; }++.ovm-draw :is(path, circle, rect, ellipse, line, polyline):nth-child(n+4) { animation-delay: .36s; }++@keyframes ovm-draw { to { stroke-dashoffset: 0; } }++++.ovm-pop { transition: transform .32s cubic-bezier(.34, 1.56, .64, 1); transform-origin: 50% 50%; }++.ovm-pop:active, :active > .ovm-pop { transform: scale(.82); transition-duration: .08s; transition-timing-function: ease-out; }++++.ovm-nudge { transition: transform .28s cubic-bezier(.34, 1.56, .64, 1); }++.ovm-nudge:hover, :hover > .ovm-nudge, :focus-visible > .ovm-nudge { transform: translate(var(--ovm-x, 2px), var(--ovm-y, 0)); }++++.ovm-ring { transform-origin: 50% 12%; }++.ovm-ring:hover, :hover > .ovm-ring, :focus-visible > .ovm-ring { animation: ovm-ring .9s cubic-bezier(.36, .07, .19, .97); }++@keyframes ovm-ring { 0%, 100% { transform: rotate(0); } 15% { transform: rotate(14deg); } 32% { transform: rotate(-12deg); } 50% { transform: rotate(8deg); } 68% { transform: rotate(-5deg); } 84% { transform: rotate(2deg); } }++++.ovm-beat { transform-origin: 50% 55%; }++.ovm-beat:hover, :hover > .ovm-beat, :focus-visible > .ovm-beat { animation: ovm-beat .7s ease-out; }++@keyframes ovm-beat { 0%, 100% { transform: scale(1); } 18% { transform: scale(1.2); } 36% { transform: scale(.96); } 54% { transform: scale(1.1); } 72% { transform: scale(1); } }++++.ovm-shake.is-no, .ovm-shake.ovm-demo:hover, :hover > .ovm-shake.ovm-demo { animation: ovm-shake .5s cubic-bezier(.36, .07, .19, .97); }++@keyframes ovm-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-3px); } 40% { transform: translateX(3px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(1px); } }++++@media (prefers-reduced-motion: reduce) {++ .ovm-draw :is(path, circle, rect, ellipse, line, polyline) { animation: none; stroke-dashoffset: 0; }++ .ovm-pop, .ovm-nudge, .ovm-ring, .ovm-beat, .ovm-shake { animation: none !important; transition: none !important; transform: none !important; }++}++html[data-ov-motion="reduced"] .ovm-draw :is(path, circle, rect, ellipse, line, polyline) { animation: none; stroke-dashoffset: 0; }++html[data-ov-motion="reduced"] :is(.ovm-pop, .ovm-nudge, .ovm-ring, .ovm-beat, .ovm-shake) { animation: none !important; transition: none !important; transform: none !important; }@@@ -234,3 +234,13 @@.mg-none{color:var(--text-muted)}.mg-table tr.is-missing td{background:color-mix(in srgb,var(--bg-secondary) 50%,transparent)}.mg-table code{white-space:nowrap}++++/* Make it move (L50 step 7): each preset on the glyph, played by pointing, pressing or tapping. */++.mv-grid{list-style:none;margin:0 0 16px;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:8px}++.mv-tile{position:relative;width:100%;display:grid;justify-items:center;gap:6px;padding:20px 8px 14px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;cursor:pointer}++.mv-tile svg{width:40px;height:40px;margin-bottom:4px}++.mv-tile b{font-size:14px}++.mv-tile small{color:var(--text-muted);font-size:12px}++.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}@@@ -57,6 +57,11 @@history.replaceState(null, '', `${location.pathname}${q}#png`);}, 120);}++ // Make it move: a tap on "Draw in" draws it again (a fresh copy restarts its animation).++ document.querySelectorAll('[data-mv="draw"]').forEach((b) => b.addEventListener('click', () => {++ const svg = b.querySelector('svg');++ if (svg) svg.replaceWith(svg.cloneNode(true));++ }));form.addEventListener('input', update);form.addEventListener('change', update);form.addEventListener('submit', (e) => { e.preventDefault(); update(); });@@@ -6,6 +6,10 @@* <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too, and Lucide's or Material's:* <ov-icon name="lucide:settings"></ov-icon> <ov-icon name="material:delete"></ov-icon> (openvibe.design/icons/migrate)* <ov-icon name="bell" variant="solid"></ov-icon> line | solid | auto (the default)++ * <ov-icon name="bell" motion="ring"></ov-icon> draw | pop | nudge | ring | beat | shake: one move when it, or the++ * button or link it sits in, is pointed at or pressed (draw: on show;++ * shake: when it gets the attribute no). Nothing loops or spins.++ * <ov-icon name="heart" on></ov-icon> on: the solid drawing, as a control that is turned on** Every size by itself: on auto, an icon 18 px or smaller shows its solid drawing when it has one (a container query,* so it follows the real size, not the attribute), and a glyph made only of lines draws them a little heavier there.@@@ -64,23 +68,44 @@+ '.v-solid.has-s .l{display:none}.v-solid.has-s .s{display:inline}'+ '@container (max-width: 18px){'+ '.v-auto.has-s .l{display:none}.v-auto.has-s .s{display:inline}'-- + '.v-auto:not(.has-s) .l,.v-line .l{stroke-width:var(--ov-icon-stroke-small,2.4)}}';++ + '.v-auto:not(.has-s) .l,.v-line .l{stroke-width:var(--ov-icon-stroke-small,2.4)}}'++ // On: the solid drawing, as for a control that is turned on (a liked heart), with one beat when it turns on.++ + ':host([on]) .has-s .l{display:none}:host([on]) .has-s .s{display:inline;animation:ovm-beat .5s ease-out}'++ // Motion (plan L50 step 7; openvibe.design/icons/pack/ov-icon-motion.css is the same for any SVG).++ + 'svg{transform-origin:50% 50%}'++ + ':host([motion=draw]) .l>*{stroke-dasharray:160;stroke-dashoffset:160;animation:ovm-draw .9s cubic-bezier(.65,0,.35,1) forwards}'++ + ':host([motion=draw]) .l>*:nth-child(2){animation-delay:.12s}:host([motion=draw]) .l>*:nth-child(n+3){animation-delay:.24s}'++ + ':host([motion=pop]) svg{transition:transform .32s cubic-bezier(.34,1.56,.64,1)}:host([motion=pop][data-press]) svg{transform:scale(.82);transition-duration:.08s}'++ + ':host([motion=nudge]) svg{transition:transform .28s cubic-bezier(.34,1.56,.64,1)}:host([motion=nudge][data-play]) svg{transform:translate(var(--ovm-x,2px),var(--ovm-y,0))}'++ + ':host([motion=ring]) svg{transform-origin:50% 12%}:host([motion=ring][data-play]) svg{animation:ovm-ring .9s cubic-bezier(.36,.07,.19,.97)}'++ + ':host([motion=beat]) svg{transform-origin:50% 55%}:host([motion=beat][data-play]) svg{animation:ovm-beat .7s ease-out}'++ + ':host([motion=shake][no]) svg{animation:ovm-shake .5s cubic-bezier(.36,.07,.19,.97)}'++ + '@keyframes ovm-draw{to{stroke-dashoffset:0}}'++ + '@keyframes ovm-ring{0%,100%{transform:rotate(0)}15%{transform:rotate(14deg)}32%{transform:rotate(-12deg)}50%{transform:rotate(8deg)}68%{transform:rotate(-5deg)}84%{transform:rotate(2deg)}}'++ + '@keyframes ovm-beat{0%,100%{transform:scale(1)}18%{transform:scale(1.2)}36%{transform:scale(.96)}54%{transform:scale(1.1)}72%{transform:scale(1)}}'++ + '@keyframes ovm-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px)}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(1px)}}'++ // Calm: the system's reduced motion or OpenVibe's (html[data-ov-motion="reduced"]) stops every move.++ + ':host([data-calm]) svg,:host([data-calm]) svg *{animation:none!important;transition:none!important}'++ + ':host([data-calm]) .l>*{stroke-dashoffset:0!important}:host([data-calm]) svg{transform:none!important}';var sheet = null;try { sheet = new CSSStyleSheet(); sheet.replaceSync(CSS); } catch (e) { sheet = null; }var live = new Set();++ var reduceMq = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;++ function calm() { return !!(reduceMq && reduceMq.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced'; }++ if (reduceMq && reduceMq.addEventListener) reduceMq.addEventListener('change', function () { live.forEach(function (el) { el._calm(); }); });function pref() {var p = document.documentElement.getAttribute('data-ov-icons');return p === 'line' || p === 'solid' ? p : 'auto';}-- new MutationObserver(function () { live.forEach(function (el) { el._look(); }); })-- .observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-icons'] });++ new MutationObserver(function () { live.forEach(function (el) { el._look(); el._calm(); }); })++ .observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-icons', 'data-ov-motion'] });function OvIcon() { return Reflect.construct(HTMLElement, [], OvIcon); }OvIcon.prototype = Object.create(HTMLElement.prototype);OvIcon.prototype.constructor = OvIcon;Object.setPrototypeOf(OvIcon, HTMLElement);-- Object.defineProperty(OvIcon, 'observedAttributes', { get: function () { return ['name', 'size', 'variant', 'label']; } });++ Object.defineProperty(OvIcon, 'observedAttributes', { get: function () { return ['name', 'size', 'variant', 'label', 'motion']; } });OvIcon.prototype.connectedCallback = function () {if (!this.shadowRoot) {@@@ -96,14 +121,36 @@live.add(this);this._size();this._label();++ this._calm();++ this._wire();this._draw();};-- OvIcon.prototype.disconnectedCallback = function () { live.delete(this); };++ OvIcon.prototype.disconnectedCallback = function () { live.delete(this); this._unwire(); };++ OvIcon.prototype._calm = function () { this.toggleAttribute('data-calm', calm()); };++ // A move plays when the icon, or the button, link or label it sits in, is pointed at, focused or pressed.++ OvIcon.prototype._wire = function () {++ this._unwire();++ if (!this.hasAttribute('motion')) return;++ var self = this;++ var target = this.closest('a, button, label, summary, [role="button"], [role="menuitem"], [role="tab"]') || this;++ var on = function () { self.removeAttribute('data-play'); void self.offsetWidth; self.setAttribute('data-play', ''); };++ var off = function () { self.removeAttribute('data-play'); };++ var down = function () { self.setAttribute('data-press', ''); };++ var up = function () { self.removeAttribute('data-press'); };++ var ends = function (e) { if (self.getAttribute('motion') !== 'nudge' && e.target === self._svg) off(); };++ var pairs = [[target, 'pointerenter', on], [target, 'focusin', on], [target, 'pointerleave', off], [target, 'focusout', off],++ [target, 'pointerdown', down], [target, 'pointerup', up], [target, 'pointercancel', up], [target, 'pointerleave', up]];++ pairs.forEach(function (p) { p[0].addEventListener(p[1], p[2]); });++ if (this.shadowRoot) this.shadowRoot.addEventListener('animationend', ends);++ this._wired = function () { pairs.forEach(function (p) { p[0].removeEventListener(p[1], p[2]); }); if (self.shadowRoot) self.shadowRoot.removeEventListener('animationend', ends); };++ };++ OvIcon.prototype._unwire = function () { if (this._wired) { this._wired(); this._wired = null; } };OvIcon.prototype.attributeChangedCallback = function (attr, was, now) {if (!this._svg || was === now) return;if (attr === 'name') this._draw();else if (attr === 'size') this._size();else if (attr === 'label') this._label();++ else if (attr === 'motion') this._wire();else this._look();};OvIcon.prototype._size = function () {nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/**++ * Icon motion (plan L50 step 7): six presets, each a single move when something happens. Nothing loops and nothing++ * spins; reduced motion (the system's, or OpenVibe's calm setting, html[data-ov-motion="reduced"]) stops them all.++ * The CSS is public/css/icon-motion.css (served as /icons/pack/ov-icon-motion.css); <ov-icon motion="…"> carries the++ * same moves in its own shadow.++ *++ * PRESETS [{ id, name, says, when }]++ * suggest(glyph) the preset that suits a glyph (a bell rings, a heart beats, an arrow leads)++ * nudgeOf(glyph) the way an arrow-like glyph leans: { x, y } in px++ * CSS the stylesheet's text++ */++const fs = require('fs');++const path = require('path');++++const PRESETS = Object.freeze([++ { id: 'draw', name: 'Draw in', says: 'Draws itself in, line by line, the first time it shows.', when: 'on show' },++ { id: 'pop', name: 'Press', says: 'Gives under the finger and springs back.', when: 'on press' },++ { id: 'nudge', name: 'Lead', says: 'Leans the way it leads when you point at it.', when: 'on point' },++ { id: 'ring', name: 'Ring', says: 'Swings like a bell, twice, when you point at it.', when: 'on point' },++ { id: 'beat', name: 'Beat', says: 'Beats once, like a heart, when you point at it.', when: 'on point' },++ { id: 'shake', name: 'Shake no', says: 'Shakes no, once, when something is refused (add is-no).', when: 'on refusal' },++]);++++const NUDGE = { 'arrow-right': [2, 0], 'arrow-left': [-2, 0], back: [-2, 0], 'arrow-up': [0, -2], 'arrow-down': [0, 2], down: [0, 2], up: [0, -2],++ open: [2, -2], send: [2, -2], plane: [2, -2], forward: [2, 0], enter: [2, 0], leave: [2, 0], chevron: [0, 2], 'chevrons-right': [2, 0],++ 'chevrons-left': [-2, 0], download: [0, 2], upload: [0, -2], reply: [-2, 0], 'fast-forward': [2, 0], rewind: [-2, 0], skip: [2, 0], share: [2, -2] };++const SUITS = {++ ring: ['bell', 'megaphone', 'phone', 'alert'],++ beat: ['heart', 'heart-pulse', 'star', 'thumbs-up', 'live', 'record', 'fire', 'spark', 'gift', 'trophy', 'crown'],++ draw: ['check', 'check-circle', 'check-double', 'signature', 'pencil', 'route', 'wave', 'chart', 'trend-up', 'timeline'],++ shake: ['ban', 'lock', 'x-circle', 'alert-circle', 'close'],++};++++function suggest(glyph) {++ if (NUDGE[glyph]) return 'nudge';++ for (const [id, list] of Object.entries(SUITS)) if (list.includes(glyph)) return id;++ return 'pop';++}++const nudgeOf = (glyph) => { const d = NUDGE[glyph] || [2, 0]; return { x: d[0], y: d[1] }; };++++const CSS = fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'css', 'icon-motion.css'), 'utf8');++++module.exports = { PRESETS, suggest, nudgeOf, CSS };@@@ -107,6 +107,7 @@ function createIconRoutes(ctx) {const file = req.params.file;res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');if (file === 'ov-glyphs-svg.zip') return res.type('application/zip').attachment(file).send(ex.zip());++ if (file === 'ov-icon-motion.css') return res.type('text/css').send(require('../glyphs/motion').CSS);const p = Object.prototype.hasOwnProperty.call(ex.PACK, file) && ex.PACK[file];if (!p) return next();res.type(p.type).attachment(file).sendFile(p.path, (err) => { if (err && !res.headersSent) next(); });@@@ -14,6 +14,7 @@ const glyphs = require('openvibe-shared/glyphs');const { layout, esc } = require('../render/layout');const ex = require('../glyphs/export');const formats = require('../glyphs/formats');++const motion = require('../glyphs/motion');const usage = require('../glyphs/fa-usage.json');const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');@@@ -133,6 +134,19 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()</div></section>`;++ // L50 step 7: make it move. Each preset on this glyph, played by pointing at it (or pressing, or tapping to replay++ // the draw); the one that suits it is marked. Nothing loops; calm stops it.++ const suits = motion.suggest(name);++ const lean = motion.nudgeOf(name);++ const moving = `<section class="ic-sec" id="move" aria-labelledby="gp-move">++ <div class="ic-sec-head"><h2 id="gp-move">Make it move</h2><p>One move when something happens: point at each, press it, or tap the first to draw it again. Nothing loops or spins, and calm or reduced motion stops them.</p></div>++ <ul class="mv-grid">${motion.PRESETS.map((p) => `<li><button type="button" class="mv-tile${p.id === suits ? ' is-suits' : ''}" data-mv="${esc(p.id)}"${p.id === 'nudge' ? ` style="--ovm-x:${lean.x}px;--ovm-y:${lean.y}px"` : ''}>${glyph(name, 40, `ovm-${p.id}${p.id === 'shake' ? ' ovm-demo' : ''}`)}<b>${esc(p.name)}</b><small>${esc(p.when)}</small>${p.id === suits ? '<i>Suits it</i>' : ''}</button></li>`).join('')}</ul>++ <div class="gp-use">++ <div class="gp-way"><h3>With <ov-icon></h3>${kit.snippet({ code: `<ov-icon name="${name}" motion="${suits}"></ov-icon>${glyphs.solid(name) ? `\n<ov-icon name="${name}" motion="beat" on></ov-icon> <!-- on: the solid drawing, as when turned on -->` : ''}`, id: 'gp-mv-wc', label: 'Copy' })}</div>++ <div class="gp-way"><h3>On any SVG</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/icons/pack/ov-icon-motion.css">\n<svg class="ovm-${suits}" …>…</svg>`, id: 'gp-mv-css', label: 'Copy' })}</div>++ </div>++</section>`;++const near = related(name);const rel = `<section class="ic-sec" aria-labelledby="gp-rel"><div class="ic-sec-head"><h2 id="gp-rel">Near it</h2><p>Glyphs that share a word with ${esc(name)}, then its neighbours. <a href="/icons">All ${glyphs.NAMES.length}</a>.</p></div>@@@ -156,9 +170,9 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()return layout({title: head.length <= 40 ? head : `${T} icon: SVG, PNG, font`,description: `A free ${T.toLowerCase()} icon: download the SVG, make a PNG at any size and colour, or use the font.${fa.length ? ` A drop-in for Font Awesome's fa-${fa[0]}.` : ''} MIT.`.slice(0, 160),-- path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css'], scripts: ['js/icon.js'], jsonLd,++ path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css', 'css/icon-motion.css'], scripts: ['js/icon.js'], jsonLd,image: `${base}/icons/${name}.png?size=512&color=mark&pad=18&ground=squircle&bg=0b1020`, imageAlt: `The ${T.toLowerCase()} icon`,-- body: `<article class="ic gp">${hero}${maker}${use}${rel}</article>`,++ body: `<article class="ic gp">${hero}${maker}${use}${moving}${rel}</article>`,});}@@@ -181,6 +181,30 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"assert.ok((await t.get('/llms.txt')).text.includes('/ov-icon.js'), 'llms.txt names it for agents');});++ await check('make it move (L50 step 7): six presets, the one that suits marked, a stylesheet, <ov-icon motion>', async () => {++ const motion = require('../server/glyphs/motion');++ assert.deepEqual(motion.PRESETS.map((p) => p.id), ['draw', 'pop', 'nudge', 'ring', 'beat', 'shake']);++ assert.deepEqual(['bell', 'heart', 'arrow-right', 'check', 'ban', 'gear'].map(motion.suggest), ['ring', 'beat', 'nudge', 'draw', 'shake', 'pop']);++ assert.deepEqual(motion.nudgeOf('back'), { x: -2, y: 0 }, 'an arrow leans its own way');++ const page = (await t.get('/icons/bell')).text;++ assert.ok(page.includes('<h2 id="gp-move">Make it move</h2>'));++ assert.equal((page.match(/<button type="button" class="mv-tile/g) || []).length, 6);++ assert.match(page, /class="mv-tile is-suits" data-mv="ring"/, 'a bell rings');++ assert.ok(page.includes('<ov-icon name="bell" motion="ring">') || page.includes('<ov-icon name="bell" motion="ring">'));++ assert.match(page, /\/css\/icon-motion\.css/, 'the page loads the moves');++ const css = await t.get('/icons/pack/ov-icon-motion.css');++ assert.match(css.headers.get('content-type'), /text\/css/);++ assert.equal(css.headers.get('access-control-allow-origin'), '*');++ for (const id of ['draw', 'pop', 'nudge', 'ring', 'beat', 'shake']) assert.ok(css.text.includes(`.ovm-${id}`), id);++ assert.ok(!/infinite|rotate\(360deg\)|turn\)/.test(css.text), 'nothing loops or spins');++ assert.match(css.text, /@media \(prefers-reduced-motion: reduce\)/);++ assert.match(css.text, /html\[data-ov-motion="reduced"\]/, 'OpenVibe\'s calm setting stops them too');++ const wc = (await t.get('/ov-icon.js')).text;++ for (const k of ['ovm-draw', 'ovm-ring', 'ovm-beat', 'ovm-shake']) assert.ok(wc.includes(`@keyframes ${k}{`) && css.text.includes(`@keyframes ${k} {`), `${k} in both`);++ assert.ok(!/infinite/.test(wc), 'the component loops nothing either');++ assert.ok(wc.includes(":host([data-calm]) svg,:host([data-calm]) svg *{animation:none!important;transition:none!important}"));++ });++await check('the pack: fonts, the sprite, the CSS, the code points and a ZIP of every SVG', async () => {const woff = await t.get('/icons/pack/ov-glyphs.woff2');assert.equal(woff.status, 200);
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+/*! OpenVibe icon motion · MIT · https://openvibe.design/icons (plan L50 step 7)
+ * Put a class on an inline SVG icon (or motion="…" on <ov-icon>). Each moves once, when something happens: nothing
+ * loops, nothing spins, and reduced motion (the system's, or OpenVibe's calm setting) stops them all.
+ * ovm-draw draws itself in, line by line, the first time it shows
+ * ovm-pop gives under the finger and springs back
+ * ovm-nudge leans toward where it leads when pointed at (--ovm-x, --ovm-y: the way; 2px right by default)
+ * ovm-ring swings like a bell, twice, when pointed at
+ * ovm-beat beats once, like a heart, when pointed at
+ * ovm-shake shakes no, once, when it gets the class is-no (or when pointed at, on the demo)
+ */
+.ovm-draw :is(path, circle, rect, ellipse, line, polyline) {
+ stroke-dasharray: var(--ovm-len, 160);
+ stroke-dashoffset: var(--ovm-len, 160);
+ animation: ovm-draw var(--ovm-draw-time, .9s) cubic-bezier(.65, 0, .35, 1) forwards;
+}
+.ovm-draw :is(path, circle, rect, ellipse, line, polyline):nth-child(2) { animation-delay: .12s; }
+.ovm-draw :is(path, circle, rect, ellipse, line, polyline):nth-child(3) { animation-delay: .24s; }
+.ovm-draw :is(path, circle, rect, ellipse, line, polyline):nth-child(n+4) { animation-delay: .36s; }
+@keyframes ovm-draw { to { stroke-dashoffset: 0; } }
+
+.ovm-pop { transition: transform .32s cubic-bezier(.34, 1.56, .64, 1); transform-origin: 50% 50%; }
+.ovm-pop:active, :active > .ovm-pop { transform: scale(.82); transition-duration: .08s; transition-timing-function: ease-out; }
+
+.ovm-nudge { transition: transform .28s cubic-bezier(.34, 1.56, .64, 1); }
+.ovm-nudge:hover, :hover > .ovm-nudge, :focus-visible > .ovm-nudge { transform: translate(var(--ovm-x, 2px), var(--ovm-y, 0)); }
+
+.ovm-ring { transform-origin: 50% 12%; }
+.ovm-ring:hover, :hover > .ovm-ring, :focus-visible > .ovm-ring { animation: ovm-ring .9s cubic-bezier(.36, .07, .19, .97); }
+@keyframes ovm-ring { 0%, 100% { transform: rotate(0); } 15% { transform: rotate(14deg); } 32% { transform: rotate(-12deg); } 50% { transform: rotate(8deg); } 68% { transform: rotate(-5deg); } 84% { transform: rotate(2deg); } }
+
+.ovm-beat { transform-origin: 50% 55%; }
+.ovm-beat:hover, :hover > .ovm-beat, :focus-visible > .ovm-beat { animation: ovm-beat .7s ease-out; }
+@keyframes ovm-beat { 0%, 100% { transform: scale(1); } 18% { transform: scale(1.2); } 36% { transform: scale(.96); } 54% { transform: scale(1.1); } 72% { transform: scale(1); } }
+
+.ovm-shake.is-no, .ovm-shake.ovm-demo:hover, :hover > .ovm-shake.ovm-demo { animation: ovm-shake .5s cubic-bezier(.36, .07, .19, .97); }
+@keyframes ovm-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-3px); } 40% { transform: translateX(3px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(1px); } }
+
+@media (prefers-reduced-motion: reduce) {
+ .ovm-draw :is(path, circle, rect, ellipse, line, polyline) { animation: none; stroke-dashoffset: 0; }
+ .ovm-pop, .ovm-nudge, .ovm-ring, .ovm-beat, .ovm-shake { animation: none !important; transition: none !important; transform: none !important; }
+}
+html[data-ov-motion="reduced"] .ovm-draw :is(path, circle, rect, ellipse, line, polyline) { animation: none; stroke-dashoffset: 0; }
+html[data-ov-motion="reduced"] :is(.ovm-pop, .ovm-nudge, .ovm-ring, .ovm-beat, .ovm-shake) { animation: none !important; transition: none !important; transform: none !important; }
@@ -234,3 +234,13 @@
@@ -234,3 +234,13 @@
.mg-none{color:var(--text-muted)}
.mg-none{color:var(--text-muted)}
.mg-table tr.is-missing td{background:color-mix(in srgb,var(--bg-secondary) 50%,transparent)}
.mg-table tr.is-missing td{background:color-mix(in srgb,var(--bg-secondary) 50%,transparent)}
.mg-table code{white-space:nowrap}
.mg-table code{white-space:nowrap}
+
+/* Make it move (L50 step 7): each preset on the glyph, played by pointing, pressing or tapping. */
+.mv-grid{list-style:none;margin:0 0 16px;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:8px}
+.mv-tile{position:relative;width:100%;display:grid;justify-items:center;gap:6px;padding:20px 8px 14px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;cursor:pointer}
+.mv-tile svg{width:40px;height:40px;margin-bottom:4px}
+.mv-tile b{font-size:14px}
+.mv-tile small{color:var(--text-muted);font-size:12px}
+.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}
@@ -57,6 +57,11 @@
@@ -57,6 +57,11 @@
history.replaceState(null, '', `${location.pathname}${q}#png`);
history.replaceState(null, '', `${location.pathname}${q}#png`);
}, 120);
}, 120);
}
}
+ // Make it move: a tap on "Draw in" draws it again (a fresh copy restarts its animation).
+ document.querySelectorAll('[data-mv="draw"]').forEach((b) => b.addEventListener('click', () => {
+ const svg = b.querySelector('svg');
+ if (svg) svg.replaceWith(svg.cloneNode(true));
+ }));
form.addEventListener('input', update);
form.addEventListener('input', update);
form.addEventListener('change', update);
form.addEventListener('change', update);
form.addEventListener('submit', (e) => { e.preventDefault(); update(); });
form.addEventListener('submit', (e) => { e.preventDefault(); update(); });
@@ -6,6 +6,10 @@
@@ -6,6 +6,10 @@
* <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too, and Lucide's or Material's:
* <ov-icon name="fa-gear"></ov-icon> a Font Awesome name works too, and Lucide's or Material's:
* <ov-icon name="lucide:settings"></ov-icon> <ov-icon name="material:delete"></ov-icon> (openvibe.design/icons/migrate)
* <ov-icon name="lucide:settings"></ov-icon> <ov-icon name="material:delete"></ov-icon> (openvibe.design/icons/migrate)
* <ov-icon name="bell" variant="solid"></ov-icon> line | solid | auto (the default)
* <ov-icon name="bell" variant="solid"></ov-icon> line | solid | auto (the default)
+ * <ov-icon name="bell" motion="ring"></ov-icon> draw | pop | nudge | ring | beat | shake: one move when it, or the
+ * button or link it sits in, is pointed at or pressed (draw: on show;
+ * shake: when it gets the attribute no). Nothing loops or spins.
+ * <ov-icon name="heart" on></ov-icon> on: the solid drawing, as a control that is turned on
*
*
* Every size by itself: on auto, an icon 18 px or smaller shows its solid drawing when it has one (a container query,
* Every size by itself: on auto, an icon 18 px or smaller shows its solid drawing when it has one (a container query,
* so it follows the real size, not the attribute), and a glyph made only of lines draws them a little heavier there.
* so it follows the real size, not the attribute), and a glyph made only of lines draws them a little heavier there.
@@ -64,23 +68,44 @@
@@ -64,23 +68,44 @@
+ '.v-solid.has-s .l{display:none}.v-solid.has-s .s{display:inline}'
+ '.v-solid.has-s .l{display:none}.v-solid.has-s .s{display:inline}'
+ '@container (max-width: 18px){'
+ '@container (max-width: 18px){'
+ '.v-auto.has-s .l{display:none}.v-auto.has-s .s{display:inline}'
+ '.v-auto.has-s .l{display:none}.v-auto.has-s .s{display:inline}'
- + '.v-auto:not(.has-s) .l,.v-line .l{stroke-width:var(--ov-icon-stroke-small,2.4)}}';
+ + '.v-auto:not(.has-s) .l,.v-line .l{stroke-width:var(--ov-icon-stroke-small,2.4)}}'
+ // On: the solid drawing, as for a control that is turned on (a liked heart), with one beat when it turns on.
+ + ':host([on]) .has-s .l{display:none}:host([on]) .has-s .s{display:inline;animation:ovm-beat .5s ease-out}'
+ // Motion (plan L50 step 7; openvibe.design/icons/pack/ov-icon-motion.css is the same for any SVG).
+ + 'svg{transform-origin:50% 50%}'
+ + ':host([motion=draw]) .l>*{stroke-dasharray:160;stroke-dashoffset:160;animation:ovm-draw .9s cubic-bezier(.65,0,.35,1) forwards}'
+ + ':host([motion=draw]) .l>*:nth-child(2){animation-delay:.12s}:host([motion=draw]) .l>*:nth-child(n+3){animation-delay:.24s}'
+ + ':host([motion=pop]) svg{transition:transform .32s cubic-bezier(.34,1.56,.64,1)}:host([motion=pop][data-press]) svg{transform:scale(.82);transition-duration:.08s}'
+ + ':host([motion=nudge]) svg{transition:transform .28s cubic-bezier(.34,1.56,.64,1)}:host([motion=nudge][data-play]) svg{transform:translate(var(--ovm-x,2px),var(--ovm-y,0))}'
+ + ':host([motion=ring]) svg{transform-origin:50% 12%}:host([motion=ring][data-play]) svg{animation:ovm-ring .9s cubic-bezier(.36,.07,.19,.97)}'
+ + ':host([motion=beat]) svg{transform-origin:50% 55%}:host([motion=beat][data-play]) svg{animation:ovm-beat .7s ease-out}'
+ + ':host([motion=shake][no]) svg{animation:ovm-shake .5s cubic-bezier(.36,.07,.19,.97)}'
+ + '@keyframes ovm-draw{to{stroke-dashoffset:0}}'
+ + '@keyframes ovm-ring{0%,100%{transform:rotate(0)}15%{transform:rotate(14deg)}32%{transform:rotate(-12deg)}50%{transform:rotate(8deg)}68%{transform:rotate(-5deg)}84%{transform:rotate(2deg)}}'
+ + '@keyframes ovm-beat{0%,100%{transform:scale(1)}18%{transform:scale(1.2)}36%{transform:scale(.96)}54%{transform:scale(1.1)}72%{transform:scale(1)}}'
+ + '@keyframes ovm-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px)}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(1px)}}'
+ // Calm: the system's reduced motion or OpenVibe's (html[data-ov-motion="reduced"]) stops every move.
+ + ':host([data-calm]) svg,:host([data-calm]) svg *{animation:none!important;transition:none!important}'
+ + ':host([data-calm]) .l>*{stroke-dashoffset:0!important}:host([data-calm]) svg{transform:none!important}';
var sheet = null;
var sheet = null;
try { sheet = new CSSStyleSheet(); sheet.replaceSync(CSS); } catch (e) { sheet = null; }
try { sheet = new CSSStyleSheet(); sheet.replaceSync(CSS); } catch (e) { sheet = null; }
var live = new Set();
var live = new Set();
+ var reduceMq = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
+ function calm() { return !!(reduceMq && reduceMq.matches) || document.documentElement.getAttribute('data-ov-motion') === 'reduced'; }
+ if (reduceMq && reduceMq.addEventListener) reduceMq.addEventListener('change', function () { live.forEach(function (el) { el._calm(); }); });
function pref() {
function pref() {
var p = document.documentElement.getAttribute('data-ov-icons');
var p = document.documentElement.getAttribute('data-ov-icons');
return p === 'line' || p === 'solid' ? p : 'auto';
return p === 'line' || p === 'solid' ? p : 'auto';
}
}
- new MutationObserver(function () { live.forEach(function (el) { el._look(); }); })
- .observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-icons'] });
+ new MutationObserver(function () { live.forEach(function (el) { el._look(); el._calm(); }); })
+ .observe(document.documentElement, { attributes: true, attributeFilter: ['data-ov-icons', 'data-ov-motion'] });
function OvIcon() { return Reflect.construct(HTMLElement, [], OvIcon); }
function OvIcon() { return Reflect.construct(HTMLElement, [], OvIcon); }
OvIcon.prototype = Object.create(HTMLElement.prototype);
OvIcon.prototype = Object.create(HTMLElement.prototype);
OvIcon.prototype.constructor = OvIcon;
OvIcon.prototype.constructor = OvIcon;
Object.setPrototypeOf(OvIcon, HTMLElement);
Object.setPrototypeOf(OvIcon, HTMLElement);
- Object.defineProperty(OvIcon, 'observedAttributes', { get: function () { return ['name', 'size', 'variant', 'label']; } });
+ Object.defineProperty(OvIcon, 'observedAttributes', { get: function () { return ['name', 'size', 'variant', 'label', 'motion']; } });
OvIcon.prototype.connectedCallback = function () {
OvIcon.prototype.connectedCallback = function () {
if (!this.shadowRoot) {
if (!this.shadowRoot) {
@@ -96,14 +121,36 @@
@@ -96,14 +121,36 @@
live.add(this);
live.add(this);
this._size();
this._size();
this._label();
this._label();
+ this._calm();
+ this._wire();
this._draw();
this._draw();
};
};
- OvIcon.prototype.disconnectedCallback = function () { live.delete(this); };
+ OvIcon.prototype.disconnectedCallback = function () { live.delete(this); this._unwire(); };
+ OvIcon.prototype._calm = function () { this.toggleAttribute('data-calm', calm()); };
+ // A move plays when the icon, or the button, link or label it sits in, is pointed at, focused or pressed.
+ OvIcon.prototype._wire = function () {
+ this._unwire();
+ if (!this.hasAttribute('motion')) return;
+ var self = this;
+ var target = this.closest('a, button, label, summary, [role="button"], [role="menuitem"], [role="tab"]') || this;
+ var on = function () { self.removeAttribute('data-play'); void self.offsetWidth; self.setAttribute('data-play', ''); };
+ var off = function () { self.removeAttribute('data-play'); };
+ var down = function () { self.setAttribute('data-press', ''); };
+ var up = function () { self.removeAttribute('data-press'); };
+ var ends = function (e) { if (self.getAttribute('motion') !== 'nudge' && e.target === self._svg) off(); };
+ var pairs = [[target, 'pointerenter', on], [target, 'focusin', on], [target, 'pointerleave', off], [target, 'focusout', off],
+ [target, 'pointerdown', down], [target, 'pointerup', up], [target, 'pointercancel', up], [target, 'pointerleave', up]];
+ pairs.forEach(function (p) { p[0].addEventListener(p[1], p[2]); });
+ if (this.shadowRoot) this.shadowRoot.addEventListener('animationend', ends);
+ this._wired = function () { pairs.forEach(function (p) { p[0].removeEventListener(p[1], p[2]); }); if (self.shadowRoot) self.shadowRoot.removeEventListener('animationend', ends); };
+ };
+ OvIcon.prototype._unwire = function () { if (this._wired) { this._wired(); this._wired = null; } };
OvIcon.prototype.attributeChangedCallback = function (attr, was, now) {
OvIcon.prototype.attributeChangedCallback = function (attr, was, now) {
if (!this._svg || was === now) return;
if (!this._svg || was === now) return;
if (attr === 'name') this._draw();
if (attr === 'name') this._draw();
else if (attr === 'size') this._size();
else if (attr === 'size') this._size();
else if (attr === 'label') this._label();
else if (attr === 'label') this._label();
+ else if (attr === 'motion') this._wire();
else this._look();
else this._look();
};
};
OvIcon.prototype._size = function () {
OvIcon.prototype._size = function () {
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * Icon motion (plan L50 step 7): six presets, each a single move when something happens. Nothing loops and nothing
+ * spins; reduced motion (the system's, or OpenVibe's calm setting, html[data-ov-motion="reduced"]) stops them all.
+ * The CSS is public/css/icon-motion.css (served as /icons/pack/ov-icon-motion.css); <ov-icon motion="…"> carries the
+ * same moves in its own shadow.
+ *
+ * PRESETS [{ id, name, says, when }]
+ * suggest(glyph) the preset that suits a glyph (a bell rings, a heart beats, an arrow leads)
+ * nudgeOf(glyph) the way an arrow-like glyph leans: { x, y } in px
+ * CSS the stylesheet's text
+ */
+const fs = require('fs');
+const path = require('path');
+
+const PRESETS = Object.freeze([
+ { id: 'draw', name: 'Draw in', says: 'Draws itself in, line by line, the first time it shows.', when: 'on show' },
+ { id: 'pop', name: 'Press', says: 'Gives under the finger and springs back.', when: 'on press' },
+ { id: 'nudge', name: 'Lead', says: 'Leans the way it leads when you point at it.', when: 'on point' },
+ { id: 'ring', name: 'Ring', says: 'Swings like a bell, twice, when you point at it.', when: 'on point' },
+ { id: 'beat', name: 'Beat', says: 'Beats once, like a heart, when you point at it.', when: 'on point' },
+ { id: 'shake', name: 'Shake no', says: 'Shakes no, once, when something is refused (add is-no).', when: 'on refusal' },
+]);
+
+const NUDGE = { 'arrow-right': [2, 0], 'arrow-left': [-2, 0], back: [-2, 0], 'arrow-up': [0, -2], 'arrow-down': [0, 2], down: [0, 2], up: [0, -2],
+ open: [2, -2], send: [2, -2], plane: [2, -2], forward: [2, 0], enter: [2, 0], leave: [2, 0], chevron: [0, 2], 'chevrons-right': [2, 0],
+ 'chevrons-left': [-2, 0], download: [0, 2], upload: [0, -2], reply: [-2, 0], 'fast-forward': [2, 0], rewind: [-2, 0], skip: [2, 0], share: [2, -2] };
+const SUITS = {
+ ring: ['bell', 'megaphone', 'phone', 'alert'],
+ beat: ['heart', 'heart-pulse', 'star', 'thumbs-up', 'live', 'record', 'fire', 'spark', 'gift', 'trophy', 'crown'],
+ draw: ['check', 'check-circle', 'check-double', 'signature', 'pencil', 'route', 'wave', 'chart', 'trend-up', 'timeline'],
+ shake: ['ban', 'lock', 'x-circle', 'alert-circle', 'close'],
+};
+
+function suggest(glyph) {
+ if (NUDGE[glyph]) return 'nudge';
+ for (const [id, list] of Object.entries(SUITS)) if (list.includes(glyph)) return id;
+ return 'pop';
+}
+const nudgeOf = (glyph) => { const d = NUDGE[glyph] || [2, 0]; return { x: d[0], y: d[1] }; };
+
+const CSS = fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'css', 'icon-motion.css'), 'utf8');
+
+module.exports = { PRESETS, suggest, nudgeOf, CSS };
@@ -107,6 +107,7 @@ function createIconRoutes(ctx) {
@@ -107,6 +107,7 @@ function createIconRoutes(ctx) {
const file = req.params.file;
const file = req.params.file;
res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');
if (file === 'ov-glyphs-svg.zip') return res.type('application/zip').attachment(file).send(ex.zip());
if (file === 'ov-glyphs-svg.zip') return res.type('application/zip').attachment(file).send(ex.zip());
+ if (file === 'ov-icon-motion.css') return res.type('text/css').send(require('../glyphs/motion').CSS);
const p = Object.prototype.hasOwnProperty.call(ex.PACK, file) && ex.PACK[file];
const p = Object.prototype.hasOwnProperty.call(ex.PACK, file) && ex.PACK[file];
if (!p) return next();
if (!p) return next();
res.type(p.type).attachment(file).sendFile(p.path, (err) => { if (err && !res.headersSent) next(); });
res.type(p.type).attachment(file).sendFile(p.path, (err) => { if (err && !res.headersSent) next(); });
@@ -14,6 +14,7 @@ const glyphs = require('openvibe-shared/glyphs');
@@ -14,6 +14,7 @@ const glyphs = require('openvibe-shared/glyphs');
const { layout, esc } = require('../render/layout');
const { layout, esc } = require('../render/layout');
const ex = require('../glyphs/export');
const ex = require('../glyphs/export');
const formats = require('../glyphs/formats');
const formats = require('../glyphs/formats');
+const motion = require('../glyphs/motion');
const usage = require('../glyphs/fa-usage.json');
const usage = require('../glyphs/fa-usage.json');
const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
@@ -133,6 +134,19 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -133,6 +134,19 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
</div>
</div>
</section>`;
</section>`;
+ // L50 step 7: make it move. Each preset on this glyph, played by pointing at it (or pressing, or tapping to replay
+ // the draw); the one that suits it is marked. Nothing loops; calm stops it.
+ const suits = motion.suggest(name);
+ const lean = motion.nudgeOf(name);
+ const moving = `<section class="ic-sec" id="move" aria-labelledby="gp-move">
+ <div class="ic-sec-head"><h2 id="gp-move">Make it move</h2><p>One move when something happens: point at each, press it, or tap the first to draw it again. Nothing loops or spins, and calm or reduced motion stops them.</p></div>
+ <ul class="mv-grid">${motion.PRESETS.map((p) => `<li><button type="button" class="mv-tile${p.id === suits ? ' is-suits' : ''}" data-mv="${esc(p.id)}"${p.id === 'nudge' ? ` style="--ovm-x:${lean.x}px;--ovm-y:${lean.y}px"` : ''}>${glyph(name, 40, `ovm-${p.id}${p.id === 'shake' ? ' ovm-demo' : ''}`)}<b>${esc(p.name)}</b><small>${esc(p.when)}</small>${p.id === suits ? '<i>Suits it</i>' : ''}</button></li>`).join('')}</ul>
+ <div class="gp-use">
+ <div class="gp-way"><h3>With <ov-icon></h3>${kit.snippet({ code: `<ov-icon name="${name}" motion="${suits}"></ov-icon>${glyphs.solid(name) ? `\n<ov-icon name="${name}" motion="beat" on></ov-icon> <!-- on: the solid drawing, as when turned on -->` : ''}`, id: 'gp-mv-wc', label: 'Copy' })}</div>
+ <div class="gp-way"><h3>On any SVG</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/icons/pack/ov-icon-motion.css">\n<svg class="ovm-${suits}" …>…</svg>`, id: 'gp-mv-css', label: 'Copy' })}</div>
+ </div>
+</section>`;
+
const near = related(name);
const near = related(name);
const rel = `<section class="ic-sec" aria-labelledby="gp-rel">
const rel = `<section class="ic-sec" aria-labelledby="gp-rel">
<div class="ic-sec-head"><h2 id="gp-rel">Near it</h2><p>Glyphs that share a word with ${esc(name)}, then its neighbours. <a href="/icons">All ${glyphs.NAMES.length}</a>.</p></div>
<div class="ic-sec-head"><h2 id="gp-rel">Near it</h2><p>Glyphs that share a word with ${esc(name)}, then its neighbours. <a href="/icons">All ${glyphs.NAMES.length}</a>.</p></div>
@@ -156,9 +170,9 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
@@ -156,9 +170,9 @@ function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options()
return layout({
return layout({
title: head.length <= 40 ? head : `${T} icon: SVG, PNG, font`,
title: head.length <= 40 ? head : `${T} icon: SVG, PNG, font`,
description: `A free ${T.toLowerCase()} icon: download the SVG, make a PNG at any size and colour, or use the font.${fa.length ? ` A drop-in for Font Awesome's fa-${fa[0]}.` : ''} MIT.`.slice(0, 160),
description: `A free ${T.toLowerCase()} icon: download the SVG, make a PNG at any size and colour, or use the font.${fa.length ? ` A drop-in for Font Awesome's fa-${fa[0]}.` : ''} MIT.`.slice(0, 160),
- path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css'], scripts: ['js/icon.js'], jsonLd,
+ path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css', 'css/icon-motion.css'], scripts: ['js/icon.js'], jsonLd,
image: `${base}/icons/${name}.png?size=512&color=mark&pad=18&ground=squircle&bg=0b1020`, imageAlt: `The ${T.toLowerCase()} icon`,
image: `${base}/icons/${name}.png?size=512&color=mark&pad=18&ground=squircle&bg=0b1020`, imageAlt: `The ${T.toLowerCase()} icon`,
- body: `<article class="ic gp">${hero}${maker}${use}${rel}</article>`,
+ body: `<article class="ic gp">${hero}${maker}${use}${moving}${rel}</article>`,
});
});
}
}
@@ -181,6 +181,30 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
@@ -181,6 +181,30 @@ const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json"
assert.ok((await t.get('/llms.txt')).text.includes('/ov-icon.js'), 'llms.txt names it for agents');
assert.ok((await t.get('/llms.txt')).text.includes('/ov-icon.js'), 'llms.txt names it for agents');
});
});
+ await check('make it move (L50 step 7): six presets, the one that suits marked, a stylesheet, <ov-icon motion>', async () => {
+ const motion = require('../server/glyphs/motion');
+ assert.deepEqual(motion.PRESETS.map((p) => p.id), ['draw', 'pop', 'nudge', 'ring', 'beat', 'shake']);
+ assert.deepEqual(['bell', 'heart', 'arrow-right', 'check', 'ban', 'gear'].map(motion.suggest), ['ring', 'beat', 'nudge', 'draw', 'shake', 'pop']);
+ assert.deepEqual(motion.nudgeOf('back'), { x: -2, y: 0 }, 'an arrow leans its own way');
+ const page = (await t.get('/icons/bell')).text;
+ assert.ok(page.includes('<h2 id="gp-move">Make it move</h2>'));
+ assert.equal((page.match(/<button type="button" class="mv-tile/g) || []).length, 6);
+ assert.match(page, /class="mv-tile is-suits" data-mv="ring"/, 'a bell rings');
+ assert.ok(page.includes('<ov-icon name="bell" motion="ring">') || page.includes('<ov-icon name="bell" motion="ring">'));
+ assert.match(page, /\/css\/icon-motion\.css/, 'the page loads the moves');
+ const css = await t.get('/icons/pack/ov-icon-motion.css');
+ assert.match(css.headers.get('content-type'), /text\/css/);
+ assert.equal(css.headers.get('access-control-allow-origin'), '*');
+ for (const id of ['draw', 'pop', 'nudge', 'ring', 'beat', 'shake']) assert.ok(css.text.includes(`.ovm-${id}`), id);
+ assert.ok(!/infinite|rotate\(360deg\)|turn\)/.test(css.text), 'nothing loops or spins');
+ assert.match(css.text, /@media \(prefers-reduced-motion: reduce\)/);
+ assert.match(css.text, /html\[data-ov-motion="reduced"\]/, 'OpenVibe\'s calm setting stops them too');
+ const wc = (await t.get('/ov-icon.js')).text;
+ for (const k of ['ovm-draw', 'ovm-ring', 'ovm-beat', 'ovm-shake']) assert.ok(wc.includes(`@keyframes ${k}{`) && css.text.includes(`@keyframes ${k} {`), `${k} in both`);
+ assert.ok(!/infinite/.test(wc), 'the component loops nothing either');
+ assert.ok(wc.includes(":host([data-calm]) svg,:host([data-calm]) svg *{animation:none!important;transition:none!important}"));
+ });
+
await check('the pack: fonts, the sprite, the CSS, the code points and a ZIP of every SVG', async () => {
await check('the pack: fonts, the sprite, the CSS, the code points and a ZIP of every SVG', async () => {
const woff = await t.get('/icons/pack/ov-glyphs.woff2');
const woff = await t.get('/icons/pack/ov-glyphs.woff2');
assert.equal(woff.status, 200);
assert.equal(woff.status, 200);