feat: add scoreboard with player names, per-category leaderboards, and themed tables

Add player_name column to games (migration included), with regex-based
validation and anti-abuse sanitization. New /scoreboard page shows recent
games from localStorage, popular categories with play counts, and top-10
per-category leaderboards sorted by completion time. Also includes
two-click reverse word matching, base URL prefix support for reverse-proxy
hosting, BasePageHandler refactoring, themed table CSS for all 5 themes,
and comprehensive test coverage for player names and scoreboard API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathew Sir Guest the best
2026-05-07 10:38:19 -06:00
co-authored by Claude Opus 4.6
parent 9319f08875
commit 36c3c712fe
27 changed files with 3038 additions and 24 deletions
+3
View File
@@ -0,0 +1,3 @@
[flake8]
max-line-length = 88
exclude = .venv
+18
View File
@@ -0,0 +1,18 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
- repo: https://github.com/pycqa/flake8
rev: 7.1.2
hooks:
- id: flake8
+1
View File
@@ -0,0 +1 @@
3.13
+78
View File
@@ -0,0 +1,78 @@
# ai_demo_backend
A backend HTTP server that powers a word search puzzle game playable in a web browser. Built with Python and Tornado.
## What is Word Search?
Word search is a puzzle where a grid of letters contains hidden words placed horizontally, vertically, or diagonally. Players scan the grid to find and select each hidden word from a provided list.
This backend serves the game logic — generating puzzles, validating found words, and managing game state — over HTTP for a browser-based frontend.
## Installation
Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/).
```sh
uv sync
```
This installs all runtime and dev dependencies into a local `.venv`.
## Database Setup
Apply migrations and seed the database with word categories (animals, colors, food):
```sh
uv run alembic upgrade head
uv run python seed.py
```
This creates a local `wordsearch.db` SQLite file. The seed script is idempotent — running it again skips existing categories.
## Development
Format code:
```sh
uv run black .
```
Lint:
```sh
uv run flake8
```
## Testing
Run all tests:
```sh
uv run pytest
```
Run only unit tests:
```sh
uv run pytest tests/unit
```
Run only e2e tests:
```sh
uv run pytest tests/e2e
```
## Documentation
Build the API documentation:
```sh
uv run sphinx-build -b html docs/source docs/build/html
```
Then open `docs/build/html/index.html` in a browser.
## See Also
* Mathew Guest
* Co-Authored with Claude
@@ -0,0 +1,40 @@
"""add player_name to games
Revision ID: 3a2aa8e15433
Revises: 71016fe57b9d
Create Date: 2026-05-07 02:32:19.229401
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3a2aa8e15433"
down_revision: Union[str, Sequence[str], None] = "71016fe57b9d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"games",
sa.Column(
"player_name",
sa.String(length=20),
server_default="Anonymous",
nullable=False,
),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("games", "player_name")
# ### end Alembic commands ###
+15 -3
View File
@@ -23,13 +23,14 @@ from handlers.api import (
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
def make_app(session_factory=None, debug=False): def make_app(session_factory=None, debug=False, prefix="/"):
"""Create and configure the Tornado application. """Create and configure the Tornado application.
Args: Args:
session_factory: SQLAlchemy session factory. If None, API session_factory: SQLAlchemy session factory. If None, API
endpoints that require a database will not work. endpoints that require a database will not work.
debug: Enable debug mode (auto-reload, stack traces). debug: Enable debug mode (auto-reload, stack traces).
prefix: URL prefix for reverse-proxy hosting (e.g. ``/wordsearch/``).
Returns: Returns:
A configured ``tornado.web.Application`` instance. A configured ``tornado.web.Application`` instance.
@@ -50,6 +51,7 @@ def make_app(session_factory=None, debug=False):
template_path=os.path.join(BASE_DIR, "templates"), template_path=os.path.join(BASE_DIR, "templates"),
static_path=os.path.join(BASE_DIR, "static"), static_path=os.path.join(BASE_DIR, "static"),
session_factory=session_factory, session_factory=session_factory,
base_url=prefix,
debug=debug, debug=debug,
) )
@@ -57,12 +59,22 @@ def make_app(session_factory=None, debug=False):
@click.command() @click.command()
@click.option("--port", default=8888, type=int, help="Port to listen on.") @click.option("--port", default=8888, type=int, help="Port to listen on.")
@click.option("--debug", is_flag=True, help="Enable debug mode.") @click.option("--debug", is_flag=True, help="Enable debug mode.")
def main(port, debug): @click.option(
"--prefix",
default="/",
help="URL prefix for reverse-proxy hosting (e.g. /wordsearch/).",
)
def main(port, debug, prefix):
"""Start the web server.""" """Start the web server."""
if not prefix.startswith("/"):
prefix = "/" + prefix
if not prefix.endswith("/"):
prefix = prefix + "/"
engine = get_engine() engine = get_engine()
init_db(engine) init_db(engine)
session_factory = get_session_factory(engine) session_factory = get_session_factory(engine)
app = make_app(session_factory=session_factory, debug=debug) app = make_app(session_factory=session_factory, debug=debug, prefix=prefix)
app.listen(port) app.listen(port)
print(f"Server started at http://localhost:{port}") print(f"Server started at http://localhost:{port}")
tornado.ioloop.IOLoop.current().start() tornado.ioloop.IOLoop.current().start()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,763 @@
# Website Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Redesign the word search frontend with a dark indigo palette, pill-style navbar, footer, two new content pages (How to Play, About), and emoji/icon accents.
**Architecture:** Enhance existing Tornado template hierarchy in place. `base.html` gets navbar restructure, Bootstrap Icons CDN, semantic HTML, and a footer. Child templates set an `active_page` block for nav highlighting. Two new handlers + templates for the new pages. CSS swaps teal variables for indigo palette.
**Tech Stack:** Python/Tornado, Bootstrap 5.3 (dark mode), Bootstrap Icons (CDN), pytest + pytest-tornasync
**Spec:** `docs/superpowers/specs/2026-05-06-website-redesign-design.md`
---
## File Map
### Modified
| File | Responsibility |
|------|---------------|
| `tests/e2e/test_hello.py` | E2e tests for all routes including new ones |
| `app.py` | Route registration — add `/howtoplay` and `/about` |
| `static/css/theme.css` | Full palette swap + new component styles |
| `templates/base.html` | Shared layout: navbar, footer, Bootstrap Icons, active page logic |
| `templates/main.html` | Homepage hero with emoji and coral CTA |
| `templates/game.html` | Game board card restyled for new palette |
### Created
| File | Responsibility |
|------|---------------|
| `handlers/howtoplay.py` | HowToPlayHandler — renders howtoplay.html |
| `handlers/about.py` | AboutHandler — renders about.html |
| `templates/howtoplay.html` | How to Play instructions page |
| `templates/about.html` | About page with app description and authors |
---
### Task 1: Add e2e tests for new routes
**Files:**
- Modify: `tests/e2e/test_hello.py`
- [ ] **Step 1: Add tests for `/howtoplay` and `/about` routes**
Replace the contents of `tests/e2e/test_hello.py` with:
```python
import pytest
from app import make_app
@pytest.fixture
def app():
return make_app()
async def test_homepage_returns_200(http_server_client):
response = await http_server_client.fetch("/")
assert response.code == 200
async def test_game_returns_200(http_server_client):
response = await http_server_client.fetch("/game")
assert response.code == 200
async def test_howtoplay_returns_200(http_server_client):
response = await http_server_client.fetch("/howtoplay")
assert response.code == 200
async def test_about_returns_200(http_server_client):
response = await http_server_client.fetch("/about")
assert response.code == 200
```
- [ ] **Step 2: Run tests to verify new tests fail**
Run: `uv run pytest tests/e2e/ -v`
Expected: `test_homepage_returns_200` PASS, `test_game_returns_200` PASS, `test_howtoplay_returns_200` FAIL (404), `test_about_returns_200` FAIL (404)
---
### Task 2: Create handlers and register routes
**Files:**
- Create: `handlers/howtoplay.py`
- Create: `handlers/about.py`
- Create: `templates/howtoplay.html` (minimal placeholder)
- Create: `templates/about.html` (minimal placeholder)
- Modify: `app.py`
- [ ] **Step 1: Create HowToPlayHandler**
Create `handlers/howtoplay.py`:
```python
import tornado.web
class HowToPlayHandler(tornado.web.RequestHandler):
def get(self):
self.render("howtoplay.html")
```
- [ ] **Step 2: Create AboutHandler**
Create `handlers/about.py`:
```python
import tornado.web
class AboutHandler(tornado.web.RequestHandler):
def get(self):
self.render("about.html")
```
- [ ] **Step 3: Create placeholder templates**
Create `templates/howtoplay.html`:
```html
{% extends "base.html" %}
{% block title %}Word Search — How to Play{% end %}
{% block content %}
<h2>How to Play</h2>
<p>Instructions coming soon.</p>
{% end %}
```
Create `templates/about.html`:
```html
{% extends "base.html" %}
{% block title %}Word Search — About{% end %}
{% block content %}
<h2>About</h2>
<p>About page coming soon.</p>
{% end %}
```
- [ ] **Step 4: Register routes in app.py**
In `app.py`, add imports after the existing handler imports:
```python
from handlers.howtoplay import HowToPlayHandler
from handlers.about import AboutHandler
```
Add routes to the `make_app()` route list, after the `/game` route:
```python
(r"/howtoplay", HowToPlayHandler),
(r"/about", AboutHandler),
```
- [ ] **Step 5: Run tests to verify all pass**
Run: `uv run pytest tests/e2e/ -v`
Expected: All 4 tests PASS
- [ ] **Step 6: Lint and commit**
Run: `uv run black . && uv run flake8`
```bash
git add handlers/howtoplay.py handlers/about.py templates/howtoplay.html templates/about.html app.py tests/e2e/test_hello.py
git commit -m "feat: add howtoplay and about routes with placeholder templates"
```
---
### Task 3: Swap CSS palette and add component styles
**Files:**
- Modify: `static/css/theme.css`
- [ ] **Step 1: Replace full contents of `static/css/theme.css`**
```css
:root {
--indigo-darkest: #1a1a2e;
--indigo-dark: #16213e;
--indigo-mid: #0f3460;
--coral: #e94560;
--coral-light: #ff6b81;
--gold: #f5c518;
--text-primary: #e0e0e0;
--text-muted: #a0aec0;
}
body {
background-color: var(--indigo-darkest);
color: var(--text-primary);
}
/* Navbar */
.navbar {
background-color: var(--indigo-dark);
border-bottom: 1px solid var(--indigo-mid);
}
.navbar-brand {
color: var(--coral);
font-weight: 700;
display: flex;
align-items: center;
gap: 0.4rem;
}
.navbar-brand:hover {
color: var(--coral-light);
}
.navbar-nav .nav-link {
color: var(--text-muted);
border-radius: 6px;
padding: 0.4rem 1rem;
margin: 0 0.15rem;
font-weight: 500;
transition: background-color 0.2s, color 0.2s;
}
.navbar-nav .nav-link:hover {
color: #ffffff;
background-color: rgba(15, 52, 96, 0.5);
}
.navbar-nav .nav-link.active {
color: #ffffff;
background-color: var(--indigo-mid);
}
/* Hero */
.hero {
text-align: center;
padding: 3rem 1rem;
}
.hero-emoji {
font-size: 3rem;
margin-bottom: 0.75rem;
}
.hero h1 {
color: #ffffff;
font-weight: 800;
}
.hero .lead {
color: var(--text-muted);
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
/* Buttons */
.btn-accent {
background-color: var(--coral);
border-color: var(--coral);
color: #ffffff;
font-weight: 600;
}
.btn-accent:hover {
background-color: var(--coral-light);
border-color: var(--coral-light);
color: #ffffff;
}
/* Cards */
.card {
background-color: var(--indigo-dark);
border-color: var(--indigo-mid);
}
/* Content pages */
.content-section {
background-color: var(--indigo-dark);
border: 1px solid var(--indigo-mid);
border-radius: 8px;
padding: 2rem;
margin-bottom: 1.5rem;
}
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background-color: var(--indigo-mid);
color: var(--gold);
border-radius: 50%;
font-weight: 700;
font-size: 0.875rem;
flex-shrink: 0;
}
.step-item {
display: flex;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1.25rem;
}
.step-item p {
margin: 0;
}
.tip-card {
background-color: rgba(15, 52, 96, 0.4);
border-left: 3px solid var(--gold);
border-radius: 4px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
}
/* Footer */
.site-footer {
border-top: 1px solid var(--indigo-mid);
padding: 1.25rem 0;
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 3rem;
}
```
- [ ] **Step 2: Verify the app still renders**
Run: `uv run pytest tests/e2e/ -v`
Expected: All 4 tests PASS (CSS changes don't break templates)
- [ ] **Step 3: Commit**
```bash
git add static/css/theme.css
git commit -m "style: swap teal palette for dark indigo with coral accents"
```
---
### Task 4: Restructure base.html
**Files:**
- Modify: `templates/base.html`
- [ ] **Step 1: Replace full contents of `templates/base.html`**
```html
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Word Search{% end %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1WR6zNiELmFp5mGg7gCjSP54e0TpoS"
crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet">
<link rel="stylesheet" href="{{ static_url('css/theme.css') }}">
</head>
<body>
{% block active_page %}{% end %}
<nav class="navbar navbar-expand-lg">
<div class="container">
<a class="navbar-brand" href="/">
<i class="bi bi-search"></i> Word Search
</a>
<button class="navbar-toggler" type="button"
data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link {% if active_page == 'home' %}active{% end %}"
href="/">
<i class="bi bi-house-door"></i> Home
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'play' %}active{% end %}"
href="/game">
<i class="bi bi-controller"></i> Play
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'howtoplay' %}active{% end %}"
href="/howtoplay">
<i class="bi bi-question-circle"></i> How to Play
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_page == 'about' %}active{% end %}"
href="/about">
<i class="bi bi-info-circle"></i> About
</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container py-4">
{% block content %}{% end %}
</main>
<footer class="site-footer">
<div class="container">
&copy; 2026 Word Search
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
</body>
</html>
```
**Note on active page logic:** Tornado templates don't support `{% set %}` like Jinja2. The `{% block active_page %}{% end %}` block is intentionally empty in the base. Each child template overrides it with `{% block active_page %}{% set active_page = "home" %}{% end %}`. Tornado's `{% set %}` creates a template variable accessible in the rest of the template including the base.
- [ ] **Step 2: Update child templates to set active_page**
In `templates/main.html`, add after the `{% extends %}` line:
```html
{% block active_page %}{% set active_page = "home" %}{% end %}
```
In `templates/game.html`, add after the `{% extends %}` line:
```html
{% block active_page %}{% set active_page = "play" %}{% end %}
```
In `templates/howtoplay.html`, add after the `{% extends %}` line:
```html
{% block active_page %}{% set active_page = "howtoplay" %}{% end %}
```
In `templates/about.html`, add after the `{% extends %}` line:
```html
{% block active_page %}{% set active_page = "about" %}{% end %}
```
- [ ] **Step 3: Run tests**
Run: `uv run pytest tests/e2e/ -v`
Expected: All 4 tests PASS
- [ ] **Step 4: Commit**
```bash
git add templates/base.html templates/main.html templates/game.html templates/howtoplay.html templates/about.html
git commit -m "feat: restructure base template with navbar pills, footer, and active page logic"
```
---
### Task 5: Restyle homepage hero
**Files:**
- Modify: `templates/main.html`
- [ ] **Step 1: Replace content block in `templates/main.html`**
Full file contents:
```html
{% extends "base.html" %}
{% block active_page %}{% set active_page = "home" %}{% end %}
{% block title %}Word Search — Home{% end %}
{% block content %}
<header class="hero">
<div class="hero-emoji">🧩</div>
<h1 class="display-4 fw-bold mb-3">Word Search</h1>
<p class="lead mb-4">
Find hidden words in a grid of letters. Words can appear horizontally,
vertically, or diagonally. How fast can you find them all?
</p>
<a href="/game" class="btn btn-accent btn-lg px-5">🎮 Start Game</a>
</header>
{% end %}
```
- [ ] **Step 2: Run tests**
Run: `uv run pytest tests/e2e/ -v`
Expected: All 4 tests PASS
- [ ] **Step 3: Commit**
```bash
git add templates/main.html
git commit -m "style: restyle homepage hero with emoji and coral CTA"
```
---
### Task 6: Update game page for new palette
**Files:**
- Modify: `templates/game.html`
- [ ] **Step 1: Replace full contents of `templates/game.html`**
```html
{% extends "base.html" %}
{% block active_page %}{% set active_page = "play" %}{% end %}
{% block title %}Word Search — Play{% end %}
{% block content %}
<h2 class="mb-4">🎯 Game Board</h2>
<div class="card">
<div class="card-body text-center py-5">
<p class="text-muted">Game board coming soon.</p>
</div>
</div>
{% end %}
```
- [ ] **Step 2: Run tests**
Run: `uv run pytest tests/e2e/ -v`
Expected: All 4 tests PASS
- [ ] **Step 3: Commit**
```bash
git add templates/game.html
git commit -m "style: update game page with active nav and emoji heading"
```
---
### Task 7: Create How to Play page content
**Files:**
- Modify: `templates/howtoplay.html`
- [ ] **Step 1: Replace full contents of `templates/howtoplay.html`**
```html
{% extends "base.html" %}
{% block active_page %}{% set active_page = "howtoplay" %}{% end %}
{% block title %}Word Search — How to Play{% end %}
{% block content %}
<h2 class="mb-4">📖 How to Play</h2>
<div class="content-section">
<h3 class="mb-4">Step-by-Step Instructions</h3>
<div class="step-item">
<span class="step-number">1</span>
<p>
📋 <strong>Check the word list</strong> — Look at the list of words
displayed alongside the grid. These are the words you need to find.
</p>
</div>
<div class="step-item">
<span class="step-number">2</span>
<p>
🔍 <strong>Scan for the first letter</strong> — Pick a word from the
list and scan the grid for its first letter. This gives you a starting
point.
</p>
</div>
<div class="step-item">
<span class="step-number">3</span>
<p>
↗️ <strong>Look in all directions</strong> — Words can run
horizontally, vertically, or diagonally — and they can go forward or
backward. Check all eight directions from your starting letter.
</p>
</div>
<div class="step-item">
<span class="step-number">4</span>
<p>
👆 <strong>Select the word</strong> — Click or tap the first letter of
the word, then click the last letter to highlight the entire word.
</p>
</div>
<div class="step-item">
<span class="step-number">5</span>
<p>
<strong>Words get crossed off</strong> — When you correctly find a
word, it gets crossed off the list so you can track your progress.
</p>
</div>
<div class="step-item">
<span class="step-number">6</span>
<p>
🏆 <strong>Find them all to win!</strong> — The puzzle is complete when
every word on the list has been found. Try to beat your best time!
</p>
</div>
</div>
<h3 class="mb-3">💡 Tips</h3>
<div class="tip-card">
<strong>Start with uncommon letters</strong> — Letters like Q, X, Z, and J
appear less often in the grid, making words with those letters easier to spot.
</div>
<div class="tip-card">
<strong>Scan systematically</strong> — Work through the grid row by row or
column by column rather than jumping around randomly.
</div>
<div class="tip-card">
<strong>Look for word shapes</strong> — Sometimes you can spot diagonal words
by the distinctive slanted pattern they make in the grid.
</div>
{% end %}
```
- [ ] **Step 2: Run tests**
Run: `uv run pytest tests/e2e/ -v`
Expected: All 4 tests PASS
- [ ] **Step 3: Commit**
```bash
git add templates/howtoplay.html
git commit -m "feat: add How to Play page with step-by-step instructions and tips"
```
---
### Task 8: Create About page content
**Files:**
- Modify: `templates/about.html`
- [ ] **Step 1: Replace full contents of `templates/about.html`**
```html
{% extends "base.html" %}
{% block active_page %}{% set active_page = "about" %}{% end %}
{% block title %}Word Search — About{% end %}
{% block content %}
<h2 class="mb-4">️ About</h2>
<div class="content-section">
<h3>🧩 What is Word Search?</h3>
<p>
Word Search is a browser-based puzzle game where you find hidden words in a
grid of letters. Words can be placed horizontally, vertically, or
diagonally — forwards or backwards. The backend generates puzzles,
validates your selections, and tracks your progress in real time.
</p>
</div>
<div class="content-section">
<h3>🛠️ Built With</h3>
<ul>
<li><strong>Python</strong> — core language</li>
<li><strong>Tornado</strong> — async web framework</li>
<li><strong>Bootstrap 5</strong> — responsive UI</li>
<li><strong>Bootstrap Icons</strong> — interface icons</li>
</ul>
</div>
<div class="content-section">
<h3>✍️ Authors</h3>
<ul>
<li><strong>Mathew Guest</strong> — creator and developer</li>
<li><strong>🤖 Claude</strong> (Anthropic) — AI co-author</li>
</ul>
</div>
<div class="content-section">
<h3>📂 Source Code</h3>
<p>
The source code for this project will be available on GitHub.
<em>Link coming soon.</em>
</p>
</div>
{% end %}
```
- [ ] **Step 2: Run tests**
Run: `uv run pytest tests/e2e/ -v`
Expected: All 4 tests PASS
- [ ] **Step 3: Commit**
```bash
git add templates/about.html
git commit -m "feat: add About page with app description, tech stack, and authors"
```
---
### Task 9: Final verification
- [ ] **Step 1: Run full test suite**
Run: `uv run pytest -v`
Expected: All tests PASS (unit + e2e)
- [ ] **Step 2: Lint and format**
Run: `uv run black . && uv run flake8`
Expected: No errors
- [ ] **Step 3: Start the server and visually verify with Playwright**
Start the app: `uv run python app.py &`
Use Playwright MCP tools to:
1. Navigate to `http://localhost:8888` — verify indigo palette, navbar with 4 pill-style links, "Home" is active, hero section with puzzle emoji, coral "Start Game" button, footer with copyright
2. Navigate to `http://localhost:8888/game` — verify "Play" is active in navbar, game card renders
3. Navigate to `http://localhost:8888/howtoplay` — verify "How to Play" is active, 6 numbered steps visible, 3 tip cards visible
4. Navigate to `http://localhost:8888/about` — verify "About" is active, 4 content sections visible (What is, Built With, Authors, Source Code)
5. Resize browser to mobile width — verify navbar collapses to hamburger menu
- [ ] **Step 4: Stop the server and commit any fixes**
Kill the background server process. If any visual issues were found and fixed, commit the fixes.
@@ -0,0 +1,95 @@
# Website Redesign — Design Spec
## Context
The word search game has a functional but bare-bones frontend — two pages, a basic teal dark theme, no footer, no imagery. This redesign makes it visually polished and adds two new content pages, while keeping the existing Tornado template architecture.
## Decisions
- **Palette:** Dark Indigo + Warm Pop — `#1a1a2e` base, `#16213e` navbar/cards, `#0f3460` borders/active states, `#e94560` coral accent, `#f5c518` gold highlights
- **Approach:** Enhance in place — modify existing templates and CSS, add new templates and handlers
- **Nav:** 4 links — Home, Play, How to Play, About — rendered as pill-style buttons with active state highlighting
- **Footer:** Minimal — copyright line only
- **Icons:** Bootstrap Icons (CDN) for UI elements (nav, buttons)
- **Emoji:** In content headings and hero sections for personality
## Pages
### Home (`/`)
- Hero section with puzzle piece emoji, heading, description, coral "Start Game" CTA button with gamepad emoji
- Semantic: `<header>` for hero, `<main>` wrapper from base
### Play (`/game`)
- Existing game board placeholder, restyled with new palette
- No structural changes
### How to Play (`/howtoplay`) — NEW
- Step-by-step instructions for word search:
1. Look at the word list on the side of the grid
2. Scan the grid for the first letter of a word
3. Words can run horizontally, vertically, or diagonally (forward or backward)
4. Click/tap the first letter, then the last letter to select a word
5. Found words are crossed off the list
6. Find all words to win
- Use numbered steps with emoji accents (e.g. magnifying glass, eyes, checkmark)
- Tips section at the bottom
### About (`/about`) — NEW
- Short app description: what it is, what it's built with (Python, Tornado, Bootstrap)
- Author: Claude (AI assistant by Anthropic) — listed as co-author with the project creator
- Placeholder for git repo link (to be added later)
- Use emoji accents (robot face, tools, etc.)
## Files to Modify
### `static/css/theme.css`
- Replace teal CSS custom properties with indigo palette
- Add navbar pill-button styles (`.nav-link` as rounded pills, active state with `#0f3460` fill)
- Add footer styles (border-top, muted text, centered)
- Add hero section styles
- Style the new content pages (step lists, about section)
### `templates/base.html`
- Add Bootstrap Icons CDN link in `<head>`
- Restructure navbar: brand gets magnifying glass icon, nav links get pill-button classes
- Active-link logic: each child template sets `{% block active_page %}home{% end %}`, base template uses conditionals to add an `.active` class to the matching nav link
- Wrap `<main>` content area properly
- Add `<footer>` with copyright before closing `</body>`
- Use semantic HTML: `<nav>`, `<main>`, `<footer>`
### `templates/main.html`
- Restyle hero with emoji, updated button classes for coral accent
### `templates/game.html`
- Update card styling to match new palette (no structural changes)
### `app.py`
- Add routes: `/howtoplay` -> `HowToPlayHandler`, `/about` -> `AboutHandler`
- Import new handlers
## Files to Create
### `handlers/howtoplay.py`
- `HowToPlayHandler` — renders `howtoplay.html`
### `handlers/about.py`
- `AboutHandler` — renders `about.html`
### `templates/howtoplay.html`
- Extends `base.html`
- Numbered instruction steps with emoji
- Tips section
### `templates/about.html`
- Extends `base.html`
- App description, author info, repo link placeholder
## Verification
1. Run `uv run python app.py` and open `http://localhost:8888`
2. Check all 4 pages render correctly with new palette
3. Verify navbar highlights active page
4. Verify footer appears on all pages
5. Check responsive behavior (navbar collapses on mobile)
6. Run `uv run pytest` to confirm e2e test still passes
7. Run `uv run black .` and `uv run flake8` for code quality
+19
View File
@@ -0,0 +1,19 @@
"""Shared handler base classes."""
import tornado.web
class BasePageHandler(tornado.web.RequestHandler):
"""Base handler that injects ``base_url`` into all templates."""
def static_url(self, path, **kwargs):
url = super().static_url(path, **kwargs)
base_url = self.application.settings.get("base_url", "/")
if base_url != "/":
url = base_url + url.lstrip("/")
return url
def get_template_namespace(self):
ns = super().get_template_namespace()
ns["base_url"] = self.application.settings.get("base_url", "/")
return ns
+6 -2
View File
@@ -1,6 +1,10 @@
import tornado.web """About page handler."""
from handlers import BasePageHandler
class AboutHandler(tornado.web.RequestHandler): class AboutHandler(BasePageHandler):
"""Serves the about page."""
def get(self): def get(self):
self.render("about.html") self.render("about.html")
+9 -1
View File
@@ -290,11 +290,19 @@ class GuessHandler(BaseAPIHandler):
except (IndexError, KeyError, TypeError): except (IndexError, KeyError, TypeError):
return self._miss_response(game, session) return self._miss_response(game, session)
# Try forward and reversed letter order
reversed_letters = letters[::-1]
word = ( word = (
session.query(Word) session.query(Word)
.filter_by(game_id=game.id, text=letters, found=False) .filter_by(game_id=game.id, text=letters, found=False)
.first() .first()
) )
if not word:
word = (
session.query(Word)
.filter_by(game_id=game.id, text=reversed_letters, found=False)
.first()
)
if not word: if not word:
return self._miss_response(game, session) return self._miss_response(game, session)
@@ -303,7 +311,7 @@ class GuessHandler(BaseAPIHandler):
{"row": word.start_row + i * dr, "col": word.start_col + i * dc} {"row": word.start_row + i * dr, "col": word.start_col + i * dc}
for i in range(len(word.text)) for i in range(len(word.text))
] ]
if cells != expected_cells: if cells != expected_cells and list(reversed(cells)) != expected_cells:
return self._miss_response(game, session) return self._miss_response(game, session)
return self._mark_found(session, game, word) return self._mark_found(session, game, word)
+10
View File
@@ -0,0 +1,10 @@
"""Game page handler."""
from handlers import BasePageHandler
class GameHandler(BasePageHandler):
"""Serves the game page where users play word search."""
def get(self):
self.render("game.html")
+6 -2
View File
@@ -1,6 +1,10 @@
import tornado.web """How to Play page handler."""
from handlers import BasePageHandler
class HowToPlayHandler(tornado.web.RequestHandler): class HowToPlayHandler(BasePageHandler):
"""Serves the how-to-play page."""
def get(self): def get(self):
self.render("howtoplay.html") self.render("howtoplay.html")
+10
View File
@@ -0,0 +1,10 @@
"""Landing page handler."""
from handlers import BasePageHandler
class MainHandler(BasePageHandler):
"""Serves the home page."""
def get(self):
self.render("main.html")
+2 -2
View File
@@ -1,9 +1,9 @@
"""Scoreboard page handler.""" """Scoreboard page handler."""
import tornado.web from handlers import BasePageHandler
class ScoreboardPageHandler(tornado.web.RequestHandler): class ScoreboardPageHandler(BasePageHandler):
"""Serves the scoreboard page.""" """Serves the scoreboard page."""
def get(self): def get(self):
+22
View File
@@ -269,6 +269,28 @@ body {
font-weight: 600; font-weight: 600;
} }
/* Tables (scoreboard) */
.table {
color: var(--text-primary);
border-color: var(--indigo-mid);
}
.table > :not(caption) > * > * {
background-color: var(--indigo-dark);
border-bottom-color: var(--indigo-mid);
color: var(--text-primary);
}
.table-hover > tbody > tr:hover > * {
background-color: var(--indigo-mid);
color: var(--heading-color);
}
.table thead th {
color: var(--heading-color);
border-bottom-color: var(--indigo-mid);
}
/* Form controls */ /* Form controls */
.form-select, .form-select,
.form-control { .form-control {
+5 -3
View File
@@ -1,6 +1,8 @@
(function () { (function () {
"use strict"; "use strict";
var BASE = document.body.dataset.baseUrl || "/";
let gameState = null; let gameState = null;
let timerInterval = null; let timerInterval = null;
let selectedCells = []; let selectedCells = [];
@@ -35,7 +37,7 @@
// Load categories // Load categories
async function loadCategories() { async function loadCategories() {
try { try {
const resp = await fetch("/api/categories"); const resp = await fetch(BASE + "api/categories");
if (!resp.ok) throw new Error("Server error"); if (!resp.ok) throw new Error("Server error");
const categories = await resp.json(); const categories = await resp.json();
categorySelect.innerHTML = ""; categorySelect.innerHTML = "";
@@ -65,7 +67,7 @@
const boardSize = parseInt(boardSizeInput.value); const boardSize = parseInt(boardSizeInput.value);
const wordCount = parseInt(wordCountInput.value); const wordCount = parseInt(wordCountInput.value);
const playerName = playerNameInput.value.trim(); const playerName = playerNameInput.value.trim();
const resp = await fetch("/api/game/new", { const resp = await fetch(BASE + "api/game/new", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -253,7 +255,7 @@
// Send guess to server // Send guess to server
async function sendGuess(payload) { async function sendGuess(payload) {
try { try {
const resp = await fetch("/api/game/" + gameState.id + "/guess", { const resp = await fetch(BASE + "api/game/" + gameState.id + "/guess", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
+4 -2
View File
@@ -1,6 +1,8 @@
(function () { (function () {
"use strict"; "use strict";
var BASE = document.body.dataset.baseUrl || "/";
var loadingDiv = document.getElementById("scoreboard-loading"); var loadingDiv = document.getElementById("scoreboard-loading");
var recentSection = document.getElementById("recent-games-section"); var recentSection = document.getElementById("recent-games-section");
var recentTbody = document.querySelector("#recent-games-table tbody"); var recentTbody = document.querySelector("#recent-games-table tbody");
@@ -65,7 +67,7 @@
' <div class="card-body">' + ' <div class="card-body">' +
' <h5 class="card-title">' + escapeHtml(titleCase(c.name)) + '</h5>' + ' <h5 class="card-title">' + escapeHtml(titleCase(c.name)) + '</h5>' +
' <p class="card-text text-muted">' + c.completed_games + ' games completed</p>' + ' <p class="card-text text-muted">' + c.completed_games + ' games completed</p>' +
' <a href="/game" class="btn btn-accent btn-sm">Play</a>' + ' <a href="' + BASE + 'game" class="btn btn-accent btn-sm">Play</a>' +
' </div>' + ' </div>' +
'</div>'; '</div>';
popularRow.appendChild(col); popularRow.appendChild(col);
@@ -115,7 +117,7 @@
async function loadScoreboard() { async function loadScoreboard() {
try { try {
var resp = await fetch("/api/scoreboard"); var resp = await fetch(BASE + "api/scoreboard");
if (!resp.ok) throw new Error("Failed to load"); if (!resp.ok) throw new Error("Failed to load");
var data = await resp.json(); var data = await resp.json();
+7 -7
View File
@@ -19,12 +19,12 @@
})(); })();
</script> </script>
</head> </head>
<body> <body data-base-url="{{ base_url }}">
{% block active_page %}{% end %} {% block active_page %}{% end %}
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<div class="container"> <div class="container">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="{{ base_url }}">
<i class="bi bi-search"></i> Word Search <i class="bi bi-search"></i> Word Search
</a> </a>
<button class="navbar-toggler" type="button" <button class="navbar-toggler" type="button"
@@ -35,31 +35,31 @@
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'home' %}active{% end %}" <a class="nav-link {% if active_page == 'home' %}active{% end %}"
href="/"> href="{{ base_url }}">
<i class="bi bi-house-door"></i> Home <i class="bi bi-house-door"></i> Home
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'play' %}active{% end %}" <a class="nav-link {% if active_page == 'play' %}active{% end %}"
href="/game"> href="{{ base_url }}game">
<i class="bi bi-controller"></i> Play <i class="bi bi-controller"></i> Play
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'howtoplay' %}active{% end %}" <a class="nav-link {% if active_page == 'howtoplay' %}active{% end %}"
href="/howtoplay"> href="{{ base_url }}howtoplay">
<i class="bi bi-question-circle"></i> How to Play <i class="bi bi-question-circle"></i> How to Play
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'scoreboard' %}active{% end %}" <a class="nav-link {% if active_page == 'scoreboard' %}active{% end %}"
href="/scoreboard"> href="{{ base_url }}scoreboard">
<i class="bi bi-trophy"></i> Scoreboard <i class="bi bi-trophy"></i> Scoreboard
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if active_page == 'about' %}active{% end %}" <a class="nav-link {% if active_page == 'about' %}active{% end %}"
href="/about"> href="{{ base_url }}about">
<i class="bi bi-info-circle"></i> About <i class="bi bi-info-circle"></i> About
</a> </a>
</li> </li>
+1 -1
View File
@@ -12,6 +12,6 @@
Find hidden words in a grid of letters. Words can appear horizontally, Find hidden words in a grid of letters. Words can appear horizontally,
vertically, or diagonally. How fast can you find them all? vertically, or diagonally. How fast can you find them all?
</p> </p>
<a href="/game" class="btn btn-accent btn-lg px-5">🎮 Start Game</a> <a href="{{ base_url }}game" class="btn btn-accent btn-lg px-5">🎮 Start Game</a>
</header> </header>
{% end %} {% end %}
+1 -1
View File
@@ -48,7 +48,7 @@
<div id="scoreboard-empty" class="d-none text-center py-4"> <div id="scoreboard-empty" class="d-none text-center py-4">
<p class="text-muted fs-5">No completed games yet. Be the first to finish a puzzle!</p> <p class="text-muted fs-5">No completed games yet. Be the first to finish a puzzle!</p>
<a href="/game" class="btn btn-accent">Start a Game</a> <a href="{{ base_url }}game" class="btn btn-accent">Start a Game</a>
</div> </div>
<script src="{{ static_url('js/scoreboard.js') }}"></script> <script src="{{ static_url('js/scoreboard.js') }}"></script>
View File
View File
+98
View File
@@ -258,3 +258,101 @@ async def test_guess_cells_with_null_element(http_server_client, seeded_db):
assert resp.code == 200 assert resp.code == 200
data = json.loads(resp.body) data = json.loads(resp.body)
assert data["correct"] is False assert data["correct"] is False
async def test_new_game_with_player_name(http_server_client, seeded_db):
body = json.dumps(
{"category_id": seeded_db.id, "board_size": 10, "player_name": "Alice"}
)
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert data["player_name"] == "Alice"
async def test_new_game_default_player_name(http_server_client, seeded_db):
body = json.dumps({"category_id": seeded_db.id, "board_size": 10})
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
data = json.loads(resp.body)
assert data["player_name"] == "Anonymous"
async def test_new_game_invalid_player_name_chars(http_server_client, seeded_db):
body = json.dumps(
{"category_id": seeded_db.id, "player_name": "<script>alert(1)</script>"}
)
resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
raise_error=False,
)
assert resp.code == 400
data = json.loads(resp.body)
assert "error" in data
async def test_scoreboard_empty(http_server_client, seeded_db):
resp = await http_server_client.fetch("/api/scoreboard")
assert resp.code == 200
data = json.loads(resp.body)
assert data["leaderboards"] == {}
assert isinstance(data["popular_categories"], list)
async def test_scoreboard_with_completed_game(http_server_client, seeded_db):
# Create a game with few words so we can complete it
body = json.dumps(
{
"category_id": seeded_db.id,
"board_size": 10,
"word_count": 3,
"player_name": "TestPlayer",
}
)
create_resp = await http_server_client.fetch(
"/api/game/new",
method="POST",
body=body,
headers={"Content-Type": "application/json"},
)
game_data = json.loads(create_resp.body)
game_id = game_data["id"]
# Find all words by text guess
for w in game_data["words"]:
guess_body = json.dumps({"word": w["text"]})
resp = await http_server_client.fetch(
f"/api/game/{game_id}/guess",
method="POST",
body=guess_body,
headers={"Content-Type": "application/json"},
)
assert resp.code == 200
# Verify game is completed
state_resp = await http_server_client.fetch(f"/api/game/{game_id}")
state_data = json.loads(state_resp.body)
assert state_data["status"] == "completed"
# Now check scoreboard
sb_resp = await http_server_client.fetch("/api/scoreboard")
assert sb_resp.code == 200
sb_data = json.loads(sb_resp.body)
assert "animals" in sb_data["leaderboards"]
entry = sb_data["leaderboards"]["animals"][0]
assert entry["player_name"] == "TestPlayer"
assert entry["time_seconds"] >= 0
assert entry["board_size"] == 10
+33
View File
@@ -0,0 +1,33 @@
import pytest
from app import make_app
@pytest.fixture
def app():
return make_app()
async def test_homepage_returns_200(http_server_client):
response = await http_server_client.fetch("/")
assert response.code == 200
async def test_game_returns_200(http_server_client):
response = await http_server_client.fetch("/game")
assert response.code == 200
async def test_howtoplay_returns_200(http_server_client):
response = await http_server_client.fetch("/howtoplay")
assert response.code == 200
async def test_about_returns_200(http_server_client):
response = await http_server_client.fetch("/about")
assert response.code == 200
async def test_scoreboard_returns_200(http_server_client):
response = await http_server_client.fetch("/scoreboard")
assert response.code == 200
View File