Stage 17 (contour round 4a): quick fixes
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s

- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
This commit is contained in:
Ilia Denisov
2026-06-06 14:08:40 +02:00
parent f6bffd1f57
commit b15fd30c4f
12 changed files with 61 additions and 32 deletions
+12 -1
View File
@@ -1,9 +1,20 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import { app } from '../lib/app.svelte';
const dur = $derived(app.reduceMotion ? 0 : 260);
</script>
{#if app.toast}
<div class="toast {app.toast.kind}" role="status" aria-live="polite">{app.toast.text}</div>
<div
class="toast {app.toast.kind}"
role="status"
aria-live="polite"
in:fly={{ y: 32, duration: dur }}
out:fade={{ duration: dur }}
>
{app.toast.text}
</div>
{/if}
<style>
+7 -2
View File
@@ -33,7 +33,7 @@
locale: Locale;
focus: { row: number; col: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: () => void;
ontogglezoom: (row: number, col: number) => void;
} = $props();
const Z = 1.85;
@@ -65,7 +65,7 @@
function onTap(row: number, col: number) {
const now = Date.now();
if (now - lastTap < 300) {
ontogglezoom();
ontogglezoom(row, col); // zoom toward the double-tapped cell, not the top-left
lastTap = 0;
return;
}
@@ -172,6 +172,9 @@
}
.cell.hl {
background: var(--tile-recent);
/* The bottom edge goes darker than the highlighted fill (not lighter, as the plain
--tile-edge would), so the tile still reads as raised. */
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
}
.cell.flash {
/* Two flashes to draw the eye, then settle back to normal so it does not distract. */
@@ -181,9 +184,11 @@
0%,
100% {
background: var(--tile-bg);
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
50% {
background: var(--tile-recent);
box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
}
}
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
+4 -2
View File
@@ -324,6 +324,8 @@
[r[i], r[j]] = [r[j], r[i]];
}
placement = newPlacement(r);
// A short "shake": a few quick light taps rather than one.
for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
}
function openExchange() {
resetPlacement();
@@ -509,13 +511,13 @@
locale={app.locale}
{focus}
oncell={onCell}
ontogglezoom={() => { if (!gameOver) zoomed = !zoomed; }}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
/>
</div>
</div>
<div class="status">
<span>{t('game.bag', { n: view.bagLen })}</span>
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
+2 -2
View File
@@ -9,8 +9,8 @@ describe('i18n catalog', () => {
});
it('interpolates parameters', () => {
expect(translate('en', 'game.bag', { n: 7 })).toBe('Bag 7');
expect(translate('ru', 'game.bag', { n: 7 })).toBe('Мешок 7');
expect(translate('en', 'game.bag', { n: 7 })).toBe('7 in the bag');
expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке');
});
it('maps error codes to keys with a generic fallback', () => {
+2 -1
View File
@@ -46,7 +46,8 @@ export const en = {
'new.find': 'Find a game',
'new.searching': 'Looking for an opponent…',
'game.bag': 'Bag {n}',
'game.bag': '{n} in the bag',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn',
'game.waiting': "Waiting for {name}",
+2 -1
View File
@@ -47,7 +47,8 @@ export const ru: Record<MessageKey, string> = {
'new.find': 'Найти игру',
'new.searching': 'Ищем соперника…',
'game.bag': 'Мешок {n}',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход',
'game.waiting': 'Ожидаем {name}',
+4 -1
View File
@@ -12,7 +12,10 @@ describe('validDisplayName', () => {
['Name P._Last', false],
['Name Last', false],
['_Name', false],
['Name.', false],
['Anna B.', true],
['Name.', true],
['Name..', false],
['Name_', false],
['Name2', false],
['', false],
['a'.repeat(33), false],
+4 -3
View File
@@ -9,9 +9,10 @@ export const maxDisplayName = 32;
export const maxAwayMinutes = 12 * 60;
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
// may be followed by a single space. No leading/trailing separator and no adjacent
// separators except "<dot|underscore> <space>". Same rule as the Go displayNameRe.
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*$/u;
// may be followed by a single space. No leading separator and no adjacent separators
// except "<dot|underscore> <space>"; a single trailing "." is allowed (Stage 17). Same
// rule as the Go displayNameRe.
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
/** displayNameError returns true when the trimmed name is a valid display name. */
export function validDisplayName(raw: string): boolean {
+3 -1
View File
@@ -253,7 +253,9 @@
</section>
{/if}
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
<!-- Logout is hidden for now (Stage 17) but kept wired — drop `hidden` to re-enable
once its entry point is decided; logout() also still runs on an invalid session. -->
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>