feat(landing): Russian default + SEO head, icons and OG card #157

Merged
developer merged 1 commits from feature/landing-seo into development 2026-07-01 23:29:28 +00:00
17 changed files with 367 additions and 18 deletions
+41
View File
@@ -0,0 +1,41 @@
# Erudit — site icons + Open Graph card
The favicon set and the `og:image` link-preview card for the public landing
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
| Output (committed to `ui/public/`) | Purpose |
|------|---------|
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
## How it works
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
character) from LiberationSans (Arial-metric, the game's font stack) with their
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
plain SVG from those outlines — no font is needed at generation time — writes
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
installed chromium version; the SVG sources are deterministic.
## Regenerate
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
needs no extra packages**.
```sh
cd assets/icons
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. regenerate everything in ui/public/:
node build/generate.js
```
+78
View File
@@ -0,0 +1,78 @@
'use strict';
// Extract the glyph outlines the site icons and the og-image wordmark need from a
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
// plus the advance width so generate.js can lay out words without the font.
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
const opentype = require('opentype.js');
const fs = require('fs');
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
const b = fs.readFileSync(FONT);
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
const FS = 1000; // em scale
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
function glyphData(ch) {
const g = font.charToGlyph(ch);
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
const contours = [];
let cur = null, prev = null;
for (const c of p.commands) {
if (c.type === 'M') {
if (cur) contours.push(cur);
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
prev = { x: c.x, y: c.y };
} else if (c.type === 'L') {
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'C') {
cur[cur.length - 1].o = [c.x1, c.y1];
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Q') {
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
cur[cur.length - 1].o = c1;
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Z') {
if (cur && cur.length > 1) {
const last = cur[cur.length - 1], first = cur[0];
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
first.i = last.i; // fold the duplicate closing point into the first
cur.pop();
}
}
if (cur) { contours.push(cur); cur = null; }
}
}
if (cur) contours.push(cur);
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
const out = contours.map(ct => {
const v = [], i = [], o = [];
ct.forEach(pt => {
v.push(pt.v);
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
});
return { i, o, v, c: true };
});
const bbox = out.length
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
}
const glyphs = {};
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
const out = __dirname + '/glyphs.json';
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// Site icons + the Open Graph card for the public landing, drawn from the same
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
// ui/public/:
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
// favicon.ico 32x32 PNG-in-ICO render of the same
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
// og-image.png 1200x630 card: tile + wordmark on the board green
const fs = require('fs');
const path = require('path');
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
const UI = path.resolve(__dirname, '../../../ui');
const OUT = path.join(UI, 'public');
// ---- palette (vk loader tile + app.css board tokens) ------------------------
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
// ---- glyph outlines -> SVG path data ----------------------------------------
const r2 = n => Math.round(n * 100) / 100;
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
function pathD(g, sc, tx, ty) {
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
const Z = [0, 0];
return g.contours.map(ct => {
const n = ct.v.length;
let d = `M${pt(ct.v[0], Z)}`;
for (let k = 1; k <= n; k++) {
const a = k - 1, b = k % n;
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
}
return d + 'Z';
}).join('');
}
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
}
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
// the capital height (measured on «Э»). Returns the combined path + the width.
function textLine(str, capPx, x, y, colour) {
const sc = capPx / G.glyphs['Э'].bbox.h;
let d = '', w = 0;
for (const ch of str) {
const g = G.glyphs[ch];
if (g.contours.length) d += pathD(g, sc, x + w, y);
w += g.adv * sc;
}
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
}
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
function tile(cx, cy, size, withScore) {
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
return s;
}
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
// ---- favicon.svg (the committed vector master) -------------------------------
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
const appleTouch = svg(180, 180,
`<rect width="180" height="180" fill="${FACE}"/>` +
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
// ---- og-image: tile + wordmark, centred as one group on the board green ------
function ogImage() {
const W = 1200, H = 630, tileSize = 340, gap = 84;
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
const textW = Math.max(l1.width, l2.width);
const left = (W - (tileSize + gap + textW)) / 2;
const tx = left + tileSize + gap;
// Two baselines around the vertical centre; the tile centre sits between them.
const b1 = 295, b2 = 408;
const body =
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
tile(left + tileSize / 2, H / 2, tileSize, true) +
textLine('Эрудит', 112, tx, b1, TEXT).svg +
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
return svg(W, H, body);
}
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
async function shoot(page, markup, w, h, transparent) {
await page.setViewportSize({ width: w, height: h });
await page.setContent(`<body style="margin:0">${markup}</body>`);
return page.screenshot({ omitBackground: transparent });
}
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
function icoFromPNG(png, sizePx) {
const h = Buffer.alloc(22);
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
return Buffer.concat([h, png]);
}
(async () => {
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
const browser = await chromium.launch();
const page = await browser.newPage();
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
await browser.close();
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
}
})();
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -1099,7 +1099,14 @@ parameters from the URL and the gateway verifies them in-process — §12); a st
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
static file serving and never reaches the Go edge. The landing shell carries the site's
static SEO head — Russian title/description, the Open Graph card (Telegram/VK link
previews), JSON-LD and a canonical link — with absolute URLs pinned to the production
origin, so the test contour canonicalises to production instead of being indexed as a
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
browser-language detection — crawlers render with arbitrary languages). The favicon set,
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
+7 -1
View File
@@ -23,7 +23,13 @@ variant's alphabet, remembers answers within the session and rate-limits repeats
A public **landing page** at the site root introduces the game, switches language and
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
(it follows the system scheme, not the saved preference); its language choice is saved.
(it follows the system scheme, not the saved preference); its language choice is saved. The
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
render with arbitrary languages, so detection would make the indexed content
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page.
### First-run onboarding
The first time a player opens the app it walks them through the interface with a light
+8 -1
View File
@@ -24,7 +24,14 @@ top-1 подсказку, безлимитную проверку слова с
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется.
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
открывается на **русском** — язык браузера не определяется автоматически (роботы
рендерят страницу с произвольным языком, и автоопределение сделало бы
проиндексированный контент недетерминированным); сохранённый выбор 🌐 по-прежнему
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница.
### Первый запуск: онбординг
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
+21 -8
View File
@@ -2,16 +2,29 @@ import { expect, test } from './fixtures';
// The landing page is a separate Vite entry (landing.html), served at "/" in production while
// the game SPA lives at /app/ and /telegram/. In dev it is reachable at /landing.html.
test('landing shows the pitch, switches language via the dropdown, and toggles theme', async ({ page }) => {
test('landing defaults to Russian, switches language via the dropdown, and toggles theme', async ({ page }) => {
await page.goto('/landing.html');
// The tagline renders (English in the default test browser).
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
// The language dropdown switches the copy to Russian.
await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('menuitem', { name: /Русский/ }).click();
// Russian by default regardless of the browser language (the test browser is en-US):
// crawlers render with arbitrary navigator.language, so the landing never auto-detects.
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible();
await expect(page).toHaveTitle(/Эрудит \(Скрэббл\)/);
expect(await page.evaluate(() => document.documentElement.lang)).toBe('ru');
// The static SEO head is present (the page is client-rendered; this layer is what
// non-rendering crawlers and link-preview bots see).
expect(await page.locator('meta[name="description"]').getAttribute('content')).toContain('Эрудит');
expect(await page.locator('meta[property="og:image"]').getAttribute('content')).toBe(
'https://erudit-game.ru/og-image.png',
);
const jsonLd = await page.locator('script[type="application/ld+json"]').textContent();
expect(JSON.parse(jsonLd!)['@type']).toBe('WebApplication');
// The language dropdown switches the copy to English, and the document language follows.
await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('menuitem', { name: /English/ }).click();
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
expect(await page.evaluate(() => document.documentElement.lang)).toBe('en');
// The theme toggle flips the document theme (ephemeral, light<->dark).
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
@@ -24,7 +37,7 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t
// adds .app-shell); the landing is a normal scrolling document and must keep scrolling.
test('the landing is a normal scrolling document (the SPA document-pin does not apply)', async ({ page }) => {
await page.goto('/landing.html');
await expect(page.getByText(/Play Scrabble/i)).toBeVisible();
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible();
const state = await page.evaluate(() => ({
shell: document.documentElement.classList.contains('app-shell'),
+10 -2
View File
@@ -1,7 +1,11 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<!-- The SPA shell is an empty client-rendered document behind the public landing — keep it
out of search indexes (robots.txt deliberately does NOT disallow /app/, so crawlers can
reach this tag). -->
<meta name="robots" content="noindex" />
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
render-blocking <script> to telegram.org hangs the whole page on a network that blocks
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
@@ -13,7 +17,11 @@
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>Scrabble</title>
<title>Эрудит (Скрэббл)</title>
<!-- Relative icon hrefs: the gateway serves the same build under /app/, /telegram/ and /vk/. -->
<link rel="icon" href="favicon.ico" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
</head>
<body>
<div id="app"></div>
+43 -2
View File
@@ -1,10 +1,51 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<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>
<!-- The SEO head is static and Russian-only: the page is client-rendered, so this layer is
what non-rendering crawlers and link-preview bots (Telegram/VK use Open Graph) see. The
absolute URLs are the production origin on purpose — the test contour then canonicalises
to production instead of being indexed as a separate site. -->
<title>Эрудит (Скрэббл) — играть онлайн с друзьями или роботом</title>
<meta
name="description"
content="Бесплатная онлайн-игра в слова «Эрудит» (Скрэббл): играйте со случайным соперником, с друзьями или против робота — в Telegram, ВКонтакте или прямо в браузере."
/>
<link rel="canonical" href="https://erudit-game.ru/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Эрудит" />
<meta property="og:title" content="Эрудит (Скрэббл) — играть онлайн" />
<meta property="og:description" content="Игра в слова: со случайным соперником, с друзьями или против робота." />
<meta property="og:url" content="https://erudit-game.ru/" />
<meta property="og:image" content="https://erudit-game.ru/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:locale:alternate" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<!-- Relative icon hrefs, like the in-page assets: the same build serves under any path. -->
<link rel="icon" href="favicon.ico" sizes="32x32" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Эрудит (Скрэббл)",
"alternateName": "Erudit (Scrabble)",
"url": "https://erudit-game.ru/",
"image": "https://erudit-game.ru/og-image.png",
"description": "Бесплатная онлайн-игра в слова «Эрудит» (Скрэббл): играйте со случайным соперником, с друзьями или против робота — в Telegram, ВКонтакте или прямо в браузере.",
"applicationCategory": "GameApplication",
"operatingSystem": "Any",
"inLanguage": ["ru", "en"],
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "RUB" }
}
</script>
</head>
<body>
<div id="app"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect x="5.52" y="5.52" width="84.95" height="84.95" rx="10.15" fill="#D9B978" stroke="#B49559" stroke-width="3.05"/><path d="M46.51 25.06C46.51 25.06 46.51 25.06 46.51 25.06C42.72 25.06 39.47 25.9 36.76 27.58C34.04 29.26 32.13 31.57 31.01 34.51C31.01 34.51 31.01 34.51 31.01 34.51C31.01 34.51 24.25 32.27 24.25 32.27C26.01 27.96 28.78 24.71 32.54 22.52C36.3 20.33 40.98 19.23 46.58 19.23C46.58 19.23 46.58 19.23 46.58 19.23C54.6 19.23 60.87 21.61 65.4 26.36C69.94 31.12 72.2 37.69 72.2 46.08C72.2 46.08 72.2 46.08 72.2 46.08C72.2 51.64 71.19 56.47 69.18 60.57C67.16 64.68 64.23 67.84 60.38 70.06C56.53 72.28 51.93 73.38 46.58 73.38C46.58 73.38 46.58 73.38 46.58 73.38C35.97 73.38 28.38 68.75 23.8 59.49C23.8 59.49 23.8 59.49 23.8 59.49C23.8 59.49 29.63 56.58 29.63 56.58C31.34 60.06 33.63 62.76 36.5 64.66C39.36 66.57 42.6 67.52 46.21 67.52C46.21 67.52 46.21 67.52 46.21 67.52C51.51 67.52 55.82 65.83 59.15 62.46C62.47 59.09 64.37 54.54 64.84 48.81C64.84 48.81 64.84 48.81 64.84 48.81C64.84 48.81 40.42 48.81 40.42 48.81C40.42 48.81 40.42 43.06 40.42 43.06C40.42 43.06 64.84 43.06 64.84 43.06C64.32 37.36 62.46 32.93 59.26 29.78C56.06 26.63 51.81 25.06 46.51 25.06Z" fill="#1A1A1A" stroke="#1A1A1A" stroke-width="1.69"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+10 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { applyTheme } from './lib/theme';
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
import { i18n, setLocale, t, type Locale } from './lib/i18n/index.svelte';
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
import { aboutContent } from './lib/aboutContent';
import { telegramChannelLink, vkAppLink } from './lib/landing';
@@ -9,7 +9,8 @@
// Standalone landing page, 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).
// scheme and the icon toggles light<->dark in memory. Language IS persisted (synced with the
// app) but defaults to Russian, never to the browser language — see landing.ts.
let theme = $state<'light' | 'dark'>('light');
let langOpen = $state(false);
@@ -31,7 +32,13 @@
prefs = await loadPrefs();
theme = systemTheme();
applyTheme(theme);
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
setLocale(prefs.locale ?? 'ru');
});
// Keep the document language honest for assistive tech and crawlers: the static shell
// declares lang="ru" (the default), and a 🌐 switch to English must follow.
$effect(() => {
document.documentElement.lang = i18n.locale;
});
function toggleTheme(): void {
+9
View File
@@ -1,7 +1,16 @@
import { mount } from 'svelte';
import './app.css';
import { setLocale } from './lib/i18n/index.svelte';
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.
// The landing always starts in Russian — the primary audience and the language of the
// static SEO head (crawlers render with arbitrary navigator.language, e.g. Googlebot's
// en-US, so browser detection would make the indexed content nondeterministic). Set
// before mount so the very first paint is Russian; a saved 🌐 choice is applied in
// Landing's onMount once prefs load.
setLocale('ru');
export default mount(Landing, { target: document.getElementById('app')! });