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.

+
- - A dark tidal shelf with a pool in it, and small mechanical creatures digging and building on its floor. - - - - {[{ x: 115, y: 280, rx: 80, ry: 55 }, { x: 315, y: 265, rx: 90, ry: 65 }, { x: 520, y: 275, rx: 75, ry: 50 }, { x: 345, y: 400, rx: 65, ry: 42 }].map(p => ( - - - - - ))} - - {[{ x: 100, y: 290, c: "#b3aea6" }, { x: 300, y: 255, c: "#6b6274" }, { x: 340, y: 290, c: "#3a393e" }, { x: 520, y: 270, c: "#d6d0c2" }, { x: 345, y: 405, c: "#b3aea6" }].map(c => ( - - - - - ))} - - + {/* While the pool loads, and if the view is ever lost: the ring fills like a pool, then the view opens out of it. */} +
+ + +