openvibers/openvibe.live Commits

A phone is not "lite": the home's animations play on it (#758)

OP OpenVibers committed 3 days ago · d676011d2b · 5 files, +71 −34

Every phone was lite (a coarse pointer, a screen under 820 px, or 4 GB of memory put .rs-lite on <html>), and lite,
with a phone-width block in home-fx.css, switched the home's motion off whatever the person's Motion setting said:
on production on a phone none of the wires, the core, the aurora, the banner or the headline moved.

Lite is now a device that says it is short of memory (2 GB or less) or a person who asked their browser to save data.
A phone plays the home's motion: the tour's wires and core, the hero's sparks (fewer, and without the glow around
each, which is the costly part of a frame), the headline, the banner. What follows a pointer (the spotlight, the card
tilt) is left out where there is no pointer. The phone block keeps what is not motion: no blend mode over the aurora,
no glass blur behind the bar and the scrim, no collage.

Measured on a 390 px touch profile: production, 0 of the home's own animations running besides the hero buttons;
this branch, 11 wires and 5 core animations with the tour on screen, the sparks canvas, 60 frames a second.

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex 3eba0988..217d4982 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -169,24 +169,26 @@ html[data-ov-motion=off] .hfx-sparks,html[data-ov-motion=off] .hfx-spot{display:
.rs-lite .hb { --hb-wave: 8s; }
.rs-lite #page-home .stream-card { transition: none; }
--/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
-- The CTA banner's conic border animates a registered custom property through a masked
-- conic-gradient (re-rasterised every frame), the ambient blobs and hero aurora run large
-- blur filters with blend modes, the collage is a 3D-tilted stack of floating thumbnails.
-- All of it flickers on low-end Android GPUs, so on phones they become static. */
++/* ── A phone: the home's motion plays (the owner, 2026-11-10: it was all switched off here, and on every phone by
++ .rs-lite, whatever the person's Motion setting said). What a phone still leaves out is what costs a frame without
++ being motion: blend modes over the aurora, glass blur behind the bar and the scrim, the collage (it has no room),
++ and a card's lift under a pointer that is not there. */
@media (max-width: 820px), (pointer: coarse) {
-- .home-cta-banner::before { animation: none !important; background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 70%, transparent), color-mix(in srgb, var(--accent) 15%, transparent) 50%, color-mix(in srgb, var(--accent) 55%, transparent)); opacity: 0.6; }
-- .cta-ambient { animation: none !important; filter: none !important; transform: none !important; opacity: 0.7; }
-- .cta-embers, .cta-robot-glow, .cta-owner-shine { display: none !important; }
-- .cta-btn-glow { animation: none !important; box-shadow: none !important; }
-- .cta-hero-grad { animation: none !important; }
-- .hero-aurora, .hero-aurora::before, .hero-aurora::after { filter: none !important; mix-blend-mode: normal !important; animation: none !important; transform: none !important; opacity: 0.22; }
++ .hero-aurora, .hero-aurora::before, .hero-aurora::after { mix-blend-mode: normal !important; opacity: 0.3; }
.hero-collage { display: none !important; }
-- .hero-bg-layer, .hero-bg-layer.active { animation: none !important; transform: none !important; transition: opacity var(--ov-dur-3, 0.4s) var(--ov-ease-out, ease); }
.hero-scrim { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; }
.navbar { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background: var(--glass-strong); }
.stream-card, .stream-card:hover { transform: none !important; }
}
++/* ── Lite (a device short of memory, or a person saving data): the heavy effects stand still. The CTA banner's
++ conic border animates a registered custom property through a masked conic-gradient (re-rasterised every frame),
++ and the ambient blobs and hero aurora run large blur filters. ── */
++.rs-lite .home-cta-banner::before { background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 70%, transparent), color-mix(in srgb, var(--accent) 15%, transparent) 50%, color-mix(in srgb, var(--accent) 55%, transparent)); opacity: 0.6; }
++.rs-lite .cta-ambient { transform: none !important; opacity: 0.7; }
++.rs-lite .cta-btn-glow { animation: none !important; box-shadow: none !important; }
++.rs-lite .cta-hero-grad { animation: none !important; }
++.rs-lite .hero-aurora, .rs-lite .hero-aurora::before, .rs-lite .hero-aurora::after { transform: none !important; opacity: 0.22; }
++.rs-lite .hero-bg-layer, .rs-lite .hero-bg-layer.active { transition: opacity var(--ov-dur-3, 0.4s) var(--ov-ease-out, ease); }
.rs-lite .home-cta-banner::before { animation: none !important; }
.rs-lite .cta-ambient { animation: none !important; filter: none !important; }
.rs-lite .cta-embers, .rs-lite .cta-robot-glow, .rs-lite .cta-owner-shine, .rs-lite .hero-collage { display: none !important; }
@@@ -327,15 +329,13 @@ html[data-ov-motion=off] .hfx-sparks,html[data-ov-motion=off] .hfx-spot{display:
.hfx-shimmer { animation: none !important; filter: drop-shadow(0 0 14px color-mix(in srgb, var(--accent) 32%, transparent)); }
.hfx-glow { animation: none !important; }
--/* Phones: nothing in the hero moves on its own. The page is scrolled past here far more than it is
-- looked at, and every running animation in a region being scrolled costs a frame budget the
-- scroll needs. */
++/* Lite: nothing in the hero moves on its own. */
.rs-lite .hero-float { animation: heroFloatIn var(--ov-dur-4, 0.6s) var(--ov-ease-out, cubic-bezier(0.2, 0.8, 0.2, 1)) var(--in-delay, 0s) forwards !important; }
.rs-lite .hero-bg-layer.active { animation: none !important; transform: scale(1.06) !important; will-change: auto !important; }
.rs-lite .hero-aurora, .rs-lite .hero-aurora::before, .rs-lite .hero-aurora::after { animation: none !important; }
.rs-lite .hfx-spot, .rs-lite .hfx-sparks { display: none !important; }
--/* Phones: no masked decoration on tall sections. The restream card's dot texture (a radial
++/* Lite: no masked decoration on tall sections. The restream card's dot texture (a radial
mask-image over a ~1600px-tall box on a phone) and the About banner's lit edge (a mask-composite
over a ~2600px-tall box) are each a separate mask layer composited on every frame of a scroll
through them. Both are faint texture — the banner keeps its real 1px border. */
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex d1160bc7..d547ef20 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -145,7 +145,7 @@
}
html[data-ov-motion=off] .tour-feat{opacity: 1;transform: none}
.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }
--/* Phones keep the glow but not the repainting gradients. */
++/* Lite keeps the glow but not the repainting gradients. */
.rs-lite .tour-core b { animation: none; -webkit-text-fill-color: var(--text-primary); background: none; }
.rs-lite .tour-core::before, .rs-lite .tour-core-logo { animation: none; }
.rs-lite .tour-wire { animation: none; }
ddiff --git a/public/index.html b/public/index.html
iindex 3cfad19b..6652a3b7 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -55,13 +55,14 @@
if (t.id && /^[a-z0-9-]{1,40}$/.test(t.id)) el.setAttribute('data-theme', t.id);
} catch (_) { /* nothing kept: theme-loader.js paints shortly */ }
})();
-- // "Lite" rendering for phones, coarse pointers and low-memory devices. Stylesheets on every
-- // route key off .rs-lite, so it is decided here, before first paint, rather than by the home
-- // page's effects script (which only loads on the home route).
++ // "Lite" rendering: a device that says it is short of memory (2 GB or less), or a person who asked their
++ // browser to save data. A phone is not lite: until 2026-11-10 every phone was, and lite switched the home's
++ // motion off whatever the person's Motion setting said (the owner: "none of the cool animations work even
++ // though I have the motion on"). Stylesheets on every route key off .rs-lite, so it is decided here, before
++ // first paint, rather than by the home page's effects script (which only loads on the home route).
(function () {
try {
-- if ((document.documentElement.getAttribute('data-ov-motion') === 'off')) return;
-- if (matchMedia('(pointer: coarse)').matches || matchMedia('(max-width: 820px)').matches || (navigator.deviceMemory && navigator.deviceMemory <= 4)) {
++ if ((navigator.deviceMemory && navigator.deviceMemory <= 2) || (navigator.connection && navigator.connection.saveData)) {
document.documentElement.classList.add('rs-lite');
}
} catch (_) { /* */ }
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
iindex c119e8bf..c3f51f7e 100644
---- a/public/js/home-fx.js
++++ b/public/js/home-fx.js
@@@ -15,12 +15,10 @@
const REDUCED = window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off');
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
if (REDUCED) return;
-- // Phones, coarse pointers and low-memory devices run in "lite" mode: no particle canvas, no
-- // pointer spotlight, no tilt. They still get the cheap IntersectionObserver work, because the
-- // viewport gate below is the only thing that starts the hero button wave — bailing out of the
-- // whole module here is why those buttons sat frozen on a phone.
-- // matchMedia rather than innerWidth, which forces a synchronous layout while the page is loading.
-- const LITE = COARSE || window.matchMedia('(max-width: 820px)').matches || (navigator.deviceMemory && navigator.deviceMemory <= 4);
++ // "Lite" is a device short of memory or a person saving data (index.html decides it before first paint, by the
++ // same rule): no particle canvas, no repainting gradients. A phone is not lite and plays the home's motion. What
++ // follows a pointer (the spotlight, the tilt) is left out where there is no pointer to follow (COARSE).
++ const LITE = !!((navigator.deviceMemory && navigator.deviceMemory <= 2) || (navigator.connection && navigator.connection.saveData));
if (LITE) document.documentElement.classList.add('rs-lite');
const onHome = () => { const p = document.getElementById('page-home'); return !!(p && p.classList.contains('active')); };
@@@ -28,7 +26,7 @@
// ── Hero sparks canvas ─────────────────────────────────────
let sparksStop = null;
function startSparks() {
-- if (sparksStop || LITE || window.innerWidth < 720) return;
++ if (sparksStop || LITE) return;
const hero = document.querySelector('#page-home .hero');
if (!hero) return;
const canvas = document.createElement('canvas');
@@@ -42,7 +40,9 @@
function resize() { const r = hero.getBoundingClientRect(); w = canvas.width = Math.max(1, Math.round(r.width)); h = canvas.height = Math.max(1, Math.round(r.height)); }
function spawn() { return { x: Math.random() * w, y: h + 10 + Math.random() * 40, r: 1 + Math.random() * 2.4, vy: 12 + Math.random() * 26, vx: (Math.random() - 0.5) * 10, sw: Math.random() * Math.PI * 2, c: COLORS[(Math.random() * COLORS.length) | 0], a: 0.25 + Math.random() * 0.5, life: 0 }; }
resize();
-- const N = Math.min(70, Math.max(30, Math.round(w / 18)));
++ // A phone's hero is narrow: fewer sparks, and no glow around each (the blur is the costly part of a frame).
++ const N = Math.min(70, Math.max(16, Math.round(w / 18)));
++ const GLOW = COARSE ? 0 : 8;
for (let i = 0; i < N; i++) { const p = spawn(); p.y = Math.random() * h; parts.push(p); }
function tick(now) {
if (!running) return;
@@@ -55,7 +55,7 @@
const fade = Math.min(1, p.y / 80) * Math.min(1, (h - p.y) / 120);
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${p.c},${(p.a * Math.max(0, fade)).toFixed(3)})`;
-- ctx.shadowBlur = 8; ctx.shadowColor = `rgba(${p.c},0.8)`;
++ ctx.shadowBlur = GLOW; if (GLOW) ctx.shadowColor = `rgba(${p.c},0.8)`;
ctx.fill();
}
ctx.shadowBlur = 0;
@@@ -70,7 +70,7 @@
// ── Cursor spotlight + magnetic buttons ─────────────────────
function attachHeroPointer() {
-- if (LITE) return;
++ if (LITE || COARSE) return;
const hero = document.querySelector('#page-home .hero');
if (!hero || hero.dataset.hfx) return;
hero.dataset.hfx = '1';
@@@ -211,7 +211,7 @@
// ── Stream card tilt ───────────────────────────────────────
function attachTilt() {
-- if (LITE) return;
++ if (LITE || COARSE) return;
// Pointer events arrive faster than frames; the old handler read layout on every one of them.
let tiltRaf = 0, lastEv = null;
document.addEventListener('pointermove', (e) => {
@@@ -233,7 +233,7 @@
// ── Headline shimmer ───────────────────────────────────────
function attachHeadline() {
-- if (LITE) return; // an infinite gradient sweep over text is a real battery cost on a phone
++ if (LITE) return; // an infinite gradient sweep over text repaints every frame: not where memory is short
const h1 = document.querySelector('#page-home .hero h1');
if (h1) h1.classList.add('hfx-shimmer');
const rot = document.getElementById('hero-rotate');
ddiff --git a/test/home-lite.test.js b/test/home-lite.test.js
nnew file mode 100644
iindex 00000000..5b7809d5
---- /dev/null
++++ b/test/home-lite.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * A phone is not "lite" (the owner, 2026-11-10: "none of the cool animations work even though I have the motion on").
++ * Until then every phone was: a coarse pointer, a screen under 820 px or 4 GB of memory put .rs-lite on <html>, and
++ * .rs-lite and a phone-width block in home-fx.css switched the home's motion off whatever the person's Motion setting
++ * said. Lite is a device that says it is short of memory, or a person saving data; a phone plays the home's motion.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
++
++// The decision before first paint, and the same rule in the home's effects script.
++const html = read('public/index.html');
++const decide = html.slice(html.indexOf('"Lite" rendering'), html.indexOf("classList.add('rs-lite')"));
++assert.ok(decide.length > 0, 'index.html decides lite before first paint');
++assert.ok(/navigator\.deviceMemory <= 2/.test(decide) && /saveData/.test(decide), 'lite: short of memory, or saving data');
++assert.ok(!/pointer: coarse|max-width/.test(decide), 'a touch screen or a narrow one is not lite');
++assert.ok(!/data-ov-motion/.test(decide), 'lite does not read the Motion setting: they are two different things');
++
++const fx = read('public/js/home-fx.js');
++const lite = /const LITE = ([^;]+);/.exec(fx);
++assert.ok(lite && /deviceMemory <= 2/.test(lite[1]) && /saveData/.test(lite[1]) && !/COARSE|max-width/.test(lite[1]), 'home-fx.js: the same rule');
++assert.ok(/if \(sparksStop \|\| LITE\) return;/.test(fx), 'the sparks run on a phone (fewer, without the glow)');
++assert.ok(/function attachHeroPointer\(\) \{\s+if \(LITE \|\| COARSE\) return;/.test(fx), 'what follows a pointer is left out where there is none');
++
++// The phone block of the home's effects keeps layout and glass rules only: nothing in it stops an animation.
++const css = read('public/css/home-fx.css');
++const at = css.indexOf('@media (max-width: 820px), (pointer: coarse) {');
++assert.ok(at > 0, 'the phone block is there');
++const block = css.slice(at, css.indexOf('\n}\n', at));
++assert.ok(!/animation:\s*none/.test(block), 'the phone block stops no animation');
++assert.ok(/\.hero-collage \{ display: none !important; \}/.test(block), 'it still leaves out the collage, which has no room');
++
++console.log('home-lite: ok');