Show layout-aware keyboard labels

#2
by vbonnet - opened
app/package.json CHANGED
@@ -5,6 +5,7 @@
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
 
8
  "build": "vite build",
9
  "preview": "vite preview"
10
  },
 
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
+ "test": "node --test \"test/**/*.test.js\"",
9
  "build": "vite build",
10
  "preview": "vite preview"
11
  },
app/src/ui/TitleMenu.jsx CHANGED
@@ -15,6 +15,7 @@ import { useGame } from "../store.js";
15
  import { signed } from "../game/signed.js";
16
  import { INK, ORANGE, MONO } from "../theme.js";
17
  import { ComicButton, ComicTitle, HalftoneRamp, ANTON } from "./comic.jsx";
 
18
 
19
  const rowIn = keyframes`
20
  from { transform: translateY(12px); opacity: 0; }
@@ -49,13 +50,14 @@ const Kbd = styled("kbd")(({ round }) => ({
49
  boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
50
  }));
51
 
 
52
  const TILES = {
53
  kb: [
54
- { caps: "cluster-arrows", name: "Move", hint: "arrows or ZQSD" },
55
- { caps: ["A", "E"], name: "Kick", hint: "left / right" },
56
- { caps: ["R"], name: "Sit", hint: "tap again to stand" },
57
- { caps: ["G"], name: "Pick up", hint: "beak to the ground" },
58
- { caps: ["C"], name: "Camera", hint: "toggle chase" },
59
  { caps: ["Space"], name: "Reset", hint: "fresh start" },
60
  ],
61
  pad: [
@@ -77,6 +79,33 @@ const HINTS = {
77
  touch: "drag to orbit \u00b7 pinch to zoom",
78
  };
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  function closeMenu() {
81
  useGame.setState({ menuOpen: false });
82
  if (!useGame.getState().entered) useGame.setState({ entered: true });
@@ -89,6 +118,9 @@ export default function TitleMenu() {
89
  const touchMode = useGame((s) => s.touchMode);
90
  const [closing, setClosing] = useState(false);
91
  const prevOpen = useRef(menuOpen);
 
 
 
92
 
93
  // Keep the overlay mounted through the 0.35 s closing fade.
94
  useEffect(() => {
@@ -126,9 +158,14 @@ export default function TitleMenu() {
126
 
127
  if (!menuOpen && !closing) return null;
128
 
129
- // A plugged-in gamepad wins over the touch tutorial.
130
- const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb";
131
- const tiles = TILES[tutorialVariant];
 
 
 
 
 
132
  const ctaLabel = entered ? "Resume" : "Waddle in";
133
 
134
  return (
@@ -320,8 +357,8 @@ export default function TitleMenu() {
320
  </Box>
321
  ) : (
322
  <Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
323
- {t.caps.map((c) => (
324
- <Kbd key={c} round={t.round ? 1 : 0}>{c}</Kbd>
325
  ))}
326
  </Box>
327
  )}
 
15
  import { signed } from "../game/signed.js";
16
  import { INK, ORANGE, MONO } from "../theme.js";
17
  import { ComicButton, ComicTitle, HalftoneRamp, ANTON } from "./comic.jsx";
18
+ import { readLayoutMap, resolveKeycaps } from "./keyboard-layout.js";
19
 
20
  const rowIn = keyframes`
21
  from { transform: translateY(12px); opacity: 0; }
 
50
  boxShadow: "0 0 0 2px rgba(255, 255, 255, 0.82)",
51
  }));
52
 
53
+ // Letter keycaps are resolved from physical codes for the active layout.
54
  const TILES = {
55
  kb: [
56
+ { caps: "cluster-arrows", name: "Move" },
57
+ { codes: ["KeyQ", "KeyE"], name: "Kick", hint: "left / right" },
58
+ { codes: ["KeyR"], name: "Sit", hint: "tap again to stand" },
59
+ { codes: ["KeyG"], name: "Pick up", hint: "beak to the ground" },
60
+ { codes: ["KeyC"], name: "Camera", hint: "toggle chase" },
61
  { caps: ["Space"], name: "Reset", hint: "fresh start" },
62
  ],
63
  pad: [
 
79
  touch: "drag to orbit \u00b7 pinch to zoom",
80
  };
81
 
82
+ const KB_CODES = TILES.kb.flatMap((t) => t.codes ?? []);
83
+
84
+ function useKeycaps(enabled) {
85
+ const [keycaps, setKeycaps] = useState(() => resolveKeycaps(KB_CODES, null));
86
+
87
+ useEffect(() => {
88
+ if (!enabled) return;
89
+ let cancelled = false;
90
+ let latest = 0;
91
+ const read = async () => {
92
+ const ticket = ++latest;
93
+ const map = await readLayoutMap();
94
+ // Overlapping reads can settle out of order; only the newest paints.
95
+ if (!cancelled && ticket === latest) setKeycaps(resolveKeycaps(KB_CODES, map));
96
+ };
97
+ read();
98
+ const keyboard = navigator.keyboard;
99
+ keyboard?.addEventListener?.("layoutchange", read);
100
+ return () => {
101
+ cancelled = true;
102
+ keyboard?.removeEventListener?.("layoutchange", read);
103
+ };
104
+ }, [enabled]);
105
+
106
+ return keycaps;
107
+ }
108
+
109
  function closeMenu() {
110
  useGame.setState({ menuOpen: false });
111
  if (!useGame.getState().entered) useGame.setState({ entered: true });
 
118
  const touchMode = useGame((s) => s.touchMode);
119
  const [closing, setClosing] = useState(false);
120
  const prevOpen = useRef(menuOpen);
121
+ // A plugged-in gamepad wins over the touch tutorial.
122
+ const tutorialVariant = padConnected ? "pad" : touchMode ? "touch" : "kb";
123
+ const { labels, moveHint } = useKeycaps(tutorialVariant === "kb");
124
 
125
  // Keep the overlay mounted through the 0.35 s closing fade.
126
  useEffect(() => {
 
158
 
159
  if (!menuOpen && !closing) return null;
160
 
161
+ const tiles =
162
+ tutorialVariant === "kb"
163
+ ? TILES.kb.map((t) => ({
164
+ ...t,
165
+ caps: t.codes ? t.codes.map((code) => labels[code]) : t.caps,
166
+ hint: t.hint ?? moveHint,
167
+ }))
168
+ : TILES[tutorialVariant];
169
  const ctaLabel = entered ? "Resume" : "Waddle in";
170
 
171
  return (
 
357
  </Box>
358
  ) : (
359
  <Box sx={{ display: "flex", gap: "0.22rem", justifyContent: "center" }}>
360
+ {t.caps.map((c, i) => (
361
+ <Kbd key={i} round={t.round ? 1 : 0}>{c}</Kbd>
362
  ))}
363
  </Box>
364
  )}
app/src/ui/keyboard-layout.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Controls use physical KeyboardEvent.code positions; getLayoutMap() provides
2
+ // the characters printed on those keys for the active layout.
3
+ const MOVEMENT_CODES = ["KeyW", "KeyA", "KeyS", "KeyD"];
4
+ const MOVEMENT_FALLBACK_HINT = "arrows or WASD";
5
+
6
+ function keycap(value) {
7
+ if (typeof value !== "string") return null;
8
+ const glyph = value.trim();
9
+ if ([...glyph].length !== 1 || /[\p{M}\p{C}\p{Z}]/u.test(glyph)) return null;
10
+ const upper = glyph.toUpperCase();
11
+ return [...upper].length === 1 ? upper : glyph; // "ß" must not become "SS"
12
+ }
13
+
14
+ export function resolveKeycaps(codes, layoutMap) {
15
+ const required = [...new Set([...MOVEMENT_CODES, ...codes])];
16
+ const labels = {};
17
+ let complete = true;
18
+ for (const code of required) {
19
+ let label = null;
20
+ try {
21
+ label = keycap(layoutMap?.get?.(code));
22
+ } catch {
23
+ label = null;
24
+ }
25
+ if (!label) complete = false;
26
+ labels[code] = label;
27
+ }
28
+ if (!complete) {
29
+ for (const code of required) labels[code] = code.replace(/^Key/, "");
30
+ return { labels, moveHint: MOVEMENT_FALLBACK_HINT };
31
+ }
32
+ return {
33
+ labels,
34
+ moveHint: `arrows or ${MOVEMENT_CODES.map((code) => labels[code]).join("")}`,
35
+ };
36
+ }
37
+
38
+ export async function readLayoutMap(nav = globalThis.navigator) {
39
+ try {
40
+ return (await nav?.keyboard?.getLayoutMap?.()) ?? null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
app/test/keyboard-layout.test.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { readLayoutMap, resolveKeycaps } from "../src/ui/keyboard-layout.js";
5
+
6
+ const AZERTY = new Map(
7
+ Object.entries({ KeyW: "z", KeyA: "q", KeyS: "s", KeyD: "d", KeyQ: "a", KeyE: "e" }),
8
+ );
9
+ const QWERTY = new Map(
10
+ Object.entries({ KeyW: "w", KeyA: "a", KeyS: "s", KeyD: "d", KeyQ: "q", KeyE: "e" }),
11
+ );
12
+ const DVORAK = new Map(
13
+ Object.entries({ KeyW: ",", KeyA: "a", KeyS: "o", KeyD: "e", KeyQ: "'", KeyE: "." }),
14
+ );
15
+
16
+ const CODES = ["KeyQ", "KeyE"];
17
+
18
+ test("resolves caps and the movement hint from the active layout", () => {
19
+ assert.deepEqual(resolveKeycaps(CODES, QWERTY), {
20
+ labels: { KeyW: "W", KeyA: "A", KeyS: "S", KeyD: "D", KeyQ: "Q", KeyE: "E" },
21
+ moveHint: "arrows or WASD",
22
+ });
23
+ const azerty = resolveKeycaps(CODES, AZERTY);
24
+ assert.deepEqual(azerty.labels, {
25
+ KeyW: "Z", KeyA: "Q", KeyS: "S", KeyD: "D", KeyQ: "A", KeyE: "E",
26
+ });
27
+ assert.equal(azerty.moveHint, "arrows or ZQSD");
28
+
29
+ const dvorak = resolveKeycaps(CODES, DVORAK);
30
+ assert.equal(dvorak.labels.KeyQ, "'");
31
+ assert.equal(dvorak.moveHint, "arrows or ,AOE");
32
+ });
33
+
34
+ test("falls back to QWERTY labels without a map", () => {
35
+ assert.deepEqual(resolveKeycaps(CODES, null), {
36
+ labels: { KeyW: "W", KeyA: "A", KeyS: "S", KeyD: "D", KeyQ: "Q", KeyE: "E" },
37
+ moveHint: "arrows or WASD",
38
+ });
39
+ assert.equal(resolveKeycaps(["KeyX"], null).labels.KeyX, "X");
40
+ });
41
+
42
+ const ALL_QWERTY = {
43
+ labels: { KeyW: "W", KeyA: "A", KeyS: "S", KeyD: "D", KeyQ: "Q", KeyE: "E" },
44
+ moveHint: "arrows or WASD",
45
+ };
46
+
47
+ test("falls back entirely to QWERTY when the map misses a movement key", () => {
48
+ const partial = new Map([["KeyW", "z"], ["KeyA", "q"], ["KeyS", "s"]]);
49
+ assert.deepEqual(resolveKeycaps(CODES, partial), ALL_QWERTY);
50
+ });
51
+
52
+ test("falls back entirely to QWERTY when the map misses a requested key", () => {
53
+ const partial = new Map([...AZERTY].filter(([code]) => code !== "KeyE"));
54
+ assert.deepEqual(resolveKeycaps(CODES, partial), ALL_QWERTY);
55
+ });
56
+
57
+ test("rejects layout values that cannot sit on a keycap", () => {
58
+ const junk = new Map(
59
+ Object.entries({ KeyQ: "", KeyE: " ", KeyW: "Dead", KeyA: "́", KeyS: 7 }),
60
+ );
61
+ assert.deepEqual(resolveKeycaps(CODES, junk), ALL_QWERTY);
62
+ });
63
+
64
+ test("a throwing map cannot break the menu", () => {
65
+ const hostile = { get: () => { throw new Error("nope"); } };
66
+ assert.deepEqual(resolveKeycaps(CODES, hostile), ALL_QWERTY);
67
+ });
68
+
69
+ test("readLayoutMap yields null when the API is absent or refuses", async () => {
70
+ assert.equal(await readLayoutMap({}), null);
71
+ assert.equal(await readLayoutMap({ keyboard: {} }), null);
72
+ const rejects = { keyboard: { getLayoutMap: () => Promise.reject(new Error("blocked")) } };
73
+ assert.equal(await readLayoutMap(rejects), null);
74
+ const throws = { keyboard: { getLayoutMap: () => { throw new Error("framed"); } } };
75
+ assert.equal(await readLayoutMap(throws), null);
76
+ });
77
+
78
+ test("readLayoutMap returns the map where the API is supported", async () => {
79
+ const nav = { keyboard: { getLayoutMap: async () => AZERTY } };
80
+ assert.equal(await readLayoutMap(nav), AZERTY);
81
+ });