067e77ed5a
Make Daily Word feel like a focused mobile app screen, not a page with a keyboard. * True viewport: while view==='play' && game==='word', a $effect locks body scroll and hides the site footer (mobile only), so the keyboard is genuinely pinned, not riding the document scroll. Effect cleanup ALWAYS removes the class on re-run or unmount, so leaving /play (back button OR any navigation) can never strand it. * Keyboard restyled on-brand + modern: flat off-white (--surface) keys with a hairline border, soft 11px radius, no heavy raised shadow, ~46px tall, ↵ / ⌫ glyphs, centered (max-width 430) instead of a full-bleed beige slab. * Tiles now size to fit BOTH width and the height left above the keyboard (--tile = min(cap, width/cols, (100dvh-budget)/rows), gap 4px), so the active row and keyboard are always visible — Long Word's 6×7 gets slightly smaller tiles. Real-device Safari/Chrome is the final check (100dvh + safe-area handling). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
12 KiB
Svelte
271 lines
12 KiB
Svelte
<script>
|
||
import { getJSON, postJSON } from '$lib/api.js';
|
||
|
||
let { variant = '5', onstatus } = $props();
|
||
|
||
let length = $state(5);
|
||
let maxGuesses = $state(6);
|
||
let answer = $state(null); // revealed by the server only at win/loss
|
||
let why = $state(null);
|
||
let date = $state('');
|
||
let dict = $state(null); // Set of valid guesses
|
||
let guesses = $state([]); // submitted rows (strings)
|
||
let cols = $state([]); // server-returned colours, parallel to guesses
|
||
let current = $state(''); // the row being typed
|
||
let status = $state('playing'); // 'playing' | 'won' | 'lost'
|
||
let loading = $state(true);
|
||
let submitting = $state(false);
|
||
let message = $state('');
|
||
let ready = $state(false); // animate-in once loaded
|
||
|
||
const ROWS = 'qwertyuiop|asdfghjkl|zxcvbnm'.split('|').map((r) => r.split(''));
|
||
|
||
const stateKey = $derived(`goodnews:word:${variant}:${date}`);
|
||
const statsKey = $derived(`goodnews:word:${variant}:stats`);
|
||
|
||
async function load() {
|
||
loading = true; ready = false;
|
||
guesses = []; cols = []; current = ''; status = 'playing'; answer = null; why = null; message = '';
|
||
try {
|
||
const p = await getJSON('/api/puzzle/word?variant=' + variant); // holds NO answer
|
||
length = p.length; maxGuesses = p.guesses; date = p.date;
|
||
if (!dict || dict._len !== length) {
|
||
const words = await getJSON('/words-' + length + '.json');
|
||
dict = new Set(words); dict._len = length;
|
||
}
|
||
restore();
|
||
} catch {
|
||
message = 'Could not load today’s puzzle.';
|
||
}
|
||
loading = false;
|
||
requestAnimationFrame(() => (ready = true));
|
||
}
|
||
|
||
function restore() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||
if (saved && Array.isArray(saved.guesses)) {
|
||
guesses = saved.guesses;
|
||
cols = saved.cols || [];
|
||
status = saved.status || 'playing';
|
||
answer = saved.answer ?? null;
|
||
why = saved.why ?? null;
|
||
}
|
||
} catch { /* ignore */ }
|
||
onstatus?.(summary());
|
||
}
|
||
function persist() {
|
||
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
|
||
onstatus?.(summary());
|
||
}
|
||
function summary() {
|
||
return { variant, date, status, tries: guesses.length, max: maxGuesses };
|
||
}
|
||
|
||
// Best-known status per key for the on-screen keyboard, from server colours.
|
||
let keyState = $derived.by(() => {
|
||
const ks = {};
|
||
const rank = { absent: 0, present: 1, correct: 2 };
|
||
guesses.forEach((g, gi) => {
|
||
const cs = cols[gi] || [];
|
||
for (let i = 0; i < g.length; i++) {
|
||
const k = g[i];
|
||
if (cs[i] && (!(k in ks) || rank[cs[i]] > rank[ks[k]])) ks[k] = cs[i];
|
||
}
|
||
});
|
||
return ks;
|
||
});
|
||
|
||
function flash(m) { message = m; setTimeout(() => (message = ''), 1400); }
|
||
|
||
function key(k) {
|
||
if (status !== 'playing' || loading || submitting) return;
|
||
if (k === 'enter') return submit();
|
||
if (k === 'back') { current = current.slice(0, -1); return; }
|
||
if (/^[a-z]$/.test(k) && current.length < length) current += k;
|
||
}
|
||
|
||
async function submit() {
|
||
if (submitting) return;
|
||
if (current.length < length) return flash('Not enough letters');
|
||
if (dict && !dict.has(current)) return flash('Not in word list');
|
||
submitting = true;
|
||
try {
|
||
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
|
||
guesses = [...guesses, current];
|
||
cols = [...cols, res.colors];
|
||
current = '';
|
||
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
|
||
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
|
||
persist();
|
||
} catch {
|
||
flash('Hmm — couldn’t check that. Try again.');
|
||
} finally {
|
||
submitting = false;
|
||
}
|
||
}
|
||
|
||
function recordStat(won) {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(statsKey) || 'null') || { played: 0, won: 0, streak: 0, dist: {} };
|
||
s.played += 1;
|
||
if (won) { s.won += 1; s.streak += 1; s.dist[guesses.length] = (s.dist[guesses.length] || 0) + 1; }
|
||
else { s.streak = 0; }
|
||
localStorage.setItem(statsKey, JSON.stringify(s));
|
||
} catch { /* ignore */ }
|
||
}
|
||
let stats = $derived.by(() => {
|
||
if (status === 'playing') return null;
|
||
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
|
||
});
|
||
|
||
function onKeydown(e) {
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
const k = e.key.toLowerCase();
|
||
if (k === 'enter' || k === 'backspace' || /^[a-z]$/.test(k)) {
|
||
e.preventDefault();
|
||
key(k === 'backspace' ? 'back' : k);
|
||
}
|
||
}
|
||
|
||
const EMOJI = { correct: '🟩', present: '🟨', absent: '⬛' };
|
||
let copied = $state(false);
|
||
function share() {
|
||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||
const score = status === 'won' ? guesses.length : 'X';
|
||
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
||
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`;
|
||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
||
}
|
||
|
||
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
|
||
// must not depend on `loading`, or load()'s own loading flip would re-fire it.
|
||
$effect(() => {
|
||
variant;
|
||
load();
|
||
});
|
||
</script>
|
||
|
||
<svelte:window onkeydown={onKeydown} />
|
||
|
||
<div class="wordgame" class:ready>
|
||
{#if loading}
|
||
<p class="muted">Loading today’s puzzle…</p>
|
||
{:else}
|
||
<div class="play-area">
|
||
<div class="board" style="--cols:{length}; --rows:{maxGuesses}">
|
||
{#each Array(maxGuesses) as _, r (r)}
|
||
{@const g = guesses[r]}
|
||
{@const cs = cols[r] || null}
|
||
<div class="row">
|
||
{#each Array(length) as _, c (c)}
|
||
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
||
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}>{(ch || '').toUpperCase()}</div>
|
||
{/each}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if message}<p class="flash">{message}</p>{/if}
|
||
|
||
{#if status !== 'playing'}
|
||
<div class="result rise">
|
||
<p class="rmark">{status === 'won' ? '✦ nicely done ✦' : 'today’s word:'}
|
||
{#if status === 'lost' && answer}<strong>{answer.toUpperCase()}</strong>{/if}</p>
|
||
{#if why}<p class="why"><span class="lbl">Why this word</span>{why}</p>{/if}
|
||
{#if stats}
|
||
<div class="stats">
|
||
<div><span class="n">{stats.played}</span><span class="l">played</span></div>
|
||
<div><span class="n">{stats.played ? Math.round(100 * stats.won / stats.played) : 0}%</span><span class="l">solved</span></div>
|
||
<div><span class="n">{stats.streak}</span><span class="l">streak</span></div>
|
||
</div>
|
||
{/if}
|
||
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if status === 'playing'}
|
||
<div class="keyboard">
|
||
{#each ROWS as row, ri (ri)}
|
||
<div class="krow">
|
||
{#if ri === 2}<button class="key wide enter" onclick={() => key('enter')} aria-label="Enter">↵</button>{/if}
|
||
{#each row as k (k)}
|
||
<button class="key {keyState[k] || ''}" onclick={() => key(k)}>{k.toUpperCase()}</button>
|
||
{/each}
|
||
{#if ri === 2}<button class="key wide" onclick={() => key('back')} aria-label="Backspace">⌫</button>{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.wordgame { max-width: 480px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
|
||
.wordgame.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
|
||
.board { display: grid; gap: 6px; margin: 0 auto 18px; width: min(100%, 340px); }
|
||
.row { display: grid; grid-template-columns: repeat(var(--cols), 1fr); gap: 6px; }
|
||
.tile {
|
||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
|
||
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
|
||
background: var(--surface);
|
||
}
|
||
.tile.filled { border-color: #b7c0cb; }
|
||
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
|
||
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
|
||
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
|
||
.flash {
|
||
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
|
||
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
|
||
}
|
||
/* Flat, warm, on-brand keys — off-white with a hairline border, soft radius,
|
||
no heavy raised shadow. Centered, never a full-bleed slab. */
|
||
.keyboard { display: flex; flex-direction: column; gap: 6px; margin: 10px auto 0; max-width: 430px; }
|
||
.krow { display: flex; gap: 5px; justify-content: center; }
|
||
.key {
|
||
flex: 1; min-width: 0; max-width: 42px; height: 48px; border: 1px solid var(--line); border-radius: 11px;
|
||
background: var(--surface); color: var(--ink); font-family: var(--label); font-weight: 600;
|
||
font-size: 0.92rem; cursor: pointer; text-transform: uppercase; transition: background 0.12s ease, transform 0.05s ease;
|
||
}
|
||
.key:active { transform: translateY(1px); background: var(--bg); }
|
||
.key.wide { max-width: 56px; font-size: 1.1rem; }
|
||
.key.enter { background: var(--accent-soft); border-color: transparent; color: var(--accent-deep); }
|
||
.key.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
|
||
.key.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
|
||
.key.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
|
||
.key:hover { filter: brightness(0.98); }
|
||
.muted { color: var(--muted); text-align: center; }
|
||
|
||
/* Mobile: a focused app screen — board sizes to fit BOTH width and the height
|
||
left above the keyboard, so the active row + keyboard are always visible. */
|
||
@media (max-width: 720px) {
|
||
.wordgame { display: flex; flex-direction: column; height: 100%; max-width: 100%; }
|
||
.play-area { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column;
|
||
justify-content: center; padding: 4px 0; }
|
||
.board {
|
||
--tile: min(58px, calc((100vw - 44px) / var(--cols)), calc((100dvh - 300px) / var(--rows)));
|
||
gap: 4px; width: fit-content; margin: 0 auto 10px;
|
||
}
|
||
.row { grid-template-columns: repeat(var(--cols), var(--tile)); gap: 4px; }
|
||
.tile { width: var(--tile); height: var(--tile); aspect-ratio: auto; font-size: calc(var(--tile) * 0.46); }
|
||
.keyboard { flex-shrink: 0; margin: 8px auto calc(env(safe-area-inset-bottom) + 4px); gap: 5px; }
|
||
.key { height: 46px; }
|
||
}
|
||
.result { text-align: center; }
|
||
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 10px; }
|
||
.rmark strong { font-style: normal; letter-spacing: 0.06em; }
|
||
.result .why { color: #3b4754; font-size: 0.95rem; margin: 0 auto 16px; max-width: 380px;
|
||
border-left: 2px solid var(--accent); padding-left: 12px; text-align: left; }
|
||
.result .why .lbl { display: block; text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.68rem;
|
||
color: var(--accent-deep); font-weight: 600; margin-bottom: 2px; }
|
||
.stats { display: flex; justify-content: center; gap: 26px; margin: 0 0 18px; }
|
||
.stats .n { display: block; font-size: 1.6rem; font-weight: 700; font-family: var(--label); }
|
||
.stats .l { color: var(--muted); font-size: 0.78rem; }
|
||
.share { background: var(--accent); color: #fff; border: none; border-radius: 999px;
|
||
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
|
||
.share:hover { background: var(--accent-deep); }
|
||
</style>
|