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#

Browser gunicorn 2 workers × 4 threads before_request g.profile = current_profile() blueprint view library / drills / docs … services + models scoring, stats, cube SQLite /data Jinja render → after_request → response context processor injects settings, nav_items, current_profile; security headers added the JSON API skips Jinja and returns jsonify() directly
Every request resolves a profile before the view runs.

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 by app/static/js/cube/, with shared fixtures keeping the two honest.
Bootstrap
app/services/bootstrap.py. Imports app/data/algorithms.json and the built-in plans on first boot, idempotently.

Design rules#

These are the ones that will bite you if you ignore them.

  1. Everything runs in Docker. No host Python, ever. See Testing.
  2. 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.
  3. No new Python dependencies without agreement. Five is the budget.
  4. The design system is base.css. Use its tokens and the macros in partials/_macros.html. Do not invent a second visual language and do not restyle the shared shell. See Frontend.
  5. Light and airy, up to 90% of the viewport, works at 375px. Both of those are testable and both are checked.
  6. Empty and error states are designed, not blank.
  7. 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#

  1. This page.
  2. Data model — the tables and how they relate.
  3. Scoring — the one algorithm that drives the product.
  4. Cube engine — notation, facelets, tracks.
  5. Frontend — tokens, macros, the JS modules.
  6. 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.

Source: docs/developer/index.md