Initial commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.gitignore
|
||||
*.md
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.idea/
|
||||
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal 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 A–Z, 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
8
Dockerfile
Normal 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
60
README.md
Normal 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
0
app/__init__.py
Normal file
198
app/generator.py
Normal file
198
app/generator.py
Normal 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
251
app/main.py
Normal 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
72
app/normaliser.py
Normal 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
143
app/pdf.py
Normal 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
41
app/templates/base.html
Normal 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">
|
||||
✦ WORDSMITH · PUZZLE WORKSHOP · EST. 2026 ✦
|
||||
</footer>
|
||||
</div>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
162
app/templates/index.html
Normal file
162
app/templates/index.html
Normal 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 %}
|
||||
161
app/templates/theme_edit.html
Normal file
161
app/templates/theme_edit.html
Normal 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
57
app/templates/themes.html
Normal 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
96
app/themes.py
Normal 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
21
docker-compose.yml
Normal 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
5
requirements.txt
Normal 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
701
static/style.css
Normal 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
33
themes/common-birds.json
Normal 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
31
themes/farm-animals.json
Normal 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
31
themes/mr-men.json
Normal 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"
|
||||
]
|
||||
}
|
||||
32
themes/precious-stones.json
Normal file
32
themes/precious-stones.json
Normal 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"
|
||||
]
|
||||
}
|
||||
33
themes/science-biology.json
Normal file
33
themes/science-biology.json
Normal 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"
|
||||
]
|
||||
}
|
||||
33
themes/science-chemistry.json
Normal file
33
themes/science-chemistry.json
Normal 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"
|
||||
]
|
||||
}
|
||||
34
themes/science-physics.json
Normal file
34
themes/science-physics.json
Normal 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
31
themes/sea-creatures.json
Normal 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
32
themes/superheroes.json
Normal 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
31
themes/transformers.json
Normal 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
31
themes/villains.json
Normal 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
33
themes/wild-animals.json
Normal 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
369
wordsearch-specs.md
Normal 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
|
||||
A–Z 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 **20–30 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"
|
||||
- 2–4 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 (20–30 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`.
|
||||
Reference in New Issue
Block a user