From b9fc0ece028fbeef409c1f7183a6cc8f262d5b97 Mon Sep 17 00:00:00 2001 From: Paul Glover Date: Mon, 10 Aug 2026 17:41:43 -0400 Subject: [PATCH] Move between images with the arrow keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mouse has no swipe. A desktop browser can drag, but swipeOutcome's thresholds are written for a finger — a fifth of the viewport, or a flick at a finger's speed — and a mouse produces neither reliably, so the viewer reads as inert on a desktop. ← and → draw another image, ↓ returns to the entry screen: the same two outcomes a swipe produces, deliberately, so keyOutcome returns swipeOutcome's vocabulary and both routes run through the new Viewer.commit rather than each building its own dismissal. ↑ stays unbound. Not for the reason swipe-up is — a keyboard collides with no system gesture — but because ← and → already mean "another image". Two smaller things fall out of it. dismiss() now takes the outcome immediately when there is no photo on screen, because a display:none element never fires transitionend and would otherwise sit through the full 400 ms timeout; that also gives the error state a way out that is not a page reload, which it did not have before, since onPointerDown refuses to start a gesture on a hidden photo. And modified or auto-repeating arrows are left alone: a held key would abort each load to start the next. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4oAqvh4CPXFK2uWJnSXpa --- CLAUDE.md | 9 +++++- README.md | 18 +++++++++--- core.test.mjs | 39 ++++++++++++++++++++++++ static/app.js | 80 ++++++++++++++++++++++++++++++++++++++++++++------ static/core.js | 27 +++++++++++++++++ 5 files changed, 159 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c94357..396b098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,14 @@ fine and then 422 on load. **Swiping up is deliberately unused and must stay that way.** On a phone an upward drag near the bottom edge belongs to the system. `swipeOutcome` tests -`travel.y > …` rather than a magnitude for this reason. +`travel.y > …` rather than a magnitude for this reason. `keyOutcome` leaves ↑ +unbound too, but for an unrelated reason — nothing for it to mean — so don't +collapse the two rationales into one. + +**Keys and swipes must resolve to the same outcomes.** `keyOutcome` returns +`swipeOutcome`'s vocabulary and both run through `Viewer.commit`, which is what +keeps a key press and a gesture from drifting into different behaviour. A new +outcome in one without the other reaches `commit` as a silent fall-through. **The entry screen is not decoration.** The Fullscreen API only works inside a user gesture, so the "Random Image" click is the only opportunity to make the diff --git a/README.md b/README.md index a8edfa9..2619387 100644 --- a/README.md +++ b/README.md @@ -349,10 +349,20 @@ library. An entry screen naming the folder and its image count, then the viewer: -- **swipe left or right** — another image at random. Direction carries no meaning, - since the next image is random either way, so both do the same thing and only - the exit animation differs. -- **swipe down** — back to the entry screen. +- **swipe left or right**, or press **←** or **→** — another image at random. + Direction carries no meaning, since the next image is random either way, so + both do the same thing and only the exit animation differs. +- **swipe down**, or press **↓** — back to the entry screen. + +The keys are there because a mouse has no swipe. A desktop browser can drag, but +the thresholds are written for a finger — a fifth of the viewport, or a flick at +a finger's speed — and neither is what a mouse produces. A key press and a swipe +resolve to the same two outcomes and run through the same code (`keyOutcome` in +`core.js`, `Viewer.commit` in `app.js`), so the two cannot drift apart. + +**↑ is unbound**, but not for the reason swipe-up is: a keyboard has no system +gesture to collide with. There is simply nothing for it to mean, since ← and → +already say "another image". **Swiping up is deliberately unused, and should stay that way.** On a phone an upward drag near the bottom edge belongs to the system, so anything bound to it diff --git a/core.test.mjs b/core.test.mjs index 5db57ec..3e96f02 100644 --- a/core.test.mjs +++ b/core.test.mjs @@ -9,6 +9,7 @@ import assert from "node:assert/strict"; import { Metrics, fittedSize, + keyOutcome, placement, predictEnd, randomFrom, @@ -246,6 +247,44 @@ test("no axis lock means no outcome", () => { assert.equal(swipeOutcome(null, { x: 500, y: 500 }, { x: 500, y: 500 }, PHONE), "none"); }); +// --------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------- + +test("the arrow keys resolve to the same outcomes as the swipes", () => { + assert.deepEqual(keyOutcome("ArrowLeft"), { outcome: "random", direction: -1 }); + assert.deepEqual(keyOutcome("ArrowRight"), { outcome: "random", direction: 1 }); + assert.equal(keyOutcome("ArrowDown").outcome, "exit"); +}); + +test("left and right differ only in the edge the image leaves by", () => { + // Direction is the animation and nothing else — the next image is random + // either way, exactly as with a horizontal swipe. + const left = keyOutcome("ArrowLeft"); + const right = keyOutcome("ArrowRight"); + assert.equal(left.outcome, right.outcome); + assert.equal(left.direction, -right.direction); +}); + +test("every outcome a key produces is one the viewer already knows how to run", () => { + // commit() in app.js switches on this, and swipeOutcome's vocabulary is what + // it was written against. A new outcome here would reach it as a silent + // fall-through to "another image". + const swipeVocabulary = new Set(["random", "exit"]); + for (const key of ["ArrowLeft", "ArrowRight", "ArrowDown"]) { + assert.ok(swipeVocabulary.has(keyOutcome(key).outcome), key); + } +}); + +test("up is unbound, as are the keys the browser wants for itself", () => { + // Not the system-gesture conflict that keeps swipe-up unused — a keyboard has + // none. There is simply nothing for it to mean: left and right already say + // "another image". + for (const key of ["ArrowUp", " ", "Enter", "Escape", "PageDown", "a", "Tab"]) { + assert.equal(keyOutcome(key), null, key); + } +}); + // --------------------------------------------------------------------------- // Random draw // --------------------------------------------------------------------------- diff --git a/static/app.js b/static/app.js index f3467ee..8f6fdf7 100644 --- a/static/app.js +++ b/static/app.js @@ -12,6 +12,7 @@ // absent from every browser on iOS. import { + keyOutcome, Metrics, placement, predictEnd, @@ -102,13 +103,18 @@ const ScreenBoost = { // --------------------------------------------------------------------------- // Viewer — one image, always fitted, no chrome. // -// * swipe left or right — clear the image and show another at random -// * swipe down — clear the image and return to the entry screen +// * swipe left or right, or press ← or → — clear the image and show another +// at random +// * swipe down, or press ↓ — clear the image and return to the entry screen // // Swiping up is deliberately unused. On a phone an upward drag near the bottom // edge belongs to the system, so binding anything to it means the gesture // sometimes dismisses the app instead. Left/right has no such conflict, and // direction carries no meaning when the next image is random. +// +// The keys are for a desktop browser, where the thresholds a swipe is written +// against — a fifth of the viewport, a flick at a finger's speed — are not what +// a mouse produces. Both routes converge on commit() so they cannot drift. // --------------------------------------------------------------------------- const Viewer = { @@ -154,6 +160,12 @@ const Viewer = { this.el.addEventListener("lostpointercapture", () => { if (!this.settling && this.axis !== null) this.springBack(); }); + + // On the document rather than the viewer: nothing inside the viewer is + // focusable, so keystrokes there arrive at the body. onKeyDown ignores + // everything while the viewer is hidden, which leaves the entry screen's + // buttons their own keyboard behaviour. + document.addEventListener("keydown", (e) => this.onKeyDown(e)); }, show(image) { @@ -407,23 +419,61 @@ const Viewer = { ); switch (swipeOutcome(axis, travel, predicted, viewport)) { - case "random": { + case "random": // Which edge it leaves by follows the drag; either direction means the // same thing, so only the animation differs. - const direction = travel.x < 0 || predicted.x < 0 ? -1 : 1; - this.dismiss(direction * Math.max(viewport.width, 1), 0, () => - App.showRandomImage(), - ); + this.commit("random", travel.x < 0 || predicted.x < 0 ? -1 : 1); break; - } case "exit": - this.dismiss(0, Math.max(viewport.height, 1), () => App.exit()); + this.commit("exit", 1); break; default: this.springBack(); } }, + // MARK: - Keys + // + // The keyboard half of the gestures — see keyOutcome() in core.js for what is + // bound and what deliberately is not. + + onKeyDown(e) { + // Not on the entry screen, and not on top of a gesture: `start` is a drag + // in progress, `settling` a swipe still animating out. Both would have the + // image swapped from under them. + if (this.el.hidden || this.settling || this.start) return; + // A modified arrow is a browser shortcut — history navigation, scroll to + // the end — and taking it would be rude even where it currently does + // nothing useful. + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + // A held key repeats at the system rate, which would abort each load to + // start the next and put nothing on screen for as long as it is held. + if (e.repeat) return; + + const action = keyOutcome(e.key); + if (!action) return; + + // Only for a key that resolved to something, so ↑ and everything else keep + // whatever the browser does with them. + e.preventDefault(); + this.commit(action.outcome, action.direction); + }, + + // Carry out an outcome, from either a swipe or a key. + // + // Both routes end here so they cannot drift apart: the animation, the clear, + // and the ordering between them are written once. + commit(outcome, direction) { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + if (outcome === "exit") { + this.dismiss(0, Math.max(viewport.height, 1), () => App.exit()); + } else { + this.dismiss(direction * Math.max(viewport.width, 1), 0, () => + App.showRandomImage(), + ); + } + }, + setTransform(x, y, animated, duration = 0) { this.photo.style.transition = animated ? `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)` @@ -445,6 +495,18 @@ const Viewer = { // ordering is the whole reason this goes through a completion handler instead // of just calling finish(). dismiss(x, y, finish) { + // Nothing on screen to slide out: a viewer still loading its first image, + // or one showing the error. A key reaches this state where a swipe cannot + // — onPointerDown refuses to start on a hidden photo — and a display:none + // element never fires transitionend, so this would otherwise sit through + // the whole timeout before doing anything. It also means the error state + // now has a way out that is not a page reload. + if (this.photo.hidden) { + this.clearPhoto(); + finish(); + return; + } + this.settling = true; const done = () => { diff --git a/static/core.js b/static/core.js index bf9d8e6..d1176d1 100644 --- a/static/core.js +++ b/static/core.js @@ -176,6 +176,33 @@ export function swipeOutcome(axis, travel, predicted, viewport) { return "none"; } +// What an arrow key means, or null for a key this viewer does not use. +// +// Keys exist because a mouse has no swipe: a desktop browser can drag, but the +// gesture thresholds are written for a finger and the whole idea reads as a +// touchscreen one. The outcomes are deliberately the same two a swipe produces, +// so both go through one path in the viewer. +// +// Left and right differ only in the edge the image leaves by, exactly as with a +// swipe — the next image is random either way, so direction carries no meaning. +// +// Up is unbound, and not because of the system-gesture conflict that keeps +// swipe-up unused: a keyboard has no such conflict. It is unbound because there +// is nothing for it to mean. "Another image" is already left and right, and a +// third key doing the same thing is not a feature. +export function keyOutcome(key) { + switch (key) { + case "ArrowLeft": + return { outcome: "random", direction: -1 }; + case "ArrowRight": + return { outcome: "random", direction: 1 }; + case "ArrowDown": + return { outcome: "exit", direction: 1 }; + default: + return null; + } +} + // --------------------------------------------------------------------------- // Random draw // ---------------------------------------------------------------------------