openvibers/openvibe.design Commits

Icons: make them move (draw, press, lead, ring, beat, shake no) (#79)

OP OpenVibers committed 12 days ago · 037a43a0e6 · 8 files, +195 −7

Plan L50 step 7. Six presets, each one move when something happens; nothing loops or spins, and reduced motion or
OpenVibe's calm setting stops them all.
- public/css/icon-motion.css (also /icons/pack/ov-icon-motion.css, CORS): .ovm-draw/pop/nudge/ring/beat/shake on any
  inline SVG, played by the icon or the button it sits in.
- <ov-icon motion="ring">: the same moves in its shadow, played when it or its button, link or label is pointed at,
  focused or pressed; on shows the solid drawing (a liked heart) with one beat; calm follows the system and
  html[data-ov-motion=reduced].
- Each icon page: Make it move, every preset live on that icon, the one that suits it marked (a bell rings, a heart
  beats, an arrow leads its own way), with both snippets.

Changes

ddiff --git a/public/css/icon-motion.css b/public/css/icon-motion.css
nnew file mode 100644
iindex 0000000..2c4ee46
---- /dev/null
++++ b/public/css/icon-motion.css
@@@ -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; }
ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex 9d9eb70..2da71e3 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -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}
ddiff --git a/public/js/icon.js b/public/js/icon.js
iindex b81e889..ba39839 100644
---- a/public/js/icon.js
++++ b/public/js/icon.js
@@@ -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(); });
ddiff --git a/public/ov-icon.js b/public/ov-icon.js
iindex e949a2a..1b3767c 100644
---- a/public/ov-icon.js
++++ b/public/ov-icon.js
@@@ -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 () {
ddiff --git a/server/glyphs/motion.js b/server/glyphs/motion.js
nnew file mode 100644
iindex 0000000..86e140b
---- /dev/null
++++ b/server/glyphs/motion.js
@@@ -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 };
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex 61b3a2e..83e9fc7 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -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(); });
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
iindex 42ef224..4f1ef6c 100644
---- a/server/views/icon-page.js
++++ b/server/views/icon-page.js
@@@ -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 &lt;ov-icon&gt;</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>`,
});
}
ddiff --git a/test/icon-exports.test.js b/test/icon-exports.test.js
iindex 4a878bb..3eaa0eb 100644
---- a/test/icon-exports.test.js
++++ b/test/icon-exports.test.js
@@@ -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('&lt;ov-icon name=&quot;bell&quot; motion=&quot;ring&quot;&gt;') || 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);