openvibers/openvibe.shared Commits

openvibe-shared 2.20.4: ring icons without CSS transforms inside the SVG (icons missing while scrolling on phones) (#49)

OP OpenVibers committed 10 Oct 2026 · b4dbd5cdce · 5 files, +46 −33

Owner report (2026-10-10, phone screenshot of the openvibe.network tools section): icons go missing, especially while scrolling. The Pastes card showed its ring and comet but no glyph. The first chip of each row (YouTube Downloader, Merge PDF, Whois, Fancy Text, Logo Maker) showed no icon at all. Those chips sit in a vertical line under a scrolling thumb.

**Cause (as far as a desktop can tell).** It doesn't reproduce in headless Chrome at the same size (480 px at 3×), with forced dark mode or with simulated left-edge touch swipes: software raster paints everything. What failed on the phone is exactly what the icon CSS moved with CSS transforms inside the SVG:
- the glyph's `transform-box: view-box` + `transform-origin` + transform transition (started by a touch's sticky `:hover`);
- the comet's rotated circle;
- the progress circle's rotation;
- pulse's scale.

This file already documents one browser bug from that pipeline (a styled origin pivoting every solid glyph off-centre).

**Change (2.20.4).** No CSS transform touches an element inside the SVG any more:
- The comet is a dash moving round the ring (`stroke-dashoffset`, dash + gap = one circumference, so the loop is seamless).
- The progress arc is a `<path>` that starts at twelve o'clock (`applyProgress` is unchanged; the length is the same).
- The glyph keeps only its stroke/fill transitions, and the hover lift scales the whole `<svg>` element by 1.06.
- Pulse animates opacity only.
- Hover effects sit under `@media (hover: hover)`: a thumb scrolling over a link starts nothing.

**Same look.** Old and new rendered side by side at rest, with progress 0.4, busy, busy + progress, pulse and 22 px are the same, including the comet's position at the same moment.

Tests: suite 56/58 with 1 skipped. `analytics-retention` and `config` failed in the parallel run (a native crash under load) and both pass alone. `showcase.css` regenerated (`scripts/build-styles.js`).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 94aac37..bfeadeb 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,19 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 2.20.4 — 2026-10-10
++
++- Ring icons (`ov-icons.js`, `showcase.css`) no longer put a CSS transform on any element inside the SVG. On a phone,
++ scrolling left some icons unpainted: a glyph missing inside its ring, or a whole chip icon blank. The icons that
++ failed were the ones under the scrolling thumb, plus the cards whose comet spins. Both used CSS transforms on SVG
++ children (`transform-box: view-box` with `transform-origin` on the glyph, a rotated comet circle, a rotated progress
++ circle and a scaled pulse).
++ - The comet is now a dash moving round the ring (`stroke-dashoffset`), and the progress arc is a path that starts at
++ twelve o'clock.
++ - Pulse animates opacity only, and the hover lift scales the whole `<svg>` element.
++ - Hover effects apply only under `(hover: hover)`, so a thumb scrolling over a link starts nothing.
++ - At rest the icons render the same as 2.20.3, comet position included.
++
## 2.20.3 — 2026-10-10
- Filled controls use the theme's readable pair, `--accent-strong` with `--on-accent-strong`, instead of `--accent` with
ddiff --git a/ov-icons.js b/ov-icons.js
iindex fd8a4e0..19c8d20 100644
---- a/ov-icons.js
++++ b/ov-icons.js
@@@ -15,8 +15,11 @@
* OpenVibeIcons.set(el, { progress, state, icon })
* OpenVibeIcons.svg(name, size) markup string, for server-side / templates
*
-- * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/
-- * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".
++ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is opacity/stroke inside the
++ * SVG and transform on the <svg> element only: no CSS transform ever touches an element inside the SVG
++ * (transform-box/transform-origin on SVG children left glyphs and whole icons unpainted on phones while
++ * scrolling), and hover effects apply only where a pointer can hover, so a thumb scrolling over a link
++ * starts nothing. Motion stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".
*/
(function (root) {
'use strict';
@@@ -83,26 +86,25 @@
const CSS = `
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
--.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
++.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
--.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}
--.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
--.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-box:view-box;transform-origin:12px 12px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
--.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:transform .25s cubic-bezier(.2,1.4,.3,1),fill .2s}
++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;animation:oviSpin 3.6s linear infinite}
++.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
++.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}
++.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}
.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
.ov-icon[style*="--ovi-size:2"] .ovi-glyph,.ov-icon[data-size^="2"] .ovi-glyph{stroke-width:2.3}
.ov-icon[style*="--ovi-size:2"] .ovi-ring,.ov-icon[data-size^="2"] .ovi-ring{stroke-width:3;opacity:.34}
--.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
--.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
++@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}
.ov-icon[data-fx=none] .ovi-comet{display:none}
.ov-icon[data-fx=pulse] .ovi-comet{display:none}
--.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}
++.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}
.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
.ov-icon[data-progress] .ovi-prog{opacity:1}
.ov-icon[data-progress] .ovi-comet{opacity:0}
--.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}
++.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 85.66}
.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
@@@ -110,8 +112,8 @@
.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}
.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}
.ov-icon[data-state=error] svg{animation:oviShake .4s ease}
--@keyframes oviSpin{to{transform:rotate(360deg)}}
--@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}
++@keyframes oviSpin{from{stroke-dashoffset:0}to{stroke-dashoffset:-125.66}}
++@keyframes oviPulse{0%,100%{opacity:.08}50%{opacity:.24}}
@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}
@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}
@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}
@@@ -174,11 +176,10 @@
if (!solid) return forSprite ? def.glyph : `<g class="ovi-glyph">${def.glyph}</g>`;
const [w, d] = solid; const sc = 18 / 512; const tx = (24 - w * sc) / 2, ty = 3;
const paint = forSprite ? ' fill="currentColor" stroke="none" style="fill:var(--ovi-glyph,var(--text-primary,#e6edf7))"' : '';
-- // The position/scale transform must live on a plain (unclassed) <g>, not on the .ovi-glyph
-- // element itself: that class sets transform-box/transform-origin for the hover-scale
-- // animation, and browsers resolve a "transform" *attribute* through the same CSS transform
-- // pipeline — so pairing it with a non-default transform-origin pivots the glyph around
-- // (12,12) instead of applying it in place, throwing every solid glyph off-centre.
++ // The position/scale transform lives on a plain (unclassed) <g>, and no stylesheet rule sets a
++ // CSS transform, transform-box or transform-origin on anything inside the SVG: browsers resolve a
++ // "transform" *attribute* through the same CSS transform pipeline, so a styled origin once pivoted
++ // every solid glyph off-centre, and transforms on SVG children left icons unpainted on phones.
return `<g class="ovi-glyph ovi-glyph--solid"${paint}><g transform="translate(${tx.toFixed(2)} ${ty}) scale(${sc.toFixed(5)})"><path d="${d}"/></g></g>`;
}
@@@ -192,7 +193,7 @@
const key = resolve(name); const def = registry[key];
const off = def.offset || OFFSETS[key] || [0, 0]; // measured optical centring, see scripts/measure-icons.js
const s = size ? ` width="${size}" height="${size}"` : '';
-- return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)">${glyphMarkup(key, def, false)}</g></svg>`;
++ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><path class="ovi-prog" d="M24 4a20 20 0 1 1 0 40a20 20 0 1 1 0-40"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)">${glyphMarkup(key, def, false)}</g></svg>`;
}
// ── Sprite mode (server-rendered lists) ──────────────────────────────────────────────
ddiff --git a/package-lock.json b/package-lock.json
iindex e8fe84c..40c6ecb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "2.20.3",
++ "version": "2.20.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "2.20.3",
++ "version": "2.20.4",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 1e18d41..96e00a2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "2.20.3",
++ "version": "2.20.4",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/showcase.css b/showcase.css
iindex 9a8cff1..7d2b0ad 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -68,26 +68,25 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
@media (max-width:520px){.sc-actions .sc-btn{flex:1 1 100%;justify-content:center}.sc-code{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.sc-card,.sc-btn,.sc-next-main,.sc-next-go{transition:none}}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
--.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
++.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
--.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}
--.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
--.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-box:view-box;transform-origin:12px 12px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
--.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:transform .25s cubic-bezier(.2,1.4,.3,1),fill .2s}
++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 99.66;animation:oviSpin 3.6s linear infinite}
++.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
++.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transition:stroke .2s}
++.ov-icon .ovi-glyph--solid{fill:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke:none;transition:fill .2s}
.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
.ov-icon[style*="--ovi-size:2"] .ovi-glyph,.ov-icon[data-size^="2"] .ovi-glyph{stroke-width:2.3}
.ov-icon[style*="--ovi-size:2"] .ovi-ring,.ov-icon[data-size^="2"] .ovi-ring{stroke-width:3;opacity:.34}
--.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
--.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
++@media (hover:hover){.ov-icon:hover svg,a:hover>.ov-icon svg,button:hover>.ov-icon svg{transform:scale(1.06)}.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}}
.ov-icon[data-fx=none] .ovi-comet{display:none}
.ov-icon[data-fx=pulse] .ovi-comet{display:none}
--.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}
++.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite}
.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
.ov-icon[data-progress] .ovi-prog{opacity:1}
.ov-icon[data-progress] .ovi-comet{opacity:0}
--.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}
++.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 85.66}
.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
@@@ -95,8 +94,8 @@ a.sc-card:hover,a.sc-card:focus-visible{border-color:var(--accent,#3b82f6);trans
.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}
.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}
.ov-icon[data-state=error] svg{animation:oviShake .4s ease}
--@keyframes oviSpin{to{transform:rotate(360deg)}}
--@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}
++@keyframes oviSpin{from{stroke-dashoffset:0}to{stroke-dashoffset:-125.66}}
++@keyframes oviPulse{0%,100%{opacity:.08}50%{opacity:.24}}
@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}
@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}
@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}