R6(a): de-stage code, docs, READMEs; split stage6_test

Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
Ilia Denisov
2026-06-10 16:56:03 +02:00
parent a372343797
commit 8881214213
156 changed files with 749 additions and 778 deletions
+7 -7
View File
@@ -4,10 +4,10 @@ Pure-HTML5 game client — **plain Svelte 5 (runes) + TypeScript + Vite**, no
SvelteKit. Talks to the `gateway` over **Connect-RPC + FlatBuffers**; embeddable in
platform webviews and packageable to native via Capacitor.
Stage 7 ships the **playable slice**: sign in (guest / email), the "my games" lobby,
The **playable slice**: sign in (guest / email), the "my games" lobby,
auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint,
word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru),
theme, and the profile. **Stage 8** adds friends/blocks (with one-time friend codes),
theme, and the profile. **Social** surfaces add friends/blocks (with one-time friend codes),
friend-game invitations, profile editing + email binding, the statistics screen, the
lobby notification badge, and the in-game history + GCG export (share or download,
finished games only).
@@ -26,11 +26,11 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
```
`GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` (Stage 11)
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
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_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).
share-to-Telegram deep-link base. `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.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
@@ -40,7 +40,7 @@ The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
A single Connect `Execute(message_type, payload)` carries every unary op; the request
and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`.
The session token rides in `Authorization: Bearer`; a domain failure comes back in
`result_code`. `Subscribe` is the live event stream; R4 made its game events carry a state **delta**
`result_code`. `Subscribe` is the live event stream; its game events carry a state **delta**
that `lib/gamedelta.ts` applies to the per-game cache (`lib/gamecache.ts`), so a move renders without
a follow-up `game.state` (a gap falls back to a refetch). `lib/transport.ts` is the real
client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken
@@ -48,7 +48,7 @@ out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`
**No board on the wire:** `StateView` is a summary + rack only, so the client
reconstructs the 15×15 board by replaying the decoded move journal (`game.history`).
**The play loop is alphabet-agnostic (Stage 13):** the rack and the play / exchange /
**The play loop is alphabet-agnostic:** the rack and the play / exchange /
word-check requests carry **alphabet indices**, and the client caches each variant's
`(index, letter, value)` table — sent once behind `StateRequest.include_alphabet` — in
`lib/alphabet.ts`, rendering the rack and blank chooser from it. **Premium squares**
+5 -5
View File
@@ -16,7 +16,7 @@ async function openGame(page: Page): Promise<void> {
await expect(page.locator('.pane')).toHaveCount(1);
}
test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => {
test('offline shows the Connecting indicator and softly disables server actions', async ({ page }) => {
await openGame(page);
// The exchange/draw tab is a server action; on my turn with tiles in the bag it is live.
const draw = page.locator('.tab').first();
@@ -56,7 +56,7 @@ test('a placed tile is saved as a draft and restored on reopening the game', asy
await page.waitForTimeout(600); // let the debounced draft save flush to the mock store
// Leave the game and reopen it. The mock keeps the saved composition, so the pending tile is
// restored without re-placing it (Stage 17 #4/#6).
// restored without re-placing it.
await page.evaluate(() => (location.hash = '/'));
await page.getByRole('button', { name: /Ann/ }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
@@ -77,7 +77,7 @@ test('a pending tile recalls on double-tap, not on a single tap', async ({ page
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
// A single tap must NOT recall it (changed in Stage 17 — recall was too easy to trigger).
// A single tap must NOT recall it (recall was too easy to trigger).
await page.waitForTimeout(350); // clear the double-tap window from the placing tap
await page.locator('[data-cell].pending').first().click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
@@ -158,7 +158,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
await expect(page.locator('.status .over')).toBeVisible();
});
test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => {
test('a placed tile drags from one board cell to another (relocation)', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
@@ -181,7 +181,7 @@ test('a placed tile drags from one board cell to another (Stage 17 relocation)',
expect(to).not.toBe(from);
});
test('chat and word-check open as their own screens and back to the game (Stage 17)', async ({ page }) => {
test('chat and word-check open as their own screens and back to the game', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
+1 -1
View File
@@ -1,7 +1,7 @@
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/ (Stage 17). In dev it is reachable at /landing.html.
// 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 }) => {
await page.goto('/landing.html');
+3 -3
View File
@@ -1,6 +1,6 @@
import { expect, test, type Page } from './fixtures';
// Stage 8 social / account / history surfaces against the mock transport (no backend).
// Social / account / history surfaces against the mock transport (no backend).
// The mock profile is a durable account, so friends, invitations, stats and the GCG
// export are reachable from the seeded fixture.
@@ -154,7 +154,7 @@ test('game: an opponent who is already a friend shows a disabled "in friends"',
await loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend
await page.locator('.burger').first().click();
// The in-game friend item is derived from the server's friend list (Stage 17): a friend reads
// The in-game friend item is derived from the server's friend list: a friend reads
// a disabled "✓ in friends", not the addable "Add to friends".
const inFriends = page.getByRole('button', { name: /in friends/i });
await expect(inFriends).toBeVisible();
@@ -210,7 +210,7 @@ test('chat: the message field shows on your turn, the nudge replaces it otherwis
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
await page.locator('.burger').first().click();
await page.getByRole('button', { name: 'Chat' }).click();
// On your turn the message field + Send are shown and the nudge is hidden (Stage 17);
// On your turn the message field + Send are shown and the nudge is hidden;
// chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
// through the aria-label.
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
+2 -3
View File
@@ -10,10 +10,9 @@
// - shared (svelte+i18n): near-static framework runtime; only drifts on a dep/Svelte bump.
// - landing own: the landing's own code; kept minimal.
// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is
// ~2 KB. Lazy-loading was analysed and rejected for R5 (no total-size win — every chunk still
// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still
// ships and is summed — plus added request latency); the bulk is the Connect/FlatBuffers
// transport runtime + generated bindings + the Svelte runtime, irreducible within scope. See
// PRERELEASE.md R5 for the full rationale.
// transport runtime + generated bindings + the Svelte runtime, irreducible within scope.
import { readFileSync, existsSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { join } from 'node:path';
+2 -2
View File
@@ -6,7 +6,7 @@
import { aboutContent } from './lib/aboutContent';
import { telegramChannelLink } from './lib/landing';
// Standalone landing page (Stage 17), the public entry at "/" (the game SPA lives at /app/ and
// 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).
@@ -192,7 +192,7 @@
font-size: 1.05rem;
}
/* The Telegram entry is just the bigger logo (no button chrome, no caption); the link
keeps an aria-label for assistive tech (Stage 17). */
keeps an aria-label for assistive tech. */
.tg {
align-self: center;
display: inline-flex;
+2 -2
View File
@@ -42,10 +42,10 @@
--gap: 8px;
--pad: 12px;
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
content-safe-area inset (Stage 17), 0 elsewhere. */
content-safe-area inset, 0 elsewhere. */
--tg-content-top: 0px;
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
--tg-content-top, so the in-app header aligns to that band (Stage 17), 0 elsewhere. */
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
--tg-safe-top: 0px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
+2 -2
View File
@@ -110,7 +110,7 @@
(--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below
is unchanged) and centres the title + menu as a pair (hamburger right of the title) within
it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls,
which sit in the band's corners (Stage 17). */
which sit in the band's corners. */
:global(html.tg-fullscreen) .bar {
min-height: var(--tg-content-top);
box-sizing: border-box;
@@ -119,7 +119,7 @@
/* +12px of vertical breathing room (6 above / 6 below the centred content, on top of the
notch) so Telegram's native controls aren't flush against our header. Applied as
padding because the bar is sized by its content here, not by min-height (owner review
tweaks, Stage 17). */
tweaks). */
padding-top: calc(var(--tg-safe-top) + 6px);
padding-bottom: 6px;
}
+2 -2
View File
@@ -24,7 +24,7 @@
// bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the
// keyboard with a transform (kb), driven by the visual viewport — a compositor-only
// move, so neither the page behind nor the sheet relayouts as the keyboard animates
// (Stage 17). The backdrop is not resized in this mode (no per-event reflow).
// The backdrop is not resized in this mode (no per-event reflow).
let vh = $state(0);
let top = $state(0);
let kb = $state(0);
@@ -95,7 +95,7 @@
}
/* Bottom-sheet mode (the chat): a wide sheet pinned to the bottom that lifts above the
soft keyboard via a transform (--kb) — compositor-only, so the page behind and the
sheet itself do not relayout as the keyboard animates (Stage 17). */
sheet itself do not relayout as the keyboard animates. */
.backdrop.bottom {
align-items: flex-end;
padding: 0;
+3 -3
View File
@@ -31,11 +31,11 @@
// The promotional banner is feature-gated OFF until it is polished after release. The flag is
// a compile-time `false`, so the {#if} branch — and with it the AdBanner import and its
// banner.ts logic — is dead-code-eliminated from the production bundle (Stage 17). Flip to
// banner.ts logic — is dead-code-eliminated from the production bundle. Flip to
// true to bring it back.
const SHOW_AD_BANNER = false;
// Edge-swipe back (Stage 17): a left-edge rightward drag returns to `back`, the standard
// Edge-swipe back: a left-edge rightward drag returns to `back`, the standard
// mobile gesture. Listened at the window in the CAPTURE phase so the board's own pointer
// handlers (which capture/stop the event) can never swallow it; armed only from the very
// left edge (<=24px), touch/pen only, so it never competes with the board's gestures.
@@ -72,7 +72,7 @@
flex-direction: column;
/* Fit the visible viewport (set from visualViewport, app.svelte.ts) so a screen with a
bottom input — chat, word-check — stays above an open soft keyboard without the page
scrolling; falls back to the full height where the var is unset (Stage 17). */
scrolling; falls back to the full height where the var is unset. */
height: var(--vvh, 100%);
}
.content {
+2 -2
View File
@@ -94,7 +94,7 @@
return () => cancelAnimationFrame(raf);
});
// Pinch zoom (Stage 17): a two-finger spread zooms in toward the pinch midpoint, a pinch
// Pinch zoom: a two-finger spread zooms in toward the pinch midpoint, a pinch
// close zooms out. preventDefault fires only for two touches, so the one-finger native
// scroll of the zoomed board is left untouched. It maps to the same two-state zoom as
// double-tap, toggling toward the midpoint cell.
@@ -278,7 +278,7 @@
.cell.pending {
background: var(--tile-pending);
/* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed
board) instead of the touch starting a board pan (Stage 17). */
board) instead of the touch starting a board pan. */
touch-action: none;
}
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
+2 -2
View File
@@ -15,7 +15,7 @@
messages: ChatMessage[];
myId: string;
busy: boolean;
// Chat and nudge are mutually exclusive by turn (Stage 17): on the player's own turn the
// Chat and nudge are mutually exclusive by turn: on the player's own turn the
// message field + send are shown (and nudging makes no sense — there is no one to
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
// cooldown is active the nudge is disabled with an "awaiting reply" caption.
@@ -72,7 +72,7 @@
gap: 10px;
/* Fill the chat screen; the list scrolls and the input pins to the bottom. The screen
fits the visual viewport (--vvh), so an open keyboard simply shrinks it and the input
stays visible — no modal relayout, no page jump (Stage 17). */
stays visible — no modal relayout, no page jump. */
flex: 1;
min-height: 0;
padding: 10px var(--pad);
+1 -1
View File
@@ -7,7 +7,7 @@
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, StateView } from '../lib/model';
// The chat is its own screen (Stage 17), so the soft keyboard simply resizes the viewport with
// The chat is its own screen, so the soft keyboard simply resizes the viewport with
// the input pinned to the bottom — no modal relayout jank. It loads the game state (for the
// turn-based chat/nudge toggle) and the message list, and clears the unread badge while open.
let { id }: { id: string } = $props();
+1 -1
View File
@@ -8,7 +8,7 @@
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import type { Variant } from '../lib/model';
// Word-check on its own screen (Stage 17): unlimited dictionary lookups, each with a
// Word-check on its own screen: unlimited dictionary lookups, each with a
// complaint, off the board so the soft keyboard never relayouts the play area.
let { id }: { id: string } = $props();
+14 -14
View File
@@ -112,7 +112,7 @@
}
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
// scheduleDraftSave persists the composition (rack order + pending tiles) after a short
// debounce; best-effort, so a failed save never interrupts play (Stage 17).
// debounce; best-effort, so a failed save never interrupts play.
function scheduleDraftSave() {
if (draftSaveTimer) clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => {
@@ -178,7 +178,7 @@
if (!e) return;
if (e.kind === 'opponent_moved' && e.gameId === id) {
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
// the move as a delta with no fetch (R4).
// the move as a delta with no fetch.
if (placement.pending.length > 0) void load();
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
} else if (e.kind === 'your_turn' && e.gameId === id) {
@@ -208,15 +208,15 @@
let hoverKey = '';
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
// The empty board cell the dragged tile is currently aimed at, highlighted as a drop
// target while carrying a tile over the board (Stage 17). Null over an occupied cell.
// target while carrying a tile over the board. Null over an occupied cell.
let dropTarget = $state<{ row: number; col: number } | null>(null);
// Rack reordering (Stage 17): while a rack tile is dragged, reorderDragId is its stable id
// Rack reordering: while a rack tile is dragged, reorderDragId is its stable id
// (so the rack hides it — the ghost stands in) and reorderTo is the drop slot over the rack
// (a gap opens there). Only when no tiles are pending, so the order is a clean permutation.
let reorderDragId = $state<number | null>(null);
let reorderTo = $state<number | null>(null);
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
// hidden from the board (the ghost stands in) like a lifted rack tile (Stage 17).
// hidden from the board (the ghost stands in) like a lifted rack tile.
let draggingPend = $state<{ row: number; col: number } | null>(null);
let dragPointerId = -1;
@@ -245,7 +245,7 @@
drag = null;
}
function onRackDown(e: PointerEvent, index: number) {
// Tiles may be arranged on the opponent's turn too (Stage 17 #5): only placement is
// Tiles may be arranged on the opponent's turn too: only placement is
// relaxed — the preview and Make-move stay your-turn-only, so an off-turn draft is
// position-only (never scored or submitted).
if (busy || gameOver) return;
@@ -390,7 +390,7 @@
window.removeEventListener('pointerdown', onExtraPointer);
clearHover();
clearReorder();
// Flush a pending draft save so leaving mid-composition still persists it (Stage 17).
// Flush a pending draft save so leaving mid-composition still persists it.
if (draftSaveTimer) {
clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
@@ -401,7 +401,7 @@
function onCell(row: number, col: number) {
if (swallowClick) return;
// A pending tile is recalled by a double-tap or by dragging it back to the rack, not
// by a single tap (which recalled too easily — Stage 17).
// by a single tap (which recalled too easily).
if (pendingMap.has(`${row},${col}`)) return;
if (selected != null) {
// A committed tile already sits here: keep the rack selection so a stray tap
@@ -418,7 +418,7 @@
scheduleDraftSave();
}
// relocatePending moves a placed-but-unsubmitted tile from one board cell to another free one
// (a board→board drag), keeping its rack slot and any blank letter (Stage 17).
// (a board→board drag), keeping its rack slot and any blank letter.
function relocatePending(fromRow: number, fromCol: number, toRow: number, toCol: number) {
const pt = placement.pending.find((p) => p.row === fromRow && p.col === fromCol);
if (!pt) return;
@@ -459,7 +459,7 @@
function recompute() {
preview = null;
if (previewTimer) clearTimeout(previewTimer);
// Off-turn the composition is position-only: no score preview or evaluate (Stage 17 #5).
// Off-turn the composition is position-only: no score preview or evaluate.
if (!isMyTurn) return;
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
@@ -473,7 +473,7 @@
}
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
// post-move game and the refilled rack — without a follow-up game.state + game.history (R4).
// post-move game and the refilled rack — without a follow-up game.state + game.history.
function applyMoveResult(r: MoveResult) {
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
moves = [...moves, r.move];
@@ -613,7 +613,7 @@
}
// Friend state for the in-game "add to friends" item, derived from the server so it is
// correct across reloads and live-updates when a request is answered (Stage 17):
// correct across reloads and live-updates when a request is answered:
// `friends` are the caller's accepted friends; `requested` are the addressees already
// requested (pending or declined — both block a re-send and read as "request sent").
let friends = $state(new Set<string>());
@@ -985,7 +985,7 @@
min-width: 0;
}
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
while the pending word is known to be illegal (Stage 17). */
while the pending word is known to be illegal. */
.make {
min-width: 56px;
background: none;
@@ -1045,7 +1045,7 @@
pointer-events: none;
z-index: 60;
}
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x (Stage 17). */
/* On touch the finger covers the tile, so enlarge the drag ghost ~1.5x. */
.ghost.touch {
transform: translate(-50%, -50%) scale(1.5);
}
+2 -2
View File
@@ -20,7 +20,7 @@
selected: number | null;
shuffling?: boolean;
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
// the drag ghost stands in) and dropIndex is the slot where a gap opens (Stage 17).
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
draggingId?: number | null;
dropIndex?: number | null;
ondown: (e: PointerEvent, index: number) => void;
@@ -93,7 +93,7 @@
user-select: none;
-webkit-user-select: none;
/* iOS shows a tap/active highlight that can linger on the neighbour sliding into a
dragged tile's slot (Stage 17); suppress it so only our own styles mark a tile. */
dragged tile's slot; suppress it so only our own styles mark a tile. */
-webkit-tap-highlight-color: transparent;
}
.tile.selected {
+1 -1
View File
@@ -1,5 +1,5 @@
// Localised "About" / landing copy, shared by the About screen and the public landing
// page (Stage 17). Kept out of the flat i18n catalog because it is structured (a heading,
// page. Kept out of the flat i18n catalog because it is structured (a heading,
// a rules link, two bulleted sections) and only used in these two long-form places.
import type { Locale } from './i18n/index.svelte';
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from './alphabet';
// The cache module is per-file-isolated by vitest, so only what these tests seed exists.
describe('alphabet cache (Stage 13)', () => {
describe('alphabet cache', () => {
it('upper-cases letters for display and maps indices and values case-insensitively', () => {
setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 },
+1 -1
View File
@@ -1,4 +1,4 @@
// Per-variant alphabet table cache (Stage 13). The client is alphabet-agnostic: it caches
// Per-variant alphabet table cache. The client is alphabet-agnostic: it caches
// each variant's (index, letter, value) table — sent by the server on a per-variant cache
// miss, behind game.state's include_alphabet flag — and renders the rack and the blank
// chooser with it while live play exchanges bare alphabet indices on the wire. Letters are
+6 -6
View File
@@ -36,7 +36,7 @@ export interface Toast {
export const app = $state<{
ready: boolean;
/** Whether the live-event stream is connected; drives the matchmaking poll fallback (R4). */
/** Whether the live-event stream is connected; drives the matchmaking poll fallback. */
streamAlive: boolean;
session: Session | null;
profile: Profile | null;
@@ -154,12 +154,12 @@ function openStream(): void {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'match_found') {
// Seed the cache from the event's initial state so the game renders instantly on arrival,
// then navigate (R4).
// then navigate.
if (e.state) setCachedGame(e.state.game.id, e.state, []);
navigate(`/game/${e.gameId}`);
} else if (e.kind === 'notify') {
// A started invited game seeds its cache so opening it is instant; the lobby badge stays
// on the authoritative refresh (R4).
// on the authoritative refresh.
if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []);
void refreshNotifications();
}
@@ -231,7 +231,7 @@ async function adoptSession(s: Session): Promise<void> {
}
/**
* applyLinkResult applies a completed account link or merge (Stage 11): it adopts a
* applyLinkResult applies a completed account link or merge: it adopts a
* switched session (a guest initiator whose durable counterpart won, so the active
* account changed) or, otherwise, refreshes the current profile in place.
*/
@@ -261,7 +261,7 @@ function syncTelegramChrome(): void {
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
* `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its
* band (Stage 17). Called on launch and on Telegram's safe-area / fullscreen change events.
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
*/
function syncTelegramSafeArea(): void {
if (typeof document === 'undefined') return;
@@ -275,7 +275,7 @@ function syncTelegramSafeArea(): void {
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
* without the page scrolling, so the layout no longer jumps when the keyboard appears (Stage 17).
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
*/
function syncViewportHeight(): void {
if (typeof document === 'undefined') return;
+8 -8
View File
@@ -69,7 +69,7 @@ export interface GatewayClient {
lobbyCancel(): Promise<void>;
// --- game ---
// Stage 13: the play loop exchanges alphabet indices, so submit/evaluate/exchange/
// The play loop exchanges alphabet indices, so submit/evaluate/exchange/
// check-word take the game's variant (to map letters<->indices via the cached alphabet
// table), and gameState's includeAlphabet asks the server to embed that table.
gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
@@ -82,10 +82,10 @@ export interface GatewayClient {
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
/** Hide a finished game from the caller's own lobby list (Stage 17); per-account, irreversible. */
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
hideGame(gameId: string): Promise<void>;
// --- draft (Stage 17) ---
// --- draft ---
/** The player's server-persisted client-side composition (rack order + board tiles), so a
* reload or a second device resumes the same arrangement. The JSON is opaque to the
* gateway; the client owns the {rack_order, board_tiles} shape. */
@@ -97,7 +97,7 @@ export interface GatewayClient {
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
// --- friends (Stage 8) ---
// --- friends ---
friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>;
/** Addressees the caller has already requested (pending or declined); cannot re-request. */
@@ -109,24 +109,24 @@ export interface GatewayClient {
friendCodeIssue(): Promise<FriendCode>;
friendCodeRedeem(code: string): Promise<AccountRef>;
// --- blocks (Stage 8) ---
// --- blocks ---
blocksList(): Promise<AccountRef[]>;
block(accountId: string): Promise<void>;
unblock(accountId: string): Promise<void>;
// --- invitations (Stage 8) ---
// --- invitations ---
invitationsList(): Promise<Invitation[]>;
invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation>;
invitationAccept(invitationId: string): Promise<Invitation>;
invitationDecline(invitationId: string): Promise<Invitation>;
invitationCancel(invitationId: string): Promise<void>;
// --- profile / stats / history (Stage 8) ---
// --- profile / stats / history ---
profileUpdate(p: ProfileUpdate): Promise<Profile>;
statsGet(): Promise<Stats>;
exportGcg(gameId: string): Promise<GcgExport>;
// --- account linking & merge (Stage 11) ---
// --- account linking & merge ---
linkEmailRequest(email: string): Promise<void>;
linkEmailConfirm(email: string, code: string): Promise<LinkResult>;
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
+4 -4
View File
@@ -21,7 +21,7 @@ import {
} from './codec';
describe('codec', () => {
it('round-trips a draft save request and view (Stage 17)', () => {
it('round-trips a draft save request and view', () => {
const json = '{"rack_order":"1,0","board_tiles":[]}';
const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json)));
expect(req.gameId()).toBe('g1');
@@ -35,7 +35,7 @@ describe('codec', () => {
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
});
it('encodes a SubmitPlayRequest with alphabet indices (Stage 13)', () => {
it('encodes a SubmitPlayRequest with alphabet indices', () => {
setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 },
{ index: 1, letter: 'b', value: 3 },
@@ -268,10 +268,10 @@ describe('codec', () => {
});
});
// Stage 13: the live play loop exchanges alphabet indices, mapped through the per-variant
// The live play loop exchanges alphabet indices, mapped through the per-variant
// table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces
// the whole table), so they are independent of order.
describe('codec — alphabet on the wire (Stage 13)', () => {
describe('codec — alphabet on the wire', () => {
it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
setAlphabet('scrabble_en', [
{ index: 0, letter: 'a', value: 1 },
+7 -7
View File
@@ -38,7 +38,7 @@ import type {
// --- request encoders ---
// buildPlayTile encodes one to-place tile by its alphabet index (Stage 13); a placed blank
// buildPlayTile encodes one to-place tile by its alphabet index; a placed blank
// carries its designated letter's index with blank set.
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
fb.PlayTile.startPlayTile(b);
@@ -73,7 +73,7 @@ export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Ui
return finish(b, fb.StateRequest.endStateRequest(b));
}
// encodeDraftSave wraps the player's composition JSON (Stage 17). The string is opaque on the
// encodeDraftSave wraps the player's composition JSON. The string is opaque on the
// wire — the gateway forwards it verbatim and only the client reads {rack_order, board_tiles}.
export function encodeDraftSave(gameId: string, json: string): Uint8Array {
const b = new Builder(256);
@@ -324,7 +324,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
// rack to display letters with it (Stage 13).
// rack to display letters with it.
function decodeStateViewTable(v: fb.StateView): StateView {
const g = v.game();
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
@@ -355,7 +355,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult {
const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
const m = r.move();
const g = r.game();
// The actor's refilled rack rides back as alphabet indices (R4); decode it with the game's variant.
// The actor's refilled rack rides back as alphabet indices; decode it with the game's variant.
const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
const rack: string[] = [];
for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0));
@@ -493,7 +493,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
}
}
// --- Stage 8 encoders ---
// --- social encoders ---
export function encodeTarget(accountId: string): Uint8Array {
const b = new Builder(64);
@@ -563,7 +563,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
// --- account linking & merge (Stage 11) ---
// --- account linking & merge ---
export function encodeLinkEmailRequest(email: string): Uint8Array {
const b = new Builder(128);
@@ -604,7 +604,7 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult {
};
}
// --- Stage 8 decoders ---
// --- social decoders ---
function decodeAccountRef(r: fb.AccountRef): AccountRef {
return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
+1 -1
View File
@@ -1,4 +1,4 @@
// Global connectivity signal (Stage 17). `online` is false while the app is actively failing to
// Global connectivity signal. `online` is false while the app is actively failing to
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
+1 -1
View File
@@ -1,5 +1,5 @@
// Draft (client-side composition) serialization, kept pure for unit tests. The server stores
// the JSON opaquely (Stage 17); only the client interprets {rack_order, board_tiles}. The rack
// the JSON opaquely; only the client interprets {rack_order, board_tiles}. The rack
// order is a comma-joined permutation of the server rack's indices, in the player's visual
// order; the board tiles are the tiles laid but not yet submitted.
+1 -1
View File
@@ -44,7 +44,7 @@ describe('applyMoveDelta', () => {
expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
});
it('refetches when the payload carries no delta (pre-R4 peer / dropped payload)', () => {
it('refetches when the payload carries no delta (older peer / dropped payload)', () => {
expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
});
+2 -2
View File
@@ -1,4 +1,4 @@
// Pure reducers that advance the per-game cache from live events (R4), so the UI renders a move
// Pure reducers that advance the per-game cache from live events, so the UI renders a move
// from the event without a follow-up game.state + game.history fetch. They never touch the network
// or the cache store — the stream handler applies the returned cache and the game screen acts on
// `refetch` — which keeps the gap / own-move / idempotency logic unit-testable in isolation.
@@ -42,7 +42,7 @@ export function applyMoveDelta(cached: CachedGame | undefined, d: MoveDelta): De
// Nothing cached to advance (the game was never opened on this device): ignore it; the next open
// cold-loads the game.
if (!cached) return { refetch: false };
// A pre-R4 peer, or a dropped payload, carried no delta: the open game must refetch.
// An older peer, or a dropped payload, carried no delta: the open game must refetch.
if (!d.move || !d.game) return { refetch: true };
const have = cached.view.game.moveCount;
const next = d.game.moveCount;
+1 -1
View File
@@ -1,4 +1,4 @@
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
// Pure helpers for the public landing page, kept out of the Svelte component so
// the per-language Telegram-channel link selection is unit-testable.
import type { Locale } from './i18n/index.svelte';
+1 -1
View File
@@ -1,4 +1,4 @@
// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three
// Pure grouping + ordering of the lobby's game list. The lobby shows three
// sections — games awaiting the caller's move, games awaiting the opponent, and finished
// games — each ordered by last activity: your-turn oldest-first (the longest-neglected on
// top), the other two newest-first.
+1 -1
View File
@@ -1,4 +1,4 @@
// Mock alphabet fixtures (Stage 13). In production the per-variant (index, letter, value)
// Mock alphabet fixtures. In production the per-variant (index, letter, value)
// table comes from the server; the mock seeds the same client cache from a local copy so
// the rack, the blank chooser and the mock's scoring work with no backend. The data is the
// solver's value tables (scrabble-solver/rules/rules.go), in alphabet-index order, so a
+4 -4
View File
@@ -98,7 +98,7 @@ export class MockGateway implements GatewayClient {
constructor() {
// Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
// mock-driven UI is alphabet-agnostic without a backend (Stage 13).
// mock-driven UI is alphabet-agnostic without a backend.
seedMockAlphabets();
}
@@ -324,7 +324,7 @@ export class MockGateway implements GatewayClient {
}
async complaint(): Promise<void> {}
// Hide a finished game from the caller's list (Stage 17): drop it from the in-memory store so a
// Hide a finished game from the caller's list: drop it from the in-memory store so a
// subsequent gamesList omits it, mirroring the backend's per-account, finished-only rule.
async hideGame(gameId: string): Promise<void> {
const g = this.game(gameId);
@@ -333,7 +333,7 @@ export class MockGateway implements GatewayClient {
this.drafts.delete(gameId);
}
// --- draft (Stage 17): an in-memory composition store, so the reload/off-turn flow is
// --- draft: an in-memory composition store, so the reload/off-turn flow is
// exercised without a backend. A committed move clears the actor's own draft, as on the server.
async draftGet(gameId: string): Promise<string> {
return this.drafts.get(gameId) ?? '';
@@ -470,7 +470,7 @@ export class MockGateway implements GatewayClient {
Object.assign(this.profile, p);
return { ...this.profile };
}
// --- account linking & merge (Stage 11) ---
// --- account linking & merge ---
async linkEmailRequest(_email: string): Promise<void> {}
async linkEmailConfirm(email: string, _code: string): Promise<LinkResult> {
// An address containing "merge" stands in for one already owned by another
+1 -1
View File
@@ -42,7 +42,7 @@ export const PROFILE: Profile = {
};
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable.
// is a durable account so the social surfaces (friends, stats, history) are reachable.
// Ann is the active game's opponent but deliberately not a friend, so the in-game
// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend.
export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }];
+4 -4
View File
@@ -70,7 +70,7 @@ export interface StateView {
export interface MoveResult {
move: MoveRecord;
game: GameView;
/** The actor's refilled rack after the move (R4), so the mover renders the next state without a refetch. */
/** The actor's refilled rack after the move, so the mover renders the next state without a refetch. */
rack: string[];
bagLen: number;
}
@@ -198,7 +198,7 @@ export interface Session {
supportedLanguages: string[];
}
// LinkResult is the outcome of an account link/merge step (Stage 11). status is
// LinkResult is the outcome of an account link/merge step. status is
// 'linked' (bound to the current account), 'merge_required' (the identity belongs to
// another account — the secondary* fields summarise it for the irreversible
// confirmation) or 'merged'. session is set only when the active account switched
@@ -230,8 +230,8 @@ export interface GameList {
* A live event delivered over the Subscribe stream. The game events carry the move as a
* delta — move plus the post-move summary (and the bag size) — the client applies to its
* cached game without a refetch; match_found / game_started carry the recipient's initial
* StateView; notify carries the changed lobby payload (R4). The enriched fields are optional
* so a client falls back to a refetch when a payload is absent (a gap, or a pre-R4 peer).
* StateView; notify carries the changed lobby payload. The enriched fields are optional
* so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
*/
export type PushEvent =
| { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number }
+1 -1
View File
@@ -4,7 +4,7 @@ import { BOARD_SIZE, centre, premiumGrid } from './premiums';
// Premium-square geometry parity with scrabble-solver/rules/rules.go: scrabble_en/scrabble_ru
// share standardBoard (centre is a double word); erudit_ru shares the geometry but a
// non-doubling centre. Tile-value and alphabet parity moved to the Go engine test
// (backend/internal/engine AlphabetTable) in Stage 13 — the server now owns that table.
// (backend/internal/engine AlphabetTable) — the server now owns that table.
describe('premium layout', () => {
it('is a 15x15 grid with TW corners', () => {
const g = premiumGrid('scrabble_en');
+3 -3
View File
@@ -2,7 +2,7 @@
// of truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard). The board is not
// transmitted on the wire (StateView has no board), so the client renders the premiums
// locally; only the centre differs by variant. A Vitest parity test pins the geometry.
// Tile values and the alphabet moved to the server-sent per-variant table in Stage 13 (see
// Tile values and the alphabet come from the server-sent per-variant table (see
// lib/alphabet.ts), so this file is geometry only.
import type { Variant } from './model';
@@ -85,5 +85,5 @@ export function centre(variant: Variant): { row: number; col: number } {
return { row: 7, col: 7 };
}
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts,
// Stage 13); the board geometry above is all this module owns.
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts);
// the board geometry above is all this module owns.
+1 -1
View File
@@ -14,7 +14,7 @@ export const maxAwayMinutes = 12 * 60;
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
// may be followed by a single space. No leading separator and no adjacent separators
// except "<dot|underscore> <space>"; a single trailing "." is allowed (Stage 17). Same
// except "<dot|underscore> <space>"; a single trailing "." is allowed. Same
// rule as the Go displayNameRe.
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
+1 -1
View File
@@ -51,7 +51,7 @@ describe('resultBadge', () => {
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (Stage 17 regression).
// must read this as a loss, matching the game-detail screen (regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
+1 -1
View File
@@ -1,4 +1,4 @@
// Retry policy + error classification for the gateway transport (Stage 17). When a unary call
// Retry policy + error classification for the gateway transport. When a unary call
// fails at the transport level the app retries it with capped exponential backoff while showing
// the "Connecting…" indicator, instead of flashing a red toast each time.
//
+1 -1
View File
@@ -192,7 +192,7 @@ export function onTelegramPath(): boolean {
return location.pathname.startsWith('/telegram/');
}
// --- Login Widget (web sign-in for account linking, Stage 11) ---
// --- Login Widget (web sign-in for account linking) ---
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
// attach a Telegram identity to an existing account from a browser; inside the Mini
+2 -3
View File
@@ -1,4 +1,4 @@
// Game variants offered on New Game, and the Stage 15 gating of that choice by the
// Game variants offered on New Game, and the gating of that choice by the
// languages the sign-in service supports. Kept out of the .svelte screen so the
// gating is unit-testable (the project's node-env Vitest layer).
@@ -14,8 +14,7 @@ export interface VariantOption {
// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by
// the game's alphabet, not the interface language: the English-alphabet game is always
// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
// (Stage 17).
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит".
export const ALL_VARIANTS: VariantOption[] = [
{ id: 'scrabble_en', label: 'new.english' },
{ id: 'scrabble_ru', label: 'new.russian' },
+3 -3
View File
@@ -62,7 +62,7 @@
return `${me?.score ?? 0} : ${opp.join(', ')}`;
}
// Hiding a finished game (Stage 17). The delete action sits behind each finished row and is
// Hiding a finished game. The delete action sits behind each finished row and is
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
// per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null);
@@ -277,13 +277,13 @@
user-select: none;
}
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows (Stage 17). */
consecutive rows. */
.list {
display: flex;
flex-direction: column;
}
/* Each finished row can slide left to reveal a delete action sitting behind it; the row's
own opaque background hides that action until revealed (Stage 17). */
own opaque background hides that action until revealed. */
.rowwrap {
position: relative;
overflow: hidden;
+5 -5
View File
@@ -12,8 +12,8 @@
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24;
// The offered variants are gated by the languages the sign-in service supports
// (Stage 15); the auto-match list and the friend-invite picker both use this.
// The offered variants are gated by the languages the sign-in service supports;
// the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.session?.supportedLanguages));
const timeouts = [
{ secs: 300, key: 'time.minutes' as MessageKey, n: 5 },
@@ -39,7 +39,7 @@
}
}
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
// up the match_found push drives navigation (R4). It polls lobby.poll every 2.5s.
// up the match_found push drives navigation. It polls lobby.poll every 2.5s.
function startPoll() {
if (poll) return;
poll = setInterval(async () => {
@@ -58,7 +58,7 @@
}
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
// reaper later substitutes a robot for a game the player abandoned (Stage 17 fix).
// reaper later substitutes a robot for a game the player abandoned.
function cancelSearch() {
stop();
searching = false;
@@ -104,7 +104,7 @@
let selected = $state<string[]>([]);
let friendFilter = $state('');
// No default game type yet — the player must pick one (a smarter default from play
// history / language is TODO-6). '' renders the disabled placeholder option.
// history / language would be a future refinement). '' renders the disabled placeholder option.
let inviteVariant = $state<Variant | ''>('');
let timeoutSecs = $state(86400);
let hints = $state(1);
+3 -3
View File
@@ -49,7 +49,7 @@
}
// populate loads the editable form from the current profile. The profile screen is
// edited inline (no edit/cancel toggle, Stage 17), so this runs on mount and after a
// edited inline (no edit/cancel toggle), so this runs on mount and after a
// link/merge swaps the active account.
function populate() {
const p = app.profile;
@@ -215,7 +215,7 @@
</form>
{/if}
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
<!-- Linking & merge. Shown to everyone, including guests, who
upgrade by binding their first identity. -->
<section class="emailbox">
<h3>{t('profile.linkAccount')}</h3>
@@ -246,7 +246,7 @@
{/if}
</section>
<!-- Logout is hidden for now (Stage 17) but kept wired — drop `hidden` to re-enable
<!-- Logout is hidden for now but kept wired — drop `hidden` to re-enable
once its entry point is decided; logout() also still runs on an invalid session. -->
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
+2 -2
View File
@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => ({
base: './',
define: {
// App version shown on the About screen, injected at build time from `git describe`
// via a Docker build-arg (Stage 17). Falls back to "dev" for a plain local/mock build,
// via a Docker build-arg. Falls back to "dev" for a plain local/mock build,
// so a missing build-arg never breaks the build.
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
},
@@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => ({
build: {
target: 'es2022',
sourcemap: true,
// Two entries (Stage 17): the game SPA (index.html, served at /app/ + /telegram/) and the
// Two entries: 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: {