From 6b963fccc0fe43f6b84303a1bb79bb3c0fa24e52 Mon Sep 17 00:00:00 2001
From: Cameron
Date: Fri, 25 Sep 2026 20:40:07 -0700
Subject: [PATCH] Open the tide pool from a loading screen of the site's ring
and title, where water rises in the ring as the pool loads and the view then
opens out of it like a ripple while the title settles into place; if the
browser drops the 3D view, show the ring again and bring the pool back when
it returns instead of falling back to the still picture, use less graphics
memory on phones, and bring the optional browser test up to date.
---
public/tide-pool.css | 51 ++++++--
public/tide-pool.js | 195 +++++++++++++++++++++++------
scripts/test-tide-pool-browser.mjs | 32 +++--
src/components/tide-pool.tsx | 48 +++----
src/index.tsx | 2 +-
src/tide-pool.test.tsx | 6 +-
6 files changed, 253 insertions(+), 81 deletions(-)
diff --git a/public/tide-pool.css b/public/tide-pool.css
index d196923..8c3b28e 100644
--- a/public/tide-pool.css
+++ b/public/tide-pool.css
@@ -4,7 +4,8 @@ body:has(.tide-pool) { margin: 0; background: #0a0a0a; }
body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { display: none; }
.tide-pool { --ink: #e5e5e5; --muted: #9a949e; --line: #3a373d; --panel: #0c0b0dee; --accent: #d4b9df;
position: relative; height: 100svh; min-height: 560px; overflow: hidden; color: var(--ink);
- --site-text: #e5e5e5; --site-border: #48444b; --site-link: #d4b9df; font-size: 14px; }
+ --site-text: #e5e5e5; --site-border: #48444b; --site-link: #d4b9df; font-size: 14px;
+ --ring: clamp(104px, 15vmin, 148px); --title-size: clamp(2.4rem, 5vw, 5.2rem); --mark-scale: .7; }
.tide-pool a { color: #d3cbd6; text-underline-offset: .3em; text-decoration-color: #a58aad; }
.tide-pool :is(button, a, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tide-pool button { font: inherit; border-radius: 0; cursor: pointer; }
@@ -13,13 +14,12 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di
.tide-heading { position: absolute; z-index: 2; top: max(20px, env(safe-area-inset-top)); left: clamp(16px, 3.4vw, 56px); pointer-events: none; }
.tide-exit { pointer-events: auto; display: inline-flex; min-height: 44px; align-items: center; font-size: .8rem; }
-.tide-heading h1 { margin: .3rem 0 .4rem; font: 400 clamp(2.4rem, 5vw, 5.2rem)/1 var(--site-font-display); letter-spacing: -.01em; text-transform: uppercase; }
+.tide-heading h1, .tide-mark { margin: .3rem 0 .4rem; font: 400 var(--title-size)/1 var(--site-font-display); letter-spacing: -.01em; text-transform: uppercase; }
.tide-heading p { margin: 0; color: #b0a9b5; font-size: .82rem; }
.tide-figure { position: absolute; inset: 0; margin: 0; }
.tide-stage { width: 100%; height: 100%; background: #0a0a0a; }
-.tide-stage canvas, .tide-still { display: block; width: 100%; height: 100%; }
-.tide-stage canvas { position: absolute; inset: 0; touch-action: none; cursor: grab; }
+.tide-stage canvas { display: block; width: 100%; height: 100%; position: absolute; inset: 0; touch-action: none; cursor: grab; }
.tide-stage canvas.is-dragging { cursor: grabbing; }
.tide-stage canvas.is-dropping { cursor: crosshair; }
.tide-stage [hidden] { display: none; }
@@ -27,6 +27,39 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di
.tide-label b { font: 400 1.5rem/1 var(--site-font-display); letter-spacing: 0; margin-right: .45rem; vertical-align: -.2rem; color: #ece6df; }
.tide-stage canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: -6px; }
+/* The veil: the site's ring mark over the dark while the pool loads, filling like a pool, with the title beneath it. When the
+ pool is ready the view opens out of the ring's middle and the title glides up to its place. */
+.tide-veil { position: absolute; inset: 0; z-index: 1; background: #0a0a0a; pointer-events: none; transition: opacity .6s ease; }
+.tide-pool:not([data-veiled]) .tide-veil { opacity: 0; visibility: hidden; transition: opacity .9s ease, visibility 0s .9s; }
+/* Opening through the ring, the scene draws the dark and the ring itself, so the veil keeps only the water to fade. */
+.tide-pool .tide-veil[data-iris] { background: none; opacity: 1; visibility: visible; transition: none; }
+.tide-pool .tide-veil[data-iris="done"] { visibility: hidden; }
+.tide-veil-ring { position: absolute; left: 50%; top: 50%; width: var(--ring); height: var(--ring); margin: calc(var(--ring) / -2) 0 0 calc(var(--ring) / -2); animation: tide-veil-in 1.2s ease both; }
+.tide-veil-ring > svg { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke: #e5e5e5; }
+.tide-veil:is([data-iris="open"], [data-iris="done"]) .tide-veil-ring > svg { visibility: hidden; }
+.tide-veil-water { position: absolute; inset: 18.5%; border-radius: 50%; overflow: hidden; transition: opacity .5s ease; }
+.tide-veil[data-iris] .tide-veil-water { opacity: 0; }
+.tide-pool:is(:not([data-veiled]), [data-veiled="failed"]) :is(.tide-veil-bob, .tide-veil-swell) { animation-play-state: paused; }
+.tide-veil-level { position: absolute; inset: 0; transform: translateY(calc((1 - var(--level, .2)) * 100%)); transition: transform 1.8s cubic-bezier(.4, 0, .2, 1); }
+.tide-veil-bob { position: absolute; left: 0; top: -10%; width: 200%; height: 120%; animation: tide-bob 4.6s ease-in-out infinite alternate; }
+.tide-veil-swell { width: 100%; height: 100%; animation: tide-swell 7s linear infinite; }
+.tide-veil-swell svg { display: block; width: 100%; height: 100%; }
+.tide-veil-sea { fill: #7b62c9; fill-opacity: .24; }
+.tide-veil-crest { fill: none; stroke: #cdbcf0; stroke-width: 1.25px; vector-effect: non-scaling-stroke; }
+.tide-veil-note { position: absolute; left: 16px; right: 16px; top: calc(50% + var(--ring) / 2 + 2.6rem + var(--title-size) * var(--mark-scale)); margin: 0;
+ text-align: center; font-size: .64rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); transition: opacity .4s ease; animation: tide-veil-in 1.2s .3s ease both; }
+.tide-veil[data-iris] .tide-veil-note { opacity: 0; }
+.tide-mark { position: absolute; z-index: 2; left: 50%; top: calc(50% + var(--ring) / 2 + 1.5rem); margin: 0; white-space: nowrap; pointer-events: none;
+ transform: translateX(-50%) scale(var(--mark-scale)); transform-origin: 50% 0; animation: tide-veil-in 1.2s .15s ease both; }
+.tide-pool[data-titled] .tide-mark { visibility: hidden; }
+.tide-pool:not([data-titled]) .tide-heading h1 { opacity: 0; }
+/* Under the veil the instruments wait; they come in as the view opens. */
+.tide-pool[data-veiled] :is(.tide-heading p, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-status, .tide-inspect) { opacity: 0; pointer-events: none; }
+.tide-pool[data-arriving] :is(.tide-heading p, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker) { transition: opacity 1s ease 1.8s; }
+@keyframes tide-veil-in { from { opacity: 0; } }
+@keyframes tide-bob { from { transform: translateY(-3%); } to { transform: translateY(3%); } }
+@keyframes tide-swell { to { transform: translateX(-50%); } }
+
/* Readout: clock, tide and census, set as a hairline table. */
.tide-hud { position: absolute; z-index: 3; top: 76px; right: clamp(16px, 3.4vw, 56px); width: 244px; }
.tide-clock { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; border-top: 1px solid var(--line); }
@@ -120,7 +153,6 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di
.tide-notes[open] summary::after { content: ' −'; }
#tide-describe { min-height: 44px; padding: .5rem .9rem; color: #ddd6e1; background: #0d0b10; border: 1px solid var(--line); font-size: .8rem; }
#tide-description:empty { display: none; }
-.tide-pool noscript { position: absolute; left: 16px; bottom: 240px; max-width: 300px; }
/* When nobody is touching it, the instruments fade and the shelf has the screen to itself. */
.tide-heading, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-inspect, .tide-notes { transition: opacity .5s ease; }
@@ -128,6 +160,9 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di
opacity: 0; pointer-events: none; transition-duration: 2.2s; }
@media (prefers-reduced-motion: reduce) {
.tide-heading, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker, .tide-inspect, .tide-notes { transition: none; }
+ .tide-veil-bob, .tide-veil-swell { animation: none; }
+ .tide-veil-level { transition: none; }
+ .tide-pool[data-arriving] :is(.tide-heading p, .tide-hud, .tide-controls, .tide-nav, .tide-utility, .tide-ticker) { transition: none; }
}
@media (max-width: 1100px) {
@@ -141,7 +176,8 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di
}
@media (max-width: 720px) {
.tide-heading { top: max(8px, env(safe-area-inset-top)); }
- .tide-heading h1 { font-size: 2.3rem; margin-top: .1rem; }
+ .tide-pool { --title-size: 2.3rem; --mark-scale: 1; }
+ .tide-heading h1 { margin-top: .1rem; }
.tide-heading p { display: none; }
.tide-notes { top: 8px; right: 16px; }
.tide-hud { top: calc(max(8px, env(safe-area-inset-top)) + 92px); left: 16px; right: auto; width: auto; }
@@ -173,8 +209,7 @@ body:has(.tide-pool) .theme-toggle, body:has(.tide-pool) .theme-preferences { di
#tide-reset { display: none; }
}
@media (max-height: 620px) and (min-width: 721px) {
- .tide-pool { min-height: 420px; }
- .tide-heading h1 { font-size: 2.2rem; }
+ .tide-pool { min-height: 420px; --title-size: 2.2rem; --mark-scale: .9; }
.tide-heading p { display: none; }
.tide-hud { top: 60px; }
.tide-inspect { bottom: 90px; }
diff --git a/public/tide-pool.js b/public/tide-pool.js
index b57e4c1..a113fe2 100644
--- a/public/tide-pool.js
+++ b/public/tide-pool.js
@@ -5,21 +5,33 @@ import {
const root = document.querySelector('[data-tide-pool]');
const status = document.querySelector('#tide-status');
+// The veil over the pool while it loads: the water in the ring rises as each part arrives.
+const veil = document.querySelector('#tide-veil'), veilNote = document.querySelector('#tide-veil-note');
+const rise = level => veil?.style.setProperty('--level', String(level));
if (root) initialize().catch(error => {
console.error(error);
- status.textContent = 'The still edition. Interactive 3D is unavailable in this browser.';
+ root.dataset.veiled = 'failed'; rise(.5);
+ veilNote.textContent = 'This browser cannot show the pool';
+ status.textContent = 'The pool needs WebGL, which this browser cannot provide.';
});
const $ = s => root.querySelector(s);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
async function initialize() {
- // Local, pinned modules. A failed import or unavailable WebGL leaves the still intact.
+ rise(.4);
+ // Local, pinned modules. A failed import or unavailable WebGL leaves the veil up, with a note.
const T = await import('/public/tide-pool-vendor/three.module.js');
+ const post = await Promise.all(['EffectComposer', 'RenderPass', 'UnrealBloomPass', 'OutputPass', 'ShaderPass']
+ .map(name => import(`/public/tide-pool-vendor/jsm/postprocessing/${name}.js`)))
+ .catch(error => { console.warn('Bloom unavailable; rendering without it.', error); return null; });
+ rise(.6);
const canvas = $('#tide-canvas');
const stage = $('#tide-stage');
const mobile = matchMedia('(pointer: coarse)').matches;
- const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: mobile ? 'low-power' : 'default' });
+ // Drawn through the post-processing chain, the scene never touches the canvas's own buffer except as one flat
+ // full-screen pass, so multisampling it would only cost memory, which phones run short of.
+ const renderer = new T.WebGLRenderer({ canvas, antialias: !post, alpha: false, powerPreference: mobile ? 'low-power' : 'default' });
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = T.PCFSoftShadowMap;
renderer.outputColorSpace = T.SRGBColorSpace;
@@ -48,12 +60,29 @@ async function initialize() {
gl_FragColor = sum;
}`,
};
+ // The veil's ring, drawn over the first frames so the view can open out of it: the pool shows through the ring's
+ // middle, then the opening widens past the edges of the screen and the ring runs out ahead of it like a ripple, its
+ // lines keeping their width as it grows, and fades. In pixels of the finished image, after tone mapping, so the dark
+ // and the ring match the page's exactly.
+ const IRIS = {
+ uniforms: { tDiffuse: { value: null }, uIris: { value: new T.Vector4() }, uBands: { value: new T.Vector4() } },
+ vertexShader: 'varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }',
+ fragmentShader: `
+ uniform sampler2D tDiffuse; uniform vec4 uIris, uBands; varying vec2 vUv;
+ float edge(float d, float r) { return smoothstep(r - .75, r + .75, d); }
+ void main() {
+ // uIris: the centre, the opening's radius, and how soft its edge has grown. uBands: the ring's heavy line, the
+ // gap, and the fine line outside it, and how strongly it still shows.
+ float d = distance(gl_FragCoord.xy, uIris.xy), r = uIris.z, a = r + uBands.x, b = a + uBands.y;
+ vec3 col = mix(texture2D(tDiffuse, vUv).rgb, vec3(.0392), smoothstep(r - uIris.w, r + uIris.w, d));
+ float ring = edge(d, r) - edge(d, a) + edge(d, b) - edge(d, b + uBands.z);
+ gl_FragColor = vec4(mix(col, vec3(.898), ring * uBands.w), 1.);
+ }`,
+ };
// Bloom: lamps, eyes, and glints bleed softly past their edges. Without the add-ons, the scene renders plainly.
- let composer = null, tilt = null;
- try {
- const base = '/public/tide-pool-vendor/jsm/postprocessing/';
- const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }, { OutputPass }, { ShaderPass }] = await Promise.all(
- ['EffectComposer', 'RenderPass', 'UnrealBloomPass', 'OutputPass', 'ShaderPass'].map(name => import(`${base}${name}.js`)));
+ let composer = null, tilt = null, iris = null;
+ if (post) try {
+ const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }, { OutputPass }, { ShaderPass }] = post;
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new T.Vector2(512, 512), mobile ? .35 : .45, .55, .72));
@@ -61,33 +90,38 @@ async function initialize() {
tilt[1].uniforms.uDir.value.set(0, 1);
tilt.forEach(pass => composer.addPass(pass));
composer.addPass(new OutputPass());
+ iris = new ShaderPass(IRIS);
+ iris.enabled = false;
+ composer.addPass(iris);
} catch (error) {
console.warn('Bloom unavailable; rendering without it.', error);
- composer = null; tilt = null;
+ composer = null; tilt = null; iris = null;
}
// Light follows a slow day. Eyes and visors are unlit, so they read as signal lamps at night.
// Image-based light: a generated violet dusk sky, prefiltered into an environment map that lights and reflects on every surface.
- {
- const sky = new T.Scene();
- sky.add(new T.Mesh(new T.SphereGeometry(10, 32, 16), new T.ShaderMaterial({
- side: T.BackSide, depthWrite: false,
- vertexShader: 'varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }',
- fragmentShader: `varying vec3 vDir;
- void main() {
- float up = vDir.y;
- vec3 zenith = vec3(.16, .13, .28), horizon = vec3(.55, .48, .62), ground = vec3(.05, .045, .06);
- vec3 col = up > 0. ? mix(horizon, zenith, pow(up, .6)) : mix(horizon * .5, ground, pow(-up, .4));
- // A soft sun low in the sky, where the key light comes from.
- vec3 sunDir = normalize(vec3(-.7, .45, -.2));
- col += vec3(1., .88, .82) * pow(max(dot(vDir, sunDir), 0.), 24.) * 3.;
- gl_FragColor = vec4(col, 1.);
- }`,
- })));
+ const sky = new T.Scene();
+ sky.add(new T.Mesh(new T.SphereGeometry(10, 32, 16), new T.ShaderMaterial({
+ side: T.BackSide, depthWrite: false,
+ vertexShader: 'varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }',
+ fragmentShader: `varying vec3 vDir;
+ void main() {
+ float up = vDir.y;
+ vec3 zenith = vec3(.16, .13, .28), horizon = vec3(.55, .48, .62), ground = vec3(.05, .045, .06);
+ vec3 col = up > 0. ? mix(horizon, zenith, pow(up, .6)) : mix(horizon * .5, ground, pow(-up, .4));
+ // A soft sun low in the sky, where the key light comes from.
+ vec3 sunDir = normalize(vec3(-.7, .45, -.2));
+ col += vec3(1., .88, .82) * pow(max(dot(vDir, sunDir), 0.), 24.) * 3.;
+ gl_FragColor = vec4(col, 1.);
+ }`,
+ })));
+ function makeEnvironment() {
const pmrem = new T.PMREMGenerator(renderer);
- scene.environment = pmrem.fromScene(sky, .04).texture;
+ const map = pmrem.fromScene(sky, .04).texture;
pmrem.dispose();
+ return map;
}
+ scene.environment = makeEnvironment();
const hemi = new T.HemisphereLight(0xd8ccdf, 0x141118, 1.4);
scene.add(hemi);
const sun = new T.DirectionalLight(0xf4efe8, 3.8);
@@ -1321,7 +1355,7 @@ async function initialize() {
clampGoal();
}
function easeCamera(dt) {
- if (intro && performance.now() - intro.start > 1800 && !intro.released) { intro.released = true; Object.assign(goal, intro.to); }
+ if (intro && performance.now() - intro.start > intro.hold && !intro.released) { intro.released = true; Object.assign(goal, intro.to); }
if (intro?.released && cameraSettled()) intro = null;
const k = reduced.matches ? 1 : 1 - Math.exp(-dt * (intro?.released ? 1.1 : director.on ? .32 : 7));
view.x += (goal.x - view.x) * k; view.z += (goal.z - view.z) * k; view.zoom += (goal.zoom - view.zoom) * k;
@@ -1366,6 +1400,70 @@ async function initialize() {
return best;
}
+ // Lifting the veil. Shaders compile in the background where the browser allows it, and the title's face loads, first.
+ const controls = () => root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]');
+ const prepare = () => Promise.race([
+ Promise.all([Promise.resolve().then(() => renderer.compileAsync(scene, camera)), document.fonts?.ready]).catch(() => {}),
+ new Promise(done => setTimeout(done, 6000)),
+ ]);
+ // The view opens out of the ring: the water in it drains away to show the pool through the ring's middle, then the
+ // opening widens past the edges of the screen as the ring runs out ahead of it like a ripple, and the title rises to
+ // its place.
+ const ring = $('.tide-veil-ring'), HOLD = .6, OPEN = 1.6;
+ let reveal = null;
+ function unveil(message) {
+ const motion = !!iris && !reduced.matches && visible && !document.hidden;
+ reveal = { start: performance.now(), motion };
+ if (motion) iris.enabled = true;
+ frameTime = 1 / 60; qualityClock = 4;
+ canvas.hidden = false;
+ render();
+ if (motion) veil.dataset.iris = 'hole'; else reveal = null;
+ delete root.dataset.veiled;
+ root.dataset.ready = 'true'; root.dataset.arriving = 'true';
+ setTimeout(() => { delete root.dataset.arriving; }, 3000);
+ if (intro) intro.start = performance.now();
+ glideTitle(motion ? HOLD * 1000 : 0);
+ controls().forEach(b => { b.disabled = false; });
+ status.textContent = message;
+ updateButtons(); readout(); schedule();
+ }
+ function openIris() {
+ const t = (performance.now() - reveal.start) / 1000;
+ if (t >= HOLD + OPEN) { endReveal(); return; }
+ if (t >= HOLD && veil.dataset.iris !== 'open') veil.dataset.iris = 'open';
+ const { width, height } = stage.getBoundingClientRect(), ratio = renderer.getPixelRatio();
+ const from = ring.getBoundingClientRect().width / 2 * .66, to = Math.hypot(width, height) / 2 + 2;
+ const u = clamp((t - HOLD) / OPEN, 0, 1), e = u < .5 ? 4 * u ** 3 : 1 - (2 - 2 * u) ** 3 / 2, r = from + (to - from) * e;
+ // The ring's proportions: its middle is .66 of its radius, the heavy line runs to .814, the fine one from .96 to 1.
+ const R = from / .66 * ratio, thin = 1 - .5 * e;
+ iris.uniforms.uIris.value.set(width / 2 * ratio, height / 2 * ratio, r * ratio, (.75 + (r - from) * .08) * ratio);
+ iris.uniforms.uBands.value.set(.154 * R * thin, .146 * R * (1 + 2 * e), .04 * R * thin, 1 - clamp((e - .05) / .6, 0, 1));
+ }
+ function endReveal() {
+ reveal = null;
+ if (iris) iris.enabled = false;
+ if (veil.dataset.iris) veil.dataset.iris = 'done';
+ wake();
+ }
+ // The title under the ring glides up to its place at the top as the view opens.
+ function glideTitle(delay) {
+ const mark = $('.tide-mark'), title = $('.tide-heading h1');
+ if (root.dataset.titled) return;
+ if (!mark?.animate || reduced.matches) { root.dataset.titled = 'true'; return; }
+ const text = element => { const range = document.createRange(); range.selectNodeContents(element); return range.getBoundingClientRect(); };
+ const from = text(mark), to = text(title);
+ mark.style.transform = 'none'; mark.style.transformOrigin = '0 0';
+ const box = mark.getBoundingClientRect(), plain = text(mark);
+ const place = rect => {
+ const k = rect.width / plain.width;
+ return `translate(${rect.left - box.left - (plain.left - box.left) * k}px, ${rect.top - box.top - (plain.top - box.top) * k}px) scale(${k})`;
+ };
+ const done = () => { root.dataset.titled = 'true'; };
+ mark.animate([{ transform: place(from) }, { transform: place(to) }], { duration: 1500, delay, easing: 'cubic-bezier(.65, 0, .35, 1)', fill: 'both' })
+ .finished.then(done, done);
+ }
+
const pauseButton = $('#tide-pause'), soundButton = $('#tide-sound');
let paused = reduced.matches, visible = true, lost = false, frame = 0, previous = 0, dirty = true;
let audio = null, sound = false;
@@ -1461,6 +1559,7 @@ async function initialize() {
const band = .13 * clamp(stage.clientHeight / Math.max(1, stage.clientWidth) * .75, 1, 1.8);
tilt.forEach(pass => { pass.uniforms.uFocus.value = at; pass.uniforms.uAmount.value = amount; pass.uniforms.uBand.value = band; });
}
+ if (reveal?.motion) openIris();
if (composer) composer.render(); else renderer.render(scene, camera);
placeLabels();
dirty = false;
@@ -1704,7 +1803,7 @@ async function initialize() {
aim(dt);
easeCamera(dt);
render();
- if (!paused || !cameraSettled()) frame = requestAnimationFrame(loop);
+ if (!paused || !cameraSettled() || reveal) frame = requestAnimationFrame(loop);
else lastFrame = 0;
}
// Where the camera wants to be this frame: on the machine being followed, or wherever the wandering eye has settled.
@@ -1985,12 +2084,32 @@ async function initialize() {
document.addEventListener('visibilitychange', schedule);
new IntersectionObserver(entries => { visible = entries[0].isIntersecting; schedule(); }, { threshold: .05 }).observe(stage);
new ResizeObserver(resize).observe(stage);
+ // If the browser takes the 3D view away, the veil comes back until it is returned, and then the view opens out of the
+ // ring again, where it was.
+ let restoring = 0;
canvas.addEventListener('webglcontextlost', e => {
e.preventDefault(); lost = true; schedule();
- delete root.dataset.ready;
- canvas.hidden = true; root.querySelector('.tide-still').removeAttribute('hidden');
- root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]').forEach(b => { b.disabled = true; });
- status.textContent = 'The 3D view was interrupted. Reload the page to return to the pool.';
+ if (reveal) endReveal();
+ delete root.dataset.ready; delete veil.dataset.iris;
+ root.dataset.veiled = 'lost'; rise(.5);
+ veilNote.textContent = 'Bringing the pool back';
+ controls().forEach(b => { b.disabled = true; });
+ status.textContent = 'The 3D view was interrupted. Bringing it back.';
+ clearTimeout(restoring);
+ restoring = setTimeout(() => {
+ veilNote.textContent = 'The pool went dark. Reload the page to return to it';
+ status.textContent = 'The 3D view was interrupted. Reload the page to return to the pool.';
+ }, 8000);
+ });
+ canvas.addEventListener('webglcontextrestored', async () => {
+ clearTimeout(restoring);
+ veilNote.textContent = 'Bringing the pool back';
+ scene.environment = makeEnvironment();
+ resize();
+ await prepare();
+ if (renderer.getContext().isContextLost()) return;
+ lost = false;
+ unveil('The pool is back.');
});
window.addEventListener('pagehide', () => { visible = false; schedule(); });
window.addEventListener('pageshow', () => { visible = true; schedule(); });
@@ -2005,16 +2124,16 @@ async function initialize() {
return n > a.n ? { c, n } : a;
}, { c: null, n: 0 }).c;
if (busiest) {
- intro = { start: performance.now(), to: { ...goal } };
+ intro = { start: Infinity, hold: 1000, to: { ...goal } };
Object.assign(view, { x: busiest.x, z: busiest.z, zoom: 2.6 }); Object.assign(goal, view);
placeCamera();
}
}
- root.dataset.ready = 'true';
- canvas.hidden = false; root.querySelector('.tide-still').setAttribute('hidden', '');
- root.querySelectorAll('.tide-controls button, .tide-nav button, .tide-utility button, #tide-describe, [data-species]').forEach(b => { b.disabled = false; });
- status.textContent = paused ? 'Paused for reduced motion. Press Play when you want to watch.' : 'Drag to look around. Tap a machine to follow it.';
- updateButtons(); readout(); schedule();
+ rise(.85);
+ await prepare();
+ // Give the ring a moment to settle in before it opens, so a quick load doesn't flash it.
+ await new Promise(done => setTimeout(done, Math.max(0, 1300 - performance.now())));
+ if (!lost) unveil(paused ? 'Paused for reduced motion. Press Play when you want to watch.' : 'Drag to look around. Tap a machine to follow it.');
// When nobody is touching it, the instruments fade and the shelf has the screen to itself. Any touch brings them back.
let idleTimer = 0;
const busy = () => root.querySelector('.tide-notes[open], :is(.tide-heading, .tide-hud, .tide-inspect, .tide-controls, .tide-nav, .tide-utility):hover') ||
diff --git a/scripts/test-tide-pool-browser.mjs b/scripts/test-tide-pool-browser.mjs
index 6aa8e15..6d08941 100644
--- a/scripts/test-tide-pool-browser.mjs
+++ b/scripts/test-tide-pool-browser.mjs
@@ -19,10 +19,12 @@ try {
const page = await context.newPage();
page.on('pageerror', e => errors.push(String(e)));
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
- const response = await page.goto(`${base}/tide-pool?immersive-review=2`, { waitUntil: 'networkidle' });
+ const response = await page.goto(`${base}/tide-pool?immersive-review=2&shelf=41`, { waitUntil: 'networkidle' });
assert.equal(response.status(), 200);
assert.equal(response.headers()['x-robots-tag'], undefined);
await page.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled);
+ // The view opens out of the veil's ring, and the title settles in its place.
+ await page.waitForFunction(() => document.querySelector('[data-tide-pool]').dataset.titled && !['hole', 'open'].includes(document.querySelector('#tide-veil').dataset.iris));
assert.equal(await page.evaluate(() => window.__audio.length), 0);
for (const [width, height] of [[1440,900],[1024,768],[768,1024],[393,852],[320,720],[844,430]]) {
await page.setViewportSize({ width, height });
@@ -44,14 +46,15 @@ try {
assert.match(await page.locator('#tide-instructions').textContent(), new RegExp(kind));
await page.locator('#tide-canvas').focus();
await page.keyboard.press('ArrowRight'); await page.keyboard.press('Enter');
- assert.match(await page.locator('#tide-status').textContent(), /dropped in pool/);
+ assert.match(await page.locator('#tide-status').textContent(), /dropped in pool|blooms in pool/);
}
assert.equal(await page.locator('[data-tide-pool]').getAttribute('data-engaged'), 'true');
await page.screenshot({ path: `${out}/offering-response.png` });
// Follow a machine from the census, then release it.
- await page.locator('[data-species="breaker"]').click();
+ await page.locator('.tide-census summary').click();
+ await page.locator('[data-species="scraper"]').click();
assert.ok(await page.locator('#tide-inspect').isVisible());
- assert.match(await page.locator('#tide-inspect-title').textContent(), /^BR-\d{3}$/);
+ assert.match(await page.locator('#tide-inspect-title').textContent(), /^SC-\d{3}$/);
await page.screenshot({ path: `${out}/following.png` });
await page.locator('#tide-release').click();
assert.ok(await page.locator('#tide-inspect').isHidden());
@@ -67,6 +70,9 @@ try {
await page.locator('#tide-pause').click();
await page.waitForFunction(() => window.__audio[0]?.state === 'suspended');
await page.mouse.move(2, 2);
+ // Paused, frames stop once the camera has come to rest.
+ await page.waitForFunction(() => { const { goal, view } = window.__tidePool; return Math.abs(goal.x - view.x) < .002 && Math.abs(goal.z - view.z) < .002 && Math.abs(goal.zoom - view.zoom) < .002; });
+ await page.waitForTimeout(200);
const still = await page.locator('#tide-canvas').screenshot();
await page.waitForTimeout(400);
assert.ok(still.equals(await page.locator('#tide-canvas').screenshot()));
@@ -102,7 +108,7 @@ try {
await context.close();
const touch = await browser.newContext({ viewport: { width: 393, height: 852 }, isMobile: true, hasTouch: true, reducedMotion: 'reduce' });
const m = await touch.newPage();
- await m.goto(`${base}/tide-pool`, { waitUntil: 'networkidle' });
+ await m.goto(`${base}/tide-pool?shelf=41`, { waitUntil: 'networkidle' });
await m.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled);
assert.equal(await m.locator('#tide-pause').textContent(), 'Play');
await m.locator('#tide-canvas').focus();
@@ -118,16 +124,24 @@ try {
await m.locator('#tide-describe').tap();
assert.match(await m.locator('#tide-description').textContent(), /The shelf holds/);
await m.locator('.tide-notes summary').tap();
- await m.evaluate(() => document.querySelector('#tide-canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext());
+ // Losing the 3D context brings the veil back; when the browser returns it, the pool comes back where it was.
+ await m.evaluate(() => { window.__lose = document.querySelector('#tide-canvas').getContext('webgl2').getExtension('WEBGL_lose_context'); window.__lose.loseContext(); });
await m.waitForFunction(() => document.querySelector('#tide-status').textContent.includes('interrupted'));
- assert.ok(await m.locator('.tide-still').isVisible());
+ assert.equal(await m.locator('[data-tide-pool]').getAttribute('data-veiled'), 'lost');
+ assert.ok(await m.locator('.tide-veil-ring').isVisible());
assert.ok(await m.locator('[data-tool="brass"]').isDisabled());
+ await m.evaluate(() => window.__lose.restoreContext());
+ await m.waitForFunction(() => !document.querySelector('[data-tool="brass"]').disabled);
+ assert.equal(await m.locator('[data-tide-pool]').getAttribute('data-veiled'), null);
+ assert.match(await m.locator('#tide-status').textContent(), /back/);
await touch.close();
const fallback = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 393, height: 852 } });
const f = await fallback.newPage(); await f.goto(`${base}/tide-pool`);
- assert.ok(await f.locator('.tide-still').isVisible());
+ assert.ok(await f.locator('.tide-veil-ring').isVisible());
+ assert.ok(await f.locator('noscript .tide-veil-note').isVisible());
+ assert.equal(await f.locator('noscript .tide-veil-note').textContent(), 'The pool needs JavaScript to fill');
await f.screenshot({ path: `${out}/no-javascript.png` });
await fallback.close();
- await writeFile(`${out}/receipt.json`, JSON.stringify({ base, at: new Date().toISOString(), sizes, errors, passed: ['fullscreen geometry', 'desktop and mobile touch', 'keyboard', 'material-specific instructions', 'instant offering response', 'follow and release', 'zoom and pool navigation', 'pause pixel equality', 'opt-in audio', 'reduced motion', 'context loss', 'no-JS fallback'] }, null, 2));
+ await writeFile(`${out}/receipt.json`, JSON.stringify({ base, at: new Date().toISOString(), sizes, errors, passed: ['fullscreen geometry', 'desktop and mobile touch', 'keyboard', 'material-specific instructions', 'instant offering response', 'follow and release', 'zoom and pool navigation', 'pause pixel equality', 'opt-in audio', 'reduced motion', 'context loss and return', 'no-JS veil'] }, null, 2));
console.log(JSON.stringify({ passed: true, base, out, sizes }));
} finally { await browser.close(); }
diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx
index 19ad753..c5063aa 100644
--- a/src/components/tide-pool.tsx
+++ b/src/components/tide-pool.tsx
@@ -9,6 +9,9 @@ const SPECIES = [
{ id: "artificer", code: "AR", name: "Artificer", note: "Builds from brass and salvage: structures in the water, one small part at a time, and sluice gates and beacons. Each has its own way of building, which its children inherit. Mends parts that breakers and storms work loose." },
];
+// Two swells across twice the ring's width, so sliding it along by half repeats seamlessly.
+const WAVE = "M0 10C16.5 6 33.5 6 50 10S83.5 14 100 10S133.5 6 150 10S183.5 14 200 10";
+
const TOOLS = [
{ id: "look", label: "Look" },
{ id: "brass", label: "Brass" },
@@ -19,38 +22,36 @@ const TOOLS = [
export function TidePoolContent() {
return (
-
+ ← Other
Tide pool
One pool on a tidal shelf. Everything else is dug.
+
Tide pool
-
+ {/* While the pool loads, and if the view is ever lost: the ring fills like a pool, then the view opens out of it. */}
+
+
+
+
+
+
+
+
Filling the pool
+
+
@@ -124,8 +125,7 @@ export function TidePoolContent() {
-
Preparing the pool. A still view remains if 3D is unavailable.
-
+
Preparing the pool.
Inside the pool
Everything here is one surface: a shelf of ground that the tide washes over every four minutes. The tide's reach swells and shrinks with the moon, which waxes and wanes over forty minutes: springs come at full and new moon, so some low waters lay the shelf bare, and the rock stays dark where the water has just left it. Full-moon nights are silver; on moonless nights the plankton, the stars in still water, and the lit windows show best. Wherever the ground dips and cannot drain as the tide falls, water stays behind, and that is a pool. There is one pool to begin with. Borers dig burrows and tunnels, masons fill hollows and raise walls, and breakers knock walls down, so the pools grow, join, drain, and form on their own. Pools are named as they appear. Artificers build structures in the water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, solid plates, decks that reach out over the water, pods to live in, gears and tide wheels the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. A structure's footing is solid: the water goes around it, and the ground under it holds. Scrapers sleep in the pods, collectors keep hoards on the decks, and the tabs circle below and dart in among the frames when something startles them. Breakers work parts loose at the footing and artificers make them good again; when a footing gives way, everything it held falls. A tide wheel on a structure turns only while the water runs through it and stores its charge; the structure's lamps and windows burn at night only with a charged wheel nearby. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full, and a beacon, powered by a wheel, burns at night, feeds the plankton around it, and draws the tabs. Now and then a storm comes in off the sea: the water rises and runs rough, the waves batter whatever stands at the waterline, and the fish hide among the frames. Day turns to night every seven minutes, and the film on each floor grows only in wet light. At night the plankton glows wherever something moves through it.
@@ -141,7 +141,7 @@ export function TidePoolContent() {
{/* Lets the three.js add-ons resolve the same pinned module the page already uses. */}
-
+
);
}
diff --git a/src/index.tsx b/src/index.tsx
index a12b7ad..964eee1 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -187,7 +187,7 @@ app.get("/other", async (c) => {
// Commissioned public artwork, listed under Other. Unlike the artifacts, it is indexable.
app.get("/tide-pool", async (c) => {
const stream = renderToReadableStream(
-
+
);
diff --git a/src/tide-pool.test.tsx b/src/tide-pool.test.tsx
index 75ff61c..534b066 100644
--- a/src/tide-pool.test.tsx
+++ b/src/tide-pool.test.tsx
@@ -12,8 +12,12 @@ test("public tide pool keeps the shell and progressive enhancement with an immer
assert.ok(html.includes('Tap the water to drop brass.'));
assert.ok(!html.includes("page-container-wide"));
assert.ok(!html.includes('name="robots"'));
- assert.ok(html.includes("tide-still-title"));
+ assert.ok(html.includes('data-veiled="loading"'));
+ assert.ok(html.includes('class="tide-veil-ring"'));
+ assert.ok(html.includes('class="tide-mark" aria-hidden="true">Tide pool