Files
scrabble-game/ui/src/components/Modal.svelte
T
Ilia Denisov bf46b9492d
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
fix(ui): one-word games must not highlight phantom cross words; review polish
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00

151 lines
4.7 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
let {
title = '',
titleAside = '',
onclose,
overlayKeyboard = false,
bottomSheet = false,
children,
}: {
title?: string;
/** Optional secondary text pinned to the right of the title row, in a lighter (non-bold)
* muted style — e.g. the bag count beside the "Exchange or pass" title. */
titleAside?: string;
onclose?: () => void;
overlayKeyboard?: boolean;
bottomSheet?: boolean;
children?: Snippet;
} = $props();
// Track the visual viewport so the backdrop covers only the area above an open
// mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport
// backdrop still centres it behind the keyboard. Sizing the backdrop to
// visualViewport keeps the sheet (and the start of a chat) fully on screen.
// overlayKeyboard opts out: the sheet is small and top-anchored, so the keyboard
// simply overlays the empty lower area — no resize, no relayout jank (e.g. check word).
// bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the
// keyboard with a transform (kb), driven by the visual viewport — a compositor-only
// move, so neither the page behind nor the sheet relayouts as the keyboard animates
// The backdrop is not resized in this mode (no per-event reflow).
let vh = $state(0);
let top = $state(0);
let kb = $state(0);
$effect(() => {
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
if (!vv || overlayKeyboard) return;
const update = () => {
vh = vv.height;
top = vv.offsetTop;
// Soft-keyboard height: the layout viewport minus the visible viewport.
kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="backdrop"
class:overlay={overlayKeyboard}
class:bottom={bottomSheet}
style:height={!bottomSheet && vh ? `${vh}px` : null}
style:top={!bottomSheet && vh ? `${top}px` : null}
onclick={() => onclose?.()}
>
<div
class="sheet"
role="dialog"
aria-modal="true"
tabindex="-1"
style:--kb={bottomSheet ? `${kb}px` : null}
onclick={(e) => e.stopPropagation()}
>
{#if title}
<div class="titlerow">
<h2>{title}</h2>
{#if titleAside}<span class="aside">{titleAside}</span>{/if}
</div>
{/if}
{@render children?.()}
</div>
</div>
<style>
.backdrop {
position: fixed;
left: 0;
right: 0;
top: 0;
/* Base fallback; overridden inline to the visual-viewport height/top so the
backdrop (and the centred sheet) stay above an open mobile keyboard. */
height: 100dvh;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 40;
}
/* Overlay mode: top-anchor the (small) sheet and don't track the keyboard, so the
soft keyboard overlays the empty lower area without resizing/relaying out. */
.backdrop.overlay {
align-items: flex-start;
padding-top: 12vh;
}
/* Bottom-sheet mode (the chat): a wide sheet pinned to the bottom that lifts above the
soft keyboard via a transform (--kb) — compositor-only, so the page behind and the
sheet itself do not relayout as the keyboard animates. */
.backdrop.bottom {
align-items: flex-end;
padding: 0;
}
.backdrop.bottom .sheet {
width: 100%;
max-width: 640px;
border-radius: var(--radius) var(--radius) 0 0;
transform: translateY(calc(-1 * var(--kb, 0px)));
transition: transform 0.15s ease;
}
.sheet {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--pad);
width: min(94vw, 420px);
/* dvh tracks the dynamic viewport, so the sheet shrinks above an open mobile
keyboard instead of being scrolled off the top (vh fallback first). */
max-height: 86vh;
max-height: 86dvh;
overflow: auto;
}
.titlerow {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
h2 {
margin: 0 0 10px;
font-size: 1.05rem;
}
/* Secondary title-row text (e.g. the bag count): lighter and muted so the title still leads. */
.aside {
font-weight: 400;
font-size: 0.9rem;
color: var(--text-muted);
white-space: nowrap;
}
</style>