Stage 7 polish: game rework + board zoom + tests (Parts D/E/F/I)
- Board: fixed-viewport transform-scale zoom (animated) with counter-scaled cqw labels, corner letters, bonus-label modes (boardlabels), contrasting grid lines
- Game: Screen shell + game tab-bar (Draw/Skip/Hint/Shuffle) via HoldConfirm popovers; MakeMove 🏁 + compact popup; rack collapses used slots; hint places tiles on board (placementFromHint) + no_hint_available toast; Scores:N replaces Hints; history slide-down (swipe/click, scroll-locked); check-word alphabet/length limit + in-memory cache + 5s throttle
- backend: no_hint_available result code split + test
- vitest: banner rotator + linkify, resultBadge, boardlabels, placementFromHint (29 tests); Playwright smoke updated; prod bundle ~74 KB gzip
This commit is contained in:
+297
-190
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Header from '../components/Header.svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import Menu from '../components/Menu.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import HoldConfirm from '../components/HoldConfirm.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 { GatewayError } from '../lib/client';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||
import { lastPlayTiles, replay } from '../lib/board';
|
||||
@@ -18,6 +20,7 @@
|
||||
direction,
|
||||
newPlacement,
|
||||
place,
|
||||
placementFromHint,
|
||||
rackView,
|
||||
recallAt,
|
||||
reset,
|
||||
@@ -35,8 +38,9 @@
|
||||
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 focus = $state<{ row: number; col: number } | null>(null);
|
||||
let panel = $state<'none' | 'chat'>('none');
|
||||
let historyOpen = $state(false);
|
||||
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
|
||||
let exchangeOpen = $state(false);
|
||||
let exchangeSel = $state<number[]>([]);
|
||||
@@ -47,6 +51,9 @@
|
||||
let messages = $state<ChatMessage[]>([]);
|
||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
|
||||
|
||||
const checkedWords = new Map<string, boolean>();
|
||||
let lastCheckAt = 0;
|
||||
|
||||
const variant = $derived(view?.game.variant ?? 'english');
|
||||
const board = $derived(replay(moves));
|
||||
const premium = $derived(premiumGrid(variant));
|
||||
@@ -56,12 +63,11 @@
|
||||
);
|
||||
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 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);
|
||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -76,7 +82,6 @@
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChat() {
|
||||
try {
|
||||
messages = await gateway.chatList(id);
|
||||
@@ -84,7 +89,6 @@
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
$effect(() => {
|
||||
@@ -99,7 +103,7 @@
|
||||
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||
}
|
||||
|
||||
// --- tile placement: pointer drag + tap, both feeding the placement model ---
|
||||
// --- tile placement: pointer drag + tap ---
|
||||
let downInfo: { index: number; x0: number; y0: number } | null = null;
|
||||
let dragMoved = false;
|
||||
let swallowClick = false;
|
||||
@@ -117,7 +121,7 @@
|
||||
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 (isCoarse() && !zoomed) zoomed = true;
|
||||
}
|
||||
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
|
||||
}
|
||||
@@ -158,10 +162,11 @@
|
||||
selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
function attemptPlace(index: number, row: number, col: number) {
|
||||
if (board[row]?.[col]) return;
|
||||
if (pendingMap.has(`${row},${col}`)) return;
|
||||
focus = { row, col };
|
||||
if (isCoarse() && !zoomed) zoomed = true;
|
||||
if (placement.rack[index] === BLANK) {
|
||||
blankPrompt = { rackIndex: index, row, col };
|
||||
return;
|
||||
@@ -169,7 +174,6 @@
|
||||
placement = place(placement, index, row, col);
|
||||
recompute();
|
||||
}
|
||||
|
||||
function chooseBlank(letter: string) {
|
||||
if (!blankPrompt) return;
|
||||
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
|
||||
@@ -187,7 +191,7 @@
|
||||
try {
|
||||
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
|
||||
} catch {
|
||||
/* preview is best-effort */
|
||||
/* best-effort */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
@@ -198,6 +202,7 @@
|
||||
busy = true;
|
||||
try {
|
||||
await gateway.submitPlay(id, sub.dir, sub.tiles);
|
||||
zoomed = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@@ -205,7 +210,6 @@
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPlacement() {
|
||||
placement = reset(placement);
|
||||
preview = null;
|
||||
@@ -224,7 +228,6 @@
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doResign() {
|
||||
resignOpen = false;
|
||||
busy = true;
|
||||
@@ -237,18 +240,24 @@
|
||||
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 };
|
||||
if (h.move.tiles.length && view) {
|
||||
placement = placementFromHint(h.move.tiles, view.rack);
|
||||
if (isCoarse()) zoomed = true;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
recompute();
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
// The backend does not spend a hint when there is no move.
|
||||
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
||||
showToast(t('game.noHintOptions'), 'info');
|
||||
} else {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shuffle() {
|
||||
if (placement.pending.length > 0) return;
|
||||
const r = [...placement.rack];
|
||||
@@ -258,14 +267,12 @@
|
||||
}
|
||||
placement = newPlacement(r);
|
||||
}
|
||||
|
||||
function toggleDir() {
|
||||
dirOverride = dir === 'H' ? 'V' : 'H';
|
||||
recompute();
|
||||
}
|
||||
|
||||
function openExchange() {
|
||||
menuOpen = false;
|
||||
resetPlacement();
|
||||
exchangeSel = [];
|
||||
exchangeOpen = true;
|
||||
@@ -289,16 +296,31 @@
|
||||
}
|
||||
|
||||
function openCheck() {
|
||||
menuOpen = false;
|
||||
checkWord = '';
|
||||
checkResult = null;
|
||||
checkOpen = true;
|
||||
}
|
||||
function onCheckInput(e: Event) {
|
||||
const allowed = new Set(alphabet(variant));
|
||||
const raw = (e.target as HTMLInputElement).value.toUpperCase();
|
||||
checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join('');
|
||||
}
|
||||
async function runCheck() {
|
||||
const w = checkWord.trim();
|
||||
const w = checkWord.trim().toUpperCase();
|
||||
if (!w) return;
|
||||
if (checkedWords.has(w)) {
|
||||
checkResult = { word: w, legal: checkedWords.get(w)! };
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastCheckAt < 5000) {
|
||||
showToast(t('game.checkWait'), 'info');
|
||||
return;
|
||||
}
|
||||
lastCheckAt = Date.now();
|
||||
try {
|
||||
checkResult = await gateway.checkWord(id, w);
|
||||
const r = await gateway.checkWord(id, w);
|
||||
checkedWords.set(r.word.toUpperCase(), r.legal);
|
||||
checkResult = r;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
@@ -308,123 +330,173 @@
|
||||
try {
|
||||
await gateway.complaint(id, checkResult.word, '');
|
||||
showToast(t('game.complaintSent'));
|
||||
checkOpen = false;
|
||||
} 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];
|
||||
messages = [...messages, await gateway.chatPost(id, text)];
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
async function nudge() {
|
||||
try {
|
||||
const m = await gateway.nudge(id);
|
||||
messages = [...messages, m];
|
||||
messages = [...messages, await gateway.nudge(id)];
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// History slide-down: swipe down on the board to open, up / tap to close.
|
||||
let swipeY: number | null = null;
|
||||
function boardSwipeStart(e: PointerEvent) {
|
||||
swipeY = e.clientY;
|
||||
}
|
||||
function boardSwipeEnd(e: PointerEvent) {
|
||||
if (swipeY == null) return;
|
||||
const dy = e.clientY - swipeY;
|
||||
swipeY = null;
|
||||
if (dy > 40) historyOpen = true;
|
||||
else if (dy < -40) historyOpen = false;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const menuItems = $derived([
|
||||
{ label: t('game.history'), onclick: () => (historyOpen = true) },
|
||||
{ label: t('game.chat'), onclick: openChat },
|
||||
{ label: t('game.checkWord'), onclick: openCheck },
|
||||
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Header title={t('app.title')} back="/">
|
||||
<Screen title={t('app.title')} back="/" scroll={!historyOpen}>
|
||||
{#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}
|
||||
<Menu items={menuItems} />
|
||||
{/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}
|
||||
{#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>
|
||||
|
||||
<Controls
|
||||
{preview}
|
||||
hints={view.hintsRemaining}
|
||||
busy={busy || !isMyTurn}
|
||||
{ambiguous}
|
||||
{dir}
|
||||
ondraw={openExchange}
|
||||
onskip={doPass}
|
||||
onshuffle={shuffle}
|
||||
onhint={doHint}
|
||||
ondir={toggleDir}
|
||||
/>
|
||||
<div class="stage">
|
||||
{#if historyOpen}
|
||||
<div class="history">
|
||||
<ol>
|
||||
{#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}
|
||||
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="boardwrap"
|
||||
class:slid={historyOpen}
|
||||
onpointerdown={boardSwipeStart}
|
||||
onpointerup={boardSwipeEnd}
|
||||
onclick={() => historyOpen && (historyOpen = false)}
|
||||
>
|
||||
<Board
|
||||
{board}
|
||||
{premium}
|
||||
pending={pendingMap}
|
||||
{recent}
|
||||
centre={ctr}
|
||||
{zoomed}
|
||||
{variant}
|
||||
labelMode={app.boardLabels}
|
||||
locale={app.locale}
|
||||
{focus}
|
||||
oncell={onCell}
|
||||
ontogglezoom={() => (zoomed = !zoomed)}
|
||||
/>
|
||||
</div>
|
||||
</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') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !gameOver}
|
||||
<div class="rack-row">
|
||||
<div class="rack-wrap">
|
||||
<Rack {slots} {variant} {selected} ondown={onRackDown} />
|
||||
</div>
|
||||
{#if placement.pending.length > 0}
|
||||
<HoldConfirm triggerClass="make" onhold={commit}>
|
||||
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
|
||||
{#snippet popover(close)}
|
||||
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
|
||||
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
|
||||
{/snippet}
|
||||
</HoldConfirm>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{/if}
|
||||
|
||||
{#snippet tabbar()}
|
||||
{#if view && !gameOver}
|
||||
<TabBar>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || bagEmpty} onhold={openExchange}>
|
||||
{#snippet trigger()}<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>{/snippet}
|
||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); openExchange(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||
</HoldConfirm>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
|
||||
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||
</HoldConfirm>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}>
|
||||
{#snippet trigger()}
|
||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
{/snippet}
|
||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||
</HoldConfirm>
|
||||
<button class="tab" disabled={busy || placement.pending.length > 0} onclick={shuffle}>
|
||||
<span class="sq">🔀</span>
|
||||
</button>
|
||||
</TabBar>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Screen>
|
||||
|
||||
{#if drag}
|
||||
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
|
||||
@@ -432,6 +504,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ambiguous && placement.pending.length > 0}
|
||||
<button class="dirtoggle" onclick={toggleDir} aria-label="direction">{dir === 'H' ? '↔' : '↕'}</button>
|
||||
{/if}
|
||||
|
||||
{#if blankPrompt}
|
||||
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
||||
<div class="alpha">
|
||||
@@ -460,8 +536,13 @@
|
||||
{#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>
|
||||
<input
|
||||
value={checkWord}
|
||||
oninput={onCheckInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && runCheck()}
|
||||
placeholder={t('game.checkWordPrompt')}
|
||||
/>
|
||||
<button onclick={runCheck}>{t('game.check')}</button>
|
||||
</div>
|
||||
{#if checkResult}
|
||||
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
|
||||
@@ -489,27 +570,12 @@
|
||||
</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;
|
||||
@@ -535,14 +601,61 @@
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.history {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 2;
|
||||
max-height: 60%;
|
||||
overflow: auto;
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.history ol {
|
||||
margin: 0;
|
||||
padding: 8px 14px;
|
||||
list-style: decimal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.history li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hp {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ha {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.hs {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hempty {
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.boardwrap {
|
||||
padding: 8px;
|
||||
padding: 6px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.boardwrap.slid {
|
||||
transform: translateY(62%);
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--pad) 8px;
|
||||
padding: 2px var(--pad) 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -553,60 +666,62 @@
|
||||
.over {
|
||||
color: var(--accent);
|
||||
}
|
||||
.scores {
|
||||
font-weight: 600;
|
||||
color: var(--ok);
|
||||
min-width: 64px;
|
||||
text-align: right;
|
||||
}
|
||||
.rack-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 0 var(--pad);
|
||||
padding: 0 var(--pad) 6px;
|
||||
}
|
||||
.rack-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
:global(.rack-row .wrap) {
|
||||
display: flex;
|
||||
.flag {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
:global(.make) {
|
||||
min-width: 56px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pop {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.pop.go {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 4px;
|
||||
font-size: 0.6rem;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: 999px;
|
||||
padding: 0 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.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;
|
||||
@@ -623,6 +738,20 @@
|
||||
pointer-events: none;
|
||||
z-index: 60;
|
||||
}
|
||||
.dirtoggle {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 84px;
|
||||
z-index: 30;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.alpha {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
@@ -677,6 +806,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.check button {
|
||||
padding: 10px 12px;
|
||||
@@ -715,27 +845,4 @@
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user