Initial commit

This commit is contained in:
Ovidiu U
2026-05-04 09:45:17 +01:00
commit 18b7e11657
31 changed files with 2838 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.git/
.gitignore
*.md

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
.DS_Store
.idea/

97
CLAUDE.md Normal file
View File

@@ -0,0 +1,97 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project status
The repo currently contains only `wordsearch-specs.md` — the implementation has not been written yet. The spec is the source of truth; read it before any change. When implementing, follow the directory layout, routes, and behaviours it prescribes rather than improvising.
## Local development
Iterate on the Mac with a venv + `uvicorn --reload`; only use Docker when verifying the deploy. The Docker bind-mount (`./themes`) points at the same directory, so local and container runs share theme state.
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
`--reload` restarts on `.py` changes; Jinja2 templates reload without a restart. `.venv/` must be in `.gitignore`.
### Dependency management
**Use `requirements.txt` with pinned versions. Do not introduce Poetry, uv, Pipenv, or `pyproject.toml`.** This is a homelab app — plain `pip` works identically in the venv and inside the Docker container with no extra tooling.
Initial pin set (don't drop any of these without checking why they're here):
```
fastapi==0.115.0
uvicorn[standard]==0.32.0
jinja2==3.1.4
reportlab==4.2.5
python-multipart==0.0.12
```
`python-multipart` is required for the form-encoded `POST /generate` and theme-editor submits — easy to omit until the form 500s.
### What not to add
No test framework, no linter, no formatter, no build step is specified. Don't introduce one without asking. The project is deliberately minimal: single language, single process, no build pipeline, no DB.
### Docker
Production runs the same code in a container — `docker compose up` mounts `./themes` as a volume. The container has no venv (the container *is* the isolation); the Dockerfile `pip install`s into the system Python. Don't try to share a venv between host and container.
## Architecture
### Two-form word model (central design idea)
Every input word produces two strings, and they are not interchangeable:
- **Display form** — original string, untouched. Goes on the PDF word list.
- **Grid form** — stripped of leading prefix tokens, uppercased, whitespace/punctuation removed. Goes in the grid and is what length filters and placement operate on.
Prefix stripping (`Mr`, `Mrs`, `Miss`, `Dr`, `Sir`, `Captain`, `Saint`, etc. — full list in `app/normaliser.py::PREFIXES`) is **token-based, case-insensitive, with optional trailing dot**. `"Misty"` does NOT match `"Miss"`. Multiple consecutive prefixes strip (`"Mr Dr Strange"``STRANGE`). A word that is *only* a prefix (`"Mr"`) keeps as `MR` and logs a stderr warning. Empty grid form after stripping → skip and warn.
The PDF word list shows `GRIDFORM (original prefix tokens)` only when something was stripped, e.g. `TICKLE (Mr)`. Bare `GRIDFORM` otherwise.
### Direction set is computed, not picked
Don't expose every direction as an independent toggle. Horizontal (→) and vertical (↓) are always on. The two user toggles compose:
```
base = { →, ↓ }
if diagonal: base |= { ↘, ↗ }
if reversed: base |= { reverse(d) for d in base }
```
So `reversed` without `diagonal` gives `→ ↓ ← ↑` (no diagonals reversed because none were in `base`). `diagonal + reversed` gives all 8.
### Placement: retry-then-skip, not backtrack
Per word: random direction + random start, validate collision (empty cells, or matching letters when overlap is on), commit or retry up to 200 times. Skip after 200 and warn — never block, never backtrack already-placed words. If fewer words placed than requested, generate the puzzle anyway and surface a warning to the result page.
### No sticky state
All optional toggles (diagonal, reversed, overlap) default **off** on every `/` page load. Don't add cookies, localStorage, or session state to remember the last config. Each puzzle is configured explicitly.
### Storage
Flat JSON files under `themes/<slug>.json` with `{ name, words: [...] }`. No DB. The slug is the filename and is locked after creation. `themes/` is the only persistence — generated PDFs are streamed straight to the browser as `<slug>_<YYYY-MM-DD_HH-MM-SS>.pdf` and never written to disk on the server.
### Server-rendered, almost no JS
Jinja2 templates only. The single piece of vanilla JS lives in the theme editor: a debounced `fetch` to `POST /api/normalise` to render the live preview of grid forms. No bundler, no framework, no build artifacts in the repo.
### Auth
None by design (homelab use). If exposing publicly, add HTTP basic auth as FastAPI middleware — don't introduce a user model.
## Conventions worth preserving
- Validation errors (invalid slug, empty word list, `max_length < min_length`) must render a clear message, never a stack trace.
- `max_length` is clamped to `grid_size` before filtering.
- All grid letters are uppercase AZ, including the random fill.
- The PDF is plain: no footer, no timestamp, no branding, no toggle state printed. The filename carries the timestamp.
- v1 ships puzzle-only — no answer key PDF.

8
Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY static/ ./static/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

60
README.md Normal file
View File

@@ -0,0 +1,60 @@
# Wordsmith — Puzzle Workshop
A small self-hosted web app that generates printable word search PDFs from
themed word lists. Single Python process, no database, no build step.
## Stack
- Python 3.14+
- FastAPI + Jinja2 (server-rendered HTML)
- reportlab (PDF)
- A small piece of vanilla JS for the live theme-editor preview
## Local development
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
`--reload` restarts on `.py` changes; Jinja templates reload without a restart.
## Docker
```bash
docker compose up --build
```
Maps host port `3801` → container `8000`. The bind-mounts `./themes:/app/themes`,
so any theme JSON files in the repo's `themes/` folder are picked up at boot
and any UI-edited themes are written back to the same place.
## Themes
Each theme is a flat JSON file under `themes/<slug>.json`:
```json
{
"name": "Mr Men Characters",
"words": ["Mr Tickle", "Mr Happy", "Little Miss Sunshine"]
}
```
Words can include leading honorific tokens (`Mr`, `Mrs`, `Miss`, `Dr`,
`Captain`, etc.) — they're stripped from the grid form but kept on the PDF
word list in parentheses, e.g. `TICKLE (Mr)`.
## Deployment
The repo is intended to be deployed via [Komodo](https://komo.do)'s git-stack
mode: point a stack at this repo, set the compose path to `docker-compose.yml`,
and redeploy. Themes added via the deployed UI live in Komodo's per-stack
checkout — they survive normal redeploys but a Komodo "Destroy" wipes them.
For lasting changes, edit themes locally and `git push`.
## Generated PDFs
Generated puzzles stream straight to the browser as a download
(`<slug>_<YYYY-MM-DD_HH-MM-SS>.pdf`); the server keeps no copy on disk.

0
app/__init__.py Normal file
View File

198
app/generator.py Normal file
View File

@@ -0,0 +1,198 @@
"""Grid building and word placement."""
from __future__ import annotations
import random
import string
from dataclasses import dataclass, field
from .normaliser import Normalised, normalise_all
# Direction vectors: (drow, dcol)
RIGHT = (0, 1)
DOWN = (1, 0)
DOWN_RIGHT = (1, 1)
UP_RIGHT = (-1, 1)
LEFT = (0, -1)
UP = (-1, 0)
UP_LEFT = (-1, -1)
DOWN_LEFT = (1, -1)
PLACEMENT_RETRIES = 200
@dataclass
class Placement:
word: Normalised
row: int
col: int
direction: tuple[int, int]
@dataclass
class Puzzle:
grid: list[list[str]]
placements: list[Placement]
skipped: list[Normalised] = field(default_factory=list)
filtered_out: list[Normalised] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
diagonal: bool = False
reversed_: bool = False
allow_overlap: bool = False
@property
def size(self) -> int:
return len(self.grid)
@property
def option_labels(self) -> list[str]:
labels = []
if self.diagonal:
labels.append("Diagonal")
if self.reversed_:
labels.append("Reversed")
if self.allow_overlap:
labels.append("Overlapping")
return labels
def active_directions(diagonal: bool, reversed_: bool) -> list[tuple[int, int]]:
base = [RIGHT, DOWN]
if diagonal:
base += [DOWN_RIGHT, UP_RIGHT]
if reversed_:
base = base + [(-dr, -dc) for (dr, dc) in base]
return base
def _can_place(grid: list[list[str]], word: str, row: int, col: int,
direction: tuple[int, int], allow_overlap: bool) -> bool:
size = len(grid)
dr, dc = direction
for i, ch in enumerate(word):
r, c = row + dr * i, col + dc * i
if not (0 <= r < size and 0 <= c < size):
return False
existing = grid[r][c]
if existing == "":
continue
if allow_overlap and existing == ch:
continue
return False
return True
def _commit(grid: list[list[str]], word: str, row: int, col: int,
direction: tuple[int, int]) -> None:
dr, dc = direction
for i, ch in enumerate(word):
grid[row + dr * i][col + dc * i] = ch
def _start_bounds(size: int, length: int,
direction: tuple[int, int]) -> tuple[range, range]:
"""Valid starting (row, col) ranges so the whole word fits."""
dr, dc = direction
if dr >= 0:
rows = range(0, size - (length - 1) * dr)
else:
rows = range((length - 1) * (-dr), size)
if dc >= 0:
cols = range(0, size - (length - 1) * dc)
else:
cols = range((length - 1) * (-dc), size)
return rows, cols
def generate(
*,
words: list[str],
grid_size: int,
word_count: int,
min_length: int,
max_length: int,
diagonal: bool,
reversed_: bool,
allow_overlap: bool,
rng: random.Random | None = None,
) -> Puzzle:
if rng is None:
rng = random.Random()
if grid_size < 5 or grid_size > 25:
raise ValueError("grid size must be between 5 and 25")
if word_count < 1 or word_count > 30:
raise ValueError("word count must be between 1 and 30")
if min_length < 1:
raise ValueError("min length must be at least 1")
if max_length < min_length:
raise ValueError("max length must be greater than or equal to min length")
effective_max = min(max_length, grid_size)
normalised = normalise_all(words)
eligible: list[Normalised] = []
filtered_out: list[Normalised] = []
skipped: list[Normalised] = []
for n in normalised:
if n.skipped:
skipped.append(n)
continue
length = len(n.grid)
if min_length <= length <= effective_max:
eligible.append(n)
else:
filtered_out.append(n)
rng.shuffle(eligible)
directions = active_directions(diagonal, reversed_)
grid: list[list[str]] = [["" for _ in range(grid_size)]
for _ in range(grid_size)]
placements: list[Placement] = []
warnings: list[str] = []
for word in eligible:
if len(placements) >= word_count:
break
placed = False
for _ in range(PLACEMENT_RETRIES):
direction = rng.choice(directions)
rows, cols = _start_bounds(grid_size, len(word.grid), direction)
if not rows or not cols:
continue
row = rng.choice(rows)
col = rng.choice(cols)
if _can_place(grid, word.grid, row, col, direction, allow_overlap):
_commit(grid, word.grid, row, col, direction)
placements.append(Placement(word=word, row=row, col=col,
direction=direction))
placed = True
break
if not placed:
warnings.append(
f"could not place word {word.grid!r} after "
f"{PLACEMENT_RETRIES} attempts; skipping"
)
if filtered_out:
warnings.append(
f"{len(filtered_out)} word(s) excluded by length filter "
f"(min={min_length}, max={effective_max})"
)
if len(placements) < word_count:
warnings.append(
f"asked for {word_count} word(s); placed {len(placements)}"
)
# Fill remaining empty cells with random uppercase letters.
for r in range(grid_size):
for c in range(grid_size):
if grid[r][c] == "":
grid[r][c] = rng.choice(string.ascii_uppercase)
return Puzzle(grid=grid, placements=placements, skipped=skipped,
filtered_out=filtered_out, warnings=warnings,
diagonal=diagonal, reversed_=reversed_,
allow_overlap=allow_overlap)

251
app/main.py Normal file
View File

@@ -0,0 +1,251 @@
"""FastAPI app: routes for puzzle generation and theme management."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import themes as theme_store
from .generator import generate
from .normaliser import normalise_all
from .pdf import render_puzzle
ROOT = Path(__file__).resolve().parent.parent
STATIC_DIR = ROOT / "static"
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
app = FastAPI(title="Word Search Puzzle Generator")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
def _checkbox(value: str | None) -> bool:
return value is not None and value != "" and value.lower() not in {"0", "false", "off"}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
available = theme_store.list_themes()
return templates.TemplateResponse(
"index.html",
{"request": request, "themes": available, "errors": [], "warnings": []},
)
@app.post("/generate")
async def generate_puzzle(
request: Request,
theme: str = Form(...),
grid_size: int = Form(...),
word_count: int = Form(...),
min_length: int = Form(...),
max_length: int = Form(...),
title: str = Form(""),
diagonal: str | None = Form(None),
reversed_: str | None = Form(None, alias="reversed"),
overlap: str | None = Form(None),
):
available = theme_store.list_themes()
errors: list[str] = []
try:
loaded = theme_store.load_theme(theme)
except theme_store.ThemeError as e:
errors.append(str(e))
return templates.TemplateResponse(
"index.html",
{"request": request, "themes": available, "errors": errors,
"warnings": []},
status_code=400,
)
if max_length < min_length:
errors.append("max length must be greater than or equal to min length")
if errors:
return templates.TemplateResponse(
"index.html",
{"request": request, "themes": available, "errors": errors,
"warnings": []},
status_code=400,
)
try:
puzzle = generate(
words=loaded.words,
grid_size=grid_size,
word_count=word_count,
min_length=min_length,
max_length=max_length,
diagonal=_checkbox(diagonal),
reversed_=_checkbox(reversed_),
allow_overlap=_checkbox(overlap),
)
except ValueError as e:
return templates.TemplateResponse(
"index.html",
{"request": request, "themes": available, "errors": [str(e)],
"warnings": []},
status_code=400,
)
display_title = title.strip() or loaded.name
pdf_bytes = render_puzzle(puzzle, display_title)
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"{loaded.slug}_{timestamp}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.get("/themes", response_class=HTMLResponse)
def themes_index(request: Request):
return templates.TemplateResponse(
"themes.html",
{"request": request, "themes": theme_store.list_themes()},
)
@app.get("/themes/new", response_class=HTMLResponse)
def themes_new(request: Request):
return templates.TemplateResponse(
"theme_edit.html",
{
"request": request,
"is_new": True,
"name": "",
"slug": "",
"words_text": "",
"errors": [],
},
)
@app.get("/themes/{slug}/edit", response_class=HTMLResponse)
def themes_edit(request: Request, slug: str):
try:
t = theme_store.load_theme(slug)
except theme_store.ThemeError as e:
raise HTTPException(status_code=404, detail=str(e))
return templates.TemplateResponse(
"theme_edit.html",
{
"request": request,
"is_new": False,
"name": t.name,
"slug": t.slug,
"words_text": "\n".join(t.words),
"errors": [],
},
)
@app.post("/themes")
def themes_create(
request: Request,
name: str = Form(...),
slug: str = Form(""),
words: str = Form(""),
):
proposed_slug = (slug.strip() or theme_store.slugify(name))
parsed = theme_store.parse_words_textarea(words)
errors: list[str] = []
if not theme_store.is_valid_slug(proposed_slug):
errors.append("slug must be lowercase letters, digits, and hyphens only")
if errors:
return templates.TemplateResponse(
"theme_edit.html",
{
"request": request,
"is_new": True,
"name": name,
"slug": proposed_slug,
"words_text": words,
"errors": errors,
},
status_code=400,
)
try:
theme_store.save_theme(proposed_slug, name, parsed)
except theme_store.ThemeError as e:
return templates.TemplateResponse(
"theme_edit.html",
{
"request": request,
"is_new": True,
"name": name,
"slug": proposed_slug,
"words_text": words,
"errors": [str(e)],
},
status_code=400,
)
return RedirectResponse(url="/themes", status_code=303)
@app.post("/themes/{slug}")
def themes_update(
request: Request,
slug: str,
name: str = Form(...),
words: str = Form(""),
):
parsed = theme_store.parse_words_textarea(words)
try:
theme_store.save_theme(slug, name, parsed)
except theme_store.ThemeError as e:
return templates.TemplateResponse(
"theme_edit.html",
{
"request": request,
"is_new": False,
"name": name,
"slug": slug,
"words_text": words,
"errors": [str(e)],
},
status_code=400,
)
return RedirectResponse(url="/themes", status_code=303)
@app.post("/themes/{slug}/delete")
def themes_delete(slug: str):
try:
theme_store.delete_theme(slug)
except theme_store.ThemeError as e:
raise HTTPException(status_code=400, detail=str(e))
return RedirectResponse(url="/themes", status_code=303)
@app.get("/api/themes")
def api_themes():
return [{"slug": t.slug, "name": t.name, "word_count": len(t.words)}
for t in theme_store.list_themes()]
@app.post("/api/normalise")
async def api_normalise(request: Request):
payload = await request.json()
words = payload.get("words", []) if isinstance(payload, dict) else []
if not isinstance(words, list):
return JSONResponse({"error": "words must be a list"}, status_code=400)
results = normalise_all([str(w) for w in words])
return [
{
"display": n.display,
"grid": n.grid,
"stripped": n.stripped_prefixes,
"skipped": n.skipped,
"warning": n.warning,
}
for n in results
]

72
app/normaliser.py Normal file
View File

@@ -0,0 +1,72 @@
"""Word normalisation: produce display + grid forms with prefix stripping."""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
PREFIXES = {
"mr", "mrs", "ms", "miss",
"dr", "sir", "dame", "lord", "lady", "master",
"captain", "capt", "cpt",
"professor", "prof",
"saint", "st",
}
@dataclass
class Normalised:
display: str
grid: str
stripped_prefixes: list[str]
skipped: bool = False
warning: str | None = None
def _strip_prefix_tokens(tokens: list[str]) -> tuple[list[str], list[str]]:
"""Remove leading prefix tokens. Returns (stripped_prefixes, remaining_tokens)."""
stripped: list[str] = []
i = 0
while i < len(tokens):
bare = tokens[i].rstrip(".").lower()
if bare in PREFIXES:
stripped.append(tokens[i])
i += 1
else:
break
return stripped, tokens[i:]
def normalise(word: str) -> Normalised:
display = word
tokens = word.split()
if not tokens:
return Normalised(display=display, grid="", stripped_prefixes=[],
skipped=True, warning="empty word")
stripped, remainder = _strip_prefix_tokens(tokens)
if not remainder:
# Word is *only* prefix tokens — keep raw form, warn.
joined = "".join(tokens)
grid = re.sub(r"[^A-Za-z0-9]", "", joined).upper()
warning = f"word {word!r} is only prefix tokens; keeping as {grid}"
print(f"[normaliser] warning: {warning}", file=sys.stderr)
return Normalised(display=display, grid=grid, stripped_prefixes=[],
warning=warning)
joined = "".join(remainder)
grid = re.sub(r"[^A-Za-z0-9]", "", joined).upper()
if not grid:
warning = f"word {word!r} normalises to empty after stripping; skipping"
print(f"[normaliser] warning: {warning}", file=sys.stderr)
return Normalised(display=display, grid="", stripped_prefixes=stripped,
skipped=True, warning=warning)
return Normalised(display=display, grid=grid, stripped_prefixes=stripped)
def normalise_all(words: list[str]) -> list[Normalised]:
return [normalise(w) for w in words]

143
app/pdf.py Normal file
View File

@@ -0,0 +1,143 @@
"""PDF rendering of a generated puzzle (A4 portrait, single page)."""
from __future__ import annotations
import io
from typing import Iterable
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
from .generator import Puzzle
PAGE_W, PAGE_H = A4
MARGIN = 18 * mm
TITLE_FONT = "Helvetica-Bold"
TITLE_SIZE = 24
OPTIONS_FONT = "Helvetica"
OPTIONS_SIZE = 10
GRID_FONT = "Courier-Bold"
LIST_HEADING_FONT = "Helvetica-Bold"
LIST_HEADING_SIZE = 14
LIST_ENTRY_FONT_BOLD = "Helvetica-Bold"
LIST_ENTRY_FONT_LIGHT = "Helvetica"
LIST_ENTRY_SIZE = 11
def _column_count(n: int) -> int:
if n <= 8:
return 2
if n <= 18:
return 3
return 4
def _wrap_columns(items: list[tuple], cols: int) -> list[list[tuple]]:
"""Lay items out top-to-bottom, then left-to-right, in `cols` columns."""
rows = (len(items) + cols - 1) // cols
columns: list[list[tuple]] = [[] for _ in range(cols)]
for i, item in enumerate(items):
columns[i // rows].append(item)
return columns
def _grid_geometry(grid_size: int, available_w: float, available_h: float
) -> tuple[float, float]:
"""Choose (font_size, cell_size) so a `grid_size` x `grid_size` grid
fits comfortably in the available box."""
# Cell size driven by whichever dimension is tighter, with padding.
target_cell = min(available_w, available_h) / grid_size
cell = min(target_cell, 14 * mm) # cap so 5x5 isn't comically huge
cell = max(cell, 5 * mm)
font_size = cell * 0.7
return font_size, cell
def render_puzzle(puzzle: Puzzle, title: str) -> bytes:
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
# Title
c.setFont(TITLE_FONT, TITLE_SIZE)
title_y = PAGE_H - MARGIN - TITLE_SIZE
c.drawCentredString(PAGE_W / 2, title_y, title)
# Active option labels under the title (only when extras are enabled).
options_y = title_y
labels = puzzle.option_labels
if labels:
options_y = title_y - TITLE_SIZE * 0.7
c.setFont(OPTIONS_FONT, OPTIONS_SIZE)
c.setFillGray(0.35)
c.drawCentredString(PAGE_W / 2, options_y,
" · ".join(s.upper() for s in labels))
c.setFillGray(0)
# Reserve space for word list at the bottom; estimate from entry count.
placed = puzzle.placements
cols = _column_count(len(placed)) if placed else 2
rows_per_col = (len(placed) + cols - 1) // cols if placed else 0
list_line_height = LIST_ENTRY_SIZE * 1.5
list_block_h = LIST_HEADING_SIZE * 2 + rows_per_col * list_line_height
list_block_h = max(list_block_h, 30 * mm)
grid_top = (options_y if labels else title_y) - 8 * mm
grid_bottom = MARGIN + list_block_h + 6 * mm
available_h = grid_top - grid_bottom
available_w = PAGE_W - 2 * MARGIN
grid_size = puzzle.size
font_size, cell = _grid_geometry(grid_size, available_w, available_h)
grid_w = grid_size * cell
grid_h = grid_size * cell
origin_x = (PAGE_W - grid_w) / 2
origin_y = grid_top - grid_h # top of grid sits at grid_top
c.setFont(GRID_FONT, font_size)
for r in range(grid_size):
for col_idx in range(grid_size):
ch = puzzle.grid[r][col_idx]
x = origin_x + col_idx * cell + cell / 2
# Top row in the grid array should print at the top of the grid box.
y = origin_y + (grid_size - 1 - r) * cell + (cell - font_size) / 2
c.drawCentredString(x, y, ch)
# Word list
list_top = grid_bottom - 2 * mm
c.setFont(LIST_HEADING_FONT, LIST_HEADING_SIZE)
c.drawCentredString(PAGE_W / 2, list_top, "Find these words")
entries: list[tuple[str, str]] = []
for p in placed:
prefix_text = ""
if p.word.stripped_prefixes:
prefix_text = " (" + " ".join(p.word.stripped_prefixes) + ")"
entries.append((p.word.grid, prefix_text))
if entries:
column_lists = _wrap_columns(entries, cols)
col_w = available_w / cols
list_y_start = list_top - LIST_HEADING_SIZE - 4 * mm
for col_idx, items in enumerate(column_lists):
x = MARGIN + col_idx * col_w
for i, (grid_form, prefix) in enumerate(items):
y = list_y_start - i * list_line_height
c.setFont(LIST_ENTRY_FONT_BOLD, LIST_ENTRY_SIZE)
c.drawString(x, y, grid_form)
if prefix:
bold_w = stringWidth(grid_form, LIST_ENTRY_FONT_BOLD,
LIST_ENTRY_SIZE)
c.setFont(LIST_ENTRY_FONT_LIGHT, LIST_ENTRY_SIZE)
c.setFillGray(0.4)
c.drawString(x + bold_w, y, prefix)
c.setFillGray(0)
c.showPage()
c.save()
return buf.getvalue()

41
app/templates/base.html Normal file
View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Wordsmith — Puzzle Workshop{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500;600&family=Playfair+Display:wght@800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
{% set path = request.url.path %}
<div class="shell">
<header class="site-header">
<div class="site-header__inner">
<a class="brand" href="/">
<span class="brand__mark">W</span>
<span>
<span class="brand__name">Wordsmith</span>
<span class="brand__tagline">PUZZLE WORKSHOP</span>
</span>
</a>
<nav class="tabs" aria-label="Primary">
<a href="/" {% if path in ['/', '/generate'] %}aria-current="page"{% endif %}>Generate</a>
<a href="/themes" {% if path.startswith('/themes') %}aria-current="page"{% endif %}>Themes</a>
</nav>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
&nbsp; WORDSMITH &nbsp; · &nbsp; PUZZLE WORKSHOP &nbsp; · &nbsp; EST. 2026 &nbsp;
</footer>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

162
app/templates/index.html Normal file
View File

@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}Generate · Wordsmith{% endblock %}
{% block content %}
<section class="container">
<div class="page-head">
<div class="page-head__title">
<div class="eyebrow">№ 01 · Studio</div>
<h1>Generate a puzzle</h1>
</div>
<div class="page-head__meta">{{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} on file</div>
</div>
<hr class="section-rule" />
{% if errors %}
<div class="notice">
<strong>Hold on</strong>
<ul>
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
</ul>
</div>
{% endif %}
{% if not themes %}
<div class="panel">
<p>No themes yet. <a href="/themes/new">Create one</a> to get started.</p>
</div>
{% else %}
<form class="studio" method="post" action="/generate">
<div class="form-grid">
<label class="field">
<span class="field__label">Theme</span>
<select name="theme" id="theme" required>
{% for t in themes %}
<option value="{{ t.slug }}" data-words="{{ t.words|join(',') }}">{{ t.name }} ({{ t.words|length }} words)</option>
{% endfor %}
</select>
</label>
<div class="row">
<label class="field">
<span class="field__label">Grid</span>
<input type="number" name="grid_size" id="grid-size" value="12" min="5" max="25" required />
</label>
<label class="field">
<span class="field__label">Words</span>
<input type="number" name="word_count" id="word-count-input" value="10" min="1" max="30" required />
</label>
<label class="field">
<span class="field__label">Min len</span>
<input type="number" name="min_length" value="3" min="1" required />
</label>
<label class="field">
<span class="field__label">Max len</span>
<input type="number" name="max_length" value="12" min="1" required />
</label>
</div>
<label class="field">
<span class="field__label">Title override</span>
<input type="text" name="title" placeholder="leave blank to use theme name" />
</label>
<fieldset class="fieldset">
<legend>Directions</legend>
<div class="directions-grid">
<label class="check check--locked">
<input type="checkbox" checked disabled />
<span>→ Horizontal</span>
<span class="lock">REQ</span>
</label>
<label class="check check--locked">
<input type="checkbox" checked disabled />
<span>↓ Vertical</span>
<span class="lock">REQ</span>
</label>
<label class="check">
<input type="checkbox" name="diagonal" />
<span>↘ ↗ Diagonal</span>
</label>
<label class="check">
<input type="checkbox" name="reversed" />
<span>← ↑ ↖ ↙ Reversed</span>
</label>
</div>
<div class="fieldset__divider">
<label class="check">
<input type="checkbox" name="overlap" />
<span>Allow overlapping words</span>
</label>
</div>
</fieldset>
<div class="button-row">
<button class="btn" type="submit">✦ Generate</button>
<button class="btn btn--ghost" type="reset">Reset</button>
</div>
</div>
<aside class="preview-card" aria-label="Preview">
<div class="preview-card__head">
<span class="preview-card__title">Sample preview</span>
<span class="mono muted" id="preview-stats" style="font-size:11px;">12 × 12 · 10 words</span>
</div>
<div class="ws-grid" id="ws-grid" style="grid-template-columns: repeat(12, 1fr);"></div>
<div class="ws-words" id="ws-words"></div>
</aside>
</form>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
const themeSel = document.getElementById('theme');
const gridInput = document.getElementById('grid-size');
const countInput = document.getElementById('word-count-input');
const grid = document.getElementById('ws-grid');
const wordsEl = document.getElementById('ws-words');
const stats = document.getElementById('preview-stats');
if (!themeSel || !grid) return;
function selectedWords() {
const opt = themeSel.options[themeSel.selectedIndex];
return (opt.dataset.words || '').split(',').filter(Boolean);
}
function renderPreview() {
const N = Math.max(5, Math.min(25, parseInt(gridInput.value || '12', 10) || 12));
const want = Math.max(1, parseInt(countInput.value || '10', 10) || 10);
grid.style.gridTemplateColumns = `repeat(${N}, 1fr)`;
const words = selectedWords();
const grids = words
.map(w => w.replace(/^(mr|mrs|ms|miss|dr|sir|dame|lord|lady|master|captain|capt|cpt|professor|prof|saint|st)\.?\s+/gi, '')
.replace(/[^a-z0-9]/gi, '').toUpperCase())
.filter(g => g.length > 0);
const pool = (grids.join('') || 'WORDSMITH').replace(/[^A-Z]/g, '') || 'WORDSMITH';
let html = '';
for (let i = 0; i < N * N; i++) {
html += `<span>${pool[(i * 7 + 3) % pool.length]}</span>`;
}
grid.innerHTML = html;
const sample = grids.slice(0, Math.min(want, grids.length));
wordsEl.innerHTML = sample.map((g, i) =>
`<b>${g}</b>${i < sample.length - 1 ? '<span class="sep">·</span>' : ''}`
).join('');
stats.textContent = `${N} × ${N} · ${sample.length} word${sample.length === 1 ? '' : 's'}`;
}
themeSel.addEventListener('change', renderPreview);
gridInput.addEventListener('input', renderPreview);
countInput.addEventListener('input', renderPreview);
renderPreview();
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% block title %}{% if is_new %}New theme{% else %}Edit {{ name }}{% endif %} · Wordsmith{% endblock %}
{% block content %}
<section class="container">
<div class="crumbs">
<a href="/themes">themes</a>
<span class="sep">/</span>
<span class="here">{% if is_new %}new{% else %}{{ slug }}{% endif %}</span>
</div>
<div class="page-head">
<div class="page-head__title">
<div class="eyebrow">№ 03 · Workbench</div>
<h1>{% if is_new %}New theme{% else %}Edit theme{% endif %}</h1>
</div>
{% if not is_new %}
<div class="page-head__meta">slug · {{ slug }}</div>
{% endif %}
</div>
<hr class="section-rule" />
{% if errors %}
<div class="notice">
<strong>Hold on</strong>
<ul>
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<form method="post"
action="{% if is_new %}/themes{% else %}/themes/{{ slug }}{% endif %}">
<div class="row" style="margin-bottom: var(--s-4);">
<label class="field">
<span class="field__label">Theme name</span>
<input type="text" name="name" id="name" value="{{ name }}" required />
</label>
<label class="field">
<span class="field__label">Slug</span>
<input type="text" name="slug" id="slug" value="{{ slug }}"
{% if not is_new %}readonly{% endif %} />
<span class="field__hint">lowercase letters, digits, hyphens · auto-generated on create</span>
</label>
</div>
<div class="workbench">
<label class="field">
<span class="field__label">Words · one per line</span>
<textarea name="words" id="words">{{ words_text }}</textarea>
</label>
<aside class="panel panel--inset" aria-label="Live preview">
<div class="preview-card__head" style="margin: -4px 0 12px;">
<span class="preview-card__title">Live preview</span>
<span class="mono muted" style="font-size:11px;" id="word-count">0 entries</span>
</div>
<div class="preview-list" id="preview-list">
<div class="muted">Type words to see the grid form.</div>
</div>
<div class="stat-row" id="word-stats">
<span>no words yet</span>
</div>
</aside>
</div>
<div class="bottom-bar">
<button class="btn" type="submit">Save</button>
<a class="btn btn--ghost" href="/themes">Cancel</a>
<span class="spacer"></span>
{% if not is_new %}
<form method="post" action="/themes/{{ slug }}/delete"
onsubmit="return confirm('Delete theme {{ name }}?');">
<button class="btn btn--danger" type="submit">⌫ Delete theme</button>
</form>
{% endif %}
</div>
</form>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
const nameEl = document.getElementById('name');
const slugEl = document.getElementById('slug');
const wordsEl = document.getElementById('words');
const list = document.getElementById('preview-list');
const count = document.getElementById('word-count');
const stats = document.getElementById('word-stats');
const isNew = {{ 'true' if is_new else 'false' }};
function slugify(s) {
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
if (isNew && nameEl && slugEl) {
nameEl.addEventListener('input', () => {
if (!slugEl.dataset.touched) slugEl.value = slugify(nameEl.value);
});
slugEl.addEventListener('input', () => { slugEl.dataset.touched = '1'; });
}
let timer = null;
async function refresh() {
const lines = wordsEl.value.split('\n').map(s => s.trim()).filter(Boolean);
if (lines.length === 0) {
list.innerHTML = '<div class="muted">Type words to see the grid form.</div>';
count.textContent = '0 entries';
stats.innerHTML = '<span>no words yet</span>';
return;
}
let data;
try {
const res = await fetch('/api/normalise', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({words: lines}),
});
data = await res.json();
} catch (e) {
list.innerHTML = '<div class="muted">Preview failed.</div>';
return;
}
list.innerHTML = data.map(item => {
if (item.skipped) {
return `<div class="row skipped"><span class="src">${item.display}</span><span class="dst">(skipped)</span></div>`;
}
return `<div class="row"><span class="src">${item.display}</span><span class="dst">${item.grid}</span></div>`;
}).join('');
const placeable = data.filter(d => !d.skipped);
count.textContent = `${data.length} ${data.length === 1 ? 'entry' : 'entries'}`;
if (placeable.length) {
const lens = placeable.map(d => d.grid.length);
const minIdx = lens.indexOf(Math.min(...lens));
const maxIdx = lens.indexOf(Math.max(...lens));
const min = placeable[minIdx];
const max = placeable[maxIdx];
const avg = (lens.reduce((a, b) => a + b, 0) / lens.length).toFixed(1);
stats.innerHTML = `
<span>shortest · ${min.grid} (${min.grid.length})</span>
<span>longest · ${max.grid} (${max.grid.length})</span>
<span>avg · ${avg}</span>`;
} else {
stats.innerHTML = '<span>nothing placeable</span>';
}
}
if (wordsEl) {
wordsEl.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(refresh, 250);
});
refresh();
}
})();
</script>
{% endblock %}

57
app/templates/themes.html Normal file
View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Themes · Wordsmith{% endblock %}
{% block content %}
{% set total_words = themes|map(attribute='words')|map('length')|sum %}
<section class="container">
<div class="page-head">
<div class="page-head__title">
<div class="eyebrow">№ 02 · Ledger</div>
<h1>Themes</h1>
<div class="muted mono" style="font-size:12px; margin-top:6px;">
{{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} · {{ total_words }} word{{ '' if total_words == 1 else 's' }} on file
</div>
</div>
<div>
<a class="btn" href="/themes/new">+ New theme</a>
</div>
</div>
<hr class="section-rule" />
{% if themes %}
<div class="panel" style="padding:0; overflow:hidden;">
<table class="ledger">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th class="col-words">Words</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in themes %}
<tr>
<td class="col-name">{{ t.name }}</td>
<td class="col-slug">{{ t.slug }}</td>
<td class="col-words">{{ t.words|length }}</td>
<td class="col-actions">
<span class="row-actions">
<a href="/themes/{{ t.slug }}/edit">edit</a>
<form method="post" action="/themes/{{ t.slug }}/delete"
onsubmit="return confirm('Delete theme {{ t.name }}?');">
<button type="submit" class="del">delete</button>
</form>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="panel">
<p>No themes yet. <a href="/themes/new">Create the first one</a>.</p>
</div>
{% endif %}
</section>
{% endblock %}

96
app/themes.py Normal file
View File

@@ -0,0 +1,96 @@
"""Theme storage: flat JSON files under themes/."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
THEMES_DIR = Path(__file__).resolve().parent.parent / "themes"
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
@dataclass
class Theme:
slug: str
name: str
words: list[str]
class ThemeError(ValueError):
"""Raised for invalid theme input."""
def slugify(name: str) -> str:
"""Lowercase, replace non-alphanumeric runs with single hyphen, trim."""
s = name.lower().strip()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = s.strip("-")
return s
def is_valid_slug(slug: str) -> bool:
return bool(_SLUG_RE.match(slug))
def _path(slug: str) -> Path:
if not is_valid_slug(slug):
raise ThemeError(f"invalid slug: {slug!r}")
return THEMES_DIR / f"{slug}.json"
def list_themes() -> list[Theme]:
THEMES_DIR.mkdir(parents=True, exist_ok=True)
out: list[Theme] = []
for path in sorted(THEMES_DIR.glob("*.json")):
slug = path.stem
if not is_valid_slug(slug):
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
out.append(Theme(slug=slug, name=data.get("name", slug),
words=list(data.get("words", []))))
except (json.JSONDecodeError, OSError):
continue
return out
def load_theme(slug: str) -> Theme:
path = _path(slug)
if not path.exists():
raise ThemeError(f"theme not found: {slug!r}")
data = json.loads(path.read_text(encoding="utf-8"))
return Theme(slug=slug, name=data.get("name", slug),
words=list(data.get("words", [])))
def save_theme(slug: str, name: str, words: list[str]) -> Theme:
if not is_valid_slug(slug):
raise ThemeError(
"slug must be lowercase letters, digits, and hyphens only"
)
name = name.strip()
if not name:
raise ThemeError("theme name is required")
cleaned = [w.strip() for w in words if w.strip()]
if not cleaned:
raise ThemeError("theme must contain at least one word")
THEMES_DIR.mkdir(parents=True, exist_ok=True)
path = THEMES_DIR / f"{slug}.json"
payload = {"name": name, "words": cleaned}
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
return Theme(slug=slug, name=name, words=cleaned)
def delete_theme(slug: str) -> None:
path = _path(slug)
if path.exists():
path.unlink()
def parse_words_textarea(blob: str) -> list[str]:
"""One word per line; ignore blank lines and surrounding whitespace."""
return [line.strip() for line in blob.splitlines() if line.strip()]

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
name: wordsearch
services:
wordsearch:
build: .
networks:
default: null
ports:
- mode: ingress
target: 8000
published: "3801"
protocol: tcp
restart: unless-stopped
volumes:
- type: bind
source: ./themes
target: /app/themes
bind:
create_host_path: true
networks:
default:
name: wordsearch_default

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
jinja2==3.1.4
reportlab==4.2.5
python-multipart==0.0.12

701
static/style.css Normal file
View File

@@ -0,0 +1,701 @@
/* ============================================================
Word Search — polished stylesheet
Plain CSS, no framework.
============================================================ */
:root {
/* Paper palette — newsprint */
--paper-bg: #ece4d3;
--paper: #f6efde;
--paper-2: #fbf6e8;
--paper-3: #f0e8d3;
/* Ink */
--ink: #1c1a16;
--ink-soft: #6b6356;
--ink-faint: rgba(28, 26, 22, 0.14);
--rule-dashed: rgba(28, 26, 22, 0.32);
/* Accent (red ink) */
--accent: #b3321a;
--accent-soft: rgba(179, 50, 26, 0.08);
/* Type */
--display: 'Playfair Display', 'Times New Roman', serif;
--body: 'DM Mono', ui-monospace, Menlo, monospace;
--mono: 'DM Mono', ui-monospace, Menlo, monospace;
/* Geometry */
--rule: 1.5px solid var(--ink);
--rule-2: 1px dashed var(--rule-dashed);
--double: 3px double var(--ink);
/* Spacing scale */
--s-1: 4px;
--s-2: 8px;
--s-3: 12px;
--s-4: 16px;
--s-5: 24px;
--s-6: 32px;
--s-7: 48px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--paper-bg);
color: var(--ink);
font-family: var(--body);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* Subtle paper grain — faint speckle */
body {
background-image:
radial-gradient(rgba(28,26,22,0.025) 1px, transparent 1px),
radial-gradient(rgba(28,26,22,0.018) 1px, transparent 1px);
background-size: 3px 3px, 7px 7px;
background-position: 0 0, 1px 2px;
}
/* ---------- Typography ---------- */
h1, h2, h3 { font-family: var(--display); font-weight: 900; letter-spacing: -0.01em; margin: 0; }
h1 { font-size: 30px; line-height: 1.05; }
h2 { font-size: 26px; line-height: 1.1; }
h3 { font-size: 18px; font-weight: 800; line-height: 1.2; }
a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
a:hover { text-decoration-thickness: 2px; }
.eyebrow {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-soft);
}
.muted { color: var(--ink-soft); }
.mono { font-family: var(--mono); }
/* ---------- Layout ---------- */
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
width: 100%;
max-width: 1100px;
margin: 0 auto;
padding: 0 var(--s-6);
}
main {
padding: var(--s-7) 0;
flex: 1;
}
/* ---------- Header (Stamp + Tabs) ---------- */
.site-header {
background: var(--paper);
border-bottom: var(--rule);
position: sticky;
top: 0;
z-index: 10;
}
.site-header__inner {
max-width: 1100px;
margin: 0 auto;
padding: var(--s-3) var(--s-6);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
}
.brand {
display: flex;
align-items: center;
gap: var(--s-3);
text-decoration: none;
color: var(--ink);
}
.brand__mark {
width: 38px;
height: 38px;
border: 2px solid var(--ink);
background: var(--ink);
color: var(--paper);
display: grid;
place-items: center;
font-family: var(--display);
font-weight: 900;
font-size: 22px;
flex: 0 0 auto;
}
.brand__name {
display: block;
font-family: var(--display);
font-weight: 800;
font-size: 19px;
line-height: 1;
}
.brand__tagline {
display: block;
font-family: var(--mono);
font-size: 10px;
color: var(--ink-soft);
letter-spacing: 0.08em;
margin-top: 3px;
}
.tabs {
display: flex;
gap: 0;
font-family: var(--mono);
font-size: 13px;
}
.tabs a {
padding: 8px 18px;
border: var(--rule);
color: var(--ink);
background: var(--paper);
text-decoration: none;
font-weight: 500;
}
.tabs a + a { border-left: none; }
.tabs a:hover { background: var(--paper-3); }
.tabs a[aria-current="page"] {
background: var(--ink);
color: var(--paper);
}
/* ---------- Page heading ---------- */
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--s-4);
flex-wrap: wrap;
margin-bottom: var(--s-4);
}
.page-head__title h1 { margin-top: 4px; }
.page-head__meta {
font-family: var(--mono);
font-size: 11px;
color: var(--ink-soft);
}
.section-rule {
border: 0;
border-top: var(--double);
margin: 0 0 var(--s-6);
}
/* ---------- Cards / panels ---------- */
.panel {
background: var(--paper);
border: var(--rule);
padding: var(--s-5);
}
.panel--inset {
background: var(--paper-2);
}
/* ---------- Notices (errors / warnings) ---------- */
.notice {
background: var(--paper);
border: var(--rule);
border-left: 4px solid var(--accent);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-4);
font-family: var(--mono);
font-size: 13px;
}
.notice strong {
display: block;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
margin-bottom: 4px;
}
.notice ul { margin: 0; padding-left: 18px; }
.notice--warn { border-left-color: #c08a00; }
/* ---------- Form controls ---------- */
.form-grid {
display: grid;
gap: var(--s-4);
}
.field {
display: block;
}
.field__label {
display: block;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-soft);
margin-bottom: 6px;
font-weight: 500;
}
.field__hint {
display: block;
font-family: var(--mono);
font-size: 11px;
color: var(--ink-soft);
margin-top: 6px;
}
input[type="text"],
input[type="number"],
input[type="email"],
select,
textarea {
width: 100%;
border: var(--rule);
background: var(--paper-2);
color: var(--ink);
padding: 10px 12px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.4;
border-radius: 0;
appearance: none;
-webkit-appearance: none;
outline: none;
transition: background 0.12s, box-shadow 0.12s;
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
background: var(--paper);
box-shadow: 0 0 0 3px var(--ink);
outline-offset: 2px;
}
input[readonly] { background: var(--paper-3); cursor: not-allowed; }
textarea {
min-height: 240px;
resize: vertical;
line-height: 1.7;
}
select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='%231c1a16'><path d='M0 0l6 8 6-8z'/></svg>");
background-repeat: no-repeat;
background-position: right 14px center;
background-size: 10px;
padding-right: 36px;
cursor: pointer;
}
.row { display: flex; gap: var(--s-3); }
.row > * { flex: 1; min-width: 0; }
/* Checkboxes — custom square ink boxes */
.check {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
user-select: none;
}
.check input {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border: 1.5px solid var(--ink);
background: var(--paper-2);
display: inline-block;
position: relative;
cursor: pointer;
margin: 0;
flex: 0 0 auto;
}
.check input:checked {
background: var(--ink);
}
.check input:checked::after {
content: "✓";
position: absolute;
inset: 0;
color: var(--paper);
font-size: 12px;
font-family: var(--display);
font-weight: 900;
line-height: 14px;
text-align: center;
}
.check input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.check input:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
}
.check--locked { opacity: 0.7; }
.check--locked .lock {
font-family: var(--mono);
font-size: 10px;
color: var(--ink-soft);
letter-spacing: 0.08em;
margin-left: auto;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
border: var(--rule);
background: var(--ink);
color: var(--paper);
cursor: pointer;
text-decoration: none;
transition: transform 0.05s ease, background 0.12s;
}
.btn:hover { background: #000; }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn--ghost {
background: var(--paper);
color: var(--ink);
}
.btn--ghost:hover { background: var(--paper-3); }
.btn--danger {
background: var(--paper);
color: var(--accent);
border-color: var(--accent);
}
.btn--danger:hover { background: var(--accent); color: var(--paper); }
.btn--sm { padding: 6px 12px; font-size: 12px; }
/* Inline link-style action (for table rows) */
.row-actions {
display: inline-flex;
gap: 14px;
font-family: var(--mono);
font-size: 12px;
}
.row-actions a,
.row-actions button {
color: var(--ink);
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.row-actions a.del,
.row-actions button.del { color: var(--accent); }
.row-actions form { display: inline; margin: 0; }
/* ---------- Tables (Themes ledger) ---------- */
.ledger {
width: 100%;
border-collapse: collapse;
font-family: var(--mono);
font-size: 13px;
}
.ledger thead th {
text-align: left;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
font-weight: 600;
padding: 12px 10px;
border-bottom: 1.5px solid var(--ink);
background: var(--paper-2);
}
.ledger tbody tr {
border-bottom: var(--rule-2);
transition: background 0.1s;
}
.ledger tbody tr:hover {
background: var(--paper-2);
}
.ledger tbody td {
padding: 14px 10px;
vertical-align: middle;
}
.ledger .col-name {
font-family: var(--display);
font-size: 16px;
font-weight: 700;
}
.ledger .col-slug {
color: var(--ink-soft);
}
.ledger .col-words,
.ledger .col-num {
text-align: right;
font-feature-settings: "tnum" 1;
}
.ledger .col-actions {
text-align: right;
width: 1%;
white-space: nowrap;
}
/* ---------- Studio (Generate puzzle) split layout ---------- */
.studio {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
gap: var(--s-6);
align-items: start;
}
@media (max-width: 880px) {
.studio { grid-template-columns: 1fr; }
}
.fieldset {
border: var(--rule);
background: var(--paper);
padding: var(--s-4) var(--s-5) var(--s-5);
margin: 0;
}
.fieldset > legend {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
padding: 0 8px;
background: var(--paper-bg);
margin-left: -4px;
}
.directions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px var(--s-4);
}
.fieldset__divider {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed var(--ink-faint);
}
.button-row {
display: flex;
gap: 10px;
margin-top: 4px;
}
.preview-card {
background: var(--paper);
border: var(--rule);
padding: var(--s-4);
position: sticky;
top: 96px;
}
.preview-card__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--s-3);
padding-bottom: var(--s-3);
border-bottom: var(--rule-2);
}
.preview-card__title {
font-family: var(--display);
font-weight: 800;
font-size: 16px;
}
/* Word search grid (decorative on Generate, real on Result) */
.ws-grid {
display: grid;
gap: 1px;
background: var(--ink);
border: 1.5px solid var(--ink);
aspect-ratio: 1 / 1;
}
.ws-grid > span {
background: var(--paper-2);
display: grid;
place-items: center;
font-family: var(--mono);
font-weight: 700;
font-size: clamp(10px, 1.4vw, 14px);
color: var(--ink);
}
.ws-words {
margin-top: var(--s-3);
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-family: var(--mono);
font-size: 11px;
color: var(--ink-soft);
}
.ws-words b {
color: var(--ink);
font-weight: 600;
}
.ws-words .sep { color: var(--ink-faint); }
/* ---------- Result page ---------- */
.result-grid {
margin: 0 auto;
max-width: 640px;
}
.result-grid .ws-grid > span {
font-size: clamp(11px, 1.6vw, 18px);
}
.word-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 4px 16px;
list-style: none;
padding: 0;
margin: var(--s-4) 0 0;
font-family: var(--mono);
font-size: 13px;
}
.word-list li b { font-weight: 600; }
.word-list li .pref { color: var(--ink-soft); margin-left: 4px; font-size: 11px; }
/* ---------- Edit theme workbench ---------- */
.crumbs {
font-family: var(--mono);
font-size: 12px;
color: var(--ink-soft);
margin-bottom: 4px;
}
.crumbs a { color: var(--ink-soft); text-decoration: none; }
.crumbs a:hover { color: var(--ink); }
.crumbs .sep { margin: 0 6px; }
.crumbs .here { color: var(--ink); }
.workbench {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--s-5);
}
@media (max-width: 880px) {
.workbench { grid-template-columns: 1fr; }
}
.preview-list {
font-family: var(--mono);
font-size: 13px;
line-height: 1.85;
max-height: 22rem;
overflow: auto;
}
.preview-list .row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
border-bottom: 1px dashed var(--ink-faint);
}
.preview-list .src { color: var(--ink-soft); }
.preview-list .dst { color: var(--ink); font-weight: 600; }
.preview-list .row.skipped .dst { color: var(--accent); font-weight: 500; font-style: italic; }
.stat-row {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 11px;
color: var(--ink-soft);
margin-top: var(--s-3);
padding-top: var(--s-3);
border-top: 1px dashed var(--ink-faint);
}
.bottom-bar {
display: flex;
align-items: center;
gap: var(--s-3);
margin-top: var(--s-5);
padding-top: var(--s-4);
border-top: var(--rule-2);
}
.bottom-bar .spacer { flex: 1; }
.bottom-bar form { display: inline; margin: 0; }
/* ---------- Footer ---------- */
.site-footer {
border-top: var(--double);
padding: var(--s-4) 0;
font-family: var(--mono);
font-size: 11px;
color: var(--ink-soft);
text-align: center;
letter-spacing: 0.08em;
}

33
themes/common-birds.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "Common Birds",
"words": [
"Robin",
"Sparrow",
"Crow",
"Pigeon",
"Dove",
"Magpie",
"Starling",
"Finch",
"Swallow",
"Wren",
"Blackbird",
"Jay",
"Owl",
"Eagle",
"Hawk",
"Falcon",
"Kestrel",
"Buzzard",
"Vulture",
"Heron",
"Stork",
"Swan",
"Goose",
"Duck",
"Woodpecker",
"Kingfisher",
"Raven",
"Osprey"
]
}

31
themes/farm-animals.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "Farm Animals",
"words": [
"Cow",
"Pig",
"Hen",
"Cat",
"Dog",
"Goat",
"Duck",
"Goose",
"Sheep",
"Horse",
"Donkey",
"Rabbit",
"Turkey",
"Rooster",
"Lamb",
"Calf",
"Piglet",
"Chicken",
"Foal",
"Kid",
"Pony",
"Llama",
"Alpaca",
"Mule",
"Bull",
"Stallion"
]
}

31
themes/mr-men.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "Mr Men Characters",
"words": [
"Mr Tickle",
"Mr Happy",
"Mr Bump",
"Mr Strong",
"Mr Tall",
"Mr Small",
"Mr Greedy",
"Mr Lazy",
"Mr Funny",
"Mr Noisy",
"Mr Quiet",
"Mr Fussy",
"Mr Grumpy",
"Mr Silly",
"Mr Sneeze",
"Mr Forgetful",
"Mr Worry",
"Mr Daydream",
"Little Miss Sunshine",
"Little Miss Naughty",
"Little Miss Tiny",
"Little Miss Magic",
"Little Miss Splendid",
"Little Miss Chatterbox",
"Little Miss Trouble",
"Little Miss Curious"
]
}

View File

@@ -0,0 +1,32 @@
{
"name": "Precious Stones",
"words": [
"Diamond",
"Ruby",
"Sapphire",
"Emerald",
"Topaz",
"Opal",
"Pearl",
"Amethyst",
"Garnet",
"Jade",
"Onyx",
"Quartz",
"Citrine",
"Turquoise",
"Aquamarine",
"Peridot",
"Tanzanite",
"Lapis",
"Moonstone",
"Tourmaline",
"Coral",
"Amber",
"Obsidian",
"Agate",
"Bloodstone",
"Zircon",
"Spinel"
]
}

View File

@@ -0,0 +1,33 @@
{
"name": "Science: Biology",
"words": [
"Cell",
"Nucleus",
"Membrane",
"Bacteria",
"Virus",
"Fungus",
"Algae",
"Tissue",
"Organ",
"Skeleton",
"Muscle",
"Neuron",
"Blood",
"Heart",
"Lung",
"Brain",
"Kidney",
"Liver",
"Photosynthesis",
"Respiration",
"Digestion",
"Evolution",
"Genetics",
"Chromosome",
"Protein",
"Enzyme",
"Ecosystem",
"Habitat"
]
}

View File

@@ -0,0 +1,33 @@
{
"name": "Science: Chemistry",
"words": [
"Atom",
"Molecule",
"Element",
"Compound",
"Mixture",
"Solution",
"Solvent",
"Reaction",
"Acid",
"Base",
"Alkali",
"Crystal",
"Liquid",
"Solid",
"Plasma",
"Hydrogen",
"Oxygen",
"Carbon",
"Nitrogen",
"Helium",
"Sulphur",
"Iron",
"Copper",
"Sodium",
"Calcium",
"Catalyst",
"Bond",
"Water"
]
}

View File

@@ -0,0 +1,34 @@
{
"name": "Science: Physics",
"words": [
"Force",
"Motion",
"Speed",
"Velocity",
"Friction",
"Gravity",
"Magnetism",
"Electricity",
"Energy",
"Heat",
"Light",
"Sound",
"Wave",
"Pressure",
"Mass",
"Weight",
"Inertia",
"Momentum",
"Acceleration",
"Temperature",
"Voltage",
"Current",
"Circuit",
"Magnet",
"Power",
"Frequency",
"Reflection",
"Refraction",
"Fire"
]
}

31
themes/sea-creatures.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "Sea Creatures",
"words": [
"Crab",
"Eel",
"Squid",
"Shark",
"Whale",
"Dolphin",
"Octopus",
"Lobster",
"Starfish",
"Jellyfish",
"Seahorse",
"Stingray",
"Turtle",
"Clownfish",
"Anglerfish",
"Barracuda",
"Cuttlefish",
"Manta",
"Manatee",
"Narwhal",
"Orca",
"Pufferfish",
"Seal",
"Walrus",
"Coral",
"Plankton"
]
}

32
themes/superheroes.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "Superheroes",
"words": [
"Spider-Man",
"Iron Man",
"Captain America",
"Thor",
"Hulk",
"Black Widow",
"Hawkeye",
"Black Panther",
"Doctor Strange",
"Ant-Man",
"Wasp",
"Falcon",
"Vision",
"Scarlet Witch",
"Wolverine",
"Storm",
"Cyclops",
"Rogue",
"Nightcrawler",
"Daredevil",
"Punisher",
"Batman",
"Superman",
"Wonder Woman",
"Flash",
"Aquaman",
"Green Lantern"
]
}

31
themes/transformers.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "Transformers",
"words": [
"Optimus Prime",
"Bumblebee",
"Megatron",
"Starscream",
"Soundwave",
"Ironhide",
"Ratchet",
"Jazz",
"Wheeljack",
"Bulkhead",
"Arcee",
"Sideswipe",
"Sunstreaker",
"Hot Rod",
"Ultra Magnus",
"Grimlock",
"Shockwave",
"Devastator",
"Galvatron",
"Thundercracker",
"Skywarp",
"Blitzwing",
"Laserbeak",
"Cliffjumper",
"Prowl",
"Blaster"
]
}

31
themes/villains.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "Villains",
"words": [
"Joker",
"Penguin",
"Riddler",
"Bane",
"Scarecrow",
"Catwoman",
"Two-Face",
"Loki",
"Thanos",
"Ultron",
"Magneto",
"Mystique",
"Sabretooth",
"Venom",
"Carnage",
"Green Goblin",
"Doc Ock",
"Vulture",
"Mysterio",
"Kingpin",
"Doctor Doom",
"Galactus",
"Red Skull",
"Sandman",
"Electro",
"Lizard"
]
}

33
themes/wild-animals.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "Wild Animals",
"words": [
"Tiger",
"Lion",
"Elephant",
"Giraffe",
"Zebra",
"Cheetah",
"Leopard",
"Hippo",
"Rhino",
"Gorilla",
"Chimpanzee",
"Kangaroo",
"Koala",
"Panda",
"Wolf",
"Bear",
"Fox",
"Deer",
"Moose",
"Bison",
"Buffalo",
"Hyena",
"Jaguar",
"Crocodile",
"Anaconda",
"Sloth",
"Lemur",
"Otter"
]
}

369
wordsearch-specs.md Normal file
View File

@@ -0,0 +1,369 @@
# Word Search Puzzle Generator — Spec
## Overview
A self-hosted web app that generates printable word search puzzles for kids
(and grownups). Themed word lists are managed through the UI and stored as
JSON files on disk. Every puzzle is configured explicitly per generation —
no sticky difficulty presets.
## Tech Stack
- **Python 3.14+**
- **FastAPI** — web framework
- **Jinja2** — server-rendered HTML templates (no JS framework, no build step)
- **reportlab** — PDF generation
- **uvicorn** — ASGI server
- **Vanilla JS** — minimal, only for the theme editor (textarea + fetch)
- **Storage:** JSON files on disk under `themes/`. No database.
Single language, single process, no build pipeline, no DB.
## Deployment
- Dockerfile + `docker-compose.yml`
- Single container, single port (default `8000`)
- Mount `./themes` as a volume
- Behind Pangolin/Traefik if exposing on a subdomain; otherwise hit the LXC IP
## Directory Layout
```
wordsearch/
├── app/
│ ├── main.py # FastAPI routes
│ ├── generator.py # Grid building + word placement
│ ├── normaliser.py # Word normalisation + prefix stripping
│ ├── pdf.py # PDF rendering (reportlab)
│ ├── themes.py # Load / save / list theme JSON files
│ └── templates/
│ ├── base.html
│ ├── index.html # Generate form
│ ├── themes.html # Theme list
│ └── theme_edit.html
├── themes/ # JSON theme files (mounted volume)
├── static/style.css
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
```
## Web UI
### `/` — Generate Puzzle
```
Theme: [ Mr Men Characters ▾ ]
Grid size: [ 12 ] (5 25)
Words: [ 10 ] (1 30)
Min length: [ 3 ]
Max length: [ 12 ] (clamped to grid size)
Title: [ ] (optional, overrides theme name)
Directions:
☑ Horizontal (→) — always on, locked
☑ Vertical (↓) — always on, locked
☐ Diagonal (↘ ↗)
☐ Reversed (← ↑ ↖ ↙)
☐ Allow overlapping words
[ Generate ]
```
- All optional toggles default **off** on every page load — no sticky state.
- "Generate" → POSTs the form, streams the PDF straight back as a download
(`Content-Disposition: attachment; filename="<slug>_<timestamp>.pdf"`).
- The server keeps no copy on disk; the browser is the only place the PDF
lives.
### `/themes` — Theme List
- Table of existing themes: name, slug, word count, edit/delete buttons
- "New theme" button → `/themes/new`
### `/themes/new` and `/themes/{slug}/edit` — Theme Editor
```
Theme name: [ ] (display name, e.g. "Sea Creatures")
Slug: [ ] (filename; auto-generated on create, locked on edit)
Words (one per line):
┌────────────────────────────────────────┐
│ Mr Tickle │
│ Mr Happy │
│ Little Miss Sunshine │
│ ... │
└────────────────────────────────────────┘
Live preview:
Mr Tickle → TICKLE
Mr Happy → HAPPY
Little Miss Sunshine → LITTLEMISSSUNSHINE
[ Save ] [ Delete ]
```
- The live preview shows what each word will look like in the grid after
normalisation. Updates on textarea change (debounced).
- Save writes `themes/<slug>.json`. Delete confirms then removes.
### Auth
None. Homelab use. Add HTTP basic auth via FastAPI middleware if exposed
publicly later.
## Routes
| Method | Path | Purpose |
|--------|----------------------------|----------------------------------------|
| GET | `/` | Generate form |
| POST | `/generate` | Build puzzle, return PDF download |
| GET | `/themes` | List themes |
| GET | `/themes/new` | New theme form |
| GET | `/themes/{slug}/edit` | Edit theme form |
| POST | `/themes` | Create theme |
| POST | `/themes/{slug}` | Update theme |
| POST | `/themes/{slug}/delete` | Delete theme |
| GET | `/api/themes` | JSON list of themes (dropdown source) |
| POST | `/api/normalise` | Preview normalisation for a list of |
| | | words (used by the editor live preview)|
## Word Normalisation
For every input word, the generator produces two forms:
- **Display form** — original string, untouched. Goes on the PDF word list.
- **Grid form** — what gets placed in the grid.
### Grid form rules
1. Strip any **leading prefix tokens** (case-insensitive, with or without
trailing dot). Stripping is **token-based**, not substring — `"Misty"`
does not match `"Miss"`.
2. Uppercase the result.
3. Strip all whitespace and punctuation.
### Stripped prefixes
A constant in `normaliser.py`:
```python
PREFIXES = {
"mr", "mrs", "ms", "miss",
"dr", "sir", "dame", "lord", "lady", "master",
"captain", "capt", "cpt",
"professor", "prof",
"saint", "st",
}
```
### Examples
| Input | Display form | Grid form |
|-------------------------|-----------------------|---------------------|
| `Mr Tickle` | `Mr Tickle` | `TICKLE` |
| `Mr. Bump` | `Mr. Bump` | `BUMP` |
| `Little Miss Sunshine` | `Little Miss Sunshine`| `LITTLEMISSSUNSHINE`|
| `Dr Octopus` | `Dr Octopus` | `OCTOPUS` |
| `Sir Lancelot` | `Sir Lancelot` | `LANCELOT` |
| `Misty` | `Misty` | `MISTY` |
| `cucumber` | `cucumber` | `CUCUMBER` |
| `Captain America` | `Captain America` | `AMERICA` |
### Prefix-only edge cases
- Multiple consecutive prefixes get stripped: `"Mr Dr Strange"``STRANGE`.
- Word that is **only** a prefix: `"Mr"` → keep as `MR`, log a warning to
stderr (probably a theme typo).
- After stripping, if grid form is empty, skip the word and warn.
## Word Selection
1. Load theme word list.
2. Compute grid form for each word.
3. Filter by length: `min_length ≤ len(grid_form) ≤ min(max_length, grid_size)`.
4. Shuffle.
5. Place words one by one until either:
- the requested word count is reached, or
- the filtered list is exhausted.
6. If fewer words placed than requested, generate the puzzle anyway and log
a stderr warning — there is no result page, so warnings aren't surfaced
to the user; they have to verify the dropdown's word count matches their
target before generating.
### Length filter validation
If the filter matches **fewer** words than requested, generate with what's
available and warn (e.g. "asked for 10 words; only 4 in the theme matched
your length filter"). Don't block — the user might genuinely want a sparse
puzzle.
## Word Placement Rules
### Direction set
The active directions are computed from the toggles:
```
base = { → , ↓ } # always on
if diagonal_toggle:
base |= { ↘ , ↗ }
if reversed_toggle:
base |= { reverse(d) for d in base } # add reversal of everything in base
```
So:
| Diag | Rev | Active directions |
|------|-----|-------------------|
| ☐ | ☐ | → ↓ |
| ☑ | ☐ | → ↓ ↘ ↗ |
| ☐ | ☑ | → ↓ ← ↑ |
| ☑ | ☑ | → ↓ ↘ ↗ ← ↑ ↖ ↙ (all 8) |
Direction vectors `(Δrow, Δcol)`:
| Symbol | Δrow | Δcol |
|--------|------|------|
| → | 0 | +1 |
| ↓ | +1 | 0 |
| ↘ | +1 | +1 |
| ↗ | 1 | +1 |
| ← | 0 | 1 |
| ↑ | 1 | 0 |
| ↖ | 1 | 1 |
| ↙ | +1 | 1 |
### Placement algorithm
For each word:
1. Pick a random direction from the active set.
2. Pick a random valid starting cell such that the entire word fits within
the grid (compute bounds from word length + direction vector).
3. Check collision against already-placed words:
- If overlap toggle is **off**: every cell the word would occupy must be
currently empty.
- If overlap toggle is **on**: every cell must either be empty OR contain
the same letter the word would place there (letter-sharing intersections).
4. If the placement is valid, commit it. Otherwise retry up to 200 attempts
per word (re-rolling direction + start each time), then skip with a
warning.
### Grid fill
After all words are placed, fill remaining empty cells with random uppercase
AZ letters. All letters are uppercase.
## Theme File Format
`themes/<slug>.json`:
```json
{
"name": "Mr Men Characters",
"words": [
"Mr Tickle",
"Mr Happy",
"Mr Bump",
"Little Miss Sunshine",
"Mr Strong",
"Mr Tall",
"Mr Small"
]
}
```
- `name` — human-readable, shown in dropdown and on PDF.
- `words` — list of strings, one per line in the editor textarea.
### Theme curation guidance (in README)
- Aim for **2030 words per theme** with a healthy spread of lengths
(some short, some long) so length filters give useful results across
age groups.
- Avoid hyphens and apostrophes when possible — they get stripped.
`"Spider-Man"``SPIDERMAN` is fine, but `"don't"``DONT` may surprise.
## PDF Layout
A4 portrait, single page, plain.
### Top
- Title (theme name or override), centred, large.
### Middle: Grid
- Centred, monospace font, generous letter spacing.
- No cell borders (cleaner look, easier to scan).
- Sized so a 12×12 grid is comfortable to read; scales down for larger grids.
### Bottom: Word list
- Heading: "Find these words"
- 24 columns depending on word count.
- Each entry rendered as:
- `GRIDFORM` (bold, uppercase) followed by ` (original prefix tokens)` in
lighter weight — only if the word had a stripped prefix.
- Words with no prefix render bare: just `GRIDFORM` in bold uppercase.
- Examples in the rendered list:
```
TICKLE (Mr) BUMP (Mr)
HAPPY (Mr) STRONG (Mr)
SUNSHINE (Little Miss)
CUCUMBER TOMATO
AMERICA (Captain)
```
### Active options subtitle
Directly under the title, in small uppercase letter-spaced grey text, list
any *extra* directions or modes that are enabled beyond the always-on
horizontal + vertical baseline. The labels render only when at least one
of `diagonal`, `reversed`, or `allow_overlap` is on:
```
DIAGONAL · REVERSED · OVERLAPPING
```
If none of them are enabled, render nothing — no empty line, no spacing.
### No footer
Page stays clean: no timestamp, no branding, no toggle state in the footer.
(The download filename carries the timestamp.)
### No answer key
v1 ships puzzle-only. Solution PDF is a future enhancement.
## Initial Themes to Ship
Pre-populate `themes/` with starter files (2030 words each, varied length):
- `mr-men.json`
- `sea-creatures.json`
- `superheroes.json`
- `farm-animals.json`
- `villains.json`
- `transformers.json`
- `wild-animals.json`
- `precious-stones.json`
- `common-birds.json` — songbirds plus raptors (owl, eagle, hawk, falcon, etc.)
- `science-physics.json` — forces, energy, motion, electricity
- `science-chemistry.json` — atoms, molecules, elements, reactions
- `science-biology.json` — cells, organs, microbes, ecology
## Dockerfile
```dockerfile
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY static/ ./static/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
## docker-compose.yml
```yaml
services:
wordsearch:
build: .
ports:
- "8000:8000"
volumes:
- ./themes:/app/themes
restart: unless-stopped
```
## Acceptance Criteria
- `docker compose up` starts the app, accessible at `http://<host>:8000`.
- Generate a 12×12 puzzle from a pre-shipped theme with default settings,
download a valid PDF.
- Toggling diagonal, reversed, and overlap each visibly changes the puzzle.
- Min/max length filtering works: setting `min=8` excludes short words.
- Theme editor: create new theme, see live normalisation preview, save,
appear in dropdown, generate from it.
- Edit and delete existing themes via UI.
- Words with prefixes (Mr, Dr, etc.) show stripped form in grid, full form
with prefix in parentheses on word list.
- When fewer words can be placed than requested, the PDF still generates
(warnings only go to stderr — no result page).
- Bad input (invalid slug, empty word list, max length < min length) shows
a clear error message, not a stack trace.
- Downloaded PDFs are named `<slug>_<YYYY-MM-DD_HH-MM-SS>.pdf`.