Stage 7 (wip): UI shell, libs, mock transport, screens (lobby->game), e2e smoke

- plain Svelte 5 + TS + Vite (no SvelteKit); CSS-token design system (Telegram-ready), hash router, IndexedDB session
- pure libs: domain model, premium/value maps ported from solver, board replay, placement state machine, i18n en/ru
- in-memory mock transport + seed data; pnpm start runs lobby->active game->board with no backend
- board: pointer-drag + tap placement, MakeMove (popup / 1s-hold commit), two-state zoom, blank chooser, exchange, hint, word-check, chat
- Playwright smoke (mock) green; svelte-check clean; mock bundle ~37 KB gzip
This commit is contained in:
Ilia Denisov
2026-06-03 00:32:50 +02:00
parent 19ae8f04a2
commit 453ddc5e94
48 changed files with 5696 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import type { BoardCell } from '../lib/board';
import type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model';
let {
board,
premium,
pending,
recent,
centre,
zoomed,
variant,
oncell,
ontogglezoom,
}: {
board: (BoardCell | null)[][];
premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>;
recent: Set<string>;
centre: { row: number; col: number };
zoomed: boolean;
variant: Variant;
oncell: (row: number, col: number) => void;
ontogglezoom: () => void;
} = $props();
const premClass: Record<Premium, string> = {
'': '',
TW: 'tw',
DW: 'dw',
TL: 'tl',
DL: 'dl',
};
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
// Double-tap toggles zoom.
let lastTap = 0;
function onTap(row: number, col: number) {
const now = Date.now();
if (now - lastTap < 300) {
ontogglezoom();
lastTap = 0;
return;
}
lastTap = now;
oncell(row, col);
}
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out.
const pts = new Map<number, { x: number; y: number }>();
let startDist = 0;
function dist(): number {
const p = [...pts.values()];
if (p.length < 2) return 0;
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
}
function onPointerDown(e: PointerEvent) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) startDist = dist();
}
function onPointerMove(e: PointerEvent) {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2 && startDist > 0) {
const d = dist();
if (!zoomed && d > startDist * 1.25) {
ontogglezoom();
startDist = 0;
} else if (zoomed && d < startDist * 0.8) {
ontogglezoom();
startDist = 0;
}
}
}
function onPointerUp(e: PointerEvent) {
pts.delete(e.pointerId);
if (pts.size < 2) startDist = 0;
}
function key(r: number, c: number): string {
return `${r},${c}`;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="viewport"
class:zoomed
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="grid" class:zoomed>
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
{@const letter = cell?.letter ?? p?.letter ?? ''}
{@const blank = cell?.blank ?? p?.blank ?? false}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:recent={recent.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if premLabel[premium[r][c]]}
<span class="plabel">{premLabel[premium[r][c]]}</span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
<style>
.viewport {
width: 100%;
background: var(--board-bg);
padding: 4px;
border-radius: var(--radius-sm);
touch-action: none;
}
.viewport.zoomed {
overflow: auto;
max-height: 70vh;
}
.grid {
display: grid;
grid-template-columns: repeat(15, 1fr);
gap: 2px;
width: 100%;
}
.grid.zoomed {
grid-template-columns: repeat(15, 2.6rem);
width: max-content;
}
.cell {
position: relative;
aspect-ratio: 1;
border: none;
border-radius: 2px;
background: var(--cell-bg);
color: var(--prem-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
padding: 0;
overflow: hidden;
}
.cell.tw {
background: var(--prem-tw);
}
.cell.dw {
background: var(--prem-dw);
}
.cell.tl {
background: var(--prem-tl);
}
.cell.dl {
background: var(--prem-dl);
}
.cell.filled,
.cell.pending {
background: var(--tile-bg);
color: var(--tile-text);
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
.cell.pending {
background: var(--tile-pending);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.cell.recent {
box-shadow:
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
}
.letter {
font-size: 1.05em;
font-weight: 700;
line-height: 1;
}
.grid:not(.zoomed) .letter {
font-size: 2.6vw;
}
.val {
position: absolute;
right: 1px;
bottom: 0;
font-size: 0.55em;
font-weight: 600;
}
.plabel {
opacity: 0.85;
font-weight: 600;
}
.star {
font-size: 1.1em;
opacity: 0.7;
}
</style>