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%; } }