mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
fix(web): make the game comfortable to play on a phone and in both themes
Applies the findings of the three UI/UX reviews. The two that cost a player something every single turn: - The countdown reddened during the opponent's turn. It read the deadline, which the server rewrites on every handover, and never asked whose turn it was — a panic spike, every turn, about nothing the player could act on. It now mutes when the clock is not theirs, and signals urgency by thickening as well as reddening, because green to red is the pair a red-green colour blindness cannot separate. - On a phone the keyboard closed on every turn and could not be reopened. `disabled` on the focused word field blurs it, and the recovering focus() ran on a WebSocket message, which iOS will not open a keyboard for. The field stays editable and the submit button carries the turn instead; focus is asked for inside the gesture that submitted. A draft aimed at a syllable the game has moved past is now corrected rather than kept. The board was also not holding together as the game grew. `min-height` on the shell left nothing with a definite height to shrink against, so the chain kept its full height and the page got taller every turn, walking the word field and the chat off the bottom of the screen. The shell now has a real height, the chain gives up its space first with a floor so it cannot vanish, and `main` scrolls only as a last resort. Online play had one true dead end: nothing on the pre-room screen showed the connection at all, so a server that was down left an enabled button and a screen that never changed — while each impatient tap sent a real CreateRoom until the rate limiter answered. The badge is there now, requests disable their button, a stall says so, and the lobby draws the room's refusals beside the button that produced them instead of in a chat panel below the fold. A dropped player gets a retry rather than waiting out the backoff. An invite link asks a first-time guest for a name instead of seating them as "Người chơi" with no way to fix it. Accessibility and palette: - The turn change is announced. It is the state the whole game hangs on and it reached nobody who was not looking at that line. - --accent darkened to #12692f: accent text on its own tint measured 4.29:1. Every existing pair improves, white-on-accent included. - --border-strong added for the boundary of anything you type into or press, which needs 3:1 and had 1.47:1 light, 1.78:1 dark. - One focus ring, one 44px icon-button recipe, spacing and type scales, safe-area gutters, per-route titles, a heading per screen, a skip link, and reduced motion that stops the connection dot pulsing rather than flickering it a hundred thousand times a frame. Tests follow the two interactions that changed shape: resign and kick confirm inline with a second press, because confirm() blocks the frame loop the countdown runs on and hesitating could cost the turn it was protecting. joinRoomSeated waits for the seat before anybody speaks — the server replays a seat only the conversation from when it joined, so the old helper was racing the handshake. Verified: 189 unit tests, 40 end-to-end, no type errors, and the palette and layout measured at 360/400/768/1280 in both themes.
This commit is contained in:
1 parent
b65b4eade1
commit
db7093bbe5
30 files changed
+1359
-344
No files matched your search
+15
-13
@@ -6,6 +6,7 @@ import {
|
||||
chooseDifficulty,
|
||||
openMeanings,
|
||||
playLegalMove,
|
||||
resign,
|
||||
setNickname,
|
||||
submitWord,
|
||||
waitForMyTurn
|
||||
@@ -116,7 +117,11 @@ test.describe('playing the bot', () => {
|
||||
|
||||
await submitWord(page, UNKNOWN_WORD);
|
||||
|
||||
await expect(board(page).rejection).toHaveText('Không tìm thấy từ này trong từ điển.');
|
||||
// The refused word is shown beside the reason: it was cleared out of
|
||||
// the field when it went out, and a Vietnamese rejection is usually
|
||||
// one tone mark away from being right.
|
||||
await expect(board(page).rejection).toContainText('Không tìm thấy từ này trong từ điển.');
|
||||
await expect(board(page).rejection).toContainText(UNKNOWN_WORD);
|
||||
});
|
||||
|
||||
test('a single syllable is refused for being too short', async ({ page }) => {
|
||||
@@ -125,7 +130,8 @@ test.describe('playing the bot', () => {
|
||||
|
||||
await submitWord(page, ONE_SYLLABLE_WORD);
|
||||
|
||||
await expect(board(page).rejection).toHaveText('Từ phải có ít nhất 2 tiếng.');
|
||||
await expect(board(page).rejection).toContainText('Từ phải có ít nhất 2 tiếng.');
|
||||
await expect(board(page).rejection).toContainText(ONE_SYLLABLE_WORD);
|
||||
});
|
||||
|
||||
test('a real word that does not link names the syllable it should start with', async ({
|
||||
@@ -138,7 +144,8 @@ test.describe('playing the bot', () => {
|
||||
// syllable, so this is a wrong link rather than an unknown word.
|
||||
await submitWord(page, 'toán học');
|
||||
|
||||
await expect(board(page).rejection).toHaveText('Từ phải bắt đầu bằng tiếng “sinh”.');
|
||||
await expect(board(page).rejection).toContainText('Từ phải bắt đầu bằng tiếng “sinh”.');
|
||||
await expect(board(page).rejection).toContainText('toán học');
|
||||
});
|
||||
|
||||
test('a refused word costs the player the attempt, not the turn', async ({ page }) => {
|
||||
@@ -162,8 +169,7 @@ test.describe('playing the bot', () => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible();
|
||||
@@ -175,8 +181,7 @@ test.describe('playing the bot', () => {
|
||||
|
||||
const syllable = (await board(page).syllable.textContent())?.trim() ?? '';
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(page);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Bạn có thể nối' })).toBeVisible();
|
||||
const offered = await page.locator('.suggestions li').allTextContents();
|
||||
@@ -194,8 +199,7 @@ test.describe('playing the bot', () => {
|
||||
const [opening] = await chainWords(page);
|
||||
await playLegalMove(page, new Set([opening]));
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(page);
|
||||
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Tải chuỗi từ' }).click();
|
||||
@@ -217,8 +221,7 @@ test.describe('playing the bot', () => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(page);
|
||||
await expect(page.getByRole('button', { name: 'Chơi lại' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Chơi lại' }).click();
|
||||
@@ -244,8 +247,7 @@ test.describe('playing the bot', () => {
|
||||
await playLegalMove(page, used);
|
||||
await waitForMyTurn(page);
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(page);
|
||||
await expect(page.getByRole('button', { name: 'Về trang chủ' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Về trang chủ' }).click();
|
||||
|
||||
+27
-2
@@ -105,8 +105,7 @@ export function chat(page) {
|
||||
log: page.getByTestId('chat-log'),
|
||||
input: page.getByTestId('chat-input'),
|
||||
send: page.getByTestId('chat-send'),
|
||||
unread: page.getByTestId('chat-unread'),
|
||||
error: page.getByTestId('chat-error')
|
||||
unread: page.getByTestId('chat-unread')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +173,32 @@ export async function setNickname(page, name) {
|
||||
await page.getByLabel('Tên của bạn').fill(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives up the game.
|
||||
*
|
||||
* Two presses of the same button: the confirmation is inline now, because a
|
||||
* native confirm() blocks the frame loop the countdown ring runs on and could
|
||||
* cost the turn it was protecting. The accessible name still contains "Đầu
|
||||
* hàng" in both states, so one locator drives both presses.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
export async function resign(page) {
|
||||
const button = page.getByRole('button', { name: 'Đầu hàng' });
|
||||
await button.click();
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a player out of the room. Two presses, for the same reason as resign.
|
||||
*
|
||||
* @param {import('@playwright/test').Locator} kick
|
||||
*/
|
||||
export async function confirmKick(kick) {
|
||||
await kick.click();
|
||||
await kick.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the words currently in the chain, opening word included.
|
||||
*
|
||||
|
||||
+65
-18
@@ -4,9 +4,11 @@ import {
|
||||
board,
|
||||
chainWords,
|
||||
chat,
|
||||
confirmKick,
|
||||
openMeanings,
|
||||
playLegalMove,
|
||||
readyAndStart,
|
||||
resign,
|
||||
say,
|
||||
seats,
|
||||
setNickname,
|
||||
@@ -50,6 +52,19 @@ async function joinRoom(page, nickname, code) {
|
||||
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins, and waits until the seat actually exists.
|
||||
*
|
||||
* A seat is replayed only the conversation from the moment it joined — that is
|
||||
* the server's rule, not an accident — so a test where somebody speaks has to
|
||||
* know the listener is in the room first. Without this the assertion races the
|
||||
* handshake and passes or fails on how quickly the page hydrates.
|
||||
*/
|
||||
async function joinRoomSeated(page, nickname, code) {
|
||||
await joinRoom(page, nickname, code);
|
||||
await expect(page.getByTestId('ready')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seats a pair and plays them into a game, which is where most tests start.
|
||||
*
|
||||
@@ -114,6 +129,12 @@ test.describe('playing a stranger', () => {
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
|
||||
// A name is all the link needs; it is remembered from the last visit,
|
||||
// which is what makes the link one tap for anybody who has played
|
||||
// before.
|
||||
await guest.goto('/online');
|
||||
await setNickname(guest, 'Lan');
|
||||
|
||||
// No code typed, no button pressed beyond opening the link.
|
||||
await guest.goto(`/online?code=${code}`);
|
||||
|
||||
@@ -127,11 +148,39 @@ test.describe('playing a stranger', () => {
|
||||
await close();
|
||||
});
|
||||
|
||||
test('an invite link asks a nameless guest for a name before seating them', async ({
|
||||
browser
|
||||
}) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
|
||||
// A first-time guest: nothing stored, so the link cannot introduce them.
|
||||
// Seating them anyway made them "Người chơi" on the scoreboard, in the
|
||||
// chat and in the standings, with no way to fix it from inside the room.
|
||||
await guest.goto(`/online?code=${code}`);
|
||||
|
||||
await expect(guest.getByTestId('name-needed')).toBeVisible();
|
||||
await expect(guest.getByTestId('ready')).toHaveCount(0);
|
||||
// Still only the host in the room.
|
||||
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
||||
|
||||
// The code came with the link, so giving a name is the only step left.
|
||||
await setNickname(guest, 'Lan');
|
||||
await guest.getByRole('button', { name: 'Vào phòng' }).click();
|
||||
|
||||
await expect(guest.getByTestId('ready')).toBeVisible();
|
||||
await expect(host.getByTestId('player-count')).toHaveText('2/4 người chơi');
|
||||
await expect(host.getByTestId('scoreboard').getByText('Lan')).toHaveCount(0);
|
||||
await expect(host.locator('.seat').getByText('Lan')).toBeVisible();
|
||||
|
||||
await close();
|
||||
});
|
||||
|
||||
test('resigning ends the game on both sides with the right winner', async ({ browser }) => {
|
||||
const { host, guest, close } = await playingPair(browser);
|
||||
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(host);
|
||||
|
||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
await expect(guest.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
||||
@@ -142,8 +191,7 @@ test.describe('playing a stranger', () => {
|
||||
test('a second game is agreed in the lobby the first one ends in', async ({ browser }) => {
|
||||
const { host, guest, code, close } = await playingPair(browser);
|
||||
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(host);
|
||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
|
||||
// The readiness that started the first game is spent, so the owner
|
||||
@@ -215,8 +263,7 @@ test.describe('playing a stranger', () => {
|
||||
|
||||
await guest.getByTestId('ready').click();
|
||||
await expect(kick).toBeEnabled();
|
||||
host.on('dialog', (dialog) => dialog.accept());
|
||||
await kick.click();
|
||||
await confirmKick(kick);
|
||||
|
||||
await expect(guest.getByTestId('join-error')).toHaveText('Bạn đã bị mời ra khỏi phòng.');
|
||||
await expect(host.getByTestId('player-count')).toHaveText('1/4 người chơi');
|
||||
@@ -258,7 +305,7 @@ test.describe('playing a stranger', () => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await joinRoomSeated(guest, 'Lan', code);
|
||||
|
||||
await say(host, 'chào bạn');
|
||||
// Each side is shown its own words and the other's, with a name on the
|
||||
@@ -299,7 +346,7 @@ test.describe('playing a stranger', () => {
|
||||
});
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await joinRoomSeated(guest, 'Lan', code);
|
||||
|
||||
// A conversation both players have already read.
|
||||
await say(host, 'một');
|
||||
@@ -349,7 +396,7 @@ test.describe('playing a stranger', () => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await joinRoomSeated(guest, 'Lan', code);
|
||||
|
||||
await say(host, '<b>đậm</b>');
|
||||
await expect(chat(guest).log).toContainText('<b>đậm</b>');
|
||||
@@ -373,7 +420,7 @@ test.describe('playing a stranger', () => {
|
||||
await expect(chat(host).send).toBeEnabled();
|
||||
});
|
||||
|
||||
test('a refusal is visible in the lobby, which shows no errors of its own', async ({
|
||||
test('a refusal is shown in the lobby, beside the buttons that produce it', async ({
|
||||
browser
|
||||
}) => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
@@ -381,14 +428,16 @@ test.describe('playing a stranger', () => {
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
|
||||
// Past the burst the server refuses, and the panel is the only surface
|
||||
// in this phase that can say so.
|
||||
// Past the burst the server refuses.
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await say(host, `tin ${i}`);
|
||||
}
|
||||
|
||||
// In the lobby itself. It used to land in the chat panel below it,
|
||||
// which on a phone is under a four-seat list and off the screen — so
|
||||
// "Bắt đầu" being refused looked like "Bắt đầu" being broken.
|
||||
// containText, not haveText: the box carries its own dismiss button.
|
||||
await expect(chat(host).error).toContainText('Bạn thao tác quá nhanh');
|
||||
await expect(host.getByTestId('lobby-error')).toContainText('Bạn thao tác quá nhanh');
|
||||
|
||||
await close();
|
||||
});
|
||||
@@ -397,7 +446,7 @@ test.describe('playing a stranger', () => {
|
||||
const { host, guest, close } = await twoPlayers(browser);
|
||||
|
||||
const code = await createRoom(host, 'Minh');
|
||||
await joinRoom(guest, 'Lan', code);
|
||||
await joinRoomSeated(guest, 'Lan', code);
|
||||
await say(host, 'nhớ nhé');
|
||||
await expect(chat(guest).log).toContainText('nhớ nhé');
|
||||
|
||||
@@ -527,8 +576,7 @@ test.describe('playing a stranger', () => {
|
||||
]);
|
||||
const { lead, waits } = await awaitTurn(host, guest, third);
|
||||
|
||||
lead.on('dialog', (dialog) => dialog.accept());
|
||||
await lead.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(lead);
|
||||
|
||||
// Out, but still in the room: no input, no result screen, and the game
|
||||
// carrying on in front of them.
|
||||
@@ -546,8 +594,7 @@ test.describe('playing a stranger', () => {
|
||||
await expect(next.getByTestId('player-out')).toContainText(names.get(lead));
|
||||
|
||||
// The last two settle it, and everybody sees the same table.
|
||||
next.on('dialog', (dialog) => dialog.accept());
|
||||
await next.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||
await resign(next);
|
||||
|
||||
await expect(last.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
||||
await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||
|
||||
@@ -124,17 +124,31 @@ test.describe('losing the connection', () => {
|
||||
|
||||
await socket.cut({ sustained: true });
|
||||
|
||||
await expect(page.getByText('Mất kết nối, đang thử lại…')).toBeVisible({ timeout: 20_000 });
|
||||
// Two surfaces, deliberately: the badge is the status, and the banner
|
||||
// over the board is the one the player can act on — it carries the
|
||||
// retry that saves waiting out a backoff of up to eight seconds with a
|
||||
// turn timer running.
|
||||
const badge = page.locator('.badge', { hasText: 'Mất kết nối, đang thử lại…' });
|
||||
await expect(badge).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: 'Thử lại' })).toBeVisible();
|
||||
|
||||
// Disabled rather than accepting a word that cannot go anywhere and
|
||||
// leaving the player to watch their turn expire.
|
||||
await expect(board(page).input).toBeDisabled();
|
||||
// The word cannot be sent, but the field itself stays alive: `disabled`
|
||||
// on a focused input blurs it, and a blurred input closes the on-screen
|
||||
// keyboard that nothing can then reopen without a tap. So the send is
|
||||
// what refuses, and the field says whose turn it is instead of inviting
|
||||
// a word it cannot carry.
|
||||
await expect(board(page).submit).toBeDisabled();
|
||||
await expect(board(page).input).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(board(page).input).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Mất kết nối, đang thử lại…'
|
||||
);
|
||||
|
||||
// And it comes back on its own once the connection does.
|
||||
socket.restore();
|
||||
await expect(page.getByText('Đã kết nối')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(board(page).input).toBeEnabled();
|
||||
await expect(board(page).submit).toBeEnabled();
|
||||
await expect(board(page).input).toHaveAttribute('aria-disabled', 'false');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
+97
-2
@@ -11,12 +11,19 @@
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #e9f0ea;
|
||||
--border: #c2d1c8;
|
||||
/* The same edge, drawn where it has to be found rather than merely felt.
|
||||
A card's outline may whisper; the boundary of something you are meant
|
||||
to type into or press has to clear 3:1 against what surrounds it, which
|
||||
--border does not and is not trying to. */
|
||||
--border-strong: #768b7f;
|
||||
--text: #16211b;
|
||||
--text-muted: #5b6a61;
|
||||
/* Word-game green. Dark enough to carry white text and to be read as text
|
||||
itself, which the accent has to be: the clock, a word's points and the
|
||||
connection badge are all drawn in it. */
|
||||
--accent: #15803d;
|
||||
connection badge are all drawn in it. Dark enough, specifically, to stay
|
||||
legible on its own tint — a score sitting on --accent-soft is the
|
||||
smallest accent text in the app and the pair it has to clear. */
|
||||
--accent: #12692f;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #dff2e5;
|
||||
--danger: #a3231c;
|
||||
@@ -35,8 +42,33 @@
|
||||
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-pill: 999px;
|
||||
--font: system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
/* Spacing steps, 4px based. Named so new work picks a step instead of
|
||||
inventing another value between two existing ones. */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* The type ramp, numbered rather than t-shirt sized: the steps are close
|
||||
together and "medium" versus "large" would say nothing useful about
|
||||
0.85rem against 0.9rem. Step 6 is body text and every input, which is
|
||||
1rem because anything smaller makes iOS Safari zoom the page on focus. */
|
||||
--text-1: 0.7rem;
|
||||
--text-2: 0.75rem;
|
||||
--text-3: 0.8rem;
|
||||
--text-4: 0.85rem;
|
||||
--text-5: 0.9rem;
|
||||
--text-6: 1rem;
|
||||
--text-7: 1.1rem;
|
||||
--text-8: 1.4rem;
|
||||
--text-9: 2rem;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@@ -45,6 +77,7 @@
|
||||
--surface: #17201a;
|
||||
--surface-alt: #1f2a23;
|
||||
--border: #3a4a40;
|
||||
--border-strong: #647f70;
|
||||
--text: #e6ece7;
|
||||
--text-muted: #9aab9f;
|
||||
/* Lightened rather than inverted, which is what a dark surface needs from
|
||||
@@ -96,23 +129,85 @@ input {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* One focus ring for the whole app, so a button and the field beside it are
|
||||
* marked the same way. Written with :where() so its specificity is zero and
|
||||
* any component that genuinely needs a different ring — a control whose real
|
||||
* input is visually hidden, say — simply declares one.
|
||||
*/
|
||||
:where(a, button, input, summary, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/*
|
||||
* A 44px target around a glyph that is a fraction of that. The padding is
|
||||
* negative-margined back out, so a dismiss button inside a banner does not
|
||||
* grow the banner it sits in.
|
||||
*/
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -12px -6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: var(--text-7);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Visible only to assistive technology. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Reaches the keyboard before the header does, and only then shows itself. */
|
||||
.skip:not(:focus) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--text-5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
/* Without this an infinite animation restarts a hundred thousand times
|
||||
a frame and resamples its own opacity: a flicker delivered to exactly
|
||||
the people who asked for less motion. */
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -2,7 +2,18 @@
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!--
|
||||
interactive-widget=resizes-content so the on-screen keyboard shortens
|
||||
100dvh instead of merely panning the view over it. The shell is a flex
|
||||
column with the chain as its only flexible child, so a shorter viewport
|
||||
takes height from the chain and leaves the syllable, the clock and the
|
||||
word field on screen — which is the whole point of the layout and does
|
||||
not otherwise survive a keyboard opening.
|
||||
-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="description" content="Trò chơi nối từ tiếng Việt: chơi với máy hoặc đấu 1v1 trực tuyến." />
|
||||
<title>Nối Từ</title>
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
|
||||
@@ -24,11 +24,21 @@
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Compact, because the game screens are bounded by the viewport now and
|
||||
* every pixel here is one the board does not get. Still on every screen,
|
||||
* still legible, still carrying both links: the wordlist is share-alike and
|
||||
* the credit is an obligation, not a decoration.
|
||||
*/
|
||||
.credit {
|
||||
padding: 20px 16px 28px;
|
||||
padding-block: var(--space-3);
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
padding-bottom: max(var(--space-3), env(safe-area-inset-bottom));
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.credit p {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { scrollBehavior } from '$lib/motion.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/** @type {HTMLElement | undefined} */
|
||||
@@ -10,11 +11,20 @@
|
||||
// player has to scroll through.
|
||||
const entries = $derived([...game.state.chain].reverse());
|
||||
|
||||
/** How far from the newest row still counts as watching the newest row. */
|
||||
const FOLLOW_PX = 48;
|
||||
|
||||
// Keep the newest word in view as the chain grows. Reading chain.length in
|
||||
// the effect is what subscribes it to new moves.
|
||||
//
|
||||
// Only for a reader who is already at the top, though: the newest word is
|
||||
// row one, so anybody scrolled past it is reading an older definition, and
|
||||
// yanking them back every time somebody moves is worse than making them
|
||||
// scroll.
|
||||
$effect(() => {
|
||||
game.state.chain.length;
|
||||
list?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (!list || list.scrollTop > FOLLOW_PX) return;
|
||||
list.scrollTo({ top: 0, behavior: scrollBehavior() });
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -23,7 +33,10 @@
|
||||
{#if game.state.chain.length === 0}
|
||||
<p class="empty">{t.chainEmpty}</p>
|
||||
{:else}
|
||||
<ol class="rows" bind:this={list}>
|
||||
<!-- role, because list-style: none takes the list semantics away in
|
||||
Safari with VoiceOver, and "3 trong 24" is most of what the chain
|
||||
tells somebody listening to it. -->
|
||||
<ol class="rows" role="list" bind:this={list}>
|
||||
{#each entries as entry, index}
|
||||
<!-- The panel id comes from the row's place in the chain, not the
|
||||
word, so two rows can never share one. -->
|
||||
@@ -96,10 +109,13 @@
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
font-weight: 600;
|
||||
/* Uppercase Vietnamese stacks a tone mark above a capital, which the
|
||||
inherited 1.5 only just clears. */
|
||||
line-height: 1.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -117,6 +133,11 @@
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* overflow-y sets a flex item's automatic minimum size to 0, and the
|
||||
shell is pinned at exactly 100dvh — so on a short viewport, which is
|
||||
what an open keyboard leaves, the list would shrink to nothing with no
|
||||
page scroll to recover it. A floor makes the page grow instead. */
|
||||
min-height: 4.5rem;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -136,6 +157,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
@@ -165,12 +187,16 @@
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
/* A structural cue rather than only a shadow: --shadow is black at 30-40%
|
||||
over a dark surface, which is nothing at all, and in a two-player game
|
||||
the alternating .mine tint was left doing the marking instead. */
|
||||
.rows > li.latest {
|
||||
border-inline-start: 3px solid var(--accent);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.word {
|
||||
font-size: 1.05rem;
|
||||
font-size: var(--text-6);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -179,7 +205,7 @@
|
||||
padding-left: calc(12px + 1.4em);
|
||||
padding-right: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -196,17 +222,17 @@
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
|
||||
.by {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -220,6 +246,6 @@
|
||||
.corrected {
|
||||
padding: 0 12px 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { scrollBehavior } from '$lib/motion.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
|
||||
/**
|
||||
@@ -15,18 +16,17 @@
|
||||
* and an open log would crowd the board off it.
|
||||
* - `column` says the panel has a column of its own, so the log grows into
|
||||
* the height it is given instead of stopping at a phone's worth.
|
||||
* - `errors` shows the room's refusals here. The lobby renders none of its
|
||||
* own, so this is where they land; the board has its own alert, and two
|
||||
* boxes for one error is worse than none.
|
||||
* The room's refusals are not shown here. They belong beside the button
|
||||
* that produced them, which is where the lobby now draws them: down here
|
||||
* they were below the fold on a phone, and "Bắt đầu" looked broken.
|
||||
*
|
||||
* @type {{
|
||||
* collapsible?: boolean,
|
||||
* column?: boolean,
|
||||
* errors?: boolean,
|
||||
* onsend: (text: string) => void
|
||||
* }}
|
||||
*/
|
||||
let { collapsible = false, column = false, errors = false, onsend } = $props();
|
||||
let { collapsible = false, column = false, onsend } = $props();
|
||||
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let list = $state();
|
||||
@@ -77,7 +77,7 @@
|
||||
messages.length;
|
||||
if (!open || !list) return;
|
||||
const room = list.scrollHeight - list.scrollTop - list.clientHeight;
|
||||
if (room < 80) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' });
|
||||
if (room < 80) list.scrollTo({ top: list.scrollHeight, behavior: scrollBehavior() });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -142,7 +142,13 @@
|
||||
<!-- A log, not a stack of bubbles: every line reads "name: text" in
|
||||
its author's colour, so four people talking stay tellable apart
|
||||
without a shape per speaker. -->
|
||||
<ol bind:this={list} class:column data-testid="chat-log">
|
||||
<ol
|
||||
bind:this={list}
|
||||
class:column
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
data-testid="chat-log"
|
||||
>
|
||||
{#each messages as entry}
|
||||
<li style:color={colourOf(entry.playerId)}>
|
||||
<!-- An author the server cleared belongs to nobody: the seat
|
||||
@@ -156,15 +162,6 @@
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#if errors && game.state.error}
|
||||
<!-- too_fast on a burst, must_unready_first, player_is_ready: the
|
||||
lobby's refusals, which nothing else on that screen shows. -->
|
||||
<p class="error" role="alert" data-testid="chat-error">
|
||||
{game.state.error}
|
||||
<button type="button" onclick={() => game.clearError()} aria-label={t.dismiss}>×</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form class="row" onsubmit={submit}>
|
||||
<!--
|
||||
Uncontrolled on purpose, exactly as the word field is: Vietnamese
|
||||
@@ -211,23 +208,27 @@
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
font-weight: 600;
|
||||
/* Uppercase Vietnamese stacks a tone mark above a capital. */
|
||||
line-height: 1.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--text-1);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -264,7 +265,7 @@
|
||||
gap: 6px;
|
||||
/* Colour is set per line, from the author's seat. Everything else about
|
||||
a line is the same for everybody. */
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--text-6);
|
||||
}
|
||||
|
||||
.author {
|
||||
@@ -281,31 +282,10 @@
|
||||
|
||||
.at {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -315,16 +295,11 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
/* 16px or larger stops iOS Safari zooming the page on focus. */
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
font-size: var(--text-6);
|
||||
}
|
||||
|
||||
.row button {
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
|
||||
.dot {
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
import { fraction, remainingMs, seconds } from '$lib/countdown.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { serverNow } from '$lib/ws/connection.svelte.js';
|
||||
import { Status, connection, serverNow } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
const RADIUS = 34;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
const URGENT_SECONDS = 5;
|
||||
/** The two marks worth speaking. Announcing every second would be unusable. */
|
||||
const SPOKEN_SECONDS = [10, 5];
|
||||
|
||||
let now = $state(serverNow());
|
||||
|
||||
const running = $derived(game.state.phase === 'playing' && game.state.deadlineMs > 0);
|
||||
// Whose clock this is. The server rewrites the deadline on every turn
|
||||
// handover, so without this the ring counts the opponent's time down and
|
||||
// reddens at five seconds — a panic spike, every turn, about nothing the
|
||||
// player can act on.
|
||||
const mine = $derived(running && game.state.myTurn);
|
||||
// A clock drawn over a socket that cannot carry a word is not a clock the
|
||||
// player can beat, and pretending otherwise is worse than saying so.
|
||||
const stalled = $derived(running && connection.status !== Status.OPEN);
|
||||
|
||||
// requestAnimationFrame rather than an interval: the arc is a continuous
|
||||
// value, and a browser that backgrounds the tab stops the loop, which is
|
||||
@@ -29,10 +39,27 @@
|
||||
const remaining = $derived(running ? remainingMs(game.state.deadlineMs, now) : 0);
|
||||
const left = $derived(seconds(remaining));
|
||||
const filled = $derived(running ? fraction(remaining, game.state.turnLimitMs) : 1);
|
||||
const urgent = $derived(running && left <= URGENT_SECONDS);
|
||||
const urgent = $derived(mine && !stalled && left <= URGENT_SECONDS);
|
||||
// Derived from the second rather than from `now`: the string changes once a
|
||||
// second, and computing it inside the markup ran a replace and an
|
||||
// allocation on all sixty frames.
|
||||
const label = $derived(fill(t.secondsLeft, { n: left }));
|
||||
|
||||
// role="timer" is implicitly aria-live="off", which is right — sixty
|
||||
// announcements a second would be unusable — so a screen-reader player is
|
||||
// told nothing at all. This says it twice per turn, on their own turn only.
|
||||
const spoken = $derived(mine && SPOKEN_SECONDS.includes(left) ? left : 0);
|
||||
</script>
|
||||
|
||||
<div class="ring" class:urgent class:idle={!running} role="timer" aria-label={fill(t.secondsLeft, { n: left })}>
|
||||
<div
|
||||
class="ring"
|
||||
class:mine
|
||||
class:urgent
|
||||
class:stalled
|
||||
class:idle={!running}
|
||||
role="timer"
|
||||
aria-label={label}
|
||||
>
|
||||
<svg viewBox="0 0 80 80" aria-hidden="true">
|
||||
<circle class="track" cx="40" cy="40" r={RADIUS} />
|
||||
<circle
|
||||
@@ -47,11 +74,21 @@
|
||||
<span class="value">{running ? left : '–'}</span>
|
||||
</div>
|
||||
|
||||
<p class="sr-only" role="status" aria-live="polite">
|
||||
{spoken ? fill(t.yourTimeLeft, { n: spoken }) : ''}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.ring {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
/* Somebody else's clock: shown, because the turn is timed and that is
|
||||
worth seeing, but not addressed to this player. */
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ring.mine {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -62,7 +99,11 @@
|
||||
/* Between turns there is no clock to show, so the ring reads as waiting
|
||||
rather than as a timer that has run out. */
|
||||
.ring.idle {
|
||||
color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ring.stalled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
svg {
|
||||
@@ -78,8 +119,21 @@
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/*
|
||||
* Green to red is the pair a red-green colour blindness confuses, so
|
||||
* roughly one man in twelve would get no warning at all. The ring thickens
|
||||
* and the number grows with it: both survive greyscale, and neither moves.
|
||||
*/
|
||||
.ring.urgent circle {
|
||||
stroke-width: 9;
|
||||
}
|
||||
|
||||
.ring.urgent .value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.track {
|
||||
stroke: var(--border);
|
||||
stroke: var(--border-strong);
|
||||
}
|
||||
|
||||
.arc {
|
||||
|
||||
@@ -44,21 +44,25 @@
|
||||
legend {
|
||||
padding: 0 0 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
|
||||
/* auto-fit rather than three fixed columns: at 360px each of three got
|
||||
~104px minus padding, and both "Trung bình" and "Kỷ lục: Chưa có"
|
||||
wrapped. Below that they stack instead. */
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
@@ -83,6 +87,6 @@
|
||||
|
||||
.best {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@
|
||||
import WordInput from '$lib/components/WordInput.svelte';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { Status, connection, reconnectNow } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
/**
|
||||
* The board itself, shared by both modes. It renders the store and nothing
|
||||
@@ -22,6 +23,13 @@
|
||||
*/
|
||||
let { modeLabel = '', onsubmit, onresign, gameOver, banner } = $props();
|
||||
|
||||
/** How long an armed resign button waits before it goes back to being safe. */
|
||||
const ARM_MS = 4000;
|
||||
|
||||
let arming = $state(false);
|
||||
/** @type {any} */
|
||||
let armTimer;
|
||||
|
||||
// Whose turn it is, said by name. With four people at the table "the
|
||||
// opponent is thinking" stops naming anybody.
|
||||
const turnLabel = $derived.by(() => {
|
||||
@@ -29,6 +37,31 @@
|
||||
const name = game.nameOf(game.state.turnPlayerId);
|
||||
return name ? fill(t.playerTurn, { name }) : t.opponentTurn;
|
||||
});
|
||||
|
||||
const offline = $derived(connection.status !== Status.OPEN);
|
||||
|
||||
/**
|
||||
* Two presses, in place of a native confirm().
|
||||
*
|
||||
* confirm() blocks the main thread, which stops the countdown's animation
|
||||
* frame loop while the server's deadline keeps running: hesitating over the
|
||||
* dialog can cost the turn it was protecting. This keeps the board on screen
|
||||
* and the clock moving, and disarms itself so a stray tap does not lie in
|
||||
* wait.
|
||||
*/
|
||||
function armOrResign() {
|
||||
if (arming) {
|
||||
clearTimeout(armTimer);
|
||||
arming = false;
|
||||
onresign();
|
||||
return;
|
||||
}
|
||||
arming = true;
|
||||
clearTimeout(armTimer);
|
||||
armTimer = setTimeout(() => (arming = false), ARM_MS);
|
||||
}
|
||||
|
||||
$effect(() => () => clearTimeout(armTimer));
|
||||
</script>
|
||||
|
||||
<section class="board" data-phase={game.state.phase}>
|
||||
@@ -41,10 +74,29 @@
|
||||
|
||||
{#if banner}{@render banner()}{/if}
|
||||
|
||||
<!--
|
||||
Not a live region: the connection badge above already announces the same
|
||||
change, and saying it twice is worse than saying it once. This is the
|
||||
visible half — and the only place the player can do anything about it,
|
||||
since the backoff otherwise runs up to eight seconds while their turn
|
||||
does not wait.
|
||||
-->
|
||||
{#if offline && game.state.phase === 'playing'}
|
||||
<p class="offline">
|
||||
{t.reconnecting}
|
||||
<button type="button" onclick={() => reconnectNow()}>{t.retry}</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if game.state.error}
|
||||
<p class="error" role="alert">
|
||||
{game.state.error}
|
||||
<button type="button" onclick={() => game.clearError()} aria-label={t.dismiss}>×</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
onclick={() => game.clearError()}
|
||||
aria-label={t.dismiss}>×</button
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -54,7 +106,22 @@
|
||||
<div class="turn">
|
||||
<CountdownRing />
|
||||
<div class="prompt">
|
||||
<p class="who" data-testid="turn-indicator">{turnLabel}</p>
|
||||
<!--
|
||||
The turn changing is the one state change the whole game hangs
|
||||
on, and it reached nobody who was not looking at this line: a
|
||||
screen-reader player, or anybody reading the chat beside the
|
||||
board, learned it from the clock forfeiting them.
|
||||
-->
|
||||
<p
|
||||
class="who"
|
||||
class:mine={game.state.myTurn}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
data-testid="turn-indicator"
|
||||
>
|
||||
{turnLabel}
|
||||
</p>
|
||||
<p class="syllable">
|
||||
<span class="label">{t.currentSyllable}</span>
|
||||
<strong data-testid="current-syllable">{game.state.currentSyllable || '…'}</strong>
|
||||
@@ -76,7 +143,9 @@
|
||||
that grows, and a button under it walks off the bottom of the screen
|
||||
exactly as the game gets long enough to want to give up on. -->
|
||||
{#if game.state.phase === 'playing' && !game.iAmOut}
|
||||
<button type="button" class="resign" onclick={onresign}>{t.resign}</button>
|
||||
<button type="button" class="resign" class:arming onclick={armOrResign}>
|
||||
{arming ? t.resignSure : t.resign}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<ChainHistory />
|
||||
@@ -89,25 +158,25 @@
|
||||
flex: 1;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mode {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
|
||||
.turn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
@@ -115,9 +184,17 @@
|
||||
}
|
||||
|
||||
.who {
|
||||
margin: 0 0 2px;
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
|
||||
/* The player's own turn, said loudly enough to catch the eye that is in the
|
||||
chat column beside the board. */
|
||||
.who.mine {
|
||||
color: var(--text);
|
||||
font-size: var(--text-6);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.syllable {
|
||||
@@ -127,46 +204,56 @@
|
||||
}
|
||||
|
||||
.syllable .label {
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
/* The one glyph read every single turn, so it gets the headroom: a stacked
|
||||
Vietnamese tone mark on ệ or ộ rides into the label above it at 1.2. */
|
||||
.syllable strong {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.error {
|
||||
.error,
|
||||
.offline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
|
||||
/* A 44px target on a glyph that is a fraction of that: the padding is
|
||||
negative-margined back out so the banner keeps its height. */
|
||||
.error button {
|
||||
.error {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.offline {
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.offline button {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -12px -6px;
|
||||
border: 0;
|
||||
min-height: 44px;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
margin: -8px 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--text-4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spectating {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
padding: var(--space-3);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
@@ -179,16 +266,23 @@
|
||||
.resign {
|
||||
align-self: flex-end;
|
||||
min-height: 44px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
transition: background-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.resign:hover {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
/* Armed, and saying so: the second press is the one that ends the game. */
|
||||
.resign.arming {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,9 @@
|
||||
*/
|
||||
let { isRecord, onrematch, onhome } = $props();
|
||||
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let panel = $state();
|
||||
|
||||
const result = $derived(game.state.result);
|
||||
const standings = $derived(game.state.standings);
|
||||
// What the position still had when this player lost it. It arrives with
|
||||
@@ -20,6 +23,18 @@
|
||||
// else's position.
|
||||
const elimination = $derived(game.state.elimination);
|
||||
|
||||
// The word field unmounts when the game ends, which drops focus to the top
|
||||
// of the document: a keyboard player tabs through the header and the badge
|
||||
// to reach "Chơi lại", and a screen reader is told nothing at all, because
|
||||
// role="group" is not announced on insertion.
|
||||
//
|
||||
// The panel takes focus, not the rematch button. A player who just pressed
|
||||
// Enter to submit a word may still be holding it, and a focused button
|
||||
// under that key would start the next game before they had read this one.
|
||||
$effect(() => {
|
||||
if (game.state.result) panel?.focus();
|
||||
});
|
||||
|
||||
/** Hands the finished chain to the player as a text file to keep. */
|
||||
function exportHistory() {
|
||||
const at = new Date();
|
||||
@@ -34,14 +49,20 @@
|
||||
</script>
|
||||
|
||||
{#if result}
|
||||
<div class="panel" role="group" aria-label={result.iWon ? t.won : t.lost}>
|
||||
<h2 class:won={result.iWon}>{result.iWon ? t.won : t.lost}</h2>
|
||||
<div
|
||||
class="panel"
|
||||
bind:this={panel}
|
||||
role="group"
|
||||
tabindex="-1"
|
||||
aria-label={result.iWon ? t.won : t.lost}
|
||||
>
|
||||
<h2 class:won={result.iWon} aria-live="polite">{result.iWon ? t.won : t.lost}</h2>
|
||||
|
||||
{#if endReasonMessages[result.reason]}
|
||||
<p class="reason">{endReasonMessages[result.reason]}</p>
|
||||
{/if}
|
||||
|
||||
{#if standings.length > 0}
|
||||
{#if standings.length > 2}
|
||||
<!-- Ranked by who outlasted whom, which is what the game is decided
|
||||
on. The score sits beside the place rather than setting it. -->
|
||||
<ol class="standings" aria-label={t.standingsTitle} data-testid="standings">
|
||||
@@ -94,14 +115,14 @@
|
||||
<p class="record">{t.newRecord}</p>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="export" onclick={exportHistory}>{t.exportHistory}</button>
|
||||
|
||||
<div class="actions">
|
||||
{#if onrematch}
|
||||
<button type="button" class="primary" onclick={onrematch}>{t.rematch}</button>
|
||||
{/if}
|
||||
<button type="button" onclick={onhome}>{t.home}</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="export" onclick={exportHistory}>{t.exportHistory}</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -109,8 +130,8 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
@@ -121,7 +142,7 @@
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 1.4rem;
|
||||
font-size: var(--text-8);
|
||||
}
|
||||
|
||||
h2.won {
|
||||
@@ -147,7 +168,7 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
@@ -176,25 +197,25 @@
|
||||
.points {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
gap: var(--space-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-size: var(--text-8);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -202,8 +223,10 @@
|
||||
.suggestions h3 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
font-weight: 600;
|
||||
/* Uppercase Vietnamese stacks a tone mark above a capital. */
|
||||
line-height: 1.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -219,9 +242,9 @@
|
||||
}
|
||||
|
||||
.suggestions li {
|
||||
padding: 6px 12px;
|
||||
padding: 6px var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -233,20 +256,21 @@
|
||||
|
||||
.record {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
padding: 6px var(--space-3);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.export {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
padding: 10px var(--space-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -257,8 +281,9 @@
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script>
|
||||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||||
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
||||
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { Status, connection } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
/**
|
||||
* The room between games. Everything it renders comes from the last
|
||||
@@ -9,29 +12,81 @@
|
||||
* free to refuse — which is why none of them predict the answer by
|
||||
* changing the screen first.
|
||||
*
|
||||
* `compact` drops the room code panel, for the lobby that appears under a
|
||||
* finished game's result rather than as the whole screen.
|
||||
* `compact` is the lobby that appears under a finished game's result rather
|
||||
* than as the whole screen: the room code shrinks to one line and the board
|
||||
* above already carries the connection state and the away banners.
|
||||
*
|
||||
* The callbacks report whether the request actually reached the server. A
|
||||
* socket that has just dropped answers `false`, and a button that silently
|
||||
* did nothing is the fastest way to make a room look dead.
|
||||
*
|
||||
* @type {{
|
||||
* compact?: boolean,
|
||||
* onready: (ready: boolean) => void,
|
||||
* onstart: () => void,
|
||||
* onkick: (playerId: string) => void,
|
||||
* onready: (ready: boolean) => boolean,
|
||||
* onstart: () => boolean,
|
||||
* onkick: (playerId: string) => boolean,
|
||||
* onleave: () => void
|
||||
* }}
|
||||
*/
|
||||
let { compact = false, onready, onstart, onkick, onleave } = $props();
|
||||
|
||||
/** How long an armed kick waits before it goes back to being safe. */
|
||||
const ARM_MS = 4000;
|
||||
|
||||
const s = $derived(game.state);
|
||||
// The seats nobody is in yet, drawn so a room that is waiting on people
|
||||
// looks like one rather than like a room that is simply small.
|
||||
const empties = $derived(Array.from({ length: game.freeSeats }, (_, i) => i));
|
||||
const shortHanded = $derived(s.roomPlayers.length < s.minPlayers);
|
||||
const offline = $derived(connection.status !== Status.OPEN);
|
||||
// The owner is who everybody else is waiting on, so the hint has to stop
|
||||
// telling a ready guest to keep waiting once the owner has dropped.
|
||||
const ownerAway = $derived(
|
||||
s.roomPlayers.some((/** @type {{ isOwner: boolean, connected: boolean }} */ p) => {
|
||||
return p.isOwner && !p.connected;
|
||||
})
|
||||
);
|
||||
|
||||
/** The seat whose kick button is armed, if any. */
|
||||
let armedKick = $state('');
|
||||
/** @type {any} */
|
||||
let armTimer;
|
||||
// Set when a request could not go out at all, which is a different thing
|
||||
// from the server refusing it — that arrives as game.state.error.
|
||||
let unsent = $state(false);
|
||||
|
||||
/** @param {boolean} sent */
|
||||
function report(sent) {
|
||||
unsent = !sent;
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** @param {string} playerId */
|
||||
function armOrKick(playerId) {
|
||||
if (armedKick === playerId) {
|
||||
clearTimeout(armTimer);
|
||||
armedKick = '';
|
||||
report(onkick(playerId));
|
||||
return;
|
||||
}
|
||||
armedKick = playerId;
|
||||
clearTimeout(armTimer);
|
||||
armTimer = setTimeout(() => (armedKick = ''), ARM_MS);
|
||||
}
|
||||
|
||||
$effect(() => () => clearTimeout(armTimer));
|
||||
</script>
|
||||
|
||||
<section class="lobby" aria-label={t.lobbyTitle}>
|
||||
<!-- The board carries these in the compact case, and two connection badges
|
||||
on one screen say nothing the first one did not. -->
|
||||
{#if !compact}
|
||||
<div class="top">
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
<RoomCodePanel code={s.roomCode} />
|
||||
{:else}
|
||||
<RoomCodePanel code={s.roomCode} compact />
|
||||
{/if}
|
||||
|
||||
<p class="count" data-testid="player-count">
|
||||
@@ -40,7 +95,16 @@
|
||||
|
||||
<ul class="seats">
|
||||
{#each s.roomPlayers as player (player.playerId)}
|
||||
<li class="seat" class:ready={player.isOwner || player.ready} class:me={player.isMe}>
|
||||
<!-- Owner and ready are different facts about a seat, so they are
|
||||
drawn differently: the owner has no readiness to declare, and
|
||||
painting their row as ready made the highlight read as a claim
|
||||
nobody had made. -->
|
||||
<li
|
||||
class="seat"
|
||||
class:ready={player.ready}
|
||||
class:owner={player.isOwner}
|
||||
class:me={player.isMe}
|
||||
>
|
||||
<span class="name">{player.isMe ? s.nickname || t.you : player.name}</span>
|
||||
<span class="role">{player.isOwner ? t.owner : t.guest}</span>
|
||||
|
||||
@@ -63,15 +127,20 @@
|
||||
{/if}
|
||||
|
||||
<!-- Only the owner sees these, and never on their own row: leaving
|
||||
is what an owner who wants out does, and it hands the room on. -->
|
||||
is what an owner who wants out does, and it hands the room on.
|
||||
|
||||
Two presses rather than a confirm() dialog: the native one
|
||||
blocks the frame loop, and this is the same control asking
|
||||
again rather than a second one appearing. -->
|
||||
{#if game.isOwner && !player.isMe}
|
||||
<button
|
||||
type="button"
|
||||
class="kick"
|
||||
class:arming={armedKick === player.playerId}
|
||||
disabled={player.ready}
|
||||
aria-label={t.kickPlayer}
|
||||
aria-label={armedKick === player.playerId ? t.kickSure : t.kickPlayer}
|
||||
data-testid={`kick-${player.playerId}`}
|
||||
onclick={() => onkick(player.playerId)}
|
||||
onclick={() => armOrKick(player.playerId)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -87,8 +156,18 @@
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- The grace countdown for a player who dropped. Rendered here too, not
|
||||
only on the board: a room waits in the lobby as often as it plays in
|
||||
it, and "Mất kết nối" on a row says nothing about how long the seat is
|
||||
held. -->
|
||||
{#if !compact}
|
||||
<PlayerStatus />
|
||||
{/if}
|
||||
|
||||
<p class="hint">
|
||||
{#if game.isOwner && shortHanded}
|
||||
{#if ownerAway}
|
||||
{t.ownerAway}
|
||||
{:else if game.isOwner && shortHanded}
|
||||
{fill(t.ownerNeedsMore, { n: s.minPlayers })}
|
||||
{:else if game.isOwner}
|
||||
{t.ownerStartsHint}
|
||||
@@ -99,14 +178,34 @@
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!--
|
||||
Where the button is, not in the chat panel below the fold. Every refusal
|
||||
the lobby can produce — not_everyone_ready, too_fast, must_unready_first,
|
||||
and the reload-the-page protocol mismatch — used to land on a screen the
|
||||
player had already scrolled past, which made "Bắt đầu" look broken.
|
||||
-->
|
||||
{#if s.error}
|
||||
<p class="error" role="alert" data-testid="lobby-error">
|
||||
{s.error}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
onclick={() => game.clearError()}
|
||||
aria-label={t.dismiss}>×</button
|
||||
>
|
||||
</p>
|
||||
{:else if unsent}
|
||||
<p class="error" role="alert" data-testid="lobby-unsent">{t.reconnecting}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if game.isOwner}
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={!s.canStart}
|
||||
disabled={!s.canStart || offline}
|
||||
data-testid="start-game"
|
||||
onclick={onstart}
|
||||
onclick={() => report(onstart())}
|
||||
>
|
||||
{t.startGame}
|
||||
</button>
|
||||
@@ -115,8 +214,9 @@
|
||||
type="button"
|
||||
class="primary"
|
||||
class:on={game.isReady}
|
||||
disabled={offline}
|
||||
data-testid="ready"
|
||||
onclick={() => onready(!game.isReady)}
|
||||
onclick={() => report(onready(!game.isReady))}
|
||||
>
|
||||
{game.isReady ? t.unready : t.ready}
|
||||
</button>
|
||||
@@ -124,7 +224,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Disabled rather than hidden while ready: the rule is worth seeing, and
|
||||
a button that vanishes reads as a bug. -->
|
||||
a button that vanishes reads as a bug. Not gated on the connection —
|
||||
giving up the seat is something the player can always do locally. -->
|
||||
<button type="button" class="leave" disabled={game.isReady} onclick={onleave}>
|
||||
{t.leaveRoom}
|
||||
</button>
|
||||
@@ -141,17 +242,22 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -162,17 +268,23 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding: var(--space-3) 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Readiness is a tint. Being the owner is a marker down the edge: a fact
|
||||
about the seat, not a state it has entered. */
|
||||
.seat.ready {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.seat.owner {
|
||||
border-inline-start: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.seat.empty {
|
||||
border-style: dashed;
|
||||
background: var(--surface-alt);
|
||||
@@ -188,11 +300,11 @@
|
||||
}
|
||||
|
||||
.role {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
/* One right-hand group, so a row keeps its shape whether or not it has a
|
||||
@@ -200,63 +312,97 @@
|
||||
.right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.wins {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wins strong {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
|
||||
.state.offline {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/*
|
||||
* 36px of drawn button, because a 44px circle in every seat row would add
|
||||
* a quarter of a screen to a four-seat lobby that is already long. The
|
||||
* touch target is the full 44 all the same, expanded out of the flow by a
|
||||
* pseudo-element so the row keeps its height.
|
||||
*/
|
||||
.kick {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: -4px 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
font-size: var(--text-6);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kick::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
}
|
||||
|
||||
.kick:disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.kick.arming {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-weight: 600;
|
||||
@@ -281,12 +427,13 @@
|
||||
|
||||
.leave {
|
||||
align-self: center;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
padding: 10px var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
|
||||
.leave:disabled {
|
||||
@@ -296,7 +443,7 @@
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -34,25 +34,20 @@
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-3) 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
font-size: var(--text-6);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -73,11 +73,11 @@
|
||||
<style>
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
|
||||
/** @type {{ code: string }} */
|
||||
let { code } = $props();
|
||||
/**
|
||||
* The room's code, and the three ways of getting it to somebody else.
|
||||
*
|
||||
* `compact` is the one-line variant for the lobby that sits under a finished
|
||||
* game: the full panel is a screen's worth of vertical space, but that is
|
||||
* exactly the moment a room wants to invite a replacement, so the code and a
|
||||
* copy button still have to be reachable.
|
||||
*
|
||||
* @type {{ code: string, compact?: boolean }}
|
||||
*/
|
||||
let { code, compact = false } = $props();
|
||||
|
||||
/** @type {'' | 'code' | 'link'} */
|
||||
let copied = $state('');
|
||||
let failed = $state(false);
|
||||
/** @type {any} */
|
||||
let clearTimer;
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let codeEl = $state();
|
||||
|
||||
const inviteUrl = $derived(
|
||||
typeof location === 'undefined' ? '' : `${location.origin}/online?code=${code}`
|
||||
@@ -22,15 +34,21 @@
|
||||
* @param {string} text
|
||||
*/
|
||||
async function copy(what, text) {
|
||||
clearTimeout(clearTimer);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = what;
|
||||
clearTimeout(clearTimer);
|
||||
failed = false;
|
||||
clearTimer = setTimeout(() => (copied = ''), 2000);
|
||||
} catch {
|
||||
// Clipboard access is refused outside a secure context and in some
|
||||
// embedded browsers. The code is on screen either way, so there is
|
||||
// nothing to recover — only a confirmation not to show.
|
||||
// Clipboard access is refused outside a secure context, which is
|
||||
// exactly the self-hosted http://<lan-ip> case a single Go binary
|
||||
// invites. Selecting the code is the next best thing — the player can
|
||||
// copy it by hand — and saying so beats a button that does nothing.
|
||||
copied = '';
|
||||
failed = true;
|
||||
if (codeEl) getSelection()?.selectAllChildren(codeEl);
|
||||
clearTimer = setTimeout(() => (failed = false), 6000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,23 +59,51 @@
|
||||
// Includes the user simply dismissing the sheet, which is not an error.
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => () => clearTimeout(clearTimer));
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<p class="label">{t.yourRoomCode}</p>
|
||||
<p class="code" data-testid="room-code" aria-label={code.split('').join(' ')}>{grouped}</p>
|
||||
<div class="panel" class:compact>
|
||||
{#if !compact}
|
||||
<p class="label">{t.roomCodeLabel}</p>
|
||||
{/if}
|
||||
|
||||
<p
|
||||
class="code"
|
||||
bind:this={codeEl}
|
||||
data-testid="room-code"
|
||||
aria-label={code.split('').join(' ')}
|
||||
>
|
||||
{grouped}
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" onclick={() => copy('code', code)}>
|
||||
{copied === 'code' ? t.copied : t.copyCode}
|
||||
</button>
|
||||
<button type="button" onclick={() => copy('link', inviteUrl)}>
|
||||
{copied === 'link' ? t.copied : t.copyLink}
|
||||
</button>
|
||||
{#if typeof navigator !== 'undefined' && 'share' in navigator}
|
||||
<button type="button" onclick={share}>{t.shareLink}</button>
|
||||
{#if !compact}
|
||||
<button type="button" onclick={() => copy('link', inviteUrl)}>
|
||||
{copied === 'link' ? t.copied : t.copyLink}
|
||||
</button>
|
||||
{#if typeof navigator !== 'undefined' && 'share' in navigator}
|
||||
<button type="button" onclick={share}>{t.shareLink}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if failed}
|
||||
<p class="failed" role="alert">{t.copyFailed}</p>
|
||||
{/if}
|
||||
|
||||
<!-- The code is on screen whatever the clipboard does; the link only existed
|
||||
inside a button. Selectable text, so there is always something to share
|
||||
by hand. -->
|
||||
{#if !compact}
|
||||
<p class="link">
|
||||
<span class="link-label">{t.inviteLinkLabel}</span>
|
||||
<span class="url">{inviteUrl}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -66,38 +112,89 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* One line under a finished game: the code, and the one button that matters
|
||||
when somebody has to be invited into the next one. */
|
||||
.panel.compact {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding: 10px var(--space-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
}
|
||||
|
||||
/*
|
||||
* Monospaced, and only here. The alphabet already drops 0/O and 1/I/L, but
|
||||
* 5/S, 2/Z and 8/B still pair off in a proportional face — which matters
|
||||
* when the code is being copied off a screenshot rather than read aloud.
|
||||
*/
|
||||
.code {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-family: ui-monospace, 'SFMono-Regular', 'Cascadia Mono', Menlo, monospace;
|
||||
font-size: var(--text-9);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.panel.compact .code {
|
||||
font-size: var(--text-8);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
padding: 10px var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--text-4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.failed {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
|
||||
.link-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.url {
|
||||
max-width: 100%;
|
||||
/* The player may have to select this by hand, so it wraps rather than
|
||||
being cut off. */
|
||||
overflow-wrap: anywhere;
|
||||
user-select: all;
|
||||
}
|
||||
</style>
|
||||
@@ -29,7 +29,10 @@
|
||||
<span class="who">
|
||||
{player.isMe ? s.nickname || t.you : player.name || t.someone}
|
||||
{#if !player.connected && !player.eliminated}
|
||||
<span class="away" title={t.offline}>⚠</span>
|
||||
<!-- title does not exist on a touch screen and is not reliably
|
||||
announced, so the marker carries the word itself. -->
|
||||
<span class="away" aria-hidden="true">⚠</span>
|
||||
<span class="sr-only">{t.offline}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="score">{player.score}</span>
|
||||
@@ -65,18 +68,27 @@
|
||||
align-items: center;
|
||||
flex: 1 1 0;
|
||||
min-width: 72px;
|
||||
padding: 6px 10px;
|
||||
padding: 6px var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* The active side is whose turn it is, so the board doubles as the turn
|
||||
indicator rather than needing a second one. */
|
||||
indicator rather than needing a second one. Weight as well as colour: a
|
||||
tint and a border are the same shade of grey to anybody who cannot
|
||||
separate this green from the surface behind it. */
|
||||
.side.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.side.active .score {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--accent);
|
||||
text-decoration-thickness: 3px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.side.out {
|
||||
opacity: 0.55;
|
||||
}
|
||||
@@ -88,7 +100,7 @@
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -103,7 +115,7 @@
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 1.4rem;
|
||||
font-size: var(--text-8);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -114,17 +126,17 @@
|
||||
|
||||
.series {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.badge.win {
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
font-size: 1rem;
|
||||
font-size: var(--text-6);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { Status, connection } from '$lib/ws/connection.svelte.js';
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
game.state.phase === 'playing' && game.state.myTurn && connection.status === Status.OPEN
|
||||
);
|
||||
|
||||
// Whose turn it is, said in the placeholder of a field that cannot be sent
|
||||
// from: an inviting "Nhập từ của bạn" over a dead input is how a player
|
||||
// ends up typing into one.
|
||||
const waitingFor = $derived.by(() => {
|
||||
if (enabled) return t.wordInputPlaceholder;
|
||||
if (connection.status !== Status.OPEN) return t.reconnecting;
|
||||
const name = game.nameOf(game.state.turnPlayerId);
|
||||
return name ? fill(t.playerTurn, { name }) : t.opponentTurn;
|
||||
});
|
||||
|
||||
// What the field was last seeded for. Plain lets, not state: they guard the
|
||||
// effect below and must not re-trigger it. The rejection is part of the key
|
||||
// because a refused word empties the field without ending the turn, and the
|
||||
@@ -60,9 +70,14 @@
|
||||
if (!field || (turn === seededTurn && rejection === seededRejection)) return;
|
||||
seededTurn = turn;
|
||||
seededRejection = rejection;
|
||||
// Never over an existing draft: a rejected word is still the player's
|
||||
// text, and replacing it would delete a word they were about to fix.
|
||||
if (field.value || !syllable) return;
|
||||
if (!syllable) return;
|
||||
|
||||
// The field stays editable between turns, so a draft may now be aimed at
|
||||
// a syllable the game has moved past. One that still starts with what is
|
||||
// being asked for is the player's word and is left alone; one that does
|
||||
// not is worse than no draft at all.
|
||||
const draft = field.value.trim();
|
||||
if (draft && draft.toLowerCase().startsWith(syllable.toLowerCase())) return;
|
||||
|
||||
field.value = `${syllable} `;
|
||||
// Caret after the seed, so typing continues the word instead of
|
||||
@@ -87,6 +102,12 @@
|
||||
// Safe here and only here: composition has ended by the time a submit
|
||||
// is delivered, so clearing cannot destroy an accent being formed.
|
||||
field.value = '';
|
||||
// Still inside the gesture that submitted, which is the only context an
|
||||
// on-screen keyboard will reopen for. Tapping "Gửi" moves focus to the
|
||||
// button, and a focus() from the effect above arrives on a WebSocket
|
||||
// message instead — iOS ignores that one and the player pays a dead tap
|
||||
// every turn to get the keyboard back.
|
||||
field.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -97,6 +118,13 @@
|
||||
every keystroke cancels that composition and mangles the accent. The
|
||||
server normalizes the text anyway, so the client has no reason to touch
|
||||
it.
|
||||
|
||||
Never `disabled`: setting it on the focused field blurs it, and a blurred
|
||||
field closes the on-screen keyboard, which nothing can then reopen
|
||||
without a tap. The submit button carries the turn instead, and the guard
|
||||
in handleSubmit is what actually refuses an out-of-turn word. The
|
||||
accessible name stays put while the placeholder changes, so the field is
|
||||
still the same field to anybody listening.
|
||||
-->
|
||||
<input
|
||||
bind:this={field}
|
||||
@@ -107,8 +135,10 @@
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
enterkeyhint="send"
|
||||
disabled={!enabled}
|
||||
placeholder={t.wordInputPlaceholder}
|
||||
aria-disabled={!enabled}
|
||||
aria-invalid={!!game.state.rejection}
|
||||
aria-describedby={game.state.rejection ? 'word-rejection' : undefined}
|
||||
placeholder={waitingFor}
|
||||
aria-label={t.wordInputPlaceholder}
|
||||
oncompositionstart={() => (composing = true)}
|
||||
oncompositionend={() => (composing = false)}
|
||||
@@ -119,39 +149,42 @@
|
||||
</form>
|
||||
|
||||
{#if game.state.rejection}
|
||||
<p class="rejection" role="alert">{game.state.rejection.message}</p>
|
||||
<!-- The word is shown, not only the verdict. A Vietnamese rejection is
|
||||
usually one tone mark out, and the field was cleared when the word went
|
||||
out — so without this the player rebuilds it from memory, under the
|
||||
clock, with nothing to compare against. -->
|
||||
<p class="rejection" id="word-rejection" role="alert">
|
||||
<strong>{game.state.rejection.word}</strong> — {game.state.rejection.message}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
/* 16px or larger stops iOS Safari zooming the page on focus, which on a
|
||||
phone hides half the board behind the keyboard. */
|
||||
font-size: 1rem;
|
||||
font-size: var(--text-6);
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
/* Looks exactly as the disabled field used to. It is only the behaviour
|
||||
that differs: focus, and therefore the keyboard, survives the turn. */
|
||||
input[aria-disabled='true'] {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 14px 20px;
|
||||
padding: 14px var(--space-5);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
@@ -165,11 +198,15 @@
|
||||
}
|
||||
|
||||
.rejection {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 12px;
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
|
||||
.rejection strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
+25
-5
@@ -8,10 +8,20 @@ import { RejectReason, GameEndReason, Difficulty } from '$lib/proto/noitu/v1/gam
|
||||
export const t = {
|
||||
appName: 'Nối Từ',
|
||||
tagline: 'Trò chơi nối từ tiếng Việt',
|
||||
skipToContent: 'Tới nội dung chính',
|
||||
|
||||
// Document titles. One per screen, so the tab strip, the browser history
|
||||
// and a screen reader's page announcement say which screen this is.
|
||||
titleHome: 'Nối Từ — trò chơi nối từ tiếng Việt',
|
||||
titlePlay: 'Chơi với máy · Nối Từ',
|
||||
titleOnline: 'Đấu trực tuyến · Nối Từ',
|
||||
titleRoom: 'Phòng {code} · Nối Từ',
|
||||
|
||||
nicknameLabel: 'Tên của bạn',
|
||||
nicknamePlaceholder: 'Nhập tên hiển thị',
|
||||
nicknameHint: 'Tối đa 20 ký tự. Máy chủ có thể rút gọn tên của bạn.',
|
||||
nicknameHint:
|
||||
'Tối đa 20 ký tự. Máy chủ có thể rút gọn tên của bạn. Để trống sẽ được gọi là “Người chơi”.',
|
||||
nicknameNeeded: 'Nhập tên của bạn trước khi vào phòng.',
|
||||
|
||||
playBot: 'Chơi với máy',
|
||||
playOnline: 'Đấu trực tuyến',
|
||||
@@ -21,7 +31,11 @@ export const t = {
|
||||
home: 'Về trang chủ',
|
||||
rematch: 'Chơi lại',
|
||||
resign: 'Đầu hàng',
|
||||
resignConfirm: 'Bạn chắc chắn muốn đầu hàng?',
|
||||
// Short enough to sit inside the button it arms, and it still contains the
|
||||
// word the first press was labelled with, so the control reads as the same
|
||||
// control asking again rather than as a different one.
|
||||
resignSure: 'Chắc chắn đầu hàng?',
|
||||
retry: 'Thử lại',
|
||||
|
||||
yourTurn: 'Đến lượt bạn',
|
||||
opponentTurn: 'Đối thủ đang suy nghĩ…',
|
||||
@@ -41,10 +55,12 @@ export const t = {
|
||||
correctedFrom: 'Bạn gõ “{typed}”, từ đúng là “{word}”.',
|
||||
|
||||
secondsLeft: '{n} giây',
|
||||
yourTimeLeft: 'Còn {n} giây cho lượt của bạn',
|
||||
connecting: 'Đang kết nối…',
|
||||
connected: 'Đã kết nối',
|
||||
reconnecting: 'Mất kết nối, đang thử lại…',
|
||||
noConnection: 'Không có kết nối',
|
||||
connectStalled: 'Chưa kết nối được máy chủ. Kiểm tra mạng rồi thử lại.',
|
||||
|
||||
won: 'Bạn thắng!',
|
||||
lost: 'Bạn thua.',
|
||||
@@ -73,11 +89,12 @@ export const t = {
|
||||
roomCodePlaceholder: 'VD: K7M2QP',
|
||||
roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.',
|
||||
roomCodeInvalid: 'Mã phòng gồm sáu ký tự.',
|
||||
yourRoomCode: 'Mã phòng của bạn',
|
||||
copyCode: 'Sao chép mã',
|
||||
copyLink: 'Sao chép liên kết mời',
|
||||
shareLink: 'Chia sẻ',
|
||||
copied: 'Đã sao chép',
|
||||
copyFailed: 'Không sao chép được. Hãy chọn và sao chép thủ công.',
|
||||
inviteLinkLabel: 'Liên kết mời',
|
||||
leaveRoom: 'Rời phòng',
|
||||
shareInvite: 'Vào chơi nối từ với tôi',
|
||||
|
||||
@@ -96,18 +113,21 @@ export const t = {
|
||||
guest: 'Khách',
|
||||
playerCount: '{n}/{max} người chơi',
|
||||
ready: 'Sẵn sàng',
|
||||
unready: 'Chưa sẵn sàng',
|
||||
unready: 'Bỏ sẵn sàng',
|
||||
isReady: 'Đã sẵn sàng',
|
||||
notReady: 'Chưa sẵn sàng',
|
||||
startGame: 'Bắt đầu',
|
||||
kickPlayer: 'Mời ra khỏi phòng',
|
||||
kickConfirm: 'Mời người chơi này ra khỏi phòng?',
|
||||
// Contains the label the unarmed button carries, for the same reason
|
||||
// resignSure does.
|
||||
kickSure: 'Chắc chắn mời ra khỏi phòng?',
|
||||
emptySeat: 'Còn trống',
|
||||
offline: 'Mất kết nối',
|
||||
ownerStartsHint: 'Bạn là chủ phòng. Bắt đầu khi mọi người đã sẵn sàng.',
|
||||
ownerNeedsMore: 'Cần ít nhất {n} người mới bắt đầu được. Gửi mã phòng cho bạn bè!',
|
||||
guestReadyHint: 'Bấm sẵn sàng rồi chờ chủ phòng bắt đầu.',
|
||||
waitingForStart: 'Đang chờ chủ phòng bắt đầu…',
|
||||
ownerAway: 'Chủ phòng đang mất kết nối. Chờ một chút hoặc rời phòng.',
|
||||
unreadyToLeave: 'Bỏ sẵn sàng trước khi rời phòng.',
|
||||
|
||||
playerDisconnected: '{name} mất kết nối…',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Scrolling that honours the reduced-motion setting.
|
||||
*
|
||||
* The CSS escape hatch in app.css cannot reach this: an explicit `behavior`
|
||||
* passed to scrollTo beats any `scroll-behavior` rule, so a smooth scroll asked
|
||||
* for in script animates however the reader has configured their system. The
|
||||
* chain scrolls on every single move, which makes it the worst offender.
|
||||
*/
|
||||
|
||||
/** @returns {boolean} */
|
||||
export function prefersReducedMotion() {
|
||||
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** @returns {ScrollBehavior} */
|
||||
export function scrollBehavior() {
|
||||
return prefersReducedMotion() ? 'auto' : 'smooth';
|
||||
}
|
||||
@@ -313,6 +313,29 @@ export function createClient({
|
||||
serverNow: () => now() + clockOffsetMs,
|
||||
clockOffset: () => clockOffsetMs,
|
||||
status: () => status,
|
||||
/**
|
||||
* Tries again now rather than waiting out the backoff.
|
||||
*
|
||||
* The delay is capped at eight seconds and jittered, which is right for
|
||||
* a client nobody is watching and wrong for a player who is back on the
|
||||
* train's wifi and can see their turn running out. The attempt counter
|
||||
* is reset too: the player asking is new information about the network,
|
||||
* so the schedule starts over rather than continuing to assume the worst.
|
||||
*
|
||||
* @returns {boolean} whether an attempt was actually started
|
||||
*/
|
||||
reconnectNow() {
|
||||
// A handshake the server refused outright would be refused again,
|
||||
// and a socket that already exists is either open or on its way.
|
||||
if (stopReconnecting || socket) return false;
|
||||
if (reconnectTimer !== null) {
|
||||
cancel(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
attempt = 0;
|
||||
open();
|
||||
return true;
|
||||
},
|
||||
/** Deliberate teardown: no reconnect follows. */
|
||||
close() {
|
||||
stopReconnecting = true;
|
||||
|
||||
@@ -41,6 +41,19 @@ export function send(msg) {
|
||||
return client?.send(msg) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the connection immediately instead of waiting out the backoff.
|
||||
*
|
||||
* For the player looking at a "mất kết nối" banner with a turn timer running:
|
||||
* the schedule is tuned for a client nobody is watching, and this is the case
|
||||
* where somebody is.
|
||||
*
|
||||
* @returns {boolean} whether an attempt was actually started
|
||||
*/
|
||||
export function reconnectNow() {
|
||||
return client?.reconnectNow() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's clock as this client estimates it. The countdown is drawn
|
||||
* against this rather than Date.now(), so a device with a wrong clock still
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
</script>
|
||||
|
||||
<div class="shell" class:wide>
|
||||
<a class="skip" href="#main">{t.skipToContent}</a>
|
||||
|
||||
<header>
|
||||
<a class="brand" href="/">{t.appName}</a>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<main id="main">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -28,11 +30,21 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* A definite height, not a minimum.
|
||||
*
|
||||
* The board is built to keep the syllable, the clock and the word field
|
||||
* still while the chain grows inside its own scroller — but a flex item
|
||||
* only shrinks when its container has a height to shrink against, and
|
||||
* min-height let the whole column grow instead. So the chain kept its full
|
||||
* height, the page got taller with every turn, and the things that were
|
||||
* supposed to stay put walked off the bottom of the screen.
|
||||
*/
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -41,26 +53,39 @@
|
||||
max-width: 1040px;
|
||||
}
|
||||
|
||||
/* viewport-fit=cover is opted into for the notch, which means the gutters
|
||||
have to clear it themselves: 16px normally, more where the cutout or a
|
||||
rounded corner eats into the edge. */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
padding-block: 14px;
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
font-size: var(--text-7);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The last resort, and only that. Bounded height means the chain gives up
|
||||
* its space first; this catches whatever still does not fit — a long lobby,
|
||||
* a game-over panel with standings and suggestions on a short screen — so
|
||||
* nothing is ever clipped out of reach.
|
||||
*/
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 16px;
|
||||
overflow-y: auto;
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
}
|
||||
</style>
|
||||
@@ -12,8 +12,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.titleHome}</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="home">
|
||||
<p class="tagline">{t.tagline}</p>
|
||||
<!-- The screen's heading, so the document does not start at h2 and a
|
||||
screen reader has something to land on. -->
|
||||
<h1 class="tagline">{t.tagline}</h1>
|
||||
|
||||
<NicknameInput />
|
||||
<DifficultyPicker bind:value={difficulty} />
|
||||
@@ -28,13 +34,17 @@
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-top: 12px;
|
||||
gap: var(--space-5);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* A heading by role, a tagline by weight: it introduces the game rather
|
||||
than titling a document the header already names. */
|
||||
.tagline {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-6);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -44,8 +54,9 @@
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
min-height: 44px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||||
import GameBoard from '$lib/components/GameBoard.svelte';
|
||||
import GameOverPanel from '$lib/components/GameOverPanel.svelte';
|
||||
import ChatPanel from '$lib/components/ChatPanel.svelte';
|
||||
import Lobby from '$lib/components/Lobby.svelte';
|
||||
import NicknameInput from '$lib/components/NicknameInput.svelte';
|
||||
import PlayerStatus from '$lib/components/PlayerStatus.svelte';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js';
|
||||
import { game } from '$lib/stores/game.svelte.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
import {
|
||||
createRoom,
|
||||
joinRoom,
|
||||
@@ -41,6 +43,14 @@
|
||||
*/
|
||||
let pending = $state(null);
|
||||
|
||||
/**
|
||||
* How long a held request waits before the screen stops saying "connecting"
|
||||
* and starts saying something the player can act on. The backoff is capped
|
||||
* at eight seconds and never gives up, so without this the screen would
|
||||
* claim to be connecting for as long as the player was willing to watch it.
|
||||
*/
|
||||
const STALL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Where the two-column layout starts. The media queries in the styles
|
||||
* below are the same decision expressed in CSS, so the two must agree: the
|
||||
@@ -69,16 +79,27 @@
|
||||
// True while the only reason this screen has a socket is to reclaim a game
|
||||
// it might no longer be able to reclaim.
|
||||
let resuming = $state(false);
|
||||
// An invite link arrived before this player had a name. Asking is one extra
|
||||
// tap, and the alternative is being seated as "Người chơi" with no way to
|
||||
// fix it from inside the room.
|
||||
let needName = $state(false);
|
||||
let stalled = $state(false);
|
||||
|
||||
const inviteCode = $derived(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
|
||||
const playing = $derived(game.state.phase === 'playing' || game.state.phase === 'over');
|
||||
// A seat in a room, whichever phase it is in. Both are the same layout —
|
||||
// the game or the lobby on one side, the conversation on the other.
|
||||
const inRoom = $derived(playing || game.state.phase === 'lobby');
|
||||
const named = $derived(settings.state.nickname.trim().length > 0);
|
||||
|
||||
// The resume worked, so nothing that happens from here is its fault.
|
||||
// The resume worked, so nothing that happens from here is its fault — and
|
||||
// an invite code held behind it has been answered by arriving in a room.
|
||||
$effect(() => {
|
||||
if (playing || game.state.phase === 'lobby') untrack(() => (resuming = false));
|
||||
if (inRoom)
|
||||
untrack(() => {
|
||||
resuming = false;
|
||||
pending = null;
|
||||
});
|
||||
});
|
||||
|
||||
// Owns the socket while this screen is on, exactly as the bot screen does.
|
||||
@@ -96,14 +117,28 @@
|
||||
// screen, and Hello carries it once — a socket opened on arrival
|
||||
// would introduce the player under whatever name was stored before
|
||||
// they got here.
|
||||
if (isRoomCode(code)) {
|
||||
request({ kind: 'join', code });
|
||||
} else if (hasStoredSession()) {
|
||||
if (hasStoredSession()) {
|
||||
// This tab was already in a game. Reconnecting restores it, which
|
||||
// is what a player who refreshed mid-game is expecting; the
|
||||
// nickname is already settled, so there is nothing to wait for.
|
||||
//
|
||||
// The resume comes first even when the URL carries a code: Hello
|
||||
// takes the token with it either way, so joining as well would
|
||||
// have the room refuse a second seat to somebody it had just
|
||||
// given their old one back to — a red banner on a board that had
|
||||
// in fact been restored correctly. The code is held instead, and
|
||||
// only spent if the resume is refused.
|
||||
resuming = true;
|
||||
if (isRoomCode(code)) pending = { kind: 'join', code };
|
||||
connect();
|
||||
} else if (isRoomCode(code)) {
|
||||
if (named) {
|
||||
request({ kind: 'join', code });
|
||||
} else {
|
||||
// Held, not sent. The name field is already on this screen and
|
||||
// the code is already in its field, so this is one button.
|
||||
needName = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,9 +157,24 @@
|
||||
untrack(() => flush(open));
|
||||
});
|
||||
|
||||
// A request that has been waiting on a socket for longer than a player will
|
||||
// believe. Timed from the request rather than from the status, because a
|
||||
// backoff cycles between "reconnecting" and "connecting" indefinitely and
|
||||
// neither of them is news.
|
||||
$effect(() => {
|
||||
const waiting = !!pending && connection.status !== Status.OPEN;
|
||||
if (!waiting) {
|
||||
stalled = false;
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => (stalled = true), STALL_MS);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
// A resume that the server cannot honour is not something the player did.
|
||||
// Reporting it would open the lobby with a red banner about a game they
|
||||
// have already left behind, so the token is dropped quietly instead.
|
||||
// have already left behind, so the token is dropped quietly instead — and
|
||||
// an invite code held behind the resume is spent now.
|
||||
$effect(() => {
|
||||
const failed = resuming && !!game.state.error;
|
||||
untrack(() => {
|
||||
@@ -132,6 +182,13 @@
|
||||
resuming = false;
|
||||
game.clearError();
|
||||
forgetSession();
|
||||
if (pending && !named) {
|
||||
// The link was for somebody who has still not given a name.
|
||||
needName = true;
|
||||
pending = null;
|
||||
return;
|
||||
}
|
||||
flush(connection.status === Status.OPEN);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +196,8 @@
|
||||
function request(req) {
|
||||
codeError = '';
|
||||
resuming = false;
|
||||
needName = false;
|
||||
stalled = false;
|
||||
game.clearError();
|
||||
pending = req;
|
||||
// The handshake carries the nickname as it stands now, which is why the
|
||||
@@ -149,7 +208,9 @@
|
||||
|
||||
/** @param {boolean} isOpen */
|
||||
function flush(isOpen) {
|
||||
if (!pending || !isOpen) return;
|
||||
// Nothing goes out while a resume is in flight: the seat this tab is
|
||||
// reclaiming may be in the very room the held code names.
|
||||
if (!pending || !isOpen || resuming) return;
|
||||
// Cleared only once the socket has taken it, so a request made during a
|
||||
// reconnect is carried by the next open connection rather than lost.
|
||||
const sent = pending.kind === 'create' ? send(createRoom()) : send(joinRoom(pending.code));
|
||||
@@ -162,9 +223,17 @@
|
||||
codeError = t.roomCodeInvalid;
|
||||
return;
|
||||
}
|
||||
if (!named) {
|
||||
needName = true;
|
||||
return;
|
||||
}
|
||||
request({ kind: 'join', code });
|
||||
}
|
||||
|
||||
function create() {
|
||||
request({ kind: 'create' });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
goto('/');
|
||||
}
|
||||
@@ -177,19 +246,29 @@
|
||||
* who is no longer in the room to be told about, and the button is only
|
||||
* enabled when this client already knows the rule allows it.
|
||||
*
|
||||
* Each of these reports whether the request actually reached the server, so
|
||||
* the lobby can say so rather than looking like a button that does nothing.
|
||||
*
|
||||
* @param {boolean} ready
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function ready(ready) {
|
||||
send(setReady(ready));
|
||||
return send(setReady(ready));
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function start() {
|
||||
send(startGame());
|
||||
return send(startGame());
|
||||
}
|
||||
|
||||
/** @param {string} playerId */
|
||||
/**
|
||||
* @param {string} playerId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function kick(playerId) {
|
||||
if (confirm(t.kickConfirm)) send(kickPlayer(playerId));
|
||||
// The lobby arms this with a second press of the same button; a native
|
||||
// confirm() would block the frame loop the countdown runs on.
|
||||
return send(kickPlayer(playerId));
|
||||
}
|
||||
|
||||
function leave() {
|
||||
@@ -212,12 +291,22 @@
|
||||
}
|
||||
|
||||
function giveUp() {
|
||||
if (confirm(t.resignConfirm)) send(resign());
|
||||
// Armed by the board with a second press, for the same reason as kick.
|
||||
send(resign());
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="online" class:room={inRoom}>
|
||||
<svelte:head>
|
||||
<title>{inRoom && game.state.roomCode ? fill(t.titleRoom, { code: game.state.roomCode }) : t.titleOnline}</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="online" class:room={inRoom} class:in-game={playing}>
|
||||
{#if inRoom}
|
||||
<!-- The screen's heading, so the document does not start at h2 once the
|
||||
join form's h1 is gone. Not the room code: that is on screen and
|
||||
already spelled out letter by letter for a screen reader. -->
|
||||
<h1 class="sr-only">{t.onlineTitle}</h1>
|
||||
|
||||
<!-- Two columns where there is room for them: the game on one side and
|
||||
the conversation on the other, so neither has to be scrolled past
|
||||
to reach the other. One column, game first, where there is not.
|
||||
@@ -245,26 +334,46 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- errors are not routed here any more: the lobby draws its own, beside
|
||||
the button that produced them.
|
||||
|
||||
Folded only during a game on a narrow screen. In the lobby the log
|
||||
stays open: waiting in a room is mostly what the conversation is
|
||||
for, and the pane below keeps it on screen now. -->
|
||||
<div class="pane talk">
|
||||
<ChatPanel
|
||||
collapsible={playing && !wide}
|
||||
column={wide}
|
||||
errors={!playing}
|
||||
onsend={say}
|
||||
/>
|
||||
<ChatPanel collapsible={playing && !wide} column={wide} onsend={say} />
|
||||
</div>
|
||||
{:else}
|
||||
<h1>{t.onlineTitle}</h1>
|
||||
<p class="intro">{t.onlineIntro}</p>
|
||||
|
||||
<!-- The one screen where creating and joining happen, and until now the
|
||||
only one with no connection state on it at all: a server that was
|
||||
down left an enabled button and a screen that never changed. -->
|
||||
<div class="status">
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
|
||||
<NicknameInput />
|
||||
|
||||
{#if needName}
|
||||
<p class="notice" role="alert" data-testid="name-needed">{t.nicknameNeeded}</p>
|
||||
{/if}
|
||||
|
||||
{#if game.state.error}
|
||||
<p class="error" role="alert" data-testid="join-error">{game.state.error}</p>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="primary" onclick={() => request({ kind: 'create' })}>
|
||||
{t.createRoom}
|
||||
{#if stalled}
|
||||
<p class="error" role="alert" data-testid="connect-stalled">{t.connectStalled}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Disabled while a request is in flight. Every impatient tap used to
|
||||
send a real CreateRoom, and the fifth one came back as "you are
|
||||
creating rooms too quickly" to a player who thought they had tapped
|
||||
nothing at all. -->
|
||||
<button type="button" class="primary" disabled={!!pending} onclick={create}>
|
||||
{pending?.kind === 'create' ? t.connecting : t.createRoom}
|
||||
</button>
|
||||
|
||||
<form
|
||||
@@ -288,7 +397,9 @@
|
||||
bind:value={codeInput}
|
||||
oninput={() => (codeError = '')}
|
||||
/>
|
||||
<button type="submit">{t.joinRoom}</button>
|
||||
<button type="submit" disabled={!!pending}>
|
||||
{pending?.kind === 'join' ? t.connecting : t.joinRoom}
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint" class:invalid={codeError}>{codeError || t.roomCodeHint}</p>
|
||||
</form>
|
||||
@@ -302,15 +413,23 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
gap: var(--space-4);
|
||||
min-height: 0;
|
||||
padding-top: 12px;
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* Joining is a form, not a room: it keeps a form's width whatever the
|
||||
screen the two columns were widened for. */
|
||||
screen the two columns were widened for, and sits in the middle of it
|
||||
rather than against the left edge of a 1040px shell. */
|
||||
.online:not(.room) {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pane {
|
||||
@@ -320,13 +439,31 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Stacked, the game takes the height that is going and its chain scrolls
|
||||
* inside itself — which is what keeps the conversation on screen. Without
|
||||
* this the chain grew the page one row per turn and pushed the only way
|
||||
* into the chat below the fold exactly as the game got long enough to talk
|
||||
* about, and a four-seat lobby did the same thing with its seat list.
|
||||
*
|
||||
* The lobby has no scroller of its own, so it is given one here. The board
|
||||
* does not want one: the chain is the part that grows and it already
|
||||
* scrolls, and a second scroller around it would move the word field.
|
||||
*/
|
||||
.pane.game {
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
gap: var(--space-4);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.online.room:not(.in-game) .pane.game {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Stacked: a divider does the work the second column's whitespace does. */
|
||||
.pane.talk {
|
||||
padding-top: 12px;
|
||||
flex: none;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -336,7 +473,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 320px);
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* Each column scrolls on its own, so a long chain does not push the
|
||||
@@ -346,15 +483,9 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Stacked, the game is as tall as it is and the conversation follows
|
||||
it directly. Given a column, it takes the height of one. */
|
||||
.pane.game {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pane.talk {
|
||||
padding-top: 0;
|
||||
padding-left: 24px;
|
||||
padding-left: var(--space-6);
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
@@ -372,6 +503,7 @@
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-height: 44px;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -380,6 +512,11 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.join {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -388,61 +525,70 @@
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-3) 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
font-size: 1rem;
|
||||
font-size: var(--text-6);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: 12px 18px;
|
||||
border: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row button:disabled {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--text-3);
|
||||
}
|
||||
|
||||
.hint.invalid {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error {
|
||||
.error,
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
padding: 10px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background: var(--surface-alt);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.back {
|
||||
align-self: flex-start;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-5);
|
||||
}
|
||||
</style>
|
||||
@@ -87,10 +87,20 @@
|
||||
}
|
||||
|
||||
function giveUp() {
|
||||
if (confirm(t.resignConfirm)) send(resign());
|
||||
// The board arms this with a second press of the same button. A native
|
||||
// confirm() blocks the main thread, which stops the countdown's frame
|
||||
// loop while the server's deadline keeps running — hesitating over the
|
||||
// dialog could cost the turn it was meant to protect.
|
||||
send(resign());
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.titlePlay}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="sr-only">{t.titlePlay}</h1>
|
||||
|
||||
<GameBoard modeLabel={difficultyLabels[difficulty]} onsubmit={play} onresign={giveUp}>
|
||||
{#snippet gameOver()}
|
||||
<!-- A bot always plays again, so there is nothing to negotiate: the
|
||||
|
||||
@@ -424,6 +424,85 @@ describe('a handshake the server refuses', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnecting on demand', () => {
|
||||
// The backoff is right for a client nobody is watching and wrong for a
|
||||
// player staring at a turn timer over a dead socket, so the UI can ask for
|
||||
// an attempt now. What it must not do is open a second socket, or retry a
|
||||
// handshake the server has already refused outright.
|
||||
|
||||
/** @param {any} h */
|
||||
function dropAfterOpen(h) {
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
h.last().drop();
|
||||
}
|
||||
|
||||
it('opens a socket immediately instead of waiting out the delay', () => {
|
||||
const h = setup();
|
||||
dropAfterOpen(h);
|
||||
const waiting = h.sockets.length;
|
||||
|
||||
expect(h.client.reconnectNow()).toBe(true);
|
||||
expect(h.sockets.length).toBe(waiting + 1);
|
||||
});
|
||||
|
||||
it('cancels the pending attempt, so the retry does not become two sockets', () => {
|
||||
const h = setup();
|
||||
dropAfterOpen(h);
|
||||
|
||||
h.client.reconnectNow();
|
||||
const afterRetry = h.sockets.length;
|
||||
// Whatever was scheduled has been cancelled, so flushing the clock
|
||||
// finds nothing left to fire.
|
||||
h.timers.flush();
|
||||
|
||||
expect(h.sockets.length).toBe(afterRetry);
|
||||
});
|
||||
|
||||
it('starts the schedule over, because the player asking is news about the network', () => {
|
||||
const h = setup();
|
||||
dropAfterOpen(h);
|
||||
h.timers.flush();
|
||||
h.last().drop();
|
||||
|
||||
// Two failures in, the next wait would be the second step of the ramp.
|
||||
h.client.reconnectNow();
|
||||
h.last().drop();
|
||||
|
||||
expect(h.reconnectDelays().at(-1)).toBe(BACKOFF_MS[0]);
|
||||
});
|
||||
|
||||
it('refuses while a socket already exists, rather than opening a rival', () => {
|
||||
const h = setup();
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
|
||||
expect(h.client.reconnectNow()).toBe(false);
|
||||
expect(h.sockets.length).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses after a handshake the server rejected outright', () => {
|
||||
const h = setup();
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
h.last().deliver(serverMsg('error', { code: 'protocol_version_mismatch', message: '' }));
|
||||
h.last().drop();
|
||||
const before = h.sockets.length;
|
||||
|
||||
expect(h.client.reconnectNow()).toBe(false);
|
||||
expect(h.sockets.length).toBe(before);
|
||||
});
|
||||
|
||||
it('refuses after a deliberate close, which is not something to come back from', () => {
|
||||
const h = setup();
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
h.client.close();
|
||||
|
||||
expect(h.client.reconnectNow()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backoff and the handshake', () => {
|
||||
it('resets on Welcome, not merely on the socket opening', () => {
|
||||
// A server that accepts the connection and then rejects the handshake
|
||||
|
||||
Reference in new issue
Block a user