diff --git a/web/css/style.css b/web/css/style.css index fa3ebf0..7a21c6b 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -132,8 +132,11 @@ a { the engine's pattern: overlapping upper semicircles in three concentric radii, rows offset by half a period. */ .ground { + /* Bleeds past the viewport so the parallax drift never exposes an edge. */ + inset: -16px; background: var(--tint); opacity: 0.05; + will-change: transform; mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cpath fill='none' stroke='white' stroke-width='2' d='M-120,0%20A60,60%200%200%200%200,0%20M-100,0%20A40,40%200%200%200%20-20,0%20M-80,0%20A20,20%200%200%200%20-40,0%20M0,0%20A60,60%200%200%200%20120,0%20M20,0%20A40,40%200%200%200%20100,0%20M40,0%20A20,20%200%200%200%2080,0%20M120,0%20A60,60%200%200%200%20240,0%20M140,0%20A40,40%200%200%200%20220,0%20M160,0%20A20,20%200%200%200%20200,0%20M-60,60%20A60,60%200%200%200%2060,60%20M-40,60%20A40,40%200%200%200%2040,60%20M-20,60%20A20,20%200%200%200%2020,60%20M60,60%20A60,60%200%200%200%20180,60%20M80,60%20A40,40%200%200%200%20160,60%20M100,60%20A20,20%200%200%200%20140,60%20M-120,120%20A60,60%200%200%200%200,120%20M-100,120%20A40,40%200%200%200%20-20,120%20M-80,120%20A20,20%200%200%200%20-40,120%20M0,120%20A60,60%200%200%200%20120,120%20M20,120%20A40,40%200%200%200%20100,120%20M40,120%20A20,20%200%200%200%2080,120%20M120,120%20A60,60%200%200%200%20240,120%20M140,120%20A40,40%200%200%200%20220,120%20M160,120%20A20,20%200%200%200%20200,120'/%3E%3C/svg%3E"); mask-repeat: repeat; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cpath fill='none' stroke='white' stroke-width='2' d='M-120,0%20A60,60%200%200%200%200,0%20M-100,0%20A40,40%200%200%200%20-20,0%20M-80,0%20A20,20%200%200%200%20-40,0%20M0,0%20A60,60%200%200%200%20120,0%20M20,0%20A40,40%200%200%200%20100,0%20M40,0%20A20,20%200%200%200%2080,0%20M120,0%20A60,60%200%200%200%20240,0%20M140,0%20A40,40%200%200%200%20220,0%20M160,0%20A20,20%200%200%200%20200,0%20M-60,60%20A60,60%200%200%200%2060,60%20M-40,60%20A40,40%200%200%200%2040,60%20M-20,60%20A20,20%200%200%200%2020,60%20M60,60%20A60,60%200%200%200%20180,60%20M80,60%20A40,40%200%200%200%20160,60%20M100,60%20A20,20%200%200%200%20140,60%20M-120,120%20A60,60%200%200%200%200,120%20M-100,120%20A40,40%200%200%200%20-20,120%20M-80,120%20A20,20%200%200%200%20-40,120%20M0,120%20A60,60%200%200%200%20120,120%20M20,120%20A40,40%200%200%200%20100,120%20M40,120%20A20,20%200%200%200%2080,120%20M120,120%20A60,60%200%200%200%20240,120%20M140,120%20A40,40%200%200%200%20220,120%20M160,120%20A20,20%200%200%200%20200,120'/%3E%3C/svg%3E"); @@ -506,10 +509,12 @@ a { color: var(--tint); } +/* Anchored to the viewport, never to the (animated) wrap. */ .downloads { - position: absolute; - bottom: calc(100% + 0.6rem); - right: 0; + position: fixed; + z-index: 6; + right: var(--margin); + bottom: calc(var(--margin) + 4.6rem); width: 19.5rem; display: grid; gap: 0.15rem; @@ -670,34 +675,63 @@ a { bottom: calc(var(--margin) - 0.15rem); } -/* ---- Cursor follower: a hanko dot trailing the pointer ---- */ -.follower { +/* ---- The brush cursor: a registration ring, a vermilion stroke that trails + the hand, and hanko stamps on every press. JS adds `cursor-live` only on + fine pointers with motion allowed, replacing the native arrow. ---- */ +.cursor-live, +.cursor-live * { + cursor: none; +} + +.cursor { position: fixed; - z-index: 90; + z-index: 100; top: 0; left: 0; - width: 26px; - height: 26px; - margin: -13px 0 0 -13px; - border: 1.5px solid var(--vermilion); - border-radius: 50%; opacity: 0; pointer-events: none; will-change: transform; } -.follower::before { - content: ""; +.cursor__ring { position: absolute; - top: 50%; - left: 50%; - width: 4px; - height: 4px; - margin: -2px 0 0 -2px; + width: 30px; + height: 30px; + margin: -15px 0 0 -15px; + border: 1.5px solid var(--vermilion); border-radius: 50%; +} +.cursor__bar { + position: absolute; + width: 16px; + height: 3px; + margin: -1.5px 0 0 -8px; background: var(--vermilion); + opacity: 0; +} + +/* The house seal, pressed wherever the visitor presses. */ +.stamp { + position: fixed; + z-index: 99; + top: 0; + left: 0; + width: 1.6rem; + height: 1.6rem; + margin: -0.8rem 0 0 -0.8rem; + display: grid; + place-items: center; + background: var(--vermilion); + color: var(--paper); + font-family: var(--display); + font-weight: 900; + font-size: 0.9rem; + line-height: 1; + border-radius: 3px; + pointer-events: none; } + @media not (pointer: fine) { - .follower { + .cursor { display: none; } } @@ -812,7 +846,8 @@ a { .omakase:active .omakase__seal::after { animation: none; } - .follower { + .cursor, + .stamp { display: none; } } diff --git a/web/index.html b/web/index.html index e6fd6f2..8df18f3 100644 --- a/web/index.html +++ b/web/index.html @@ -75,7 +75,7 @@ - + @@ -156,7 +156,10 @@

- + + + All releases → Read the source, build it yourself - + + diff --git a/web/js/main.js b/web/js/main.js index 4138354..4e4b1c3 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -15,6 +15,8 @@ const TYPE_MIN_MS = 30; const TYPE_JITTER_MS = 40; const PARALLAX_PX = 7; const PARALLAX_DEG = 1.3; +const GROUND_PX = 10; // the wagara ground slides against the plate for depth +const TILT_RANGE = 22; // degrees of device tilt mapped to full parallax // Mirrors `poster_config()` in examples/site.rs, so swapping the poster for the // first live render is invisible. @@ -61,7 +63,8 @@ const hint = document.getElementById('hint'); const downloadToggle = document.getElementById('download-toggle'); const downloadsPanel = document.getElementById('downloads'); const downloadsClose = document.getElementById('downloads-close'); -const followerElement = document.getElementById('follower'); +const ground = document.getElementById('ground'); +const cursorElement = document.getElementById('cursor'); const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const hasGsap = typeof window.gsap !== 'undefined'; @@ -432,46 +435,166 @@ function wireDownloads() { }); } -/* ---- Pointer life: parallax plate, hanko follower ---- */ +/* ---- Motion: the sheet answers the hand (pointer) or the wrist (gyro) ---- */ -function wirePointer() { - if (!animated || !window.matchMedia('(pointer: fine)').matches) return; +let applyDrift = null; - const moveX = window.gsap.quickTo(card, 'x', { duration: 0.7, ease: 'power2.out' }); - const moveY = window.gsap.quickTo(card, 'y', { duration: 0.7, ease: 'power2.out' }); - const tiltX = window.gsap.quickTo(card, 'rotationX', { duration: 0.7, ease: 'power2.out' }); - const tiltY = window.gsap.quickTo(card, 'rotationY', { duration: 0.7, ease: 'power2.out' }); +function wireMotion() { + if (!animated) return; + + const cardX = window.gsap.quickTo(card, 'x', { duration: 0.7, ease: 'power2.out' }); + const cardY = window.gsap.quickTo(card, 'y', { duration: 0.7, ease: 'power2.out' }); + const cardTiltX = window.gsap.quickTo(card, 'rotationX', { duration: 0.7, ease: 'power2.out' }); + const cardTiltY = window.gsap.quickTo(card, 'rotationY', { duration: 0.7, ease: 'power2.out' }); + const groundX = window.gsap.quickTo(ground, 'x', { duration: 1.1, ease: 'power2.out' }); + const groundY = window.gsap.quickTo(ground, 'y', { duration: 1.1, ease: 'power2.out' }); window.gsap.set(card, { transformPerspective: 900 }); - const followX = window.gsap.quickTo(followerElement, 'x', { duration: 0.35, ease: 'power3.out' }); - const followY = window.gsap.quickTo(followerElement, 'y', { duration: 0.35, ease: 'power3.out' }); - let followerShown = false; + applyDrift = (nx, ny) => { + cardX(nx * PARALLAX_PX); + cardY(ny * PARALLAX_PX * 0.7); + cardTiltY(nx * PARALLAX_DEG); + cardTiltX(-ny * PARALLAX_DEG); + // The ground slides the other way, so the sheet reads as layered depth. + groundX(-nx * GROUND_PX); + groundY(-ny * GROUND_PX); + }; window.addEventListener('pointermove', (event) => { - const nx = (event.clientX / window.innerWidth) * 2 - 1; - const ny = (event.clientY / window.innerHeight) * 2 - 1; - moveX(nx * PARALLAX_PX); - moveY(ny * PARALLAX_PX * 0.7); - tiltY(nx * PARALLAX_DEG); - tiltX(-ny * PARALLAX_DEG); - followX(event.clientX); - followY(event.clientY); - if (!followerShown) { - followerShown = true; - window.gsap.set(followerElement, { x: event.clientX, y: event.clientY }); - window.gsap.to(followerElement, { opacity: 0.85, duration: 0.4 }); - } + applyDrift( + (event.clientX / window.innerWidth) * 2 - 1, + (event.clientY / window.innerHeight) * 2 - 1, + ); }); - document.addEventListener('pointerdown', () => { - window.gsap.fromTo( - followerElement, - { scale: 1 }, - { scale: 1.55, duration: 0.35, ease: 'power2.out', yoyo: true, repeat: 1 }, + + bindTilt(); +} + +/* Device tilt: gamma rolls left/right; beta rests near 45° in a held hand. */ +function handleOrientation(event) { + if ((event.gamma == null && event.beta == null) || !applyDrift) return; + applyDrift( + clamp((event.gamma || 0) / TILT_RANGE, -1, 1), + clamp(((event.beta || 45) - 45) / TILT_RANGE, -1, 1), + ); +} + +function enableTilt() { + window.addEventListener('deviceorientation', handleOrientation, { passive: true }); +} + +function bindTilt() { + const Sensor = window.DeviceOrientationEvent; + if (!Sensor) return; + if (typeof Sensor.requestPermission === 'function') { + // iOS gates the motion sensor behind a user gesture. + window.addEventListener( + 'pointerdown', + () => { + Sensor.requestPermission() + .then((permission) => { + if (permission === 'granted') enableTilt(); + }) + .catch(() => {}); + }, + { once: true }, ); + } else { + enableTilt(); + } +} + +/* ---- The brush cursor: registration ring, motion stroke, hanko stamps ---- */ + +function pressStamp(x, y) { + const seal = document.createElement('span'); + seal.className = 'stamp'; + seal.textContent = 'a'; + document.body.append(seal); + window.gsap.set(seal, { x, y, rotation: -14 + Math.random() * 28 }); + window.gsap.fromTo( + seal, + { scale: 1.3, opacity: 0.9 }, + { scale: 1, opacity: 0, duration: 0.9, ease: 'power2.out', onComplete: () => seal.remove() }, + ); +} + +function wireCursor() { + if (!animated) return; + + // Every press stamps the sheet, on any pointer — mouse or touch. + window.addEventListener('pointerdown', (event) => pressStamp(event.clientX, event.clientY)); + + if (!window.matchMedia('(pointer: fine)').matches) return; + root.classList.add('cursor-live'); + + const ring = cursorElement.querySelector('.cursor__ring'); + const bar = cursorElement.querySelector('.cursor__bar'); + const moveX = window.gsap.quickTo(cursorElement, 'x', { duration: 0.16, ease: 'power3.out' }); + const moveY = window.gsap.quickTo(cursorElement, 'y', { duration: 0.16, ease: 'power3.out' }); + + // The stroke: its angle follows the hand's motion, its presence the speed. + const stroke = { angle: 0, alpha: 0, reach: 12 }; + window.gsap.ticker.add(() => { + stroke.alpha *= 0.9; + const radians = (stroke.angle * Math.PI) / 180; + window.gsap.set(bar, { + rotation: stroke.angle, + x: -Math.cos(radians) * stroke.reach, + y: -Math.sin(radians) * stroke.reach, + opacity: stroke.alpha, + }); + }); + + let shown = false; + let last = null; + window.addEventListener('pointermove', (event) => { + moveX(event.clientX); + moveY(event.clientY); + if (last) { + const dx = event.clientX - last.x; + const dy = event.clientY - last.y; + const speed = Math.hypot(dx, dy); + if (speed > 2) { + stroke.angle = (Math.atan2(dy, dx) * 180) / Math.PI; + stroke.alpha = Math.min(0.95, speed / 26); + stroke.reach = 12 + Math.min(10, speed * 0.35); + } + } + last = { x: event.clientX, y: event.clientY }; + if (!shown) { + shown = true; + window.gsap.set(cursorElement, { x: event.clientX, y: event.clientY }); + window.gsap.to(cursorElement, { opacity: 1, duration: 0.35 }); + } }); - document.addEventListener('mouseleave', () => { - followerShown = false; - window.gsap.to(followerElement, { opacity: 0, duration: 0.3 }); + + // The ring answers what it is over: a turned seal over anything pressable, + // a thin brush tip over text entry. + const setRing = (state) => { + const shapes = { + idle: { scaleX: 1, scaleY: 1, rotation: 0, borderRadius: '50%' }, + hover: { scaleX: 1.4, scaleY: 1.4, rotation: 45, borderRadius: '20%' }, + text: { scaleX: 0.14, scaleY: 1.15, rotation: 0, borderRadius: '2px' }, + }; + window.gsap.to(ring, { ...shapes[state], duration: 0.28, ease: 'power3.out', overwrite: 'auto' }); + }; + window.addEventListener('pointerover', (event) => { + const hit = event.target.closest('a, button, input, [role="button"]'); + setRing(!hit ? 'idle' : hit.matches('input, textarea') ? 'text' : 'hover'); + }); + + // Pressing squeezes the whole cursor, like a seal meeting paper. + window.addEventListener('pointerdown', () => { + window.gsap.to(cursorElement, { scale: 0.72, duration: 0.12, ease: 'power2.out' }); + }); + window.addEventListener('pointerup', () => { + window.gsap.to(cursorElement, { scale: 1, duration: 0.35, ease: 'back.out(2.5)' }); + }); + + document.documentElement.addEventListener('mouseleave', () => { + shown = false; + window.gsap.to(cursorElement, { opacity: 0, duration: 0.3 }); }); } @@ -484,7 +607,17 @@ function reveal() { return; } window.gsap.set(items, { y: 10, opacity: 0 }); - window.gsap.to(items, { y: 0, opacity: 1, duration: 0.8, ease: 'power2.out', stagger: 0.12, delay: 0.15 }); + // clearProps: a leftover transform would turn the downloads wrap into the + // containing block of its position: fixed bottom sheet on small viewports. + window.gsap.to(items, { + y: 0, + opacity: 1, + duration: 0.8, + ease: 'power2.out', + stagger: 0.12, + delay: 0.15, + clearProps: 'transform', + }); } function withoutLiveEngine() { @@ -518,7 +651,6 @@ async function boot() { poster.hidden = true; wireControls(); - wirePointer(); reveal(); scheduleDrift(FIRST_DRIFT_SECONDS); @@ -528,4 +660,8 @@ async function boot() { } wireDownloads(); +// Motion and the cursor are page furniture, not engine features — they live +// even when WebAssembly is unavailable and the poster stands in. +wireMotion(); +wireCursor(); boot();