feat(ui): move in-game status to the board — highlight, score badge, full-width rack
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) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
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) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals: - bag count -> a badge on the exchange/pass control + the foot of the move table - whose-turn / win-lose -> a thin strip above the score plaques - the tentative-move caption -> the board itself: staged tiles tint green (legal) or pink (illegal), the board tiles a formed word runs through go a shade darker, and an orange score badge sits on the main word (digit sized like a tile value, clamped on-board) The word geometry (covered cells + badge anchor) is a new pure client-side helper (ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or network preview path alike; the badge's number still comes from the preview score. Rack: a seven-column grid filling the tray width in both layouts — square, full-width tiles — with the confirm control in the fixed 7th slot. Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted hint word is never left off-screen. Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
This commit is contained in:
+103
-101
@@ -49,6 +49,7 @@
|
||||
type Placement,
|
||||
} from '../lib/placement';
|
||||
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
|
||||
import { badgePlacement, formedGeometry } from '../lib/formed';
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
@@ -483,6 +484,21 @@
|
||||
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
|
||||
// they return the moment the tile is dragged back out over the board.
|
||||
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
|
||||
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a
|
||||
// formed word runs through — greened on the board — and the main word that anchors the score
|
||||
// badge. Derived only for a legal preview, and never while dragging a staged tile back over the
|
||||
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
|
||||
const EMPTY_FORMED = new Set<string>();
|
||||
const formedGeom = $derived(
|
||||
preview?.legal && !recallOverRack ? formedGeometry(board, placement.pending) : null,
|
||||
);
|
||||
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
|
||||
const boardBadge = $derived(
|
||||
formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
|
||||
);
|
||||
// The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
|
||||
// the play's legality — green when legal, a calm pink when not.
|
||||
const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null);
|
||||
|
||||
let dragPointerId = -1;
|
||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||
@@ -610,7 +626,7 @@
|
||||
? setTimeout(() => {
|
||||
// Still holding the tile over this cell: magnify into it. Only the first
|
||||
// (zoom-in) hold centres; once zoomed we never move the board on hover.
|
||||
if (drag && isCoarse() && !landscape && !zoomed) {
|
||||
if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) {
|
||||
focus = c;
|
||||
zoomed = true;
|
||||
haptic('light');
|
||||
@@ -729,8 +745,8 @@
|
||||
if (pendingMap.has(`${row},${col}`)) return;
|
||||
focus = { row, col };
|
||||
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
|
||||
// only hides the rest of the position.
|
||||
if (isCoarse() && !landscape && !zoomed) zoomed = true;
|
||||
// only hides the rest of the position. The "Zoom the board" setting can turn it off.
|
||||
if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
|
||||
if (placement.rack[index] === BLANK) {
|
||||
blankPrompt = { rackIndex: index, row, col };
|
||||
return;
|
||||
@@ -1030,23 +1046,11 @@
|
||||
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
|
||||
// not now — showing it here would interrupt placing the preview and revert the board on close.
|
||||
hintUsedThisTurn = true;
|
||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
||||
// focus the centre of the laid tiles' bounding box.
|
||||
const p = placement.pending;
|
||||
if (p.length) {
|
||||
const rows = p.map((tt) => tt.row);
|
||||
const cols = p.map((tt) => tt.col);
|
||||
focus = {
|
||||
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
|
||||
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
|
||||
};
|
||||
// Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres)
|
||||
// on the next line (portrait only); this nonce also recentres a board that is already
|
||||
// zoomed in, where the unchanged zoom state would otherwise leave it parked where the
|
||||
// player was looking.
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse() && !landscape) zoomed = true;
|
||||
// A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
|
||||
// word — highlighted green while composing — is guaranteed visible rather than possibly
|
||||
// parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
|
||||
// focus/recenter step is gone with the zoom-in.
|
||||
if (zoomed) zoomed = false;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
syncWallet(h.walletBalance);
|
||||
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
||||
@@ -1486,15 +1490,16 @@
|
||||
{#if landscape}
|
||||
<div class="game-land">
|
||||
<div class="leftpane">
|
||||
{@render turnStrip()}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="history land">{@render historyBody()}</div>
|
||||
{@render statusBlock()}
|
||||
{@render rackRow()}
|
||||
<TabBar>{@render controlButtons()}</TabBar>
|
||||
</div>
|
||||
<div class="rightpane">{@render boardBlock()}</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render turnStrip()}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||
{#if historyOpen}
|
||||
@@ -1502,7 +1507,6 @@
|
||||
{/if}
|
||||
{@render boardBlock()}
|
||||
</div>
|
||||
{@render statusBlock()}
|
||||
{@render rackRow()}
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -1517,6 +1521,21 @@
|
||||
|
||||
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
|
||||
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
|
||||
{#snippet turnStrip()}
|
||||
<!-- A thin line above the score plaques: whose turn it is during play, or the viewer's
|
||||
result once the game ends. A hotseat game shows its result as per-seat medals on the
|
||||
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
|
||||
{#if view && !(gameOver && view.game.hotseat)}
|
||||
<div class="turnstrip" class:result={gameOver}>
|
||||
{#if gameOver}
|
||||
{resultText()}
|
||||
{:else}
|
||||
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet scoreboardBlock()}
|
||||
{#if view}
|
||||
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
|
||||
@@ -1603,10 +1622,11 @@
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{#if view.game.endReason === 'aborted'}
|
||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if view.game.endReason === 'aborted'}
|
||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||
{/if}
|
||||
<div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1639,6 +1659,9 @@
|
||||
{focus}
|
||||
{recenter}
|
||||
{dropTarget}
|
||||
{previewLegal}
|
||||
formed={formedCells}
|
||||
scoreBadge={boardBadge}
|
||||
oncell={onCell}
|
||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||
onrecall={onRecall}
|
||||
@@ -1647,25 +1670,6 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet statusBlock()}
|
||||
{#if view}
|
||||
<div class="status">
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
|
||||
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
|
||||
game keeps the "you won/lost" text (one human). -->
|
||||
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rackRow()}
|
||||
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
@@ -1680,23 +1684,26 @@
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet confirmBtn()}
|
||||
<!-- The confirm-move control lives in the rack's fixed 7th slot (see Rack). Shown only while a
|
||||
play is staged; disabled until the preview says it is legal. -->
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet controlButtons()}
|
||||
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
<span class="sq" data-coach="game-turn">🔄{#if view && view.bagLen > 0}<span class="badge">{view.bagLen}</span>{/if}</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
{#if view?.game.hotseat}
|
||||
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||
@@ -1906,6 +1913,23 @@
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* The thin turn/result line above the score plaques (see turnStrip): whose turn it is during
|
||||
play, accented for the viewer's win/lose result once the game ends. */
|
||||
.turnstrip {
|
||||
flex: none;
|
||||
padding: 4px var(--pad);
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--bg-elev);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.turnstrip.result {
|
||||
color: var(--accent);
|
||||
}
|
||||
.stage {
|
||||
position: relative;
|
||||
/* The board is the only part that scrolls vertically when the game does not fit;
|
||||
@@ -1924,16 +1948,12 @@
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
z-index: 2;
|
||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border
|
||||
and its shadow pin to the board immediately instead of tracking the table as
|
||||
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
|
||||
column does not jump left/right when the list overflows. */
|
||||
/* A fixed-height drawer matching the board's slid offset, so the bottom border and its shadow
|
||||
pin to the board immediately instead of tracking the table as moves accumulate. It is a flex
|
||||
column: the header and the pinned bag footer stay put while the grid (.hgridwrap) scrolls. */
|
||||
height: 62%;
|
||||
overflow: auto;
|
||||
/* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its
|
||||
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
||||
overscroll-behavior: none;
|
||||
scrollbar-gutter: stable;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -1945,8 +1965,26 @@
|
||||
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
|
||||
plaques. */
|
||||
.hgridwrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
/* No iOS rubber-band inside the scroller: the moves list does not elastically bounce past its
|
||||
ends (the document is already pinned; this stops the inner scroller's own bounce). */
|
||||
overscroll-behavior: none;
|
||||
/* Reserve the scrollbar so the centred word column does not jump left/right on overflow. */
|
||||
scrollbar-gutter: stable;
|
||||
padding: 8px var(--pad);
|
||||
}
|
||||
/* The bag count pinned bottom-left of the move table, out of the scrolling grid, so it stays put
|
||||
as moves accumulate. The same count also rides the exchange control as a badge (see
|
||||
controlButtons), which is the always-visible indicator when the table is closed. */
|
||||
.hbagfoot {
|
||||
flex: none;
|
||||
padding: 6px var(--pad);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.hgrid {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
@@ -1991,28 +2029,6 @@
|
||||
.boardwrap.slid :global(.viewport) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.status {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px var(--pad) 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.turn-ind {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.over {
|
||||
color: var(--accent);
|
||||
}
|
||||
.scores {
|
||||
font-weight: 600;
|
||||
color: var(--ok);
|
||||
min-width: 64px;
|
||||
text-align: right;
|
||||
}
|
||||
/* The single-word-rule label centred in the history header between its two icons. */
|
||||
.oneword-label {
|
||||
flex: 1;
|
||||
@@ -2037,36 +2053,22 @@
|
||||
flex: 1;
|
||||
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. It is an overlay, NOT a flex sibling of the
|
||||
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
|
||||
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
|
||||
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
|
||||
preview caption above (.status shares that padding). */
|
||||
/* The confirm-move control occupies the rack's fixed 7th column while a play is staged (a
|
||||
borderless icon button, like the tab bar). It is rendered inside the rack grid (see Rack), so it
|
||||
lines up with the tiles and stays the rightmost slot no matter how many tiles remain. */
|
||||
.make {
|
||||
position: absolute;
|
||||
right: var(--pad);
|
||||
top: 0;
|
||||
bottom: 6px;
|
||||
width: 56px;
|
||||
grid-column: 7;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
display: grid;
|
||||
place-items: center end;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.make:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
|
||||
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
|
||||
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
|
||||
so it sits inside the freed slot with its right edge level with the rack's right edge — the
|
||||
mirror of the first tile's left edge. */
|
||||
.game-land .make {
|
||||
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
|
||||
}
|
||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||
.hhead {
|
||||
|
||||
Reference in New Issue
Block a user