From 35c5dc0c74a2f12a59e8326ea9f557120d36e590 Mon Sep 17 00:00:00 2001 From: jay Date: Thu, 11 Jun 2026 07:55:44 -0400 Subject: [PATCH] Daily Word: give the keyboard + board some JUICE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard read as a timid little afterthought. Now it's a confident game board: * Bigger, bolder keys (taller, larger font) with a tactile press — a soft bottom edge + shadow that compresses on tap (translateY). Enter is a solid accent key with its own depth; feedback keys keep matching depth. * Board tiles a touch larger to fill the screen better. * Real game feedback animations: tiles POP as you type, the row REVEALS with a staggered bounce when you submit, and shakes on an invalid word. Respects prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/WordGame.svelte | 63 +++++++++++++-------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/components/WordGame.svelte b/frontend/src/lib/components/WordGame.svelte index 755ba18..9d043b5 100644 --- a/frontend/src/lib/components/WordGame.svelte +++ b/frontend/src/lib/components/WordGame.svelte @@ -15,6 +15,7 @@ let status = $state('playing'); // 'playing' | 'won' | 'lost' let loading = $state(true); let submitting = $state(false); + let shake = $state(false); let message = $state(''); let ready = $state(false); // animate-in once loaded @@ -85,10 +86,12 @@ if (/^[a-z]$/.test(k) && current.length < length) current += k; } + function triggerShake() { shake = true; setTimeout(() => (shake = false), 420); } + 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'); + if (current.length < length) { flash('Not enough letters'); triggerShake(); return; } + if (dict && !dict.has(current)) { flash('Not in word list'); triggerShake(); return; } submitting = true; try { const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 }); @@ -158,10 +161,11 @@ {#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()}
+
{(ch || '').toUpperCase()}
{/each}
{/each} @@ -217,6 +221,16 @@ .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; } + + /* Juice: a tile pops as you type; the row reveals with a staggered bounce when + you submit; the row shakes on an invalid word. */ + .tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; } + .tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; } + .row.shake { animation: shake 0.4s ease; } + @keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } } + @keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } } + @keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } } + @media (prefers-reduced-motion: reduce) { .tile, .row { animation: none !important; } } .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; @@ -224,22 +238,24 @@ /* Keyboard fills its area like a real phone game: a left block of square, alphabetical letter keys + a right column with Backspace (top) / Enter (bottom) tucked in. Flat, warm, on-brand keys — off-white, hairline border. */ - .keyboard { display: flex; align-items: stretch; gap: 6px; margin: 10px auto 0; max-width: 440px; } - .letters { flex: 1; display: grid; grid-template-columns: repeat(9, 1fr); gap: 5px; } - .controls { display: grid; grid-template-rows: 1fr 1fr; gap: 5px; width: 50px; flex-shrink: 0; } + .keyboard { display: flex; align-items: stretch; gap: 7px; margin: 12px auto 0; max-width: 460px; } + .letters { flex: 1; display: grid; grid-template-columns: repeat(9, 1fr); gap: 6px; } + .controls { display: grid; grid-template-rows: 1fr 1fr; gap: 6px; width: 58px; flex-shrink: 0; } .key { - border: 1px solid var(--line); border-radius: 11px; background: var(--surface); color: var(--ink); - font-family: var(--label); font-weight: 600; font-size: 0.95rem; cursor: pointer; text-transform: uppercase; - padding: 0; transition: background 0.12s ease, transform 0.05s ease; + border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--ink); + font-family: var(--label); font-weight: 700; font-size: 1.18rem; cursor: pointer; text-transform: uppercase; + padding: 0; box-shadow: 0 2px 0 rgba(120, 108, 84, 0.22), 0 3px 6px rgba(60, 50, 30, 0.07); + transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.12s ease, filter 0.12s ease; } - .key:active { transform: translateY(1px); background: var(--bg); } - .letters .key { aspect-ratio: 1; min-width: 0; } - .controls .key { height: 100%; font-size: 0.62rem; letter-spacing: 0.03em; } - .controls .back { font-size: 1.2rem; } - .controls .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:active { transform: translateY(2px); box-shadow: 0 0 0 rgba(0, 0, 0, 0); background: var(--bg); } + .letters .key { min-width: 0; height: 52px; } + .controls .key { height: 100%; font-size: 0.66rem; letter-spacing: 0.04em; } + .controls .back { font-size: 1.35rem; } + .controls .enter { background: var(--accent); border-color: var(--accent); color: #fff; + box-shadow: 0 2px 0 var(--accent-deep), 0 3px 6px rgba(0, 131, 173, 0.18); } + .key.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; box-shadow: 0 2px 0 #3a7d56; } + .key.present { background: #d8b24a; border-color: #d8b24a; color: #fff; box-shadow: 0 2px 0 #b8943a; } + .key.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; box-shadow: 0 2px 0 #7e8a96; } .key:hover { filter: brightness(0.98); } .muted { color: var(--muted); text-align: center; } @@ -251,13 +267,14 @@ .play-area { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; justify-content: flex-start; padding: 8px 0 4px; } .board { - --tile: min(58px, calc((100vw - 56px) / var(--cols)), calc((100dvh - 290px) / var(--rows))); - gap: 4px; width: fit-content; margin: 0 auto 10px; + --tile: min(64px, calc((100vw - 56px) / var(--cols)), calc((100dvh - 300px) / var(--rows))); + gap: 5px; width: fit-content; margin: 0 auto 10px; } - .row { grid-template-columns: repeat(var(--cols), var(--tile)); gap: 4px; } + .row { grid-template-columns: repeat(var(--cols), var(--tile)); gap: 5px; } .tile { width: var(--tile); height: var(--tile); aspect-ratio: auto; font-size: calc(var(--tile) * 0.46); } - .keyboard { flex-shrink: 0; gap: 5px; margin: 8px auto calc(env(safe-area-inset-bottom) + 4px); } - .controls { width: 46px; } + .keyboard { flex-shrink: 0; gap: 6px; margin: 10px auto calc(env(safe-area-inset-bottom) + 6px); } + .letters .key { height: 50px; } + .controls { width: 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; }