Selaa lähdekoodia

feat: editable word with cursor and on-the-fly validation

- Add blinking underline cursor in the current guess row showing
  where the next letter will be inserted.
- Add 4th keyboard row with Enter, Left, Right, Backspace keys.
  Enter and Backspace removed from existing letter rows.
- Left/Right arrow keys move cursor; typing replaces the letter
  under cursor rather than always appending.
- Enter key greys out when word is incomplete (<5 letters) or
  invalid (client-side validation via GET /api/validate/:word).
- Both physical and on-screen keyboard share the same logic.

Co-Authored-By: Claude <noreply@anthropic.com>
Oleg Panashchenko 1 viikko sitten
vanhempi
sitoutus
0563543e2a

+ 4 - 0
client/src/api.ts

@@ -12,6 +12,10 @@ async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
   return res.json() as Promise<T>;
 }
 
+export function getValidateWord(word: string): Promise<{ word: string; valid: boolean }> {
+  return fetchJSON(`${base()}/validate/${encodeURIComponent(word)}`);
+}
+
 export function getDaily(): Promise<DailyResponse> {
   return fetchJSON<DailyResponse>(`${base()}/daily`);
 }

+ 35 - 1
client/src/components/Grid.tsx

@@ -6,19 +6,35 @@ const COLOR_MAP: Record<string, string> = {
   x: '#787c7e',
 };
 
+const CURSOR_COLOR = '#6aaa64';
+
 interface GridProps {
   guesses: GuessEntry[];
   maxRows?: number;
+  /** Cursor position in the current (unsubmitted) row (0–5) */
+  cursorPos?: number;
 }
 
-export function Grid({ guesses, maxRows = 6 }: GridProps) {
+export function Grid({ guesses, maxRows = 6, cursorPos = 0 }: GridProps) {
   const rows: (GuessEntry | null)[] = [...guesses];
   while (rows.length < maxRows) {
     rows.push(null);
   }
 
+  // Find the current (unsubmitted) row — the first entry with empty colors
+  // that has letters typed. Only one such row can exist.
+  const currentRowIdx = rows.findIndex(
+    (e) => e && e.colors.length === 0 && e.guess.length > 0,
+  );
+
   return (
     <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'center' }}>
+      <style>{`
+        @keyframes wl-blink {
+          0%, 100% { opacity: 1; }
+          50% { opacity: 0; }
+        }
+      `}</style>
       {rows.map((entry, rowIdx) => (
         <div key={rowIdx} style={{ display: 'flex', gap: '4px' }}>
           {Array.from({ length: 5 }).map((_, colIdx) => {
@@ -26,6 +42,9 @@ export function Grid({ guesses, maxRows = 6 }: GridProps) {
             const colorCode = entry?.colors?.[colIdx];
             const hasFeedback = entry ? entry.colors.length > 0 : false;
             const bg = hasFeedback && colorCode ? COLOR_MAP[colorCode] : 'transparent';
+            const isCurrentRow = rowIdx === currentRowIdx;
+            const showCursor = isCurrentRow && colIdx === cursorPos;
+
             return (
               <div
                 key={colIdx}
@@ -42,9 +61,24 @@ export function Grid({ guesses, maxRows = 6 }: GridProps) {
                   color: hasFeedback ? '#fff' : '#000',
                   textTransform: 'uppercase',
                   fontFamily: 'monospace',
+                  position: 'relative',
                 }}
               >
                 {letter}
+                {showCursor && (
+                  <div
+                    style={{
+                      position: 'absolute',
+                      bottom: '4px',
+                      left: '8px',
+                      right: '8px',
+                      height: '3px',
+                      backgroundColor: CURSOR_COLOR,
+                      borderRadius: '1px',
+                      animation: 'wl-blink 1s step-end infinite',
+                    }}
+                  />
+                )}
               </div>
             );
           })}

+ 65 - 39
client/src/components/Keyboard.tsx

@@ -9,50 +9,76 @@ const COLOR_MAP: Record<string, string> = {
 interface KeyboardProps {
   letterColors: Record<string, string | undefined>;
   onKeyPress: (key: string) => void;
+  /** Disable Enter key (word incomplete or invalid) */
+  enterDisabled?: boolean;
 }
 
-export function Keyboard({ letterColors, onKeyPress }: KeyboardProps) {
+export function Keyboard({ letterColors, onKeyPress, enterDisabled }: KeyboardProps) {
   const m = getMessages();
+
+  const isNavKey = (key: string) =>
+    key === m.keyEnter || key === m.keyBackspace ||
+    key === m.keyArrowLeft || key === m.keyArrowRight;
+
+  function navLabel(key: string): string {
+    if (key === m.keyBackspace) return m.backspaceLabel;
+    if (key === m.keyArrowLeft) return m.arrowLeftLabel;
+    if (key === m.keyArrowRight) return m.arrowRightLabel;
+    return key.toUpperCase();
+  }
+
+  function renderRow(row: string[], rowIdx: number) {
+    return (
+      <div key={rowIdx} style={{ display: 'flex', gap: '4px', width: '100%', justifyContent: 'center' }}>
+        {row.map((key) => {
+          const isSpecial = isNavKey(key);
+          const letter = isSpecial ? '' : key;
+          const color = isSpecial ? undefined : letterColors[letter];
+          const bg = color ? COLOR_MAP[color] : '#d3d6da';
+          const fg = color ? '#fff' : '#000';
+          const label = isSpecial ? navLabel(key) : key.toUpperCase();
+          const flexBasis = isSpecial ? '15%' : '8%';
+
+          // Enter key: grey out when disabled
+          const isEnter = key === m.keyEnter;
+          const disabled = isEnter && enterDisabled;
+
+          return (
+            <div
+              key={key}
+              onClick={() => {
+                if (disabled) return;
+                onKeyPress(key);
+              }}
+              style={{
+                flex: `0 0 ${flexBasis}`,
+                height: '42px',
+                backgroundColor: disabled ? '#c8c8c8' : bg,
+                color: disabled ? '#999' : fg,
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                fontSize: isSpecial ? '0.7rem' : '0.78rem',
+                fontWeight: 'bold',
+                borderRadius: '4px',
+                cursor: disabled ? 'not-allowed' : 'pointer',
+                userSelect: 'none',
+                fontFamily: 'monospace',
+                opacity: disabled ? 0.6 : 1,
+              }}
+            >
+              {label}
+            </div>
+          );
+        })}
+      </div>
+    );
+  }
+
   return (
     <div style={{ display: 'flex', flexDirection: 'column', gap: '5px', alignItems: 'center', marginTop: '12px', width: '95vw', maxWidth: '340px', margin: '12px auto 0' }}>
-      {m.keyboardRows.map((row, rowIdx) => (
-        <div key={rowIdx} style={{ display: 'flex', gap: '4px', width: '100%', justifyContent: 'center' }}>
-          {row.map((key) => {
-            const isSpecial = key === m.keyEnter || key === m.keyBackspace;
-            const isBackspace = key === m.keyBackspace;
-            const letter = isSpecial ? '' : key;
-            const color = isSpecial ? undefined : letterColors[letter];
-            const bg = color ? COLOR_MAP[color] : '#d3d6da';
-            const fg = color ? '#fff' : '#000';
-            const label = isBackspace ? m.backspaceLabel : key.toUpperCase();
-            const flexBasis = isSpecial ? '15%' : '8%';
-
-            return (
-              <div
-                key={key}
-                onClick={() => onKeyPress(isBackspace ? m.keyBackspace : key)}
-                style={{
-                  flex: `0 0 ${flexBasis}`,
-                  height: '42px',
-                  backgroundColor: bg,
-                  color: fg,
-                  display: 'flex',
-                  alignItems: 'center',
-                  justifyContent: 'center',
-                  fontSize: isSpecial ? '0.7rem' : '0.78rem',
-                  fontWeight: 'bold',
-                  borderRadius: '4px',
-                  cursor: 'pointer',
-                  userSelect: 'none',
-                  fontFamily: 'monospace',
-                }}
-              >
-                {label}
-              </div>
-            );
-          })}
-        </div>
-      ))}
+      {m.keyboardRows.map((row, idx) => renderRow(row, idx))}
+      {renderRow([...m.navRow], m.keyboardRows.length)}
     </div>
   );
 }

+ 11 - 2
client/src/messages/en.ts

@@ -1,16 +1,23 @@
 /** English (en) messages for the Wordle client. */
 
 export const en = {
-  /** Keyboard layout — three rows of key labels. Special: 'Enter' and 'Backspace'. */
+  /** Keyboard layout — three rows of letter keys. Nav keys are in navRow. */
   keyboardRows: [
     ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
     ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
-    ['Enter', 'z', 'x', 'c', 'v', 'b', 'n', 'm', 'Backspace'],
+    ['z', 'x', 'c', 'v', 'b', 'n', 'm'],
   ],
 
+  /** Navigation row: Enter, Left, Right, Backspace */
+  navRow: ['Enter', 'ArrowLeft', 'ArrowRight', 'Backspace'],
+
   /** Backspace key display label */
   backspaceLabel: '⌫',
 
+  /** Arrow key display labels */
+  arrowLeftLabel: '←',
+  arrowRightLabel: '→',
+
   title: 'Wordle',
 
   /** Screen 1 — Rules */
@@ -51,4 +58,6 @@ export const en = {
   /** Shared labels */
   keyEnter: 'Enter',
   keyBackspace: 'Backspace',
+  keyArrowLeft: 'ArrowLeft',
+  keyArrowRight: 'ArrowRight',
 };

+ 7 - 0
client/src/messages/index.ts

@@ -3,8 +3,13 @@ import { ru } from './ru.js';
 
 // Use a structural type so languages with different keyboard layouts are assignable
 export interface Messages {
+  /** Letter key rows (3 rows). Special keys (Enter/Backspace) are in navRow instead. */
   keyboardRows: readonly string[][];
+  /** Navigation row: Enter, Left, Right, Backspace */
+  navRow: readonly string[];
   backspaceLabel: string;
+  arrowLeftLabel: string;
+  arrowRightLabel: string;
   title: string;
   rules: {
     heading: string;
@@ -34,6 +39,8 @@ export interface Messages {
   };
   keyEnter: string;
   keyBackspace: string;
+  keyArrowLeft: string;
+  keyArrowRight: string;
 }
 
 /** Detect language from URL path. /ru/... → 'ru', otherwise 'en'. */

+ 8 - 1
client/src/messages/ru.ts

@@ -4,11 +4,16 @@ export const ru = {
   keyboardRows: [
     ['й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ'],
     ['ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'],
-    ['Enter', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', 'Backspace'],
+    ['я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю'],
   ],
 
+  navRow: ['Enter', 'ArrowLeft', 'ArrowRight', 'Backspace'],
+
   backspaceLabel: '⌫',
 
+  arrowLeftLabel: '←',
+  arrowRightLabel: '→',
+
   title: 'Вордли',
 
   rules: {
@@ -54,4 +59,6 @@ export const ru = {
 
   keyEnter: 'Enter',
   keyBackspace: 'Backspace',
+  keyArrowLeft: 'ArrowLeft',
+  keyArrowRight: 'ArrowRight',
 };

+ 104 - 13
client/src/screens/Screen2Game.tsx

@@ -1,9 +1,10 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
 import type { GuessEntry } from '@wordle/shared';
 import { Grid } from '../components/Grid.js';
 import { Keyboard } from '../components/Keyboard.js';
 import { getMessages } from '../messages/index.js';
 import { LangSwitch } from '../components/LangSwitch.js';
+import { getValidateWord } from '../api.js';
 
 interface Screen2GameProps {
   guesses: GuessEntry[];
@@ -16,6 +17,16 @@ interface Screen2GameProps {
 
 export function Screen2Game({ guesses, letterColors, message, onGuess, onDismissMessage, gameOver }: Screen2GameProps) {
   const [currentGuess, setCurrentGuess] = useState<string>('');
+  const [cursorPos, setCursorPos] = useState<number>(0);
+  const [isValidWord, setIsValidWord] = useState<boolean | null>(null);
+
+  // Track in-flight validation to ignore stale responses
+  const validatingWord = useRef<string | null>(null);
+
+  // Reset cursor when guess is cleared (after successful submission)
+  useEffect(() => {
+    if (currentGuess.length === 0) setCursorPos(0);
+  }, [currentGuess]);
 
   // Auto-dismiss message after 2 seconds
   useEffect(() => {
@@ -24,13 +35,38 @@ export function Screen2Game({ guesses, letterColors, message, onGuess, onDismiss
     return () => clearTimeout(timer);
   }, [message, onDismissMessage]);
 
+  // Validate word on change (debounced via ref)
+  useEffect(() => {
+    if (currentGuess.length !== 5) {
+      setIsValidWord(null);
+      return;
+    }
+    const word = currentGuess;
+    validatingWord.current = word;
+    getValidateWord(word).then((res) => {
+      // Only apply if the guess hasn't changed since the request fired
+      if (validatingWord.current === word) {
+        setIsValidWord(res.valid);
+      }
+    }).catch(() => {
+      // Network error — don't block submission, let server validate
+      if (validatingWord.current === word) {
+        setIsValidWord(null);
+      }
+    });
+  }, [currentGuess]);
+
   const submitCurrent = useCallback(async () => {
     if (currentGuess.length !== 5 || gameOver) return;
+    // If we know the word is invalid, don't submit
+    if (isValidWord === false) return;
     const accepted = await onGuess(currentGuess);
     if (accepted) {
-      setCurrentGuess(''); // only clear when guess was valid
+      setCurrentGuess('');
+      setCursorPos(0);
+      setIsValidWord(null);
     }
-  }, [currentGuess, onGuess, gameOver]);
+  }, [currentGuess, onGuess, gameOver, isValidWord]);
 
   // Physical keyboard handler
   useEffect(() => {
@@ -38,46 +74,101 @@ export function Screen2Game({ guesses, letterColors, message, onGuess, onDismiss
       if (gameOver) return;
 
       if (e.key === 'Enter') {
+        e.preventDefault();
         submitCurrent();
         return;
       }
       if (e.key === 'Backspace') {
+        e.preventDefault();
         if (message) onDismissMessage();
-        setCurrentGuess((prev) => prev.slice(0, -1));
+        if (cursorPos === 0) return;
+        const cp = cursorPos;
+        const newPos = cp - 1;
+        setCursorPos(newPos);
+        setCurrentGuess((prev) => prev.slice(0, newPos) + prev.slice(cp));
+        return;
+      }
+      if (e.key === 'ArrowLeft') {
+        e.preventDefault();
+        setCursorPos((p) => Math.max(0, p - 1));
         return;
       }
-      if (/^[a-zA-Z]$/.test(e.key) && currentGuess.length < 5) {
-        setCurrentGuess((prev) => prev + e.key.toLowerCase());
+      if (e.key === 'ArrowRight') {
+        e.preventDefault();
+        setCursorPos((p) => Math.min(currentGuess.length, p + 1));
+        return;
+      }
+      if (/^[a-zA-Z]$/.test(e.key)) {
+        const letter = e.key.toLowerCase();
+        const cp = cursorPos;
+        setCurrentGuess((prev) => {
+          if (cp < prev.length) {
+            const newGuess = prev.slice(0, cp) + letter + prev.slice(cp + 1);
+            setCursorPos(cp + 1);
+            return newGuess;
+          } else if (prev.length < 5) {
+            setCursorPos(cp + 1);
+            return prev + letter;
+          }
+          return prev;
+        });
       }
     };
 
     document.addEventListener('keydown', handleKeyDown);
     return () => document.removeEventListener('keydown', handleKeyDown);
-  }, [currentGuess, message, gameOver, submitCurrent, onDismissMessage]);
+  }, [currentGuess, cursorPos, message, gameOver, submitCurrent, onDismissMessage]);
 
   // On-screen keyboard handler
   const handleKeyPress = useCallback(
     (key: string) => {
       if (gameOver) return;
+
       if (key === 'Enter') {
         submitCurrent();
       } else if (key === 'Backspace') {
         if (message) onDismissMessage();
-        setCurrentGuess((prev) => prev.slice(0, -1));
-      } else if (currentGuess.length < 5) {
-        setCurrentGuess((prev) => prev + key.toLowerCase());
+        if (cursorPos === 0) return;
+        const cp = cursorPos;
+        const newPos = cp - 1;
+        setCursorPos(newPos);
+        setCurrentGuess((prev) => prev.slice(0, newPos) + prev.slice(cp));
+      } else if (key === 'ArrowLeft') {
+        setCursorPos((p) => Math.max(0, p - 1));
+      } else if (key === 'ArrowRight') {
+        setCursorPos((p) => Math.min(currentGuess.length, p + 1));
+      } else {
+        // Letter key
+        const letter = key.toLowerCase();
+        const cp = cursorPos;
+        setCurrentGuess((prev) => {
+          if (cp < prev.length) {
+            const newGuess = prev.slice(0, cp) + letter + prev.slice(cp + 1);
+            setCursorPos(cp + 1);
+            return newGuess;
+          } else if (prev.length < 5) {
+            setCursorPos(cp + 1);
+            return prev + letter;
+          }
+          return prev;
+        });
       }
     },
-    [currentGuess, message, gameOver, submitCurrent, onDismissMessage]
+    [currentGuess, cursorPos, message, gameOver, submitCurrent, onDismissMessage]
   );
 
+  const enterDisabled = currentGuess.length !== 5 || isValidWord === false;
+
   return (
     <div style={{ maxWidth: '400px', margin: '20px auto', padding: '8px', fontFamily: 'sans-serif', position: 'relative' }}>
       <LangSwitch />
       <h1 style={{ textAlign: 'center', fontSize: '1.6rem', marginBottom: '16px' }}>{getMessages().title}</h1>
 
       <div style={{ position: 'relative' }}>
-        <Grid guesses={[...guesses, currentGuess ? { guess: currentGuess, colors: [] } : null].filter(Boolean) as GuessEntry[]} />
+        <Grid
+          guesses={[...guesses, currentGuess ? { guess: currentGuess, colors: [] } : null].filter(Boolean) as GuessEntry[]}
+          cursorPos={currentGuess.length > 0 ? cursorPos : undefined}
+        />
 
         {message && (
           <div
@@ -101,7 +192,7 @@ export function Screen2Game({ guesses, letterColors, message, onGuess, onDismiss
         )}
       </div>
 
-      <Keyboard letterColors={letterColors} onKeyPress={handleKeyPress} />
+      <Keyboard letterColors={letterColors} onKeyPress={handleKeyPress} enterDisabled={enterDisabled} />
     </div>
   );
 }