Architecture#
A Flask application, a SQLite file and a pile of static assets with no build step. Nothing is compiled, nothing is bundled, nothing is fetched from a CDN. If you can read Python and ES modules you can read all of it.
The stack#
| Layer | Choice | Why |
|---|---|---|
| Runtime | Python 3.12 in python:3.12-slim |
One container, no host environment |
| Web | Flask 3 + Jinja templates | Server-rendered pages; JS enhances, it does not own the page |
| ORM | SQLAlchemy 2 via Flask-SQLAlchemy | Typed Mapped[...] models |
| Database | SQLite (Postgres works) | Local-first; one file to back up |
| Config | YAML + RT_* env vars |
See Configuration |
| Docs | Markdown + Pygments |
This site, rendered on the fly |
| 3D | three.js, vendored | ES modules, no bundler, works offline |
| Server | gunicorn, 2 workers × 4 threads | Enough for a household |
Five runtime dependencies plus gunicorn. Adding a sixth is a conversation, not a commit.
Layout#
app/
__init__.py application factory, error handlers, CLI commands
config.py layered settings (defaults < YAML < env)
extensions.py the SQLAlchemy instance
models.py every table
blueprints/ one module per URL prefix
main.py / dashboard, profiles
library.py /library the case library
assessment.py /assessment wizard + overview
drills.py /drills practice sessions
plans.py /plans training plans and the builder
timer.py /timer full-solve timing
viewer.py /viewer the standalone 3D viewer
docs.py /docs this documentation site
api.py /api/v1 the JSON API
services/ logic with no HTTP in it
bootstrap.py first-boot seeding
profiles.py profile selection and reset
navigation.py the primary nav definition
scoring.py the 0-100 model
stats.py averages, ao5/ao12, phase summaries
cube/ the pure-Python cube engine
notation.py state.py scramble.py track.py render.py
data/algorithms.json the case library, generated from the PDFs
static/
css/base.css the design system; one per module beside it
js/app.js shared helpers; one per module beside it
js/cube/ the browser cube engine and viewer
vendor/ three.js, vendored
templates/
base.html the shell
partials/_macros.html shared Jinja components
<module>/ one directory per blueprint
docs/ this documentation, rendered at /docs
scripts/ one-off tooling (PDF extraction)
tests/ pytest, plus tests/js for the node runner
The rule the layout encodes: one blueprint owns one URL prefix, one template
directory, one CSS file and one JS file. Cross-module reuse goes through
services/, partials/_macros.html and base.css — never by importing
another blueprint.
What a request does#
The pieces of that worth knowing:
create_app() in app/__init__.py is the only entry point. It merges
config, initialises SQLAlchemy, registers blueprints from the explicit list in
app/blueprints/__init__.py, installs the error handlers, creates the schema
and seeds on first boot, then registers the CLI commands.
before_request puts the active profile on g.profile. Views never look
up a profile themselves; the id lives in the session cookie and
services/profiles.py owns the lookup and the fallback to the default profile.
The context processor injects settings, app_name, app_version,
nav_items, current_profile and phase_labels into every template, so
base.html never needs a view to pass them.
after_request sets X-Content-Type-Options, X-Frame-Options and
Referrer-Policy on everything.
Error handlers branch on the path: anything under /api/ gets JSON, the
rest gets the styled error templates.
Where the interesting logic lives#
Not in the views. A blueprint should read as parse the request, call a service, render a template.
- Scoring
app/services/scoring.py. One 0-100 number per (profile, case), and the only definition of "weak" in the codebase. The list sort, the spider graph and the drill generator all call it, so they cannot disagree.- Statistics
app/services/stats.py. Trimmed means, ao5/ao12/ao100, phase summaries. Kept apart from scoring because averages are a presentation concern and the score is a decision.- The cube engine
app/cube/. Pure Python, no Flask import anywhere in it, deterministic given a seed. Notation parsing, the 54-facelet state, scrambles, the solve track format and SVG rendering. Mirrored move-for-move byapp/static/js/cube/, with shared fixtures keeping the two honest.- Bootstrap
app/services/bootstrap.py. Importsapp/data/algorithms.jsonand the built-in plans on first boot, idempotently.
Design rules#
These are the ones that will bite you if you ignore them.
- Everything runs in Docker. No host Python, ever. See Testing.
- No network at runtime. Vendor JS into
app/static/vendor/. No CDN links, no web fonts, no external images. Downloading at build time is fine. - No new Python dependencies without agreement. Five is the budget.
- The design system is
base.css. Use its tokens and the macros inpartials/_macros.html. Do not invent a second visual language and do not restyle the shared shell. See Frontend. - Light and airy, up to 90% of the viewport, works at 375px. Both of those are testable and both are checked.
- Empty and error states are designed, not blank.
- Keyboard reachable, sensible ARIA, visible focus.
The data flow that matters#
algorithms.json ──seed──▶ case, algorithm, case_group
│
assessment ──┐ │
attempt ──┼──▶ scoring.refresh_case_score ──▶ case_score_cache
solve ──┘ │
▼
library sort · spider graph · drill selection
case_score_cache is a denormalisation, not a source of truth. It exists so
that sorting 119 cases by score is one indexed query instead of 119
computations. Anything that writes an assessment or an attempt must call
refresh_case_score(profile_id, case_id); the tests enforce that the cache and
a fresh computation agree.
Reading order for a new contributor#
- This page.
- Data model — the tables and how they relate.
- Scoring — the one algorithm that drives the product.
- Cube engine — notation, facelets, tracks.
- Frontend — tokens, macros, the JS modules.
- Testing and Contributing before your first pull request.
The JSON API and the live API console are the quickest way to see what the app can actually do.