openvibers/openvibe.live Commits
A phone is not "lite": the home's animations play on it (#758)
Changes
@@@ -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 radialmask-image over a ~1600px-tall box on a phone) and the About banner's lit edge (a mask-compositeover a ~2600px-tall box) are each a separate mask layer composited on every frame of a scrollthrough them. Both are faint texture — the banner keeps its real 1px border. */@@@ -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; }@@@ -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 (_) { /* */ }@@@ -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 shortconst h1 = document.querySelector('#page-home .hero h1');if (h1) h1.classList.add('hfx-shimmer');const rot = document.getElementById('hero-rotate');nnew file mode 100644@@@ -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');
@@ -169,24 +169,26 @@ html[data-ov-motion=off] .hfx-sparks,html[data-ov-motion=off] .hfx-spot{display:
@@ -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 .hb { --hb-wave: 8s; }
.rs-lite #page-home .stream-card { transition: none; }
.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) {
@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-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; }
.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); }
.navbar { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background: var(--glass-strong); }
.stream-card, .stream-card:hover { transform: none !important; }
.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 .home-cta-banner::before { animation: none !important; }
.rs-lite .cta-ambient { animation: none !important; filter: 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; }
.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:
@@ -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-shimmer { animation: none !important; filter: drop-shadow(0 0 14px color-mix(in srgb, var(--accent) 32%, transparent)); }
.hfx-glow { animation: none !important; }
.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-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-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 .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; }
.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
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
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. */
through them. Both are faint texture — the banner keeps its real 1px border. */
@@ -145,7 +145,7 @@
@@ -145,7 +145,7 @@
}
}
html[data-ov-motion=off] .tour-feat{opacity: 1;transform: none}
html[data-ov-motion=off] .tour-feat{opacity: 1;transform: none}
.rs-lite .tour::before, .rs-lite .tour-core-ring { display: 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 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-core::before, .rs-lite .tour-core-logo { animation: none; }
.rs-lite .tour-wire { animation: none; }
.rs-lite .tour-wire { animation: none; }
@@ -55,13 +55,14 @@
@@ -55,13 +55,14 @@
if (t.id && /^[a-z0-9-]{1,40}$/.test(t.id)) el.setAttribute('data-theme', t.id);
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 */ }
} 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 () {
(function () {
try {
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');
document.documentElement.classList.add('rs-lite');
}
}
} catch (_) { /* */ }
} catch (_) { /* */ }
@@ -15,12 +15,10 @@
@@ -15,12 +15,10 @@
const REDUCED = window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off');
const REDUCED = window.matchMedia && (document.documentElement.getAttribute('data-ov-motion') === 'off');
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
if (REDUCED) return;
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');
if (LITE) document.documentElement.classList.add('rs-lite');
const onHome = () => { const p = document.getElementById('page-home'); return !!(p && p.classList.contains('active')); };
const onHome = () => { const p = document.getElementById('page-home'); return !!(p && p.classList.contains('active')); };
@@ -28,7 +26,7 @@
@@ -28,7 +26,7 @@
// ── Hero sparks canvas ─────────────────────────────────────
// ── Hero sparks canvas ─────────────────────────────────────
let sparksStop = null;
let sparksStop = null;
function startSparks() {
function startSparks() {
- if (sparksStop || LITE || window.innerWidth < 720) return;
+ if (sparksStop || LITE) return;
const hero = document.querySelector('#page-home .hero');
const hero = document.querySelector('#page-home .hero');
if (!hero) return;
if (!hero) return;
const canvas = document.createElement('canvas');
const canvas = document.createElement('canvas');
@@ -42,7 +40,9 @@
@@ -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 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 }; }
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();
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); }
for (let i = 0; i < N; i++) { const p = spawn(); p.y = Math.random() * h; parts.push(p); }
function tick(now) {
function tick(now) {
if (!running) return;
if (!running) return;
@@ -55,7 +55,7 @@
@@ -55,7 +55,7 @@
const fade = Math.min(1, p.y / 80) * Math.min(1, (h - p.y) / 120);
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.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.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.fill();
}
}
ctx.shadowBlur = 0;
ctx.shadowBlur = 0;
@@ -70,7 +70,7 @@
@@ -70,7 +70,7 @@
// ── Cursor spotlight + magnetic buttons ─────────────────────
// ── Cursor spotlight + magnetic buttons ─────────────────────
function attachHeroPointer() {
function attachHeroPointer() {
- if (LITE) return;
+ if (LITE || COARSE) return;
const hero = document.querySelector('#page-home .hero');
const hero = document.querySelector('#page-home .hero');
if (!hero || hero.dataset.hfx) return;
if (!hero || hero.dataset.hfx) return;
hero.dataset.hfx = '1';
hero.dataset.hfx = '1';
@@ -211,7 +211,7 @@
@@ -211,7 +211,7 @@
// ── Stream card tilt ───────────────────────────────────────
// ── Stream card tilt ───────────────────────────────────────
function attachTilt() {
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.
// Pointer events arrive faster than frames; the old handler read layout on every one of them.
let tiltRaf = 0, lastEv = null;
let tiltRaf = 0, lastEv = null;
document.addEventListener('pointermove', (e) => {
document.addEventListener('pointermove', (e) => {
@@ -233,7 +233,7 @@
@@ -233,7 +233,7 @@
// ── Headline shimmer ───────────────────────────────────────
// ── Headline shimmer ───────────────────────────────────────
function attachHeadline() {
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');
const h1 = document.querySelector('#page-home .hero h1');
if (h1) h1.classList.add('hfx-shimmer');
if (h1) h1.classList.add('hfx-shimmer');
const rot = document.getElementById('hero-rotate');
const rot = document.getElementById('hero-rotate');
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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');