diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx
index efbdfc4..589dcc4 100644
--- a/src/client/components/Controls.tsx
+++ b/src/client/components/Controls.tsx
@@ -1,7 +1,9 @@
+import { useState } from "react";
import type { LaneId } from "../../shared/custom.ts";
import type { Region } from "../../shared/schema.ts";
import { REGION_LABEL } from "../../shared/time.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
+import { moveGame } from "../state/gameOrder.ts";
import type { ThemeChoice } from "../state/theme.ts";
import type { Prefs } from "../state/usePrefs.ts";
import { YourOwn } from "./YourOwn.tsx";
@@ -72,31 +74,19 @@ export function Controls({
you came to change. */}
+
+ // The whole displayed list, every time: the indices are positions
+ // on screen, and what the reader is looking at is the order they
+ // mean. See `moveGame`.
+ onUpdate({ gameOrder: moveGame(games, from, to) })
+ }
+ onReset={() => onUpdate({ gameOrder: undefined })}
+ />
@@ -290,3 +280,169 @@ export function Controls({
);
}
+
+/**
+ * Which games the reader plays, and the order they read them in.
+ *
+ * One row per game rather than the chip row this replaced: a reorder affordance
+ * needs somewhere to put a handle and two arrows, and at fourteen games a list
+ * reads better than a wrapped row of pills anyway.
+ *
+ * **Reordering lives here and nowhere else.** The focus bar and the dailies strip
+ * are the fastest tap targets on the page — the strip is the part of it that is
+ * answerable in ten seconds — and a drag target sitting on top of a tick target
+ * costs somebody a streak the first time it misfires. So the live surfaces stay
+ * drag-free and this is the screen you visit on purpose.
+ *
+ * The row itself is not a target. The handle, the two arrows and the on/off
+ * switch are four explicit controls, and nothing else here is clickable — which
+ * is what keeps this the right side of "a list row is one target": that rule is
+ * about a full-bleed row target with a second control hidden inside it.
+ */
+function GameOrder({
+ games,
+ hidden,
+ custom,
+ onToggleGame,
+ onReorder,
+ onReset,
+}: {
+ /** Every lane, already in the reader's order. */
+ games: LaneId[];
+ hidden: LaneId[];
+ /** Whether the reader has an order of their own, so reset has something to do. */
+ custom: boolean;
+ onToggleGame: (g: LaneId) => void;
+ onReorder: (from: number, to: number) => void;
+ onReset: () => void;
+}) {
+ const gameMeta = useGameMeta();
+ const [dragging, setDragging] = useState(null);
+
+ return (
+ <>
+
+
Games
+ {custom && (
+
+ )}
+
+
+ {/* Both affordances, always visible. Touch fires no drag events at all, so
+ the arrows are the mechanism and the handle is the fast path where a
+ pointer exists — and the arrows are ordinary buttons, which is what
+ makes this reachable by keyboard and screen reader without a second
+ implementation of the same interaction. */}
+
+ Drag a row, or use the arrows, to put your games in order. Everything
+ that lists a game follows it.
+
+
+
+ {games.map((id, i) => {
+ const game = gameMeta(id);
+ const on = !hidden.includes(id);
+ return (
+
{
+ setDragging(i);
+ e.dataTransfer.effectAllowed = "move";
+ }}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={() => {
+ if (dragging !== null && dragging !== i) onReorder(dragging, i);
+ setDragging(null);
+ }}
+ onDragEnd={() => setDragging(null)}
+ className={`flex items-center gap-2 rounded-lg py-1 ${
+ dragging === i ? "opacity-40" : ""
+ }`}
+ >
+
+ ⠿
+
+
+ {i + 1}
+
+
+
+
+ {/* Rendered at the ends too, and inert there. A control that
+ disappears on the first row slides the other one under the
+ finger that was aiming at it. */}
+ onReorder(i, i - 1)}
+ d="M8 3.5l4.5 5h-9z"
+ />
+ onReorder(i, i + 1)}
+ d="M8 12.5l-4.5-5h9z"
+ />
+
+ );
+ })}
+
+ >
+ );
+}
+
+/** One arrow. Disabled at the ends rather than removed — see above. */
+function Nudge({
+ label,
+ disabled,
+ onClick,
+ d,
+}: {
+ label: string;
+ disabled: boolean;
+ onClick: () => void;
+ d: string;
+}) {
+ return (
+
+ );
+}
diff --git a/test/controls.test.tsx b/test/controls.test.tsx
index 06e52ed..4f0b34b 100644
--- a/test/controls.test.tsx
+++ b/test/controls.test.tsx
@@ -133,3 +133,40 @@ describe("Controls: what am I allowed to look at", () => {
expect(render(PREFS, 3)).toContain("Show the 3 events I'm");
});
});
+
+describe("Controls: the game order editor", () => {
+ const html = () => render(PREFS);
+
+ test("both affordances ship, because touch fires no drag events", () => {
+ // The arrows are the mechanism and the handle is the pointer fast path. A
+ // drag-only list is unreachable on a phone and by keyboard alike.
+ expect(html()).toContain("draggable");
+ expect(html()).toContain('aria-label="Move Genshin Impact up (1 of 2)"');
+ expect(html()).toContain('aria-label="Move Honkai: Star Rail down (2 of 2)"');
+ });
+
+ test("an arrow at the end is disabled, not missing", () => {
+ // A control that disappears on the first row slides the other one under the
+ // finger aiming at it.
+ const markup = html();
+ // The whole tag: `disabled` is serialised before `aria-label`.
+ const tag = (label: string) =>
+ new RegExp(`