Stage 17: test-contour verification & defect fixes #19

Merged
developer merged 28 commits from feature/stage-17-contour-verification-fixes into development 2026-06-07 19:20:40 +00:00
19 changed files with 239 additions and 53 deletions
Showing only changes of commit 1d0bafaabb - Show all commits
+4
View File
@@ -10,6 +10,10 @@ async function openGame(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
// Wait for the screen-slide transition to settle so only the game pane remains;
// until it does, the leaving lobby pane's header (its menu button) is also in the
// DOM, which would make shared locators like .burger ambiguous.
await expect(page.locator('.pane')).toHaveCount(1);
} }
test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => { test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => {
+40 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte'; import { app, bootstrap } from './lib/app.svelte';
import { router } from './lib/router.svelte'; import { router } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte'; import { t } from './lib/i18n/index.svelte';
@@ -17,11 +18,36 @@
onMount(() => { onMount(() => {
void bootstrap(); void bootstrap();
}); });
// Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so
// they do not play on the initial mount, and collapse to nothing under reduce-motion.
const dir = $derived(router.route.name === 'lobby' ? 'back' : 'forward');
const enterSign = $derived(dir === 'forward' ? 1 : -1);
const leaveSign = $derived(dir === 'forward' ? -1 : 1);
const routeKey = $derived(router.route.name + (router.route.params.id ?? ''));
const animMs = $derived(app.reduceMotion ? 0 : 260);
// slideX slides a pane horizontally by a full width. sign>0 enters from / exits to
// the right; sign<0 the left. Percentage keeps it viewport-relative without reading
// innerWidth, and the .router clips the off-screen pane.
function slideX(_node: Element, { duration, sign }: { duration: number; sign: number }) {
return {
duration,
easing: cubicOut,
css: (tt: number) => `transform: translateX(${(1 - tt) * sign * 100}%)`,
};
}
</script> </script>
{#if !app.ready} {#if !app.ready}
<div class="splash">{t('common.loading')}</div> <div class="splash">{t('common.loading')}</div>
{:else if router.route.name === 'login'} {:else}
<div class="router">
{#key routeKey}
<div class="pane" in:slideX={{ duration: animMs, sign: enterSign }} out:slideX={{ duration: animMs, sign: leaveSign }}>
{#if router.route.name === 'login'}
<Login /> <Login />
{:else if router.route.name === 'new'} {:else if router.route.name === 'new'}
<NewGame /> <NewGame />
@@ -40,6 +66,10 @@
{:else} {:else}
<Lobby /> <Lobby />
{/if} {/if}
</div>
{/key}
</div>
{/if}
<Toast /> <Toast />
@@ -50,4 +80,13 @@
place-items: center; place-items: center;
color: var(--text-muted); color: var(--text-muted);
} }
.router {
position: relative;
height: 100%;
overflow: hidden;
}
.pane {
position: absolute;
inset: 0;
}
</style> </style>
+3
View File
@@ -11,6 +11,7 @@
--bg-elev: #ffffff; --bg-elev: #ffffff;
--surface: #ffffff; --surface: #ffffff;
--surface-2: #eef0f3; --surface-2: #eef0f3;
--ad-bg: #e3e7ee; /* announcement banner: a subtle accent, darker in light theme */
--text: #14181f; --text: #14181f;
--text-muted: #6b7280; --text-muted: #6b7280;
--border: #d8dce2; --border: #d8dce2;
@@ -51,6 +52,7 @@
--bg-elev: #171a21; --bg-elev: #171a21;
--surface: #171a21; --surface: #171a21;
--surface-2: #1f242d; --surface-2: #1f242d;
--ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */
--text: #e7eaf0; --text: #e7eaf0;
--text-muted: #9aa3b2; --text-muted: #9aa3b2;
--border: #2a313c; --border: #2a313c;
@@ -82,6 +84,7 @@
--bg-elev: #171a21; --bg-elev: #171a21;
--surface: #171a21; --surface: #171a21;
--surface-2: #1f242d; --surface-2: #1f242d;
--ad-bg: #272f3c; /* announcement banner: a subtle accent, lighter in dark theme */
--text: #e7eaf0; --text: #e7eaf0;
--text-muted: #9aa3b2; --text-muted: #9aa3b2;
--border: #2a313c; --border: #2a313c;
+1 -1
View File
@@ -57,7 +57,7 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
padding: 6px 0; padding: 6px 0;
background: var(--surface-2); background: var(--ad-bg);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.2; line-height: 1.2;
+5 -1
View File
@@ -6,12 +6,16 @@
messages, messages,
myId, myId,
busy, busy,
canNudge = true,
onsend, onsend,
onnudge, onnudge,
}: { }: {
messages: ChatMessage[]; messages: ChatMessage[];
myId: string; myId: string;
busy: boolean; busy: boolean;
// Nudging only makes sense while waiting on the opponent; it is disabled on the
// player's own turn (there is no one to hurry along).
canNudge?: boolean;
onsend: (text: string) => void; onsend: (text: string) => void;
onnudge: () => void; onnudge: () => void;
} = $props(); } = $props();
@@ -47,7 +51,7 @@
onkeydown={(e) => e.key === 'Enter' && send()} onkeydown={(e) => e.key === 'Enter' && send()}
/> />
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button> <button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy} aria-label={t('chat.nudge')}>🛎️</button> <button class="iconbtn" onclick={onnudge} disabled={busy || !canNudge} aria-label={t('chat.nudge')}>🛎️</button>
</div> </div>
</div> </div>
+52 -9
View File
@@ -18,6 +18,7 @@
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword'; import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { shareOrDownloadGcg } from '../lib/share'; import { shareOrDownloadGcg } from '../lib/share';
import { getCachedGame, setCachedGame } from '../lib/gamecache';
import { import {
BLANK, BLANK,
newPlacement, newPlacement,
@@ -94,6 +95,7 @@
]); ]);
view = st; view = st;
moves = hist.moves; moves = hist.moves;
setCachedGame(id, st, hist.moves);
placement = newPlacement(st.rack); placement = newPlacement(st.rack);
preview = null; preview = null;
selected = null; selected = null;
@@ -109,7 +111,17 @@
handleError(e); handleError(e);
} }
} }
onMount(load); onMount(() => {
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
if (cached) {
view = cached.view;
moves = cached.moves;
placement = newPlacement(cached.view.rack);
}
void load();
});
$effect(() => { $effect(() => {
const e = app.lastEvent; const e = app.lastEvent;
@@ -269,6 +281,17 @@
const h = await gateway.hint(id); const h = await gateway.hint(id);
if (h.move.tiles.length && view) { if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack); placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box.
const p = placement.pending;
if (p.length) {
const rows = p.map((tt) => tt.row);
const cols = p.map((tt) => tt.col);
focus = {
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
}
if (isCoarse()) zoomed = true; if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining }; view = { ...view, hintsRemaining: h.hintsRemaining };
recompute(); recompute();
@@ -428,7 +451,9 @@
{/snippet} {/snippet}
{#if view} {#if view}
<div class="scoreboard"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}>
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div> <div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
@@ -599,26 +624,39 @@
{#if panel === 'chat'} {#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}> <Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} onsend={sendChat} onnudge={nudge} /> <Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn} onsend={sendChat} onnudge={nudge} />
</Modal> </Modal>
{/if} {/if}
<style> <style>
.scoreboard { .scoreboard {
display: flex; display: flex;
gap: 2px; gap: 6px;
padding: 6px var(--pad); padding: 8px var(--pad);
background: var(--bg-elev); background: var(--bg-elev);
cursor: pointer;
} }
.seat { .seat {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 4px; padding: 5px 4px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-2);
/* inactive seats read as "sunk in" */
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.22);
} }
.seat.turn { .seat.turn {
background: var(--surface-2); /* the active seat is "raised": lifted clear of the others with side shadows */
outline: 1px solid var(--accent); background: var(--bg-elev);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.16),
-3px 0 6px -2px rgba(0, 0, 0, 0.26),
3px 0 6px -2px rgba(0, 0, 0, 0.26);
position: relative;
z-index: 1;
}
.seat.turn .nm {
color: var(--accent);
} }
.seat.win .sc { .seat.win .sc {
color: var(--ok); color: var(--ok);
@@ -642,8 +680,13 @@
position: absolute; position: absolute;
inset: 0 0 auto 0; inset: 0 0 auto 0;
z-index: 2; z-index: 2;
max-height: 60%; /* A fixed-height drawer matching the board's slid offset, so the bottom border
and its shadow pin to the board immediately instead of tracking the table as
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
column does not jump left/right when the list overflows. */
height: 62%;
overflow: auto; overflow: auto;
scrollbar-gutter: stable;
background: var(--surface-2); background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5); box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
+48 -5
View File
@@ -9,9 +9,10 @@ import { GatewayError } from './client';
import { navigate, router } from './router.svelte'; import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram'; import { insideTelegram, onTelegramPath, telegramColorScheme, telegramLaunch } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { clearGameCache } from './gamecache';
import type { BoardLabelMode } from './boardlabels'; import type { BoardLabelMode } from './boardlabels';
export interface Toast { export interface Toast {
@@ -47,8 +48,15 @@ export const app = $state<{
}); });
let unsubscribeStream: (() => void) | null = null; let unsubscribeStream: (() => void) | null = null;
let streamAlive = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null; let toastTimer: ReturnType<typeof setTimeout> | null = null;
/** documentHidden reports whether the app is currently backgrounded. */
function documentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export function showToast(text: string, kind: Toast['kind'] = 'info'): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text }; app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer); if (toastTimer) clearTimeout(toastTimer);
@@ -70,6 +78,7 @@ export function handleError(err: unknown): void {
function openStream(): void { function openStream(): void {
closeStream(); closeStream();
streamAlive = true;
unsubscribeStream = gateway.subscribe( unsubscribeStream = gateway.subscribe(
(e) => { (e) => {
app.lastEvent = e; app.lastEvent = e;
@@ -85,10 +94,30 @@ function openStream(): void {
void refreshNotifications(); void refreshNotifications();
} }
}, },
() => showToast(t('error.unavailable'), 'error'), () => {
streamAlive = false;
// A background suspend (iOS / Telegram) drops the single-shot stream. Don't
// alarm the user with the connection banner while hidden — reconnect silently
// on return (the visibilitychange handler). Show the banner only on a failure
// seen in the foreground, and retry it.
if (!documentHidden()) {
showToast(t('error.unavailable'), 'error');
scheduleReconnect();
}
},
); );
} }
/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
* app is in the foreground (a single pending attempt at a time). */
function scheduleReconnect(): void {
if (reconnectTimer || !app.session) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (app.session && !streamAlive && !documentHidden()) openStream();
}, 4000);
}
/** /**
* refreshNotifications recomputes the lobby badge count (incoming friend requests * refreshNotifications recomputes the lobby badge count (incoming friend requests
* plus open invitations). Authoritative poll, complementing the live 'notify' push. * plus open invitations). Authoritative poll, complementing the live 'notify' push.
@@ -111,8 +140,13 @@ export async function refreshNotifications(): Promise<void> {
} }
function closeStream(): void { function closeStream(): void {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
unsubscribeStream?.(); unsubscribeStream?.();
unsubscribeStream = null; unsubscribeStream = null;
streamAlive = false;
} }
async function adoptSession(s: Session): Promise<void> { async function adoptSession(s: Session): Promise<void> {
@@ -170,6 +204,10 @@ export async function bootstrap(): Promise<void> {
if (insideTelegram()) { if (insideTelegram()) {
const launch = telegramLaunch(); const launch = telegramLaunch();
if (launch.theme) applyTelegramTheme(launch.theme); if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
try { try {
await adoptSession(await gateway.authTelegram(launch.initData)); await adoptSession(await gateway.authTelegram(launch.initData));
await routeStartParam(launch.startParam); await routeStartParam(launch.startParam);
@@ -249,6 +287,7 @@ export async function loginEmail(email: string, code: string): Promise<void> {
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
closeStream(); closeStream();
clearGameCache();
gateway.setToken(null); gateway.setToken(null);
await clearSession(); await clearSession();
app.session = null; app.session = null;
@@ -314,10 +353,14 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs(); persistPrefs();
} }
// Refresh the lobby badge when the app returns to the foreground — a push 'notify' // On return to the foreground: silently re-establish a stream dropped while the app
// may have been missed while the client was hidden/closed (poll + push, see §10). // was backgrounded (iOS/Telegram suspend it), and refresh the lobby badge for any
// push 'notify' missed while hidden (poll + push, see §10).
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && app.session) void refreshNotifications(); if (document.visibilityState === 'visible' && app.session) {
if (!streamAlive) openStream();
void refreshNotifications();
}
}); });
} }
+30
View File
@@ -0,0 +1,30 @@
// In-memory per-game cache. A game the player has opened once is kept here so a
// later re-entry renders instantly from the cache while a fresh fetch updates it in
// the background — removing the blank "loading" flash and the full redraw on every
// lobby <-> game navigation. It is intentionally process-memory only (no persistence):
// stale entries are corrected by the background refresh, and the cache is cleared on
// logout.
import type { MoveRecord, StateView } from './model';
interface CachedGame {
view: StateView;
moves: MoveRecord[];
}
const cache = new Map<string, CachedGame>();
/** getCachedGame returns the last-seen state+history for a game, or undefined. */
export function getCachedGame(id: string): CachedGame | undefined {
return cache.get(id);
}
/** setCachedGame stores the latest state+history for a game. */
export function setCachedGame(id: string, view: StateView, moves: MoveRecord[]): void {
cache.set(id, { view, moves });
}
/** clearGameCache drops every cached game (called on logout). */
export function clearGameCache(): void {
cache.clear();
}
+1 -1
View File
@@ -12,7 +12,7 @@ import type { Variant } from '../model';
const SPECS: Record<Variant, string> = { const SPECS: Record<Variant, string> = {
english: english:
'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10', 'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10',
russian: russian_scrabble:
'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3', 'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3',
erudit: erudit:
'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3', 'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3',
+1 -1
View File
@@ -62,7 +62,7 @@ function emptyLinked(): LinkResult {
const POOL: Record<Variant, string> = { const POOL: Record<Variant, string> = {
english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', russian_scrabble: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
}; };
+1 -1
View File
@@ -203,7 +203,7 @@ function finishedG3(): MockGame {
return { return {
view: { view: {
id: 'g3', id: 'g3',
variant: 'russian', variant: 'russian_scrabble',
dictVersion: 'v1', dictVersion: 'v1',
status: 'finished', status: 'finished',
players: 2, players: 2,
+1 -1
View File
@@ -3,7 +3,7 @@
// FlatBuffers) and the mock transport speak this model, so the UI never touches // FlatBuffers) and the mock transport speak this model, so the UI never touches
// generated wire code directly. // generated wire code directly.
export type Variant = 'english' | 'russian' | 'erudit'; export type Variant = 'english' | 'russian_scrabble' | 'erudit';
/** Backend game status strings. */ /** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string; export type GameStatus = 'active' | 'finished' | string;
+1 -1
View File
@@ -23,7 +23,7 @@ describe('premium layout', () => {
it('doubles the centre for standard variants but not for erudit', () => { it('doubles the centre for standard variants but not for erudit', () => {
expect(centre('english')).toEqual({ row: 7, col: 7 }); expect(centre('english')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('english')[7][7]).toBe('DW'); expect(premiumGrid('english')[7][7]).toBe('DW');
expect(premiumGrid('russian')[7][7]).toBe('DW'); expect(premiumGrid('russian_scrabble')[7][7]).toBe('DW');
expect(centre('erudit')).toEqual({ row: 7, col: 7 }); expect(centre('erudit')).toEqual({ row: 7, col: 7 });
expect(premiumGrid('erudit')[7][7]).toBe(''); expect(premiumGrid('erudit')[7][7]).toBe('');
}); });
+12
View File
@@ -9,6 +9,7 @@ interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark';
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
} }
@@ -48,6 +49,17 @@ export function telegramLaunch(): TelegramLaunch {
return { initData: w.initData, startParam, theme: w.themeParams }; return { initData: w.initData, startParam, theme: w.themeParams };
} }
/**
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
* or undefined outside Telegram. Inside the Mini App this — not the OS
* prefers-color-scheme — is the authoritative theme: on some clients (Telegram
* Desktop) the OS scheme leaks into the webview and fights Telegram's own setting,
* so the app forces this value on launch.
*/
export function telegramColorScheme(): 'light' | 'dark' | undefined {
return webApp()?.colorScheme;
}
/** /**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app * startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe. * launch button carries the deep-link there rather than in initDataUnsafe.
+5
View File
@@ -27,6 +27,7 @@ export interface TelegramThemeParams {
button_color?: string; button_color?: string;
button_text_color?: string; button_text_color?: string;
secondary_bg_color?: string; secondary_bg_color?: string;
header_bg_color?: string;
} }
/** applyTelegramTheme overrides token values at runtime from Telegram themeParams. */ /** applyTelegramTheme overrides token values at runtime from Telegram themeParams. */
@@ -44,4 +45,8 @@ export function applyTelegramTheme(p: TelegramThemeParams): void {
set(p.button_color, '--accent'); set(p.button_color, '--accent');
set(p.button_text_color, '--accent-text'); set(p.button_text_color, '--accent-text');
set(p.link_color, '--accent'); set(p.link_color, '--accent');
// The nav bar tracks Telegram's chrome so it doesn't fall out of the design; the
// announcement banner takes the secondary surface so it reads as a subtle accent.
set(p.header_bg_color ?? p.bg_color, '--bg-elev');
set(p.secondary_bg_color, '--ad-bg');
} }
+2 -2
View File
@@ -13,10 +13,10 @@ describe('availableVariants', () => {
}); });
it('offers Russian and Эрудит for a ru-only service', () => { it('offers Russian and Эрудит for a ru-only service', () => {
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian', 'erudit']); expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian_scrabble', 'erudit']);
}); });
it('offers every variant for a bilingual service', () => { it('offers every variant for a bilingual service', () => {
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian', 'erudit']); expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian_scrabble', 'erudit']);
}); });
}); });
+2 -2
View File
@@ -14,13 +14,13 @@ export interface VariantOption {
// ALL_VARIANTS lists every variant in display order. // ALL_VARIANTS lists every variant in display order.
export const ALL_VARIANTS: VariantOption[] = [ export const ALL_VARIANTS: VariantOption[] = [
{ id: 'english', label: 'new.english' }, { id: 'english', label: 'new.english' },
{ id: 'russian', label: 'new.russian' }, { id: 'russian_scrabble', label: 'new.russian' },
{ id: 'erudit', label: 'new.erudit' }, { id: 'erudit', label: 'new.erudit' },
]; ];
// VARIANT_LANGUAGE maps each variant to its game language. en -> English; // VARIANT_LANGUAGE maps each variant to its game language. en -> English;
// ru -> Russian + Эрудит. // ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian: 'ru', erudit: 'ru' }; export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { english: 'en', russian_scrabble: 'ru', erudit: 'ru' };
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty // availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
// or absent set is ungated (a web/legacy session without a declared set), returning // or absent set is ungated (a web/legacy session without a declared set), returning
+1 -1
View File
@@ -78,7 +78,7 @@
const variantKey: Record<string, MessageKey> = { const variantKey: Record<string, MessageKey> = {
english: 'new.english', english: 'new.english',
russian: 'new.russian', russian_scrabble: 'new.russian',
erudit: 'new.erudit', erudit: 'new.erudit',
}; };
</script> </script>
+3
View File
@@ -10,6 +10,7 @@
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme'; import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels'; import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram';
const themes: ThemePref[] = ['auto', 'light', 'dark']; const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = { const themeLabel: Record<ThemePref, MessageKey> = {
@@ -28,6 +29,7 @@
<Screen title={t('settings.title')} back="/"> <Screen title={t('settings.title')} back="/">
<div class="page"> <div class="page">
{#if !insideTelegram()}
<section> <section>
<h3>{t('settings.theme')}</h3> <h3>{t('settings.theme')}</h3>
<div class="seg"> <div class="seg">
@@ -38,6 +40,7 @@
{/each} {/each}
</div> </div>
</section> </section>
{/if}
<section> <section>
<h3>{t('settings.language')}</h3> <h3>{t('settings.language')}</h3>