Add text size and keyboard settings (#38)
Checks / web (push) Failing after 18s
Private ROM checks / private-rom (push) Successful in 14m40s

Settings sets the text size (100, 125 or 150 %) and the key of each
button, kept in this browser's storage and validated on load. Every font
size scales with --text-scale, and the dialogue box scrolls instead of
growing past the screen. The game screen's label, the Play message and
Help's control list name the current keys.

Screen reader announcements for every native screen and touch control
remapping remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samyandClaude Opus 5.5 committed 2026-10-03 20:42:59 +02:00
1 parent 29248172bd
commit f289615bd2
10 files changed
+588 -41

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buttonKeys,
defaultPreferences,
keyMap,
keyName,
loadPreferences,
rebind,
savePreferences,
} from "../src/ui/preferences";
function storage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
values,
};
}
describe("interface preferences", () => {
afterEach(() => vi.unstubAllGlobals());
it("defaults to the original keys and standard text", () => {
const preferences = defaultPreferences();
expect(preferences.textScale).toBe(1);
const map = keyMap(preferences);
expect(map.get("KeyX")).toBe("a");
expect(map.get("KeyZ")).toBe("b");
expect(map.get("Enter")).toBe("start");
expect(map.get("ShiftRight")).toBe("select");
expect(buttonKeys(preferences, "select")).toBe("Shift");
expect(buttonKeys(preferences, "up")).toBe("↑");
});
it("names keys readably", () => {
expect(keyName("KeyQ")).toBe("Q");
expect(keyName("Digit4")).toBe("4");
expect(keyName("ArrowLeft")).toBe("←");
expect(keyName("Semicolon")).toBe("Semicolon");
});
it("moves a key to its new button and refuses reserved keys", () => {
let preferences = rebind(defaultPreferences(), "a", "KeyZ");
expect(preferences.keys.a).toEqual(["KeyZ"]);
expect(preferences.keys.b).toEqual([]);
expect(buttonKeys(preferences, "b")).toBe("None");
preferences = rebind(preferences, "start", "Tab");
expect(preferences.keys.start).toEqual(["Enter"]);
});
it("round-trips through storage and rejects invalid entries", () => {
const store = storage();
vi.stubGlobal("localStorage", store);
const saved = { ...rebind(defaultPreferences(), "start", "KeyQ"), textScale: 1.5 as const };
savePreferences(saved);
expect(loadPreferences()).toEqual(saved);
// A duplicated key or an unknown scale falls back to the defaults.
store.values.set(
"crystal-preferences-v1",
JSON.stringify({ textScale: 3, keys: { ...saved.keys, b: ["KeyQ"] } }),
);
expect(loadPreferences()).toEqual(defaultPreferences());
store.values.set("crystal-preferences-v1", "{not json");
expect(loadPreferences()).toEqual(defaultPreferences());
});
it("falls back to the defaults without storage", () => {
vi.stubGlobal("localStorage", {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
});
expect(loadPreferences()).toEqual(defaultPreferences());
expect(() => savePreferences(defaultPreferences())).not.toThrow();
});
});