Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ coverage/
public/audio/*.webm
public/audio/*.mp3
public/audio/*.webp
# EXCEPTION (round-16) : the baked hero backdrops are derived from the
# COMMITTED MIT-licensed SVG covers (scripts/generate-hero-backdrop.mjs)
# — full rights chain, deterministic, needed by the Pages deploy.
!public/audio/cover-blur.webp
!public/audio/cover2-blur.webp
26 changes: 0 additions & 26 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,15 @@
"prepublishOnly": "npm run ci && npm run build:lib",
"prepare": "husky 2>/dev/null || true",
"test:consumer": "node scripts/consumer-smoke.mjs",
"generate:shells": "node scripts/generate-player-shells.mjs"
"generate:shells": "node scripts/generate-player-shells.mjs",
"generate:backdrop": "node scripts/generate-hero-backdrop.mjs"
},
"peerDependencies": {
"pinia": "^2.1.0",
"vue": "^3.4.0"
},
"dependencies": {
"gsap": "^3.15.0",
"lenis": "^1.3.23",
"lucide-vue-next": "^0.300.0",
"motion": "^12.40.0"
},
Expand Down
Binary file added public/audio/cover-blur.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/audio/cover2-blur.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions scripts/generate-hero-backdrop.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* generate-hero-backdrop.mjs — round-16 raster-cost removal.
*
* The hero backdrop used to be the LIVE cover image under a CSS
* `filter: blur(110px) saturate(1.5) brightness(0.92)` — the single
* most expensive rasterised layer on the page (a ~2700×1500 surface
* with a 110 px kernel, re-rasterised on every tile churn during fast
* scrolling at 2K). This script BAKES that exact look offline :
*
* public/audio/cover.svg → public/audio/cover-blur.webp
* public/audio/cover2.svg → public/audio/cover2-blur.webp
*
* Technique : rasterise at 1280 px, downscale to 640 (kernel cost ↓),
* sharp blur σ28 (≈ CSS blur 110 px after the 2× stretch), saturation
* ×1.5 and brightness ×0.92 baked via modulate(), WebP q80. The CSS
* then ships `filter: none` — the layer composites as a plain quad.
*
* Re-run if the cover artwork changes : npm run generate:backdrop
*/

import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'

const ROOT = join(fileURLToPath(import.meta.url), '..', '..')
const AUDIO = join(ROOT, 'public', 'audio')

for (const name of ['cover', 'cover2']) {
const src = join(AUDIO, `${name}.svg`)
const out = join(AUDIO, `${name}-blur.webp`)
await sharp(src, { density: 192 })
.resize(640, 640, { fit: 'cover' })
.blur(28)
.modulate({ saturation: 1.5, brightness: 0.92 })
.webp({ quality: 80 })
.toFile(out)
const meta = await sharp(out).metadata()
console.log(`✓ ${name}-blur.webp ${meta.width}×${meta.height}`)
}
console.log('✅ hero backdrops baked — CSS blur(110px) layer retired.')
36 changes: 18 additions & 18 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useDemoSpotlight } from './composables/useDemoSpotlight'
import {
useStagedReveal,
useAudioReactiveBackdrop,
useSmoothScroll,
useKineticType,
useCursorGlow,
useScrollParallax,
Expand All @@ -15,7 +14,6 @@ import {
// no longer needed at the App.vue top level after the P1.1 extraction.
} from './composables/usePremiumMotion'
import {
useScrollProgress,
useScrollKineticWave,
useScrollOrbitField,
useMagneticHover,
Expand Down Expand Up @@ -91,8 +89,9 @@ const store = useAudioStore()

// ─── Premium motion layer (alpha.27) ────────────────────────────
// Apple-style staged reveal on the hero block + audio-reactive
// ambient backdrop driven by the engine's FFT bars + Lenis smooth
// scroll. All three respect prefers-reduced-motion and ship in the
// ambient backdrop driven by the engine's FFT bars. NATIVE scroll
// (Lenis removed round-16 — see usePremiumMotion.ts §3 rationale).
// Everything respects prefers-reduced-motion and ships in the
// DEMO PAGE only (never in @pulse-music/* tarballs). See
// docs/setup/PREMIUM_DEMO.md for the design rationale.
// alpha.30 cascade order — PRODUCT FIRST (Apple page discipline).
Expand All @@ -112,7 +111,6 @@ const audioReactiveSnapshot = computed(() => ({
eqBars: store.eqBars,
isPlaying: store.isPlaying,
}))
useSmoothScroll()

// alpha.28 — next-gen premium motion
// Kinetic title: split per-char + cascade
Expand Down Expand Up @@ -148,7 +146,6 @@ useAudioParticles(particleCanvasEl, audioReactiveSnapshot, {
// alpha.30 — scroll-progress channel on the hero powers the
// variable-font weight axis (Geist 650→800). The CSS consumer reads
// --scroll-progress and drives font-variation-settings.
useScrollProgress(heroEl)

// alpha.31 — cinematic finishing touches.
// 1. Hero player floats — subtle 12 s Y bob, 6 px amplitude.
Expand Down Expand Up @@ -210,7 +207,6 @@ useFirstPlayFlare(audioReactiveSnapshot)
// orbit field with amber tertiary accent, and primary-CTA magnetic
// hover. See docs/setup/ALPHA_29_RESEARCH.md for the source map.
const whyPulseSectionEl = ref<HTMLElement | null>(null)
useScrollProgress(whyPulseSectionEl)
const whyPulseWaveEl = ref<HTMLElement | null>(null)
// alpha.30 — wave amplitude 20→8 + period 7→11 so the kinetic dual-
// wave reads as gentle parallax-per-glyph, NOT "drunk text" wiggle.
Expand Down Expand Up @@ -914,8 +910,14 @@ onUnmounted(() => {
})

// ─── Hero blurred backdrop driven by current cover ────────────
// Round-16 : the backdrop consumes a PRE-BLURRED webp (baked by
// scripts/generate-hero-backdrop.mjs) instead of live-blurring the
// cover with filter: blur(110px) — that filter was the most expensive
// rasterised layer on the page at 2K. Convention : <cover>-blur.webp
// next to the cover asset ; falls back to the sharp cover if absent.
const hero = computed(() => ({
'--hero-cover': `url(${store.track.cover})`,
'--hero-cover-blur': `url(${store.track.cover.replace(/\.(svg|webp|png|jpg)$/, '-blur.webp')})`,
}))
</script>

Expand Down Expand Up @@ -1182,9 +1184,9 @@ const hero = computed(() => ({
</div>
<div class="why__col why__col--offset">
<p class="why__lede why__lede--alt">
Scroll moves the page; the page moves with the scroll. Lenis momentum +
scroll-progress channels keep the impression of control — not the toy-car overshoot of
cheap parallax.
Scroll moves the page; the page moves with the scroll. Native scrolling,
compositor-only motion — your input lands instantly, with none of the toy-car
overshoot of hijacked momentum.
</p>
</div>
</div>
Expand Down Expand Up @@ -1362,22 +1364,20 @@ code {
.hero__backdrop {
position: absolute;
inset: -60px;
background-image: var(--hero-cover);
/* Round-16 — pre-blurred asset (generate:backdrop) : blur(110px) +
saturate + brightness are BAKED into the webp. The layer is now a
plain textured quad — zero live filter, zero kernel re-raster
during fast scrolling (this was the page's costliest layer). */
background-image: var(--hero-cover-blur, var(--hero-cover));
background-size: cover;
background-position: center;
/* alpha.32 VISUAL-QA — boosted blur (80 → 110) and lowered opacity
(0.55 → 0.38) so the auto-mood cover art behaves like a real
cinematic backdrop (atmospheric, not literal). */
filter: blur(110px) saturate(1.5) brightness(0.92);
opacity: 0.38;
z-index: -2;
transform: scale(1.18);
/* Round-12 — `opacity` removed from this transition list: it is now
pumped per-frame by `--pulse-ambient` (see the motion layer below)
and a 600 ms transition would queue a fresh animation per write. */
transition:
background-image 0.6s ease,
filter 0.6s ease;
transition: background-image 0.6s ease;
}
.hero__backdrop::after {
/* Centre vignette so the eye lands on the player even when the
Expand Down
6 changes: 6 additions & 0 deletions src/components/AudioBars.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { onBeforeUnmount, onMounted, ref } from 'vue'
import { isScrolling } from '../composables/useScrollActivity'

interface Engine {
eqBars: readonly number[]
Expand Down Expand Up @@ -54,6 +55,11 @@ 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
const c = canvas.value
if (!c) return
const ctx = c.getContext('2d')
Expand Down
22 changes: 15 additions & 7 deletions src/components/ProductReveal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,10 @@ onBeforeUnmount(() => {
);
mix-blend-mode: screen;
pointer-events: none;
filter: blur(28px);
/* Round-17 — blur(28px) removed : a linear gradient is already soft,
and this full-width layer is tweened (yPercent/opacity) per scrub
frame — the live filter forced a re-raster of ~2560×550 px on the
way through the pin. Visually indistinguishable without it. */
z-index: -1;
}

Expand All @@ -408,7 +411,9 @@ onBeforeUnmount(() => {
);
mix-blend-mode: screen;
pointer-events: none;
filter: blur(20px);
/* Round-17 — blur(20px) removed : the radial fades to transparent
100% already ; the filter doubled the raster cost of a layer
bigger than the viewport (inset -30%). */
z-index: -1;
}

Expand Down Expand Up @@ -472,7 +477,12 @@ onBeforeUnmount(() => {
rgba(139, 92, 246, 0.04) 70%,
transparent 100%
);
filter: blur(60px);
/* Round-17 — blur(60px) removed : this halo MOVES with the product
(y/scale tweened every scrub frame), so the filter re-composited a
~1500×900 blurred+blended layer per scrolled frame — the single
hottest layer of the measured reveal-flick jank (45%). The radial
stops below already end at transparent 100% ; widening the inner
stop compensates the lost softness. */
z-index: -1;
pointer-events: none;
}
Expand Down Expand Up @@ -564,19 +574,17 @@ onBeforeUnmount(() => {
inset: auto -10vw 0 -10vw;
height: 28vh;
opacity: 0.6;
filter: blur(36px);
/* Round-17 — blur removed (gradient-only layer, see desktop note) */
}
.reveal__flare {
/* extend past the viewport so the wash never shows a circular cut */
inset: -40vw -30vw -30vw -30vw;
filter: blur(40px);
opacity: 0.7;
}
.reveal__product::before {
/* Halo behind the centred player — extend full-bleed for the same
no-hard-ring reason as desktop, just at mobile proportions. */
inset: -40% -40% -50% -40%;
filter: blur(48px);
}
.reveal__stage {
/* Spread the stage background gradients out so they cover the
Expand Down Expand Up @@ -669,7 +677,7 @@ onBeforeUnmount(() => {
rgba(139, 92, 246, 0.12) 40%,
transparent 100%
);
filter: blur(40px);
/* Round-17 — blur removed (gradient-only, mobile slide halo) */
z-index: -1;
pointer-events: none;
}
Expand Down
54 changes: 5 additions & 49 deletions src/composables/useAdvancedMotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,55 +69,11 @@ function runWhileVisible(el: HTMLElement, tick: FrameRequestCallback): () => voi
}
}

// ─── 1. useScrollProgress ────────────────────────────────────────────

/**
* Tracks the target element's scroll progress through the viewport as
* a number in [0..1]:
* - 0 when the element's top hits the bottom of the viewport
* - 1 when the element's bottom hits the top of the viewport
*
* Sets a CSS custom property `--scroll-progress` on the element so the
* CSS consumer can drive any compositable property. Zero Vue rerender.
*
* This is the foundation block: the same primitive that Apple's
* scroll-driven product pages use (a single 0..1 scalar that paints
* everything from sequence frames to text masks to camera positions).
*/
export function useScrollProgress(target: Ref<HTMLElement | null>): void {
let dispose: (() => void) | null = null

onMounted(() => {
if (typeof window === 'undefined') return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
target.value?.style.setProperty('--scroll-progress', '0.5')
return
}
const el = target.value
if (!el) return
// Round-12 fluidity : visibility-gated + scroll-delta-gated. The
// value only changes when the page scrolls (or resizes), so the
// rect read + style write are skipped on static frames — this loop
// used to force a layout EVERY frame for the page's lifetime.
let lastScrollY = -1
let lastVh = -1
dispose = runWhileVisible(el, () => {
const vh = window.innerHeight
if (window.scrollY === lastScrollY && vh === lastVh) return
lastScrollY = window.scrollY
lastVh = vh
const rect = el.getBoundingClientRect()
const span = rect.height + vh
const traversed = vh - rect.top
const p = Math.max(0, Math.min(1, traversed / span))
el.style.setProperty('--scroll-progress', p.toFixed(4))
})
})

onBeforeUnmount(() => {
dispose?.()
})
}
// ─── 1. useScrollProgress — REMOVED round-16 ────────────────────────
// Its only consumer (the hero font-variation wght axis) was deleted in
// round-12 for fluidity ; the hook then burned a rect read + style
// write per scrolled frame for nothing. Re-add from git history if a
// CSS consumer ever returns.

// ─── 2. useScrollKineticWave ──────────────────────────────────────────

Expand Down
8 changes: 6 additions & 2 deletions src/composables/useDemoTour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ function abortableScrollTo(
}
// Reduced motion → jump directly to the target Y, no smooth scroll.
if (prefersReducedMotion()) {
window.scrollTo(0, targetY)
// 'instant' bypasses the html { scroll-behavior: smooth } added in
// round-16 — this branch IS the no-animation path.
window.scrollTo({ top: targetY, behavior: 'instant' })
resolve()
return
}
Expand All @@ -428,7 +430,9 @@ function abortableScrollTo(
if (!isPaused()) elapsed += dt
const t = Math.min(1, elapsed / duration)
const eased = easing(t)
window.scrollTo(0, startY + distance * eased)
// 'instant' : the tour eases manually frame-by-frame ; letting the
// CSS smooth behavior re-ease every step would fight it (round-16).
window.scrollTo({ top: startY + distance * eased, behavior: 'instant' })
if (t < 1 && !signal.aborted) {
raf = requestAnimationFrame(tick)
} else if (!signal.aborted) {
Expand Down
Loading
Loading