diff --git a/frontend/src/lib/components/WordGame.svelte b/frontend/src/lib/components/WordGame.svelte index cb01ed2..f6d8916 100644 --- a/frontend/src/lib/components/WordGame.svelte +++ b/frontend/src/lib/components/WordGame.svelte @@ -153,40 +153,44 @@ {#if loading}

Loading today’s puzzle…

{:else} -
- {#each Array(maxGuesses) as _, r (r)} - {@const g = guesses[r]} - {@const cs = cols[r] || null} -
- {#each Array(length) as _, c (c)} - {@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')} -
{(ch || '').toUpperCase()}
- {/each} +
+
+ {#each Array(maxGuesses) as _, r (r)} + {@const g = guesses[r]} + {@const cs = cols[r] || null} +
+ {#each Array(length) as _, c (c)} + {@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')} +
{(ch || '').toUpperCase()}
+ {/each} +
+ {/each} +
+ + {#if message}

{message}

{/if} + + {#if status !== 'playing'} +
+

{status === 'won' ? '✦ nicely done ✦' : 'today’s word:'} + {#if status === 'lost' && answer}{answer.toUpperCase()}{/if}

+ {#if why}

Why this word{why}

{/if} + {#if stats} +
+
{stats.played}played
+
{stats.played ? Math.round(100 * stats.won / stats.played) : 0}%solved
+
{stats.streak}streak
+
+ {/if} +
- {/each} + {/if}
- {#if message}

{message}

{/if} - - {#if status !== 'playing'} -
-

{status === 'won' ? '✦ nicely done ✦' : 'today’s word:'} - {#if status === 'lost' && answer}{answer.toUpperCase()}{/if}

- {#if why}

Why this word{why}

{/if} - {#if stats} -
-
{stats.played}played
-
{stats.played ? Math.round(100 * stats.won / stats.played) : 0}%solved
-
{stats.streak}streak
-
- {/if} - -
- {:else} + {#if status === 'playing'}
{#each ROWS as row, ri (ri)}
- {#if ri === 2}{/if} + {#if ri === 2}{/if} {#each row as k (k)} {/each} @@ -217,19 +221,34 @@ 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; } - .keyboard { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } + .keyboard { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; } .krow { display: flex; gap: 5px; justify-content: center; } .key { - flex: 1; min-width: 0; max-width: 42px; height: 52px; border: none; border-radius: 7px; - background: #e4e7ea; color: var(--ink); font-family: var(--label); font-weight: 600; - font-size: 0.92rem; cursor: pointer; text-transform: uppercase; + flex: 1; min-width: 0; max-width: 44px; height: 54px; border: none; border-radius: 8px; + background: #e9e2d4; color: var(--ink); font-family: var(--label); font-weight: 600; + font-size: 1rem; cursor: pointer; text-transform: uppercase; + box-shadow: 0 1.5px 0 rgba(70, 58, 36, 0.16); transition: background 0.12s ease, transform 0.05s ease; } - .key.wide { max-width: 62px; font-size: 0.7rem; } - .key.correct { background: #4a9d6e; color: #fff; } - .key.present { background: #d8b24a; color: #fff; } - .key.absent { background: #9aa6b2; color: #fff; } - .key:hover { filter: brightness(0.96); } + .key:active { transform: translateY(1px); box-shadow: none; } + .key.wide { max-width: 66px; font-size: 0.68rem; letter-spacing: 0.03em; } + .key.enter { background: var(--accent-soft); color: var(--accent-deep); } + .key.correct { background: #4a9d6e; color: #fff; box-shadow: 0 1.5px 0 rgba(40, 90, 60, 0.32); } + .key.present { background: #d8b24a; color: #fff; box-shadow: 0 1.5px 0 rgba(150, 110, 20, 0.32); } + .key.absent { background: #9aa6b2; color: #fff; box-shadow: 0 1.5px 0 rgba(80, 90, 100, 0.32); } + .key:hover { filter: brightness(0.97); } .muted { color: var(--muted); text-align: center; } + + /* Mobile: fill the height — board scrolls in the middle, keyboard pinned at the + bottom and always reachable (full-bleed, on-brand, safe-area aware). */ + @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 { margin-bottom: 12px; } + .keyboard { flex-shrink: 0; margin: 6px -16px 0; gap: 6px; background: var(--bg); + border-top: 1px solid var(--line); padding: 9px 6px calc(9px + env(safe-area-inset-bottom)); } + .key { height: 50px; } + } .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; } diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index 8d04e2c..c2588ca 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -4,14 +4,15 @@ import WordGame from '$lib/components/WordGame.svelte'; import WordSearchGame from '$lib/components/WordSearchGame.svelte'; - let view = $state('hub'); // 'hub' | 'word' | 'wordsearch' + let view = $state('hub'); // 'hub' | 'select' | 'play' + let game = $state('word'); // 'word' | 'wordsearch' let variant = $state('5'); let wsSize = $state('med'); let date = $state(''); - let wordStatus = $state({ 5: null, 6: null }); // null | {status, tries, max} - let wsStatus = $state(null); // null | {status, found, ms} + let wordStatus = $state({ 5: null, 6: null }); + let wsStatus = $state(null); - function readStatus(v) { + function readWord(v) { try { const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null'); if (s && (s.status === 'won' || s.status === 'lost')) { @@ -20,50 +21,71 @@ } catch { /* ignore */ } return null; } - function readWs() { - let inProgress = null; - for (const sz of ['small', 'med', 'large']) { - try { - const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${sz}:${date}`) || 'null'); - if (s && Array.isArray(s.foundWords)) { - const st = { status: s.status, found: s.foundWords.length, ms: s.ms }; - if (st.status === 'done') return st; // prefer a finished one - if (st.found > 0 && !inProgress) inProgress = st; - } - } catch { /* ignore */ } - } - return inProgress; + function readWsSize(sz) { + try { + const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${sz}:${date}`) || 'null'); + if (s && Array.isArray(s.foundWords)) return { status: s.status, found: s.foundWords.length, ms: s.ms }; + } catch { /* ignore */ } + return null; } function refreshStatus() { - wordStatus = { 5: readStatus('5'), 6: readStatus('6') }; - wsStatus = readWs(); + wordStatus = { 5: readWord('5'), 6: readWord('6') }; + let ws = null; + for (const sz of ['small', 'med', 'large']) { + const s = readWsSize(sz); + if (s && s.status === 'done') { ws = s; break; } + if (s && s.found > 0 && !ws) ws = s; + } + wsStatus = ws; } function fmtMs(ms) { const s = Math.round(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); } - function wsLabel() { + + // Hub card one-liners + function wordLabel() { + const a = wordStatus['5'], b = wordStatus['6']; + if (!a && !b) return 'Guess the day’s word'; + const part = (s, mx) => s ? (s.status === 'won' ? `${s.tries}/${mx}` : 'X') : '–'; + return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`; + } + function wsHubLabel() { if (!wsStatus) return 'Find the day’s themed words'; if (wsStatus.status === 'done') return `Today: cleared${wsStatus.ms ? ' · ' + fmtMs(wsStatus.ms) : ''}`; if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`; return 'Find the day’s themed words'; } - function wordLabel() { - const a = wordStatus['5'], b = wordStatus['6']; - const done = [a, b].filter(Boolean); - if (done.length === 0) return 'Play today’s word'; - const parts = []; - if (a) parts.push(`5·${a.status === 'won' ? a.tries + '/6' : 'X'}`); - if (b) parts.push(`6·${b.status === 'won' ? b.tries + '/7' : 'X'}`); - return `Today: ${parts.join(' ')}`; + // Game-selection option statuses + function wordOpt(v) { + const s = wordStatus[v]; + if (!s) return 'Play'; + return s.status === 'won' ? `Solved ${s.tries}/${s.max}` : 'Out of guesses'; + } + function wsOpt(sz) { + const s = readWsSize(sz); + if (!s) return 'Play'; + if (s.status === 'done') return `Cleared${s.ms ? ' · ' + fmtMs(s.ms) : ''}`; + if (s.found > 0) return `${s.found} found`; + return 'Play'; } + function openGame(g) { game = g; view = 'select'; refreshStatus(); } + function pick(v) { if (game === 'word') variant = v; else wsSize = v; view = 'play'; } + function back() { + view = view === 'play' ? 'select' : 'hub'; + refreshStatus(); + } + + const WS_OPTS = [ + ['small', 'Small', 'cosy · 8×8'], + ['med', 'Medium', 'balanced · 11×11'], + ['large', 'Large', 'a longer sit · 14×14'], + ]; + onMount(async () => { - try { - const p = await getJSON('/api/puzzle/word?variant=5'); - date = p.date; - } catch { /* offline — game view will surface it */ } + try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ } refreshStatus(); }); @@ -73,22 +95,22 @@
- {#if view !== 'hub'} - - {:else} + {#if view === 'hub'} News + {:else} + {/if}
-
+
{#if view === 'hub'}

Play

A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.

- -
- {:else if view === 'word'} -
- - + + {:else if view === 'select'} +

{game === 'word' ? 'Daily Word' : 'Word Search'}

+

{game === 'word' ? 'Pick your length.' : 'Pick your size.'}

+
+ {#if game === 'word'} + + + {:else} + {#each WS_OPTS as [sz, name, desc] (sz)} + + {/each} + {/if}
- - {:else if view === 'wordsearch'} -
- - - -
- + + {:else if view === 'play'} + {#if game === 'word'} + + {:else} + + {/if} {/if}
@@ -130,6 +169,7 @@ .back svg { width: 17px; height: 17px; display: block; } .page { padding: 22px 20px 70px; } h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; } + .seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); } .sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; } @@ -145,13 +185,27 @@ .gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; } .gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; } - .variant { display: flex; gap: 10px; justify-content: center; margin: 0 0 22px; } - .vchip { - display: flex; flex-direction: column; align-items: center; gap: 2px; - border: 1px solid var(--line); background: var(--surface); color: var(--ink); - border-radius: 12px; padding: 8px 18px; font: inherit; font-weight: 600; cursor: pointer; + /* Game-selection options */ + .opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; } + .opt { + display: flex; align-items: center; justify-content: space-between; gap: 14px; + background: var(--surface); border: 1px solid var(--line); border-radius: 14px; + padding: 16px 18px; cursor: pointer; font: inherit; color: var(--ink); + box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease; + } + .opt:hover { border-color: var(--accent); transform: translateY(-1px); } + .opt-main { display: flex; flex-direction: column; text-align: left; } + .opt-main strong { font-size: 1.1rem; } + .opt-main span { color: var(--muted); font-size: 0.82rem; } + .opt-go { flex-shrink: 0; font-size: 0.82rem; font-weight: 600; color: #fff; background: var(--accent); + border-radius: 999px; padding: 6px 14px; } + .opt-go.done { background: var(--accent-soft); color: var(--accent-deep); } + + /* Full-height game layout on mobile so the keyboard is always reachable. */ + @media (max-width: 720px) { + .page.gameview { + height: calc(100dvh - 64px); padding: 10px 16px 0; + display: flex; flex-direction: column; overflow: hidden; + } } - .vchip span { font-weight: 400; font-size: 0.72rem; color: var(--muted); } - .vchip.on { background: var(--accent); border-color: var(--accent); color: #fff; } - .vchip.on span { color: rgba(255,255,255,0.8); }