Stage 17 round 6 (cluster 1): profile, tap flash, variant naming, chat/nudge by turn
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s

- Profile: drop the hint-balance line.
- Board: no mobile tap flash on a cell tap (-webkit-tap-highlight-color: transparent),
  matching the web click; the only intentional cell animation stays the last-word flash.
- Variant names keyed by the game's alphabet, not the UI language: english -> Scrabble
  always, russian_scrabble -> Скрэббл always (unlocalized, never collide), erudit localized.
- Chat/nudge are mutually exclusive by turn: the message field + Send show on your turn,
  the nudge replaces them on the opponent's turn; while the nudge cooldown is active the
  button is disabled with a grey 'awaiting reply' caption to its left.
This commit is contained in:
Ilia Denisov
2026-06-07 11:18:25 +02:00
parent a420d6a2cd
commit 512ad4dfb9
8 changed files with 47 additions and 34 deletions
+6 -4
View File
@@ -165,12 +165,14 @@ test('link account: the Telegram web sign-in control is offered in a browser', a
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
}); });
test('chat send and nudge are icon buttons', async ({ page }) => { test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
await page.locator('.burger').first().click(); await page.locator('.burger').first().click();
await page.getByRole('button', { name: 'Chat' }).click(); await page.getByRole('button', { name: 'Chat' }).click();
// Icon-only controls expose their action through the aria-label. // On your turn the message field + Send are shown and the nudge is hidden (Stage 17);
// chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
// through the aria-label.
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Nudge' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0);
}); });
+3
View File
@@ -250,6 +250,9 @@
border-radius: 1px; border-radius: 1px;
background: var(--cell-bg); background: var(--cell-bg);
color: var(--prem-text); color: var(--prem-text);
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
cell animation is the last-word .flash highlight). */
-webkit-tap-highlight-color: transparent;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
font-size: 0; font-size: 0;
+21 -5
View File
@@ -6,16 +6,20 @@
messages, messages,
myId, myId,
busy, busy,
canNudge = true, myTurn = false,
nudgeOnCooldown = false,
onsend, onsend,
onnudge, onnudge,
}: { }: {
messages: ChatMessage[]; messages: ChatMessage[];
myId: string; myId: string;
busy: boolean; busy: boolean;
// Nudging only makes sense while waiting on the opponent; it is disabled on the // Chat and nudge are mutually exclusive by turn (Stage 17): on the player's own turn the
// player's own turn (there is no one to hurry along). // message field + send are shown (and nudging makes no sense — there is no one to
canNudge?: boolean; // hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
// cooldown is active the nudge is disabled with an "awaiting reply" caption.
myTurn?: boolean;
nudgeOnCooldown?: boolean;
onsend: (text: string) => void; onsend: (text: string) => void;
onnudge: () => void; onnudge: () => void;
} = $props(); } = $props();
@@ -44,6 +48,7 @@
{/each} {/each}
</div> </div>
<div class="input"> <div class="input">
{#if myTurn}
<input <input
maxlength="60" maxlength="60"
placeholder={t('chat.placeholder')} placeholder={t('chat.placeholder')}
@@ -51,7 +56,10 @@
onkeydown={(e) => e.key === 'Enter' && send()} onkeydown={(e) => e.key === 'Enter' && send()}
/> />
<button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button> <button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || !canNudge} aria-label={t('chat.nudgeAction')}>🛎️</button> {:else}
{#if nudgeOnCooldown}<span class="cooldown">{t('chat.awaitingReply')}</span>{/if}
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div> </div>
</div> </div>
@@ -99,6 +107,14 @@
.input { .input {
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center;
}
/* The cooldown caption sits to the left of the disabled nudge button. */
.cooldown {
flex: 1;
text-align: right;
color: var(--text-muted);
font-size: 0.85rem;
} }
.input input { .input input {
flex: 1; flex: 1;
+1 -1
View File
@@ -760,7 +760,7 @@
{#if panel === 'chat'} {#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}> <Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn && !nudgeOnCooldown} onsend={sendChat} onnudge={nudge} /> <Chat {messages} myId={app.session?.userId ?? ''} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Modal> </Modal>
{/if} {/if}
+2 -1
View File
@@ -41,7 +41,7 @@ export const en = {
'new.title': 'New game', 'new.title': 'New game',
'new.subtitle': 'Auto-match with another player', 'new.subtitle': 'Auto-match with another player',
'new.english': 'Scrabble', 'new.english': 'Scrabble',
'new.russian': 'Scrabble', 'new.russian': 'Скрэббл',
'new.erudit': 'Erudite', 'new.erudit': 'Erudite',
'new.find': 'Find a game', 'new.find': 'Find a game',
'new.searching': 'Looking for an opponent…', 'new.searching': 'Looking for an opponent…',
@@ -96,6 +96,7 @@ export const en = {
'chat.send': 'Send', 'chat.send': 'Send',
'chat.nudge': 'Waiting for your move!', 'chat.nudge': 'Waiting for your move!',
'chat.nudgeAction': 'Nudge', 'chat.nudgeAction': 'Nudge',
'chat.awaitingReply': "Waiting for the opponent's reply",
'chat.empty': 'No messages yet.', 'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you', 'chat.nudged': '{name} nudged you',
+2 -1
View File
@@ -41,7 +41,7 @@ export const ru: Record<MessageKey, string> = {
'new.title': 'Новая игра', 'new.title': 'Новая игра',
'new.subtitle': 'Автоподбор соперника', 'new.subtitle': 'Автоподбор соперника',
'new.english': 'Скрэббл', 'new.english': 'Scrabble',
'new.russian': 'Скрэббл', 'new.russian': 'Скрэббл',
'new.erudit': 'Эрудит', 'new.erudit': 'Эрудит',
'new.find': 'Найти игру', 'new.find': 'Найти игру',
@@ -97,6 +97,7 @@ export const ru: Record<MessageKey, string> = {
'chat.send': 'Отправить', 'chat.send': 'Отправить',
'chat.nudge': 'Жду вашего хода!', 'chat.nudge': 'Жду вашего хода!',
'chat.nudgeAction': 'Поторопить', 'chat.nudgeAction': 'Поторопить',
'chat.awaitingReply': 'Ждём реакцию соперника',
'chat.empty': 'Сообщений пока нет.', 'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас', 'chat.nudged': '{name} торопит вас',
+5 -4
View File
@@ -11,10 +11,11 @@ export interface VariantOption {
label: MessageKey; label: MessageKey;
} }
// ALL_VARIANTS lists every variant in display order. The labels are display names, not // ALL_VARIANTS lists every variant in display order. The labels are display names keyed by
// language names: both Scrabble variants render as "Scrabble"/"Скрэббл" and Erudit as // the game's alphabet, not the interface language: the English-alphabet game is always
// "Erudite"/"Эрудит" (Stage 17) — the offered list is language-gated, so within one // "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
// language the names stay distinct. // two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит"
// (Stage 17).
export const ALL_VARIANTS: VariantOption[] = [ export const ALL_VARIANTS: VariantOption[] = [
{ id: 'english', label: 'new.english' }, { id: 'english', label: 'new.english' },
{ id: 'russian_scrabble', label: 'new.russian' }, { id: 'russian_scrabble', label: 'new.russian' },
-11
View File
@@ -166,8 +166,6 @@
<div class="name">{p.displayName}</div> <div class="name">{p.displayName}</div>
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if} {#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<div class="hintbal"><span>{t('profile.hintBalance')}</span><b>{p.hintBalance}</b></div>
{#if p.isGuest} {#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p> <p class="muted">{t('profile.guestLocked')}</p>
{:else} {:else}
@@ -284,15 +282,6 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8rem; font-size: 0.8rem;
} }
.hintbal {
display: flex;
justify-content: space-between;
color: var(--text-muted);
}
.hintbal b {
color: var(--text);
font-weight: 600;
}
.muted { .muted {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.9rem; font-size: 0.9rem;