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

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:
Ilia Denisov
2026-07-10 14:58:32 +02:00
parent 2683103fc1
commit 77a690fcf6
21 changed files with 635 additions and 198 deletions
+23 -43
View File
@@ -4,15 +4,16 @@
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
import type { Snippet } from 'svelte';
let {
slots,
variant,
selected,
landscape = false,
shuffling = false,
draggingId = null,
dropIndex = null,
confirm,
ondown,
}: {
// Each slot carries a stable id that travels with its tile through a shuffle, so the
@@ -20,14 +21,14 @@
slots: (RackSlot & { id: number })[];
variant: Variant;
selected: number | null;
/** Landscape layout: size the tiles to share the (narrow) left-panel width instead of the
* viewport-width measure, so seven tiles never overflow the panel. */
landscape?: boolean;
shuffling?: boolean;
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
draggingId?: number | null;
dropIndex?: number | null;
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
* (the parent owns the button and its enablement; the rack only positions it). */
confirm?: Snippet;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
@@ -57,7 +58,7 @@
}
</script>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"
@@ -75,21 +76,26 @@
{/if}
</button>
{/each}
{@render confirm?.()}
</div>
<style>
/* Seven equal columns filling the whole tray width, so the tiles are square and as large as the
row allows — the same in portrait and landscape. The tile glyphs use cqw against the rack width
(the query container) so they track the tile size, the way the board sizes its labels on its
.scaler. */
.rack {
display: flex;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
align-items: center;
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
footer the same size as during play — no layout jump between states. */
align-items: start;
container-type: inline-size;
/* Reserve about one tile's height so an empty rack (e.g. a finished game) keeps the footer the
same size as during play — no layout jump between states. */
min-height: min(12.5vw, 46px);
}
.tile {
position: relative;
flex: 0 0 auto;
width: min(12.5vw, 46px);
aspect-ratio: 1;
background: var(--tile-bg);
color: var(--tile-text);
@@ -97,7 +103,6 @@
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
font-size: 1.4rem;
touch-action: none;
user-select: none;
-webkit-user-select: none;
@@ -109,35 +114,6 @@
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
viewport-width measure that would overflow seven tiles in a column. */
.rack.landscape {
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
resolves unreliably, so the rack — which has a definite width — is the query container. A
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
container-type: inline-size;
}
.rack.landscape .tile {
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
tiles leave. */
flex: 0 0 auto;
width: calc((100% - 6 * 5px) / 7);
max-width: 46px;
}
.rack.landscape .letter {
font-size: 6cqw;
}
.rack.landscape .val {
font-size: 3.1cqw;
}
.rack.landscape .star {
font-size: 7.6cqw;
}
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile {
@@ -150,12 +126,15 @@
position: absolute;
top: 8%;
left: 14%;
font-size: 6vmin; /* cqw fallback for Chrome < 105 — approximates the portrait rack ≈ viewport width */
font-size: 6cqw;
}
.val {
position: absolute;
right: 4px;
bottom: 1px;
font-size: 0.7rem;
font-size: 3.1vmin; /* cqw fallback for Chrome < 105 */
font-size: 3.1cqw;
font-weight: 600;
}
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
@@ -167,6 +146,7 @@
left: 0;
right: 0;
text-align: center;
font-size: 1.7rem;
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw;
}
</style>