mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-20 00:15:39 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user