Landing v2: icon switchers, ephemeral theme, channel link, drop browser CTA
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 7s
CI / integration (pull_request) Successful in 10s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s

Owner review-pass rework of the landing page:
- Rename the per-language Telegram link build var
  VITE_TELEGRAM_LINK_EN/_RU -> VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU
  (it carries a channel username; the landing builds https://t.me/<name> --
  the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*).
- Language switcher -> a globe icon dropdown (flags + names), saved + synced
  to the app prefs.
- Theme switcher -> a sun/moon icon toggle, ephemeral (follows the system
  scheme, no auto, never persisted) -- galaxy-game style.
- Drop the "Play in browser" CTA (no standalone-web onboarding yet).

Docs: FUNCTIONAL(+ru), PLAN, deploy + ui READMEs.
This commit is contained in:
Ilia Denisov
2026-06-08 16:40:07 +02:00
parent 5928be40b0
commit 3fd279cf8c
15 changed files with 167 additions and 136 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU`
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
are the per-language "Play in Telegram" links shown on the landing page (Stage 17).
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
+14 -7
View File
@@ -1,14 +1,21 @@
import { expect, test } from './fixtures';
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
// the game SPA moves to /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
test('landing shows the pitch, a browser CTA to /app/, and switches language', async ({ page }) => {
// the game SPA lives at /app/ and /telegram/ (Stage 17). In dev it is reachable at /landing.html.
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
await page.goto('/landing.html');
// The primary call to action opens the web app mount.
await expect(page.getByRole('link', { name: /Play in browser/i })).toHaveAttribute('href', '/app/');
// The tagline renders (English in the default test browser).
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
// The language switch flips the copy to Russian (reusing the app i18n).
await page.getByRole('button', { name: 'Русский' }).click();
await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible();
// The language dropdown switches the copy to Russian.
await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('menuitem', { name: /Русский/ }).click();
await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible();
// The theme toggle flips the document theme (ephemeral, light<->dark).
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
await page.getByRole('button', { name: 'Theme' }).click();
const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
expect(after).not.toBe(before);
});
+103 -89
View File
@@ -1,89 +1,84 @@
<script lang="ts">
import { onMount } from 'svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme';
import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } from './lib/i18n/index.svelte';
import { applyTheme } from './lib/theme';
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
import { aboutContent } from './lib/aboutContent';
import { telegramBotLink } from './lib/landing';
import { telegramChannelLink } from './lib/landing';
// Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA
// (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but
// not the app store — so it stays light (no gateway, auth or live stream).
// Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
// stays light. Theme is EPHEMERAL here (no auto, no persistence): it starts from the system
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the app).
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
auto: 'settings.themeAuto',
light: 'settings.themeLight',
dark: 'settings.themeDark',
};
const locales: Locale[] = ['en', 'ru'];
let theme = $state<ThemePref>('auto');
let theme = $state<'light' | 'dark'>('light');
let langOpen = $state(false);
let prefs: Partial<Prefs> = {};
// The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h).
const about = $derived(aboutContent(i18n.locale, 24));
const tgLink = $derived(telegramBotLink(i18n.locale));
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
const tgLink = $derived(telegramChannelLink(i18n.locale));
const locales: { code: Locale; label: string }[] = [
{ code: 'en', label: '🇬🇧 English' },
{ code: 'ru', label: '🇷🇺 Русский' },
];
function systemTheme(): 'light' | 'dark' {
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
onMount(async () => {
prefs = await loadPrefs();
theme = prefs.theme ?? 'auto';
theme = systemTheme();
applyTheme(theme);
applyReduceMotion(prefs.reduceMotion ?? false);
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
});
function persist(): void {
// savePrefs takes the full set, so keep the labels/lines the app may have stored.
function toggleTheme(): void {
theme = theme === 'light' ? 'dark' : 'light';
applyTheme(theme); // ephemeral — deliberately not persisted
}
function chooseLocale(lc: Locale): void {
setLocale(lc);
langOpen = false;
// Persist the language only, keeping the app's other prefs (notably its own persisted theme).
void savePrefs({
theme,
locale: i18n.locale,
theme: prefs.theme ?? 'auto',
locale: lc,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
boardLines: prefs.boardLines ?? false,
});
}
function chooseTheme(th: ThemePref): void {
theme = th;
applyTheme(th);
persist();
}
function chooseLocale(lc: Locale): void {
setLocale(lc);
persist();
prefs = { ...prefs, locale: lc };
}
</script>
<main class="landing">
<header class="bar">
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
<div class="lang">
<button class="icon" aria-label="Language" aria-expanded={langOpen} onclick={() => (langOpen = !langOpen)}>🌐</button>
{#if langOpen}
<!-- svelte-ignore a11y_consider_explicit_label -->
<button class="backdrop" onclick={() => (langOpen = false)}></button>
<div class="menu" role="menu">
{#each locales as l (l.code)}
<button role="menuitem" class:on={i18n.locale === l.code} onclick={() => chooseLocale(l.code)}>{l.label}</button>
{/each}
</div>
{/if}
</div>
<button class="icon" aria-label="Theme" onclick={toggleTheme}>{theme === 'light' ? '☼' : '☾'}</button>
</header>
<section class="hero">
<h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p>
<div class="cta">
<a class="play primary" href="/app/">{t('landing.playWeb')}</a>
{#if tgLink}
<a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer">
<img src="telegram-logo.svg" alt="" width="22" height="22" />
{t('landing.playTelegram')}
</a>
{/if}
</div>
{#if tgLink}
<a class="play" href={tgLink} target="_blank" rel="noopener noreferrer">
<img src="telegram-logo.svg" alt="" width="22" height="22" />
{t('landing.playTelegram')}
</a>
{/if}
</section>
<section class="info">
@@ -125,32 +120,65 @@
.bar {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.seg {
display: flex;
gap: 6px;
.lang {
position: relative;
}
.opt {
padding: 7px 12px;
border: 1px solid var(--border);
background: var(--surface);
.icon {
min-width: 40px;
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
background: transparent;
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
user-select: none;
font-size: 0.85rem;
cursor: pointer;
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
background: none;
border: none;
}
.menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 150px;
overflow: hidden;
}
.menu button {
text-align: left;
padding: 10px 14px;
background: none;
border: none;
color: var(--text);
white-space: nowrap;
}
.menu button:hover {
background: var(--surface-2);
}
.menu button.on {
color: var(--accent);
font-weight: 600;
}
.hero {
text-align: center;
display: flex;
flex-direction: column;
gap: 14px;
gap: 16px;
padding: 24px 0 8px;
}
.hero h1 {
@@ -164,33 +192,20 @@
color: var(--text-muted);
font-size: 1.05rem;
}
.cta {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 6px;
}
.play {
align-self: center;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
padding: 12px 24px;
border-radius: var(--radius-sm);
font-weight: 700;
text-decoration: none;
border: 1px solid var(--border);
}
.play.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
margin-top: 6px;
}
.play.tg {
background: var(--surface);
color: var(--text);
}
.play.tg img {
.play img {
display: block;
}
.info {
@@ -225,7 +240,6 @@
display: flex;
flex-direction: column;
gap: 5px;
color: var(--text);
}
.ft {
margin-top: auto;
-1
View File
@@ -153,7 +153,6 @@ export const en = {
'about.version': 'Version {v}',
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
'landing.playWeb': 'Play in browser',
'landing.playTelegram': 'Play in Telegram',
'lang.en': 'English',
-1
View File
@@ -154,7 +154,6 @@ export const ru: Record<MessageKey, string> = {
'about.version': 'Версия {v}',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
'landing.playWeb': 'Играть в браузере',
'landing.playTelegram': 'Играть в Telegram',
'lang.en': 'English',
+12 -12
View File
@@ -1,20 +1,20 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { telegramBotLink } from './landing';
import { telegramChannelLink } from './landing';
describe('telegramBotLink', () => {
describe('telegramChannelLink', () => {
afterEach(() => vi.unstubAllEnvs());
it('returns the per-language bot link when configured', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game');
expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game');
expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game');
it('builds the per-language t.me link from the channel name', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
});
it('returns null when the locale link is unset or blank', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', '');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' ');
expect(telegramBotLink('en')).toBeNull();
expect(telegramBotLink('ru')).toBeNull();
it('returns null when the locale channel is unset or blank', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
expect(telegramChannelLink('en')).toBeNull();
expect(telegramChannelLink('ru')).toBeNull();
});
});
+13 -9
View File
@@ -1,16 +1,20 @@
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
// the per-language Telegram-bot link selection is unit-testable.
// the per-language Telegram-channel link selection is unit-testable.
import type { Locale } from './i18n/index.svelte';
/**
* telegramBotLink returns the t.me link for the locale's game bot, or null when it is not
* configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU)
* because the test and prod contours run different bots (different usernames), so the link
* cannot be hardcoded.
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
* (the id to post, the name to link). A leading "@" is tolerated.
*/
export function telegramBotLink(locale: Locale): string | null {
const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN;
const link = (raw as string | undefined)?.trim();
return link ? link : null;
export function telegramChannelLink(locale: Locale): string | null {
const raw =
locale === 'ru'
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
return name ? `https://t.me/${name}` : null;
}