feat(ui): landscape two-column game layout, board fitted by height
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s
When the viewport is wider than tall (matchMedia orientation: landscape) the game screen switches from the portrait stack to a two-column layout: the board fills the right column as the largest square that fits the height (no zoom, shrinking by width when cramped — lowest priority), while the left panel stacks the rack (+ make), the status line, the score plaques, the always-open docked history and the controls. Board zoom, the history slide-drawer gestures and the growing nav bar are gated off in landscape; the portrait layout is unchanged and both render from the same snippets so behaviour stays single-sourced. The mock e2e now defaults to a portrait viewport (the mobile-first app the gesture/zoom/history specs are written for); landscape.spec.ts covers the wide layout in its own viewport.
This commit is contained in:
+242
-124
@@ -61,6 +61,16 @@
|
||||
let exchangeSel = $state<number[]>([]);
|
||||
let resignOpen = $state(false);
|
||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null);
|
||||
// Landscape (wide) layout: when the viewport is wider than tall the game switches to a
|
||||
// two-column layout — the board fills the right side as a square fitted to the height (no
|
||||
// zoom), while the rack, status, scores, the always-open history and the controls stack in a
|
||||
// left panel. A plain matchMedia flag (like isCoarse below) drives it; the portrait layout is
|
||||
// unchanged. The wiring effect is below, after isCoarse.
|
||||
let landscape = $state(false);
|
||||
// The history is shown either because the player opened the drawer (portrait) or always in
|
||||
// landscape, where it is docked open in the left panel. Gates the per-seat add-friend
|
||||
// affordance and its confirm reset so both work regardless of layout.
|
||||
const historyShown = $derived(historyOpen || landscape);
|
||||
|
||||
const variant = $derived(view?.game.variant ?? 'scrabble_en');
|
||||
const board = $derived(replay(moves));
|
||||
@@ -300,6 +310,18 @@
|
||||
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
|
||||
}
|
||||
|
||||
// Track the viewport orientation to drive the landscape layout switch (see `landscape`). The
|
||||
// listener is torn down when the screen unmounts; falls back to portrait where matchMedia is
|
||||
// unavailable.
|
||||
$effect(() => {
|
||||
if (typeof matchMedia === 'undefined') return;
|
||||
const mq = matchMedia('(orientation: landscape)');
|
||||
const update = () => (landscape = mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
});
|
||||
|
||||
// --- tile placement: pointer drag + tap ---
|
||||
// A drag carries its source: a rack slot (lift a tile onto the board) or a pending
|
||||
// board cell (drag the tile back to the rack). downInfo also holds the press origin,
|
||||
@@ -437,7 +459,7 @@
|
||||
hoverKey = ck;
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
hoverTimer =
|
||||
c && !zoomed
|
||||
c && !zoomed && !landscape
|
||||
? 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.
|
||||
@@ -539,7 +561,7 @@
|
||||
if (board[row]?.[col]) return;
|
||||
if (pendingMap.has(`${row},${col}`)) return;
|
||||
focus = { row, col };
|
||||
if (isCoarse() && !zoomed) zoomed = true;
|
||||
if (isCoarse() && !zoomed && !landscape) zoomed = true;
|
||||
if (placement.rack[index] === BLANK) {
|
||||
blankPrompt = { rackIndex: index, row, col };
|
||||
return;
|
||||
@@ -653,7 +675,7 @@
|
||||
// unchanged zoom state would otherwise leave it parked where the player was looking.
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse()) zoomed = true;
|
||||
if (isCoarse() && !landscape) zoomed = true;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
recompute();
|
||||
}
|
||||
@@ -769,6 +791,7 @@
|
||||
setTimeout(() => (swallowClick = false), 120);
|
||||
}
|
||||
function onBoardWrapDown(e: PointerEvent) {
|
||||
if (landscape) return; // the history is docked open in landscape — no slide-drawer gesture
|
||||
boardPointers.add(e.pointerId);
|
||||
if (boardPointers.size > 1) {
|
||||
boardSwipe = null; // multi-touch (a pinch) is never a swipe
|
||||
@@ -803,7 +826,7 @@
|
||||
}
|
||||
// A closed history clears every per-seat add-friend confirmation.
|
||||
$effect(() => {
|
||||
if (!historyOpen) addConfirm = {};
|
||||
if (!historyShown) addConfirm = {};
|
||||
});
|
||||
|
||||
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
|
||||
@@ -866,17 +889,55 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
|
||||
<Screen
|
||||
title={t(variantNameKey(variant))}
|
||||
back="/"
|
||||
growNav={!landscape}
|
||||
column
|
||||
scroll={false}
|
||||
tabbar={landscape ? undefined : bottomBar}
|
||||
>
|
||||
{#if view}
|
||||
{#if landscape}
|
||||
<div class="game-land">
|
||||
<div class="leftpane">
|
||||
{@render rackRow()}
|
||||
{@render statusBlock()}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="history land">{@render historyBody()}</div>
|
||||
<TabBar>{@render controlButtons()}</TabBar>
|
||||
</div>
|
||||
<div class="rightpane">{@render boardBlock()}</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render scoreboardBlock()}
|
||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||
{#if historyOpen}
|
||||
<div class="history">{@render historyBody()}</div>
|
||||
{/if}
|
||||
{@render boardBlock()}
|
||||
</div>
|
||||
{@render statusBlock()}
|
||||
{@render rackRow()}
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{/if}
|
||||
</Screen>
|
||||
|
||||
<!-- 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 scoreboardBlock()}
|
||||
{#if view}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="scoreboard" onclick={toggleHistory}>
|
||||
<div class="scoreboard" class:flat={landscape} onclick={landscape ? undefined : toggleHistory}>
|
||||
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
|
||||
{#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">{seatName(s)}</div>
|
||||
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
|
||||
{#if historyOpen && canAddFriend(s.accountId)}
|
||||
{#if historyShown && canAddFriend(s.accountId)}
|
||||
<span class="addfriend">
|
||||
<TapConfirm
|
||||
label={t('friends.addFromGame')}
|
||||
@@ -891,80 +952,84 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
|
||||
{#if historyOpen}
|
||||
<div class="history">
|
||||
<div class="hhead">
|
||||
{#if gameOver}
|
||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||
{:else}
|
||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
{#if view.game.endReason === 'aborted'}
|
||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet historyBody()}
|
||||
{#if view}
|
||||
<div class="hhead">
|
||||
{#if gameOver}
|
||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||
{:else}
|
||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="boardwrap"
|
||||
class:slid={historyOpen}
|
||||
onpointerdown={onBoardWrapDown}
|
||||
onpointermove={onBoardWrapMove}
|
||||
onpointerup={onBoardWrapUp}
|
||||
onpointercancel={onBoardWrapUp}
|
||||
onclick={() => {
|
||||
if (!swallowClick) closeHistoryByGesture();
|
||||
}}
|
||||
>
|
||||
<Board
|
||||
{board}
|
||||
{premium}
|
||||
pending={pendingMap}
|
||||
highlight={recent}
|
||||
flash={recentFlash}
|
||||
centre={ctr}
|
||||
{zoomed}
|
||||
{variant}
|
||||
labelMode={app.boardLabels}
|
||||
lines={app.boardLines}
|
||||
locale={app.locale}
|
||||
{focus}
|
||||
{recenter}
|
||||
{dropTarget}
|
||||
oncell={onCell}
|
||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||
onrecall={onRecall}
|
||||
onpenddown={onBoardDown}
|
||||
/>
|
||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
{#if view.game.endReason === 'aborted'}
|
||||
<p class="horganizer">{t('game.abortedNote')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet boardBlock()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="boardwrap"
|
||||
class:slid={historyOpen && !landscape}
|
||||
onpointerdown={onBoardWrapDown}
|
||||
onpointermove={onBoardWrapMove}
|
||||
onpointerup={onBoardWrapUp}
|
||||
onpointercancel={onBoardWrapUp}
|
||||
onclick={() => {
|
||||
if (!swallowClick) closeHistoryByGesture();
|
||||
}}
|
||||
>
|
||||
<Board
|
||||
{board}
|
||||
{premium}
|
||||
pending={pendingMap}
|
||||
highlight={recent}
|
||||
flash={recentFlash}
|
||||
centre={ctr}
|
||||
{zoomed}
|
||||
{variant}
|
||||
labelMode={app.boardLabels}
|
||||
lines={app.boardLines}
|
||||
locale={app.locale}
|
||||
{focus}
|
||||
{recenter}
|
||||
{dropTarget}
|
||||
oncell={onCell}
|
||||
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver && !landscape) zoomed = !zoomed; }}
|
||||
onrecall={onRecall}
|
||||
onpenddown={onBoardDown}
|
||||
/>
|
||||
</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}
|
||||
@@ -976,57 +1041,60 @@
|
||||
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- The footer is drawn even when the game is over (rack + tab bar), but inert:
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
<div class="rack-row" class:inert={gameOver}>
|
||||
<div class="rack-wrap">
|
||||
<Rack
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0}
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="loading">{t('common.loading')}</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet tabbar()}
|
||||
{#if view}
|
||||
<TabBar>
|
||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
<TapConfirm
|
||||
triggerClass="tab"
|
||||
label={t('game.hint')}
|
||||
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
|
||||
onconfirm={doHint}
|
||||
>
|
||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
</TapConfirm>
|
||||
{#if placement.pending.length > 0}
|
||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
||||
<span class="sq">🔀</span>
|
||||
</button>
|
||||
{/if}
|
||||
</TabBar>
|
||||
{#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. -->
|
||||
<div class="rack-row" class:inert={gameOver}>
|
||||
<div class="rack-wrap">
|
||||
<Rack
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0}
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Screen>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet controlButtons()}
|
||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
<TapConfirm
|
||||
triggerClass="tab"
|
||||
label={t('game.hint')}
|
||||
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
|
||||
onconfirm={doHint}
|
||||
>
|
||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
</TapConfirm>
|
||||
{#if placement.pending.length > 0}
|
||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="tab" disabled={busy || gameOver} onclick={shuffle}>
|
||||
<span class="sq">🔀</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet bottomBar()}
|
||||
{#if view}
|
||||
<TabBar>{@render controlButtons()}</TabBar>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if drag}
|
||||
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
|
||||
@@ -1411,4 +1479,54 @@
|
||||
color: #fff !important;
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* --- Landscape (wide) layout ------------------------------------------------------------
|
||||
When the viewport is wider than tall the game lays out as two columns: a left panel (rack,
|
||||
status, scores, the always-open history, the controls) and the board on the right, fitted
|
||||
to the available height as a square (no zoom). All the portrait styles above are untouched;
|
||||
these rules apply only inside the landscape branch's `.game-land` wrapper. */
|
||||
.game-land {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
||||
gap: 4px;
|
||||
/* The left-column children carry their own horizontal --pad (matching portrait), so the
|
||||
grid only pads its right edge; the board centres in the right pane. */
|
||||
padding: 4px var(--pad) 6px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leftpane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.rightpane {
|
||||
/* A size container so the board can be the largest square that fits — by height, shrinking
|
||||
by width when the column is narrow (the board has the lowest priority, per the design). */
|
||||
container-type: size;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.rightpane .boardwrap {
|
||||
padding: 0;
|
||||
width: min(100cqw, 100cqh);
|
||||
}
|
||||
/* The history docked open in the left panel: a normal flex child that fills the remaining
|
||||
height and scrolls, its sticky header staying pinned — no slide-down drawer, no transform. */
|
||||
.history.land {
|
||||
position: static;
|
||||
height: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
box-shadow: none;
|
||||
border-bottom: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
/* The score plaques do not toggle the history in landscape (it is always open). */
|
||||
.scoreboard.flat {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user