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
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:
@@ -28,9 +28,10 @@ const maxAwayWindow = 12 * time.Hour
|
|||||||
|
|
||||||
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
|
||||||
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
|
||||||
// by a single space. No leading or trailing separator and no two adjacent separators,
|
// by a single space. No leading separator and no two adjacent separators (except
|
||||||
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
|
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so
|
||||||
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
|
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
|
||||||
|
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
|
||||||
|
|
||||||
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
// ErrInvalidProfile is returned when a profile update carries an unacceptable
|
||||||
// field (an unknown language, an invalid timezone, or an over-long display name).
|
// field (an unknown language, an invalid timezone, or an over-long display name).
|
||||||
|
|||||||
@@ -12,19 +12,21 @@ func TestValidateDisplayName(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
ok bool
|
ok bool
|
||||||
}{
|
}{
|
||||||
"plain": {"Kaya", "Kaya", true},
|
"plain": {"Kaya", "Kaya", true},
|
||||||
"cyrillic": {"Кая", "Кая", true},
|
"cyrillic": {"Кая", "Кая", true},
|
||||||
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
|
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
|
||||||
"single dot": {"Mr.Smith", "Mr.Smith", true},
|
"single dot": {"Mr.Smith", "Mr.Smith", true},
|
||||||
"dot then space": {"Mr. Smith", "Mr. Smith", true},
|
"dot then space": {"Mr. Smith", "Mr. Smith", true},
|
||||||
"trim surrounding": {" Kaya ", "Kaya", true},
|
"trim surrounding": {" Kaya ", "Kaya", true},
|
||||||
"adjacent specials": {"Name P._Last", "", false},
|
"adjacent specials": {"Name P._Last", "", false},
|
||||||
"two spaces": {"Name Last", "", false},
|
"two spaces": {"Name Last", "", false},
|
||||||
"leading special": {"_Name", "", false},
|
"leading special": {"_Name", "", false},
|
||||||
"trailing special": {"Name.", "", false},
|
"trailing underscore": {"Name_", "", false},
|
||||||
"digit rejected": {"Name2", "", false},
|
"trailing dot ok": {"Anna B.", "Anna B.", true},
|
||||||
"blank": {" ", "", false},
|
"double trailing dot": {"Name..", "", false},
|
||||||
"too long": {strings.Repeat("a", 33), "", false},
|
"digit rejected": {"Name2", "", false},
|
||||||
|
"blank": {" ", "", false},
|
||||||
|
"too long": {strings.Repeat("a", 33), "", false},
|
||||||
}
|
}
|
||||||
for name, tc := range cases {
|
for name, tc := range cases {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel"><h2>Seats</h2>
|
<section class="panel"><h2>Seats</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
|
<thead><tr><th>Seat</th><th>Player</th><th>Score</th><th>Hints used</th><th>Winner</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Seats}}
|
{{range .Seats}}
|
||||||
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
<tr><td>{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Score}}</td><td>{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
|
|
||||||
|
const dur = $derived(app.reduceMotion ? 0 : 260);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.toast}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
focus: { row: number; col: number } | null;
|
focus: { row: number; col: number } | null;
|
||||||
oncell: (row: number, col: number) => void;
|
oncell: (row: number, col: number) => void;
|
||||||
ontogglezoom: () => void;
|
ontogglezoom: (row: number, col: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const Z = 1.85;
|
const Z = 1.85;
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
function onTap(row: number, col: number) {
|
function onTap(row: number, col: number) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTap < 300) {
|
if (now - lastTap < 300) {
|
||||||
ontogglezoom();
|
ontogglezoom(row, col); // zoom toward the double-tapped cell, not the top-left
|
||||||
lastTap = 0;
|
lastTap = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,6 +172,9 @@
|
|||||||
}
|
}
|
||||||
.cell.hl {
|
.cell.hl {
|
||||||
background: var(--tile-recent);
|
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 {
|
.cell.flash {
|
||||||
/* Two flashes to draw the eye, then settle back to normal so it does not distract. */
|
/* Two flashes to draw the eye, then settle back to normal so it does not distract. */
|
||||||
@@ -181,9 +184,11 @@
|
|||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
background: var(--tile-bg);
|
background: var(--tile-bg);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--tile-edge);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
background: var(--tile-recent);
|
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
|
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
|
||||||
|
|||||||
@@ -324,6 +324,8 @@
|
|||||||
[r[i], r[j]] = [r[j], r[i]];
|
[r[i], r[j]] = [r[j], r[i]];
|
||||||
}
|
}
|
||||||
placement = newPlacement(r);
|
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() {
|
function openExchange() {
|
||||||
resetPlacement();
|
resetPlacement();
|
||||||
@@ -509,13 +511,13 @@
|
|||||||
locale={app.locale}
|
locale={app.locale}
|
||||||
{focus}
|
{focus}
|
||||||
oncell={onCell}
|
oncell={onCell}
|
||||||
ontogglezoom={() => { if (!gameOver) zoomed = !zoomed; }}
|
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status">
|
<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}
|
{#if gameOver}
|
||||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ describe('i18n catalog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('interpolates parameters', () => {
|
it('interpolates parameters', () => {
|
||||||
expect(translate('en', 'game.bag', { n: 7 })).toBe('Bag 7');
|
expect(translate('en', 'game.bag', { n: 7 })).toBe('7 in the bag');
|
||||||
expect(translate('ru', 'game.bag', { n: 7 })).toBe('Мешок 7');
|
expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps error codes to keys with a generic fallback', () => {
|
it('maps error codes to keys with a generic fallback', () => {
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export const en = {
|
|||||||
'new.find': 'Find a game',
|
'new.find': 'Find a game',
|
||||||
'new.searching': 'Looking for an opponent…',
|
'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.hints': 'Hints {n}',
|
||||||
'game.yourTurn': 'Your turn',
|
'game.yourTurn': 'Your turn',
|
||||||
'game.waiting': "Waiting for {name}",
|
'game.waiting': "Waiting for {name}",
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'new.find': 'Найти игру',
|
'new.find': 'Найти игру',
|
||||||
'new.searching': 'Ищем соперника…',
|
'new.searching': 'Ищем соперника…',
|
||||||
|
|
||||||
'game.bag': 'Мешок {n}',
|
'game.bag': '{n} в мешке',
|
||||||
|
'game.bagEmpty': 'Мешок пуст',
|
||||||
'game.hints': 'Подсказки {n}',
|
'game.hints': 'Подсказки {n}',
|
||||||
'game.yourTurn': 'Ваш ход',
|
'game.yourTurn': 'Ваш ход',
|
||||||
'game.waiting': 'Ожидаем {name}',
|
'game.waiting': 'Ожидаем {name}',
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ describe('validDisplayName', () => {
|
|||||||
['Name P._Last', false],
|
['Name P._Last', false],
|
||||||
['Name Last', false],
|
['Name Last', false],
|
||||||
['_Name', false],
|
['_Name', false],
|
||||||
['Name.', false],
|
['Anna B.', true],
|
||||||
|
['Name.', true],
|
||||||
|
['Name..', false],
|
||||||
|
['Name_', false],
|
||||||
['Name2', false],
|
['Name2', false],
|
||||||
['', false],
|
['', false],
|
||||||
['a'.repeat(33), false],
|
['a'.repeat(33), false],
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export const maxDisplayName = 32;
|
|||||||
export const maxAwayMinutes = 12 * 60;
|
export const maxAwayMinutes = 12 * 60;
|
||||||
|
|
||||||
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
|
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
|
||||||
// may be followed by a single space. No leading/trailing separator and no adjacent
|
// may be followed by a single space. No leading separator and no adjacent separators
|
||||||
// separators except "<dot|underscore> <space>". Same rule as the Go displayNameRe.
|
// except "<dot|underscore> <space>"; a single trailing "." is allowed (Stage 17). Same
|
||||||
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*$/u;
|
// rule as the Go displayNameRe.
|
||||||
|
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u;
|
||||||
|
|
||||||
/** displayNameError returns true when the trimmed name is a valid display name. */
|
/** displayNameError returns true when the trimmed name is a valid display name. */
|
||||||
export function validDisplayName(raw: string): boolean {
|
export function validDisplayName(raw: string): boolean {
|
||||||
|
|||||||
@@ -253,7 +253,9 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user