Stage 7 polish (round 2): layout/zoom/tab-bar/hint/check fixes
- nav bar grows ONLY in game (other screens: minimal nav, content fills); tab bar always bottom - tab bar: tighter icon/label spacing, bigger icons, hint badge on the icon corner - board zoom reworked to width-based (real native scroll, fixes Safari/Chrome) + constant cqw labels; pinch & swipe-to-history dropped (conflict), double-tap kept, history via menu - beginner bonus labels shrunk to fit cells - Draw opens exchange directly (no confirm); confirm popovers restyled like the hamburger dropdown (vertical); removed the floating direction toggle - pending tiles darker bg (no outline); last-word dark-tile highlight (static / 1s flash) - check button disabled for <2/>15 chars, already-checked, or 5s cooldown - global user-select:none (inputs exempt); docs updated; TODO-4 alphabet-on-wire
This commit is contained in:
@@ -529,8 +529,10 @@ Open details: deployment target/host; dashboards; load expectations.
|
|||||||
pinned to the bottom) + a one-line **announcement banner** (client-side mock
|
pinned to the bottom) + a one-line **announcement banner** (client-side mock
|
||||||
rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
|
rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
|
||||||
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
||||||
board **zoom reworked** to a fixed viewport with counter-scaled labels, corner-letter
|
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
|
||||||
tiles, contrasting grid lines, and a Settings **bonus-label style** (beginner/
|
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
|
||||||
|
tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
|
||||||
|
**bonus-label style** (beginner/
|
||||||
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
||||||
`no_hint_available` result code); the history opens as an in-place **slide-down**
|
`no_hint_available` result code); the history opens as an in-place **slide-down**
|
||||||
(not a modal); word-check is alphabet/length-limited, cached and throttled. Design
|
(not a modal); word-check is alphabet/length-limited, cached and throttled. Design
|
||||||
@@ -561,3 +563,10 @@ Open details: deployment target/host; dashboards; load expectations.
|
|||||||
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
|
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
|
||||||
guest accounts with no active games once their last session is gone; the
|
guest accounts with no active games once their last session is gone; the
|
||||||
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
|
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
|
||||||
|
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
|
||||||
|
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
|
||||||
|
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
|
||||||
|
letters. Consider extending `game.state` to carry the variant's `(letter, index,
|
||||||
|
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
|
||||||
|
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
|
||||||
|
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
|
||||||
|
|||||||
+18
-11
@@ -10,11 +10,12 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi
|
|||||||
|
|
||||||
## Layout shell (`components/Screen.svelte`)
|
## Layout shell (`components/Screen.svelte`)
|
||||||
|
|
||||||
A mobile-app feel: the screen is a full-height flex column where the **nav bar grows**
|
A full-height flex column: the nav bar, the announcement strip, the content, and an
|
||||||
to absorb spare vertical space (its buttons stay top-aligned) and everything else —
|
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
|
||||||
the announcement strip, the content, and the optional bottom tab bar — **pins to the
|
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
|
||||||
bottom**, the strip directly above the content. Tall content scrolls within the content
|
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
|
||||||
region. Every screen except Login uses `Screen`.
|
pinning the board and controls to the **bottom** for thumb reach. Every screen except
|
||||||
|
Login uses `Screen`.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
@@ -31,12 +32,18 @@ region. Every screen except Login uses `Screen`.
|
|||||||
|
|
||||||
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
|
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
|
||||||
value), the point value bottom-right; blanks show no value.
|
value), the point value bottom-right; blanks show no value.
|
||||||
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) via
|
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
|
||||||
`transform: scale()` on an inner layer inside a **fixed-size viewport** (the page never
|
the board's width** inside a fixed-size viewport (a real layout change → native scroll
|
||||||
reflows; the viewport scrolls when zoomed), with a smooth transition. Cell/tile **text
|
that works consistently across browsers; no `transform`, which broke scrolling
|
||||||
lives in a counter-scaled layer** (`scale(1/z)`) sized in `cqw`, so labels stay a
|
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
|
||||||
constant size (relatively smaller at higher zoom). On touch, attempting to place a tile
|
they stay a constant size as the cells grow (relatively smaller at higher zoom).
|
||||||
auto-zooms in centred on the target; double-tap and pinch toggle.
|
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
|
||||||
|
target; the custom pinch and swipe-to-open-history gestures were dropped because they
|
||||||
|
fight native scroll — history opens from the menu.
|
||||||
|
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
|
||||||
|
last completed word gets a dark tile background — static while it is the opponent's
|
||||||
|
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
|
||||||
|
the pending tiles are highlighted.
|
||||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||||
nothing. Default **beginner**.
|
nothing. Default **beginner**.
|
||||||
|
|||||||
+13
-4
@@ -27,8 +27,8 @@
|
|||||||
--tile-bg: #f4e2b8;
|
--tile-bg: #f4e2b8;
|
||||||
--tile-edge: #d8c190;
|
--tile-edge: #d8c190;
|
||||||
--tile-text: #2a2113;
|
--tile-text: #2a2113;
|
||||||
--tile-pending: #ffe7a3;
|
--tile-pending: #f2cf73;
|
||||||
--tile-recent: #fff6d8;
|
--tile-recent: #c8a85c;
|
||||||
--prem-tw: #e06a5b; /* triple word */
|
--prem-tw: #e06a5b; /* triple word */
|
||||||
--prem-dw: #efa6a0; /* double word + centre */
|
--prem-dw: #efa6a0; /* double word + centre */
|
||||||
--prem-tl: #4f8fd6; /* triple letter */
|
--prem-tl: #4f8fd6; /* triple letter */
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
--tile-bg: #d9c79a;
|
--tile-bg: #d9c79a;
|
||||||
--tile-edge: #b6a473;
|
--tile-edge: #b6a473;
|
||||||
--tile-text: #20190d;
|
--tile-text: #20190d;
|
||||||
--tile-pending: #f0d98f;
|
--tile-pending: #d8b75e;
|
||||||
--tile-recent: #4a4636;
|
--tile-recent: #7a6638;
|
||||||
--prem-tw: #b1493d;
|
--prem-tw: #b1493d;
|
||||||
--prem-dw: #8c5450;
|
--prem-dw: #8c5450;
|
||||||
--prem-tl: #34608f;
|
--prem-tl: #34608f;
|
||||||
@@ -130,6 +130,15 @@ body {
|
|||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
/* No text selection anywhere by default; inputs opt back in below. */
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
|
|
||||||
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
|
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
||||||
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="nav">
|
<header class="nav" class:grow>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
{#if back}
|
{#if back}
|
||||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||||
@@ -20,10 +21,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* The nav bar grows to fill the spare vertical space (buttons stay at the top), so
|
/* By default the nav bar is minimal and the content fills the screen. In the game
|
||||||
the rest of the screen pins to the bottom — a mobile-app layout. */
|
it grows (class `grow`) to push the board and controls to the bottom. */
|
||||||
.nav {
|
.nav {
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -32,6 +33,9 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
.nav.grow {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -96,7 +96,14 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 19;
|
z-index: 19;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 132px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
tabbar,
|
tabbar,
|
||||||
children,
|
children,
|
||||||
scroll = true,
|
scroll = true,
|
||||||
|
growNav = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
back?: string;
|
back?: string;
|
||||||
@@ -20,13 +21,14 @@
|
|||||||
tabbar?: Snippet;
|
tabbar?: Snippet;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
scroll?: boolean;
|
scroll?: boolean;
|
||||||
|
growNav?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<Header {title} {back} {menu} />
|
<Header {title} {back} {menu} grow={growNav} />
|
||||||
<AdBanner />
|
<AdBanner />
|
||||||
<main class="content" class:scroll>{@render children?.()}</main>
|
<main class="content" class:scroll class:fill={!growNav}>{@render children?.()}</main>
|
||||||
{#if tabbar}
|
{#if tabbar}
|
||||||
<nav class="tabbar">{@render tabbar()}</nav>
|
<nav class="tabbar">{@render tabbar()}</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -42,6 +44,9 @@
|
|||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
.content.fill {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
.content.scroll {
|
.content.scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-88
@@ -10,7 +10,8 @@
|
|||||||
board,
|
board,
|
||||||
premium,
|
premium,
|
||||||
pending,
|
pending,
|
||||||
recent,
|
highlight,
|
||||||
|
flash,
|
||||||
centre,
|
centre,
|
||||||
zoomed,
|
zoomed,
|
||||||
variant,
|
variant,
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
board: (BoardCell | null)[][];
|
board: (BoardCell | null)[][];
|
||||||
premium: Premium[][];
|
premium: Premium[][];
|
||||||
pending: Map<string, { letter: string; blank: boolean }>;
|
pending: Map<string, { letter: string; blank: boolean }>;
|
||||||
recent: Set<string>;
|
highlight: Set<string>;
|
||||||
|
flash: boolean;
|
||||||
centre: { row: number; col: number };
|
centre: { row: number; col: number };
|
||||||
zoomed: boolean;
|
zoomed: boolean;
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
@@ -40,19 +42,20 @@
|
|||||||
|
|
||||||
let viewport = $state<HTMLElement>();
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
// When zoomed in (typically on a placement), centre the focus cell.
|
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
|
||||||
|
// works in every browser. Centre the focus cell when zoomed in.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const vp = viewport;
|
const vp = viewport;
|
||||||
if (!vp || !zoomed || !focus) return;
|
if (!vp || !zoomed || !focus) return;
|
||||||
const cell = vp.clientWidth / 15;
|
const cell = (vp.clientWidth * Z) / 15;
|
||||||
vp.scrollTo({
|
vp.scrollTo({
|
||||||
left: (focus.col + 0.5) * cell * Z - vp.clientWidth / 2,
|
left: (focus.col + 0.5) * cell - vp.clientWidth / 2,
|
||||||
top: (focus.row + 0.5) * cell * Z - vp.clientHeight / 2,
|
top: (focus.row + 0.5) * cell - vp.clientHeight / 2,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Double-tap toggles zoom.
|
// Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll).
|
||||||
let lastTap = 0;
|
let lastTap = 0;
|
||||||
function onTap(row: number, col: number) {
|
function onTap(row: number, col: number) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -65,50 +68,11 @@
|
|||||||
oncell(row, col);
|
oncell(row, col);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal pinch: spread zooms in, pinch zooms out (two-state).
|
|
||||||
const pts = new Map<number, { x: number; y: number }>();
|
|
||||||
let startDist = 0;
|
|
||||||
function dist(): number {
|
|
||||||
const p = [...pts.values()];
|
|
||||||
return p.length < 2 ? 0 : 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = (r: number, c: number) => `${r},${c}`;
|
const key = (r: number, c: number) => `${r},${c}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="viewport" class:zoomed bind:this={viewport}>
|
||||||
<div
|
<div class="scaler" style="--z: {z};">
|
||||||
class="viewport"
|
|
||||||
class:zoomed
|
|
||||||
bind:this={viewport}
|
|
||||||
onpointerdown={onPointerDown}
|
|
||||||
onpointermove={onPointerMove}
|
|
||||||
onpointerup={onPointerUp}
|
|
||||||
onpointercancel={onPointerUp}
|
|
||||||
>
|
|
||||||
<div class="scaler" style="transform: scale({z}); --inv: {1 / z};">
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each board as rowCells, r (r)}
|
{#each board as rowCells, r (r)}
|
||||||
{#each rowCells as cell, c (c)}
|
{#each rowCells as cell, c (c)}
|
||||||
@@ -120,24 +84,23 @@
|
|||||||
class="cell {premClass[premium[r][c]]}"
|
class="cell {premClass[premium[r][c]]}"
|
||||||
class:filled={!!cell}
|
class:filled={!!cell}
|
||||||
class:pending={!!p && !cell}
|
class:pending={!!p && !cell}
|
||||||
class:recent={recent.has(key(r, c))}
|
class:hl={!!cell && highlight.has(key(r, c))}
|
||||||
|
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
||||||
data-cell
|
data-cell
|
||||||
data-row={r}
|
data-row={r}
|
||||||
data-col={c}
|
data-col={c}
|
||||||
onclick={() => onTap(r, c)}
|
onclick={() => onTap(r, c)}
|
||||||
>
|
>
|
||||||
<span class="ct">
|
{#if letter}
|
||||||
{#if letter}
|
<span class="letter">{letter}</span>
|
||||||
<span class="letter">{letter}</span>
|
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
||||||
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
{:else if r === centre.row && c === centre.col}
|
||||||
{:else if r === centre.row && c === centre.col}
|
<span class="star">★</span>
|
||||||
<span class="star">★</span>
|
{:else if bl?.kind === 'single'}
|
||||||
{:else if bl?.kind === 'single'}
|
<span class="b1">{bl.text}</span>
|
||||||
<span class="b1">{bl.text}</span>
|
{:else if bl?.kind === 'split'}
|
||||||
{:else if bl?.kind === 'split'}
|
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
|
||||||
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
|
{/if}
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -153,15 +116,13 @@
|
|||||||
background: var(--board-bg);
|
background: var(--board-bg);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
touch-action: none;
|
|
||||||
}
|
}
|
||||||
.viewport.zoomed {
|
.viewport.zoomed {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.scaler {
|
.scaler {
|
||||||
width: 100%;
|
width: calc(100% * var(--z));
|
||||||
transform-origin: 0 0;
|
transition: width 0.25s ease;
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
}
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -179,6 +140,7 @@
|
|||||||
color: var(--prem-text);
|
color: var(--prem-text);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-size: 0;
|
||||||
}
|
}
|
||||||
.cell.tw {
|
.cell.tw {
|
||||||
background: var(--prem-tw);
|
background: var(--prem-tw);
|
||||||
@@ -200,35 +162,37 @@
|
|||||||
}
|
}
|
||||||
.cell.pending {
|
.cell.pending {
|
||||||
background: var(--tile-pending);
|
background: var(--tile-pending);
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
}
|
||||||
.cell.recent {
|
.cell.hl {
|
||||||
box-shadow:
|
background: var(--tile-recent);
|
||||||
inset 0 -2px 0 var(--tile-edge),
|
|
||||||
0 0 0 2px var(--warn);
|
|
||||||
}
|
}
|
||||||
/* Counter-scaled text layer: stays constant size as the board scales. */
|
.cell.flash {
|
||||||
.ct {
|
animation: tileflash 1s ease-in-out infinite;
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
transform: scale(var(--inv));
|
|
||||||
transform-origin: center;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
@keyframes tileflash {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background: var(--tile-bg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background: var(--tile-recent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
|
||||||
|
the board grows on zoom (relatively smaller, never overflowing). */
|
||||||
.letter {
|
.letter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6%;
|
top: 5%;
|
||||||
left: 9%;
|
left: 8%;
|
||||||
font-size: 4.3cqw;
|
font-size: 4.2cqw;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.val {
|
.val {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5%;
|
right: 5%;
|
||||||
bottom: 2%;
|
bottom: 3%;
|
||||||
font-size: 2.5cqw;
|
font-size: 2.4cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.star {
|
.star {
|
||||||
@@ -236,7 +200,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 4cqw;
|
font-size: 3.6cqw;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.b1 {
|
.b1 {
|
||||||
@@ -244,7 +208,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 3cqw;
|
font-size: 2.7cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
@@ -255,15 +219,18 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
line-height: 1;
|
line-height: 1.05;
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 1px;
|
||||||
}
|
}
|
||||||
.bt {
|
.bt {
|
||||||
font-size: 2.4cqw;
|
font-size: 1.7cqw;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.bb {
|
.bb {
|
||||||
font-size: 3.1cqw;
|
font-size: 1.9cqw;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+40
-69
@@ -13,11 +13,10 @@
|
|||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
|
||||||
import { lastPlayTiles, replay } from '../lib/board';
|
import { replay } from '../lib/board';
|
||||||
import { alphabet, centre, premiumGrid } from '../lib/premiums';
|
import { alphabet, centre, premiumGrid } from '../lib/premiums';
|
||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
direction,
|
|
||||||
newPlacement,
|
newPlacement,
|
||||||
place,
|
place,
|
||||||
placementFromHint,
|
placementFromHint,
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
|
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const checkedWords = new Map<string, boolean>();
|
const checkedWords = new Map<string, boolean>();
|
||||||
let lastCheckAt = 0;
|
let cooling = $state(false);
|
||||||
|
|
||||||
const variant = $derived(view?.game.variant ?? 'english');
|
const variant = $derived(view?.game.variant ?? 'english');
|
||||||
const board = $derived(replay(moves));
|
const board = $derived(replay(moves));
|
||||||
@@ -61,12 +60,24 @@
|
|||||||
const pendingMap = $derived(
|
const pendingMap = $derived(
|
||||||
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
|
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 lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
|
||||||
|
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
|
||||||
|
// are highlighted. It flashes when the opponent just moved and it is now our turn.
|
||||||
|
const highlight = $derived(
|
||||||
|
placement.pending.length > 0 || !lastPlay
|
||||||
|
? new Set<string>()
|
||||||
|
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
|
||||||
|
);
|
||||||
|
const flash = $derived(
|
||||||
|
!!lastPlay &&
|
||||||
|
!!view &&
|
||||||
|
view.game.status === 'active' &&
|
||||||
|
lastPlay.player !== view.seat &&
|
||||||
|
view.game.toMove === view.seat,
|
||||||
|
);
|
||||||
const slots = $derived(rackView(placement));
|
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 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);
|
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -267,11 +278,6 @@
|
|||||||
}
|
}
|
||||||
placement = newPlacement(r);
|
placement = newPlacement(r);
|
||||||
}
|
}
|
||||||
function toggleDir() {
|
|
||||||
dirOverride = dir === 'H' ? 'V' : 'H';
|
|
||||||
recompute();
|
|
||||||
}
|
|
||||||
|
|
||||||
function openExchange() {
|
function openExchange() {
|
||||||
resetPlacement();
|
resetPlacement();
|
||||||
exchangeSel = [];
|
exchangeSel = [];
|
||||||
@@ -305,18 +311,17 @@
|
|||||||
const raw = (e.target as HTMLInputElement).value.toUpperCase();
|
const raw = (e.target as HTMLInputElement).value.toUpperCase();
|
||||||
checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join('');
|
checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join('');
|
||||||
}
|
}
|
||||||
|
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
|
||||||
|
// length. The input filter already restricts to the variant's alphabet.
|
||||||
|
function canCheck(): boolean {
|
||||||
|
const w = checkWord.trim();
|
||||||
|
return w.length >= 2 && w.length <= 15 && !checkedWords.has(w.toUpperCase()) && !cooling;
|
||||||
|
}
|
||||||
async function runCheck() {
|
async function runCheck() {
|
||||||
|
if (!canCheck()) return;
|
||||||
const w = checkWord.trim().toUpperCase();
|
const w = checkWord.trim().toUpperCase();
|
||||||
if (!w) return;
|
cooling = true;
|
||||||
if (checkedWords.has(w)) {
|
setTimeout(() => (cooling = false), 5000);
|
||||||
checkResult = { word: w, legal: checkedWords.get(w)! };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Date.now() - lastCheckAt < 5000) {
|
|
||||||
showToast(t('game.checkWait'), 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastCheckAt = Date.now();
|
|
||||||
try {
|
try {
|
||||||
const r = await gateway.checkWord(id, w);
|
const r = await gateway.checkWord(id, w);
|
||||||
checkedWords.set(r.word.toUpperCase(), r.legal);
|
checkedWords.set(r.word.toUpperCase(), r.legal);
|
||||||
@@ -355,19 +360,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
function resultText(): string {
|
||||||
if (!view) return '';
|
if (!view) return '';
|
||||||
const me = view.game.seats[view.seat];
|
const me = view.game.seats[view.seat];
|
||||||
@@ -383,7 +375,7 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Screen title={t('app.title')} back="/" scroll={!historyOpen}>
|
<Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<Menu items={menuItems} />
|
<Menu items={menuItems} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -419,15 +411,14 @@
|
|||||||
<div
|
<div
|
||||||
class="boardwrap"
|
class="boardwrap"
|
||||||
class:slid={historyOpen}
|
class:slid={historyOpen}
|
||||||
onpointerdown={boardSwipeStart}
|
|
||||||
onpointerup={boardSwipeEnd}
|
|
||||||
onclick={() => historyOpen && (historyOpen = false)}
|
onclick={() => historyOpen && (historyOpen = false)}
|
||||||
>
|
>
|
||||||
<Board
|
<Board
|
||||||
{board}
|
{board}
|
||||||
{premium}
|
{premium}
|
||||||
pending={pendingMap}
|
pending={pendingMap}
|
||||||
{recent}
|
{highlight}
|
||||||
|
{flash}
|
||||||
centre={ctr}
|
centre={ctr}
|
||||||
{zoomed}
|
{zoomed}
|
||||||
{variant}
|
{variant}
|
||||||
@@ -475,10 +466,9 @@
|
|||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
{#if view && !gameOver}
|
{#if view && !gameOver}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || bagEmpty} onhold={openExchange}>
|
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
|
||||||
{#snippet trigger()}<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>{/snippet}
|
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); openExchange(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
</button>
|
||||||
</HoldConfirm>
|
|
||||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
|
||||||
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
{#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}
|
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||||
@@ -504,10 +494,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ambiguous && placement.pending.length > 0}
|
|
||||||
<button class="dirtoggle" onclick={toggleDir} aria-label="direction">{dir === 'H' ? '↔' : '↕'}</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if blankPrompt}
|
{#if blankPrompt}
|
||||||
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
|
||||||
<div class="alpha">
|
<div class="alpha">
|
||||||
@@ -542,7 +528,7 @@
|
|||||||
onkeydown={(e) => e.key === 'Enter' && runCheck()}
|
onkeydown={(e) => e.key === 'Enter' && runCheck()}
|
||||||
placeholder={t('game.checkWordPrompt')}
|
placeholder={t('game.checkWordPrompt')}
|
||||||
/>
|
/>
|
||||||
<button onclick={runCheck}>{t('game.check')}</button>
|
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if checkResult}
|
{#if checkResult}
|
||||||
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
|
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
|
||||||
@@ -694,17 +680,16 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
.pop {
|
.pop {
|
||||||
padding: 9px 12px;
|
padding: 9px 14px;
|
||||||
border: 1px solid var(--border);
|
border: none;
|
||||||
background: var(--surface);
|
background: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
.pop.go {
|
.pop:hover {
|
||||||
background: var(--accent);
|
background: var(--surface-2);
|
||||||
color: var(--accent-text);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
}
|
||||||
.badge {
|
.badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -738,20 +723,6 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 60;
|
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 {
|
.alpha {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user