From 81d99bb0cf717c833f19f9caa8e1bd844febb8e0 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 21 Sep 2026 16:59:02 +0700 Subject: [PATCH] feat(web): teach the chain rule on landing and remember the last difficulty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/lib/components/ChainHistory.svelte | 51 ++++++++++++------- .../lib/components/DifficultyPicker.svelte | 15 ++++-- web/src/lib/stores/settings.svelte.js | 30 ++++++++++- web/src/routes/+page.svelte | 35 ++++++++++--- 4 files changed, 101 insertions(+), 30 deletions(-) diff --git a/web/src/lib/components/ChainHistory.svelte b/web/src/lib/components/ChainHistory.svelte index eb12f50..40063b1 100644 --- a/web/src/lib/components/ChainHistory.svelte +++ b/web/src/lib/components/ChainHistory.svelte @@ -99,24 +99,29 @@ {/if} - {#if entry.byMe && entry.typed && entry.typed !== entry.word} + {#if entry.opening} + + {t.openingCaption} + {:else if entry.byMe && entry.typed && entry.typed !== entry.word} {fill(t.correctedFrom, { typed: entry.typed, word: entry.word })} {/if} - {#if entry.parts.length} + {#if entry.parts.length && (index === 0 || open)} -

- {entry.parts - .map((/** @type {import('$lib/stores/game.svelte.js').PointPart} */ p) => - `+${p.value} ${pointKindLabels[p.kind] ?? ''}` - ) - .join(' · ')} -

+ 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. --> + {/if} {#if open} + + {best > 0 ? `${t.bestScore}: ${best}` : difficultyHints[difficulty]} + {/each} @@ -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); } diff --git a/web/src/lib/stores/settings.svelte.js b/web/src/lib/stores/settings.svelte.js index de2fea4..b9bf430 100644 --- a/web/src/lib/stores/settings.svelte.js +++ b/web/src/lib/stores/settings.svelte.js @@ -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} */ - 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} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 7e4cd1c..928a3ae 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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. -->

{t.tagline}

+ +

+ {fill(t.howToPlay, { example: t.howToPlayExample })} +

+ - +
@@ -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