// One finger on the photo, while a chip's ruler is open — the knob moved off // the picture instead of out of the row. // // A phone's ruler stands in a column at the foot of the screen and the corner a // reader is judging sits above it, so the hand leaves the number to go and look // at what the number is doing. What this holds down is the trade the stage makes // instead: with a ruler open, one finger dragged across the photo IS that // ruler's track — right for up the scale, left for down it, the range travelling // the photo's whole width, the same travel the row's own slider has — and the // number stops at its own ends. It holds down the two things that could be lost // to it as well: two fingers are still the phone's own zoom (a pinch is not a // value, so the finger's hold is given up the moment the second one lands), and // with the row put away the same drag changes nothing at all. The row's own // track takes a touch anywhere on it too, whatever the engine would have done // with the thumb's 3px box. // // npm run build && node scripts/studio-drag-check.mjs // PLAYWRIGHT_CORE= CHROME= \ // APP_PORT=6164 SAMPLE_PORT=6165 node scripts/studio-drag-check.mjs // // Playwright is not a dependency of this package (a browser run needs it, a // build does not), so a run without it says SKIP and exits 0. Two real frames // are served off a local server and `showDirectoryPicker` is answered with a // folder made of them, the same stand-in `studio-pinch-check.mjs` uses. import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..'); const APP_PORT = Number(process.env.APP_PORT ?? 4312); const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4313); const A_NAME = 'P1010256.JPG'; const B_NAME = 'D0004128.JPG'; let failures = 0; function step(name, ok, detail = '') { if (!ok) failures++; console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); return ok; } const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); process.exit(0); } // --- the two frames, off a local server (the page fetches them by CORS) ------ const sampleServer = createServer(async (req, res) => { const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); if (name !== A_NAME && name !== B_NAME) { res.writeHead(404).end(); return; } const bytes = await readFile(path.join(SAMPLES, name)); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': bytes.length, 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', }); res.end(bytes); }); await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); // A server already up, when one is named; otherwise the build in `dist/`. let app = null; const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`; if (!process.env.BASE) { app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { cwd: FRONTEND, stdio: 'ignore', }); let up = false; for (let i = 0; i < 60 && !up; i++) { up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); if (!up) await new Promise((r) => setTimeout(r, 500)); } if (!up) { app.kill('SIGTERM'); sampleServer.close(); console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); process.exit(1); } } // --- the browser, on a phone ------------------------------------------------- const browser = await playwright.chromium.launch({ executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], }); // A folder of two frames, handed over the way the browser hands one over. const INIT = ({ origin, names, folder }) => { const bytes = new Map(); const load = async (n) => { if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer())); return bytes.get(n); }; const fileHandle = (name) => { const h = { kind: 'file', name, __fake: true }; Object.defineProperties(h, { getFile: { value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1), }), }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, }); return h; }; const dirHandle = (label) => { const h = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(h, { values: { value: () => names.map(fileHandle)[Symbol.iterator]() }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, getDirectoryHandle: { value: async () => { throw new DOMException('no folder', 'NotFoundError'); } }, getFileHandle: { value: async (name) => { if (!names.includes(name)) throw new DOMException('no frame', 'NotFoundError'); return fileHandle(name); }, }, }); return h; }; const root = () => dirHandle(folder); window.showDirectoryPicker = async () => root(); const revive = (row) => { if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name); return row; }; for (const m of ['get', 'getAll']) { const original = IDBObjectStore.prototype[m]; IDBObjectStore.prototype[m] = function (...a) { const req = original.apply(this, a); const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); Object.defineProperty(req, 'result', { configurable: true, get() { const v = d.get.call(this); return Array.isArray(v) ? v.map(revive) : revive(v); }, }); return req; }; } }; // The phone the gesture is made on: a width under the 860px the app calls // mobile at, a touch screen, and nothing else changed. const context = await browser.newContext({ viewport: { width: 412, height: 900 }, hasTouch: true, isMobile: true, deviceScaleFactor: 1, }); // No server is up behind this build, and a vite preview answers an API path // with the SPA's own `index.html` — which parses to `{}` and so reads as an // empty *answer* rather than as "no server", taking the studio down with it. const replies = { '/api/auth/me': { user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } }, '/api/recipes': { recipes: [] }, '/api/photos/mine': { photos: [] }, '/api/photos': { photos: [] }, }; await context.route('**/api/**', (route) => { const body = replies[new URL(route.request().url()).pathname]; return route.fulfill( body ? { status: 200, contentType: 'application/json', body: JSON.stringify(body) } : { status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'HTTP 404' }) } ); }); await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [A_NAME, B_NAME], folder: 'CheckRoll' }); const page = await context.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); // --- the fingers ------------------------------------------------------------- // Touch, through the browser's own input pipeline rather than as a hand-made // `PointerEvent`: the point of the check is what a real finger does on the // browser's own pointers, not what a script can make one look like. const cdp = await context.newCDPSession(page); const touch = async (type, points) => { await cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], i) => ({ x, y, id: i + 1 })), }); }; // The stage's own zoom, read the way the reader sees it: the transform on the // photo. Untransformed is the fitted photo, which is 1. const scale = () => page.$eval('.canvas-wrap img', (e) => { const t = getComputedStyle(e).transform; return t === 'none' ? 1 : Number(new DOMMatrixReadOnly(t).a); }); // The box the photo is fitted to: the drag is measured against its width, the // way the stage measures it. const stageBox = () => page.$eval('.canvas-wrap', (e) => { const r = e.getBoundingClientRect(); return { w: r.width, cx: r.left + r.width / 2, cy: r.top + r.height / 2 }; }); // The open ruler's number, read off the row's own track: the slider is what the // drag writes, so asking the slider is asking what landed. const rulerValue = () => page.$eval('[data-col="slider"] input[type="range"]', (e) => Number(e.value)); const chipText = (key) => page.$eval(`[data-key="${key}"]`, (e) => e.textContent ?? ''); // One finger: down, walked across in even steps, lifted. const drag = async (from, [dx, dy], steps = 8) => { await touch('touchStart', [from]); for (let i = 1; i <= steps; i++) await touch('touchMove', [[from[0] + (dx * i) / steps, from[1] + (dy * i) / steps]]); await touch('touchEnd', []); await page.waitForTimeout(250); }; // Two fingers put down `from` either side of the middle of the stage and walked // apart to `to` — the zoom, unchanged from `studio-pinch-check.mjs`. const pinch = async ([cx, cy], from, to) => { await touch('touchStart', [ [cx - from, cy], [cx + from, cy], ]); for (const d of [1 / 3, 2 / 3, 1]) { await touch('touchMove', [ [cx - (from + (to - from) * d), cy], [cx + (from + (to - from) * d), cy], ]); } await touch('touchEnd', []); await page.waitForTimeout(300); }; // --- the frame on the stage -------------------------------------------------- await page.goto(`${base}/library`); // The library has a phone's own screen at this width, and the catalogue is // picked on the other one; the studio the frame opens into is the phone's either // way, because that is the window's width and not this switch (see `isMobile`). await page.click('button:has-text("Desktop")').catch(() => {}); await page.click('[data-key="lib-add"]'); await page.waitForFunction((sel) => document.querySelectorAll(sel).length >= 2, '[data-key^="lib-thumb-"]', { timeout: 120_000, }); await page.click('[data-key="lib-open"]'); await page.waitForFunction(() => { const img = document.querySelector('.canvas-wrap img'); return !!img && img.getBoundingClientRect().width > 0; }, null, { timeout: 120_000 }); await page.waitForTimeout(1500); step('the catalogue frame is on the phone’s stage, fitted', (await scale()) === 1, `${page.url()}`); // --- a ruler, open over the photo -------------------------------------------- // LIGHT's panels are the phone's chips, and a panel's row is a knob: the tap // opens its ruler in the column above the strip (see .col-slider). await page.click('nav.rail button:has-text("LIGHT")'); await page.waitForSelector('[data-key="dev-tone"]', { timeout: 30_000 }); await page.click('[data-key="dev-tone"]'); await page.waitForSelector('[data-key="contrast"]', { timeout: 30_000 }); await page.click('[data-key="contrast"]'); await page.waitForSelector('[data-col="slider"] input[type="range"]', { timeout: 30_000 }); step('a tap on a knob opens its ruler, the row the photo is about to become', (await rulerValue()) === 0, `CONTRAST ${await rulerValue()}`); // --- one finger across the photo --------------------------------------------- // CONTRAST is one of the bipolar knobs the spec draws from -100 to +100 (see // `deepen` in shared/utils/paramDefs), so a quarter of the photo's width is // fifty of them — and the number is on the row, not under the hand. const box = await stageBox(); await drag([box.cx, box.cy], [box.w * 0.25, 0]); const quarter = await rulerValue(); step('a finger dragged to the right lifts the knob with it', Math.abs(quarter - 50) <= 1, `CONTRAST ${quarter} for a quarter of the width`); step('and the photo stayed where it was — the drag is the value, not the view', (await scale()) === 1, `scale ${await scale()}`); // --- the look waits for the hand to come off --------------------------------- // Rendering the look is the one thing in a drag that costs more than a frame, so // it is held while the finger is down: the number moves on the row as the hand // does, the photo's own src does not — not even over a pause a debounce would // have rendered on — and it lands once, when the finger lifts. const srcBefore = await page.$eval('.canvas-wrap img', (e) => e.src); const from = await rulerValue(); await touch('touchStart', [[box.cx, box.cy]]); await touch('touchMove', [[box.cx + box.w * 0.1, box.cy]]); await page.waitForTimeout(600); const srcMid = await page.$eval('.canvas-wrap img', (e) => e.src); const live = await rulerValue(); step('the number is on the row while the hand is still on the photo', Math.abs(live - (from + 20)) <= 1, `CONTRAST ${from} -> ${live}`); step('and the look itself is not rendered under it', srcMid === srcBefore, srcMid === srcBefore ? 'the photo held' : 'the photo re-rendered mid-drag'); await touch('touchMove', [[box.cx + box.w * 0.2, box.cy]]); await touch('touchEnd', []); await page.waitForFunction((before) => document.querySelector('.canvas-wrap img')?.src !== before, srcBefore, { timeout: 30_000 }).catch(() => {}); const srcAfter = await page.$eval('.canvas-wrap img', (e) => e.src); step('the look lands once, when the finger lifts', srcAfter !== srcBefore, srcAfter === srcBefore ? 'nothing rendered' : 'one render for the gesture'); step('and the knob stopped where the hand left it', Math.abs((await rulerValue()) - (from + 40)) <= 1, `CONTRAST ${await rulerValue()}`); // Past the far edge the number stops at its own end rather than running on. await drag([box.cx, box.cy], [box.w * 0.6, 0]); step('dragged past the edge, the knob stops at its own end', (await rulerValue()) === 100, `CONTRAST ${await rulerValue()}`); // --- two fingers are still the zoom ----------------------------------------- // The second finger is not a second value: the zoom wins, and the knob it was // holding is left exactly where the pinch found it. const held = await rulerValue(); await pinch([box.cx, box.cy], 30, 150); const zoomed = await scale(); step('two fingers on the photo are still the phone’s own zoom', zoomed >= 2, `scale ${zoomed.toFixed(2)}`); step('and a pinch is not a value — the knob it let go did not move', (await rulerValue()) === held, `CONTRAST ${held} -> ${await rulerValue()}`); await pinch([box.cx, box.cy], 150, 30); await page.waitForTimeout(200); step('and two fingers together put the photo back', (await scale()) === 1, `scale ${(await scale()).toFixed(2)}`); // --- and back down the scale ------------------------------------------------- await drag([box.cx, box.cy], [-box.w * 0.25, 0]); const down = await rulerValue(); step('dragged to the left the knob comes down with it', Math.abs(down - 50) <= 1, `CONTRAST ${down}`); // --- the hand on the ruler itself -------------------------------------------- // The other half of the same trade, and the one an engine can take away: the // number can be had by the hand that lands on the ruler, not only by the one // walking across the photo. WebKit begins a slider drag only when the touch // falls inside the thumb's own box — a 3px line here (see `SliderThumbElement:: // handleTouchStart`) — so on a phone the ticks across the track answered a finger // while the middle of the ruler answered nothing. The row reads the value from // the touch's own place on the track, so what is made here is the gesture a // browser's slider would take: an untrusted `PointerEvent`, which Blink's own // slider ignores and the row's own listener does not. The whole gesture is put // down on one pixel of the box it started on, the way a hand is. const trackTouch = (rail, fraction, type = 'pointerdown', pointerId = 7, pointerType = 'touch') => page.$eval( '[data-col="slider"] input[type="range"]', (e, p) => { e.dispatchEvent( new PointerEvent(p.type, { bubbles: true, cancelable: true, composed: true, pointerId: p.pointerId, pointerType: p.pointerType, isPrimary: p.pointerId === 7, buttons: 1, clientX: p.rail.left + p.rail.width * p.fraction, clientY: p.rail.mid, }) ); }, { rail, fraction, type, pointerId, pointerType } ); // The ruler's own box, taken once: a hand on it is a fixed pixel, so the whole // gesture is measured against the box it started on. const trackRect = () => page.$eval('[data-col="slider"] input[type="range"]', (e) => { const r = e.getBoundingClientRect(); return { left: r.left, width: r.width, mid: r.top + r.height / 2, value: Number(e.value) }; }); const rail = await trackRect(); await trackTouch(rail, 0.85); const onTrack = await rulerValue(); step('a touch on the ruler’s own track takes the number from where it landed', Math.abs(onTrack - 70) <= 1, `CONTRAST ${onTrack} at 85% of the track`); const steady = (await trackRect()).width; await trackTouch(rail, 0.5, 'pointermove'); step('and the number walks with the hand along the ruler', Math.abs(await rulerValue()) <= 1, `CONTRAST ${await rulerValue()} at the middle`); step('and the track stays under the hand while the number changes length', (await trackRect()).width === steady, `${steady} -> ${(await trackRect()).width}`); const sole = await rulerValue(); await trackTouch(rail, 0.25, 'pointermove', 9); step('a second finger is not a second value', (await rulerValue()) === sole, `CONTRAST ${sole} -> ${await rulerValue()}`); await trackTouch(rail, 0.2, 'pointermove'); step('and the number comes down as the hand goes back', Math.abs((await rulerValue()) + 60) <= 1, `CONTRAST ${await rulerValue()} at a fifth`); await trackTouch(rail, 0.5, 'pointerup'); const lifted = await rulerValue(); await trackTouch(rail, 0.95, 'pointermove'); step('the number stands where the finger was lifted', (await rulerValue()) === lifted, `CONTRAST ${lifted} -> ${await rulerValue()}`); await trackTouch(rail, 0.1, 'pointerdown', 11, 'mouse'); step('and the mouse keeps the drag the engines already give it', (await rulerValue()) === lifted, `CONTRAST ${lifted} -> ${await rulerValue()}`); // --- with the row put away --------------------------------------------------- // The drag belongs to the ruler, not to the photo, and a phone puts the row away // through the path under the strip — the strip it came from is off the screen // while the row stands (see the rule that hides the options column). The same // finger across the same picture then moves nothing at all. const before = await chipText('contrast'); await page.click('[data-key="crumb-strip"]'); await page.waitForFunction(() => !document.querySelector('[data-col="slider"] input[type="range"]'), null, { timeout: 30_000 }); await drag([box.cx, box.cy], [box.w * 0.3, 0]); step('with the ruler put away the same drag changes nothing', (await chipText('contrast')) === before, `${before} -> ${await chipText('contrast')}`); step('and the photo is still fitted, neither panned nor zoomed', (await scale()) === 1, `scale ${await scale()}`); step('no uncaught error', errors.length === 0, errors.join(' | ')); await context.close(); await browser.close(); sampleServer.close(); app?.kill('SIGTERM'); console.log(failures ? `\n${failures} FAILED` : '\nall ok'); process.exit(failures ? 1 : 0);