diff --git a/scripts/generate-player-shells.mjs b/scripts/generate-player-shells.mjs
index dcdf00e..a98a9c2 100644
--- a/scripts/generate-player-shells.mjs
+++ b/scripts/generate-player-shells.mjs
@@ -32,19 +32,19 @@ mkdirSync(OUT, { recursive: true })
/** Every decorative card on the demo page, by section. */
const SPECS = [
// Pick a mood — 9 cards (PickAMoodSection.vue variant specs).
- { name: 'mood-auto', v: 'auto', w: 480 },
- { name: 'mood-vinyl', v: 'vinyl', a: '#C8A97E', w: 480 },
- { name: 'mood-sunset', v: 'sunset', a: '#F59E0B', w: 480 },
- { name: 'mood-midnight', v: 'midnight', a: '#8B5CF6', w: 480 },
- { name: 'mood-aurora', v: 'aurora', a: '#06B6D4', w: 480 },
- { name: 'mood-dark', v: 'dark', w: 480 },
- { name: 'mood-light', v: 'light', a: '#6750A4', w: 480 },
- { name: 'mood-transparent', v: 'transparent', w: 480, alpha: true },
+ { name: 'mood-auto', v: 'auto', w: 536 },
+ { name: 'mood-vinyl', v: 'vinyl', a: '#C8A97E', w: 536 },
+ { name: 'mood-sunset', v: 'sunset', a: '#F59E0B', w: 536 },
+ { name: 'mood-midnight', v: 'midnight', a: '#8B5CF6', w: 536 },
+ { name: 'mood-aurora', v: 'aurora', a: '#06B6D4', w: 536 },
+ { name: 'mood-dark', v: 'dark', w: 536 },
+ { name: 'mood-light', v: 'light', a: '#6750A4', w: 536 },
+ { name: 'mood-transparent', v: 'transparent', w: 536, alpha: true },
{
name: 'mood-custom-brown',
v: 'custom',
a: '#E8A87C',
- w: 480,
+ w: 536,
bg: 'linear-gradient(135deg, #2c1610 0%, #4a2c1f 45%, #6b4226 100%)',
},
// Three widths — auto variant at the three demo breakpoints.
@@ -84,15 +84,39 @@ for (const s of SPECS) {
if (s.w) q.set('w', String(s.w))
if (s.bg) q.set('bg', s.bg)
await page.goto(url(q.toString()), { waitUntil: 'networkidle' })
- // Transparent page so alpha captures stay alpha.
- await page.addStyleTag({ content: 'html,body{background:transparent!important}' })
+ // Round-20 user report ("coins noirs") — the first pipeline only
+ // cleared html/body, but `.app` (and the showcase stage) kept their
+ // dark backgrounds BEHIND the player, so omitBackground had nothing
+ // transparent to reveal : every capture shipped opaque corners
+ // (hasAlpha=false). Clear the WHOLE ancestor chain — the rounded
+ // card then keeps true alpha corners.
+ await page.addStyleTag({
+ content:
+ 'html,body,#app,.app,.showcase,.showcase__player{background:transparent!important}' +
+ '.app::before,.app::after,.showcase::before,.showcase::after{display:none!important}' +
+ // Round-21 ('coins sombres' follow-up) : the card's own
+ // box-shadow paints INSIDE the screenshot rect beyond the
+ // rounded corners (alpha~18 black pixels). Kill it at capture
+ // time — PlayerShell re-creates it at display time with
+ // drop-shadow, which follows the alpha (i.e. the radius).
+ '.showcase .mp{box-shadow:none!important}',
+ })
await page.waitForSelector('.showcase .mp', { timeout: 15000 })
// Let the cover image decode + the card settle.
await page.waitForTimeout(1200)
const el = page.locator('.showcase .mp').first()
const png = await el.screenshot({ omitBackground: true, type: 'png' })
- const img = sharp(png)
- const meta = await img.metadata()
+ // Round-21 — ALPHA EROSION : some component-internal overlay paints
+ // a faint veil (alpha ~18) across the whole rect, including the
+ // corners beyond the border-radius — the user's residual 'coins
+ // sombres' on light backdrops. Squash any alpha below 32 to 0
+ // (nothing legitimate in the chrome sits under ~12 % opacity ; the
+ // glassy `transparent` variant lives well above it).
+ const rawImg = sharp(png).ensureAlpha()
+ const { data, info } = await rawImg.raw().toBuffer({ resolveWithObject: true })
+ for (let i = 3; i < data.length; i += 4) if (data[i] < 32) data[i] = 0
+ const img = sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
+ const meta = info
const out = join(OUT, `${s.name}.webp`)
await img.webp({ quality: 84, alphaQuality: 90 }).toFile(out)
manifest[s.name] = {
diff --git a/src/App.vue b/src/App.vue
index 535a0e7..54445e3 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1941,27 +1941,15 @@ body.tour-running .mp[data-fab='true'] .mp__fab-chrome {
width: min(86vw, 1880px);
margin: 0 auto;
padding: clamp(60px, 6vw, 140px) clamp(24px, 3vw, 72px);
- /* Round-12b FLUIDITY — the page is ~20 700 px tall with 1 900 DOM
- nodes, 74 blurred layers and 87 promoted layers. content-visibility
- lets the browser SKIP style/layout/paint for every offscreen plain
- section (the 3 pinned showcases — .reveal, .rotate3d,
- .phone-showcase — keep their own classes and are NOT affected, so
- ScrollTrigger pin measurements stay exact). `auto` remembers the
- real height after first render ; the 1100px estimate only seeds
- the initial scrollbar. In-section animations are
- IntersectionObserver-driven and keep working (intersection is
- still computed for c-v subtrees). */
- content-visibility: auto;
- contain-intrinsic-size: auto 1100px;
-}
-/* The three pinned ScrollTrigger showcases also carry `.section` —
- exempt them : pin spacers + scrub measurements need real layout at
- refresh time, and `content-visibility` containment would skew them. */
-.reveal,
-.rotate3d,
-.phone-showcase {
- content-visibility: visible;
- contain-intrinsic-size: none;
+ /* Round-21 — content-visibility REMOVED (was round-12b). It was
+ added when the page carried 25 live player instances and 74 live
+ blur layers ; skipping offscreen sections then saved real work.
+ After the shell architecture (round-14) and the decorative-blur
+ purge (rounds 17+21), permanent rendering is cheap — and c-v's
+ remaining contribution was NEGATIVE : its one-frame re-renders
+ were precisely the user-reported 'rame d'un coup' spikes at
+ section (re)entry on ascent (zone profiling : max 191-236 ms at
+ the bottom zones, gone without it). */
}
.section--narrow {
width: min(86vw, 1280px);
@@ -2245,7 +2233,8 @@ body.tour-running .mp[data-fab='true'] .mp__fab-chrome {
}
.responsive__frame {
max-width: 100%;
- filter: drop-shadow(0 18px 40px rgba(0, 0, 0, 0.35));
+ /* Round-21 — drop-shadow moved into PlayerShell itself (the shadow
+ belongs to the card, and shells now ship shadow-free captures). */
}
/* ─── FAB PALETTE ──────────────────────────────────────────── */
@@ -2807,7 +2796,10 @@ samp,
width: clamp(220px, 30vw, 380px);
height: clamp(220px, 30vw, 380px);
border-radius: 50%;
- filter: blur(60px);
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
opacity: 0.55;
mix-blend-mode: screen;
/* The composable writes transform in vh units relative to the centre. */
@@ -3029,7 +3021,10 @@ samp,
rgba(139, 92, 246, 0.14) 32%,
transparent 62%
);
- filter: blur(36px);
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
z-index: -1;
pointer-events: none;
opacity: calc(0.7 + var(--pulse-ambient, 0) * 0.3);
@@ -3044,7 +3039,10 @@ samp,
bottom: -32px;
height: 28px;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.45) 0%, transparent 70%);
- filter: blur(12px);
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
z-index: -1;
pointer-events: none;
}
diff --git a/src/assets/shells/face-back-sunset.webp b/src/assets/shells/face-back-sunset.webp
index 0a8c204..31e479d 100644
Binary files a/src/assets/shells/face-back-sunset.webp and b/src/assets/shells/face-back-sunset.webp differ
diff --git a/src/assets/shells/face-front-auto.webp b/src/assets/shells/face-front-auto.webp
index 53a1dfc..64ba9a0 100644
Binary files a/src/assets/shells/face-front-auto.webp and b/src/assets/shells/face-front-auto.webp differ
diff --git a/src/assets/shells/manifest.json b/src/assets/shells/manifest.json
index b623e56..209c6d4 100644
--- a/src/assets/shells/manifest.json
+++ b/src/assets/shells/manifest.json
@@ -1,48 +1,48 @@
{
"mood-auto": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-vinyl": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-sunset": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-midnight": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-aurora": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-dark": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-light": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-transparent": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"mood-custom-brown": {
- "w": 960,
- "h": 368,
- "ratio": 2.6087
+ "w": 1072,
+ "h": 396,
+ "ratio": 2.7071
},
"width-320": {
"w": 640,
@@ -74,4 +74,4 @@
"h": 300,
"ratio": 2.2667
}
-}
+}
\ No newline at end of file
diff --git a/src/assets/shells/mood-aurora.webp b/src/assets/shells/mood-aurora.webp
index d8b217e..3ee92fc 100644
Binary files a/src/assets/shells/mood-aurora.webp and b/src/assets/shells/mood-aurora.webp differ
diff --git a/src/assets/shells/mood-auto.webp b/src/assets/shells/mood-auto.webp
index 0026395..6d78286 100644
Binary files a/src/assets/shells/mood-auto.webp and b/src/assets/shells/mood-auto.webp differ
diff --git a/src/assets/shells/mood-custom-brown.webp b/src/assets/shells/mood-custom-brown.webp
index fad0887..78ea01e 100644
Binary files a/src/assets/shells/mood-custom-brown.webp and b/src/assets/shells/mood-custom-brown.webp differ
diff --git a/src/assets/shells/mood-dark.webp b/src/assets/shells/mood-dark.webp
index 7b1de37..73f4dc9 100644
Binary files a/src/assets/shells/mood-dark.webp and b/src/assets/shells/mood-dark.webp differ
diff --git a/src/assets/shells/mood-light.webp b/src/assets/shells/mood-light.webp
index f08a978..a1eadf2 100644
Binary files a/src/assets/shells/mood-light.webp and b/src/assets/shells/mood-light.webp differ
diff --git a/src/assets/shells/mood-midnight.webp b/src/assets/shells/mood-midnight.webp
index bc06f60..e355314 100644
Binary files a/src/assets/shells/mood-midnight.webp and b/src/assets/shells/mood-midnight.webp differ
diff --git a/src/assets/shells/mood-sunset.webp b/src/assets/shells/mood-sunset.webp
index 90f6b60..7dadf4d 100644
Binary files a/src/assets/shells/mood-sunset.webp and b/src/assets/shells/mood-sunset.webp differ
diff --git a/src/assets/shells/mood-transparent.webp b/src/assets/shells/mood-transparent.webp
index 88e6d6c..135175c 100644
Binary files a/src/assets/shells/mood-transparent.webp and b/src/assets/shells/mood-transparent.webp differ
diff --git a/src/assets/shells/mood-vinyl.webp b/src/assets/shells/mood-vinyl.webp
index 9c24601..fb82813 100644
Binary files a/src/assets/shells/mood-vinyl.webp and b/src/assets/shells/mood-vinyl.webp differ
diff --git a/src/assets/shells/phone-auto.webp b/src/assets/shells/phone-auto.webp
index 6380035..0345384 100644
Binary files a/src/assets/shells/phone-auto.webp and b/src/assets/shells/phone-auto.webp differ
diff --git a/src/assets/shells/width-320.webp b/src/assets/shells/width-320.webp
index 76c5de0..df27084 100644
Binary files a/src/assets/shells/width-320.webp and b/src/assets/shells/width-320.webp differ
diff --git a/src/assets/shells/width-480.webp b/src/assets/shells/width-480.webp
index 5983f29..8be6b38 100644
Binary files a/src/assets/shells/width-480.webp and b/src/assets/shells/width-480.webp differ
diff --git a/src/assets/shells/width-720.webp b/src/assets/shells/width-720.webp
index d9664b8..74e4f7d 100644
Binary files a/src/assets/shells/width-720.webp and b/src/assets/shells/width-720.webp differ
diff --git a/src/components/AudioBars.vue b/src/components/AudioBars.vue
index ff05a53..0c0c7a0 100644
--- a/src/components/AudioBars.vue
+++ b/src/components/AudioBars.vue
@@ -15,7 +15,7 @@
*/
import { onBeforeUnmount, onMounted, ref } from 'vue'
-import { isScrolling } from '../composables/useScrollActivity'
+import { isScrollingFast } from '../composables/useScrollActivity'
interface Engine {
eqBars: readonly number[]
@@ -55,11 +55,13 @@ const render = () => {
return
}
raf = requestAnimationFrame(render)
- // Round-18 — freeze the draw while the page scrolls : a static
- // canvas scrolls as a cached texture (zero raster), and the eye
- // tracks the page motion, not the bars. Resumes within one frame
- // after the scroll settles.
- if (isScrolling()) return
+ // Round-23 ("la page tremble") — the round-18 freeze was BINARY :
+ // any scroll event froze the bars, then they snapped back through
+ // the attack smoothing 160 ms later. A slow wheel scroll therefore
+ // alternated freeze -> catch-up -> freeze : the user-reported
+ // tremor. The freeze now only engages during FAST scrolling
+ // (> 1500 px/s), where the eye cannot track the bars anyway.
+ if (isScrollingFast()) return
const c = canvas.value
if (!c) return
const ctx = c.getContext('2d')
@@ -75,49 +77,55 @@ const render = () => {
const engine = props.engine
const playing = engine?.isPlaying ?? false
- // Upsample the 4 focal bars into N bands via cosine interpolation,
- // plus a small symmetry curve so the visualiser feels balanced.
+ // Round-23 ("des vagues stables et calculées, ce sera plus joli") —
+ // the per-bar pseudo-random character (hash sin(i*12.9898)*43758…)
+ // made neighbours jitter independently : energetic, but noisy, and
+ // it burned a hash + ripple per bar per frame. New model : THREE
+ // superposed deterministic travelling sine waves (slow phase
+ // velocities, irrational-ish frequency ratios so the pattern never
+ // visibly repeats), modulated by the audio energy when playing.
+ // Calm, continuous, ocean-like — and cheaper : 3 sin() per bar.
+ //
+ // idle : signed-off silhouette + a barely-there breathing wave
+ // playing : silhouette anchor + energy envelope × wave field
const fft = engine?.eqBars ?? [0, 0, 0, 0]
- // Round-7 (user feedback : "au repos elles sont très bien" mais en
- // lecture "ça ne donne pas l'effet attendu") — the active branch used
- // to REPLACE the idle silhouette with `base × bell`, which collapsed
- // 64 bars onto 4 interpolated FFT values : everything rose and fell
- // together, mushy and uniform. New model : the bars DANCE AROUND the
- // resting silhouette instead of discarding it —
- // target = idle-anchor + audio energy × bell × per-bar character
- // with per-bar character = a stable pseudo-random gain (breaks the
- // 4-band uniformity : neighbours respond differently to the same
- // energy) + a travelling phase so peaks ripple across the row.
- // Asymmetric smoothing (fast attack / slow release) makes kicks SNAP
- // and decay like a real analyser instead of the old single-constant
- // mush.
const tNow = performance.now() / 1000
+ // Global audio energy (mean of the 4 focal bands) — ONE smooth
+ // scalar instead of 64 independent reactions.
+ let energy = 0
+ if (playing) {
+ let sum = 0
+ for (let i = 0; i < fft.length; i++) sum += fft[i] || 0
+ energy = fft.length ? sum / fft.length : 0
+ }
for (let i = 0; i < N; i++) {
const t = i / (N - 1)
// Bell curve around centre — louder at the middle bands, quieter
// at edges. Matches the perceptual "spectrum" feel.
const bell = Math.sin(t * Math.PI) ** 1.6
- // Sample 2 fft channels and blend.
- const f0 = fft[Math.floor(t * (fft.length - 1))] ?? 0
- const f1 = fft[Math.min(fft.length - 1, Math.floor(t * (fft.length - 1)) + 1)] ?? 0
- const blend = (Math.floor(t * (fft.length - 1)) + t) % 1
- const base = f0 * (1 - blend) + f1 * blend
// IDLE silhouette : static "spectrum at rest" (unchanged — the
// user signed off on this shape).
const idle = bell * 0.46 + Math.sin(i * 0.55) * 0.07 + Math.cos(i * 0.31) * 0.05
+ // Deterministic wave field, normalised to [0..1] : two travelling
+ // waves moving against each other + one long swell.
+ const x = t * Math.PI * 2
+ const wave =
+ 0.5 +
+ 0.27 * Math.sin(x * 3.0 - tNow * 1.35) +
+ 0.16 * Math.sin(x * 5.2 + tNow * 0.9) +
+ 0.07 * Math.sin(x * 1.3 - tNow * 0.4)
let target
if (playing) {
- // Stable per-bar gain (deterministic from the index) so adjacent
- // bars have personality ; travelling phase so energy ripples.
- const character = 0.65 + 0.7 * Math.abs((Math.sin(i * 12.9898) * 43758.5453) % 1)
- const ripple = 0.85 + 0.3 * Math.sin(i * 0.45 - tNow * 7)
const anchor = Math.max(0.14, idle * 0.55)
- target = Math.min(1, anchor + base * bell * 1.5 * character * ripple)
+ target = Math.min(1, anchor + energy * bell * (0.55 + 0.85 * wave))
} else {
- target = Math.max(0.18, idle)
+ // Resting silhouette with a breathing micro-swell (±0.03) so the
+ // strip feels alive without ever reading as motion.
+ target = Math.max(0.18, idle + (wave - 0.5) * 0.06)
}
- // Asymmetric smoothing : ~35 ms attack, ~260 ms release at 60 fps.
- const k = target > smoothed[i] ? 0.45 : 0.1
+ // Asymmetric smoothing — slightly softer attack than before
+ // (0.45 -> 0.3) : the wave model wants continuity, not snap.
+ const k = target > smoothed[i] ? 0.3 : 0.12
smoothed[i] += (target - smoothed[i]) * k
}
@@ -295,7 +303,10 @@ onBeforeUnmount(() => {
rgba(236, 72, 153, 0.1) 40%,
transparent 100%
);
- filter: blur(40px);
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
mix-blend-mode: screen;
}
.bars__canvas {
diff --git a/src/components/FeaturesGrid.vue b/src/components/FeaturesGrid.vue
index e000a27..20970d3 100644
--- a/src/components/FeaturesGrid.vue
+++ b/src/components/FeaturesGrid.vue
@@ -36,13 +36,16 @@
diff --git a/src/components/ProductReveal.vue b/src/components/ProductReveal.vue
index 51514ae..4f83217 100644
--- a/src/components/ProductReveal.vue
+++ b/src/components/ProductReveal.vue
@@ -158,11 +158,27 @@ onMounted(() => {
trigger: w,
start: 'top top',
end: 'bottom bottom',
- scrub: 1, // tween catches up over 1 s — feels organic
+ // Round-21 — 1 s -> 0.75 s : the 1 s catch-up replayed act
+ // boundaries well after the wheel stopped ('the page keeps
+ // moving without me', worst at the release — user request to
+ // 'free the user more fluidly'). 0.5 s was measured WORSE while
+ // moving (same animation path compressed into fewer, heavier
+ // frames : pin-ascent jank 42->59 %). 0.75 s is the measured
+ // middle : shorter trailing, no per-frame overload.
+ scrub: 0.75,
pin: s,
pinSpacing: true,
anticipatePin: 1,
onUpdate: (st) => {
+ // Round-21 — act swaps are skipped while the user FLICKS
+ // through the pin (|velocity| > 3000 px/s) : each swap
+ // re-skins the live player, and a fast traverse used to fire
+ // all six re-skins back-to-back (worst on ascent : 42-59 %
+ // janky frames through the pin zone). The final low-velocity
+ // update always lands the correct act. (First tried in
+ // round-17, shelved as unprovable under whole-page variance —
+ // re-validated with per-zone profiling.)
+ if (Math.abs(st.getVelocity()) > 3000) return
// 6 segments → segment index from progress.
const i = Math.min(5, Math.floor(st.progress * 6))
swapAct(i)
diff --git a/src/components/ProductRotate3D.vue b/src/components/ProductRotate3D.vue
index 975ef1d..e612b50 100644
--- a/src/components/ProductRotate3D.vue
+++ b/src/components/ProductRotate3D.vue
@@ -285,6 +285,11 @@ onBeforeUnmount(() => {
/* Halo — coloured wash behind the rotator, tinted by the auto-mood
cover. GSAP fades it in at mid-scroll. */
.rotate3d__halo {
+ /* Round-22 user feedback ("je n'aime pas les gros effets en BG") -
+ this wash, once its blur was gone, turned into a loud hard-edged
+ colour disc. Removed outright rather than re-blurred : the section
+ reads cleaner without it. */
+ display: none;
position: absolute;
inset: 10% 15%;
border-radius: 50%;
@@ -295,7 +300,10 @@ onBeforeUnmount(() => {
rgba(255, 120, 90, 0.16) 60%,
transparent 80%
);
- filter: blur(60px);
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
z-index: 1;
opacity: 0.35;
will-change: opacity, transform;
@@ -315,7 +323,10 @@ onBeforeUnmount(() => {
rgba(0, 0, 0, 0.2) 40%,
transparent 75%
);
- filter: blur(14px);
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
z-index: 0;
opacity: 0.4;
will-change: opacity, transform;
@@ -400,7 +411,10 @@ onBeforeUnmount(() => {
rgba(236, 72, 153, 0.16) 40%,
transparent 100%
);
- filter: blur(80px);
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
mix-blend-mode: screen;
}
.rotate3d__floor {
diff --git a/src/composables/usePremiumMotion.ts b/src/composables/usePremiumMotion.ts
index c52ae31..37deec7 100644
--- a/src/composables/usePremiumMotion.ts
+++ b/src/composables/usePremiumMotion.ts
@@ -33,7 +33,7 @@
*/
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
-import { isScrolling } from './useScrollActivity'
+import { isScrollingFast } from './useScrollActivity'
import { animate, stagger } from 'motion'
// ─── 1. Staged entrance ──────────────────────────────────────────────
@@ -139,7 +139,7 @@ export function useAudioReactiveBackdrop(
// mid-scroll.
frameToggle = !frameToggle
if (frameToggle) return
- if (isScrolling()) return
+ if (isScrollingFast()) return
const el = root.value
if (!el) return
const e = engine.value
@@ -554,7 +554,7 @@ export function useAudioParticles(
frameToggle = !frameToggle
if (frameToggle) return
// Round-18 — frozen while scrolling (same rationale as AudioBars).
- if (isScrolling()) return
+ if (isScrollingFast()) return
const c = canvas.value
if (!c) return
const ctx = c.getContext('2d')
diff --git a/src/composables/useScrollActivity.ts b/src/composables/useScrollActivity.ts
index 16c76e4..041254a 100644
--- a/src/composables/useScrollActivity.ts
+++ b/src/composables/useScrollActivity.ts
@@ -26,19 +26,30 @@
let installed = false
let scrolling = false
let settleTimer: ReturnType | null = null
+let lastY = 0
+let lastT = 0
+let speed = 0 // px/s, decayed estimate
const SETTLE_MS = 160
function ensureListener(): void {
if (installed || typeof window === 'undefined') return
installed = true
+ lastY = window.scrollY
+ lastT = performance.now()
window.addEventListener(
'scroll',
() => {
+ const now = performance.now()
+ const dt = Math.max(1, now - lastT)
+ speed = Math.abs(window.scrollY - lastY) / (dt / 1000)
+ lastY = window.scrollY
+ lastT = now
scrolling = true
if (settleTimer) clearTimeout(settleTimer)
settleTimer = setTimeout(() => {
scrolling = false
+ speed = 0
}, SETTLE_MS)
},
{ passive: true },
@@ -50,3 +61,15 @@ export function isScrolling(): boolean {
ensureListener()
return scrolling
}
+
+/**
+ * Round-23 ("la page tremble") — the binary freeze was the tremor :
+ * a slow wheel scroll alternated freeze -> catch-up -> freeze on the
+ * audio cosmetics every few frames. Freezing is only worth it (and
+ * only invisible) during FAST scrolling, so consumers now gate on
+ * velocity instead : true only above `threshold` px/s (default 1500).
+ */
+export function isScrollingFast(threshold = 1500): boolean {
+ ensureListener()
+ return scrolling && speed > threshold
+}
diff --git a/src/styles/glow-system.css b/src/styles/glow-system.css
index 135cec3..fd3d069 100644
--- a/src/styles/glow-system.css
+++ b/src/styles/glow-system.css
@@ -97,6 +97,12 @@
rgb(var(--glow-rose) / calc(var(--glow-opacity-soft) * 0.7)) 60%,
transparent 100%
);
+ /* Round-22 — blur RESTORED here (was removed round-21) : without it
+ the ellipse gradient overruns the box edges and CUTS into a hard
+ rounded RECTANGLE (user : 'les aurores sont devenues carrees').
+ This layer is STATIC (no scrub, no per-frame work) and, with
+ content-visibility gone, it rasterises ONCE — the round-21 spikes
+ came from moving/one-frame-re-rendered layers, not this one. */
filter: blur(var(--glow-blur-md));
mix-blend-mode: screen;
}
@@ -154,13 +160,25 @@
variant (sunset/midnight/aurora/vinyl). The `:has()` modern selector
keeps the wash off neutral light/dark/transparent variants where a
warm glow would feel out of place. Chrome 105+, Safari 15.4+,
- Firefox 121+ (caniuse > 92 % in 2026). */
+ Firefox 121+ (caniuse > 92 % in 2026).
+
+ Round-20 — the shells (PlayerShell, pre-rendered captures) replaced
+ most live .mp instances, which silently KILLED these blooms (the
+ :has(.mp…) selector no longer matched — user report). Each selector
+ now also matches .player-shell[data-variant] so shell cells glow
+ exactly like the real component. The blooms are static rasters
+ (no per-frame work) ; content-visibility skips them offscreen. */
.variants .grid__cell:has(.mp[data-variant='sunset'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='sunset'])::after,
.variants .grid__cell:has(.mp[data-variant='midnight'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='midnight'])::after,
.variants .grid__cell:has(.mp[data-variant='aurora'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='aurora'])::after,
.variants .grid__cell:has(.mp[data-variant='vinyl'])::after,
-.variants .grid__cell:has(.mp[data-variant='auto'])::after {
+.variants .grid__cell:has(.player-shell[data-variant='vinyl'])::after,
+.variants .grid__cell:has(.mp[data-variant='auto'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='auto'])::after {
content: '';
position: absolute;
inset: calc(var(--glow-spread-lg) * -1) calc(var(--glow-spread-md) * -1);
@@ -173,12 +191,19 @@
rgb(var(--glow-rose) / var(--glow-opacity-soft)) 65%,
transparent 100%
);
+ /* Round-22 — blur RESTORED here (was removed round-21) : without it
+ the ellipse gradient overruns the box edges and CUTS into a hard
+ rounded RECTANGLE (user : 'les aurores sont devenues carrees').
+ This layer is STATIC (no scrub, no per-frame work) and, with
+ content-visibility gone, it rasterises ONCE — the round-21 spikes
+ came from moving/one-frame-re-rendered layers, not this one. */
filter: blur(var(--glow-blur-lg));
mix-blend-mode: screen;
}
/* Per-variant accent so the wash matches the mood mesh. */
-.variants .grid__cell:has(.mp[data-variant='sunset'])::after {
+.variants .grid__cell:has(.mp[data-variant='sunset'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='sunset'])::after {
background: radial-gradient(
ellipse 90% 75% at center,
rgb(var(--glow-warm) / var(--glow-opacity-strong)) 0%,
@@ -186,7 +211,8 @@
transparent 100%
);
}
-.variants .grid__cell:has(.mp[data-variant='midnight'])::after {
+.variants .grid__cell:has(.mp[data-variant='midnight'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='midnight'])::after {
background: radial-gradient(
ellipse 90% 75% at center,
rgb(var(--glow-cool) / var(--glow-opacity-strong)) 0%,
@@ -194,7 +220,8 @@
transparent 100%
);
}
-.variants .grid__cell:has(.mp[data-variant='aurora'])::after {
+.variants .grid__cell:has(.mp[data-variant='aurora'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='aurora'])::after {
background: radial-gradient(
ellipse 90% 75% at center,
rgb(var(--glow-aqua) / var(--glow-opacity-strong)) 0%,
@@ -202,7 +229,8 @@
transparent 100%
);
}
-.variants .grid__cell:has(.mp[data-variant='vinyl'])::after {
+.variants .grid__cell:has(.mp[data-variant='vinyl'])::after,
+.variants .grid__cell:has(.player-shell[data-variant='vinyl'])::after {
background: radial-gradient(
ellipse 90% 75% at center,
rgb(var(--glow-warm) / var(--glow-opacity-med)) 0%,
@@ -225,7 +253,10 @@
rgb(var(--glow-cool) / var(--glow-opacity-soft)) 45%,
transparent 100%
);
- filter: blur(var(--glow-blur-lg));
+ /* Round-21 - blur removed : gradient-only decorative layer ; the
+ radial fade is already soft and the filter forced a costly raster
+ burst when the layer (re)entered the viewport (user-reported
+ hitches at pin release + page ascent). */
mix-blend-mode: screen;
}
diff --git a/src/styles/responsive-fix.css b/src/styles/responsive-fix.css
index c3a0eda..791339d 100644
--- a/src/styles/responsive-fix.css
+++ b/src/styles/responsive-fix.css
@@ -690,17 +690,23 @@ body,
flex-direction: row;
align-items: stretch;
gap: clamp(24px, 2vw, 48px);
- overflow-x: visible;
- flex-wrap: nowrap;
- /* Centre the row in the section so the 320 frame doesn't sit
- awkwardly far-left while the 720 stretches its size at the
- opposite end — instead the trio reads as one cohesive group. */
+ /* Round-20 user report — between 1440 and ~1700 px the fixed
+ 320+480+720 row (~1390 px + gaps) overflowed the narrow section
+ (~1200 px) and `overflow-x: auto` SLICED the 720 card at the
+ container edge. Cards must never be cut by their parents :
+ overflow stays visible (drop-shadows breathe) and the row WRAPS
+ — the 720 frame drops to its own line until the viewport gives
+ the trio enough room. */
+ overflow: visible;
+ flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
}
.responsive__cell {
flex: 0 0 auto;
- max-width: none;
+ /* keep 100% so a frame can still shrink-fit inside tight parents
+ instead of being clipped (its PlayerShell img scales with it). */
+ max-width: 100%;
}
}