From b1dcb57077ed70b9138dedca7860588b92a0cc04 Mon Sep 17 00:00:00 2001 From: Marcos Garcia de Lima Date: Wed, 15 Jul 2026 17:07:43 -0300 Subject: [PATCH 1/2] Clear the reveal transform so the mobile downloads sheet anchors to the viewport --- web/js/main.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/js/main.js b/web/js/main.js index 4138354..fcce525 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -484,7 +484,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() { From 86bc87e80f98f3db3aa74c08784994db89794cef Mon Sep 17 00:00:00 2001 From: Marcos Garcia de Lima Date: Thu, 16 Jul 2026 10:37:28 -0300 Subject: [PATCH 2/2] Add gyroscope parallax and a constructivist brush cursor The wagara ground now slides against the plate for layered depth, driven by the pointer on desktop and by device tilt on mobile (iOS asks for the motion sensor on the first gesture). The native cursor gives way to a registration ring with a vermilion stroke that trails the hand's motion: it turns into a rotated seal over pressable things, a brush tip over text entry, and every press stamps the sheet with the house seal. Also anchors the downloads panel to the viewport instead of its animated wrap, which clipped the mobile bottom sheet. --- web/css/style.css | 75 +++++++++++++----- web/index.html | 17 ++-- web/js/main.js | 192 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 226 insertions(+), 58 deletions(-) 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 fcce525..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 }); }); } @@ -528,7 +651,6 @@ async function boot() { poster.hidden = true; wireControls(); - wirePointer(); reveal(); scheduleDrift(FIRST_DRIFT_SECONDS); @@ -538,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();