Files
upbeatBytes/frontend/src/lib/components/WordGame.svelte
T
thejayman77 067e77ed5a Daily Word mobile: true viewport + flat warm keyboard + height-aware tiles
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>
2026-06-11 07:27:39 -04:00

271 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 todays 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 — couldnt 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 todays 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 ✦' : 'todays 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>