feat(web): teach the chain rule on landing and remember the last difficulty

A newcomer used to reach the first running clock never having been told what
nối từ requires; the landing screen now states the rule with a worked example
before the nickname field, and the chain's own opening row gets a caption
explaining why it starts the way it does. The difficulty ladder's record line
falls back to a truthful one-line description of what the bot actually does
at that rung instead of "chưa có", and the rung a player picks now survives
across visits in the settings store, so a bot game reached from elsewhere
(the quick-match nudge, next commit) can resume it.
This commit is contained in:
tiennm99 committed 2026-09-21 16:59:02 +07:00
1 parent d94c2a105c
commit 81d99bb0cf
4 files changed
+101 -30

No files matched your search

+33 -18
View File
@@ -99,24 +99,29 @@
{/if}
</span>
</button>
{#if entry.byMe && entry.typed && entry.typed !== entry.word}
{#if entry.opening}
<!-- A newcomer sees one grey word with nobody credited for it and
no reason given why it starts the chain. -->
<span class="corrected">{t.openingCaption}</span>
{:else if entry.byMe && entry.typed && entry.typed !== entry.word}
<!-- The server accepted a different spelling from the one typed.
Saying so beats silently rewriting the player's word. -->
<span class="corrected">
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
</span>
{/if}
{#if entry.parts.length}
{#if entry.parts.length && (index === 0 || open)}
<!-- Why the word scored what it did, not only that it did: the
client has no wordlist to re-derive this from, so the terms
travel with the total. -->
<p class="parts">
{entry.parts
.map((/** @type {import('$lib/stores/game.svelte.js').PointPart} */ p) =>
`+${p.value} ${pointKindLabels[p.kind] ?? ''}`
)
.join(' · ')}
</p>
travel with the total. Shown only on the newest row and on
one whose meaning panel is open — every row, in a 9rem list,
used to turn three rows of history into three rows of
arithmetic. -->
<ul class="parts" aria-label={t.pointsBreakdown}>
{#each entry.parts as p, partIndex (partIndex)}
<li>+{p.value} <span>{pointKindLabels[p.kind] ?? ''}</span></li>
{/each}
</ul>
{/if}
{#if open}
<!-- Plain text from the server, rendered as text: the builder
@@ -155,7 +160,7 @@
h2 {
margin: 0 0 var(--space-2);
color: var(--text-muted);
font-size: var(--text-4);
font-size: var(--text-2);
font-weight: 600;
/* Uppercase Vietnamese stacks a tone mark above a capital, which the
inherited 1.5 only just clears. */
@@ -174,7 +179,7 @@
.rows {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--space-2);
margin: 0;
padding: 0;
/* overflow-y sets a flex item's automatic minimum size to 0, and the
@@ -266,7 +271,7 @@
}
.word {
font-size: var(--text-6);
font-size: var(--text-3);
font-weight: 600;
}
@@ -275,7 +280,7 @@
padding-left: calc(12px + 1.4em);
padding-right: 12px;
color: var(--text-muted);
font-size: var(--text-4);
font-size: var(--text-2);
line-height: 1.4;
}
@@ -292,12 +297,12 @@
display: inline-flex;
gap: 8px;
margin-left: auto;
font-size: var(--text-3);
font-size: var(--text-2);
}
.by {
color: var(--text-muted);
font-size: var(--text-3);
font-size: var(--text-2);
}
.badge {
@@ -316,14 +321,24 @@
.corrected {
padding: 0 12px 8px;
color: var(--text-muted);
font-size: var(--text-3);
font-size: var(--text-2);
}
.parts {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin: 0;
padding: 0 12px 8px;
list-style: none;
}
.parts li {
padding: 0 var(--space-2);
border-radius: var(--radius-pill);
background: var(--surface-alt);
color: var(--text-muted);
font-size: var(--text-2);
font-size: var(--text-1);
font-variant-numeric: tabular-nums;
}
</style>
+10 -5
View File
@@ -1,5 +1,5 @@
<script>
import { difficultyLabels, difficultyOrder, t } from '$lib/i18n/vi.js';
import { difficultyHints, difficultyLabels, difficultyOrder, t } from '$lib/i18n/vi.js';
import { settings } from '$lib/stores/settings.svelte.js';
/** @type {{ value: number, onselect?: (difficulty: number) => void }} */
@@ -28,7 +28,12 @@
}}
/>
<span class="name">{difficultyLabels[difficulty]}</span>
<span class="best">{t.bestScore}: {best > 0 ? best : t.noBestScore}</span>
<!-- What the rung does beats "chưa có" once there is no record to
show instead: a first-time player has nothing true to read in a
record line, but what the bot does at this rung always is. -->
<span class="best">
{best > 0 ? `${t.bestScore}: ${best}` : difficultyHints[difficulty]}
</span>
</label>
{/each}
</div>
@@ -42,9 +47,9 @@
}
legend {
padding: 0 0 6px;
padding: 0 0 var(--space-2);
font-weight: 600;
font-size: var(--text-5);
font-size: var(--text-2);
}
/* auto-fit rather than three fixed columns: at 360px each of three got
@@ -87,6 +92,6 @@
.best {
color: var(--text-muted);
font-size: var(--text-2);
font-size: var(--text-1);
}
</style>
+29 -1
View File
@@ -11,6 +11,7 @@
const NICKNAME_KEY = 'noitu.nickname';
const THEME_KEY = 'noitu.theme';
const BEST_KEY = 'noitu.bestScores';
const DIFFICULTY_KEY = 'noitu.lastDifficulty';
/** Mirrors the server's own cap so the input cannot promise a name it will lose. */
export const MAX_NICKNAME_LENGTH = 20;
@@ -82,6 +83,20 @@ function normalizeTheme(value) {
return value === 'dark' ? 'dark' : 'light';
}
/**
* The bot difficulty last picked, from the landing screen or the quick-match
* queue's own nudge. `null` when nothing has been picked yet in this browser
* — a caller falls back to its own default rather than this store guessing
* one on its behalf.
* @returns {number | null}
*/
function initialDifficulty() {
const raw = read(DIFFICULTY_KEY, '');
if (raw === '') return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
/** Reads the theme the inline script in app.html already applied, if any. */
function initialTheme() {
const saved = read(THEME_KEY, '');
@@ -99,7 +114,9 @@ export function createSettingsStore() {
/** @type {'light' | 'dark'} */
theme: initialTheme(),
/** @type {Record<string, number>} */
bestScores: readBestScores()
bestScores: readBestScores(),
/** @type {number | null} */
lastDifficulty: initialDifficulty()
});
return {
@@ -128,6 +145,17 @@ export function createSettingsStore() {
this.setTheme(state.theme === 'dark' ? 'light' : 'dark');
},
/**
* Remembers the ladder rung the player last picked, so a quick-match
* wait offering the bot as a nudge sends them to the rung they already
* chose rather than back to the ladder's default.
* @param {number} difficulty - a Difficulty enum value
*/
setLastDifficulty(difficulty) {
state.lastDifficulty = difficulty;
write(DIFFICULTY_KEY, String(difficulty));
},
/**
* @param {number|string} difficulty - a Difficulty enum value
* @returns {number}
+29 -6
View File
@@ -3,9 +3,17 @@
import DifficultyPicker from '$lib/components/DifficultyPicker.svelte';
import NicknameInput from '$lib/components/NicknameInput.svelte';
import { Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
import { t } from '$lib/i18n/vi.js';
import { fill, t } from '$lib/i18n/vi.js';
import { settings } from '$lib/stores/settings.svelte.js';
let difficulty = $state(Difficulty.MEDIUM);
// Resumes the rung last played rather than always opening on Medium, so
// choosing a difficulty is not a step repeated every visit.
let difficulty = $state(settings.state.lastDifficulty ?? Difficulty.MEDIUM);
/** @param {number} value */
function selectDifficulty(value) {
settings.setLastDifficulty(value);
}
function playBot() {
goto(`/play?difficulty=${difficulty}`);
@@ -21,8 +29,14 @@
screen reader has something to land on. -->
<h1 class="tagline">{t.tagline}</h1>
<!-- The rule itself, before the nickname or the ladder: a newcomer's first
read of it used to be a rejection message under a running clock. -->
<p class="how">
{fill(t.howToPlay, { example: t.howToPlayExample })}
</p>
<NicknameInput />
<DifficultyPicker bind:value={difficulty} />
<DifficultyPicker bind:value={difficulty} onselect={selectDifficulty} />
<div class="actions">
<button type="button" class="primary" onclick={playBot}>{t.playBot}</button>
@@ -45,19 +59,19 @@
.tagline {
margin: 0;
color: var(--text-muted);
font-size: var(--text-6);
font-size: var(--text-3);
font-weight: 400;
}
.actions {
display: flex;
flex-direction: column;
gap: 10px;
gap: var(--space-3);
}
.actions > * {
min-height: 44px;
padding: 14px;
padding: var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
@@ -71,6 +85,15 @@
border-color: transparent;
background: var(--accent);
color: var(--accent-text);
transition: background-color 150ms ease-out;
}
.actions .primary:hover {
background: var(--accent-hover);
}
.actions .primary:active {
background: var(--accent-pressed);
}
/* Its own line under the two big taps, not one more of them: this is a