From 0ca3aa368eccb5587490a7d46b73c5787f625890 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Tue, 13 Oct 2026 08:08:42 +0000 Subject: [PATCH 1/2] Let landing sculpture scroll natively in section grids --- CHANGELOG.md | 1 + .../assets/landing/js/experience.js | 2 +- .../assets/landing/js/scene/database-scene.js | 20 +++-- .../assets/landing/js/scene/landing-frame.js | 7 +- .../assets/landing/js/scroll-progress.js | 65 +++++++------- .../assets/landing/styles/frontend.css | 15 +--- .../assets/landing/styles/scene.css | 6 +- .../assets/landing/styles/sections.css | 84 ++++++------------- examples/official-site/index.sql | 2 + .../sqlpage/templates/landing-page.handlebars | 26 ++++-- tests/end-to-end/landing-touch.spec.ts | 11 +++ tests/end-to-end/landing.spec.ts | 26 ++++++ 12 files changed, 137 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91629566..53bb921d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## v0.47.0 (unreleased) +- The official homepage's database sculpture now scrolls natively with its section after the opening flight, and section layouts reserve its space with CSS grid. - For sites that store their pages in a database, checking for page updates no longer makes other pages wait when the database is busy. In a test, another page loaded in under 1 ms instead of about 250 ms. - Redesigned the official website homepage with live SQLPage examples, an interactive database sculpture, flowing section transitions, responsive layouts, and direct links to documentation and deployment guides. - Loading embedded card content no longer adds duplicate form submission or file validation handlers to existing fields. Fragment initialization also includes the element announcing the fragment. diff --git a/examples/official-site/assets/landing/js/experience.js b/examples/official-site/assets/landing/js/experience.js index 0e49903b..4bc422a2 100644 --- a/examples/official-site/assets/landing/js/experience.js +++ b/examples/official-site/assets/landing/js/experience.js @@ -61,7 +61,7 @@ export function initExperience(root) { setStatus("error"); } } - stopScroll = initScrollProgress(section, mount, state); + stopScroll = initScrollProgress(section, state); syncMotion(); media.addEventListener("change", syncMotion, { signal: events.signal }); root diff --git a/examples/official-site/assets/landing/js/scene/database-scene.js b/examples/official-site/assets/landing/js/scene/database-scene.js index d1f93333..f1380eaa 100644 --- a/examples/official-site/assets/landing/js/scene/database-scene.js +++ b/examples/official-site/assets/landing/js/scene/database-scene.js @@ -320,10 +320,17 @@ export function createDatabaseScene({ if (pointers.size) return; const scale = canvasWidth / 1000; let { left, right, top, bottom } = screenBounds; - left = clamp(canvasLeft + left * scale - 12, 0, innerWidth); - right = clamp(canvasLeft + right * scale + 12, left, innerWidth); - top = clamp(canvasTop + top * scale - 12, 90, innerHeight); - bottom = clamp(canvasTop + bottom * scale + 12, top, innerHeight); + left = canvasLeft + left * scale - 12; + right = canvasLeft + right * scale + 12; + top = canvasTop + top * scale - 12; + bottom = canvasTop + bottom * scale + 12; + // Clip in viewport space, then keep the target in the canvas host's local + // coordinates. Rotation can otherwise put gesture centers off the screen. + const origin = mount.getBoundingClientRect(); + left = clamp(left, -origin.left, innerWidth - origin.left); + right = clamp(right, left, innerWidth - origin.left); + top = clamp(top, 90 - origin.top, innerHeight - origin.top); + bottom = clamp(bottom, top, innerHeight - origin.top); hitArea.style.left = `${left}px`; hitArea.style.top = `${top}px`; hitArea.style.width = `${right - left}px`; @@ -367,10 +374,11 @@ export function createDatabaseScene({ pointers.size > 0 || !lastOrientation.equals(dragQuaternion) || parallax.lengthSq() > 0.000001; + const viewportTop = mount.getBoundingClientRect().top + canvasTop; const inView = state.opacity > 0.05 && - canvasTop + canvasWidth * 0.81 > 0 && - canvasTop + canvasWidth * 0.22 < window.innerHeight; + viewportTop + canvasWidth * 0.81 > 0 && + viewportTop + canvasWidth * 0.22 < window.innerHeight; if (!inView) resetBudget(); if (readySent) { hitArea.inert = !inView; diff --git a/examples/official-site/assets/landing/js/scene/landing-frame.js b/examples/official-site/assets/landing/js/scene/landing-frame.js index af2e6c8e..e4543002 100644 --- a/examples/official-site/assets/landing/js/scene/landing-frame.js +++ b/examples/official-site/assets/landing/js/scene/landing-frame.js @@ -57,12 +57,11 @@ function frameAt(width, height, anchor, portrait, bodyWidth = width * 0.48) { // This small module runs before Three.js downloads, so even the loading layout // uses the same measured letter, portrait flow, and projection as the live scene. -export function layoutLandingFrame(mount, anchor) { - const width = Math.max(1, mount.clientWidth); +export function layoutLandingFrame(viewport, anchor) { + const width = Math.max(1, viewport.clientWidth); const screenHeight = window.innerHeight; const portrait = width < 900 && screenHeight >= width; const shortLandscape = !portrait && screenHeight < 560; - const viewport = mount.closest(".sqlpage-world").querySelector(".viewport"); const section = viewport.parentElement; const heading = anchor?.closest(".hero-copy")?.querySelector(".title-from"); const intro = viewport.querySelector(".intro-row"); @@ -80,7 +79,7 @@ export function layoutLandingFrame(mount, anchor) { `${-Math.max(0, value - screenHeight)}px`, ); height = - portrait || shortLandscape ? value : Math.max(1, mount.clientHeight); + portrait || shortLandscape ? value : Math.max(1, viewport.clientHeight); }; const measureLetter = () => { if (portrait && heading) { diff --git a/examples/official-site/assets/landing/js/scroll-progress.js b/examples/official-site/assets/landing/js/scroll-progress.js index 8130aefd..5926d6c9 100644 --- a/examples/official-site/assets/landing/js/scroll-progress.js +++ b/examples/official-site/assets/landing/js/scroll-progress.js @@ -2,7 +2,7 @@ import { layoutLandingFrame, SCULPTURE_FRAME } from "./scene/landing-frame.js"; /** One sculpture: an opening flight, then page anchors with fading handoffs. */ -export function initScrollProgress(section, mount, state) { +export function initScrollProgress(section, state) { const root = section.closest(".sqlpage-world"); const preview = root.querySelector(".scene-preview"); const layer = root.querySelector(".scene-layer"); @@ -28,13 +28,30 @@ export function initScrollProgress(section, mount, state) { const intro = section.querySelector(".intro-row"); const header = section.querySelector(".site-header"); const ribbon = section.querySelector(".sql-ribbon"); + function dock(host) { + if (layer.parentElement === host) return; + // Keep focus and pointer capture when supported; older browsers retain focus + // explicitly while moving the same canvas and controls, without a new scene. + if (host.moveBefore) host.moveBefore(layer, null); + else { + const focused = layer.contains(document.activeElement) + ? document.activeElement + : null; + host.append(layer); + focused?.focus({ preventScroll: true }); + } + layer.toggleAttribute("data-docked", host !== root); + } function measure() { // Read authored page positions even when earlier sections are held under a cover. root.setAttribute("data-measuring-layout", ""); // Measure the authored hero pose, independent of its current text fade/shift. section.style.setProperty("--progress", "0"); section.style.setProperty("--intro-shift", "0px"); - heroFrame = layoutLandingFrame(mount, root.querySelector(".anchor-letter")); + heroFrame = layoutLandingFrame( + viewport, + root.querySelector(".anchor-letter"), + ); anchors = []; heroRange = Math.max(0, section.offsetHeight - viewport.offsetHeight); const maxScroll = Math.max( @@ -43,39 +60,17 @@ export function initScrollProgress(section, mount, state) { ); for (const stop of stops) { const rect = stop.getBoundingClientRect(); - const sheet = stop.closest(".landing-section"); - const sheetRect = sheet.getBoundingClientRect(); - const nextSheet = sheet.nextElementSibling; // Measure the visible model, not its much larger square canvas. const size = rect.width / SCULPTURE_FRAME.bodyWidth; const bodyHeight = size * (SCULPTURE_FRAME.bounds.bottom - SCULPTURE_FRAME.bounds.top); const bodyTop = rect.top + scrollY; const fadeDistance = Math.min(innerHeight * 0.12, bodyHeight * 0.45); - const heldTop = root.hasAttribute("data-section-motion") - ? rect.top - - sheetRect.top + - (parseFloat(sheet.style.getPropertyValue("--section-stick-top")) || 0) - : -Infinity; - // A held section can leave part of its sculpture on screen. In that case - // its lifetime ends when the next wave covers it, not at its authored Y. - let exitScroll = - heldTop + bodyHeight > 0 ? Infinity : bodyTop + bodyHeight; - if (nextSheet?.matches(".landing-section")) { - const waveHeight = parseFloat( - getComputedStyle(nextSheet).getPropertyValue("--wave-height"), - ); - const coverScroll = - nextSheet.getBoundingClientRect().top + - scrollY - - waveHeight * 0.75 - - (heldTop + bodyHeight * 0.5); - exitScroll = Math.min(exitScroll, coverScroll); - } anchors.push({ element: stop, enterScroll: Math.min(bodyTop - innerHeight, maxScroll - fadeDistance), - exitScroll, + // The next section naturally covers a docked sculpture in its own layer. + exitScroll: Infinity, fadeDistance, bodyHeight, bodyTop, @@ -86,7 +81,7 @@ export function initScrollProgress(section, mount, state) { } heroDockScroll = Math.min( maxScroll, - anchors[0].bodyTop - innerHeight * 0.25, + Math.floor(anchors[0].bodyTop - innerHeight * 0.25), ); anchors[0].enterScroll = -Infinity; // The opening flight supplies its entrance. anchors.at(-1).exitScroll = Infinity; // Keep the closing composition visible. @@ -128,11 +123,10 @@ export function initScrollProgress(section, mount, state) { let sceneOpacity = 1; if (!heroRange && scroll < heroDockScroll) { // Reduced motion keeps the opening sculpture attached to the hero layout. - state.frame = { - ...heroFrame, - top: heroFrame.top + viewport.getBoundingClientRect().top, - }; + dock(viewport); + state.frame = heroFrame; } else if (heroRange && scroll <= heroDockScroll) { + dock(root); const bounds = SCULPTURE_FRAME.bounds; const size = Math.min( (innerWidth * 0.9) / SCULPTURE_FRAME.bodyWidth, @@ -177,8 +171,9 @@ export function initScrollProgress(section, mount, state) { ) index++; const anchor = anchors[index]; - // Follow the actual page anchor, including its section holding beneath the - // next cover. Only the opening flight interpolates position or size. + dock(anchor.element); + // Native scrolling and section stickiness own placement. Geometry here + // controls fading only; the canvas and input bounds stay local to the host. const rect = anchor.element.getBoundingClientRect(); if (state.motion) { sceneOpacity = Math.min( @@ -191,8 +186,8 @@ export function initScrollProgress(section, mount, state) { ); } state.frame = { - left: rect.left - anchor.size * SCULPTURE_FRAME.bounds.left, - top: rect.top - anchor.size * SCULPTURE_FRAME.bounds.top, + left: -anchor.size * SCULPTURE_FRAME.bounds.left, + top: -anchor.size * SCULPTURE_FRAME.bounds.top, size: anchor.size, }; } diff --git a/examples/official-site/assets/landing/styles/frontend.css b/examples/official-site/assets/landing/styles/frontend.css index a5f059ff..4998ee65 100644 --- a/examples/official-site/assets/landing/styles/frontend.css +++ b/examples/official-site/assets/landing/styles/frontend.css @@ -11,7 +11,7 @@ } .deployment-grid { display: grid; - grid-template-columns: 1.6fr 1fr; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.2fr); gap: 25px; } .deployment-section .eyebrow { @@ -22,15 +22,11 @@ margin-bottom: 20px; } .deployment-notes { + grid-column: 1 / -1; align-self: end; text-align: right; margin: 0 0 20px; } -.landing-section .deployment-sculpture { - top: -85px; - right: 5%; - width: 17%; -} .frontend-tabs, .frontend-panels { grid-column: 1 / -1; @@ -391,18 +387,13 @@ padding-bottom: 100px; } .deployment-grid { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.24fr); gap: 20px; } .deployment-section h2 { font-size: clamp(36px, 10.8vw, 52px); max-width: 100%; } - .landing-section .deployment-sculpture { - width: 19%; - top: -10px; - right: -2%; - } .deployment-notes { text-align: left; margin: 0; diff --git a/examples/official-site/assets/landing/styles/scene.css b/examples/official-site/assets/landing/styles/scene.css index 22eee11e..917173a2 100644 --- a/examples/official-site/assets/landing/styles/scene.css +++ b/examples/official-site/assets/landing/styles/scene.css @@ -1,4 +1,4 @@ -/* One fixed sculpture layer, static fallback, and the opening stage’s pinned lifetime. */ +/* One sculpture: a fixed opening flight, then native scrolling in section hosts. */ .sqlpage-world { position: relative; overflow-x: clip; @@ -39,6 +39,10 @@ position: absolute; height: 100svh; } +.scene-layer[data-docked] { + position: absolute; + height: auto; +} .scene-layer .model-interaction[data-ready="true"] { pointer-events: auto; } diff --git a/examples/official-site/assets/landing/styles/sections.css b/examples/official-site/assets/landing/styles/sections.css index cd5de951..6710501a 100644 --- a/examples/official-site/assets/landing/styles/sections.css +++ b/examples/official-site/assets/landing/styles/sections.css @@ -25,39 +25,36 @@ font: 13px / 1.65 var(--mono); color: #5b9ba9; } +.section-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.2fr); + gap: clamp(16px, 3vw, 36px); + align-items: start; + margin: 25px 0 40px; +} +.section-heading h2 { + margin: 0; +} .scene-stop { - position: absolute; - top: 20px; - right: 0; - width: 14%; + position: relative; + width: 100%; aspect-ratio: 0.68; - pointer-events: none; } -.purpose-section .scene-stop { - left: 0; - right: auto; - top: 130px; - width: 18%; +.purpose-section .section-heading { + grid-template-columns: minmax(0, 0.28fr) minmax(0, 1fr); } -.landing-section.purpose-section .section-inner > h2 { - width: 100%; +.purpose-section .scene-stop { + align-self: end; + /* Reserve the full square camera below the visible jewel for every drag pose. */ + aspect-ratio: 0.5; } .purpose-section h2 span { display: block; - text-align: left; - width: 76%; - margin-left: 24%; font-size: clamp(48px, 5.8vw, 76px); line-height: 1.05; } -.landing-section .section-inner > h2, -.database-grid { - width: 80%; -} - -.landing-section .demo-sculpture { - top: -50px; - width: 18%; +.demo-section .section-heading { + grid-template-columns: minmax(0, 1fr) minmax(0, 0.24fr); } .demo-section { padding-top: 70px; @@ -83,7 +80,6 @@ padding-bottom: 105px; } .purpose-section h2 { - min-height: 355px; font-size: clamp(48px, 6.5vw, 89px); } .purpose-intro { @@ -91,8 +87,7 @@ grid-template-columns: 1.2fr 0.5fr 1fr; gap: 45px; align-items: center; - padding-left: 24%; - margin: -100px 0 40px; + margin: 0 0 40px; } .purpose-intro > p:first-child { color: #a6abbb; @@ -374,12 +369,8 @@ a.chip:hover { .landing-section { padding: 80px 6% 70px; } - .purpose-section .scene-stop { - width: 14%; - } .purpose-intro { gap: 20px; - margin-top: -90px; } .purpose-intro > p:first-child { font-size: 17px; @@ -399,18 +390,9 @@ a.chip:hover { } } @media (max-width: 600px) { - .landing-section .scene-stop, - .purpose-section .scene-stop { - width: 8%; - right: -3%; - top: 35px; - } - .landing-section .section-inner > h2, - .purpose-intro, - .purpose-grid, - .database-grid { - width: 100%; - max-width: 100%; + .section-heading { + grid-template-columns: minmax(0, 1fr) minmax(0, 0.1fr); + margin-top: 20px; } .landing-section { padding: 65px clamp(20px, 6vw, 32px); @@ -422,31 +404,14 @@ a.chip:hover { .eyebrow { font-size: 10px; } - .demo-section h2 { - max-width: 78%; - } - .purpose-section .scene-stop { - left: 0; - right: auto; - top: 130px; - width: 22%; - } - .landing-section .demo-sculpture { - width: 19%; - right: 0; - top: 0; - } .purpose-section h2 span { font-size: clamp(28px, 7.1vw, 36px); } .purpose-section h2 { - /* Reserve space below the rotating sculpture before the full-width copy. */ - min-height: max(180px, calc(58px + 38vw)); font-size: clamp(33px, 8.2vw, 50px); text-wrap: balance; } .purpose-intro { - padding-left: 0; grid-template-columns: 1fr 1fr; margin: 35px 0; } @@ -486,7 +451,6 @@ a.chip:hover { padding-bottom: 115px; } .databases-section h2 { - max-width: 88%; font-size: clamp(36px, 10vw, 43px); } .databases-section > .section-inner > p .chip { diff --git a/examples/official-site/index.sql b/examples/official-site/index.sql index aeacae62..c3e15bc8 100644 --- a/examples/official-site/index.sql +++ b/examples/official-site/index.sql @@ -3,6 +3,8 @@ select 'http_header' as component, '; rel="canonical"' as "Link"; -- A full document template uses an empty shell rather than the default site chrome. +-- Section grids host the shared sculpture after its opening flight so it scrolls +-- with the page even when rendering or JavaScript updates fall behind. select 'shell-empty' as component; select 'landing-page' as component, (select count(*) from component) as component_count, diff --git a/examples/official-site/sqlpage/templates/landing-page.handlebars b/examples/official-site/sqlpage/templates/landing-page.handlebars index 1aaad86b..ef9b0ffa 100644 --- a/examples/official-site/sqlpage/templates/landing-page.handlebars +++ b/examples/official-site/sqlpage/templates/landing-page.handlebars @@ -191,8 +191,10 @@ {{> landing-wave}}

-- real SQLPage components, a few lines each

-

Data,
but clickable

- +
+

Data,
but clickable

+
+

-- pick a component

@@ -220,13 +222,15 @@ {{> landing-wave}}

-- Low maintenance, on purpose.

-

A useful tool for them.
A manageable
application for you.

+
+
+

A useful tool for them.
A manageable
application for you.

+

You need a webapp,
but you don't want to maintain a webapp. Build it, or let your coding agent do it. Don't maintain 1000 lines of code.

An unshaved yak

-- The yak stays unshaved.
-- App maintenance is not a hobby.

-

-- What do you have to maintain?

@@ -287,6 +291,7 @@

-- Your files. Your deployment.

No frontend
to build.

+

-- read, edit, and act.
-- forms, tables, charts, and navigation.
-- Tables with manners. CRUD, but polite.

@@ -332,16 +337,17 @@
SQL injectionblocked ×
XSSblocked ×
CSRFblocked ×
{{icon_img "shield-check"}}
-
{{> landing-wave}}

SELECT * FROM your_database;

-

Keep your
database. Add
the app.

+
+

Keep your
database. Add
the app.

+
+

We're seeing other databases. A healthy relationship model.

-
SQLite-- built in PostgreSQL-- native driver @@ -362,8 +368,10 @@ {{> landing-wave}}

-- A tool for them. Not a second job for you.

-

Small team.
Serious tools;

- +
+

Small team.
Serious tools;

+
+
COMMIT; start with a query

DELETE busy;
-- NULL drama.

diff --git a/tests/end-to-end/landing-touch.spec.ts b/tests/end-to-end/landing-touch.spec.ts index d35a57e3..84b75614 100644 --- a/tests/end-to-end/landing-touch.spec.ts +++ b/tests/end-to-end/landing-touch.spec.ts @@ -69,9 +69,20 @@ test.describe("landing sculpture touch gestures", () => { expect(await page.evaluate(() => scrollY)).toBe(0); expect((await canvas.screenshot()).equals(initial)).toBe(false); + const scrollFinished = page.evaluate( + () => + new Promise((resolve) => { + document.addEventListener("scrollend", () => resolve(), { + once: true, + }); + }), + ); await swipe(30, -220); await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(100); await expect(sculpture).toHaveAttribute("data-cancelled", "true"); + // Native placement catches up immediately. Finish the fling before resetting + // scroll, so momentum from the previous gesture cannot move the recovery drag. + await scrollFinished; // A cancelled scroll must release the trackball and allow the next drag. await page.evaluate(() => window.scrollTo(0, 0)); diff --git a/tests/end-to-end/landing.spec.ts b/tests/end-to-end/landing.spec.ts index e9f917df..67755760 100644 --- a/tests/end-to-end/landing.spec.ts +++ b/tests/end-to-end/landing.spec.ts @@ -228,6 +228,32 @@ for (const viewport of [ const anchor = await sample(target.top - height * 0.25 + 1); await expectAtPageAnchor(anchor, index); expect(anchor.opacity).toBe(1); + if (index === 0) { + // Animation callbacks are paused. Native scrolling must move both the + // last painted canvas and its input target with the section anyway. + const movement = await page.evaluate(() => { + const stop = document.querySelector("[data-scene-stop]")!; + const canvas = stop.querySelector("canvas")!; + const input = stop.querySelector(".model-interaction")!; + const before = [stop, canvas, input].map( + (element) => element.getBoundingClientRect().top, + ); + const transform = canvas.style.transform; + window.scrollBy({ top: 32, behavior: "instant" }); + return { + deltas: [stop, canvas, input].map( + (element, index) => + element.getBoundingClientRect().top - before[index]!, + ), + unchanged: canvas.style.transform === transform, + }; + }); + expect(movement.deltas[0]).toBeCloseTo(-32, 1); + expect(movement.deltas[1]).toBeCloseTo(movement.deltas[0]!, 1); + expect(movement.deltas[2]).toBeCloseTo(movement.deltas[0]!, 1); + expect(movement.unchanged).toBe(true); + await sample(target.top - height * 0.25 + 1); + } const next = route[index + 1]; if (!next) continue; // Reaching the top is not a reason to remove an otherwise visible model. From ec898f37e25f527571eb49982f889ddb8416a6ec Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Tue, 13 Oct 2026 08:27:43 +0000 Subject: [PATCH 2/2] Preserve the painted sculpture during docking --- .../assets/landing/js/scroll-progress.js | 15 ++++++++++++++ tests/end-to-end/landing.spec.ts | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/examples/official-site/assets/landing/js/scroll-progress.js b/examples/official-site/assets/landing/js/scroll-progress.js index 5926d6c9..e9a0a42c 100644 --- a/examples/official-site/assets/landing/js/scroll-progress.js +++ b/examples/official-site/assets/landing/js/scroll-progress.js @@ -30,6 +30,7 @@ export function initScrollProgress(section, state) { const ribbon = section.querySelector(".sql-ribbon"); function dock(host) { if (layer.parentElement === host) return; + const before = layer.getBoundingClientRect(); // Keep focus and pointer capture when supported; older browsers retain focus // explicitly while moving the same canvas and controls, without a new scene. if (host.moveBefore) host.moveBefore(layer, null); @@ -41,6 +42,20 @@ export function initScrollProgress(section, state) { focused?.focus({ preventScroll: true }); } layer.toggleAttribute("data-docked", host !== root); + // Preserve the painted frame until the renderer applies the new local pose. + const after = layer.getBoundingClientRect(); + const dx = before.left - after.left; + const dy = before.top - after.top; + const canvas = layer.querySelector("canvas"); + if (canvas) { + const pose = new DOMMatrix(canvas.style.transform || undefined); + pose.e += dx; + pose.f += dy; + canvas.style.transform = pose.toString(); + const input = layer.querySelector(".model-interaction"); + input.style.left = `${parseFloat(input.style.left || "0") + dx}px`; + input.style.top = `${parseFloat(input.style.top || "0") + dy}px`; + } } function measure() { // Read authored page positions even when earlier sections are held under a cover. diff --git a/tests/end-to-end/landing.spec.ts b/tests/end-to-end/landing.spec.ts index 67755760..a1f828ac 100644 --- a/tests/end-to-end/landing.spec.ts +++ b/tests/end-to-end/landing.spec.ts @@ -229,6 +229,26 @@ for (const viewport of [ await expectAtPageAnchor(anchor, index); expect(anchor.opacity).toBe(1); if (index === 0) { + const docking = Math.floor(target.top - height * 0.25); + await sample(docking - 1); + const beforeDock = (await canvas.boundingBox())!; + await canvas.evaluate((canvas) => { + const layer = canvas.closest(".scene-layer")!; + // Observe the move before the renderer's next frame can conceal a blink. + const observer = new MutationObserver(() => { + if (!layer.parentElement?.matches("[data-scene-stop]")) return; + const rect = canvas.getBoundingClientRect(); + canvas.dataset.dockPosition = JSON.stringify([rect.x, rect.y]); + observer.disconnect(); + }); + observer.observe(layer.parentElement!, { childList: true }); + }); + await sample(docking + 1); + const dockPosition = JSON.parse( + (await canvas.getAttribute("data-dock-position"))!, + ) as [number, number]; + expect(Math.abs(dockPosition[0] - beforeDock.x)).toBeLessThan(3); + expect(Math.abs(dockPosition[1] - beforeDock.y)).toBeLessThan(3); // Animation callbacks are paused. Native scrolling must move both the // last painted canvas and its input target with the section anyway. const movement = await page.evaluate(() => {