diff --git a/CHANGELOG.md b/CHANGELOG.md index f6546d2..6a0f60a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Svifa follows [semantic versioning](https://semver.org). ### Added +- Drawing between pixels is on by default, with no CSS. Svifa chooses the blocks that scroll with the content and gives each a layer + of its own (`will-change: translate`, which it sets for as long as it runs and then puts back), splitting a block around a fixed + element inside it or a `z-index` that reaches past it, and holding sticky elements still while they are held. It chooses once the + page is idle and again when the page changes; an element that becomes fixed inside a block loses that block's layer before the + page is drawn again. Where a layer would change what is drawn over what (a fixed or sticky element with no `z-index`, followed by + content), it draws on whole pixels and says why once in the console. `data-svifa-subpixel="off"` leaves an element on whole + pixels, and `subpixel: false` turns it off. Firefox, which at a screen scale of 100% draws whole pixels whatever it is asked, and + a device with only a touch screen get no layers. +- Scrolling boxes inside the scroller glide the wheel themselves, with the same feel and drawn between pixels, and hand it on at + their end. Boxes found on the page are ready before the wheel first turns over them; a box with a Svifa of its own keeps it. + `nested: 'native'` leaves boxes to the browser, and a function decides for each box, giving one a feel of its own if it returns + options. `configure` passes on to them. While the page glides, a box's content moves with it exactly: the browser moves a box + with a layer of its own only by whole pixels, and Svifa puts back the part of a pixel it leaves out, in every box inside, also + one whose wheel is left to the browser. - `snap`: a scroller with `scroll-snap-type` glides to the snap points its CSS sets. The wheel moves as far as it would without snapping and the glide rests on the nearest snap point; with `mandatory`, one notch or one trackpad swipe moves one point. `scrollTo` and same-page links rest on the snap point nearest their target. `scroll-padding`, `scroll-margin`, @@ -20,14 +34,42 @@ Svifa follows [semantic versioning](https://semver.org). is built with its framework's own tool from the package as it would be published, served as its framework serves it, and driven in a browser, on each change. +### Changed + +- `nested: true`, the default, now glides a scrolling box inside the scroller; it left the box to the browser before. `nested: + 'native'` is the earlier behaviour. +- A page that names its elements in `subpixel` keeps them and no longer needs a CSS rule for them: Svifa sets each one's layer + itself. +- Drawn on whole pixels (with `subpixel: false`, in Firefox, which at a screen scale of 100% draws whole pixels, or where Svifa + cannot draw between pixels), a glide never moves slower than one pixel a sixtieth of a second (60 px a second), at any refresh + rate. Its last pixels come at an even pace instead of ever more slowly, which showed as the page standing still for up to about + 180 ms and then hopping a pixel. On whole pixels a glide therefore ends sooner (one wheel notch at the default `lerp`: about 530 ms + instead of 874 ms), and a timed glide whose easing starts or ends slowly arrives a little before its time. Drawn between pixels, a + glide slows to a stop along its own curve, as before. + ### Fixed +- Zoomed to a part of a pixel (a browser zoom such as 80% or 125%, or a screen scale such as 125%), a page drawn between pixels + shimmered as it glided: the browser draws each scroll position on a whole device pixel, up to half of one off it, and Svifa drew + the rest of the way from the scroll position, not from where the browser drew it. It now draws from there, and the glide ends + where the browser draws the page at rest. The position Svifa reports is unchanged. +- At a device scale of a part of a pixel, a glide drawn between pixels could report a position up to half a device pixel past + where it was sent, before the browser had shown which places it keeps (at 125%, 6.4 for a glide sent to 6). A glide already past + the place it then aims at is held there. +- Drawn on whole pixels at a device scale of a part of a pixel, a glide could take its last pixel early (at 125%, a glide sent to 1 + rested while its curve was at 0.42, since on the device grid both are 0.8). Until the browser shows which places it keeps, a glide + rests only once it is on its last pixel on both grids. +- Drawn between pixels, a glide could go up to a pixel past the end of the page: the fraction drawn on the last block made the page + that much longer for the browser. The scroller is now put on the whole pixel just before the place, and the content is only ever + moved toward the start. +- In Firefox, the wheel over a ` +
${'

line

'.repeat(40)}
+
+ ` + +// Opens the test page with restInBox(g, box, delta): turns the wheel over the box, from its top, and returns the frames, at 60 Hz, +// until it rests where the turn sends it, with nothing of its content drawn between pixels any more. +async function open(page: Page, body = '') { + await openPage(page, body) + await page.evaluate(() => { + const w = window as any + w.restInBox = (g: unknown, box: HTMLElement, delta: number) => { + box.scrollTop = 0 + w.frame(g, 60, 2) + w.wheel(box, delta) + const drawn = () => [...box.children].some((child) => (child as HTMLElement).style.translate !== '') + let frames = 0 + while ((box.scrollTop !== delta || drawn()) && frames < 1000) { + w.frame(g) + frames++ + } + return frames + } + }) +} + +test.describe('a scrolling box inside', () => { + test('glides the wheel itself, smoothly, while the page stays where it is, then hands the wheel on at its end', async ({ + page, + }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const g = new w.Svifa({ autoRaf: false }) + const box = document.getElementById('box')! + const first = document.getElementById('first')! + // where the box's content is drawn: where the box is scrolled to, and the part of a pixel drawn on its content + const drawn = () => box.scrollTop - (first.style.translate ? Number.parseFloat(first.style.translate.split(' ')[1]) : 0) + w.frame(g) + const prevented = w.wheel(box, 120) + const path: number[] = [] + for (let i = 0; i < 400 && (box.scrollTop < 120 || first.style.translate !== ''); i++) { + w.frame(g) + path.push(drawn()) + } + // each frame moves less than the one before, as the curve does (on whole pixels, to within the pixel each is drawn on) + const layered = first.style.willChange.includes('translate') + const steps = path.map((at, i) => at - (i ? path[i - 1] : 0)) + const slowing = steps.slice(1, -1).every((step, i) => step >= 0 && step <= steps[i] + (layered ? 1e-6 : 1)) + box.scrollTop = box.scrollHeight + w.frame(g, 60, 2) + const atEnd = w.wheel(box, 100) + return { + prevented, + frames: path.length, + rest: box.scrollTop === box.scrollHeight - box.clientHeight, + slowing, + page: window.scrollY, + atEnd, + target: g.target, + } + }) + expect(result.prevented).toBe(true) + expect(result.frames).toBeGreaterThan(10) + expect(result.slowing).toBe(true) + expect(result.page).toBe(0) + // at its end the box lets the wheel go on: the page glides + expect(result.atEnd).toBe(true) + expect(result.target).toBe(100) + }) + + test('nested: "native" leaves boxes to the browser, and nested: false glides the page over them', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const box = document.getElementById('box')! + const g = new w.Svifa({ autoRaf: false, nested: 'native' }) + const native = w.wheel(box, 100) + g.destroy() + const h = new w.Svifa({ autoRaf: false, nested: false }) + const before = box.scrollTop + const over = w.wheel(box, 100) + w.frame(h, 60, 10) + return { native, over, page: h.target, box: box.scrollTop - before } + }) + // the browser's own scrolling of the box, where a scripted wheel event has one (WebKit), is the browser's: the box is not moved + // once the page glides over it + expect(result).toEqual({ native: false, over: true, page: 100, box: 0 }) + }) + + test('leaves to the browser a box marked data-svifa-native, one the native option picks, form controls and editable text', async ({ + page, + }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false, native: (element: Element) => element.id === 'picked' }) + return ['marked', 'picked', 'text', 'edit'].map((id) => w.wheel(document.getElementById(id), 50)) + }) + expect(result).toEqual([false, false, false, false]) + }) + + test('the nested option decides for each box: the page, the browser, or a feel of its own', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const rest = w.restInBox + const box = document.getElementById('box')! + const decide = { now: true as unknown } + const g = new w.Svifa({ autoRaf: false, nested: () => decide.now }) + w.frame(g) + const plain = rest(g, box, 100) + g.destroy() + const h = new w.Svifa({ autoRaf: false, nested: () => ({ lerp: 0.5 }) }) + w.frame(h) + const quick = rest(h, box, 100) + h.destroy() + decide.now = 'native' + const k = new w.Svifa({ autoRaf: false, nested: () => decide.now }) + const native = w.wheel(box, 100) + k.destroy() + decide.now = false + const m = new w.Svifa({ autoRaf: false, nested: () => decide.now }) + const toPage = w.wheel(box, 100) + return { plain, quick, native, toPage, pageTarget: m.target } + }) + expect(result.quick).toBeLessThan(result.plain) + expect(result).toMatchObject({ native: false, toPage: true, pageTarget: 100 }) + }) + + test('configure changes how the boxes glide too', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const rest = w.restInBox + const box = document.getElementById('box')! + const g = new w.Svifa({ autoRaf: false }) + w.frame(g) + const before = rest(g, box, 100) + g.configure({ lerp: 0.5 }) + const after = rest(g, box, 100) + return { before, after } + }) + expect(result.after).toBeLessThan(result.before) + }) + + test('a box with a Svifa of its own decides; a Svifa made on purpose takes over from one Svifa made; destroy takes them off', async ({ + page, + }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const box = document.getElementById('box')! + const g = new w.Svifa({ autoRaf: false }) + w.wheel(box, 50) + // one made for the box by the page's Svifa gives way, and the box's own decides from then on + let threw = false + let own: any = null + try { + own = new w.Svifa({ autoRaf: false, scroller: box, wheel: false }) + } catch { + threw = true + } + const left = w.wheel(box, 50) + own?.destroy() + g.destroy() + const gone = w.wheel(box, 50) + return { threw, left, gone, classes: box.className } + }) + expect(result).toEqual({ threw: false, left: false, gone: false, classes: '' }) + }) + + test('a box that scrolls sideways glides a sideways turn, and lets an up and down turn go to the page', async ({ page }) => { + await open(page, BOXES) + const result = await page.evaluate(() => { + const w = window as any + const wide = document.getElementById('wide')! + const g = new w.Svifa({ autoRaf: false }) + w.frame(g) + const sideways = w.wheel(wide, 0, { deltaX: 200 }) + w.frame(g, 60, 200) + const down = w.wheel(wide, 100) + return { sideways, left: wide.scrollLeft, down, page: g.target } + }) + expect(result).toEqual({ sideways: true, left: 200, down: true, page: 100 }) + }) +}) + +// Firefox draws whole pixels whatever it is asked, so Svifa draws nothing between them there +test.describe('a scrolling box drawn between pixels', { tag: '@not-firefox' }, () => { + // The browser draws a scrolling box that has a layer of its own where it rests, moving its content by whatever the page around it + // is moved by rounded to a whole device pixel. While the page glides, the box's content carries the rest. + for (const scale of [1, 2]) { + test.describe(`at a device scale of ${scale}`, () => { + test.use({ deviceScaleFactor: scale }) + test('moves the content of every box with the page while the page glides, one the wheel leaves to the browser too, and leaves nothing at rest', async ({ + page, + }) => { + await open( + page, + `
+

text

+

text

+
` + ) + await page.evaluate(() => { + const w = window as any + window.scrollTo({ top: 200, behavior: 'instant' }) + w.g = new w.Svifa({ autoRaf: false }) + }) + await page.waitForFunction(() => document.getElementById('kept')!.style.willChange === 'translate', null, { timeout: 3000 }) + const result = await page.evaluate(() => { + const w = window as any + const y = (element: HTMLElement) => + element.style.translate ? Number.parseFloat(element.style.translate.split(' ')[1]) : 0 + const main = document.getElementById('main')! + const ids = ['first', 'kept'].map((id) => document.getElementById(id)!) + w.frame(w.g) + w.wheel(window, 77.7) + let worst = 0 + let frames = 0 + while (w.g.isGliding && frames < 2000) { + w.frame(w.g, 240) + frames++ + // what the page is drawn by, and what the browser leaves of it on the box: the part over the nearest whole device pixel + const moved = y(main) * devicePixelRatio + const left = (moved - Math.sign(moved) * Math.round(Math.abs(moved))) / devicePixelRatio + for (const element of ids) worst = Math.max(worst, Math.abs(y(element) - left)) + } + return { frames, worst, rest: ids.map((element) => element.style.translate) } + }) + expect(result.frames).toBeGreaterThan(10) + // (to within the six decimals a translate is written to) + expect(result.worst).toBeLessThan(1e-6) + expect(result.rest).toEqual(['', '']) + }) + }) + } + + test('never shows a scroll bar a box does not have while its content moves with the page', async ({ page }) => { + await open( + page, + `
+
${'line
'.repeat(8)}
+
` + ) + await page.evaluate(() => { + const w = window as any + window.scrollTo({ top: 200, behavior: 'instant' }) + w.g = new w.Svifa({ autoRaf: false }) + }) + await page.waitForFunction(() => document.getElementById('pre')!.style.willChange === 'translate', null, { timeout: 3000 }) + const result = await page.evaluate(() => { + const w = window as any + const code = document.getElementById('code')! + // the box's own overflow, as it is written on it + const own = () => [code.style.overflowX, code.style.overflowY].join(' / ') + const style = own() + // the box shows a vertical scroll bar whenever what it holds is taller than it and its overflow lets it scroll that way (the + // test browser hides scroll bars, so they are not measured by their width) + const bar = () => code.scrollHeight > code.clientHeight && /auto|scroll|overlay/.test(getComputedStyle(code).overflowY) + const before = bar() + w.frame(w.g) + w.wheel(window, 77.7) + let shown = 0 + let moved = 0 + while (w.g.isGliding) { + w.frame(w.g, 240) + if (bar()) shown++ + if (document.getElementById('pre')!.style.translate) moved++ + } + return { before, shown, moved, same: own() === style, after: bar() } + }) + expect(result.moved).toBeGreaterThan(10) + expect(result).toMatchObject({ before: false, shown: 0, same: true, after: false }) + }) + + test('is ready before the wheel first turns over it, draws its content between pixels while it glides, and leaves nothing at rest', async ({ + page, + }) => { + await open(page, BOXES) + await page.evaluate(() => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false }) + }) + // the page is chosen once it is idle, and the boxes found inside it with it + await page.waitForFunction(() => document.getElementById('first')!.style.willChange === 'translate', null, { timeout: 3000 }) + const result = await page.evaluate(() => { + const w = window as any + const box = document.getElementById('box')! + const first = document.getElementById('first')! + w.frame(w.g) + w.wheel(box, 77.7) + let drawn = false + let worst = 0 + while (box.scrollTop < 77 || first.style.translate !== '') { + w.frame(w.g, 240) + if (first.style.translate !== '') drawn = true + const y = first.style.translate ? Number.parseFloat(first.style.translate.split(' ')[1]) : 0 + worst = Math.max(worst, y) + if (w.clock > 20000) break + } + const rest = { top: box.scrollTop, translate: first.style.translate } + w.g.destroy() + return { drawn, worst, rest, after: first.getAttribute('style') } + }) + expect(result.drawn).toBe(true) + // only ever moved toward the start + expect(result.worst).toBeLessThanOrEqual(0) + expect(result.rest).toEqual({ top: 78, translate: '' }) + expect(result.after).toBe('margin: 0px;') + }) +}) diff --git a/tests/properties.spec.ts b/tests/properties.spec.ts index e8ce88f..034eb12 100644 --- a/tests/properties.spec.ts +++ b/tests/properties.spec.ts @@ -25,9 +25,10 @@ async function check(page: Page, arbitrary: fc.Arbitrary, run: (value: T) await fc.assert(fc.asyncProperty(arbitrary, run), { numRuns }) } -// The motion's own properties, run at the project's device scale and again at 2, where some browsers keep whole CSS pixels. +// The motion's own properties, run at the project's device scale and again at 2 and at 1.25, where some browsers keep whole CSS +// pixels. function motion() { - test('a glide by lerp follows one curve in time, whatever the frames, and rests on the frame the curve reaches its pixel', async ({ + test('a glide by lerp follows one curve in time, whatever the frames, never slower than a pixel a sixtieth of a second, and rests on the frame the curve reaches its pixel', async ({ page, }) => { await check(page, fc.record({ lerp, from, delta: turn, frames }), async (c) => { @@ -39,22 +40,29 @@ function motion() { const per = Number.isInteger(window.scrollY) ? 1 : devicePixelRatio const start = Math.round(from * limit) window.scrollTo({ top: start, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false, lerp }) + // drawn on whole pixels, where the least pace applies + const g = new w.Svifa({ autoRaf: false, lerp, subpixel: false }) let clock = 1000 g.raf(clock) w.wheel(window, delta) const goal = g.target const rate = lerp * 60 - let elapsed = 0 + // the curve, frame by frame: closing in on the goal by lerp, but never slower than 60 px a second, and never past the goal + let curve = start let off = 0 let wrongFrame = -1 + let slowFrame = -1 let count = 0 for (; g.isGliding && count < 100000; count++) { const ms = frames[count % frames.length] clock += ms - elapsed += ms / 1000 + const before = g.scroll g.raf(clock) - const curve = goal + (start - goal) * Math.exp(-rate * elapsed) + const least = (60 * ms) / 1000 + const closer = goal + (curve - goal) * Math.exp((-rate * ms) / 1000) + curve = curve < goal ? Math.max(closer, Math.min(goal, curve + least)) : Math.min(closer, Math.max(goal, curve - least)) + // every frame moves at least its share of 60 px a second, or the rest of the way + if (Math.abs(g.scroll - before) < Math.min(least, Math.abs(goal - before)) - 1e-9 && slowFrame < 0) slowFrame = count // the curve rests once the browser would draw it on the pixel of its end (a frame that lands on the half pixel between, // to within rounding, may go either way) const away = Math.abs(curve - goal) * per @@ -63,11 +71,12 @@ function motion() { } const rest = { scroll: g.scroll, y: window.scrollY, gliding: g.isGliding } g.destroy() - return { start, limit, goal, off, wrongFrame, rest } + return { start, limit, goal, off, wrongFrame, slowFrame, rest } }, c) expect(run.goal).toBe(Math.min(Math.max(run.start + c.delta, 0), run.limit)) expect(run.off).toBeLessThan(1e-6) expect(run.wrongFrame).toBe(-1) + expect(run.slowFrame).toBe(-1) expect(run.rest).toEqual({ scroll: run.goal, y: run.goal, gliding: false }) }) }) @@ -183,7 +192,7 @@ function motion() { }) }) - test('a timed glide follows its curve over its time, arrives on the frame its time runs out, and resolves true', async ({ + test('a timed glide follows its curve, never slower than a pixel a sixtieth of a second, arrives by the frame its time runs out, and resolves true', async ({ page, }) => { const cases = fc.record({ @@ -207,21 +216,31 @@ function motion() { const start = Math.round(from * limit) const goal = Math.min(Math.max(to, 0), limit) window.scrollTo({ top: start, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false }) + const g = new w.Svifa({ autoRaf: false, subpixel: false }) let clock = 1000 g.raf(clock) const arrived = g.scrollTo(to, { duration, easing }) let elapsed = 0 let off = 0 let count = 0 - let due = -1 // the frame on which the time runs out + // the curve, frame by frame: the easing over its time, but never slower than 60 px a second toward the goal and never past it + // by that rule; it arrives once its time runs out or it reaches the goal, whichever comes first + let model = start + let due = -1 + const way = Math.sign(goal - start) for (; g.isGliding && count < 100000; count++) { const ms = frames[count % frames.length] clock += ms elapsed += ms / 1000 - if (due < 0 && elapsed / duration >= 1) due = count g.raf(clock) - if (g.isGliding) off = Math.max(off, Math.abs(g.scroll - (start + (goal - start) * easing(elapsed / duration)))) + const t = Math.min(1, elapsed / duration) + const eased = t >= 1 ? goal : start + (goal - start) * easing(t) + const least = (60 * ms) / 1000 + model = way > 0 ? Math.max(eased, Math.min(goal, model + least)) : Math.min(eased, Math.max(goal, model - least)) + // short of the goal by no more than rounding is at the goal + if (Math.abs(goal - model) < 1e-9) model = goal + if (due < 0 && (t >= 1 || model === goal)) due = count + if (g.isGliding) off = Math.max(off, Math.abs(g.scroll - model)) } const result = { goal, start, off, last: count - 1, due, resolved: await arrived, scroll: g.scroll, y: window.scrollY } g.destroy() @@ -244,6 +263,12 @@ test.describe('properties', () => { motion() }) + // a part of a device pixel per CSS pixel, as on a laptop set to 125%, where browsers keep whole CSS pixels + test.describe('at a device scale of 1.25', () => { + test.use({ deviceScaleFactor: 1.25 }) + motion() + }) + test('options of any kind leave every setting usable, and the scroller always inside the page', async ({ page }) => { const anything = fc.oneof( fc.double(), diff --git a/tests/react.spec.ts b/tests/react.spec.ts index a07aed4..1bfafb4 100644 --- a/tests/react.spec.ts +++ b/tests/react.spec.ts @@ -75,7 +75,7 @@ test('SmoothScroll makes one Svifa (StrictMode included), retunes it when a prop const prevented = w.wheel(window, 200) for (let t = 1016; w.made.isGliding; t += 16) w.made.raf(t) return { - classes: document.documentElement.className, + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), prevented, y: window.scrollY, reports: w.reports > 0, diff --git a/tests/solid.spec.ts b/tests/solid.spec.ts index cf35ebd..432fd0d 100644 --- a/tests/solid.spec.ts +++ b/tests/solid.spec.ts @@ -61,7 +61,11 @@ test('SmoothScroll makes one Svifa, retunes it, makes it again when what it scro const w = window as any w.made = w.probe const prevented = w.wheel(window, 200) - return { classes: document.documentElement.className, prevented, lerp: w.made.options.lerp } + return { + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), + prevented, + lerp: w.made.options.lerp, + } }) expect(first).toEqual({ classes: 'svifa svifa-gliding', prevented: true, lerp: 0.1 }) await glide(page) diff --git a/tests/subpixel.spec.ts b/tests/subpixel.spec.ts index d73cc41..36966f7 100644 --- a/tests/subpixel.spec.ts +++ b/tests/subpixel.spec.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 Arcwright Studio +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { expect, test } from './fixtures' import { open } from './setup' @@ -11,7 +13,8 @@ const PAGE = `
` -const STYLE = '.move, .stick { will-change: translate; }' +// no CSS of its own: Svifa gives each element its layer +const STYLE = '' // the vertical translate an element carries, in CSS pixels (0 when it has none) const SHIFT = `(element) => { const t = element.style.translate; return t ? Number.parseFloat(t.split(' ')[1]) : 0 }` @@ -240,23 +243,25 @@ test('draws a sticky element exactly in every frame as it reaches its offset, ev }) test.describe('the browser’s own grid', () => { - test('never leaves more than half a pixel to draw, in a browser that cuts positions down as in one that rounds them', async ({ - page, - }) => { + test('never puts the scroller past where the content is drawn, and leaves less than a pixel to draw', async ({ page }) => { await open(page, PAGE, STYLE) - const worst = await page.evaluate(() => { + const result = await page.evaluate(() => { const w = window as any const g = new w.Svifa({ autoRaf: false, subpixel: '.move' }) w.frame(g) w.wheel(window, 263.9) - let worst = 0 + let ahead = 0 + let behind = 0 while (g.isGliding) { w.frame(g, 144) - worst = Math.max(worst, Math.abs(window.scrollY - g.scroll)) + ahead = Math.max(ahead, window.scrollY - g.scroll) + behind = Math.max(behind, g.scroll - window.scrollY) } - return worst + return { ahead, behind } }) - expect(worst).toBeLessThanOrEqual(0.5 + 1e-6) + // the content is only ever moved toward the start (moved toward its end it would make the page longer) + expect(result.ahead).toBeLessThanOrEqual(1e-6) + expect(result.behind).toBeLessThan(1) }) test('without drawing between pixels, rests on the nearest pixel to where it was sent', async ({ page }) => { @@ -288,13 +293,14 @@ test.describe('the browser’s own grid', () => { w.frame(g) g.scrollTo(200.3) let last = 0 + // at 1000 Hz a frame's share of the least pace (60 px a second) is 0.06 px, so a jump onto the grid at the end would show while (g.isGliding) { last = window.scrollY - shift() - w.frame(g, 120) + w.frame(g, 1000) } return { last, rest: window.scrollY, value: g.scroll, left: one.style.translate } }) - expect(Math.abs(result.last - result.rest)).toBeLessThan(0.02) + expect(Math.abs(result.last - result.rest)).toBeLessThan(0.07) expect(result.value).toBe(result.rest) expect(result.left).toBe('') }) @@ -314,19 +320,22 @@ test.describe('the browser’s own grid', () => { [limit - 1, -1], ].map(([start, delta]) => { window.scrollTo({ top: start, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false }) + // drawn on whole pixels: where the scroller is, is where the content is drawn + const g = new w.Svifa({ autoRaf: false, subpixel: false }) w.frame(g) w.wheel(window, delta) let step = 0 let drawn = 0 + // at 240 Hz a frame's share of the least pace is a quarter of a pixel, well under the step a jump would make while (g.isGliding) { const before = g.scroll - w.frame(g) + w.frame(g, 240) step = Math.max(step, Math.abs(g.scroll - before)) drawn = Math.max(drawn, Math.abs(window.scrollY - g.scroll)) } - // arriving on a whole pixel settles the last half of one at once, with the frame's own share of the curve - const run = { aimed: start + delta, rest: g.scroll, y: window.scrollY, smooth: step < 0.6, drawn: drawn <= 0.5 } + // arriving on a whole pixel settles the last half of one at once, with the frame's own share of the curve or of the least + // pace (a quarter of a pixel at 240 Hz), whichever is more + const run = { aimed: start + delta, rest: g.scroll, y: window.scrollY, smooth: step <= 0.75 + 1e-9, drawn: drawn <= 0.5 } g.destroy() return run }) @@ -338,7 +347,8 @@ test.describe('the browser’s own grid', () => { await open(page) const worst = await page.evaluate(() => { const w = window as any - const g = new w.Svifa({ autoRaf: false }) + // drawn on whole pixels: where the scroller is, is where the content is drawn + const g = new w.Svifa({ autoRaf: false, subpixel: false }) w.frame(g) w.wheel(window, 7) let worst = 0 @@ -399,7 +409,7 @@ test.describe('the browser’s own grid', () => { [0.1, 16, 5], ]) { window.scrollTo({ top: 300, behavior: 'instant' }) - const g = new w.Svifa({ autoRaf: false, lerp }) + const g = new w.Svifa({ autoRaf: false, lerp, subpixel: false }) let clock = 1000 g.raf(clock) w.wheel(window, delta) @@ -418,6 +428,188 @@ test.describe('the browser’s own grid', () => { }) }) +// Zoomed to a part of a pixel, a browser keeps the scroller on a grid of whole CSS pixels (a device scale, as Playwright sets it) or of +// device pixels (Chrome at the browser's own zoom, Ctrl and minus), and draws each place on a whole device pixel, up to half of one +// off it (Chrome works it out in single precision and rounds half up). Drawn between pixels, the content is drawn from where the +// browser draws the scroller: the place it is drawn at moves on with the glide and never back, stays within half a device pixel of +// the glide's place (which is what Svifa reports), and ends where the browser draws the scroller at rest, with nothing left to draw. +// Whole turns, so on whole CSS pixels each glide rests where it was sent; on device pixels, on the one nearest. +function zoomedGlides({ shiftSource, device }: { shiftSource: string; device: boolean }) { + const w = window as any + const shift = new Function(`return ${shiftSource}`)() as (element: HTMLElement) => number + const one = document.getElementById('one')! + const limit = document.documentElement.scrollHeight - innerHeight + const ratio = devicePixelRatio + const drawnAt = (place: number) => Math.round(Math.fround(place * ratio)) / ratio + const runs: Record[] = [] + for (const [start, delta, hz] of [ + [0, 137, 240], + [301, 23, 1000], + [302, 61, 240], + [903, -61, 1000], + [limit - 3, 9, 240], + ]) { + window.scrollTo({ top: start, behavior: 'instant' }) + const g = new w.Svifa({ autoRaf: false, subpixel: '.move' }) + w.frame(g) + w.wheel(window, delta) + const goal = g.target + const rest = device ? Math.min(Math.round(goal * ratio), Math.floor(limit * ratio)) / ratio : goal + const way = Math.sign(goal - window.scrollY) + let drawn = drawnAt(window.scrollY) + let back = 0 + let off = 0 + let toward = 0 + let reported = 0 + let step = 0 + while (g.isGliding) { + const before = g.scroll + w.frame(g, hz) + if (!g.isGliding) break + const now = drawnAt(window.scrollY) - shift(one) + back = Math.max(back, (drawn - now) * way) + off = Math.max(off, Math.abs(now - g.scroll)) + toward = Math.max(toward, shift(one)) + if (!(g.scroll >= 0 && g.scroll <= limit) || (g.scroll - before) * way < -1e-9) reported++ + step = Math.abs(now - drawn) + drawn = now + } + runs.push({ + start, + delta, + back: back <= 1e-6, + off: off <= 0.5 / ratio + 1e-6, + toward: toward <= 1e-6, + reported, + // read back in single precision by some browsers, and the device pixel ratio is single precision too + rest: g.scroll === window.scrollY && Math.abs(window.scrollY - rest) < 1e-3, + // the last move, onto where the browser draws it at rest, is no bigger than the glide's own last step (in Firefox the least + // pace, a quarter of a pixel at 240 Hz) or a tiny fraction; Firefox keeps a translate to a sixtieth of a CSS pixel + landed: Math.abs(drawnAt(window.scrollY) - drawn) <= Math.max(step, 0.07) + 1 / 60, + left: one.style.translate, + }) + g.destroy() + } + return runs +} + +const ZOOMED = { back: true, off: true, toward: true, reported: 0, rest: true, landed: true, left: '' } + +for (const scale of [0.8, 0.9, 1.25, 1.5]) { + test.describe(`zoomed to ${scale * 100}%`, () => { + test.use({ deviceScaleFactor: scale }) + test('the content is drawn moving on with the glide from where the browser draws the scroller, and lands where it rests', async ({ + page, + }) => { + await open(page, PAGE, STYLE) + const runs = await page.evaluate(zoomedGlides, { shiftSource: SHIFT, device: false }) + for (const run of runs) expect(run).toEqual({ start: run.start, delta: run.delta, ...ZOOMED }) + }) + }) +} + +// Before a glide's first move shows the browser's grid, the nearest device pixel to the goal can lie past it (at 125%, 6.4 for 6): +// once the browser shows it keeps whole CSS pixels, a glide already past the place it then aims at is held there, so what it reports +// never goes past where it was sent. These are the turns and frames the property tests found. +test.describe('zoomed to 125%, before the browser has shown its grid', () => { + test.use({ deviceScaleFactor: 1.25 }) + test('a glide never reports a place past its goal', async ({ page }) => { + await open(page) + const past = await page.evaluate(() => { + const w = window as any + const found: string[] = [] + for (const [lerp, delta, steps] of [ + [0.5373233962673998, 6, [36, 1, 1, 48, 48, 48, 48]], + [0.5148734114161081, 2, [1, 8, 1, 48, 48, 48]], + ] as [number, number, number[]][]) { + window.scrollTo({ top: 0, behavior: 'instant' }) + const g = new w.Svifa({ autoRaf: false, lerp }) + let clock = 1000 + g.raf(clock) + w.wheel(window, delta) + for (const ms of steps) { + clock += ms + g.raf(clock) + if (g.scroll > delta + 1e-9) found.push(`${g.scroll} heading for ${delta}`) + } + while (g.isGliding) w.frame(g) + if (g.scroll !== delta) found.push(`rested at ${g.scroll}, sent to ${delta}`) + g.destroy() + } + return found + }) + expect(past).toEqual([]) + }) + + // On the device grid at 125%, 0.42 and 1 are the same place (0.8); on whole CSS pixels they are not. A glide on whole pixels rests + // only once it is on its last pixel on both grids, until the browser shows which one it keeps. + test('drawn on whole pixels, a glide is not taken to be on its last pixel early', async ({ page }) => { + await open(page) + const early = await page.evaluate(() => { + const w = window as any + const limit = document.documentElement.scrollHeight - innerHeight + const found: string[] = [] + for (const [lerp, from, delta, frames] of [ + [0.9643679362225734, 0.9795108695652174, -5, [22, 2, 8, 2, 1]], + [0.02, 0.0020108695652173913, -1, [4, 1]], + [0.02, 0, 1, [1]], + ] as [number, number, number, number[]][]) { + const start = Math.round(from * limit) + window.scrollTo({ top: start, behavior: 'instant' }) + const g = new w.Svifa({ autoRaf: false, lerp, subpixel: false }) + let clock = 1000 + g.raf(clock) + w.wheel(window, delta) + const goal = g.target + // the glide's curve, closing in on the goal by lerp and never slower than 60 px a second + let curve = start + for (let count = 0; g.isGliding && count < 100000; count++) { + const ms = frames[count % frames.length] + clock += ms + g.raf(clock) + const least = (60 * ms) / 1000 + const closer = goal + (curve - goal) * Math.exp((-lerp * 60 * ms) / 1000) + curve = curve < goal ? Math.max(closer, Math.min(goal, curve + least)) : Math.min(closer, Math.max(goal, curve - least)) + if (!g.isGliding && Math.abs(curve - goal) > 0.5) found.push(`rested at ${g.scroll} with the curve at ${curve}`) + } + g.destroy() + } + return found + }) + expect(early).toEqual([]) + }) +}) + +// The browser's own zoom is not a device scale: Chrome keeps the scroller on device pixels at every zoom (at 80% every 1.25 CSS +// pixels). Chromium's full build takes a zoom level from its profile, as a person's Chrome keeps one. +test.describe('zoomed by the browser', () => { + for (const zoom of [0.8, 1.25]) { + test( + `at ${zoom * 100}% the content is drawn moving on with the glide, from device pixels, and lands where it rests`, + { tag: '@chromium-only' }, + async ({ playwright }, testInfo) => { + const profile = testInfo.outputPath('profile') + mkdirSync(join(profile, 'Default'), { recursive: true }) + const level = Math.log(zoom) / Math.log(1.2) + writeFileSync(join(profile, 'Default', 'Preferences'), JSON.stringify({ partition: { default_zoom_level: { x: level } } })) + const context = await playwright.chromium.launchPersistentContext(profile, { + channel: 'chromium', + viewport: { width: 1280, height: 800 }, + }) + try { + const page = context.pages()[0] ?? (await context.newPage()) + await open(page, PAGE, STYLE) + expect(await page.evaluate(() => devicePixelRatio)).toBeCloseTo(zoom, 5) + const runs = await page.evaluate(zoomedGlides, { shiftSource: SHIFT, device: true }) + for (const run of runs) expect(run).toEqual({ start: run.start, delta: run.delta, ...ZOOMED }) + } finally { + await context.close() + } + } + ) + } +}) + test('holds an element stuck to the bottom still while the content glides past it', async ({ page }) => { await open( page, @@ -536,3 +728,28 @@ test('is left off for a horizontal scroller in right-to-left content', async ({ }) expect(cls).toBe('svifa') }) + +test('drawn between pixels a glide slows to a stop along its curve; in Firefox, which draws whole pixels, it keeps the least pace', async ({ + page, + browserName, +}) => { + await open(page, PAGE, STYLE) + const slowest = await page.evaluate(() => { + const w = window as any + const g = new w.Svifa({ autoRaf: false, subpixel: '.move' }) + w.frame(g) + w.wheel(window, 100) + // the least a frame moved while the glide was under way, at 60 Hz + let least = Number.POSITIVE_INFINITY + while (g.isGliding) { + const before = g.scroll + w.frame(g, 60) + if (g.isGliding) least = Math.min(least, Math.abs(g.scroll - before)) + } + g.destroy() + return least + }) + // the least pace is a pixel a frame at 60 Hz; along the curve, the last frames move a small part of one + if (browserName === 'firefox') expect(slowest).toBeGreaterThanOrEqual(1 - 1e-9) + else expect(slowest).toBeLessThan(0.1) +}) diff --git a/tests/svelte.spec.ts b/tests/svelte.spec.ts index 2517226..0264675 100644 --- a/tests/svelte.spec.ts +++ b/tests/svelte.spec.ts @@ -92,7 +92,7 @@ test('smoothScroll makes one Svifa that components inside reach, and takes it of const prevented = w.wheel(window, 200) return { same: shared === w.probe, - classes: document.documentElement.className, + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), prevented, lerp: w.probe.options.lerp, } diff --git a/tests/vue.spec.ts b/tests/vue.spec.ts index 46ff652..507e2a4 100644 --- a/tests/vue.spec.ts +++ b/tests/vue.spec.ts @@ -67,7 +67,11 @@ test('SmoothScroll makes one Svifa, retunes it, makes it again when what it scro const w = window as any w.made = w.probe const prevented = w.wheel(window, 200) - return { classes: document.documentElement.className, prevented, lerp: w.made.options.lerp } + return { + classes: [...document.documentElement.classList].filter((name) => name !== 'svifa-subpixel').join(' '), + prevented, + lerp: w.made.options.lerp, + } }) expect(first).toEqual({ classes: 'svifa svifa-gliding', prevented: true, lerp: 0.1 }) await glide(page) diff --git a/tests/wheel.spec.ts b/tests/wheel.spec.ts index 6ff127e..6834c90 100644 --- a/tests/wheel.spec.ts +++ b/tests/wheel.spec.ts @@ -61,6 +61,36 @@ test.describe('the wheel', () => { } }) + test('on whole pixels the page never stands still for more than a sixtieth of a second before it rests, at any refresh rate', async ({ + page, + }) => { + await open(page) + const runs = await page.evaluate(() => { + const w = window as any + return [60, 120, 240, 500].map((hz) => { + window.scrollTo({ top: 0, behavior: 'instant' }) + const g = new w.Svifa({ autoRaf: false, subpixel: false }) + w.frame(g, hz) + w.wheel(window, 100) + // the longest run of frames the page is drawn in the same place while the glide is under way + let still = 0 + let longest = 0 + let drawn = window.scrollY + while (g.isGliding) { + w.frame(g, hz) + if (window.scrollY === drawn) longest = Math.max(longest, ++still) + else still = 0 + drawn = window.scrollY + } + g.destroy() + return { hz, ms: (longest * 1000) / hz, rest: window.scrollY } + }) + }) + // a sixtieth of a second, and the frame that shows the move + for (const run of runs) expect(run).toEqual({ hz: run.hz, ms: expect.any(Number), rest: 100 }) + for (const run of runs) expect(run.ms).toBeLessThanOrEqual(1000 / 60 + 1000 / run.hz + 1e-6) + }) + test('adds up notches that come while a glide is under way', async ({ page }) => { await open(page) const target = await page.evaluate(() => { @@ -159,11 +189,11 @@ test.describe('the wheel', () => { expect(prevented).toBe(false) }) - test('glides over a set time when given a duration', async ({ page }) => { + test('glides over a set time when given a duration, never slower than a pixel a sixtieth of a second', async ({ page }) => { await open(page) const frames = await page.evaluate(() => { const w = window as any - w.g = new w.Svifa({ autoRaf: false, duration: 0.5 }) + w.g = new w.Svifa({ autoRaf: false, duration: 0.5, subpixel: false }) w.frame(w.g) w.wheel(window, 300) let count = 0 @@ -171,11 +201,21 @@ test.describe('the wheel', () => { w.frame(w.g, 60) count++ } - return { count, at: window.scrollY } + // the easing over half a second, held to a pixel a frame at 60 Hz where it would go slower: the frame it reaches 300 px + let model = 0 + let expected = 0 + // (short of it by no more than rounding is there) + for (let t = 1 / 60; model < 300 - 1e-9; t += 1 / 60) { + const eased = t >= 0.5 ? 300 : (300 * (1 - 2 ** (-20 * t))) / (1 - 2 ** -10) + model = Math.max(eased, Math.min(300, model + 1)) + expected++ + } + return { count, expected, at: window.scrollY } }) expect(frames.at).toBe(300) - expect(frames.count).toBeGreaterThanOrEqual(29) - expect(frames.count).toBeLessThanOrEqual(31) + // its time is 30 frames; held to the pace at the slow end of the easing, it arrives before that + expect(frames.count).toBe(frames.expected) + expect(frames.count).toBeLessThan(30) }) })