UI: render move history as a per-seat column grid + swipe-down to open
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 42s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 42s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
Replace the flat chronological move list with a ruled matrix aligned under the score plaque: one column per seat, each seat's moves filling its column top to bottom. A cell is the move's word(s) and its score, "WORD (12)", centred; the player names and the running total are dropped (the plaque heads the column and shows the live total). Non-play moves keep their dim parenthesised tag; the awaited opponent's next cell shows a dim "thinking..." (never the viewer's own turn). Thin 1px rules between columns and rows match the panel's separator. Re-introduce a swipe-down-on-the-board gesture to open the history, gated to the zoom-out board scrolled to its top so it never fights the zoomed board's pan or the stage's own vertical scroll (the conflict that retired this gesture before). Grid layout extracted to lib/history.ts (unit-tested); add game.thinking to the EN/RU catalogs; e2e covers the gesture and the grid on Chromium and WebKit.
This commit is contained in:
+94
-52
@@ -14,6 +14,7 @@
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { Direction, EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||
import { lastMoveCells, replay } from '../lib/board';
|
||||
import { historyGrid } from '../lib/history';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
@@ -101,6 +102,12 @@
|
||||
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
|
||||
// move while the game is active, but never the viewer themselves (their own pending cell
|
||||
// stays empty) and never on a finished game.
|
||||
const thinkingSeat = $derived(
|
||||
!view || gameOver || view.game.toMove === view.seat ? null : view.game.toMove,
|
||||
);
|
||||
|
||||
// moveActionLabel localizes a non-play move's history label (pass/exchange/resign/timeout);
|
||||
// an unknown action (forward-compat for the MoveAction string union) falls back to its raw
|
||||
@@ -637,29 +644,61 @@
|
||||
}
|
||||
|
||||
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
|
||||
// While the history is open the board is inert (CSS pointer-events), so the whole slid board
|
||||
// reads as a "tap or swipe up to close" surface and the stage cannot scroll instead of close.
|
||||
// The tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
|
||||
// never depends on where a fast swipe's pointerup lands (which differs across engines).
|
||||
// Closing genuinely clears `historyOpen` (rather than only scrolling the slid board out of
|
||||
// The boardwrap surface drives two gestures, selected by `historyOpen`:
|
||||
// - open: the slid board is inert (CSS pointer-events), so the whole board reads as a
|
||||
// "tap or swipe up to close" surface and the stage cannot scroll instead of close. The
|
||||
// tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
|
||||
// never depends on where a fast swipe's pointerup lands (which differs across engines).
|
||||
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
|
||||
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
|
||||
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
|
||||
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
|
||||
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
||||
let histSwipeY: number | null = null;
|
||||
let stageEl = $state<HTMLDivElement>();
|
||||
let boardSwipe: { x: number; y: number; mode: 'open' | 'close' } | null = null;
|
||||
function toggleHistory() {
|
||||
historyOpen = !historyOpen;
|
||||
}
|
||||
function closeHistoryByGesture() {
|
||||
if (!historyOpen) return;
|
||||
historyOpen = false;
|
||||
histSwipeY = null;
|
||||
boardSwipe = null;
|
||||
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
|
||||
swallowClick = true;
|
||||
setTimeout(() => (swallowClick = false), 120);
|
||||
}
|
||||
function openHistoryByGesture() {
|
||||
if (historyOpen) return;
|
||||
historyOpen = true;
|
||||
boardSwipe = null;
|
||||
// Swallow the click the opening pull may synthesise, so it neither places a tile nor
|
||||
// immediately re-closes the panel via the boardwrap's tap-to-close.
|
||||
swallowClick = true;
|
||||
setTimeout(() => (swallowClick = false), 120);
|
||||
}
|
||||
function onBoardWrapDown(e: PointerEvent) {
|
||||
histSwipeY = historyOpen ? e.clientY : null;
|
||||
if (historyOpen) {
|
||||
boardSwipe = { x: e.clientX, y: e.clientY, mode: 'close' };
|
||||
return;
|
||||
}
|
||||
// Arm the open pull only where a downward drag is free to mean "reveal the history":
|
||||
// the zoom-out board scrolled to its top, a touch pointer, no tile drag under way.
|
||||
const onTile = !!(e.target as Element | null)?.closest?.('.cell.pending');
|
||||
const atTop = (stageEl?.scrollTop ?? 0) <= 2;
|
||||
boardSwipe =
|
||||
!zoomed && atTop && !drag && !onTile && e.pointerType !== 'mouse'
|
||||
? { x: e.clientX, y: e.clientY, mode: 'open' }
|
||||
: null;
|
||||
}
|
||||
function onBoardWrapMove(e: PointerEvent) {
|
||||
if (histSwipeY !== null && histSwipeY - e.clientY > 32) closeHistoryByGesture();
|
||||
if (!boardSwipe) return;
|
||||
const dx = e.clientX - boardSwipe.x;
|
||||
const dy = e.clientY - boardSwipe.y;
|
||||
if (boardSwipe.mode === 'close') {
|
||||
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
||||
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
||||
openHistoryByGesture(); // a clear, vertical-dominant downward pull
|
||||
}
|
||||
}
|
||||
// A closed history clears every per-seat add-friend confirmation.
|
||||
$effect(() => {
|
||||
@@ -733,7 +772,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="stage" class:histopen={historyOpen}>
|
||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||
{#if historyOpen}
|
||||
<div class="history">
|
||||
<div class="hhead">
|
||||
@@ -746,16 +785,23 @@
|
||||
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<ol>
|
||||
{#each moves as m, i (i)}
|
||||
<li>
|
||||
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
||||
<span class="ha" class:sys={m.action !== 'play'}>{m.action === 'play' ? m.words.join(', ') : `(${moveActionLabel(m.action)})`}</span>
|
||||
<span class="hs">{m.score} <span class="ht">({m.total})</span></span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
||||
</ol>
|
||||
<div class="hgridwrap">
|
||||
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
|
||||
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
|
||||
{#each row as cell (cell.player)}
|
||||
<div class="hcell">
|
||||
{#if cell.kind === 'play'}
|
||||
<span>{cell.words?.join(', ')} <span class="hsc">({cell.score})</span></span>
|
||||
{:else if cell.kind === 'action'}
|
||||
<span class="hsys">({moveActionLabel(cell.action ?? '')})</span>
|
||||
{:else if cell.kind === 'thinking'}
|
||||
<span class="hsys">{t('game.thinking')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -766,8 +812,10 @@
|
||||
class:slid={historyOpen}
|
||||
onpointerdown={onBoardWrapDown}
|
||||
onpointermove={onBoardWrapMove}
|
||||
onpointerup={() => (histSwipeY = null)}
|
||||
onclick={closeHistoryByGesture}
|
||||
onpointerup={() => (boardSwipe = null)}
|
||||
onclick={() => {
|
||||
if (!swallowClick) closeHistoryByGesture();
|
||||
}}
|
||||
>
|
||||
<Board
|
||||
{board}
|
||||
@@ -976,44 +1024,38 @@
|
||||
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;
|
||||
/* The history is a ruled matrix: one column per seat (aligned under the score plaque), each
|
||||
seat's moves filling its column top to bottom. The thin grid lines are the 1px gap showing
|
||||
the border colour through the cells' surface fill; there is no outer frame (cells sit flush
|
||||
to the grid edge — the .hhead border above and the .history border below close the table).
|
||||
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
||||
plaques. */
|
||||
.hgridwrap {
|
||||
padding: 8px var(--pad);
|
||||
}
|
||||
.history li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
.hgrid {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hp {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ha {
|
||||
flex: 1;
|
||||
.hcell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
min-height: 1.7em;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ha.sys {
|
||||
/* A non-scoring move (pass/exchange/resign/timeout): dimmer and parenthesised so it
|
||||
reads as a system action rather than a scored word. */
|
||||
/* Secondary annotations within a cell — the parenthesised move score, a non-play action
|
||||
label, the awaited "thinking…" — read muted, like the old running-total/system labels. */
|
||||
.hsc,
|
||||
.hsys {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.hs {
|
||||
.hsc {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ht {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.hempty {
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.boardwrap {
|
||||
padding: 6px;
|
||||
|
||||
Reference in New Issue
Block a user