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:
@@ -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>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import type { ChatMessage } from '../lib/model';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
let {
|
||||
messages,
|
||||
myId,
|
||||
busy,
|
||||
onsend,
|
||||
onnudge,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
myId: string;
|
||||
busy: boolean;
|
||||
onsend: (text: string) => void;
|
||||
onnudge: () => void;
|
||||
} = $props();
|
||||
|
||||
let text = $state('');
|
||||
|
||||
function send() {
|
||||
const v = text.trim();
|
||||
if (!v) return;
|
||||
onsend(v);
|
||||
text = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat">
|
||||
<div class="list">
|
||||
{#if messages.length === 0}
|
||||
<p class="empty">{t('chat.empty')}</p>
|
||||
{/if}
|
||||
{#each messages as m (m.id)}
|
||||
{#if m.kind === 'nudge'}
|
||||
<div class="note">{t('chat.nudge')}</div>
|
||||
{:else}
|
||||
<div class="msg" class:mine={m.senderId === myId}>{m.body}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="input">
|
||||
<input
|
||||
maxlength="60"
|
||||
placeholder={t('chat.placeholder')}
|
||||
bind:value={text}
|
||||
onkeydown={(e) => e.key === 'Enter' && send()}
|
||||
/>
|
||||
<button onclick={send} disabled={busy}>{t('chat.send')}</button>
|
||||
<button class="nudge" onclick={onnudge} disabled={busy}>{t('chat.nudge')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 56vh;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
}
|
||||
.msg {
|
||||
align-self: flex-start;
|
||||
max-width: 80%;
|
||||
padding: 7px 11px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.msg.mine {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.note {
|
||||
align-self: center;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.input {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.input input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.input button {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import type { EvalResult } from '../lib/model';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
let {
|
||||
preview,
|
||||
hints,
|
||||
busy,
|
||||
ambiguous,
|
||||
dir,
|
||||
ondraw,
|
||||
onskip,
|
||||
onshuffle,
|
||||
onhint,
|
||||
ondir,
|
||||
}: {
|
||||
preview: EvalResult | null;
|
||||
hints: number;
|
||||
busy: boolean;
|
||||
ambiguous: boolean;
|
||||
dir: 'H' | 'V';
|
||||
ondraw: () => void;
|
||||
onskip: () => void;
|
||||
onshuffle: () => void;
|
||||
onhint: () => void;
|
||||
ondir: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="controls">
|
||||
<div class="preview">
|
||||
{#if preview}
|
||||
{#if preview.legal}
|
||||
<span class="ok">{t('game.preview', { n: preview.score })}</span>
|
||||
{:else}
|
||||
<span class="bad">{t('game.previewIllegal')}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if ambiguous}
|
||||
<button class="dir" onclick={ondir} title="direction">{dir === 'H' ? '↔' : '↕'}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick={ondraw} disabled={busy}>{t('game.draw')}</button>
|
||||
<button onclick={onskip} disabled={busy}>{t('game.skip')}</button>
|
||||
<button onclick={onshuffle} disabled={busy}>{t('game.shuffle')}</button>
|
||||
<button class="hint" onclick={onhint} disabled={busy || hints <= 0}>
|
||||
{t('game.hint')}{hints > 0 ? ` (${hints})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview {
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
.dir {
|
||||
margin-left: auto;
|
||||
width: 34px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.row button {
|
||||
flex: 1;
|
||||
padding: 11px 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.row button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.hint {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,741 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Header from '../components/Header.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import Board from './Board.svelte';
|
||||
import Rack from './Rack.svelte';
|
||||
import MakeMove from './MakeMove.svelte';
|
||||
import Controls from './Controls.svelte';
|
||||
import Chat from './Chat.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||
import { lastPlayTiles, replay } from '../lib/board';
|
||||
import { alphabet, centre, premiumGrid } from '../lib/premiums';
|
||||
import {
|
||||
BLANK,
|
||||
direction,
|
||||
newPlacement,
|
||||
place,
|
||||
rackView,
|
||||
recallAt,
|
||||
reset,
|
||||
toSubmit,
|
||||
type Placement,
|
||||
} from '../lib/placement';
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
let view = $state<StateView | null>(null);
|
||||
let moves = $state<MoveRecord[]>([]);
|
||||
let placement = $state<Placement>(newPlacement([]));
|
||||
let preview = $state<EvalResult | null>(null);
|
||||
let dirOverride = $state<Direction | undefined>(undefined);
|
||||
let busy = $state(false);
|
||||
let zoomed = $state(false);
|
||||
let selected = $state<number | null>(null);
|
||||
let panel = $state<'none' | 'chat' | 'history'>('none');
|
||||
let menuOpen = $state(false);
|
||||
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
||||
let exchangeOpen = $state(false);
|
||||
let exchangeSel = $state<number[]>([]);
|
||||
let checkOpen = $state(false);
|
||||
let checkWord = $state('');
|
||||
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
|
||||
let resignOpen = $state(false);
|
||||
let messages = $state<ChatMessage[]>([]);
|
||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
|
||||
|
||||
const variant = $derived(view?.game.variant ?? 'english');
|
||||
const board = $derived(replay(moves));
|
||||
const premium = $derived(premiumGrid(variant));
|
||||
const ctr = $derived(centre(variant));
|
||||
const pendingMap = $derived(
|
||||
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
|
||||
);
|
||||
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
|
||||
const slots = $derived(rackView(placement));
|
||||
const isMyTurn = $derived(
|
||||
!!view && view.game.status === 'active' && view.game.toMove === view.seat,
|
||||
);
|
||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||
const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
|
||||
const ambiguous = $derived(placement.pending.length === 1);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [st, hist] = await Promise.all([gateway.gameState(id), gateway.gameHistory(id)]);
|
||||
view = st;
|
||||
moves = hist.moves;
|
||||
placement = newPlacement(st.rack);
|
||||
preview = null;
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChat() {
|
||||
try {
|
||||
messages = await gateway.chatList(id);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
$effect(() => {
|
||||
const e = app.lastEvent;
|
||||
if (!e) return;
|
||||
if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load();
|
||||
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
|
||||
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
|
||||
});
|
||||
|
||||
function isCoarse(): boolean {
|
||||
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||
}
|
||||
|
||||
// --- tile placement: pointer drag + tap, both feeding the placement model ---
|
||||
let downInfo: { index: number; x0: number; y0: number } | null = null;
|
||||
let dragMoved = false;
|
||||
let swallowClick = false;
|
||||
|
||||
function onRackDown(e: PointerEvent, index: number) {
|
||||
if (!isMyTurn || busy) return;
|
||||
downInfo = { index, x0: e.clientX, y0: e.clientY };
|
||||
dragMoved = false;
|
||||
window.addEventListener('pointermove', onWinMove);
|
||||
window.addEventListener('pointerup', onWinUp);
|
||||
}
|
||||
function onWinMove(e: PointerEvent) {
|
||||
if (!downInfo) return;
|
||||
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
|
||||
dragMoved = true;
|
||||
const slot = placement.rack[downInfo.index];
|
||||
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
|
||||
if (isCoarse() && !zoomed) zoomed = true; // auto zoom-in on touch placement
|
||||
}
|
||||
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
|
||||
}
|
||||
function onWinUp(e: PointerEvent) {
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
const di = downInfo;
|
||||
downInfo = null;
|
||||
if (drag && dragMoved && di) {
|
||||
const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(
|
||||
'[data-cell]',
|
||||
) as HTMLElement | null;
|
||||
drag = null;
|
||||
if (el?.dataset.row && el.dataset.col) {
|
||||
attemptPlace(di.index, Number(el.dataset.row), Number(el.dataset.col));
|
||||
}
|
||||
swallowClick = true;
|
||||
setTimeout(() => (swallowClick = false), 60);
|
||||
} else if (di) {
|
||||
selected = selected === di.index ? null : di.index;
|
||||
drag = null;
|
||||
}
|
||||
}
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
});
|
||||
|
||||
function onCell(row: number, col: number) {
|
||||
if (swallowClick) return;
|
||||
if (pendingMap.has(`${row},${col}`)) {
|
||||
placement = recallAt(placement, row, col);
|
||||
recompute();
|
||||
return;
|
||||
}
|
||||
if (selected != null) {
|
||||
attemptPlace(selected, row, col);
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
function attemptPlace(index: number, row: number, col: number) {
|
||||
if (board[row]?.[col]) return;
|
||||
if (pendingMap.has(`${row},${col}`)) return;
|
||||
if (placement.rack[index] === BLANK) {
|
||||
blankPrompt = { rackIndex: index, row, col };
|
||||
return;
|
||||
}
|
||||
placement = place(placement, index, row, col);
|
||||
recompute();
|
||||
}
|
||||
|
||||
function chooseBlank(letter: string) {
|
||||
if (!blankPrompt) return;
|
||||
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
||||
blankPrompt = null;
|
||||
recompute();
|
||||
}
|
||||
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function recompute() {
|
||||
preview = null;
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
if (!sub) return;
|
||||
previewTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
|
||||
} catch {
|
||||
/* preview is best-effort */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const sub = toSubmit(placement, dirOverride);
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles);
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPlacement() {
|
||||
placement = reset(placement);
|
||||
preview = null;
|
||||
selected = null;
|
||||
dirOverride = undefined;
|
||||
}
|
||||
|
||||
async function doPass() {
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.pass(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doResign() {
|
||||
resignOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.resign(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doHint() {
|
||||
try {
|
||||
const h = await gateway.hint(id);
|
||||
const word = h.move.words[0] ?? h.move.tiles.map((x) => x.letter).join('');
|
||||
showToast(t('game.hintShown', { word, n: h.move.score }));
|
||||
if (view) view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function shuffle() {
|
||||
if (placement.pending.length > 0) return;
|
||||
const r = [...placement.rack];
|
||||
for (let i = r.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[r[i], r[j]] = [r[j], r[i]];
|
||||
}
|
||||
placement = newPlacement(r);
|
||||
}
|
||||
|
||||
function toggleDir() {
|
||||
dirOverride = dir === 'H' ? 'V' : 'H';
|
||||
recompute();
|
||||
}
|
||||
|
||||
function openExchange() {
|
||||
menuOpen = false;
|
||||
resetPlacement();
|
||||
exchangeSel = [];
|
||||
exchangeOpen = true;
|
||||
}
|
||||
function toggleExch(i: number) {
|
||||
exchangeSel = exchangeSel.includes(i) ? exchangeSel.filter((x) => x !== i) : [...exchangeSel, i];
|
||||
}
|
||||
async function doExchange() {
|
||||
if (!view || exchangeSel.length === 0) return;
|
||||
const tiles = exchangeSel.map((i) => view!.rack[i]);
|
||||
exchangeOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.exchange(id, tiles);
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCheck() {
|
||||
menuOpen = false;
|
||||
checkWord = '';
|
||||
checkResult = null;
|
||||
checkOpen = true;
|
||||
}
|
||||
async function runCheck() {
|
||||
const w = checkWord.trim();
|
||||
if (!w) return;
|
||||
try {
|
||||
checkResult = await gateway.checkWord(id, w);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
async function complain() {
|
||||
if (!checkResult) return;
|
||||
try {
|
||||
await gateway.complaint(id, checkResult.word, '');
|
||||
showToast(t('game.complaintSent'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function openChat() {
|
||||
menuOpen = false;
|
||||
panel = 'chat';
|
||||
void loadChat();
|
||||
}
|
||||
async function sendChat(text: string) {
|
||||
try {
|
||||
const m = await gateway.chatPost(id, text);
|
||||
messages = [...messages, m];
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
async function nudge() {
|
||||
try {
|
||||
const m = await gateway.nudge(id);
|
||||
messages = [...messages, m];
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function resultText(): string {
|
||||
if (!view) return '';
|
||||
const me = view.game.seats[view.seat];
|
||||
if (me?.isWinner) return t('game.won');
|
||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Header title={t('app.title')} back="/">
|
||||
{#snippet menu()}
|
||||
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu">≡</button>
|
||||
{#if menuOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
|
||||
<div class="dropdown">
|
||||
<button onclick={() => { menuOpen = false; panel = 'history'; }}>{t('game.history')}</button>
|
||||
<button onclick={openChat}>{t('game.chat')}</button>
|
||||
<button onclick={openCheck}>{t('game.checkWord')}</button>
|
||||
<button onclick={() => { menuOpen = false; resignOpen = true; }}>{t('game.dropGame')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Header>
|
||||
|
||||
{#if view}
|
||||
<div class="scoreboard">
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
|
||||
<div class="sc">{s.score}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="boardwrap">
|
||||
<Board
|
||||
{board}
|
||||
{premium}
|
||||
pending={pendingMap}
|
||||
{recent}
|
||||
centre={ctr}
|
||||
{zoomed}
|
||||
{variant}
|
||||
oncell={onCell}
|
||||
ontogglezoom={() => (zoomed = !zoomed)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<span>{t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||
{:else}
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })}</span>
|
||||
{/if}
|
||||
<span>{t('game.hints', { n: view.hintsRemaining })}</span>
|
||||
</div>
|
||||
|
||||
{#if !gameOver}
|
||||
<div class="rack-row">
|
||||
<div class="rack-wrap">
|
||||
<Rack {slots} {variant} {selected} ondown={onRackDown} />
|
||||
</div>
|
||||
{#if placement.pending.length > 0}
|
||||
<MakeMove
|
||||
label={t('game.makeMove')}
|
||||
resetLabel={t('game.reset')}
|
||||
onmake={commit}
|
||||
onreset={resetPlacement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Controls
|
||||
{preview}
|
||||
hints={view.hintsRemaining}
|
||||
busy={busy || !isMyTurn}
|
||||
{ambiguous}
|
||||
{dir}
|
||||
ondraw={openExchange}
|
||||
onskip={doPass}
|
||||
onshuffle={shuffle}
|
||||
onhint={doHint}
|
||||
ondir={toggleDir}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{/if}
|
||||
|
||||
{#if drag}
|
||||
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
|
||||
<span>{drag.blank ? '' : drag.letter}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if blankPrompt}
|
||||
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
||||
<div class="alpha">
|
||||
{#each alphabet(variant) as ch (ch)}
|
||||
<button onclick={() => chooseBlank(ch)}>{ch}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if exchangeOpen && view}
|
||||
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
|
||||
<div class="exch">
|
||||
{#each view.rack as letter, i (i)}
|
||||
<button class="etile" class:sel={exchangeSel.includes(i)} onclick={() => toggleExch(i)}>
|
||||
{letter === BLANK ? '?' : letter}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="confirm" disabled={exchangeSel.length === 0} onclick={doExchange}>
|
||||
{t('game.exchangeConfirm', { n: exchangeSel.length })}
|
||||
</button>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if checkOpen}
|
||||
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
|
||||
<div class="check">
|
||||
<input placeholder={t('game.checkWordPrompt')} bind:value={checkWord} onkeydown={(e) => e.key === 'Enter' && runCheck()} />
|
||||
<button onclick={runCheck}>{t('game.checkWord')}</button>
|
||||
</div>
|
||||
{#if checkResult}
|
||||
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
|
||||
{checkResult.legal
|
||||
? t('game.wordLegal', { word: checkResult.word })
|
||||
: t('game.wordIllegal', { word: checkResult.word })}
|
||||
</p>
|
||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||
{/if}
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if resignOpen}
|
||||
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if panel === 'chat'}
|
||||
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
|
||||
<Chat {messages} myId={app.session?.userId ?? ''} {busy} onsend={sendChat} onnudge={nudge} />
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if panel === 'history' && view}
|
||||
<Modal title={t('game.history')} onclose={() => (panel = 'none')}>
|
||||
<ol class="history">
|
||||
{#each moves as m, i (i)}
|
||||
<li>
|
||||
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
||||
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
|
||||
<span class="hs">{m.score}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scoreboard {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 6px var(--pad);
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.seat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.seat.turn {
|
||||
background: var(--surface-2);
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
.seat.win .sc {
|
||||
color: var(--ok);
|
||||
}
|
||||
.nm {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sc {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.boardwrap {
|
||||
padding: 8px;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--pad) 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.turn-ind {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.over {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rack-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 0 var(--pad);
|
||||
}
|
||||
.rack-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
:global(.rack-row .wrap) {
|
||||
display: flex;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 40px;
|
||||
}
|
||||
.icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
}
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 44px;
|
||||
z-index: 9;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dropdown button {
|
||||
padding: 11px 14px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.dropdown button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ghost {
|
||||
position: fixed;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--tile-pending);
|
||||
color: var(--tile-text);
|
||||
border-radius: 5px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
box-shadow: var(--shadow);
|
||||
pointer-events: none;
|
||||
z-index: 60;
|
||||
}
|
||||
.alpha {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.alpha button {
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
.exch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.etile {
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--tile-bg);
|
||||
color: var(--tile-text);
|
||||
border-radius: 5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.etile.sel {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.confirm {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
.confirm:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.check input {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.check button {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
.complain {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.confirm-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.confirm-row button {
|
||||
flex: 1;
|
||||
padding: 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.danger {
|
||||
background: var(--danger) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
.history {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.history li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.hp {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ha {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.hs {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
// The contextual commit control: appears when tiles are pending. A short tap opens a
|
||||
// minimalist popup (Make move / Reset); a press-and-hold (~1s) commits immediately.
|
||||
let {
|
||||
label,
|
||||
resetLabel,
|
||||
onmake,
|
||||
onreset,
|
||||
}: { label: string; resetLabel: string; onmake: () => void; onreset: () => void } = $props();
|
||||
|
||||
let popup = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let held = false;
|
||||
|
||||
function clear() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
function down() {
|
||||
held = false;
|
||||
clear();
|
||||
timer = setTimeout(() => {
|
||||
held = true;
|
||||
popup = false;
|
||||
onmake();
|
||||
}, 1000);
|
||||
}
|
||||
function up() {
|
||||
clear();
|
||||
if (!held) popup = true;
|
||||
}
|
||||
function leave() {
|
||||
clear();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<button
|
||||
class="make"
|
||||
onpointerdown={down}
|
||||
onpointerup={up}
|
||||
onpointerleave={leave}
|
||||
onpointercancel={leave}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
|
||||
{#if popup}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="backdrop" onclick={() => (popup = false)}></div>
|
||||
<div class="popup">
|
||||
<button
|
||||
class="go"
|
||||
onclick={() => {
|
||||
popup = false;
|
||||
onmake();
|
||||
}}>{label}</button
|
||||
>
|
||||
<button
|
||||
class="rs"
|
||||
onclick={() => {
|
||||
popup = false;
|
||||
onreset();
|
||||
}}>{resetLabel}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
.make {
|
||||
height: 100%;
|
||||
min-width: 64px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 18;
|
||||
}
|
||||
.popup {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 19;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 6px;
|
||||
min-width: 140px;
|
||||
}
|
||||
.popup button {
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.popup .go {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { RackSlot } from '../lib/placement';
|
||||
import { BLANK } from '../lib/placement';
|
||||
import { tileValue } from '../lib/premiums';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
let {
|
||||
slots,
|
||||
variant,
|
||||
selected,
|
||||
ondown,
|
||||
}: {
|
||||
slots: RackSlot[];
|
||||
variant: Variant;
|
||||
selected: number | null;
|
||||
ondown: (e: PointerEvent, index: number) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="rack">
|
||||
{#each slots as slot (slot.index)}
|
||||
{#if slot.used}
|
||||
<span class="slot empty"></span>
|
||||
{:else}
|
||||
<button
|
||||
class="slot tile"
|
||||
class:selected={selected === slot.index}
|
||||
data-rack-index={slot.index}
|
||||
onpointerdown={(e) => ondown(e, slot.index)}
|
||||
>
|
||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
||||
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
.slot {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.empty {
|
||||
background: var(--surface-2);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
background: var(--tile-bg);
|
||||
color: var(--tile-text);
|
||||
border: none;
|
||||
box-shadow: inset 0 -3px 0 var(--tile-edge);
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.tile.selected {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.val {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 1px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user