···103103 return { lang, puzzleNumber };
104104}
105105106106+/**
107107+ * Where the language toggle should take you. Puzzle pages encode the language
108108+ * in the URL (`/p/<lang>/<n>`), so the router re-derives the language from the
109109+ * path on every render — switching `ctx.lang` alone leaves a puzzle page on its
110110+ * original language. Returns the equivalent puzzle path in `lang`, or null when
111111+ * the current path is language-independent (or already in `lang`) and a plain
112112+ * re-render suffices.
113113+ */
114114+export function pathForLangSwitch(currentPath: string, lang: Lang): string | null {
115115+ const puzzle = parsePuzzleTarget(currentPath);
116116+ if (puzzle && puzzle.lang !== lang) return `/p/${lang}/${puzzle.puzzleNumber}`;
117117+ return null;
118118+}
119119+106120/** Deterministic rkey for a result record: `<lang>-<puzzleNumber>` (documented in the lexicon). */
107121export function resultRkey(lang: Lang, puzzleNumber: number): string {
108122 return `${lang}-${puzzleNumber}`;
+8-1
src/main.ts
···11import './ui/theme.css';
22-import { isLang, type Lang, parsePuzzleTarget } from './config.js';
22+import { isLang, type Lang, parsePuzzleTarget, pathForLangSwitch } from './config.js';
33import {
44 isOAuthCallback,
55 completeCallback,
···3737 ctx.lang = lang;
3838 localStorage.setItem(LANG_KEY, lang);
3939 document.documentElement.lang = lang;
4040+ // On a puzzle page the language lives in the URL, so switch the path too —
4141+ // otherwise render() re-derives the old language and nothing visibly changes.
4242+ const dest = pathForLangSwitch(location.pathname, lang);
4343+ if (dest) {
4444+ ctx.navigate(dest);
4545+ return;
4646+ }
4047 render();
4148 },
4249
+17
tests/config.test.ts
···22import {
33 puzzleTarget,
44 parsePuzzleTarget,
55+ pathForLangSwitch,
56 resultRkey,
67 daysSinceEpoch,
78 puzzleNumberFor,
···3233 expect(parsePuzzleTarget('https://atmot.herve.bzh/p/FR/412')).toBeNull();
3334 expect(parsePuzzleTarget('https://atmot.herve.bzh/p/de/1')).toBeNull();
3435 expect(parsePuzzleTarget('https://atmot.herve.bzh/p/fr/0')).toBeNull();
3636+ });
3737+});
3838+3939+describe('pathForLangSwitch — keep puzzle pages in sync with the lang toggle', () => {
4040+ it('redirects a puzzle page to the same puzzle in the new language', () => {
4141+ expect(pathForLangSwitch('/p/fr/1', 'en')).toBe('/p/en/1');
4242+ expect(pathForLangSwitch('/p/en/412', 'fr')).toBe('/p/fr/412');
4343+ });
4444+4545+ it('returns null when the puzzle is already in the target language', () => {
4646+ expect(pathForLangSwitch('/p/fr/1', 'fr')).toBeNull();
4747+ });
4848+4949+ it('returns null on language-independent paths (home, about)', () => {
5050+ expect(pathForLangSwitch('/', 'fr')).toBeNull();
5151+ expect(pathForLangSwitch('/about', 'en')).toBeNull();
3552 });
3653});
3754
+38
tests/header.test.ts
···11+// @vitest-environment happy-dom
22+import { describe, expect, it, vi } from 'vitest';
33+import { renderHeader } from '../src/ui/header.js';
44+import { pathForLangSwitch } from '../src/config.js';
55+import type { Ctx } from '../src/ui/context.js';
66+77+function makeCtx(over: Partial<Ctx> = {}): Ctx {
88+ return {
99+ lang: 'fr',
1010+ session: null,
1111+ handle: null,
1212+ pendingRecord: null,
1313+ setLang() {},
1414+ navigate() {},
1515+ rerender() {},
1616+ async signInWith() {},
1717+ async signOut() {},
1818+ ...over,
1919+ };
2020+}
2121+2222+describe('renderHeader language switch', () => {
2323+ it('the EN button asks to switch to English', () => {
2424+ const setLang = vi.fn();
2525+ const header = renderHeader(makeCtx({ setLang }));
2626+ const en = [...header.querySelectorAll('button')].find((b) => b.textContent === 'EN');
2727+ expect(en).toBeDefined();
2828+ en!.click();
2929+ expect(setLang).toHaveBeenCalledWith('en');
3030+ });
3131+3232+ // Regression: on a French puzzle page, switching to EN must route to the
3333+ // English puzzle. Previously setLang only flipped ctx.lang and re-rendered,
3434+ // so the router re-derived `fr` from the unchanged path and nothing changed.
3535+ it('switching language on a puzzle page routes to the same puzzle in the new language', () => {
3636+ expect(pathForLangSwitch('/p/fr/1', 'en')).toBe('/p/en/1');
3737+ });
3838+});