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
+5 -1
View File
@@ -29,7 +29,11 @@ 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).
share-to-Telegram deep-link base (Stage 9). `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_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
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
## How it talks to the gateway
+14
View File
@@ -0,0 +1,14 @@
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 }) => {
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 language switch flips the copy to Russian (reusing the app i18n).
await page.getByRole('button', { name: 'Русский' }).click();
await expect(page.getByRole('link', { name: /Играть в браузере/ })).toBeVisible();
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- A normal scrollable page (no board, no Telegram SDK), so allow the browser's zoom. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Scrabble</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/landing.ts"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1000px" height="1000px" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>Artboard</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="99.2583404%" id="linearGradient-1">
<stop stop-color="#2AABEE" offset="0%"></stop>
<stop stop-color="#229ED9" offset="100%"></stop>
</linearGradient>
</defs>
<g id="Artboard" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<circle id="Oval" fill="url(#linearGradient-1)" cx="500" cy="500" r="500"></circle>
<path d="M226.328419,494.722069 C372.088573,431.216685 469.284839,389.350049 517.917216,369.122161 C656.772535,311.36743 685.625481,301.334815 704.431427,301.003532 C708.567621,300.93067 717.815839,301.955743 723.806446,306.816707 C728.864797,310.92121 730.256552,316.46581 730.922551,320.357329 C731.588551,324.248848 732.417879,333.113828 731.758626,340.040666 C724.234007,419.102486 691.675104,610.964674 675.110982,699.515267 C668.10208,736.984342 654.301336,749.547532 640.940618,750.777006 C611.904684,753.448938 589.856115,731.588035 561.733393,713.153237 C517.726886,684.306416 492.866009,666.349181 450.150074,638.200013 C400.78442,605.66878 432.786119,587.789048 460.919462,558.568563 C468.282091,550.921423 596.21508,434.556479 598.691227,424.000355 C599.00091,422.680135 599.288312,417.758981 596.36474,415.160431 C593.441168,412.561881 589.126229,413.450484 586.012448,414.157198 C581.598758,415.158943 511.297793,461.625274 375.109553,553.556189 C355.154858,567.258623 337.080515,573.934908 320.886524,573.585046 C303.033948,573.199351 268.692754,563.490928 243.163606,555.192408 C211.851067,545.013936 186.964484,539.632504 189.131547,522.346309 C190.260287,513.342589 202.659244,504.134509 226.328419,494.722069 Z" id="Path-3" fill="#FFFFFF"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+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;
}
+10
View File
@@ -1,3 +1,4 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
@@ -34,5 +35,14 @@ export default defineConfig(({ mode }) => ({
build: {
target: 'es2022',
sourcemap: true,
// Two entries (Stage 17): the game SPA (index.html, served at /app/ + /telegram/) and the
// public landing page (landing.html, served at /). Assets are shared in dist/assets/, and
// the relative base lets one build serve under any path.
rollupOptions: {
input: {
main: resolve(import.meta.dirname, 'index.html'),
landing: resolve(import.meta.dirname, 'landing.html'),
},
},
},
}));