Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s

Close out Stage 17 round 6:

- Landing page at / — one Vite build with two entries (index.html = game
  SPA, landing.html = a lightweight landing reusing the theme/i18n/
  aboutContent leaf modules, not the app store).
- Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/
  (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/
  + /telegram/). Per-language "Play in Telegram" link via new
  VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset).
- Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the
  go:embed zero modtime emitted no validators, so the client re-downloaded
  the whole bundle every launch).
- Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default
  interval (the first tick at 15s raced the edge idle timeout -> reconnect
  storm).

PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated;
round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e,
full e2e (60) green.
This commit is contained in:
Ilia Denisov
2026-06-08 13:33:05 +02:00
parent b8787a4123
commit e16076c89e
27 changed files with 519 additions and 82 deletions
+236
View File
@@ -0,0 +1,236 @@
<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 { loadPrefs, savePrefs, type Prefs } from './lib/session';
import { aboutContent } from './lib/aboutContent';
import { telegramBotLink } 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).
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 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));
onMount(async () => {
prefs = await loadPrefs();
theme = prefs.theme ?? 'auto';
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.
void savePrefs({
theme,
locale: i18n.locale,
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();
}
</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>
</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>
</section>
<section class="info">
<p class="rules">
<a href={about.rulesUrl} target="_blank" rel="noopener noreferrer">{about.rulesPrefix}{about.rulesLink}</a>
</p>
<div class="cols">
<div class="col">
<h2>{about.randomTitle}</h2>
<p class="respect">❗️ {about.randomRespect}</p>
<ul>
{#each about.random as r (r)}<li>{r}</li>{/each}
</ul>
</div>
<div class="col">
<h2>{about.friendsTitle}</h2>
<ul>
{#each about.friends as f (f)}<li>{f}</li>{/each}
</ul>
</div>
</div>
</section>
<footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer>
</main>
<style>
.landing {
min-height: 100%;
box-sizing: border-box;
max-width: 760px;
margin: 0 auto;
padding: 16px var(--pad) 40px;
display: flex;
flex-direction: column;
gap: 28px;
color: var(--text);
}
.bar {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.seg {
display: flex;
gap: 6px;
}
.opt {
padding: 7px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
user-select: none;
font-size: 0.85rem;
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.hero {
text-align: center;
display: flex;
flex-direction: column;
gap: 14px;
padding: 24px 0 8px;
}
.hero h1 {
margin: 0;
font-size: 2.4rem;
letter-spacing: 0.5px;
}
.tagline {
margin: 0 auto;
max-width: 30ch;
color: var(--text-muted);
font-size: 1.05rem;
}
.cta {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 6px;
}
.play {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
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);
}
.play.tg {
background: var(--surface);
color: var(--text);
}
.play.tg img {
display: block;
}
.info {
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: 18px var(--pad);
}
.rules {
margin: 0 0 14px;
text-align: center;
}
.rules a {
color: var(--accent);
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
}
.col h2 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.respect {
margin: 0 0 8px;
color: var(--text-muted);
font-size: 0.9rem;
}
.col ul {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 5px;
color: var(--text);
}
.ft {
margin-top: auto;
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
import { mount } from 'svelte';
import './app.css';
import Landing from './Landing.svelte';
// Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at
// /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts.
export default mount(Landing, { target: document.getElementById('app')! });
+4
View File
@@ -152,6 +152,10 @@ export const en = {
'about.description': 'A multiplatform Scrabble game.',
'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',
'lang.ru': 'Русский',
+4
View File
@@ -153,6 +153,10 @@ export const ru: Record<MessageKey, string> = {
'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
'landing.playWeb': 'Играть в браузере',
'landing.playTelegram': 'Играть в Telegram',
'lang.en': 'English',
'lang.ru': 'Русский',
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { telegramBotLink } from './landing';
describe('telegramBotLink', () => {
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('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();
});
});
+16
View File
@@ -0,0 +1,16 @@
// 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.
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.
*/
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;
}