Daily Word: give the keyboard + board some JUICE

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) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 07:55:44 -04:00
parent abfbcabad0
commit 35c5dc0c74
+40 -23
View File
@@ -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}
<div class="row">
<div class="row" class:shake={shake && r === guesses.length}>
{#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>
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
style={cs ? `animation-delay:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
{/each}
</div>
{/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; }