Files
scrabble-game/ui/src/game/Rack.svelte
T
Ilia Denisov 77a690fcf6
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
feat(ui): move in-game status to the board — highlight, score badge, full-width rack
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.
2026-07-10 14:58:32 +02:00

153 lines
5.7 KiB
Svelte

<script lang="ts">
import type { RackSlot } from '../lib/placement';
import { BLANK } from '../lib/placement';
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,
shuffling = false,
draggingId = null,
dropIndex = null,
confirm,
ondown,
}: {
// Each slot carries a stable id that travels with its tile through a shuffle, so the
// keyed list reorders (rather than relabelling in place) and the hop animation fires.
slots: (RackSlot & { id: number })[];
variant: Variant;
selected: number | null;
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();
// Used slots are hidden (the rack shifts left, freeing room on the right for the
// MakeMove control); the slot still exists in the model for per-tile recall. While
// reordering, the dragged tile is lifted out (the ghost shows it).
const visible = $derived(slots.filter((s) => !s.used));
const shown = $derived(draggingId == null ? visible : visible.filter((s) => s.id !== draggingId));
// hop flies a tile to its shuffled position along a low parabola (apogee ≈ half a tile
// height). The duration scales with the horizontal distance — i.e. the arc length — so
// the longest swap (slot 1 ↔ 7) takes ~0.3s and shorter swaps land sooner. It runs only
// while a shuffle is in progress (and motion is not reduced); ordinary reflow
// (placing/recalling a tile) is instant.
function hop(node: HTMLElement, { from, to }: { from: DOMRect; to: DOMRect }, active: boolean) {
const dx = from.left - to.left;
const dy = from.top - to.top;
const dist = Math.hypot(dx, dy);
if (!active || dist < 2) return { duration: 0 };
const span = node.parentElement?.getBoundingClientRect().width || dist;
const lift = (to.height || from.height) * 0.5;
return {
duration: Math.max(120, Math.min(300, (dist / span) * 340)),
css: (t: number, u: number) =>
`transform: translate(${dx * u}px, ${dy * u - Math.sin(Math.PI * t) * lift}px);`,
};
}
</script>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"
class:selected={selected === slot.index}
class:shift={dropIndex != null && i >= dropIndex}
data-rack-index={slot.index}
animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)}
>
{#if slot.letter === BLANK}
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
{:else}
<span class="letter">{slot.letter}</span>
<span class="val">{valueForLetter(variant, slot.letter)}</span>
{/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: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
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;
aspect-ratio: 1;
background: var(--tile-bg);
color: var(--tile-text);
border: none;
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
touch-action: none;
user-select: none;
-webkit-user-select: none;
/* iOS shows a tap/active highlight that can linger on the neighbour sliding into a
dragged tile's slot; suppress it so only our own styles mark a tile. */
-webkit-tap-highlight-color: transparent;
}
.tile.selected {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* 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 {
transition: transform 0.14s ease;
}
.tile.shift {
transform: translateX(calc(100% + 5px));
}
.letter {
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: 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
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
.star {
position: absolute;
top: calc(0.5% - 1px);
left: 0;
right: 0;
text-align: center;
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw;
}
</style>