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 @@
Skip to source
-
+
@@ -156,7 +156,10 @@ Rendered live · Rust → WebAssembly
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();