diff --git a/.changeset/scene-scroll-push.md b/.changeset/scene-scroll-push.md new file mode 100644 index 00000000..524cdc71 --- /dev/null +++ b/.changeset/scene-scroll-push.md @@ -0,0 +1,7 @@ +--- +'@camp-dev/shaders': minor +--- + +Add `scrollPush` to `ShaderScene`. With it on, a touch that scrolls the page over the canvas pushes the scene's cursor effects at the finger, and a fling keeps pushing until the page stops. The scene listens passively and never cancels a touch, so the page scrolls and zooms as it would without the scene. A touch then reaches cursor effects only this way, because touch pointer moves no longer move the cursor. A mouse works as before. `CursorRipple` stirs its water with the push. `CursorSpotlight` doesn't read the push, so it ignores touch in a scene with `scrollPush` on. + +`CursorInput` gains `setScrollPush`, `getPush`, `isPushing`, and `onPushStart`. The signal `useCursor` returns gains `getPush`, which reads the frame's push stroke. The new `PushStroke` type has the shape of a wave field's stroke. While `scrollPush` is on, `ShaderScene` ticks its shared `CursorInput` once per frame, ahead of the cursor effects that read it, and keeps drawing while a touch pushes. diff --git a/GLOSSARY.md b/GLOSSARY.md index f7dd2647..e83ad439 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -34,6 +34,10 @@ _Avoid_: Interaction, cursor ability, hover effect An Effect driven by the cursor Input that works over any scene with no help from the components beneath it. CursorSpotlight and CursorRipple are two. _Avoid_: Interactive shader, cursor component, drop-in +**Scroll push**: +The push a scene's cursor effects feel from a touch that scrolls the page over the canvas, at the finger and on through the fling. A scene opts in, and the page scrolls exactly as it would without it. +_Avoid_: Hold to interact, touch drag, scroll drag + ### Rendering **Render on demand**: diff --git a/apps/docs-tests/docs/scroll-push.spec.ts b/apps/docs-tests/docs/scroll-push.spec.ts new file mode 100644 index 00000000..acbe0854 --- /dev/null +++ b/apps/docs-tests/docs/scroll-push.spec.ts @@ -0,0 +1,78 @@ +import { type CDPSession, expect, type Page, test } from '@playwright/test'; + +/** + * Scroll push in a real browser (SHA-225): a CDP touch that drags on the + * touch probe's opted-in scene scrolls the page, and the probe's recorder + * logs the push frames that reached the scene. SwiftShader draws slowly on + * CI and the push comes once per frame, so the drag takes its time and every + * check polls. + */ +test.use({ + hasTouch: true, + isMobile: true, + viewport: { width: 412, height: 839 }, + deviceScaleFactor: 1, +}); + +const scene = (page: Page) => page.locator('[data-scroll-push-scene]'); + +const scrollY = (page: Page) => page.evaluate(() => window.scrollY); + +const touch = ( + client: CDPSession, + type: 'touchStart' | 'touchMove' | 'touchEnd', + points: Array<{ x: number; y: number; id: number }>, +) => client.send('Input.dispatchTouchEvent', { type, touchPoints: points }); + +/** + * Opens the probe with the top scene in view, once it has drawn. A scene + * pauses while it is off screen, and this one starts below the fold, so it + * scrolls in before the wait. + */ +async function open(page: Page): Promise<{ client: CDPSession; x: number; y: number }> { + await page.goto('/dev/touch-probe'); + await scene(page).evaluate((element) => element.scrollIntoView({ block: 'center' })); + await expect(scene(page)).toHaveAttribute('data-ready', 'true', { timeout: 60_000 }); + const box = await scene(page).boundingBox(); + + if (!box) throw new Error('The scroll-push scene has no box.'); + + return { + client: await page.context().newCDPSession(page), + x: box.x + box.width / 2, + y: box.y + box.height * 0.8, + }; +} + +/** The push frames the probe logged for its first contact, once it settles. */ +async function firstContactPushFrames(page: Page): Promise { + const report = await page.getByLabel('Report').inputValue(); + const match = /#1: .* (\d+) push frames/.exec(report); + + return match ? Number(match[1]) : null; +} + +test('a drag on an opted-in scene scrolls the page and pushes the scene', async ({ page }) => { + const { client, x, y } = await open(page); + const before = await scrollY(page); + + await touch(client, 'touchStart', [{ x, y, id: 1 }]); + for (let step = 1; step <= 20; step += 1) { + await touch(client, 'touchMove', [{ x, y: y - step * 10, id: 1 }]); + await page.waitForTimeout(50); + } + await touch(client, 'touchEnd', []); + + await expect.poll(() => scrollY(page)).toBeGreaterThan(before + 50); + await expect.poll(() => firstContactPushFrames(page), { timeout: 30_000 }).toBeGreaterThan(0); +}); + +test('a tap on an opted-in scene pushes nothing', async ({ page }) => { + const { client, x, y } = await open(page); + + await touch(client, 'touchStart', [{ x, y, id: 1 }]); + await page.waitForTimeout(200); + await touch(client, 'touchEnd', []); + + await expect.poll(() => firstContactPushFrames(page), { timeout: 30_000 }).toBe(0); +}); diff --git a/apps/docs/content/docs/react/api.mdx b/apps/docs/content/docs/react/api.mdx index 265286b5..b2291fac 100644 --- a/apps/docs/content/docs/react/api.mdx +++ b/apps/docs/content/docs/react/api.mdx @@ -33,6 +33,7 @@ import { ShaderScene } from '@camp-dev/shaders' | `maxFPS` | `number` | — | Sets the frame cap, the most frames per second the scene draws. With no frame cap, the scene draws at the display's refresh rate. Zero, a negative number, or a non-finite number sets no frame cap. A change applies from the next frame. See [the performance guide](/guides/perf#draw-fewer-frames-with-maxfps). | | `gamut` | `'auto' \| 'srgb' \| 'p3'` | `'auto'` | Output color gamut. `'auto'` detects via `(color-gamut: p3)` and re-resolves when the window moves to another display. See [the color guide](/guides/color#gamut--what-the-output-can-hold). | | `paused` | `boolean` | `false` | Freezes the scene on its current frame. Time stops while it is paused, so it resumes on the frame it stopped on. | +| `scrollPush` | `boolean` | `false` | Lets a touch that scrolls the page over the canvas push the scene's cursor effects, at the finger and on through the fling. Only effects that read the push, such as `CursorRipple`, feel it. The scene only listens, so the page scrolls and zooms exactly as it would without it. A touch then reaches cursor effects only as a push, so `CursorSpotlight` ignores touch. A mouse works as before. A change applies straight away. | | `style` | `CSSProperties` | — | Forwarded to the canvas container. | | `className` | `string` | — | Forwarded to the canvas container. | | `children` | `ReactNode` | — | One or more Shaders components. | @@ -59,7 +60,7 @@ function Plane() { All three return signal-shaped objects compatible with [animation props](/guides/animation): -- **`useCursor()`** → `CursorSignal` with `value` in `[0, 1]` normalized to the canvas bounding rect, plus a `presence` signal from 0 to 1 that eases in after the pointer enters the canvas and out after it leaves. Calls in one scene share one pointer input. Options: `smoothing`, `initial`, `enabled`, `element`, `target`, `onMove`. +- **`useCursor()`** → `CursorSignal` with `value` in `[0, 1]` normalized to the canvas bounding rect, plus a `presence` signal from 0 to 1 that eases in after the pointer enters the canvas and out after it leaves. Calls in one scene share one pointer input. In a scene with `scrollPush` on, `getPush()` returns the frame's push stroke: a segment in canvas units that ends at the finger, or `null` when no touch pushed. Options: `smoothing`, `initial`, `enabled`, `element`, `target`, `onMove`. - **`useScroll()`** → `ScrollSignal` with normalized scroll progress for the page. - **`useResize()`** → `ResizeSignal` with current canvas dimensions in pixels. diff --git a/apps/docs/src/app/dev/touch-probe/page.dev.tsx b/apps/docs/src/app/dev/touch-probe/page.dev.tsx new file mode 100644 index 00000000..ce9081a3 --- /dev/null +++ b/apps/docs/src/app/dev/touch-probe/page.dev.tsx @@ -0,0 +1,11 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +// three/webgpu references `self` at module load and cannot SSR, so the probe +// scene is loaded client-only. Dev route: invisible to production builds. +const ProbeScene = dynamic(() => import('./probe-scene'), { ssr: false }); + +export default function TouchProbePage() { + return ; +} diff --git a/apps/docs/src/app/dev/touch-probe/probe-scene.tsx b/apps/docs/src/app/dev/touch-probe/probe-scene.tsx new file mode 100644 index 00000000..2ef4af1a --- /dev/null +++ b/apps/docs/src/app/dev/touch-probe/probe-scene.tsx @@ -0,0 +1,119 @@ +'use client'; + +// Real-device probe for scroll push (SHA-225): two Reactive-card-sized +// scenes of LiquidGradient under CursorRipple in a page that scrolls. The +// top one has `scrollPush` on, and a recorder (./push-recorder.tsx) logs each +// contact for the report; the bottom one keeps today's touch behavior. +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { CursorRipple, LiquidGradient, ShaderScene } from '@camp-dev/shaders'; + +import styles from './probe.module.css'; +import { type PushContactRecord, type PushPhase, PushRecorder } from './push-recorder'; +import { formatPushContact } from './report'; + +/** Most contacts the report keeps, oldest dropped first. */ +const MAX_CONTACTS = 60; + +// The renderer picks WebGPU when the page can reach it, which needs a secure +// context, and falls back to WebGL2 otherwise. +const describeDevice = () => + [ + navigator.userAgent, + `${window.innerWidth}x${window.innerHeight} CSS px`, + `DPR ${window.devicePixelRatio}`, + `${navigator.maxTouchPoints} touch points`, + window.isSecureContext ? 'secure context' : 'insecure context', + 'gpu' in navigator ? 'WebGPU available' : 'no WebGPU, so WebGL2', + ].join(' | '); + +export default function ProbeScene() { + const [phase, setPhase] = useState('idle'); + const [lines, setLines] = useState([]); + const [taps, setTaps] = useState(0); + // Set once the top scene has drawn, so a test knows its input is live. + const [ready, setReady] = useState(false); + const [device] = useState(describeDevice); + const reportRef = useRef(null); + + const handleContact = useCallback((record: PushContactRecord) => { + setLines((previous) => [...previous, formatPushContact(record)].slice(-MAX_CONTACTS)); + }, []); + + const report = useMemo(() => [`Device: ${device}`, '', ...lines].join('\n'), [device, lines]); + + // The page may be served over plain HTTP on the local network, where the + // clipboard API does not exist, so selecting the text for the system copy + // menu is the one way that works everywhere. + const selectReport = () => { + reportRef.current?.focus(); + reportRef.current?.select(); + }; + + return ( +
+

Touch probe

+

Try these on the top scene, and watch the badge in its corner.

+
    +
  1. + Scroll by dragging on the scene, slowly, then fast. The water should stir as you go. +
  2. +
  3. Flick and let go. The water keeps stirring until the page stops.
  4. +
  5. Tap, and rest a finger without scrolling. Nothing should stir.
  6. +
  7. Drag sideways, and diagonally. The water should follow the finger.
  8. +
  9. Pinch on the scene. The page should zoom, and nothing should stir.
  10. +
  11. + Tap the button over the scene, and swipe from it. It should tap and scroll as normal. +
  12. +
  13. + Touch the lower scene, which keeps today's behavior. Neither scene should react to a + finger on the other. +
  14. +
+

Then select the report and paste it into SHA-225.

+
+

Scroll push

+
+ setReady(true)} scrollPush> + + + + + + {phase} + + +
+

Today's behavior

+
+ + + + +
+

Report

+
+ + +
+