Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import assert from "node:assert/strict";
import {
Metrics,
fittedSize,
keyOutcome,
placement,
predictEnd,
randomFrom,
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
80 changes: 71 additions & 9 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// absent from every browser on iOS.

import {
keyOutcome,
Metrics,
placement,
predictEnd,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)`
Expand All @@ -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 = () => {
Expand Down
27 changes: 27 additions & 0 deletions static/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading