commit 6ecf86cc0eac3d358098db13d89a778aa4bcad74
Author: Zach Rice <bynxmusic@gmail.com>
Date: Thu, 28 May 2026 17:42:18 -0400
Init commit
Diffstat:
| A | .env.example | | | 9 | +++++++++ |
| A | .gitignore | | | 32 | ++++++++++++++++++++++++++++++++ |
| A | app.py | | | 1084 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | database.py | | | 696 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | dictionary.py | | | 353 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | lessons.py | | | 460 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | phrases.py | | | 400 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | plover_engine.py | | | 692 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | requirements.txt | | | 3 | +++ |
| A | sentence_generator.py | | | 172 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | settings.py | | | 38 | ++++++++++++++++++++++++++++++++++++++ |
| A | spaced_repetition.py | | | 59 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | static/css/style.css | | | 1900 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | static/js/app.js | | | 1319 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | templates/base.html | | | 61 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | templates/dashboard.html | | | 174 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | templates/login.html | | | 46 | ++++++++++++++++++++++++++++++++++++++++++++++ |
| A | templates/partials/word_card.html | | | 2 | ++ |
| A | templates/practice.html | | | 104 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | templates/settings.html | | | 295 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | templates/stats.html | | | 103 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
21 files changed, 8002 insertions(+), 0 deletions(-)
diff --git a/.env.example b/.env.example
@@ -0,0 +1,9 @@
+# Flask secret key (generate with: python -c "import secrets; print(secrets.token_hex(32))")
+SECRET_KEY=change-me-to-a-random-string
+
+# Google OAuth credentials (from https://console.cloud.google.com/apis/credentials)
+# Create an OAuth 2.0 Client ID with redirect URI: http://localhost:5000/auth/google/callback
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+
+# Leave GOOGLE_CLIENT_ID empty to run in dev mode (no auth required)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,32 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+*.egg
+
+# Virtual environments
+venv/
+.venv/
+env/
+
+# Environment variables
+.env
+
+# Database
+*.db
+
+# Uploads (user-specific data)
+uploads/
+
+# Test results
+test-results/
+
+# IDE
+.vscode/
+.idea/
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/app.py b/app.py
@@ -0,0 +1,1084 @@
+"""Flask application for multi-user stenography practice."""
+
+import configparser
+import json
+import os
+import random
+import re
+import time
+from functools import wraps
+
+from authlib.integrations.flask_client import OAuth
+from dotenv import load_dotenv
+from flask import Flask, jsonify, redirect, render_template, request, session, url_for
+from werkzeug.utils import secure_filename
+
+from database import Database
+from lessons import (
+ TOP_WORDS,
+ get_lesson_by_id,
+ get_lesson_list,
+ get_lesson_word_list,
+ get_lesson_words,
+ get_sentences,
+)
+from plover_engine import PloverEngine
+
+load_dotenv()
+
+app = Flask(__name__)
+app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-key-change-me")
+app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0
+app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024
+
+UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "uploads")
+DEV_MODE = not os.environ.get("GOOGLE_CLIENT_ID")
+
+_start_time = int(time.time())
+
+oauth = OAuth(app)
+if not DEV_MODE:
+ google = oauth.register(
+ name="google",
+ client_id=os.environ["GOOGLE_CLIENT_ID"],
+ client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
+ server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
+ client_kwargs={"scope": "openid email profile"},
+ )
+
+db = Database()
+
+if DEV_MODE:
+ dev_user = db.ensure_dev_user()
+ print(f"DEV MODE: No Google OAuth configured. Using dev user (id={dev_user['id']}).")
+
+# Per-user engine cache
+_engine_cache = {}
+
+
+def _get_user_dict_dir(user_id):
+ return os.path.join(UPLOAD_DIR, str(user_id))
+
+
+def get_engine(user_id):
+ if user_id in _engine_cache:
+ return _engine_cache[user_id]
+ engine = PloverEngine()
+ dict_dir = _get_user_dict_dir(user_id)
+ dicts = db.get_user_dictionaries(user_id)
+ if dicts:
+ engine.load_from_uploaded(dict_dir, [dict(d) for d in dicts])
+ _engine_cache[user_id] = engine
+ return engine
+
+
+def reload_engine(user_id):
+ _engine_cache.pop(user_id, None)
+ return get_engine(user_id)
+
+
+@app.context_processor
+def inject_globals():
+ user = None
+ if "user_id" in session:
+ user = db.get_user(session["user_id"])
+ return {"cache_bust": _start_time, "current_user": user, "dev_mode": DEV_MODE}
+
+
+@app.after_request
+def add_no_cache(response):
+ if "text/html" in response.content_type or "javascript" in response.content_type:
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
+ return response
+
+
+# ── Auth ──
+
+
+def login_required(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ if DEV_MODE:
+ session.setdefault("user_id", dev_user["id"])
+ return f(*args, **kwargs)
+ if "user_id" not in session:
+ return redirect(url_for("login"))
+ return f(*args, **kwargs)
+ return decorated
+
+
+def current_user_id():
+ return session.get("user_id")
+
+
+@app.route("/login")
+def login():
+ if "user_id" in session:
+ return redirect("/")
+ if DEV_MODE:
+ session["user_id"] = dev_user["id"]
+ return redirect("/")
+ return render_template("login.html")
+
+
+@app.route("/login/google")
+def login_google():
+ if DEV_MODE:
+ session["user_id"] = dev_user["id"]
+ return redirect("/")
+ redirect_uri = url_for("auth_google_callback", _external=True)
+ return google.authorize_redirect(redirect_uri)
+
+
+@app.route("/auth/google/callback")
+def auth_google_callback():
+ if DEV_MODE:
+ session["user_id"] = dev_user["id"]
+ return redirect("/")
+
+ token = google.authorize_access_token()
+ userinfo = token.get("userinfo")
+ if not userinfo:
+ return "Authentication failed", 400
+
+ if not userinfo.get("email_verified"):
+ return render_template("login.html", error="Your Google email is not verified. Please verify it and try again.")
+
+ user = db.get_or_create_user(
+ email=userinfo["email"],
+ name=userinfo.get("name"),
+ avatar_url=userinfo.get("picture"),
+ )
+ session["user_id"] = user["id"]
+ session.permanent = True
+ return redirect("/")
+
+
+@app.route("/logout")
+def logout():
+ session.clear()
+ if DEV_MODE:
+ return redirect("/")
+ return redirect(url_for("login"))
+
+
+# ── Page routes ──
+
+
+MODE_LABELS = {
+ "common": "Common Words",
+ "random": "Random",
+ "new": "New Words",
+ "weak": "Weak Words",
+ "review": "Review",
+ "speed": "Speed Drill",
+ "sentences": "Sentences",
+ "phrases": "Phrases",
+ "lesson": "Lesson",
+}
+
+
+@app.route("/")
+@login_required
+def dashboard():
+ uid = current_user_id()
+ daily = db.get_daily_stats(uid, days=1)
+ raw_today = daily[0] if daily else {
+ "words_practiced": 0, "words_correct": 0, "total_time_ms": 0, "sessions": 0
+ }
+ wp = raw_today.get("words_practiced", 0)
+ wc = raw_today.get("words_correct", 0)
+ today = {
+ "words": wp,
+ "correct": wc,
+ "accuracy": (wc / wp * 100) if wp > 0 else 0,
+ "sessions": raw_today.get("sessions", 0),
+ }
+
+ overview = db.get_overview_stats(uid)
+ streak = overview["current_streak"]
+ words_mastered = overview["words_mastered"]
+ total_practiced = overview["unique_words"]
+ review_due = len(db.get_words_due_for_review(uid, count=1000))
+
+ lesson_list = get_lesson_list()
+ for l in lesson_list:
+ lesson_def = get_lesson_by_id(l["id"])
+ if lesson_def:
+ word_list = get_lesson_word_list(lesson_def)
+ if word_list:
+ prog = db.get_lesson_progress(uid, word_list)
+ l["progress"] = prog
+
+ raw_sessions = db.get_recent_sessions(uid, limit=5)
+ recent_sessions = []
+ for s in raw_sessions:
+ wa = s.get("words_attempted", 0)
+ wco = s.get("words_correct", 0)
+ total_ms = s.get("total_time_ms", 0)
+ total_chars = wa * 5
+ wpm = (total_chars / 5) / (total_ms / 60000) if total_ms > 0 else 0
+ recent_sessions.append({
+ "mode": s.get("mode", ""),
+ "date_str": (s.get("started_at") or "")[:10],
+ "words_attempted": wa,
+ "words_correct": wco,
+ "accuracy": (wco / wa * 100) if wa > 0 else 0,
+ "wpm": round(wpm, 1),
+ })
+
+ engine = get_engine(uid)
+ has_dicts = bool(engine._practice_words)
+
+ return render_template(
+ "dashboard.html",
+ today=today,
+ streak=streak,
+ words_mastered=words_mastered,
+ total_practiced=total_practiced,
+ review_due=review_due,
+ recent_sessions=recent_sessions,
+ lessons=lesson_list,
+ has_dicts=has_dicts,
+ )
+
+
+@app.route("/practice")
+@login_required
+def practice():
+ mode = request.args.get("mode", "common")
+ lesson_id = request.args.get("lesson")
+ mode_label = MODE_LABELS.get(mode, "Practice")
+ if lesson_id:
+ lesson = get_lesson_by_id(lesson_id)
+ if lesson:
+ mode_label = lesson["title"]
+ return render_template("practice.html", mode=mode, mode_label=mode_label, lesson_id=lesson_id or "")
+
+
+@app.route("/stats")
+@login_required
+def stats():
+ return render_template("stats.html")
+
+
+@app.route("/settings")
+@login_required
+def settings_page():
+ uid = current_user_id()
+ user_settings = db.get_user_settings(uid)
+ user_dicts = db.get_user_dictionaries(uid)
+ return render_template("settings.html", settings=user_settings, dictionaries=user_dicts)
+
+
+# ── Session API ──
+
+session_store = {}
+
+
+@app.route("/api/session/start", methods=["POST"])
+@login_required
+def api_session_start():
+ uid = current_user_id()
+ engine = get_engine(uid)
+ data = request.get_json()
+ mode = data.get("mode", "common")
+ lesson_id = data.get("lesson_id")
+ user_settings = db.get_user_settings(uid)
+ count = data.get("count", user_settings.get("words_per_session", 50))
+
+ if lesson_id:
+ mode = "lesson"
+ lesson = get_lesson_by_id(lesson_id)
+ if not lesson:
+ return jsonify({"error": "Lesson not found"}), 404
+ sentence_count = data.get("sentence_count", 10)
+ items = get_lesson_words(lesson, engine, count=sentence_count)
+ if lesson.get("mode") != "phrases":
+ return _start_sentence_session(uid, engine, mode, items)
+ else:
+ items = _select_words(uid, engine, mode, count)
+
+ if not items:
+ return jsonify({"error": "No words available for this mode"}), 400
+
+ if mode == "sentences":
+ return _start_sentence_session(uid, engine, mode, items)
+
+ session_id = db.create_session(uid, mode)
+ db.increment_daily_sessions(uid)
+
+ words_data = []
+ for i, word in enumerate(items):
+ display = engine.display_form(word)
+ hints = engine.get_all_hints(word)
+ words_data.append({
+ "word": display,
+ "index": i,
+ "word_hints": hints["word_hints"],
+ "phrase_hints": hints["phrase_hints"],
+ "fingerspell": hints.get("fingerspell", False),
+ "punct_hints": hints.get("punct_hints", []),
+ "cap_hints": hints.get("cap_hints", []),
+ })
+
+ session_store[session_id] = {
+ "words": items,
+ "user_id": uid,
+ "index": 0,
+ "streak": 0,
+ "best_streak": 0,
+ }
+
+ return jsonify({
+ "session_id": session_id,
+ "words": words_data,
+ "total": len(items),
+ })
+
+
+_SENT_PUNCT = set('.,;:!?"\')}]>…')
+
+
+def _tokenize_sentence(sentence):
+ """Split sentence into steno tokens: words and trailing punctuation separately.
+ Each token has a 'prefix' = the expected sentence text up through that token,
+ used for prefix-based input matching."""
+ tokens = []
+ for raw_word in sentence.split():
+ idx = sentence.index(raw_word, tokens[-1]["end"] if tokens else 0)
+
+ word = raw_word
+ while word and word[-1] in _SENT_PUNCT:
+ word = word[:-1]
+
+ if word:
+ word_end = idx + len(word)
+ tokens.append({"text": word, "prefix": sentence[:word_end],
+ "end": word_end, "is_punct": False})
+
+ for j in range(len(word), len(raw_word)):
+ char_end = idx + j + 1
+ tokens.append({"text": raw_word[j], "prefix": sentence[:char_end],
+ "end": char_end, "is_punct": True})
+ return tokens
+
+
+def _start_sentence_session(uid, engine, mode, sentences):
+ session_id = db.create_session(uid, mode)
+ db.increment_daily_sessions(uid)
+
+ # Pass 1: build tokens with cumulative prefixes
+ raw_tokens = []
+ cumulative_text = ""
+ for sentence in sentences:
+ sent_words = sentence.split()
+ tokens = _tokenize_sentence(sentence)
+ for token in tokens:
+ if token["is_punct"]:
+ cumulative_text += token["text"]
+ else:
+ if cumulative_text:
+ cumulative_text += " "
+ cumulative_text += token["text"]
+ raw_tokens.append({
+ "text": token["text"],
+ "is_punct": token["is_punct"],
+ "prefix": cumulative_text,
+ "sentence": sentence,
+ "sent_words": sent_words,
+ })
+
+ # Pass 2: add hints with lookahead context
+ all_tokens = []
+ for i, tok in enumerate(raw_tokens):
+ if tok["is_punct"]:
+ next_word = None
+ for j in range(i + 1, len(raw_tokens)):
+ if not raw_tokens[j]["is_punct"]:
+ next_word = raw_tokens[j]["text"]
+ break
+ space_after = next_word is not None
+ cap_next = bool(next_word and next_word[0].isupper())
+ hints = {
+ "word_hints": engine.get_punct_hints(
+ tok["text"], space_after=space_after, cap_next=cap_next),
+ "phrase_hints": [], "fingerspell": False,
+ "punct_hints": [], "cap_hints": [],
+ }
+ else:
+ sent_words = tok["sent_words"]
+ word_idx = next((wi for wi, w in enumerate(sent_words)
+ if w.startswith(tok["text"])), 0)
+ hints = engine.get_all_hints(
+ tok["text"], context_words=sent_words,
+ context_index=word_idx)
+
+ all_tokens.append({
+ "word": tok["text"],
+ "prefix": tok["prefix"],
+ "is_punct": tok["is_punct"],
+ "index": len(all_tokens),
+ "word_hints": hints["word_hints"],
+ "phrase_hints": hints.get("phrase_hints", []),
+ "fingerspell": hints.get("fingerspell", False),
+ "punct_hints": hints.get("punct_hints", []),
+ "cap_hints": hints.get("cap_hints", []),
+ "sentence": tok["sentence"],
+ })
+
+ stat_words = [t["word"] for t in all_tokens]
+
+ session_store[session_id] = {
+ "words": stat_words,
+ "user_id": uid,
+ "is_sentence": True,
+ "index": 0,
+ "streak": 0,
+ "best_streak": 0,
+ }
+
+ return jsonify({
+ "session_id": session_id,
+ "words": all_tokens,
+ "total": len(all_tokens),
+ "is_sentence": True,
+ })
+
+
+_TRAILING_PUNCT = re.compile(r'[.!?,;:"\'\)\]\}]+$')
+
+
+def _strip_word_punctuation(word):
+ """Strip trailing punctuation for stats tracking, but keep contractions intact."""
+ return _TRAILING_PUNCT.sub("", word)
+
+
+@app.route("/api/word/submit", methods=["POST"])
+@login_required
+def api_word_submit():
+ uid = current_user_id()
+ engine = get_engine(uid)
+ data = request.get_json()
+ session_id = data.get("session_id")
+ word = data.get("word", "")
+ typed = data.get("typed", "")
+ time_ms = data.get("time_ms", 0)
+ word_index = data.get("word_index", None)
+
+ if session_id not in session_store:
+ return jsonify({"error": "Session not found"}), 404
+
+ sess = session_store[session_id]
+ if sess.get("user_id") != uid:
+ return jsonify({"error": "Not your session"}), 403
+
+ correct = typed.strip() == word.strip()
+ stat_word = _strip_word_punctuation(word.strip()).lower()
+ strokes = engine.get_strokes(stat_word or word)
+ best_stroke = strokes[0] if strokes else ""
+
+ db.record_attempt(uid, session_id, stat_word or word, best_stroke, typed.strip(), correct, time_ms)
+
+ if correct:
+ sess["streak"] += 1
+ sess["best_streak"] = max(sess["best_streak"], sess["streak"])
+ else:
+ sess["streak"] = 0
+
+ if word_index is not None:
+ sess["index"] = word_index + 1
+
+ db_session = db.get_session(session_id)
+ words_attempted = db_session["words_attempted"] if db_session else 1
+ words_correct = db_session["words_correct"] if db_session else (1 if correct else 0)
+ accuracy = words_correct / words_attempted if words_attempted > 0 else 0
+
+ word_chars = len(stat_word) if stat_word else len(typed.strip())
+ wpm = (word_chars / 5) / (time_ms / 60000) if time_ms > 0 else 0
+
+ return jsonify({
+ "correct": correct,
+ "expected": word,
+ "typed": typed.strip(),
+ "time_ms": time_ms,
+ "accuracy": accuracy,
+ "streak": sess["streak"],
+ "wpm": round(wpm, 1),
+ })
+
+
+@app.route("/api/session/end", methods=["POST"])
+@login_required
+def api_session_end():
+ uid = current_user_id()
+ data = request.get_json()
+ session_id = data.get("session_id")
+
+ if session_id is None:
+ return jsonify({"error": "session_id required"}), 400
+
+ db.end_session(session_id)
+ stats = db.get_session_stats(session_id)
+ if not stats:
+ return jsonify({"error": "Session not found"}), 404
+
+ attempts = db.get_session_attempts(session_id)
+
+ weakest = []
+ for a in attempts:
+ if not a["correct"]:
+ weakest.append({
+ "word": a["word"],
+ "typed": a.get("typed_text", ""),
+ "strokes_display": a["strokes"] or "",
+ "time_ms": a["time_ms"],
+ "correct": False,
+ })
+
+ if len(weakest) < 5:
+ correct_attempts = sorted(
+ [a for a in attempts if a["correct"]],
+ key=lambda x: x["time_ms"],
+ reverse=True,
+ )
+ for a in correct_attempts:
+ if len(weakest) >= 5:
+ break
+ weakest.append({
+ "word": a["word"],
+ "typed": a.get("typed_text", ""),
+ "strokes_display": a["strokes"] or "",
+ "time_ms": a["time_ms"],
+ "correct": True,
+ })
+
+ # Full per-word results for detailed accuracy review
+ all_results = []
+ for a in attempts:
+ all_results.append({
+ "word": a["word"],
+ "typed": a.get("typed_text", ""),
+ "correct": bool(a["correct"]),
+ "time_ms": a["time_ms"],
+ "strokes": a["strokes"] or "",
+ })
+
+ total_chars = sum(len(_strip_word_punctuation(a["word"] or "")) for a in attempts)
+ total_time = stats["total_time_ms"]
+ avg_wpm = (total_chars / 5) / (total_time / 60000) if total_time > 0 else 0
+
+ session_store.pop(session_id, None)
+
+ return jsonify({
+ "session_id": session_id,
+ "words_attempted": stats["words_attempted"],
+ "words_correct": stats["words_correct"],
+ "accuracy": stats["accuracy"],
+ "avg_wpm": round(avg_wpm, 1),
+ "total_time_ms": stats["total_time_ms"],
+ "best_streak": stats["best_streak"],
+ "weakest_words": weakest,
+ "all_results": all_results,
+ })
+
+
+# ── Settings API ──
+
+
+@app.route("/api/settings", methods=["GET"])
+@login_required
+def api_settings_get():
+ return jsonify(db.get_user_settings(current_user_id()))
+
+
+@app.route("/api/settings", methods=["POST"])
+@login_required
+def api_settings_set():
+ uid = current_user_id()
+ data = request.get_json()
+ if "words_per_session" in data:
+ val = data["words_per_session"]
+ if isinstance(val, int) and 10 <= val <= 200:
+ db.update_user_settings(uid, words_per_session=val)
+ return jsonify({"ok": True, "settings": db.get_user_settings(uid)})
+
+
+@app.route("/api/data/clear", methods=["POST"])
+@login_required
+def api_data_clear():
+ db.clear_user_data(current_user_id())
+ return jsonify({"ok": True})
+
+
+# ── Dictionary Upload API ──
+
+ALLOWED_DICT_EXTENSIONS = {".json", ".py"}
+
+
+@app.route("/api/dictionaries/upload-config", methods=["POST"])
+@login_required
+def api_upload_config():
+ uid = current_user_id()
+
+ if "config" not in request.files:
+ return jsonify({"error": "No config file provided"}), 400
+
+ cfg_file = request.files["config"]
+ if not cfg_file.filename:
+ return jsonify({"error": "No file selected"}), 400
+
+ cfg_content = cfg_file.read().decode("utf-8", errors="replace")
+ entries = PloverEngine.parse_plover_config(cfg_content)
+
+ if not entries:
+ return jsonify({"error": "No dictionaries found in config. Make sure it has a [System: English Stenotype] section."}), 400
+
+ user_dir = _get_user_dict_dir(uid)
+ os.makedirs(user_dir, exist_ok=True)
+
+ cfg_path = os.path.join(user_dir, "plover.cfg")
+ with open(cfg_path, "w", encoding="utf-8") as f:
+ f.write(cfg_content)
+
+ db.clear_user_dictionaries(uid)
+
+ existing_files = set(os.listdir(user_dir)) if os.path.isdir(user_dir) else set()
+
+ result_entries = []
+ for entry in entries:
+ filename = entry["filename"]
+ safe = secure_filename(filename)
+ if not safe:
+ continue
+
+ ext = os.path.splitext(safe)[1].lower()
+ if ext not in ALLOWED_DICT_EXTENSIONS:
+ continue
+
+ uploaded = safe in existing_files
+ entry_count = 0
+ if uploaded and ext == ".json":
+ try:
+ with open(os.path.join(user_dir, safe), "r", encoding="utf-8") as f:
+ entry_count = len(json.load(f))
+ except Exception:
+ pass
+
+ dict_id = db.add_user_dictionary(
+ uid, safe, filename, entry["order"], entry_count
+ )
+
+ result_entries.append({
+ "id": dict_id,
+ "filename": safe,
+ "display_name": filename,
+ "order": entry["order"],
+ "enabled": entry["enabled"],
+ "uploaded": uploaded,
+ "entry_count": entry_count,
+ })
+
+ if any(e["uploaded"] for e in result_entries):
+ reload_engine(uid)
+
+ return jsonify({"ok": True, "dictionaries": result_entries})
+
+
+@app.route("/api/dictionaries/upload-folder", methods=["POST"])
+@login_required
+def api_upload_folder():
+ """Receive plover.cfg + only the referenced dict files (pre-filtered client-side)."""
+ uid = current_user_id()
+ cfg_file = request.files.get("config")
+ if not cfg_file:
+ return jsonify({"error": "No plover.cfg provided"}), 400
+
+ cfg_content = cfg_file.read().decode("utf-8", errors="replace")
+ entries = PloverEngine.parse_plover_config(cfg_content)
+ if not entries:
+ return jsonify({"error": "No dictionaries found in plover.cfg."}), 400
+
+ user_dir = _get_user_dict_dir(uid)
+ os.makedirs(user_dir, exist_ok=True)
+
+ with open(os.path.join(user_dir, "plover.cfg"), "w", encoding="utf-8") as out:
+ out.write(cfg_content)
+
+ dict_files = request.files.getlist("dicts")
+ file_map = {}
+ for f in dict_files:
+ name = os.path.basename(f.filename or "")
+ if name:
+ file_map[name] = f
+
+ db.clear_user_dictionaries(uid)
+
+ result_entries = []
+ saved = 0
+ for entry in entries:
+ filename = entry["filename"]
+ safe = secure_filename(filename)
+ if not safe:
+ continue
+ ext = os.path.splitext(safe)[1].lower()
+ if ext not in ALLOWED_DICT_EXTENSIONS:
+ continue
+
+ uploaded_file = file_map.get(filename) or file_map.get(safe)
+ found = False
+ entry_count = 0
+ if uploaded_file:
+ dest = os.path.join(user_dir, safe)
+ uploaded_file.save(dest)
+ found = True
+ saved += 1
+ if ext == ".json":
+ try:
+ with open(dest, "r", encoding="utf-8") as jf:
+ entry_count = len(json.load(jf))
+ except Exception:
+ pass
+
+ dict_id = db.add_user_dictionary(
+ uid, safe, filename, entry["order"], entry_count
+ )
+ result_entries.append({
+ "id": dict_id,
+ "filename": safe,
+ "display_name": filename,
+ "order": entry["order"],
+ "enabled": entry["enabled"],
+ "uploaded": found,
+ "entry_count": entry_count,
+ })
+
+ reload_engine(uid)
+
+ missing = [e for e in result_entries if not e["uploaded"]]
+ return jsonify({
+ "ok": True,
+ "dictionaries": result_entries,
+ "saved": saved,
+ "missing": len(missing),
+ })
+
+
+@app.route("/api/dictionaries/upload", methods=["POST"])
+@login_required
+def api_upload_dicts():
+ uid = current_user_id()
+ user_dir = _get_user_dict_dir(uid)
+ os.makedirs(user_dir, exist_ok=True)
+
+ files = request.files.getlist("files")
+ if not files:
+ return jsonify({"error": "No files provided"}), 400
+
+ known = {d["filename"]: d for d in db.get_user_dictionaries(uid)}
+
+ uploaded = []
+ for f in files:
+ if not f.filename:
+ continue
+ safe = secure_filename(f.filename)
+ if not safe:
+ continue
+ ext = os.path.splitext(safe)[1].lower()
+ if ext not in ALLOWED_DICT_EXTENSIONS:
+ continue
+
+ dest = os.path.join(user_dir, safe)
+ f.save(dest)
+
+ entry_count = 0
+ if ext == ".json":
+ try:
+ with open(dest, "r", encoding="utf-8") as jf:
+ entry_count = len(json.load(jf))
+ except Exception:
+ pass
+
+ if safe in known:
+ db.update_dictionary_entry_count(known[safe]["id"], uid, entry_count)
+ else:
+ order = max((d.get("dict_order", 0) for d in known.values()), default=-1) + 1
+ db.add_user_dictionary(uid, safe, f.filename, order, entry_count)
+
+ uploaded.append({"filename": safe, "entry_count": entry_count})
+
+ reload_engine(uid)
+
+ all_dicts = db.get_user_dictionaries(uid)
+ existing_files = set(os.listdir(user_dir)) if os.path.isdir(user_dir) else set()
+ dict_list = []
+ for d in all_dicts:
+ dict_list.append({
+ "id": d["id"],
+ "filename": d["filename"],
+ "display_name": d["display_name"],
+ "order": d["dict_order"],
+ "enabled": bool(d["enabled"]),
+ "uploaded": d["filename"] in existing_files,
+ "entry_count": d["entry_count"],
+ })
+
+ return jsonify({"ok": True, "uploaded": uploaded, "dictionaries": dict_list})
+
+
+@app.route("/api/dictionaries", methods=["GET"])
+@login_required
+def api_get_dicts():
+ uid = current_user_id()
+ all_dicts = db.get_user_dictionaries(uid)
+ user_dir = _get_user_dict_dir(uid)
+ existing_files = set(os.listdir(user_dir)) if os.path.isdir(user_dir) else set()
+
+ result = []
+ for d in all_dicts:
+ result.append({
+ "id": d["id"],
+ "filename": d["filename"],
+ "display_name": d["display_name"],
+ "order": d["dict_order"],
+ "enabled": bool(d["enabled"]),
+ "uploaded": d["filename"] in existing_files,
+ "entry_count": d["entry_count"],
+ })
+ return jsonify(result)
+
+
+@app.route("/api/dictionaries/<int:dict_id>/toggle", methods=["POST"])
+@login_required
+def api_toggle_dict(dict_id):
+ uid = current_user_id()
+ data = request.get_json()
+ enabled = data.get("enabled", True)
+ db.toggle_dictionary(dict_id, uid, enabled)
+ reload_engine(uid)
+ return jsonify({"ok": True})
+
+
+@app.route("/api/dictionaries/<int:dict_id>", methods=["DELETE"])
+@login_required
+def api_delete_dict(dict_id):
+ uid = current_user_id()
+ filename = db.delete_dictionary(dict_id, uid)
+ if filename:
+ fpath = os.path.join(_get_user_dict_dir(uid), filename)
+ if os.path.exists(fpath):
+ os.remove(fpath)
+ reload_engine(uid)
+ return jsonify({"ok": True})
+
+
+# ── Stats API ──
+
+
+@app.route("/api/stats/overview")
+@login_required
+def api_stats_overview():
+ uid = current_user_id()
+ raw = db.get_overview_stats(uid)
+ total_ms = raw.get("total_time_ms", 0)
+ return jsonify({
+ "total_sessions": raw.get("total_sessions", 0),
+ "total_words": raw.get("total_words_practiced", 0),
+ "words_mastered": raw.get("words_mastered", 0),
+ "avg_accuracy": raw.get("overall_accuracy", 0) * 100,
+ "avg_wpm": 0,
+ "total_time_seconds": total_ms // 1000,
+ "best_streak": raw.get("current_streak", 0),
+ })
+
+
+@app.route("/api/stats/daily")
+@login_required
+def api_stats_daily():
+ uid = current_user_id()
+ days = request.args.get("days", 30, type=int)
+ raw = db.get_daily_stats(uid, days=days)
+ result = []
+ for d in raw:
+ wp = d.get("words_practiced", 0)
+ wc = d.get("words_correct", 0)
+ result.append({
+ "date": d.get("date", ""),
+ "words_total": wp,
+ "words_correct": wc,
+ "accuracy": (wc / wp * 100) if wp > 0 else 0,
+ "avg_wpm": 0,
+ "sessions": d.get("sessions", 0),
+ })
+ return jsonify(result)
+
+
+@app.route("/api/stats/heatmap")
+@login_required
+def api_stats_heatmap():
+ return jsonify(db.get_heatmap_data(current_user_id()))
+
+
+@app.route("/api/stats/words")
+@login_required
+def api_stats_words():
+ uid = current_user_id()
+ sort_by = request.args.get("sort", "accuracy")
+ order = request.args.get("order", "asc")
+ limit = request.args.get("limit", 50, type=int)
+
+ all_stats = db.get_word_stats(uid)
+ result = []
+ for ws in all_stats:
+ att = ws.get("attempts", 0)
+ cor = ws.get("correct", 0)
+ result.append({
+ "word": ws.get("word", ""),
+ "strokes": ws.get("strokes", ""),
+ "attempts": att,
+ "correct": cor,
+ "accuracy": (cor / att * 100) if att > 0 else 0,
+ "avg_time": ws.get("avg_time_ms", 0),
+ })
+
+ reverse = order == "desc"
+ if sort_by in ("accuracy", "attempts", "correct", "avg_time"):
+ result.sort(key=lambda x: x.get(sort_by, 0), reverse=reverse)
+ elif sort_by == "word":
+ result.sort(key=lambda x: x.get("word", ""), reverse=reverse)
+ else:
+ result.sort(key=lambda x: x.get("accuracy", 0), reverse=reverse)
+
+ return jsonify(result[:limit])
+
+
+@app.route("/api/stats/sessions")
+@login_required
+def api_stats_sessions():
+ uid = current_user_id()
+ limit = request.args.get("limit", 20, type=int)
+ raw = db.get_all_sessions(uid, limit=limit)
+ result = []
+ for s in raw:
+ wa = s.get("words_attempted", 0)
+ wc = s.get("words_correct", 0)
+ total_ms = s.get("total_time_ms", 0)
+ total_chars = wa * 5
+ wpm = (total_chars / 5) / (total_ms / 60000) if total_ms > 0 else 0
+ started = s.get("started_at") or ""
+ result.append({
+ "date": started[:10] if started else "",
+ "date_str": started[:10] if started else "",
+ "mode": s.get("mode", ""),
+ "words_attempted": wa,
+ "words_correct": wc,
+ "accuracy": (wc / wa * 100) if wa > 0 else 0,
+ "wpm": round(wpm, 1),
+ "duration_seconds": total_ms // 1000,
+ })
+ return jsonify(result)
+
+
+@app.route("/api/lessons")
+@login_required
+def api_lessons():
+ uid = current_user_id()
+ lessons = get_lesson_list()
+ for l in lessons:
+ lesson_def = get_lesson_by_id(l["id"])
+ if lesson_def:
+ word_list = get_lesson_word_list(lesson_def)
+ if word_list:
+ prog = db.get_lesson_progress(uid, word_list)
+ l["progress"] = {
+ "total_words": prog["total_words"],
+ "practiced": prog["practiced"],
+ "mastered": prog["mastered"],
+ "avg_wpm": prog["avg_wpm"],
+ "avg_accuracy": prog["avg_accuracy"],
+ }
+ return jsonify(lessons)
+
+
+@app.route("/api/lessons/<lesson_id>/progress")
+@login_required
+def api_lesson_progress(lesson_id):
+ uid = current_user_id()
+ lesson = get_lesson_by_id(lesson_id)
+ if not lesson:
+ return jsonify({"error": "Lesson not found"}), 404
+ word_list = get_lesson_word_list(lesson)
+ if not word_list:
+ return jsonify({"error": "No word list for this lesson"}), 400
+ progress = db.get_lesson_progress(uid, word_list)
+ return jsonify(progress)
+
+
+# ── Helpers ──
+
+
+def _select_words(uid, engine, mode, count):
+ has_words = bool(engine.practice_words)
+
+ if mode == "common":
+ if has_words:
+ words = [w for w in TOP_WORDS[:500] if engine.word_exists(w)]
+ else:
+ words = list(TOP_WORDS[:500])
+ return words[:count]
+ elif mode == "random":
+ if has_words:
+ words = engine.practice_words
+ return random.sample(words, min(count, len(words)))
+ return list(TOP_WORDS[:count])
+ elif mode == "new":
+ practiced = db.get_practiced_words(uid)
+ if has_words:
+ available = [w for w in engine.practice_words if w not in practiced]
+ else:
+ available = [w for w in TOP_WORDS[:500] if w not in practiced]
+ return random.sample(available, min(count, len(available))) if available else []
+ elif mode == "weak":
+ word_stats = db.get_word_stats(uid)
+ weak = []
+ for ws in word_stats:
+ if ws["attempts"] >= 3:
+ accuracy = ws["correct"] / ws["attempts"]
+ weak.append((accuracy, ws["word"]))
+ weak.sort()
+ words = [w for _, w in weak[:count]]
+ if len(words) < count and has_words:
+ extra = random.sample(engine.practice_words, min(count - len(words), len(engine.practice_words)))
+ words.extend(extra)
+ return words[:count]
+ elif mode == "review":
+ words = db.get_words_due_for_review(uid, count)
+ if len(words) < count and has_words:
+ extra = random.sample(engine.practice_words, min(count - len(words), len(engine.practice_words)))
+ words.extend(extra)
+ return words[:count]
+ elif mode == "speed":
+ if has_words:
+ words = engine.single_stroke_words
+ return random.sample(words, min(count, len(words)))
+ return list(TOP_WORDS[:count])
+ elif mode == "phrases":
+ phrases = engine.all_phrases + engine.generate_phrasing_phrases(200)
+ return random.sample(phrases, min(count, len(phrases))) if phrases else []
+ elif mode == "sentences":
+ return get_sentences("intermediate", count=10)
+ else:
+ if has_words:
+ words = engine.practice_words
+ return random.sample(words, min(count, len(words)))
+ return list(TOP_WORDS[:count])
+
+
+print("Initializing StenoDojo...")
+db_check = Database()
+from sentence_generator import _load_corpus
+_load_corpus()
+print("Ready!\n")
+
+if __name__ == "__main__":
+ app.run(debug=True, port=5000)
diff --git a/database.py b/database.py
@@ -0,0 +1,696 @@
+"""Database module for multi-user stenography practice app."""
+
+import sqlite3
+from contextlib import contextmanager
+from datetime import datetime, timedelta
+
+from spaced_repetition import calculate_review
+
+DB_PATH = "/mnt/M2_1/dev/plover-practice/plover_practice.db"
+
+
+class Database:
+ def __init__(self, db_path=None):
+ self.db_path = db_path or DB_PATH
+ self._init_db()
+
+ @contextmanager
+ def _get_conn(self):
+ conn = sqlite3.connect(self.db_path, check_same_thread=False)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA foreign_keys=ON")
+ try:
+ yield conn
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+ def _init_db(self):
+ with self._get_conn() as conn:
+ tables = {r[0] for r in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ ).fetchall()}
+
+ if "sessions" not in tables:
+ self._create_tables(conn)
+ return
+
+ session_cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()}
+ if "user_id" not in session_cols:
+ self._migrate_to_v2(conn)
+ else:
+ self._create_tables(conn)
+
+ def _create_tables(self, conn):
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT UNIQUE NOT NULL,
+ name TEXT,
+ avatar_url TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ mode TEXT NOT NULL,
+ started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ ended_at TIMESTAMP,
+ words_attempted INTEGER DEFAULT 0,
+ words_correct INTEGER DEFAULT 0,
+ total_time_ms INTEGER DEFAULT 0
+ );
+
+ CREATE TABLE IF NOT EXISTS attempts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id INTEGER REFERENCES sessions(id),
+ word TEXT NOT NULL,
+ strokes TEXT,
+ typed_text TEXT,
+ correct BOOLEAN NOT NULL,
+ time_ms INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS word_stats (
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ word TEXT NOT NULL,
+ strokes TEXT,
+ attempts INTEGER DEFAULT 0,
+ correct INTEGER DEFAULT 0,
+ avg_time_ms REAL DEFAULT 0,
+ last_seen TIMESTAMP,
+ next_review TIMESTAMP,
+ ease_factor REAL DEFAULT 2.5,
+ interval_days REAL DEFAULT 0,
+ repetitions INTEGER DEFAULT 0,
+ PRIMARY KEY (user_id, word)
+ );
+
+ CREATE TABLE IF NOT EXISTS daily_stats (
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ date TEXT NOT NULL,
+ words_practiced INTEGER DEFAULT 0,
+ words_correct INTEGER DEFAULT 0,
+ total_time_ms INTEGER DEFAULT 0,
+ sessions INTEGER DEFAULT 0,
+ PRIMARY KEY (user_id, date)
+ );
+
+ CREATE TABLE IF NOT EXISTS user_dictionaries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ filename TEXT NOT NULL,
+ display_name TEXT,
+ dict_order INTEGER NOT NULL DEFAULT 0,
+ enabled BOOLEAN DEFAULT 1,
+ entry_count INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS user_settings (
+ user_id INTEGER PRIMARY KEY REFERENCES users(id),
+ words_per_session INTEGER DEFAULT 50
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
+ CREATE INDEX IF NOT EXISTS idx_attempts_session ON attempts(session_id);
+ CREATE INDEX IF NOT EXISTS idx_attempts_word ON attempts(word);
+ CREATE INDEX IF NOT EXISTS idx_word_stats_review ON word_stats(user_id, next_review);
+ CREATE INDEX IF NOT EXISTS idx_daily_stats_user ON daily_stats(user_id, date);
+ CREATE INDEX IF NOT EXISTS idx_user_dicts ON user_dictionaries(user_id, dict_order);
+ """)
+
+ def _migrate_to_v2(self, conn):
+ print("Migrating database to multi-user schema...")
+
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT UNIQUE NOT NULL,
+ name TEXT,
+ avatar_url TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("INSERT OR IGNORE INTO users (id, email, name) VALUES (1, 'local@stenodojo', 'Local User')")
+
+ session_cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)").fetchall()}
+ if "user_id" not in session_cols:
+ conn.execute("ALTER TABLE sessions ADD COLUMN user_id INTEGER DEFAULT 1")
+
+ ws_cols = {r[1] for r in conn.execute("PRAGMA table_info(word_stats)").fetchall()}
+ if "user_id" not in ws_cols:
+ conn.execute("""
+ CREATE TABLE word_stats_v2 (
+ user_id INTEGER NOT NULL DEFAULT 1 REFERENCES users(id),
+ word TEXT NOT NULL,
+ strokes TEXT,
+ attempts INTEGER DEFAULT 0,
+ correct INTEGER DEFAULT 0,
+ avg_time_ms REAL DEFAULT 0,
+ last_seen TIMESTAMP,
+ next_review TIMESTAMP,
+ ease_factor REAL DEFAULT 2.5,
+ interval_days REAL DEFAULT 0,
+ repetitions INTEGER DEFAULT 0,
+ PRIMARY KEY (user_id, word)
+ )
+ """)
+ conn.execute("""
+ INSERT INTO word_stats_v2 (user_id, word, strokes, attempts, correct, avg_time_ms,
+ last_seen, next_review, ease_factor, interval_days, repetitions)
+ SELECT 1, word, strokes, attempts, correct, avg_time_ms,
+ last_seen, next_review, ease_factor, interval_days, repetitions
+ FROM word_stats
+ """)
+ conn.execute("DROP TABLE word_stats")
+ conn.execute("ALTER TABLE word_stats_v2 RENAME TO word_stats")
+
+ ds_cols = {r[1] for r in conn.execute("PRAGMA table_info(daily_stats)").fetchall()}
+ if "user_id" not in ds_cols:
+ conn.execute("""
+ CREATE TABLE daily_stats_v2 (
+ user_id INTEGER NOT NULL DEFAULT 1 REFERENCES users(id),
+ date TEXT NOT NULL,
+ words_practiced INTEGER DEFAULT 0,
+ words_correct INTEGER DEFAULT 0,
+ total_time_ms INTEGER DEFAULT 0,
+ sessions INTEGER DEFAULT 0,
+ PRIMARY KEY (user_id, date)
+ )
+ """)
+ conn.execute("""
+ INSERT INTO daily_stats_v2 (user_id, date, words_practiced, words_correct, total_time_ms, sessions)
+ SELECT 1, date, words_practiced, words_correct, total_time_ms, sessions
+ FROM daily_stats
+ """)
+ conn.execute("DROP TABLE daily_stats")
+ conn.execute("ALTER TABLE daily_stats_v2 RENAME TO daily_stats")
+
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS user_dictionaries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ filename TEXT NOT NULL,
+ display_name TEXT,
+ dict_order INTEGER NOT NULL DEFAULT 0,
+ enabled BOOLEAN DEFAULT 1,
+ entry_count INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS user_settings (
+ user_id INTEGER PRIMARY KEY REFERENCES users(id),
+ words_per_session INTEGER DEFAULT 50
+ );
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
+ CREATE INDEX IF NOT EXISTS idx_word_stats_review ON word_stats(user_id, next_review);
+ CREATE INDEX IF NOT EXISTS idx_daily_stats_user ON daily_stats(user_id, date);
+ CREATE INDEX IF NOT EXISTS idx_user_dicts ON user_dictionaries(user_id, dict_order);
+ """)
+
+ print("Migration complete.")
+
+ # ── User methods ──
+
+ def get_or_create_user(self, email, name=None, avatar_url=None):
+ with self._get_conn() as conn:
+ row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
+ if row:
+ if name or avatar_url:
+ conn.execute(
+ "UPDATE users SET name = COALESCE(?, name), avatar_url = COALESCE(?, avatar_url) WHERE id = ?",
+ (name, avatar_url, row["id"]),
+ )
+ return dict(conn.execute("SELECT * FROM users WHERE id = ?", (row["id"],)).fetchone())
+ cursor = conn.execute(
+ "INSERT INTO users (email, name, avatar_url) VALUES (?, ?, ?)",
+ (email, name, avatar_url),
+ )
+ return {"id": cursor.lastrowid, "email": email, "name": name, "avatar_url": avatar_url}
+
+ def get_user(self, user_id):
+ with self._get_conn() as conn:
+ row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
+ return dict(row) if row else None
+
+ def ensure_dev_user(self):
+ return self.get_or_create_user("dev@stenodojo.local", name="Dev User")
+
+ # ── User Settings ──
+
+ def get_user_settings(self, user_id):
+ with self._get_conn() as conn:
+ row = conn.execute("SELECT * FROM user_settings WHERE user_id = ?", (user_id,)).fetchone()
+ if row:
+ return dict(row)
+ return {"user_id": user_id, "words_per_session": 50}
+
+ def update_user_settings(self, user_id, **kwargs):
+ allowed = {"words_per_session"}
+ kwargs = {k: v for k, v in kwargs.items() if k in allowed}
+ if not kwargs:
+ return
+ with self._get_conn() as conn:
+ existing = conn.execute("SELECT 1 FROM user_settings WHERE user_id = ?", (user_id,)).fetchone()
+ if existing:
+ sets = ", ".join(f"{k} = ?" for k in kwargs)
+ conn.execute(f"UPDATE user_settings SET {sets} WHERE user_id = ?", (*kwargs.values(), user_id))
+ else:
+ kwargs["user_id"] = user_id
+ cols = ", ".join(kwargs.keys())
+ placeholders = ", ".join("?" * len(kwargs))
+ conn.execute(f"INSERT INTO user_settings ({cols}) VALUES ({placeholders})", tuple(kwargs.values()))
+
+ # ── Dictionary management ──
+
+ def get_user_dictionaries(self, user_id):
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM user_dictionaries WHERE user_id = ? ORDER BY dict_order",
+ (user_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def add_user_dictionary(self, user_id, filename, display_name, dict_order, entry_count=0):
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ """INSERT INTO user_dictionaries (user_id, filename, display_name, dict_order, entry_count)
+ VALUES (?, ?, ?, ?, ?)""",
+ (user_id, filename, display_name, dict_order, entry_count),
+ )
+ return cursor.lastrowid
+
+ def clear_user_dictionaries(self, user_id):
+ with self._get_conn() as conn:
+ conn.execute("DELETE FROM user_dictionaries WHERE user_id = ?", (user_id,))
+
+ def toggle_dictionary(self, dict_id, user_id, enabled):
+ with self._get_conn() as conn:
+ conn.execute(
+ "UPDATE user_dictionaries SET enabled = ? WHERE id = ? AND user_id = ?",
+ (enabled, dict_id, user_id),
+ )
+
+ def delete_dictionary(self, dict_id, user_id):
+ with self._get_conn() as conn:
+ row = conn.execute(
+ "SELECT filename FROM user_dictionaries WHERE id = ? AND user_id = ?",
+ (dict_id, user_id),
+ ).fetchone()
+ if row:
+ conn.execute("DELETE FROM user_dictionaries WHERE id = ? AND user_id = ?", (dict_id, user_id))
+ return row["filename"]
+ return None
+
+ def update_dictionary_entry_count(self, dict_id, user_id, count):
+ with self._get_conn() as conn:
+ conn.execute(
+ "UPDATE user_dictionaries SET entry_count = ? WHERE id = ? AND user_id = ?",
+ (count, dict_id, user_id),
+ )
+
+ # ── Session methods ──
+
+ def create_session(self, user_id, mode):
+ with self._get_conn() as conn:
+ cursor = conn.execute(
+ "INSERT INTO sessions (user_id, mode) VALUES (?, ?)",
+ (user_id, mode),
+ )
+ return cursor.lastrowid
+
+ def end_session(self, session_id):
+ with self._get_conn() as conn:
+ conn.execute(
+ "UPDATE sessions SET ended_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (session_id,),
+ )
+
+ def record_attempt(self, user_id, session_id, word, strokes, typed_text, correct, time_ms):
+ with self._get_conn() as conn:
+ conn.execute(
+ """INSERT INTO attempts (session_id, word, strokes, typed_text, correct, time_ms)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (session_id, word, strokes, typed_text, correct, time_ms),
+ )
+
+ conn.execute(
+ """UPDATE sessions SET
+ words_attempted = words_attempted + 1,
+ words_correct = words_correct + ?,
+ total_time_ms = total_time_ms + ?
+ WHERE id = ?""",
+ (1 if correct else 0, time_ms, session_id),
+ )
+
+ existing = conn.execute(
+ "SELECT * FROM word_stats WHERE user_id = ? AND word = ?", (user_id, word)
+ ).fetchone()
+
+ now = datetime.utcnow().isoformat()
+
+ if existing:
+ old_avg = existing["avg_time_ms"]
+ old_count = existing["attempts"]
+ new_avg = ((old_avg * old_count) + time_ms) / (old_count + 1)
+
+ new_ef, new_interval, new_reps = calculate_review(
+ correct, time_ms,
+ existing["ease_factor"],
+ existing["interval_days"],
+ existing["repetitions"],
+ )
+ next_review = (datetime.utcnow() + timedelta(days=new_interval)).isoformat()
+
+ conn.execute(
+ """UPDATE word_stats SET
+ strokes = ?, attempts = attempts + 1, correct = correct + ?,
+ avg_time_ms = ?, last_seen = ?, next_review = ?,
+ ease_factor = ?, interval_days = ?, repetitions = ?
+ WHERE user_id = ? AND word = ?""",
+ (strokes, 1 if correct else 0, new_avg, now,
+ next_review, new_ef, new_interval, new_reps, user_id, word),
+ )
+ else:
+ new_ef, new_interval, new_reps = calculate_review(correct, time_ms, 2.5, 0, 0)
+ next_review = (datetime.utcnow() + timedelta(days=new_interval)).isoformat()
+
+ conn.execute(
+ """INSERT INTO word_stats
+ (user_id, word, strokes, attempts, correct, avg_time_ms,
+ last_seen, next_review, ease_factor, interval_days, repetitions)
+ VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)""",
+ (user_id, word, strokes, 1 if correct else 0, float(time_ms),
+ now, next_review, new_ef, new_interval, new_reps),
+ )
+
+ today = datetime.utcnow().strftime("%Y-%m-%d")
+ existing_daily = conn.execute(
+ "SELECT * FROM daily_stats WHERE user_id = ? AND date = ?", (user_id, today)
+ ).fetchone()
+
+ if existing_daily:
+ conn.execute(
+ """UPDATE daily_stats SET
+ words_practiced = words_practiced + 1,
+ words_correct = words_correct + ?,
+ total_time_ms = total_time_ms + ?
+ WHERE user_id = ? AND date = ?""",
+ (1 if correct else 0, time_ms, user_id, today),
+ )
+ else:
+ conn.execute(
+ """INSERT INTO daily_stats (user_id, date, words_practiced, words_correct, total_time_ms, sessions)
+ VALUES (?, ?, 1, ?, ?, 0)""",
+ (user_id, today, 1 if correct else 0, time_ms),
+ )
+
+ def get_session(self, session_id):
+ with self._get_conn() as conn:
+ row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
+ return dict(row) if row else None
+
+ def get_session_attempts(self, session_id):
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM attempts WHERE session_id = ? ORDER BY created_at",
+ (session_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_session_stats(self, session_id):
+ with self._get_conn() as conn:
+ session = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
+ if not session:
+ return None
+
+ attempts = conn.execute(
+ "SELECT correct, time_ms FROM attempts WHERE session_id = ? ORDER BY created_at",
+ (session_id,),
+ ).fetchall()
+
+ words_attempted = len(attempts)
+ words_correct = sum(1 for a in attempts if a["correct"])
+ total_time = sum(a["time_ms"] for a in attempts)
+ accuracy = words_correct / words_attempted if words_attempted > 0 else 0
+
+ best_streak = 0
+ current_streak = 0
+ for a in attempts:
+ if a["correct"]:
+ current_streak += 1
+ best_streak = max(best_streak, current_streak)
+ else:
+ current_streak = 0
+
+ return {
+ "words_attempted": words_attempted,
+ "words_correct": words_correct,
+ "accuracy": accuracy,
+ "avg_time_ms": total_time / words_attempted if words_attempted > 0 else 0,
+ "total_time_ms": total_time,
+ "best_streak": best_streak,
+ }
+
+ def get_word_stats(self, user_id, word=None):
+ with self._get_conn() as conn:
+ if word:
+ row = conn.execute(
+ "SELECT * FROM word_stats WHERE user_id = ? AND word = ?", (user_id, word)
+ ).fetchone()
+ return dict(row) if row else None
+ rows = conn.execute(
+ "SELECT * FROM word_stats WHERE user_id = ? ORDER BY last_seen DESC", (user_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_words_due_for_review(self, user_id, count=25):
+ now = datetime.utcnow().isoformat()
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ """SELECT word FROM word_stats
+ WHERE user_id = ? AND next_review <= ?
+ ORDER BY next_review ASC LIMIT ?""",
+ (user_id, now, count),
+ ).fetchall()
+ return [r["word"] for r in rows]
+
+ def get_weakest_words(self, user_id, count=25):
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ """SELECT word, attempts, correct,
+ CAST(correct AS REAL) / attempts AS accuracy
+ FROM word_stats
+ WHERE user_id = ? AND attempts >= 3
+ ORDER BY accuracy ASC, attempts DESC LIMIT ?""",
+ (user_id, count),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_practiced_words(self, user_id):
+ with self._get_conn() as conn:
+ rows = conn.execute("SELECT word FROM word_stats WHERE user_id = ?", (user_id,)).fetchall()
+ return {r["word"] for r in rows}
+
+ def get_daily_stats(self, user_id, days=30):
+ start_date = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM daily_stats WHERE user_id = ? AND date >= ? ORDER BY date DESC",
+ (user_id, start_date),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_heatmap_data(self, user_id):
+ start_date = (datetime.utcnow() - timedelta(days=365)).strftime("%Y-%m-%d")
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ """SELECT date, words_practiced AS count
+ FROM daily_stats WHERE user_id = ? AND date >= ? ORDER BY date""",
+ (user_id, start_date),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_overview_stats(self, user_id):
+ with self._get_conn() as conn:
+ totals = conn.execute(
+ """SELECT
+ COALESCE(SUM(words_practiced), 0) AS total_words,
+ COALESCE(SUM(words_correct), 0) AS total_correct,
+ COALESCE(SUM(total_time_ms), 0) AS total_time_ms,
+ COALESCE(SUM(sessions), 0) AS total_sessions,
+ COUNT(*) AS days_practiced
+ FROM daily_stats WHERE user_id = ?""",
+ (user_id,),
+ ).fetchone()
+
+ streak = self._calculate_streak(conn, user_id)
+ mastered = self.get_words_mastered_count(user_id)
+
+ unique_words = conn.execute(
+ "SELECT COUNT(*) AS count FROM word_stats WHERE user_id = ?", (user_id,)
+ ).fetchone()["count"]
+
+ total_words = totals["total_words"]
+ total_correct = totals["total_correct"]
+
+ return {
+ "total_words_practiced": total_words,
+ "total_correct": total_correct,
+ "overall_accuracy": total_correct / total_words if total_words > 0 else 0,
+ "total_time_ms": totals["total_time_ms"],
+ "total_sessions": totals["total_sessions"],
+ "days_practiced": totals["days_practiced"],
+ "current_streak": streak,
+ "words_mastered": mastered,
+ "unique_words": unique_words,
+ }
+
+ def _calculate_streak(self, conn, user_id):
+ today = datetime.utcnow().date()
+ streak = 0
+ current_date = today
+ while True:
+ date_str = current_date.strftime("%Y-%m-%d")
+ row = conn.execute(
+ "SELECT 1 FROM daily_stats WHERE user_id = ? AND date = ? AND words_practiced > 0",
+ (user_id, date_str),
+ ).fetchone()
+ if row:
+ streak += 1
+ current_date -= timedelta(days=1)
+ else:
+ break
+ return streak
+
+ def get_recent_sessions(self, user_id, limit=5):
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM sessions WHERE user_id = ? ORDER BY started_at DESC LIMIT ?",
+ (user_id, limit),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_all_sessions(self, user_id, limit=50):
+ with self._get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM sessions WHERE user_id = ? ORDER BY started_at DESC LIMIT ?",
+ (user_id, limit),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_words_mastered_count(self, user_id):
+ with self._get_conn() as conn:
+ row = conn.execute(
+ """SELECT COUNT(*) AS count FROM word_stats
+ WHERE user_id = ? AND attempts >= 5
+ AND CAST(correct AS REAL) / attempts >= 0.9""",
+ (user_id,),
+ ).fetchone()
+ return row["count"]
+
+ def increment_daily_sessions(self, user_id):
+ today = datetime.utcnow().strftime("%Y-%m-%d")
+ with self._get_conn() as conn:
+ existing = conn.execute(
+ "SELECT * FROM daily_stats WHERE user_id = ? AND date = ?", (user_id, today)
+ ).fetchone()
+ if existing:
+ conn.execute(
+ "UPDATE daily_stats SET sessions = sessions + 1 WHERE user_id = ? AND date = ?",
+ (user_id, today),
+ )
+ else:
+ conn.execute(
+ """INSERT INTO daily_stats (user_id, date, words_practiced, words_correct, total_time_ms, sessions)
+ VALUES (?, ?, 0, 0, 0, 1)""",
+ (user_id, today),
+ )
+
+ def get_lesson_progress(self, user_id, word_list):
+ """Compute mastery progress for a set of words.
+
+ Returns dict with:
+ total_words, practiced, mastered, avg_wpm, avg_accuracy,
+ per-word breakdown of attempts/accuracy/avg_time.
+
+ Mastery target: 95% accuracy and avg_time < 1500ms (~200 WPM for
+ a 5-char word) with at least 5 attempts.
+ """
+ if not word_list:
+ return {"total_words": 0, "practiced": 0, "mastered": 0,
+ "avg_wpm": 0, "avg_accuracy": 0, "words": []}
+
+ lower_words = [w.lower() for w in word_list]
+ with self._get_conn() as conn:
+ placeholders = ",".join("?" * len(lower_words))
+ rows = conn.execute(
+ f"""SELECT word, attempts, correct, avg_time_ms
+ FROM word_stats
+ WHERE user_id = ? AND LOWER(word) IN ({placeholders})""",
+ (user_id, *lower_words),
+ ).fetchall()
+
+ stats_map = {r["word"].lower(): dict(r) for r in rows}
+
+ practiced = 0
+ mastered = 0
+ total_time = 0
+ total_correct = 0
+ total_attempts = 0
+ words = []
+
+ for w in lower_words:
+ s = stats_map.get(w)
+ if s and s["attempts"] > 0:
+ practiced += 1
+ acc = s["correct"] / s["attempts"]
+ total_correct += s["correct"]
+ total_attempts += s["attempts"]
+ total_time += s["avg_time_ms"] * s["attempts"]
+ if s["attempts"] >= 5 and acc >= 0.95 and s["avg_time_ms"] < 1500:
+ mastered += 1
+ words.append({
+ "word": w,
+ "attempts": s["attempts"],
+ "accuracy": round(acc * 100, 1),
+ "avg_time_ms": round(s["avg_time_ms"]),
+ })
+ else:
+ words.append({
+ "word": w,
+ "attempts": 0,
+ "accuracy": 0,
+ "avg_time_ms": 0,
+ })
+
+ avg_accuracy = (total_correct / total_attempts * 100) if total_attempts > 0 else 0
+ avg_time = (total_time / total_attempts) if total_attempts > 0 else 0
+ avg_wpm = (5 / (avg_time / 60000)) if avg_time > 0 else 0
+
+ return {
+ "total_words": len(lower_words),
+ "practiced": practiced,
+ "mastered": mastered,
+ "avg_wpm": round(avg_wpm, 1),
+ "avg_accuracy": round(avg_accuracy, 1),
+ "words": words,
+ }
+
+ def clear_user_data(self, user_id):
+ with self._get_conn() as conn:
+ session_ids = [r["id"] for r in conn.execute(
+ "SELECT id FROM sessions WHERE user_id = ?", (user_id,)
+ ).fetchall()]
+ if session_ids:
+ placeholders = ",".join("?" * len(session_ids))
+ conn.execute(f"DELETE FROM attempts WHERE session_id IN ({placeholders})", session_ids)
+ conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
+ conn.execute("DELETE FROM word_stats WHERE user_id = ?", (user_id,))
+ conn.execute("DELETE FROM daily_stats WHERE user_id = ?", (user_id,))
diff --git a/dictionary.py b/dictionary.py
@@ -0,0 +1,353 @@
+"""Plover dictionary loader and word selection for stenography practice."""
+
+import configparser
+import json
+import os
+import random
+import re
+from pathlib import Path
+
+
+# Top 500 most common English words for frequency ranking
+COMMON_WORDS = [
+ "the", "be", "to", "of", "and", "a", "in", "that", "have", "i",
+ "it", "for", "not", "on", "with", "he", "as", "you", "do", "at",
+ "this", "but", "his", "by", "from", "they", "we", "her", "she", "or",
+ "an", "will", "my", "one", "all", "would", "there", "their", "what", "so",
+ "up", "out", "if", "about", "who", "get", "which", "go", "me", "when",
+ "make", "can", "like", "time", "no", "just", "him", "know", "take", "people",
+ "into", "year", "your", "good", "some", "could", "them", "see", "other", "than",
+ "then", "now", "look", "only", "come", "its", "over", "think", "also", "back",
+ "after", "use", "two", "how", "our", "work", "first", "well", "way", "even",
+ "new", "want", "because", "any", "these", "give", "day", "most", "us", "great",
+ "find", "here", "thing", "many", "still", "between", "long", "own", "say", "help",
+ "through", "much", "before", "line", "right", "too", "mean", "old", "same", "tell",
+ "boy", "did", "three", "call", "hand", "high", "keep", "last", "let", "begin",
+ "seem", "country", "place", "never", "run", "read", "why", "while", "world", "little",
+ "house", "city", "add", "story", "set", "change", "move", "play", "learn", "should",
+ "home", "big", "end", "put", "ask", "under", "turn", "show", "try", "life",
+ "off", "every", "near", "head", "food", "far", "thought", "few", "left", "school",
+ "open", "next", "hard", "along", "both", "might", "must", "start", "point", "kind",
+ "need", "children", "number", "each", "side", "part", "later", "word", "always", "those",
+ "feel", "talk", "book", "early", "went", "got", "been", "name", "live", "page",
+ "group", "carry", "took", "young", "often", "important", "until", "girl", "close", "night",
+ "real", "almost", "light", "above", "man", "woman", "small", "water", "write", "bring",
+ "once", "face", "family", "white", "air", "room", "mother", "area", "money", "state",
+ "stop", "leave", "power", "door", "stand", "form", "car", "another", "problem", "child",
+ "human", "service", "eye", "case", "public", "question", "look", "body", "become", "sure",
+ "already", "several", "minute", "second", "war", "better", "during", "number", "course", "against",
+ "different", "system", "father", "away", "moment", "company", "may", "level", "enough", "develop",
+ "business", "study", "game", "sit", "yes", "nothing", "age", "government", "half", "large",
+ "top", "social", "possible", "program", "short", "sense", "best", "full", "result", "idea",
+ "among", "yet", "week", "hear", "love", "plan", "death", "such", "less", "done",
+ "together", "hand", "high", "late", "hold", "free", "produce", "land", "heart", "reason",
+ "cut", "since", "today", "force", "report", "believe", "person", "town", "build", "control",
+ "student", "past", "clear", "true", "health", "cost", "able", "include", "accept", "allow",
+ "drive", "process", "front", "law", "grow", "type", "value", "meet", "four", "ever",
+ "main", "support", "view", "street", "fact", "lot", "pay", "art", "care", "simple",
+ "deal", "voice", "act", "issue", "miss", "present", "red", "low", "month", "class",
+ "offer", "rule", "step", "pass", "appear", "record", "table", "morning", "model", "interest",
+ "bit", "fine", "oil", "stay", "bed", "order", "reach", "rest", "whole", "continue",
+ "return", "effect", "happy", "dark", "increase", "quite", "single", "strong", "require", "rather",
+ "create", "expect", "market", "term", "paper", "local", "effort", "test", "wrong", "common",
+ "watch", "draw", "provide", "describe", "remember", "consider", "nation", "member", "break", "across",
+ "note", "fish", "picture", "animal", "source", "rock", "field", "perhaps", "window", "nature",
+ "horse", "material", "happen", "lost", "ball", "join", "five", "upon", "fall", "cover",
+ "explain", "hot", "rate", "road", "plant", "black", "example", "check", "fire", "cold",
+ "language", "sit", "star", "color", "decide", "space", "blue", "deep", "rock", "tree",
+ "ground", "song", "final", "range", "building", "action", "sound", "river", "complete", "cause",
+ "surface", "ocean", "object", "sleep", "feeling", "thus", "music", "million", "wind", "pain",
+ "figure", "middle", "position", "outside", "wide", "alone", "glass", "store", "piece", "notice",
+ "sign", "west", "science", "center", "test", "press", "travel", "green", "sort", "total",
+ "garden", "chance", "south", "safe", "edge", "forget", "dinner", "gold", "chair",
+]
+
+
+# Steno order for parsing strokes
+STENO_ORDER = "#STKPWHRAO*EUFRPBLGTSDZ"
+
+
+class PloverDictionary:
+ """Loads and manages Plover steno dictionaries."""
+
+ def __init__(self, config_path=None):
+ if config_path is None:
+ config_path = os.path.expanduser("~/.config/plover/plover.cfg")
+
+ self.config_path = config_path
+ self.config_dir = os.path.dirname(config_path)
+ self.stroke_to_word = {}
+ self.word_to_strokes = {}
+ self._practice_words = []
+ self._single_stroke_words = []
+ self._common_word_set = set()
+
+ self._load_dictionaries()
+ self._build_word_index()
+
+ def _load_dictionaries(self):
+ """Load all enabled JSON dictionaries from plover.cfg."""
+ config = configparser.ConfigParser()
+ config.read(self.config_path)
+
+ section = "System: English Stenotype"
+ if not config.has_section(section):
+ print(f"Warning: Section [{section}] not found in config")
+ return
+
+ dicts_json = config.get(section, "dictionaries", fallback="[]")
+ dict_entries = json.loads(dicts_json)
+
+ # Dictionaries listed first have higher priority
+ # Process in order — first dict wins for stroke_to_word
+ loaded_count = 0
+ for entry in dict_entries:
+ if not entry.get("enabled", False):
+ continue
+
+ path = entry["path"]
+
+ # Skip non-JSON dictionaries (Python plugins, etc.)
+ if not path.endswith(".json"):
+ continue
+
+ full_path = os.path.join(self.config_dir, path)
+ if not os.path.exists(full_path):
+ print(f"Warning: Dictionary not found: {full_path}")
+ continue
+
+ try:
+ with open(full_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ dict_name = os.path.basename(path)
+ for stroke, translation in data.items():
+ # First dict wins for stroke_to_word
+ if stroke not in self.stroke_to_word:
+ self.stroke_to_word[stroke] = translation
+
+ loaded_count += 1
+ print(f" Loaded: {path} ({len(data)} entries)")
+
+ except (json.JSONDecodeError, OSError) as e:
+ print(f"Warning: Failed to load {path}: {e}")
+
+ print(f" Total dictionaries loaded: {loaded_count}")
+ print(f" Total stroke entries: {len(self.stroke_to_word)}")
+
+ def _build_word_index(self):
+ """Build word_to_strokes mapping and practice word lists."""
+ # Build reverse mapping: word -> list of strokes
+ raw_word_to_strokes = {}
+
+ for stroke, translation in self.stroke_to_word.items():
+ if not self.is_practice_word(translation):
+ continue
+
+ word_key = translation.lower().strip()
+ if word_key not in raw_word_to_strokes:
+ raw_word_to_strokes[word_key] = []
+ raw_word_to_strokes[word_key].append(stroke)
+
+ # Sort strokes by preference: fewer strokes first, then shorter string
+ for word, strokes in raw_word_to_strokes.items():
+ strokes.sort(key=lambda s: (s.count("/") + 1, len(s)))
+ self.word_to_strokes[word] = strokes
+
+ # Build practice word list
+ self._practice_words = list(self.word_to_strokes.keys())
+
+ # Build single-stroke words list
+ self._single_stroke_words = [
+ word for word, strokes in self.word_to_strokes.items()
+ if any("/" not in s for s in strokes)
+ ]
+
+ # Build common words set (words in our frequency list that exist in dictionary)
+ common_lower = [w.lower() for w in COMMON_WORDS]
+ self._common_word_set = set(
+ w for w in common_lower if w in self.word_to_strokes
+ )
+
+ print(f" Practice words: {len(self._practice_words)}")
+ print(f" Single-stroke words: {len(self._single_stroke_words)}")
+ print(f" Common words (in dict): {len(self._common_word_set)}")
+
+ @staticmethod
+ def is_practice_word(translation):
+ """
+ Returns True only for plain English words/phrases suitable for practice.
+
+ Filters out:
+ - Plover commands/formatting (contains { or })
+ - Non-alphabetic content (except spaces, apostrophes, hyphens)
+ - Empty strings
+ - All-uppercase abbreviations (except single chars like "I", "a")
+ """
+ if not translation or not translation.strip():
+ return False
+
+ # Filter out Plover commands and formatting
+ if "{" in translation or "}" in translation:
+ return False
+
+ # Must contain only letters, spaces, apostrophes, hyphens
+ if not re.match(r"^[a-zA-Z][a-zA-Z '\-]*$", translation):
+ return False
+
+ # Filter out all-uppercase abbreviations (but allow single characters)
+ stripped = translation.strip()
+ if len(stripped) > 1 and stripped.replace(" ", "").replace("-", "").replace("'", "").isupper():
+ return False
+
+ return True
+
+ @staticmethod
+ def parse_stroke(stroke):
+ """
+ Parse a single stroke into individual key identifiers.
+
+ Returns list of key identifiers like ["S-", "T-", "A", "-R"].
+ For multi-stroke sequences (containing /), parses each stroke separately
+ and returns a list of lists.
+ """
+ if "/" in stroke:
+ return [PloverDictionary._parse_single_stroke(s) for s in stroke.split("/")]
+ return [PloverDictionary._parse_single_stroke(stroke)]
+
+ @staticmethod
+ def _parse_single_stroke(stroke):
+ """Parse a single stroke (no /) into key identifiers."""
+ keys = []
+
+ # Handle number bar
+ if stroke.startswith("#"):
+ keys.append("#")
+ stroke = stroke[1:]
+
+ if not stroke:
+ return keys
+
+ # Left-side keys (before vowels in steno order)
+ left_keys = "STKPWHR"
+ # Vowels/center
+ center_keys = "AO*EU"
+ # Right-side keys
+ right_keys = "FRPBLGTSDZ"
+
+ if "-" in stroke:
+ # Explicit separator: left of - is left+center, right of - is right side
+ left_part, right_part = stroke.split("-", 1)
+ # Parse left part
+ for ch in left_part:
+ if ch in left_keys:
+ keys.append(f"{ch}-")
+ elif ch in center_keys:
+ keys.append(ch)
+ # Parse right part
+ for ch in right_part:
+ if ch in right_keys:
+ keys.append(f"-{ch}")
+ elif ch in center_keys:
+ keys.append(ch)
+ else:
+ # No explicit separator — use steno order to disambiguate
+ pos = 0
+ for ch in stroke:
+ # Find this character at or after current position in steno order
+ found = False
+ for i in range(pos, len(STENO_ORDER)):
+ if STENO_ORDER[i] == ch:
+ pos = i + 1
+ # Determine which section this position falls in
+ # Steno order: #STKPWHRAO*EUFRPBLGTSDZ
+ # Indices: 0123456789...
+ # # = index 0
+ # S,T,K,P,W,H,R = indices 1-7 (left)
+ # A,O = indices 8-9 (center)
+ # * = index 10 (center)
+ # E,U = indices 11-12 (center)
+ # F,R,P,B,L,G,T,S,D,Z = indices 13-22 (right)
+ if i == 0:
+ keys.append("#")
+ elif i <= 7:
+ keys.append(f"{ch}-")
+ elif i <= 12:
+ keys.append(ch)
+ else:
+ keys.append(f"-{ch}")
+ found = True
+ break
+
+ if not found:
+ # Character not found in remaining steno order — skip
+ pass
+
+ return keys
+
+ def get_common_words(self, count=25):
+ """Return common words that exist in the dictionary, in frequency order."""
+ # Use the frequency list order, filtering to words in our dictionary
+ result = []
+ seen = set()
+ for word in COMMON_WORDS:
+ w = word.lower()
+ if w in self.word_to_strokes and w not in seen:
+ result.append(w)
+ seen.add(w)
+ if len(result) >= count:
+ break
+ return result
+
+ def get_single_stroke_words(self, count=25):
+ """Return random words achievable in a single stroke."""
+ if len(self._single_stroke_words) <= count:
+ return list(self._single_stroke_words)
+ return random.sample(self._single_stroke_words, count)
+
+ def get_weak_words(self, word_stats, count=25):
+ """
+ Return words with low accuracy from word_stats.
+
+ word_stats: list of dicts with 'word', 'attempts', 'correct' keys
+ """
+ # Filter to words with at least 3 attempts, sort by accuracy ascending
+ weak = []
+ for ws in word_stats:
+ if ws["attempts"] >= 3:
+ accuracy = ws["correct"] / ws["attempts"] if ws["attempts"] > 0 else 0
+ weak.append((accuracy, ws["word"]))
+
+ weak.sort(key=lambda x: x[0])
+ words = [w for _, w in weak[:count]]
+
+ # If not enough weak words, pad with random words
+ if len(words) < count:
+ remaining = count - len(words)
+ available = [w for w in self._practice_words if w not in set(words)]
+ if available:
+ words.extend(random.sample(available, min(remaining, len(available))))
+
+ return words
+
+ def get_new_words(self, practiced_words, count=25):
+ """Return words never practiced before."""
+ new_words = [w for w in self._practice_words if w not in practiced_words]
+ if len(new_words) <= count:
+ return new_words
+ return random.sample(new_words, count)
+
+ def get_random_words(self, count=25):
+ """Return a random sample from all practice words."""
+ if len(self._practice_words) <= count:
+ return list(self._practice_words)
+ return random.sample(self._practice_words, count)
+
+ def get_strokes_for_word(self, word):
+ """Get all valid strokes for a word."""
+ return self.word_to_strokes.get(word.lower().strip(), [])
+
+ def get_best_stroke(self, word):
+ """Get the best (shortest) stroke for a word."""
+ strokes = self.get_strokes_for_word(word)
+ return strokes[0] if strokes else None
diff --git a/lessons.py b/lessons.py
@@ -0,0 +1,460 @@
+"""Lesson structure and word frequency data for guided practice."""
+
+import random
+
+from sentence_generator import generate_sentences
+from wordfreq import top_n_list
+
+# Proper nouns / demonyms that wordfreq includes due to raw frequency.
+# These don't belong in a vocabulary practice list — steno dicts map them
+# to capitalized output, so practicing them lowercase is impossible.
+_PROPER_NOUNS = {
+ # People names
+ "john", "paul", "peter", "james", "david", "michael", "george", "mary",
+ "thomas", "william", "charles", "henry", "edward", "robert", "richard",
+ "joseph", "jack", "sam", "tom", "joe", "harry", "jane", "elizabeth",
+ "kate", "anna", "sarah", "emma", "alice", "adam", "daniel", "steve",
+ "alex", "ben", "max", "nick", "matt", "chris", "mike", "bob", "jim",
+ "dan", "bill", "jeff", "ryan", "kevin", "brian", "scott", "eric",
+ "patrick", "martin", "frank", "sean", "carl", "ray", "fred", "lee",
+ "alan", "peter", "roger", "bruce", "ralph", "roy", "donald", "larry",
+ "jerry", "terry", "wayne", "philip", "howard", "johnny", "tommy",
+ "billy", "jimmy", "bobby", "charlie", "eddie", "willie", "annie",
+ "helen", "margaret", "nancy", "ruth", "betty", "dorothy", "karen",
+ "susan", "jessica", "lisa", "jennifer", "amy", "emily", "rachel",
+ "laura", "marie", "victoria", "catherine", "diana", "sophie",
+ # Countries / regions
+ "america", "europe", "africa", "asia", "australia", "canada", "england",
+ "britain", "france", "germany", "italy", "spain", "russia", "china",
+ "japan", "india", "mexico", "ireland", "scotland", "wales", "israel",
+ "iraq", "iran", "syria", "egypt", "brazil", "korea", "vietnam",
+ "texas", "california", "florida", "york", "london", "paris",
+ "washington", "hollywood", "broadway", "manhattan", "boston", "chicago",
+ # Demonyms / nationalities — steno maps these capitalized
+ "american", "americans", "european", "african", "asian", "australian",
+ "canadian", "english", "british", "french", "german", "italian",
+ "spanish", "russian", "chinese", "japanese", "indian", "mexican",
+ "irish", "scottish", "korean", "dutch", "polish", "turkish", "swedish",
+ "norwegian", "danish", "finnish", "czech", "hungarian", "portuguese",
+ "brazilian", "thai", "vietnamese", "indonesian", "filipino", "arab",
+ "arabic", "hebrew", "persian", "greek", "roman", "latin",
+ # Religion / holidays
+ "god", "jesus", "christ", "christian", "muslim", "jewish", "catholic",
+ "bible", "christmas", "easter", "halloween",
+ # Days / months — steno maps these capitalized
+ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday",
+ "saturday", "january", "february", "march", "april", "june", "july",
+ "august", "september", "october", "november", "december",
+ # Brands / platforms
+ "facebook", "twitter", "google", "youtube", "instagram", "amazon",
+ "apple", "microsoft", "netflix", "uber", "disney", "walmart",
+ "samsung", "toyota", "nike", "tesla", "bitcoin",
+ # Political
+ "democrat", "republican", "nazi", "obama", "trump",
+ "olympic", "olympics", "grammy", "emmy", "oscar",
+ # Historical
+ "shakespeare", "darwin", "einstein", "newton", "napoleon",
+}
+
+# Top 10,000 English words by real-world frequency (wordfreq: Google, Wikipedia,
+# Reddit, Twitter, OpenSubtitles, etc.). Filtered to alphabetic common nouns/
+# verbs/adjectives — no proper nouns, no single-letter junk.
+_raw = top_n_list("en", 10000)
+TOP_WORDS = [
+ w for w in _raw
+ if ((w.isalpha() and len(w) > 1) or w in ("a", "i"))
+ and w not in _PROPER_NOUNS
+]
+
+
+# Practice sentences organized by difficulty
+SENTENCES = {
+ "beginner": [
+ "The cat sat on a mat.",
+ "I have a big red hat.",
+ "He can run and jump.",
+ "She will go to the store.",
+ "We like to play in the park.",
+ "It is a good day to go out.",
+ "They came home from school.",
+ "I want to read a book.",
+ "The dog ran to the door.",
+ "We need to get some food.",
+ "Can you help me find my bag?",
+ "I think we should go now.",
+ "She put the cup on the table.",
+ "He will come back soon.",
+ "They like to play games.",
+ "I need to do my work.",
+ "The sun is up in the sky.",
+ "We have a lot to do today.",
+ "You can sit here with me.",
+ "She said it was time to go.",
+ ],
+ "intermediate": [
+ "The quick brown fox jumps over the lazy dog.",
+ "I would like to know what you think about this.",
+ "She has been working on the project all morning.",
+ "They don't believe that it will work out in the end.",
+ "We should try to find a better way to solve this problem.",
+ "He doesn't seem to care about what other people think.",
+ "I have been trying to learn how to play the piano.",
+ "You will need to come back tomorrow for the results.",
+ "She would like to travel around the world some day.",
+ "They should have told us about the change in plans.",
+ "The children played in the garden until it got dark.",
+ "I remember when this street was just a small road.",
+ "He could not believe what he saw through the window.",
+ "We must find a way to make this work before the end.",
+ "You should always try your best no matter what happens.",
+ "The old house at the end of the street is for sale.",
+ "I think we need to talk about what happened last night.",
+ "She has never been to the ocean, but she wants to go.",
+ "They were late because the train did not come on time.",
+ "We have enough food for everyone if we share it well.",
+ ],
+ "advanced": [
+ "The government announced several important changes to the education system.",
+ "Scientists believe that the discovery could lead to significant medical advances.",
+ "The temperature dropped suddenly, and the roads became dangerous to drive on.",
+ "Experience has taught me that nothing worthwhile comes without considerable effort.",
+ "The company decided to separate the research division from the main business.",
+ "Understanding different cultures requires patience and an open mind toward others.",
+ "The original design was modified to include additional safety features throughout.",
+ "Modern technology has completely transformed how we communicate with each other.",
+ "The solution to the problem required thinking about it from a different angle.",
+ "Environmental protection has become one of the most important issues of our time.",
+ "The committee reached a decision after several hours of careful consideration.",
+ "Effective communication depends on both speaking clearly and listening carefully.",
+ "The population of the city has grown considerably over the past few decades.",
+ "Financial planning requires discipline and a clear understanding of your goals.",
+ "The relationship between science and society continues to evolve in complex ways.",
+ ],
+ "literature": [
+ # Jane Austen — Pride and Prejudice (1813)
+ "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.",
+ "She was a woman of mean understanding, little information, and uncertain temper.",
+ "Vanity and pride are different things, though the words are often used as one.",
+ "I could easily forgive his pride, if he had not mortified mine.",
+ "There is a stubbornness about me that never can bear to be frightened at the will of others.",
+ "A lady's imagination is very rapid; it jumps from admiration to love, from love to matrimony in a moment.",
+ # Jane Austen — Sense and Sensibility (1811)
+ "The family of Dashwood had long been settled in Sussex.",
+ "It is not time or opportunity that is to determine intimacy; it is disposition alone.",
+ # Charles Dickens — A Tale of Two Cities (1859)
+ "It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness.",
+ "A wonderful fact to reflect upon, that every human creature is constituted to be that profound secret and mystery to every other.",
+ "It is a far, far better thing that I do, than I have ever done; it is a far, far better rest that I go to than I have ever known.",
+ # Charles Dickens — Great Expectations (1861)
+ "In a word, I was too cowardly to do what I knew to be right, as I had been too cowardly to avoid doing what I knew to be wrong.",
+ "Take nothing on its looks; take everything on evidence. There is no better rule.",
+ "We need never be ashamed of our tears, for they are rain upon the blinding dust of earth.",
+ "Suffering has been stronger than all other teaching, and has taught me to understand what your heart used to be.",
+ # Charles Dickens — Oliver Twist (1838)
+ "There are books of which the backs and covers are by far the best parts.",
+ # Charlotte Bronte — Jane Eyre (1847)
+ "I am no bird; and no net ensnares me; I am a free human being with an independent will.",
+ "I would always rather be happy than dignified.",
+ "There was no possibility of taking a walk that day.",
+ "Life appears to me too short to be spent in nursing animosity or registering wrongs.",
+ # Emily Bronte — Wuthering Heights (1847)
+ "He is more myself than I am. Whatever our souls are made of, his and mine are the same.",
+ "I have not broken your heart; you have broken it; and in breaking it, you have broken mine.",
+ "Terror made me cruel; and finding it useless to attempt shaking the creature off, I pulled its wrist on to the broken pane.",
+ # Herman Melville — Moby Dick (1851)
+ "Call me Ishmael. Some years ago, having little or no money in my purse, I thought I would sail about a little and see the watery part of the world.",
+ "It is not down on any map; true places never are.",
+ "Better to sleep with a sober cannibal than a drunken Christian.",
+ "There is a wisdom that is woe; but there is a woe that is madness.",
+ # Leo Tolstoy — Anna Karenina (1877, trans. Constance Garnett)
+ "All happy families are alike; each unhappy family is unhappy in its own way.",
+ "He stepped down, trying not to look long at her, as if she were the sun, yet he saw her, like the sun, even without looking.",
+ "If you look for perfection, you will never be content.",
+ # Leo Tolstoy — War and Peace (1869, trans. Constance Garnett)
+ "The strongest of all warriors are these two: time and patience.",
+ "We can know only that we know nothing. And that is the highest degree of human wisdom.",
+ "Nothing is so necessary for a young man as the company of intelligent women.",
+ # Mark Twain — The Adventures of Tom Sawyer (1876)
+ "The less there is to justify a traditional custom, the harder it is to get rid of it.",
+ "He had discovered a great law of human action, without knowing it, that in order to make a man covet a thing, it is only necessary to make the thing difficult to attain.",
+ # Mark Twain — Adventures of Huckleberry Finn (1884)
+ "All right, then, I will go to it. It was awful thoughts and awful words, but they were said.",
+ "It does not matter what you do; he can find fault with it.",
+ "That is just the way with some people. They get down on a thing when they do not know nothing about it.",
+ # Mary Shelley — Frankenstein (1818)
+ "Nothing is so painful to the human mind as a great and sudden change.",
+ "Even broken in spirit as he is, no one can feel more deeply than he does the beauties of nature.",
+ "Beware; for I am fearless, and therefore powerful.",
+ "Life, although it may only be an accumulation of anguish, is dear to me, and I will defend it.",
+ # Lewis Carroll — Alice's Adventures in Wonderland (1865)
+ "It was all very well to say drink me, but the wise little Alice was not going to do that in a hurry.",
+ "If everybody minded their own business, the world would go around a great deal faster than it does.",
+ "Begin at the beginning and go on till you come to the end; then stop.",
+ "Why, sometimes I have believed as many as six impossible things before breakfast.",
+ # Oscar Wilde — The Picture of Dorian Gray (1890)
+ "The only way to get rid of a temptation is to yield to it.",
+ "Every portrait that is painted with feeling is a portrait of the artist, not of the sitter.",
+ "I am too fond of reading books to care to write them.",
+ "Experience is merely the name men gave to their mistakes.",
+ "To define is to limit.",
+ # Arthur Conan Doyle — Sherlock Holmes stories (1887-1905)
+ "When you have eliminated the impossible, whatever remains, however improbable, must be the truth.",
+ "There is nothing more deceptive than an obvious fact.",
+ "The world is full of obvious things which nobody by any chance ever observes.",
+ "It is a capital mistake to theorize before one has data.",
+ "I am a brain, Watson. The rest of me is a mere appendage.",
+ # H.G. Wells — The Time Machine (1895)
+ "We should strive to welcome change and challenges, because they are the things that let us grow.",
+ "It sounds plausible enough tonight, but wait until tomorrow. Wait for the common sense of the morning.",
+ "There is no intelligence where there is no change and no need of change.",
+ # H.G. Wells — The War of the Worlds (1898)
+ "No one would have believed in the last years of the nineteenth century that this world was being watched keenly and closely by intelligences greater than ours.",
+ "Few people realized the ordeal that was at hand. I think that the greatest wonder of the whole thing was the swiftness of it all.",
+ # Jack London — The Call of the Wild (1903)
+ "He was mastered by the sheer surging of life, the tidal wave of being, the perfect joy of each separate muscle, joint, and sinew.",
+ "There is an ecstasy that marks the summit of life, and beyond which life cannot rise.",
+ # Edgar Allan Poe — various (1830s-1840s)
+ "All that we see or seem is but a dream within a dream.",
+ "I became insane, with long intervals of horrible sanity.",
+ "Deep into that darkness peering, long I stood there, wondering, fearing, doubting.",
+ "Words have no power to impress the mind without the exquisite horror of their reality.",
+ # Robert Louis Stevenson — Treasure Island (1883)
+ "Fifteen men on the dead man's chest. Drink and the devil had done for the rest.",
+ "I was far less afraid of the captain than anyone else who knew him.",
+ # Louisa May Alcott — Little Women (1868)
+ "I am not afraid of storms, for I am learning how to sail my ship.",
+ "I like good strong words that mean something.",
+ "Love is a great beautifier.",
+ # Nathaniel Hawthorne — The Scarlet Letter (1850)
+ "No man, for any considerable period, can wear one face to himself and another to the multitude.",
+ "She had not known the weight until she felt the freedom.",
+ # Bram Stoker — Dracula (1897)
+ "There are darknesses in life and there are lights, and you are one of the lights.",
+ "We learn from failure, not from success.",
+ # L. Frank Baum — The Wonderful Wizard of Oz (1900)
+ "A heart is not judged by how much you love; but by how much you are loved by others.",
+ "No matter how dreary and gray our homes are, we people of flesh and blood would rather live there than in any other country.",
+ # Daniel Defoe — Robinson Crusoe (1719)
+ "Thus fear of danger is ten thousand times more terrifying than danger itself.",
+ "All our discontents about what we want appeared to spring from the want of thankfulness for what we have.",
+ # Jonathan Swift — Gulliver's Travels (1726)
+ "I cannot but conclude the bulk of your natives to be the most pernicious race of little odious vermin that nature ever suffered to crawl upon the surface of the earth.",
+ "Whoever could make two ears of corn or two blades of grass to grow where only one grew before, would deserve better of mankind.",
+ # Homer — The Odyssey (trans. Samuel Butler, 1900)
+ "There is a time for many words, and there is also a time for sleep.",
+ "Of all creatures that breathe and move upon the earth, nothing is bred that is weaker than man.",
+ # Walt Whitman — Leaves of Grass (1855)
+ "Do I contradict myself? Very well then I contradict myself. I am large, I contain multitudes.",
+ "I exist as I am, that is enough.",
+ "Keep your face always toward the sunshine, and shadows will fall behind you.",
+ # Assorted public domain (pre-1928)
+ "The only thing we have to fear is fear itself.",
+ "In the middle of difficulty lies opportunity.",
+ "The mind is everything. What you think, you become.",
+ "To be yourself in a world that is constantly trying to make you something else is the greatest accomplishment.",
+ "It does not do to dwell on dreams and forget to live.",
+ "We are what we repeatedly do. Excellence, then, is not an act, but a habit.",
+ "The unexamined life is not worth living.",
+ "Knowing yourself is the beginning of all wisdom.",
+ "An investment in knowledge pays the best interest.",
+ "Well done is better than well said.",
+ "Lost time is never found again.",
+ "Either write something worth reading or do something worth writing.",
+ "Tell me and I forget. Teach me and I remember. Involve me and I learn.",
+ "The journey of a thousand miles begins with a single step.",
+ ],
+}
+
+
+LESSONS = [
+ {
+ "id": "basics-1",
+ "title": "The Basics",
+ "subtitle": "Top 100 most common words",
+ "level": "beginner",
+ "word_range": (0, 100),
+ "sentences_key": "beginner",
+ "order": 1,
+ },
+ {
+ "id": "building-1",
+ "title": "Building Up",
+ "subtitle": "Words 101-200",
+ "level": "intermediate",
+ "word_range": (100, 200),
+ "sentences_key": "intermediate",
+ "order": 2,
+ },
+ {
+ "id": "building-2",
+ "title": "Growing Vocabulary",
+ "subtitle": "Words 201-350",
+ "level": "intermediate",
+ "word_range": (200, 350),
+ "sentences_key": "intermediate",
+ "order": 3,
+ },
+ {
+ "id": "building-3",
+ "title": "Expanding Range",
+ "subtitle": "Words 351-500",
+ "level": "intermediate",
+ "word_range": (350, 500),
+ "sentences_key": "intermediate",
+ "order": 4,
+ },
+ {
+ "id": "phrases-1",
+ "title": "Common Phrases",
+ "subtitle": "Two and three word phrases",
+ "level": "intermediate",
+ "word_range": None,
+ "mode": "phrases",
+ "sentences_key": "intermediate",
+ "order": 5,
+ },
+ {
+ "id": "advanced-1",
+ "title": "Advanced Words",
+ "subtitle": "Words 501-700",
+ "level": "advanced",
+ "word_range": (500, 700),
+ "sentences_key": "advanced",
+ "order": 6,
+ },
+ {
+ "id": "advanced-2",
+ "title": "Expert Words",
+ "subtitle": "Words 701-1000",
+ "level": "advanced",
+ "word_range": (700, 1000),
+ "sentences_key": "advanced",
+ "order": 7,
+ },
+ {
+ "id": "advanced-3",
+ "title": "Uncommon Words",
+ "subtitle": "Words 1001-1500",
+ "level": "advanced",
+ "word_range": (1000, 1500),
+ "order": 8,
+ },
+ {
+ "id": "expert-1",
+ "title": "Expert Vocabulary",
+ "subtitle": "Words 1501-2500",
+ "level": "expert",
+ "word_range": (1500, 2500),
+ "order": 9,
+ },
+ {
+ "id": "expert-2",
+ "title": "Broad Vocabulary",
+ "subtitle": "Words 2501-4000",
+ "level": "expert",
+ "word_range": (2500, 4000),
+ "order": 10,
+ },
+ {
+ "id": "expert-3",
+ "title": "Deep Vocabulary",
+ "subtitle": "Words 4001-6000",
+ "level": "expert",
+ "word_range": (4000, 6000),
+ "order": 11,
+ },
+ {
+ "id": "master-1",
+ "title": "Master I",
+ "subtitle": "Words 6001-8000",
+ "level": "master",
+ "word_range": (6000, 8000),
+ "order": 12,
+ },
+ {
+ "id": "master-2",
+ "title": "Master II",
+ "subtitle": "Words 8001-10000",
+ "level": "master",
+ "word_range": (8000, 10000),
+ "order": 13,
+ },
+ {
+ "id": "sentences-1",
+ "title": "Real Sentences",
+ "subtitle": "Literature and real-world text",
+ "level": "advanced",
+ "word_range": None,
+ "mode": "sentences",
+ "sentences_key": "literature",
+ "order": 14,
+ },
+]
+
+
+def get_lesson_words(lesson, engine, count=10):
+ """Get practice content for a lesson.
+
+ Word-range lessons generate sentences from their cumulative word pool.
+ The literature lesson returns randomized public domain passages.
+ Phrase lessons return multi-word phrases.
+ """
+ if lesson.get("mode") == "phrases":
+ phrases = engine.all_phrases
+ phrasing = engine.generate_phrasing_phrases(200)
+ phrases = phrases + phrasing
+ random.shuffle(phrases)
+ return phrases[:50]
+
+ if lesson.get("mode") == "sentences":
+ key = lesson.get("sentences_key", "literature")
+ sents = list(SENTENCES.get(key, SENTENCES["literature"]))
+ random.shuffle(sents)
+ return sents[:count]
+
+ word_range = lesson.get("word_range")
+ if word_range:
+ start, end = word_range
+ cumulative = TOP_WORDS[:end]
+ pool = [w for w in cumulative if engine.word_exists(w)]
+ if not pool:
+ pool = list(cumulative)
+ return generate_sentences(pool, count=count)
+
+ return []
+
+
+def get_sentences(level="beginner", count=10):
+ sents = list(SENTENCES.get(level, SENTENCES["beginner"]))
+ random.shuffle(sents)
+ return sents[:count]
+
+
+def get_lesson_list():
+ return [
+ {
+ "id": l["id"],
+ "title": l["title"],
+ "subtitle": l["subtitle"],
+ "level": l["level"],
+ "order": l["order"],
+ "mode": l.get("mode", "sentences"),
+ }
+ for l in LESSONS
+ ]
+
+
+def get_lesson_word_list(lesson):
+ """Return the raw word list for a word-range lesson (for progress tracking)."""
+ word_range = lesson.get("word_range")
+ if word_range:
+ start, end = word_range
+ return TOP_WORDS[start:end]
+ return []
+
+
+def get_lesson_by_id(lesson_id):
+ for l in LESSONS:
+ if l["id"] == lesson_id:
+ return l
+ return None
diff --git a/phrases.py b/phrases.py
@@ -0,0 +1,400 @@
+"""Jeff's Phrasing dictionary integration for stenography practice.
+
+Loads the Jeff's Phrasing Plover dictionary plugin and provides functions
+to generate phrasing-based practice content.
+"""
+
+import os
+import random
+import sys
+
+# ---------------------------------------------------------------------------
+# Import Jeff's Phrasing dictionary
+# ---------------------------------------------------------------------------
+
+JEFF_PHRASING = None
+
+_jeff_phrasing_dir = os.path.expanduser(
+ "~/.config/plover/dicts/jeff-phrasing"
+)
+
+try:
+ if _jeff_phrasing_dir not in sys.path:
+ sys.path.insert(0, _jeff_phrasing_dir)
+ import importlib
+ JEFF_PHRASING = importlib.import_module("jeff-phrasing")
+except Exception:
+ JEFF_PHRASING = None
+
+# ---------------------------------------------------------------------------
+# Starters and enders used to generate phrase combinations
+# ---------------------------------------------------------------------------
+
+# Pronoun starters: stroke -> display word
+STARTER_STROKES = {
+ "SWR": "I",
+ "KPWR": "you",
+ "KWHR": "he",
+ "SKWHR": "she",
+ "KPWH": "it",
+ "TWR": "we",
+ "TWH": "they",
+}
+
+# Common ender strokes (present tense only for generation)
+ENDER_STROKES = {
+ "RB": "ask",
+ "B": "be",
+ "RPBG": "become",
+ "BL": "believe",
+ "RBLG": "call",
+ "BGS": "can",
+ "RZ": "care",
+ "PBGZ": "change",
+ "BG": "come",
+ "RP": "do",
+ "PGS": "expect",
+ "LT": "feel",
+ "PBLG": "find",
+ "RG": "forget",
+ "GS": "get",
+ "GZ": "give",
+ "G": "go",
+ "GT": "go to",
+ "T": "have",
+ "TS": "have to",
+ "PZ": "happen",
+ "PG": "hear",
+ "RPS": "hope",
+ "PLG": "imagine",
+ "PBGS": "keep",
+ "PB": "know",
+ "RPBS": "learn",
+ "LS": "let",
+ "BLG": "like",
+ "LZ": "live",
+ "L": "look",
+ "LG": "love",
+ "RPBL": "make",
+ "PL": "may",
+ "PBL": "mean",
+ "PLZ": "move",
+ "PBLGS": "must",
+ "RPG": "need",
+ "RPGT": "need to",
+ "PS": "put",
+ "RPL": "remember",
+ "R": "run",
+ "BS": "say",
+ "S": "see",
+ "PLS": "seem",
+ "RBL": "shall",
+ "RBGS": "will",
+ "RBGSZ": "would",
+ "RBS": "wish",
+ "RBG": "work",
+ "RBT": "take",
+ "RLT": "tell",
+ "PBG": "think",
+ "RT": "try",
+ "RPB": "understand",
+ "Z": "use",
+ "P": "want",
+ "PT": "want to",
+}
+
+# Middle/structure modifiers to combine with starters + enders
+MIDDLE_STROKES = {
+ "": "simple present/past",
+ "*": "negative",
+ "A": "can/could",
+ "A*": "can't/couldn't",
+ "O": "shall/should",
+ "AO": "will/would",
+ "AO*": "won't/wouldn't",
+}
+
+# ---------------------------------------------------------------------------
+# Phrase generation via Jeff's Phrasing lookup
+# ---------------------------------------------------------------------------
+
+def _try_lookup(stroke):
+ """Attempt to look up a single stroke via Jeff's Phrasing. Returns text or None."""
+ if JEFF_PHRASING is None:
+ return None
+ try:
+ return JEFF_PHRASING.lookup((stroke,)).strip()
+ except (KeyError, TypeError, AttributeError):
+ return None
+
+
+def _build_stroke(starter, middle, ender):
+ """Combine starter, middle, and ender stroke fragments into a single stroke string."""
+ import re
+ hyphen_chars = re.compile(r"[AO*EU-]")
+
+ combined = starter + middle + ender
+
+ # Add hyphen between left and right bank when needed
+ if not hyphen_chars.search(starter + middle) and ender:
+ combined = starter + middle + "-" + ender
+
+ return combined
+
+
+def _generate_phrase_list():
+ """Build a list of (phrase_text, stroke) tuples from starter + ender combinations."""
+ if JEFF_PHRASING is None:
+ return []
+
+ phrases = []
+ seen = set()
+
+ # Simple starter + ender (present tense)
+ for s_stroke in STARTER_STROKES:
+ for e_stroke in ENDER_STROKES:
+ stroke = _build_stroke(s_stroke, "", e_stroke)
+ text = _try_lookup(stroke)
+ if text and text not in seen:
+ phrases.append((text, stroke))
+ seen.add(text)
+
+ # Starter + middle + ender
+ for s_stroke in STARTER_STROKES:
+ for m_stroke in MIDDLE_STROKES:
+ if not m_stroke:
+ continue
+ for e_stroke in ENDER_STROKES:
+ stroke = _build_stroke(s_stroke, m_stroke, e_stroke)
+ text = _try_lookup(stroke)
+ if text and text not in seen:
+ phrases.append((text, stroke))
+ seen.add(text)
+
+ # Past tense: starter + ender with D suffix
+ for s_stroke in STARTER_STROKES:
+ for e_stroke, verb in ENDER_STROKES.items():
+ past_stroke = e_stroke + "D" if not e_stroke.endswith("D") else e_stroke
+ stroke = _build_stroke(s_stroke, "", past_stroke)
+ text = _try_lookup(stroke)
+ if text and text not in seen:
+ phrases.append((text, stroke))
+ seen.add(text)
+
+ # Structure variations: be/have forms
+ structure_mods = ["E", "F", "EF"] # be, have, have been
+ for s_stroke in STARTER_STROKES:
+ for struct in structure_mods:
+ for e_stroke in ENDER_STROKES:
+ stroke = _build_stroke(s_stroke, struct, e_stroke)
+ text = _try_lookup(stroke)
+ if text and text not in seen:
+ phrases.append((text, stroke))
+ seen.add(text)
+
+ return phrases
+
+
+# ---------------------------------------------------------------------------
+# Pre-computed phrase cache
+# ---------------------------------------------------------------------------
+
+_PHRASE_CACHE = []
+_PHRASE_TEXT_SET = set()
+
+
+def _init_cache():
+ global _PHRASE_CACHE, _PHRASE_TEXT_SET
+ if not _PHRASE_CACHE:
+ _PHRASE_CACHE = _generate_phrase_list()
+ _PHRASE_TEXT_SET = {p[0] for p in _PHRASE_CACHE}
+
+
+_init_cache()
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def get_phrase_strokes(phrase_text):
+ """Return a list of stroke tuples for the given phrase text using Jeff's reverse_lookup.
+
+ Example return: [("SWRPB",), ("SWR-PB",)]
+ Returns an empty list if Jeff's Phrasing is not available or the phrase is not found.
+ """
+ if JEFF_PHRASING is None:
+ return []
+ try:
+ return JEFF_PHRASING.reverse_lookup(phrase_text)
+ except Exception:
+ return []
+
+
+def get_practice_phrases(count=20):
+ """Return a list of random phrase texts for practice.
+
+ Each item is a dict with 'text' and 'strokes' keys.
+ """
+ if not _PHRASE_CACHE:
+ return [{"text": s, "strokes": []} for s in random.sample(
+ CURATED_SENTENCES, min(count, len(CURATED_SENTENCES))
+ )]
+
+ phrases = random.sample(_PHRASE_CACHE, min(count, len(_PHRASE_CACHE)))
+ return [{"text": text, "strokes": get_phrase_strokes(text)} for text, stroke in phrases]
+
+
+def get_all_cached_phrases():
+ """Return the full list of pre-computed (phrase_text, stroke) tuples."""
+ return list(_PHRASE_CACHE)
+
+
+def get_sentence_words(count=10):
+ """Generate simple practice sentences that combine phrases with common words.
+
+ Returns a list of sentence strings suitable for steno practice.
+ """
+ sentences = list(CURATED_SENTENCES)
+ random.shuffle(sentences)
+
+ if count <= len(sentences):
+ return sentences[:count]
+
+ # If we need more than curated, also generate from phrase combinations
+ generated = []
+ completions = [
+ "now", "here", "today", "again", "soon", "there",
+ "at home", "at work", "in the morning", "every day",
+ "to the store", "for a while", "right now", "this time",
+ "a lot", "so much", "very well", "too much",
+ "about it", "with you", "for me", "to them",
+ ]
+
+ if _PHRASE_CACHE:
+ for text, stroke in random.sample(_PHRASE_CACHE, min(50, len(_PHRASE_CACHE))):
+ completion = random.choice(completions)
+ generated.append(f"{text} {completion}")
+
+ combined = sentences + generated
+ random.shuffle(combined)
+ return combined[:count]
+
+
+# ---------------------------------------------------------------------------
+# Hand-curated practice sentences
+# ---------------------------------------------------------------------------
+
+CURATED_SENTENCES = [
+ # Short phrases (2-4 words)
+ "I can do it",
+ "you have to go",
+ "I want to know",
+ "she will come",
+ "they have to work",
+ "we can see it",
+ "he will go",
+ "I need to go",
+ "you can do it",
+ "it will be",
+ "I have to say",
+ "they want to come",
+ "she can go",
+ "we need to talk",
+ "he has to go",
+ "I would like to",
+ "you should go",
+ "they will come",
+ "we have to go",
+ "I can tell",
+ "she will be",
+ "you have to come",
+ "it can be",
+ "he will come",
+ "I should go",
+
+ # Medium phrases (4-6 words)
+ "they don't want to come",
+ "she has been working",
+ "I don't think so",
+ "you can't do that",
+ "we have been here",
+ "he doesn't want to go",
+ "I have been working",
+ "they will have to come",
+ "she can't believe it",
+ "you don't have to go",
+ "we should try to go",
+ "I don't want to go",
+ "he has been looking",
+ "they don't know how",
+ "you should have come",
+ "I can't believe it",
+ "she doesn't want to come",
+ "we don't have to go",
+ "he will have to come",
+ "I have to tell you",
+ "they can't come today",
+ "you don't need to go",
+ "she would like to come",
+ "we will have to go",
+ "it doesn't have to be",
+ "I used to live here",
+ "you have to believe me",
+ "they have been working",
+ "she will need to go",
+ "he can't seem to find",
+
+ # Longer sentences (6-10 words)
+ "I think that you should go to the store",
+ "they have been working on it all day",
+ "she will come to the house in the morning",
+ "we don't want to go to work today",
+ "he has been trying to find a new home",
+ "I would like to tell you about it",
+ "you should try to come here every day",
+ "they don't believe that it will work",
+ "she has to go to the store today",
+ "we need to find a way to do it",
+ "I can't remember how to get there",
+ "you will have to come back again soon",
+ "they should have been here by now",
+ "he doesn't seem to care about it",
+ "I have been trying to call you all day",
+ "she will need to learn how to do it",
+ "we can't go to the store right now",
+ "you don't have to do it today",
+ "they used to live in a big house",
+ "I would like to know what you think",
+ "he can't seem to make it work",
+ "she has been wanting to come here",
+ "we should try to get there on time",
+ "you have to tell me what you know",
+ "they will need to come back tomorrow",
+
+ # Common conversational phrases
+ "I don't know",
+ "you have to",
+ "I want to",
+ "she will be there",
+ "they have been",
+ "we can do it",
+ "he doesn't know",
+ "I will try",
+ "you should know",
+ "it has to be",
+ "I can see",
+ "they don't care",
+ "we will go",
+ "she has been",
+ "he will try",
+ "I have been here before",
+ "you can come with me",
+ "they should have told us",
+ "she would like to help",
+ "we need to go home now",
+ "he used to work here",
+ "I will have to think about it",
+ "you don't seem to understand",
+ "they can't find it",
+ "she has to come back",
+]
diff --git a/plover_engine.py b/plover_engine.py
@@ -0,0 +1,692 @@
+"""Plover dictionary engine: loads JSON + Python dictionaries from plover.cfg."""
+
+import configparser
+import importlib
+import importlib.util
+import json
+import os
+import random
+import re
+import sys
+
+STENO_ORDER = "#STKPWHRAO*EUFRPBLGTSDZ"
+_DISPLAY_OVERRIDES = {"i": "I"}
+
+_CAP_COMMANDS = {
+ # Old-style
+ "{-|}": "cap_next",
+ "{^}{-|}": "cap_next_attach",
+ "{*-|}": "cap_retro",
+ "{*<}": "upper_retro",
+ "{<}": "upper_next",
+ "{>}": "lower_next",
+ "{*>}": "lower_retro",
+ # New-style (standalone)
+ "{:case:cap_first_word}": "cap_next",
+ "{:case:upper_first_word}": "upper_next",
+ "{:case:lower_first_char}": "lower_next",
+ "{:retro_case:cap_first_word}": "cap_retro",
+ "{:retro_case:upper_first_word}": "upper_retro",
+ "{:retro_case:lower_first_char}": "lower_retro",
+ # Compound: {} + command (space-aware cap)
+ "{}{-|}": "cap_next",
+ "{}{:case:cap_first_word}": "cap_next",
+ # Compound: {^} + command (attach + cap)
+ "{^}{:case:cap_first_word}": "cap_next_attach",
+ "{^}{:case:upper_first_word}": "upper_next_attach",
+}
+
+
+class PloverEngine:
+ def __init__(self, config_path=None):
+ self.config_path = config_path
+ self.config_dir = os.path.dirname(self.config_path) if self.config_path else ""
+ self.stroke_to_word = {}
+ self.word_to_strokes = {}
+ self.phrase_to_strokes = {}
+ self._phrase_display = {}
+ self.punctuation_to_strokes = {}
+ self.cap_strokes = {}
+ self._practice_words = []
+ self._single_stroke_words = []
+ self._python_dicts = []
+ self._stroke_priority = {}
+ self._stroke_labels = {}
+ self._stroke_context = {}
+ self._ready = False
+ self.load_error = None
+
+ def _reset(self):
+ self.stroke_to_word.clear()
+ self.word_to_strokes.clear()
+ self.phrase_to_strokes.clear()
+ self._phrase_display.clear()
+ self.punctuation_to_strokes.clear()
+ self.cap_strokes.clear()
+ self._practice_words.clear()
+ self._single_stroke_words.clear()
+ self._python_dicts.clear()
+ self._stroke_priority.clear()
+ self._stroke_labels.clear()
+ self._stroke_context.clear()
+ self._ready = False
+ self.load_error = None
+
+ def load(self):
+ self._reset()
+
+ if not self.config_path or not os.path.exists(self.config_path):
+ self.load_error = f"Plover config not found: {self.config_path}"
+ print(f"Warning: {self.load_error}")
+ self._ready = True
+ return
+
+ config = configparser.ConfigParser()
+ config.read(self.config_path)
+
+ section = "System: English Stenotype"
+ if not config.has_section(section):
+ self.load_error = f"Section [{section}] not found in plover.cfg"
+ print(f"Warning: {self.load_error}")
+ self._ready = True
+ return
+
+ dicts_json = config.get(section, "dictionaries", fallback="[]")
+ dict_entries = json.loads(dicts_json)
+
+ json_count = 0
+ py_count = 0
+
+ for priority, entry in enumerate(dict_entries):
+ if not entry.get("enabled", False):
+ continue
+
+ path = entry["path"]
+ full_path = os.path.join(self.config_dir, path)
+
+ if path.endswith(".json"):
+ if self._load_json_dict(full_path, path, priority):
+ json_count += 1
+ elif path.endswith(".py"):
+ if self._load_python_dict(full_path, path, priority):
+ py_count += 1
+
+ print(f" JSON dictionaries: {json_count}")
+ print(f" Python dictionaries: {py_count}")
+ print(f" Total stroke entries: {len(self.stroke_to_word)}")
+
+ self._build_indexes()
+ self._ready = True
+
+ def load_from_uploaded(self, dict_dir, dict_entries):
+ """Load dictionaries from user-uploaded files.
+
+ dict_entries: list of dicts with keys: filename, enabled, dict_order
+ """
+ self._reset()
+
+ if not os.path.isdir(dict_dir):
+ self.load_error = "No dictionaries uploaded yet"
+ self._ready = True
+ return
+
+ sorted_entries = sorted(dict_entries, key=lambda e: e.get("dict_order", 0))
+ json_count = 0
+ py_count = 0
+
+ for priority, entry in enumerate(sorted_entries):
+ if not entry.get("enabled", True):
+ continue
+ filename = entry["filename"]
+ full_path = os.path.join(dict_dir, filename)
+
+ if filename.endswith(".json"):
+ if self._load_json_dict(full_path, filename, priority):
+ json_count += 1
+ elif filename.endswith(".py"):
+ if self._load_python_dict(full_path, filename, priority):
+ py_count += 1
+
+ print(f" Uploaded JSON dictionaries: {json_count}")
+ print(f" Uploaded Python dictionaries: {py_count}")
+ print(f" Total stroke entries: {len(self.stroke_to_word)}")
+
+ self._build_indexes()
+ self._ready = True
+
+ @staticmethod
+ def parse_plover_config(cfg_content):
+ """Parse plover.cfg content and return the ordered list of dictionary entries."""
+ config = configparser.ConfigParser()
+ config.read_string(cfg_content)
+
+ section = "System: English Stenotype"
+ if not config.has_section(section):
+ return []
+
+ dicts_json = config.get(section, "dictionaries", fallback="[]")
+ raw = json.loads(dicts_json)
+
+ result = []
+ for i, entry in enumerate(raw):
+ path = entry.get("path", "")
+ filename = os.path.basename(path)
+ if not filename:
+ continue
+ result.append({
+ "filename": filename,
+ "original_path": path,
+ "enabled": entry.get("enabled", True),
+ "order": i,
+ })
+ return result
+
+ def _load_json_dict(self, full_path, display_path, priority=0):
+ if not os.path.exists(full_path):
+ print(f" Warning: not found: {display_path}")
+ return False
+ try:
+ with open(full_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ count = 0
+ for stroke, translation in data.items():
+ if stroke not in self.stroke_to_word:
+ self.stroke_to_word[stroke] = translation
+ self._stroke_priority[stroke] = priority
+ count += 1
+ print(f" Loaded: {display_path} ({count} entries)")
+ return True
+ except (json.JSONDecodeError, OSError) as e:
+ print(f" Warning: failed to load {display_path}: {e}")
+ return False
+
+ def _load_python_dict(self, full_path, display_path, priority=0):
+ if not os.path.exists(full_path):
+ print(f" Warning: not found: {display_path}")
+ return False
+ try:
+ dict_dir = os.path.dirname(full_path)
+ module_name = os.path.splitext(os.path.basename(full_path))[0]
+
+ if dict_dir not in sys.path:
+ sys.path.insert(0, dict_dir)
+
+ safe_name = f"plover_pydict_{module_name.replace('-', '_')}"
+ spec = importlib.util.spec_from_file_location(safe_name, full_path)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+
+ has_lookup = hasattr(mod, "lookup")
+ has_reverse = hasattr(mod, "reverse_lookup")
+ longest_key = getattr(mod, "LONGEST_KEY", 0)
+
+ if has_lookup:
+ self._python_dicts.append({
+ "module": mod,
+ "name": module_name,
+ "path": display_path,
+ "has_reverse": has_reverse,
+ "longest_key": longest_key,
+ "priority": priority,
+ })
+ label = "lookup+reverse" if has_reverse else "lookup only"
+ print(f" Loaded: {display_path} (python, {label})")
+ return True
+ else:
+ print(f" Skipped: {display_path} (no lookup function)")
+ return False
+ except Exception as e:
+ print(f" Warning: failed to load {display_path}: {e}")
+ return False
+
+ def _build_indexes(self):
+ raw = {}
+ origcase = {}
+ punct_raw = {}
+ for stroke, translation in self.stroke_to_word.items():
+ stripped = translation.strip()
+ if _is_punctuation(stripped):
+ if stripped not in punct_raw:
+ punct_raw[stripped] = []
+ punct_raw[stripped].append(stroke)
+ continue
+ plover_punct = _PLOVER_PUNCT.get(stripped)
+ if plover_punct:
+ if plover_punct not in punct_raw:
+ punct_raw[plover_punct] = []
+ punct_raw[plover_punct].append(stroke)
+ continue
+ cap_type = _CAP_COMMANDS.get(stripped)
+ if cap_type:
+ if cap_type not in self.cap_strokes:
+ self.cap_strokes[cap_type] = []
+ self.cap_strokes[cap_type].append(stroke)
+ continue
+ if not _is_practice_word(stripped):
+ continue
+ key = stripped.lower()
+ if key not in raw:
+ raw[key] = []
+ if key != stripped and key not in origcase:
+ origcase[key] = stripped
+ raw[key].append(stroke)
+
+ prio = self._stroke_priority
+ for word, strokes in raw.items():
+ strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s)))
+ if " " in word:
+ self.phrase_to_strokes[word] = strokes
+ if word not in self._phrase_display:
+ self._phrase_display[word] = origcase.get(word, word)
+ else:
+ self.word_to_strokes[word] = strokes
+
+ self._scan_python_dicts_for_punctuation(punct_raw)
+
+ for punct, strokes in punct_raw.items():
+ strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s)))
+ self.punctuation_to_strokes[punct] = strokes
+
+ for cap_type, strokes in self.cap_strokes.items():
+ strokes.sort(key=lambda s: (prio.get(s, 999), s.count("/") + 1, len(s)))
+
+ if self.cap_strokes:
+ print(f" Capitalization commands: {sum(len(v) for v in self.cap_strokes.values())}")
+
+ self._practice_words = list(self.word_to_strokes.keys())
+ self._single_stroke_words = [
+ w for w, ss in self.word_to_strokes.items()
+ if any("/" not in s for s in ss)
+ ]
+
+ print(f" Practice words: {len(self._practice_words)}")
+ print(f" Phrases (from JSON): {len(self.phrase_to_strokes)}")
+ print(f" Punctuation entries: {len(self.punctuation_to_strokes)}")
+ print(f" Single-stroke words: {len(self._single_stroke_words)}")
+
+ def _scan_python_dicts_for_punctuation(self, punct_raw):
+ """Probe Python dicts to find strokes that produce punctuation."""
+ target_chars = set(".,;:!?\"'()-[]{}…/")
+ found = 0
+ for pd in self._python_dicts:
+ mod = pd["module"]
+ sym_table = getattr(mod, "symbols", None)
+ if isinstance(sym_table, dict):
+ attach_method = getattr(mod, "attachmentMethod", "space")
+ found += self._scan_symbol_table(
+ sym_table, target_chars, punct_raw,
+ pd["priority"], attach_method)
+ if found:
+ print(f" Punctuation from Python dicts: {found}")
+
+ @staticmethod
+ def _emily_stroke_variants(starter, pattern, attachment_method):
+ """Generate attachment/capitalization variants for emily-style strokes.
+ Returns list of (stroke, label, context_tag) tuples."""
+ def build(mid):
+ if mid:
+ return starter + mid + pattern
+ return starter + ("-" if pattern else "") + pattern
+
+ if attachment_method == "space":
+ return [
+ (build(""), None, "no_space"),
+ (build("O"), "spc after", "space_after"),
+ (build("O*"), "spc + cap", "space_cap"),
+ (build("AO"), "spc both", "space_both"),
+ ]
+ else:
+ return [
+ (build(""), None, "space_both"),
+ (build("A"), "attach left", "space_after"),
+ (build("A*"), "attach + cap", "space_cap"),
+ (build("AO"), "no spc", "no_space"),
+ ]
+
+ def _scan_symbol_table(self, sym_table, target_chars, punct_raw,
+ priority, attachment_method):
+ """Extract punctuation strokes from an emily-style symbols dict."""
+ found = 0
+ for starter, patterns in sym_table.items():
+ if not isinstance(patterns, dict):
+ continue
+ for pattern, variants in patterns.items():
+ if isinstance(variants, str):
+ variants = [variants]
+ if not isinstance(variants, list):
+ continue
+ char = variants[0] if variants else ""
+ if len(char) == 1 and char in target_chars:
+ for stroke, label, ctx in self._emily_stroke_variants(
+ starter, pattern, attachment_method):
+ if char not in punct_raw:
+ punct_raw[char] = []
+ if stroke not in punct_raw[char]:
+ punct_raw[char].append(stroke)
+ self._stroke_priority[stroke] = priority
+ self._stroke_context[stroke] = ctx
+ if label:
+ self._stroke_labels[stroke] = label
+ found += 1
+ return found
+
+ def get_strokes(self, text):
+ key = text.lower().strip()
+ result = list(self.word_to_strokes.get(key, []))
+ if not result:
+ result = list(self.phrase_to_strokes.get(key, []))
+ if not result:
+ result = self._python_reverse_lookup(text.strip())
+ if not result and key != text.strip():
+ result = self._python_reverse_lookup(key)
+ return result
+
+ def _python_reverse_lookup(self, text):
+ results = []
+ for pd in self._python_dicts:
+ if not pd["has_reverse"]:
+ continue
+ try:
+ hits = pd["module"].reverse_lookup(text)
+ for stroke_tuple in hits:
+ if len(stroke_tuple) == 1:
+ results.append(stroke_tuple[0])
+ except Exception:
+ pass
+ return results
+
+ def get_phrase_strokes(self, text):
+ key = text.lower().strip()
+ result = list(self.phrase_to_strokes.get(key, []))
+ py_strokes = self._python_reverse_lookup(text.strip())
+ if not py_strokes and key != text.strip():
+ py_strokes = self._python_reverse_lookup(key)
+ for s in py_strokes:
+ if s not in result:
+ result.append(s)
+ return result
+
+ def detect_phrase_hints(self, words, current_index):
+ if current_index >= len(words):
+ return []
+ hints = []
+ max_len = min(8, len(words) - current_index + 1)
+ for length in range(2, max_len):
+ phrase = " ".join(words[current_index:current_index + length])
+ strokes = self.get_phrase_strokes(phrase)
+ if strokes:
+ single = [s for s in strokes if "/" not in s]
+ best = single[:5] if single else strokes[:5]
+ hints.append({
+ "phrase": phrase,
+ "words_covered": length,
+ "strokes": best,
+ "is_single_stroke": bool(single),
+ "keys": parse_stroke(best[0]) if best else [],
+ })
+ hints.sort(key=lambda h: h["words_covered"], reverse=True)
+ return hints
+
+ def get_all_hints(self, word, context_words=None, context_index=None,
+ suppress_cap=False):
+ bare, trailing_punct = _strip_punctuation(word)
+ lookup_word = bare if bare else word
+
+ strokes = self.get_strokes(lookup_word)
+ all_hints = []
+ for s in strokes:
+ sc = s.count("/") + 1
+ kc = sum(len(_parse_single_stroke(part)) for part in s.split("/"))
+ all_hints.append({
+ "stroke": s,
+ "stroke_count": sc,
+ "key_count": kc,
+ "keys": parse_stroke(s),
+ })
+ all_hints.sort(key=lambda h: (h["stroke_count"], h["key_count"]))
+
+ fingerspell = not all_hints and len(lookup_word) > 0
+ punct_hints = []
+ for p in trailing_punct:
+ ps = self.punctuation_to_strokes.get(p, [])
+ if ps:
+ punct_hints.append({"char": p, "stroke": ps[0]})
+
+ phrase_hints = []
+ if context_words and context_index is not None:
+ phrase_hints = self.detect_phrase_hints(context_words, context_index)
+
+ cap_hints = []
+ if (not suppress_cap and lookup_word and lookup_word[0].isupper()
+ and lookup_word not in _DISPLAY_OVERRIDES.values()):
+ for cap_type in ("cap_next", "cap_next_attach", "cap_retro"):
+ ss = self.cap_strokes.get(cap_type, [])
+ if ss:
+ cap_hints.append({"type": cap_type, "stroke": ss[0]})
+ if (not suppress_cap and lookup_word and lookup_word.isupper()
+ and len(lookup_word) > 1):
+ for cap_type in ("upper_next", "upper_next_attach", "upper_retro"):
+ ss = self.cap_strokes.get(cap_type, [])
+ if ss:
+ cap_hints.append({"type": cap_type, "stroke": ss[0]})
+
+ return {
+ "word_hints": all_hints[:10],
+ "phrase_hints": phrase_hints,
+ "fingerspell": fingerspell,
+ "punct_hints": punct_hints,
+ "cap_hints": cap_hints,
+ }
+
+ def get_punct_hints(self, char, limit=8,
+ space_after=False, cap_next=False):
+ """Build hint items for a punctuation character, sorted by context."""
+ strokes = self.punctuation_to_strokes.get(char, [])
+ if space_after and cap_next:
+ target = "space_cap"
+ elif space_after:
+ target = "space_after"
+ else:
+ target = "no_space"
+
+ hints = []
+ for s in strokes:
+ sc = s.count("/") + 1
+ kc = sum(len(_parse_single_stroke(part)) for part in s.split("/"))
+ ctx = self._stroke_context.get(s)
+ h = {
+ "stroke": s,
+ "stroke_count": sc,
+ "key_count": kc,
+ "keys": parse_stroke(s),
+ }
+ label = self._stroke_labels.get(s)
+ if label:
+ h["label"] = label
+ match_rank = 0 if ctx == target else 1
+ hints.append((match_rank, sc, kc, h))
+
+ hints.sort(key=lambda t: (t[0], t[1], t[2]))
+ return [h for _, _, _, h in hints[:limit]]
+
+ def word_exists(self, word):
+ key = word.lower().strip()
+ if key in self.word_to_strokes or key in self.phrase_to_strokes:
+ return True
+ bare, _ = _strip_punctuation(key)
+ if bare and bare != key:
+ return bare in self.word_to_strokes or bare in self.phrase_to_strokes
+ return False
+
+ def display_form(self, word):
+ """Return the canonical display form (e.g. 'i' -> 'I')."""
+ return _DISPLAY_OVERRIDES.get(word.lower().strip(), word)
+
+ @property
+ def practice_words(self):
+ return list(self._practice_words)
+
+ @property
+ def single_stroke_words(self):
+ return list(self._single_stroke_words)
+
+ @property
+ def all_phrases(self):
+ result = []
+ for key in self.phrase_to_strokes:
+ display = self._phrase_display.get(key, key)
+ if _phrase_has_proper_noun(display):
+ continue
+ result.append(display)
+ return result
+
+ def generate_phrasing_phrases(self, count=200):
+ """Generate random phrases from Python phrasing dicts (e.g. jeff-phrasing)."""
+ _V1 = ["", "A", "O", "AO"]
+ _STAR = ["", "*"]
+ _V2 = ["", "E", "U", "EU"]
+ _F = ["", "F"]
+
+ all_phrases = []
+ for pd in self._python_dicts:
+ mod = pd["module"]
+ starters = getattr(mod, "STARTERS", None)
+ enders = getattr(mod, "ENDERS", None)
+ simple_starters = getattr(mod, "SIMPLE_STARTERS", None)
+ simple_pronouns = getattr(mod, "SIMPLE_PRONOUNS", None)
+ if not starters or not enders:
+ continue
+
+ starter_keys = list(starters.keys())
+ ender_keys = list(enders.keys())
+ ss_keys = list(simple_starters.keys()) if simple_starters else []
+ sp_keys = list(simple_pronouns.keys()) if simple_pronouns else []
+
+ seen = set()
+ attempts = 0
+ while len(all_phrases) < count and attempts < count * 30:
+ attempts += 1
+ use_simple = ss_keys and sp_keys and random.random() < 0.3
+ if use_simple:
+ stroke = (random.choice(ss_keys)
+ + random.choice(sp_keys)
+ + random.choice(_F)
+ + random.choice(ender_keys))
+ else:
+ stroke = (random.choice(starter_keys)
+ + random.choice(_V1)
+ + random.choice(_STAR)
+ + random.choice(_V2)
+ + random.choice(_F)
+ + random.choice(ender_keys))
+ try:
+ out = mod.lookup((stroke,))
+ except (KeyError, Exception):
+ continue
+ out = out.strip()
+ if not out or " " not in out:
+ continue
+ wc = len(out.split())
+ if wc < 2 or wc > 5:
+ continue
+ if out in seen:
+ continue
+ seen.add(out)
+ all_phrases.append(out)
+
+ return all_phrases
+
+
+_PUNCTUATION_CHARS = set(".,;:!?\"'()-[]{}…/")
+
+_PLOVER_PUNCT = {
+ # Old-style
+ "{.}": ".", "{,}": ",", "{!}": "!", "{?}": "?",
+ "{;}": ";", "{:}": ":",
+ # New-style
+ "{:stop:.}": ".", "{:stop:?}": "?", "{:stop:!}": "!",
+ "{:comma:,}": ",", "{:comma:;}": ";", "{:comma::}": ":",
+}
+
+
+def _strip_punctuation(word):
+ """Split trailing punctuation from a word. Returns (bare_word, [punct_chars])."""
+ trailing = []
+ while word and word[-1] in _PUNCTUATION_CHARS:
+ trailing.append(word[-1])
+ word = word[:-1]
+ trailing.reverse()
+ return word, trailing
+
+
+def _is_punctuation(text):
+ return len(text) == 1 and text in _PUNCTUATION_CHARS
+
+
+def _phrase_has_proper_noun(phrase):
+ """True if phrase contains capitalized words other than 'I' and its contractions."""
+ return any(
+ w[0].isupper() and w != "I" and not w.startswith("I'")
+ for w in phrase.split() if w
+ )
+
+
+def _is_practice_word(translation):
+ if not translation or not translation.strip():
+ return False
+ if "{" in translation or "}" in translation:
+ return False
+ if not re.match(r"^[a-zA-Z][a-zA-Z '\-]*$", translation):
+ return False
+ stripped = translation.strip()
+ if len(stripped) > 1 and stripped.replace(" ", "").replace("-", "").replace("'", "").isupper():
+ return False
+ return True
+
+
+def parse_stroke(stroke):
+ if "/" in stroke:
+ return [_parse_single_stroke(s) for s in stroke.split("/")]
+ return [_parse_single_stroke(stroke)]
+
+
+def _parse_single_stroke(stroke):
+ keys = []
+ if stroke.startswith("#"):
+ keys.append("#")
+ stroke = stroke[1:]
+ if not stroke:
+ return keys
+
+ left_keys = "STKPWHR"
+ center_keys = "AO*EU"
+ right_keys = "FRPBLGTSDZ"
+
+ if "-" in stroke:
+ left_part, right_part = stroke.split("-", 1)
+ for ch in left_part:
+ if ch in left_keys:
+ keys.append(f"{ch}-")
+ elif ch in center_keys:
+ keys.append(ch)
+ for ch in right_part:
+ if ch in right_keys:
+ keys.append(f"-{ch}")
+ elif ch in center_keys:
+ keys.append(ch)
+ else:
+ pos = 0
+ for ch in stroke:
+ for i in range(pos, len(STENO_ORDER)):
+ if STENO_ORDER[i] == ch:
+ pos = i + 1
+ if i == 0:
+ keys.append("#")
+ elif i <= 7:
+ keys.append(f"{ch}-")
+ elif i <= 12:
+ keys.append(ch)
+ else:
+ keys.append(f"-{ch}")
+ break
+ return keys
diff --git a/requirements.txt b/requirements.txt
@@ -0,0 +1,3 @@
+flask>=3.0
+authlib>=1.3
+python-dotenv>=1.0
diff --git a/sentence_generator.py b/sentence_generator.py
@@ -0,0 +1,172 @@
+"""Generate practice sentences from a constrained word pool.
+
+Uses NLTK's Brown and Gutenberg corpora — filters real English sentences
+to those where every word belongs to the target pool. Falls back to
+simple templates when the pool is too small to find corpus matches.
+"""
+
+import re
+import random
+
+_corpus_cache = None
+
+
+def _normalize(word):
+ """Lowercase, strip non-alpha except apostrophes (for contractions)."""
+ return re.sub(r"[^a-z']", "", word.lower())
+
+
+def _load_corpus():
+ """Lazily load and pre-process NLTK sentences into (word_set, text) pairs."""
+ global _corpus_cache
+ if _corpus_cache is not None:
+ return _corpus_cache
+
+ from nltk.corpus import brown, gutenberg
+
+ raw = list(brown.sents()) + list(gutenberg.sents())
+ corpus = []
+ for tokens in raw:
+ clean = _rebuild(tokens)
+ if not clean:
+ continue
+ words = {_normalize(w) for w in clean.split()}
+ words.discard("")
+ if len(words) < 3:
+ continue
+ corpus.append((words, clean))
+
+ _corpus_cache = corpus
+ return corpus
+
+
+def _rebuild(tokens):
+ """Reconstruct a sentence from NLTK tokens into clean readable text."""
+ text = " ".join(tokens)
+ # Fix NLTK tokenization artifacts
+ text = re.sub(r" ([.,;:!?)\]}])", r"\1", text)
+ text = re.sub(r"([\[({]) ", r"\1", text)
+ text = text.replace("`` ", '"').replace(" ''", '"').replace("''", '"')
+ text = text.replace('``', '"')
+ text = text.replace(" n't", "n't").replace(" 're", "'re")
+ text = text.replace(" 've", "'ve").replace(" 'll", "'ll")
+ text = text.replace(" 'd", "'d").replace(" 's", "'s")
+ text = text.replace(" 'm", "'m")
+ # Strip leading/trailing quotes and whitespace
+ text = text.strip().strip('"').strip()
+ # Drop sentences with numbers
+ if re.search(r"\d", text):
+ return ""
+ # Must start with a letter and end with sentence-ending punctuation
+ if not text or not text[0].isalpha():
+ return ""
+ if not text.endswith((".", "!", "?")):
+ return ""
+ # Ensure first character is uppercase
+ text = text[0].upper() + text[1:]
+ # Skip very short or very long
+ word_count = len(text.split())
+ if word_count < 5 or word_count > 18:
+ return ""
+ # Skip sentences with remaining quote marks (dialogue fragments)
+ if '"' in text or '``' in text or "''" in text:
+ return ""
+ return text
+
+
+def _filter_corpus(word_pool, count):
+ """Return up to `count` corpus sentences using only words from pool."""
+ corpus = _load_corpus()
+ pool = {w.lower() for w in word_pool}
+ # Also include common contractions if their base is in the pool
+ extras = set()
+ for w in list(pool):
+ for suffix in ("n't", "'s", "'re", "'ve", "'ll", "'d", "'m"):
+ extras.add(w + suffix)
+ pool |= extras
+
+ matches = []
+ for word_set, text in corpus:
+ if word_set.issubset(pool):
+ matches.append(text)
+
+ random.shuffle(matches)
+ return matches[:count]
+
+
+# ── Fallback template generator for very small pools ──
+
+_PRONOUNS = {"i", "he", "she", "we", "they", "you", "it"}
+_VERBS = {
+ "be", "have", "do", "say", "go", "get", "make", "know", "take", "come",
+ "see", "think", "look", "want", "give", "use", "find", "tell", "ask",
+ "work", "seem", "feel", "try", "leave", "call", "keep", "let", "begin",
+ "run", "read", "show", "turn", "play", "learn", "set", "change", "move",
+ "put", "start", "need", "help", "talk", "open", "mean", "add", "live",
+}
+_NOUNS = {
+ "time", "people", "year", "day", "way", "man", "world", "life", "hand",
+ "part", "place", "thing", "child", "eye", "woman", "work", "case",
+ "point", "home", "water", "room", "mother", "area", "money", "story",
+ "fact", "month", "lot", "right", "study", "book", "job", "word", "side",
+ "head", "house", "name", "end", "door", "car", "food", "night", "state",
+}
+_DETS = {"the", "a", "an", "this", "that", "some", "any", "no"}
+_PREPS = {"in", "on", "at", "to", "for", "with", "from", "by", "about", "into", "over"}
+_AUXS = {"will", "would", "can", "could", "should", "must", "may", "might"}
+
+_TEMPLATES = [
+ ("SUBJ", "VERB", "DET", "NOUN"),
+ ("SUBJ", "AUX", "VERB"),
+ ("SUBJ", "VERB", "PREP", "DET", "NOUN"),
+ ("DET", "NOUN", "VERB", "PREP", "DET", "NOUN"),
+ ("SUBJ", "AUX", "VERB", "DET", "NOUN"),
+]
+
+_SLOT_MAP = {
+ "SUBJ": _PRONOUNS, "VERB": _VERBS, "NOUN": _NOUNS,
+ "DET": _DETS, "PREP": _PREPS, "AUX": _AUXS,
+}
+
+
+def _template_sentences(word_pool, count):
+ pool = {w.lower() for w in word_pool}
+ available = {}
+ for slot, candidates in _SLOT_MAP.items():
+ words = list(pool & candidates)
+ if words:
+ available[slot] = words
+
+ usable = [t for t in _TEMPLATES if all(s in available for s in t)]
+ if not usable:
+ return []
+
+ results = set()
+ for _ in range(count * 10):
+ pattern = random.choice(usable)
+ words = [random.choice(available[s]) for s in pattern]
+ words[0] = words[0].capitalize()
+ words = ["I" if w == "i" else w for w in words]
+ sent = " ".join(words) + "."
+ results.add(sent)
+ if len(results) >= count:
+ break
+ return list(results)[:count]
+
+
+def generate_sentences(word_pool, count=10):
+ """Generate practice sentences constrained to word_pool.
+
+ Tries corpus filtering first; falls back to templates for small pools.
+ """
+ if not word_pool:
+ return []
+
+ sentences = _filter_corpus(word_pool, count)
+ if len(sentences) >= count:
+ return sentences
+
+ # Supplement with template-generated sentences
+ needed = count - len(sentences)
+ sentences.extend(_template_sentences(word_pool, needed))
+ return sentences[:count]
diff --git a/settings.py b/settings.py
@@ -0,0 +1,38 @@
+"""Settings management for the stenography practice app."""
+
+import json
+import os
+
+SETTINGS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "settings.json")
+
+DEFAULTS = {
+ "plover_config_path": os.path.expanduser("~/.config/plover/plover.cfg"),
+ "words_per_session": 50,
+}
+
+
+def load():
+ settings = dict(DEFAULTS)
+ if os.path.exists(SETTINGS_PATH):
+ try:
+ with open(SETTINGS_PATH) as f:
+ stored = json.load(f)
+ settings.update(stored)
+ except (json.JSONDecodeError, OSError):
+ pass
+ return settings
+
+
+def save(settings):
+ with open(SETTINGS_PATH, "w") as f:
+ json.dump(settings, f, indent=2)
+
+
+def get(key, default=None):
+ return load().get(key, default)
+
+
+def update(updates):
+ settings = load()
+ settings.update(updates)
+ save(settings)
diff --git a/spaced_repetition.py b/spaced_repetition.py
@@ -0,0 +1,59 @@
+"""SM-2 spaced repetition algorithm for stenography practice."""
+
+
+def sm2(quality, ease_factor, interval, repetitions):
+ """
+ SM-2 spaced repetition algorithm.
+
+ quality: 0-5 (0=blackout, 5=perfect recall)
+ ease_factor: current ease factor (starts at 2.5)
+ interval: current interval in days
+ repetitions: number of consecutive correct reviews
+
+ Returns: (new_ease_factor, new_interval, new_repetitions)
+ """
+ # Calculate new ease factor
+ new_ef = ease_factor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
+ new_ef = max(1.3, new_ef)
+
+ if quality >= 3:
+ # Correct response
+ new_repetitions = repetitions + 1
+ if repetitions == 0:
+ new_interval = 1.0
+ elif repetitions == 1:
+ new_interval = 6.0
+ else:
+ new_interval = interval * new_ef
+ else:
+ # Incorrect response — reset
+ new_repetitions = 0
+ new_interval = 1.0
+ new_ef = ease_factor # Don't change ease factor on failure
+
+ return (new_ef, new_interval, new_repetitions)
+
+
+def calculate_review(correct, time_ms, ease_factor, interval, repetitions):
+ """
+ Map practice result to SM-2 quality score and calculate next review.
+
+ correct: whether the answer was correct
+ time_ms: time taken in milliseconds
+ ease_factor: current ease factor
+ interval: current interval in days
+ repetitions: consecutive correct count
+
+ Returns: (new_ease_factor, new_interval, new_repetitions)
+ """
+ if correct:
+ if time_ms < 2000:
+ quality = 5 # Perfect, fast recall
+ elif time_ms < 5000:
+ quality = 4 # Correct with hesitation
+ else:
+ quality = 3 # Correct but slow
+ else:
+ quality = 1 # Incorrect
+
+ return sm2(quality, ease_factor, interval, repetitions)
diff --git a/static/css/style.css b/static/css/style.css
@@ -0,0 +1,1900 @@
+/* ===== RESET & BASE ===== */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+:root {
+ --font-sans: 'Space Grotesk', -apple-system, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+ --radius-sm: 10px;
+ --radius-md: 16px;
+ --radius-lg: 22px;
+ --transition: 200ms ease;
+
+ /* Tokyo Night Storm (dark) */
+ --base: #24283b;
+ --base-light: #292e42;
+ --base-dark: #1f2335;
+ --inset: #1a1b26;
+ --border: #3b4261;
+ --border-light: #3b4261;
+ --shadow-dark: rgba(0, 0, 0, 0.55);
+ --shadow-light: rgba(255, 255, 255, 0.06);
+ --neu-raised: 8px 8px 18px var(--shadow-dark), -8px -8px 18px var(--shadow-light);
+ --neu-raised-sm: 5px 5px 12px var(--shadow-dark), -5px -5px 12px var(--shadow-light);
+ --neu-raised-lg: 12px 12px 28px var(--shadow-dark), -12px -12px 28px var(--shadow-light);
+ --neu-pressed: inset 4px 4px 10px var(--shadow-dark), inset -4px -4px 10px var(--shadow-light);
+ --neu-pressed-deep: inset 6px 6px 16px var(--shadow-dark), inset -6px -6px 16px var(--shadow-light);
+ --neu-flat: 0 0 0 transparent;
+
+ --text-primary: #c0caf5;
+ --text-secondary: #a9b1d6;
+ --text-muted: #737ba3;
+ --accent: #7aa2f7;
+ --accent-dark: #3d59a1;
+ --accent-glow: rgba(122, 162, 247, 0.25);
+ --success: #9ece6a;
+ --success-glow: rgba(158, 206, 106, 0.2);
+ --warning: #e0af68;
+ --warning-glow: rgba(224, 175, 104, 0.2);
+ --error: #f7768e;
+ --error-glow: rgba(247, 118, 142, 0.2);
+ --streak: #ff9e64;
+ --streak-glow: rgba(255, 158, 100, 0.2);
+ --text-on-color: #1f2335;
+
+ --chart-blue: #7aa2f7;
+ --chart-green: #9ece6a;
+ --chart-red: #f7768e;
+ --heatmap-1: #1a2740;
+ --heatmap-2: #1f3d5c;
+ --heatmap-3: #2a6490;
+ --heatmap-4: #7aa2f7;
+ --cyan: #7dcfff;
+ --magenta: #bb9af7;
+ --teal: #73daca;
+}
+
+[data-theme="light"] {
+ /* Tokyo Night Day (light) */
+ --base: #d5d6db;
+ --base-light: #e1e2e7;
+ --base-dark: #c8c9ce;
+ --inset: #cbccd1;
+ --border: #b4b5b9;
+ --border-light: #b4b5b9;
+ --shadow-dark: rgba(0, 0, 0, 0.15);
+ --shadow-light: rgba(255, 255, 255, 0.7);
+
+ --text-primary: #343b58;
+ --text-secondary: #4c505e;
+ --text-muted: #6e717b;
+ --accent: #2367c5;
+ --accent-dark: #1e5dba;
+ --accent-glow: rgba(35, 103, 197, 0.2);
+ --success: #587539;
+ --success-glow: rgba(88, 117, 57, 0.15);
+ --warning: #8f5e15;
+ --warning-glow: rgba(143, 94, 21, 0.15);
+ --error: #c2204d;
+ --error-glow: rgba(194, 32, 77, 0.15);
+ --streak: #b15c00;
+ --streak-glow: rgba(177, 92, 0, 0.15);
+ --text-on-color: #ffffff;
+
+ --chart-blue: #2367c5;
+ --chart-green: #587539;
+ --chart-red: #c2204d;
+ --heatmap-1: #c8daf0;
+ --heatmap-2: #9bbde0;
+ --heatmap-3: #5a9bd4;
+ --heatmap-4: #2367c5;
+ --cyan: #007197;
+ --magenta: #7847bd;
+ --teal: #387068;
+}
+
+html {
+ font-size: 16px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font-family: var(--font-sans);
+ background: radial-gradient(ellipse at 50% 15%, var(--base-light) 0%, var(--base) 55%, var(--base-dark) 100%);
+ background-attachment: fixed;
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ font-weight: 500;
+ transition: color 0.3s ease;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: color var(--transition);
+}
+a:hover { color: var(--text-primary); }
+
+/* ===== NAVBAR ===== */
+.navbar {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.nav-inner {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 56px;
+}
+
+.nav-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -0.02em;
+ text-transform: uppercase;
+}
+.nav-brand:hover { color: var(--accent); }
+
+.nav-logo {
+ width: 28px;
+ height: 28px;
+ color: var(--accent);
+}
+
+.nav-links {
+ list-style: none;
+ display: flex;
+ gap: 0.25rem;
+ align-items: center;
+}
+
+.nav-link {
+ display: block;
+ padding: 0.5rem 1rem;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ transition: all var(--transition);
+}
+.nav-link:hover {
+ color: var(--text-primary);
+}
+
+/* ===== HAMBURGER ===== */
+.nav-hamburger {
+ display: none;
+ flex-direction: column;
+ justify-content: center;
+ gap: 5px;
+ width: 36px;
+ height: 36px;
+ padding: 6px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ z-index: 301;
+}
+.nav-hamburger span {
+ display: block;
+ height: 2px;
+ background: var(--text-primary);
+ border-radius: 2px;
+ transition: all 0.3s ease;
+}
+.nav-hamburger.active span:nth-child(1) {
+ transform: translateY(7px) rotate(45deg);
+}
+.nav-hamburger.active span:nth-child(2) {
+ opacity: 0;
+}
+.nav-hamburger.active span:nth-child(3) {
+ transform: translateY(-7px) rotate(-45deg);
+}
+
+/* ===== MAIN CONTENT ===== */
+.main-content {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem;
+}
+
+/* ===== CARDS ===== */
+.card {
+ background: var(--base);
+ border-radius: var(--radius-md);
+ padding: 1.5rem;
+ box-shadow: var(--neu-raised);
+}
+
+.stat-card {
+ background: var(--base);
+ border-radius: var(--radius-md);
+ padding: 1.25rem;
+ text-align: center;
+ transition: all var(--transition);
+ box-shadow: var(--neu-raised);
+}
+.stat-card:hover {
+ box-shadow: var(--neu-raised-lg);
+ transform: translateY(-2px);
+}
+
+.stat-card--streak {
+ position: relative;
+ overflow: hidden;
+}
+
+.stat-card__icon {
+ width: 36px;
+ height: 36px;
+ margin: 0 auto 0.5rem;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.streak-fire { background: var(--streak); }
+.stat-icon--words { background: var(--accent); }
+.stat-icon--mastered { background: var(--success); }
+.stat-icon--review { background: var(--warning); }
+
+.stat-card__value {
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.stat-card__label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+ margin-top: 0.25rem;
+}
+
+.stat-card__sub {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-transform: none;
+ letter-spacing: 0;
+ margin-top: 0.125rem;
+}
+
+.stat-card__action {
+ display: inline-block;
+ margin-top: 0.5rem;
+ font-size: 0.8rem;
+ padding: 0.3rem 0.8rem;
+ background: var(--warning);
+ color: var(--text-on-color);
+ border-radius: var(--radius-sm);
+ font-weight: 600;
+ border: none;
+ box-shadow: var(--neu-raised-sm);
+ transition: all var(--transition);
+ cursor: pointer;
+}
+.stat-card__action:hover {
+ box-shadow: var(--neu-pressed);
+ color: var(--text-on-color);
+}
+
+/* ===== BUTTONS ===== */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.625rem 1.25rem;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: var(--base);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition);
+ text-decoration: none;
+ line-height: 1.4;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ box-shadow: var(--neu-raised-sm);
+}
+.btn:hover {
+ box-shadow: var(--neu-raised);
+ color: var(--text-primary);
+ transform: translateY(-1px);
+}
+.btn:active {
+ box-shadow: var(--neu-pressed);
+ transform: translateY(0);
+}
+
+.btn--primary {
+ background: var(--accent);
+ color: var(--text-on-color);
+}
+.btn--primary:hover {
+ color: var(--text-on-color);
+}
+
+.btn--danger {
+ background: var(--error);
+ color: var(--text-on-color);
+}
+.btn--danger:hover {
+ color: var(--text-on-color);
+}
+
+.btn--ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ box-shadow: none;
+}
+.btn--ghost:hover {
+ color: var(--text-primary);
+ background: var(--base);
+ box-shadow: var(--neu-raised-sm);
+}
+.btn--ghost:active {
+ box-shadow: var(--neu-pressed);
+}
+
+.btn--small {
+ padding: 0.375rem 0.75rem;
+ font-size: 0.8rem;
+}
+
+/* ===== BADGES ===== */
+.badge {
+ display: inline-block;
+ padding: 0.2rem 0.6rem;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border: none;
+ background: var(--base-light);
+}
+.badge--common { color: var(--accent); }
+.badge--new { color: var(--success); }
+.badge--weak { color: var(--error); }
+.badge--speed { color: var(--warning); }
+.badge--review { color: var(--streak); }
+.badge--random { color: var(--text-secondary); }
+.badge--sentences { color: var(--cyan); }
+.badge--phrases { color: var(--streak); }
+.badge--lesson { color: var(--magenta); }
+.badge--beginner { color: var(--success); }
+.badge--intermediate { color: var(--warning); }
+.badge--advanced { color: var(--error); }
+.badge--expert { color: var(--magenta); }
+.badge--master { color: var(--teal); }
+
+/* ===== PROGRESS BAR ===== */
+.progress-bar {
+ height: 10px;
+ background: var(--base);
+ border-radius: 999px;
+ overflow: hidden;
+ box-shadow: var(--neu-pressed);
+}
+
+.progress-bar__fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 999px;
+ transition: width 0.3s ease;
+}
+
+.progress-bar__label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ margin-top: 0.5rem;
+ text-align: center;
+ font-weight: 600;
+}
+
+/* ===== TABLES ===== */
+.table-wrapper {
+ overflow-x: auto;
+ border-radius: var(--radius-md);
+ box-shadow: var(--neu-pressed);
+ background: var(--base);
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.table th {
+ text-align: left;
+ padding: 0.75rem 1rem;
+ background: var(--base-dark);
+ color: var(--text-secondary);
+ font-weight: 700;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.table td {
+ padding: 0.625rem 1rem;
+ border-bottom: 1px solid rgba(128,128,160,0.06);
+}
+
+.table tbody tr:last-child td { border-bottom: none; }
+.table tbody tr:hover { background: var(--base-light); }
+
+.table-empty {
+ text-align: center;
+ color: var(--text-muted);
+ padding: 2rem 1rem;
+}
+
+.sortable { cursor: pointer; user-select: none; }
+.sortable:hover { color: var(--accent); }
+.sortable::after { content: ' \2195'; font-size: 0.7em; opacity: 0.5; }
+
+.accuracy-cell { font-weight: 700; }
+.accuracy--good { color: var(--success); }
+.accuracy--mid { color: var(--warning); }
+.accuracy--low { color: var(--error); }
+
+/* ===== DASHBOARD ===== */
+.dashboard {
+ display: flex;
+ flex-direction: column;
+ gap: 2.5rem;
+}
+
+.hero-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1.25rem;
+}
+
+.section-title {
+ font-size: 1rem;
+ font-weight: 700;
+ margin-bottom: 1rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+}
+
+.page-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-bottom: 1.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+/* ===== LESSON GRID ===== */
+.lesson-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ background: var(--base);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+ box-shadow: var(--neu-pressed-deep);
+}
+
+.lesson-card {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.875rem 1.125rem;
+ background: var(--base);
+ border-radius: var(--radius-md);
+ text-decoration: none;
+ transition: all var(--transition);
+ box-shadow: var(--neu-raised-sm);
+}
+.lesson-card:hover {
+ box-shadow: var(--neu-raised);
+ transform: translateY(-1px);
+ color: var(--text-primary);
+}
+.lesson-card:active {
+ box-shadow: var(--neu-pressed);
+ transform: translateY(0);
+}
+
+.lesson-card__order {
+ width: 34px;
+ height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-size: 0.85rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ color: var(--text-on-color);
+}
+
+.lesson-card--beginner .lesson-card__order { background: var(--success); color: var(--text-on-color); }
+.lesson-card--intermediate .lesson-card__order { background: var(--warning); color: var(--text-on-color); }
+.lesson-card--advanced .lesson-card__order { background: var(--error); color: var(--text-on-color); }
+.lesson-card--expert .lesson-card__order { background: var(--magenta); color: var(--text-on-color); }
+.lesson-card--master .lesson-card__order { background: var(--teal); color: var(--text-on-color); }
+
+.lesson-card__info { flex: 1; min-width: 0; }
+.lesson-card__title { font-weight: 600; font-size: 0.95rem; color: var(--text-primary); }
+.lesson-card__subtitle { font-size: 0.8rem; color: var(--text-secondary); }
+.lesson-card__level { flex-shrink: 0; }
+
+.lesson-progress { margin-top: 0.35rem; }
+.lesson-progress__bar {
+ position: relative;
+ height: 4px;
+ background: var(--surface-hover);
+ border-radius: 2px;
+ overflow: hidden;
+}
+.lesson-progress__fill {
+ position: absolute;
+ top: 0; left: 0; height: 100%;
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+.lesson-progress__fill--practiced {
+ background: var(--text-muted);
+ opacity: 0.3;
+}
+.lesson-progress__fill--mastered {
+ background: var(--success);
+}
+.lesson-progress__text {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ margin-top: 0.2rem;
+}
+
+/* ===== MODE GRID ===== */
+.mode-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+ background: var(--base);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+ box-shadow: var(--neu-pressed-deep);
+}
+
+.mode-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 1.25rem 0.75rem;
+ background: var(--base);
+ border-radius: var(--radius-md);
+ transition: all var(--transition);
+ text-decoration: none;
+ box-shadow: var(--neu-raised-sm);
+}
+.mode-card:hover {
+ box-shadow: var(--neu-raised);
+ transform: translateY(-2px);
+ color: var(--text-primary);
+}
+.mode-card:active {
+ box-shadow: var(--neu-pressed);
+ transform: translateY(0);
+}
+
+.mode-card__icon {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ margin-bottom: 0.75rem;
+}
+
+.mode-icon--common { background: var(--accent); }
+.mode-icon--new { background: var(--success); }
+.mode-icon--weak { background: var(--error); }
+.mode-icon--speed { background: var(--warning); }
+.mode-icon--review { background: #7c3aed; }
+.mode-icon--random { background: #64748b; }
+.mode-icon--sentences { background: #0ea5e9; }
+.mode-icon--phrases { background: var(--streak); }
+
+.mode-card__title {
+ font-weight: 700;
+ font-size: 0.9rem;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.mode-card__desc {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+/* ===== TODAY'S PROGRESS ===== */
+.progress-container {
+ background: var(--base);
+ border-radius: var(--radius-md);
+ padding: 1.25rem;
+ box-shadow: var(--neu-pressed);
+}
+
+/* ===== EMPTY STATE ===== */
+.empty-state {
+ text-align: center;
+ padding: 3rem 1.5rem;
+ color: var(--text-secondary);
+ background: var(--base);
+ border-radius: var(--radius-md);
+ box-shadow: var(--neu-pressed);
+}
+
+/* ===== PRACTICE PAGE ===== */
+#practice-container {
+ min-height: calc(100vh - 56px - 4rem);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.practice-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1.5rem;
+}
+
+.practice-mode-label {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.practice-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 4rem;
+ color: var(--text-secondary);
+ gap: 1rem;
+}
+
+.session-config {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+}
+.session-config__label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+.session-config__options {
+ display: flex;
+ gap: 0.5rem;
+}
+.session-config__btn {
+ padding: 0.5rem 1.2rem;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 0.85rem;
+ transition: all 0.15s ease;
+}
+.session-config__btn:hover {
+ border-color: var(--accent);
+ color: var(--text-primary);
+}
+.session-config__btn--active {
+ background: var(--accent);
+ color: var(--base);
+ border-color: var(--accent);
+ font-weight: 600;
+}
+.session-config__start {
+ margin-top: 0.5rem;
+ padding: 0.65rem 2rem;
+ font-size: 1rem;
+}
+
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--base-light);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.practice-area {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 1.5rem;
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.practice-main {
+ display: flex;
+ gap: 1.5rem;
+ align-items: flex-start;
+}
+
+.practice-left {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+/* ===== MONKEYTYPE TYPING DISPLAY ===== */
+.typing-display {
+ width: 100%;
+ background: var(--base);
+ border-radius: var(--radius-lg);
+ padding: 2rem 2.5rem;
+ min-height: 180px;
+ max-height: 220px;
+ overflow-y: auto;
+ scrollbar-width: none;
+ cursor: text;
+ position: relative;
+ transition: box-shadow var(--transition);
+ box-shadow: var(--neu-pressed-deep);
+}
+
+.typing-display::-webkit-scrollbar {
+ display: none;
+}
+
+.typing-display.typing-display--focused {
+ box-shadow: var(--neu-pressed-deep);
+}
+
+.typing-input {
+ position: fixed;
+ top: -100px;
+ left: -100px;
+ opacity: 0;
+ pointer-events: none;
+ width: 1px;
+ height: 1px;
+}
+
+.typing-words {
+ font-family: var(--font-mono);
+ font-size: 1.5rem;
+ line-height: 2.4;
+ color: var(--text-muted);
+ user-select: none;
+}
+
+.tw-word { transition: color 0.1s ease; }
+.tw-word--active { color: var(--text-primary); }
+.tw-word--correct { color: var(--text-muted); }
+.tw-word--incorrect {
+ color: var(--error);
+ text-decoration: underline;
+ text-decoration-color: var(--error);
+ text-decoration-thickness: 3px;
+ text-underline-offset: 4px;
+}
+.tw-word--punct {
+ color: var(--accent);
+}
+.tw-word--phrase {
+ color: var(--streak);
+ background: rgba(251, 146, 60, 0.08);
+ border-radius: 3px;
+ padding: 0 2px;
+}
+
+.tw-char { position: relative; }
+.tw-char--correct { color: var(--success); }
+.tw-char--incorrect { color: var(--error); }
+.tw-char--extra { color: var(--error); opacity: 0.7; }
+.tw-space-sep {
+ display: inline-block;
+ width: 1ch;
+ text-align: center;
+ color: var(--text-muted);
+ opacity: 0.25;
+ user-select: none;
+}
+.tw-space-sep--done { color: var(--success); opacity: 0.2; }
+.tw-space-sep--correct { color: var(--success); opacity: 0.5; }
+.tw-space-sep--incorrect { color: var(--error); opacity: 1; }
+.tw-space-sep--missed { color: var(--error); opacity: 0.7; text-decoration: line-through; }
+.tw-space-sep--pending { color: var(--accent); opacity: 0.5; }
+
+.tw-cursor {
+ display: inline-block;
+ width: 3px;
+ height: 1.4em;
+ background: var(--accent);
+ vertical-align: text-bottom;
+ animation: cursor-blink 1s step-end infinite;
+ margin-left: 1px;
+ border-radius: 2px;
+}
+
+@keyframes cursor-blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* ===== TYPED TEXT DISPLAY ===== */
+.typed-text-display {
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ min-height: 1.6em;
+ padding: 0.5rem 0.75rem;
+ background: var(--base);
+ border-radius: var(--radius-sm);
+ white-space: pre-wrap;
+ word-break: break-all;
+ box-shadow: var(--neu-pressed);
+}
+
+/* ===== STENO HINT ===== */
+.steno-hint {
+ width: 100%;
+ background: var(--base);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ box-shadow: var(--neu-pressed);
+}
+
+.steno-hint--revealed { }
+
+.steno-keyboards {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.steno-keyboard-section { flex: 1; min-width: 0; }
+.steno-keyboard-section--multi { flex-basis: 100%; }
+
+.steno-keyboards-row {
+ display: flex;
+ gap: 0.5rem;
+ align-items: flex-start;
+}
+
+.steno-keyboard-cell { flex: 1; min-width: 0; text-align: center; }
+
+.steno-keyboard-cell__label {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--accent);
+ margin-bottom: 0.25rem;
+}
+.steno-keyboard-cell__label--phrase { color: var(--streak); }
+
+.steno-hint__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+}
+
+.steno-hint__title {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ font-weight: 700;
+}
+.steno-hint__title--phrase { color: var(--streak); }
+
+.steno-stroke {
+ font-family: var(--font-mono);
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: var(--accent);
+}
+.steno-stroke--phrase { color: var(--streak); }
+
+.steno-hint__keyboard { display: flex; justify-content: center; }
+
+.steno-keyboard {
+ width: 100%;
+ max-width: 520px;
+ height: auto;
+}
+
+.steno-key {
+ fill: var(--base-light);
+ stroke: none;
+ transition: fill 0.15s ease, filter 0.15s ease;
+}
+
+.steno-key.active {
+ fill: var(--accent);
+}
+
+.steno-keyboard--phrase .steno-key.active {
+ fill: var(--streak);
+}
+
+.steno-key-label {
+ fill: var(--text-secondary);
+ font-family: var(--font-sans);
+ font-size: 12px;
+ font-weight: 700;
+ text-anchor: middle;
+ dominant-baseline: central;
+ pointer-events: none;
+}
+.steno-key-label--small { font-size: 10px; }
+
+/* ===== SIDE HINTS PANEL ===== */
+.hints-panel {
+ width: 260px;
+ flex-shrink: 0;
+ background: var(--base);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ max-height: 600px;
+ overflow-y: auto;
+ position: sticky;
+ top: 72px;
+ box-shadow: var(--neu-pressed);
+}
+
+.hints-panel__header {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-muted);
+ margin-bottom: 0.75rem;
+ font-weight: 700;
+}
+
+.hints-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.hint-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ padding: 0.5rem 0.625rem;
+ background: var(--base);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all var(--transition);
+ box-shadow: var(--neu-raised-sm);
+}
+.hint-item:hover {
+ box-shadow: var(--neu-raised);
+ transform: translateY(-1px);
+}
+.hint-item:active {
+ box-shadow: var(--neu-pressed);
+ transform: translateY(0);
+}
+
+.hint-item--best {
+ background: var(--accent);
+ color: var(--text-on-color);
+ box-shadow: var(--neu-raised-sm);
+}
+.hint-item--best .hint-item__stroke { color: var(--text-on-color); }
+.hint-item--best .hint-item__meta { opacity: 0.75; color: var(--text-on-color); }
+
+.hint-item--empty {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ cursor: default;
+ text-align: center;
+ box-shadow: none;
+}
+
+.hint-item--punct { opacity: 0.8; }
+.hint-item--punct .hint-item__stroke {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+.hint-item--cap { opacity: 0.8; }
+.hint-item--cap .hint-item__stroke {
+ font-size: 0.8rem;
+ color: var(--accent);
+}
+
+.hint-item__stroke {
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.hint-item__meta {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
+
+.hint-item__label {
+ display: inline-block;
+ background: var(--accent);
+ color: var(--base-dark);
+ padding: 0 5px;
+ border-radius: 3px;
+ font-weight: 600;
+ font-size: 0.65rem;
+ margin-left: 4px;
+ vertical-align: middle;
+}
+
+/* ===== PHRASE HINTS ===== */
+.phrase-hints {
+ margin-bottom: 1rem;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid rgba(128,128,160,0.08);
+}
+
+.phrase-hints__header {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--streak);
+ margin-bottom: 0.5rem;
+ font-weight: 700;
+}
+
+.phrase-hints-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.phrase-hint-item {
+ padding: 0.5rem 0.625rem;
+ background: var(--base);
+ border-radius: var(--radius-sm);
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ box-shadow: var(--neu-raised-sm);
+}
+
+.phrase-hint-item--single {
+ background: var(--streak);
+ box-shadow: var(--neu-raised-sm);
+}
+.phrase-hint-item--single .phrase-hint-item__phrase { color: var(--text-on-color); }
+.phrase-hint-item--single .phrase-hint-item__stroke { color: var(--text-on-color); opacity: 0.9; }
+.phrase-hint-item--single .phrase-hint-item__words { color: var(--text-on-color); opacity: 0.6; }
+
+.phrase-hint-item__phrase {
+ font-size: 0.8rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-style: italic;
+}
+
+.phrase-hint-item__words {
+ font-size: 0.65rem;
+ color: var(--text-muted);
+}
+
+.phrase-hint-item__stroke {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ font-weight: 700;
+ color: var(--streak);
+}
+
+/* ===== SESSION STATS BAR ===== */
+.session-stats {
+ display: flex;
+ gap: 1.5rem;
+ padding: 0.75rem 1.5rem;
+ background: var(--base);
+ border-radius: var(--radius-md);
+ width: 100%;
+ justify-content: center;
+ box-shadow: var(--neu-pressed);
+}
+
+.session-stat {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.85rem;
+}
+
+.session-stat__icon {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+.session-stat__icon--time { background: var(--accent); }
+.session-stat__icon--accuracy { background: var(--success); }
+.session-stat__icon--streak { background: var(--streak); }
+.session-stat__icon--wpm { background: var(--warning); }
+.session-stat__icon--progress { background: #a78bfa; }
+
+.session-stat__value {
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+/* ===== MODAL / SESSION SUMMARY ===== */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(8px);
+}
+
+.modal {
+ background: var(--base);
+ border-radius: var(--radius-lg);
+ padding: 2.5rem;
+ max-width: 500px;
+ width: 90%;
+ box-shadow: var(--neu-raised-lg), 0 20px 60px rgba(0,0,0,0.3);
+}
+
+.modal__title {
+ text-align: center;
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin-bottom: 1.5rem;
+ color: var(--text-primary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.summary-accuracy {
+ text-align: center;
+ font-size: 3.5rem;
+ font-weight: 800;
+ margin-bottom: 1.5rem;
+}
+
+.summary-accuracy--good { color: var(--success); text-shadow: 0 0 20px var(--success-glow); }
+.summary-accuracy--mid { color: var(--warning); text-shadow: 0 0 20px var(--warning-glow); }
+.summary-accuracy--low { color: var(--error); text-shadow: 0 0 20px var(--error-glow); }
+
+.summary-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ text-align: center;
+}
+
+.summary-stat__label {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+}
+
+.summary-stat__value {
+ display: block;
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-top: 0.25rem;
+}
+
+.summary-weak-words { margin-bottom: 1.5rem; }
+
+.summary-weak-words__title {
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.summary-weak-words__list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.weak-word-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.3rem 0.7rem;
+ background: var(--base);
+ border-radius: 999px;
+ font-size: 0.8rem;
+ box-shadow: var(--neu-raised-sm);
+}
+
+.weak-word-chip__word { font-weight: 700; color: var(--error); }
+.weak-word-chip__stroke {
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
+
+.summary-errors__list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ max-height: 200px;
+ overflow-y: auto;
+}
+.error-detail {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.35rem 0.7rem;
+ background: var(--base);
+ border-radius: var(--radius);
+ font-size: 0.8rem;
+}
+.error-detail__words {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-width: 0;
+}
+.error-detail__expected {
+ font-weight: 700;
+ color: var(--success);
+}
+.error-detail__arrow {
+ color: var(--text-muted);
+ font-size: 0.7rem;
+}
+.error-detail__typed {
+ font-weight: 600;
+ color: var(--error);
+}
+.error-detail__stroke {
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+
+.modal__actions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: center;
+}
+
+/* ===== STATS PAGE ===== */
+.stats-page {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.stats-overview {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 1rem;
+}
+
+.stats-section {
+ background: var(--base);
+ border-radius: var(--radius-md);
+ padding: 1.5rem;
+ box-shadow: var(--neu-pressed);
+}
+
+.stats-section .section-title { margin-bottom: 1.25rem; }
+
+.chart-container {
+ position: relative;
+ width: 100%;
+ height: 280px;
+}
+.chart-container canvas { width: 100% !important; height: 100% !important; }
+
+/* ===== HEATMAP ===== */
+.heatmap-container { overflow-x: auto; padding-bottom: 0.5rem; }
+
+.heatmap {
+ display: inline-grid;
+ grid-template-rows: repeat(7, 14px);
+ grid-auto-flow: column;
+ grid-auto-columns: 14px;
+ gap: 3px;
+}
+
+.heatmap-cell {
+ width: 14px;
+ height: 14px;
+ border-radius: 3px;
+ background: var(--base-dark);
+ box-shadow: var(--neu-pressed);
+}
+
+.heatmap-cell[data-level="1"] { background: var(--heatmap-1); box-shadow: none; }
+.heatmap-cell[data-level="2"] { background: var(--heatmap-2); box-shadow: none; }
+.heatmap-cell[data-level="3"] { background: var(--heatmap-3); box-shadow: none; }
+.heatmap-cell[data-level="4"] { background: var(--heatmap-4); box-shadow: none; }
+
+.heatmap-months {
+ display: flex;
+ gap: 0;
+ margin-bottom: 0.5rem;
+ font-size: 0.65rem;
+ color: var(--text-muted);
+}
+.heatmap-month { text-align: left; }
+
+.heatmap-legend {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-top: 0.75rem;
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
+.heatmap-legend__cell { width: 12px; height: 12px; border-radius: 3px; }
+
+/* ===== SETTINGS PAGE ===== */
+.settings-page { max-width: 700px; }
+
+.settings-section {
+ background: var(--base);
+ border-radius: var(--radius-md);
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--neu-pressed);
+}
+
+.settings-section--danger {
+ box-shadow: var(--neu-pressed);
+}
+
+.setting-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+.setting-row:last-child { margin-bottom: 0; }
+
+.setting-label { font-weight: 700; font-size: 0.9rem; color: var(--text-primary); margin-bottom: 0.25rem; }
+.setting-help { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem; }
+
+.setting-input-group {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.setting-input {
+ padding: 0.5rem 0.75rem;
+ background: var(--base);
+ border: none;
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ font-size: 0.875rem;
+ min-width: 300px;
+ transition: box-shadow var(--transition);
+ box-shadow: var(--neu-pressed);
+}
+.setting-input--short { min-width: 80px; max-width: 100px; }
+.setting-input:focus {
+ outline: none;
+ box-shadow: var(--neu-pressed-deep), 0 0 0 2px var(--accent);
+}
+
+.settings-toast {
+ position: fixed;
+ bottom: 2rem;
+ right: 2rem;
+ padding: 0.75rem 1.5rem;
+ border-radius: var(--radius-sm);
+ font-size: 0.875rem;
+ font-weight: 600;
+ z-index: 1000;
+ border: none;
+}
+
+.settings-toast--success {
+ background: var(--success);
+ color: var(--text-on-color);
+ box-shadow: var(--neu-raised-sm);
+}
+
+.settings-toast--error {
+ background: var(--error);
+ color: var(--text-on-color);
+ box-shadow: var(--neu-raised-sm);
+}
+
+/* ===== THEME TOGGLE ===== */
+.theme-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ background: var(--base);
+ border: none;
+ border-radius: 50%;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all var(--transition);
+ font-size: 1.1rem;
+ line-height: 1;
+ box-shadow: var(--neu-raised-sm);
+}
+.theme-toggle:hover {
+ color: var(--accent);
+ box-shadow: var(--neu-raised);
+ transform: translateY(-1px);
+}
+.theme-toggle:active {
+ box-shadow: var(--neu-pressed);
+ transform: translateY(0);
+}
+
+/* ===== RESPONSIVE ===== */
+@media (max-width: 768px) {
+ /* --- Mobile Nav Overlay --- */
+ .nav-hamburger { display: flex; }
+ .nav-links {
+ position: fixed;
+ inset: 0;
+ background: var(--base);
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ z-index: 300;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.3s ease;
+ }
+ .nav-links.open {
+ opacity: 1;
+ pointer-events: all;
+ }
+ .nav-links .nav-link {
+ font-size: 1.5rem;
+ padding: 1rem 2rem;
+ }
+ .nav-links .theme-toggle {
+ margin-top: 1rem;
+ width: 48px;
+ height: 48px;
+ font-size: 1.4rem;
+ }
+
+ /* --- Dashboard --- */
+ .hero-stats { grid-template-columns: repeat(2, 1fr); }
+ .mode-grid { grid-template-columns: repeat(2, 1fr); }
+ .nav-inner { padding: 0 1rem; }
+ .main-content { padding: 1.5rem 1rem; }
+ .summary-stats { grid-template-columns: 1fr; gap: 0.75rem; }
+ .modal { padding: 1.5rem; }
+ .stats-overview { grid-template-columns: repeat(2, 1fr); }
+ .setting-row { flex-direction: column; }
+ .setting-input { min-width: unset; width: 100%; }
+
+ /* --- Practice Mobile (portrait) --- */
+ #practice-container {
+ min-height: calc(100dvh - 56px);
+ justify-content: flex-start;
+ padding: 0;
+ }
+ .practice-header {
+ padding: 0.5rem 0;
+ margin-bottom: 0.5rem;
+ }
+ .practice-header .btn { font-size: 0.75rem; padding: 0.4rem 0.75rem; }
+ .practice-mode-label { font-size: 0.85rem; }
+ .practice-area { gap: 0.5rem; }
+ .practice-main {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+ .practice-left { gap: 0.5rem; }
+ .typing-display {
+ min-height: unset;
+ max-height: 130px;
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius-md);
+ }
+ .typing-words {
+ font-size: 1.1rem;
+ line-height: 2;
+ }
+ .typed-text-display { display: none; }
+ .steno-hint { padding: 0.5rem; }
+ .steno-keyboard { max-width: 360px; }
+ .hints-panel {
+ width: 100%;
+ max-height: 120px;
+ position: static;
+ padding: 0.75rem;
+ }
+ .hints-list { flex-direction: row; flex-wrap: wrap; gap: 0.25rem; }
+ .hint-item { padding: 0.35rem 0.5rem; }
+ .hint-item__stroke { font-size: 0.8rem; }
+ .hint-item__meta { display: none; }
+ .session-stats {
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.8rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .hero-stats { grid-template-columns: repeat(2, 1fr); }
+ .mode-grid { grid-template-columns: repeat(2, 1fr); }
+ .practice-header { flex-wrap: wrap; gap: 0.5rem; }
+
+ .typing-display { max-height: 110px; padding: 0.6rem 0.75rem; }
+ .typing-words { font-size: 1rem; line-height: 1.9; }
+ .hints-panel { max-height: 100px; }
+ .steno-keyboard { max-width: 300px; }
+}
+
+/* --- Practice Landscape Mobile --- */
+@media (max-height: 500px) and (orientation: landscape) {
+ #practice-container {
+ min-height: calc(100dvh - 56px);
+ justify-content: flex-start;
+ padding: 0;
+ }
+ .main-content { padding: 0.5rem 1rem; }
+ .practice-header { margin-bottom: 0.25rem; padding: 0.25rem 0; }
+ .practice-header .btn { font-size: 0.7rem; padding: 0.3rem 0.6rem; }
+ .practice-mode-label { font-size: 0.8rem; }
+ .practice-area { gap: 0.35rem; }
+ .practice-main {
+ flex-direction: row;
+ gap: 0.5rem;
+ align-items: stretch;
+ }
+ .practice-left { gap: 0.35rem; flex: 1; }
+ .typing-display {
+ min-height: unset;
+ max-height: 90px;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-sm);
+ }
+ .typing-words { font-size: 0.95rem; line-height: 1.8; }
+ .typed-text-display { display: none; }
+ .steno-hint { padding: 0.35rem; }
+ .steno-keyboard { max-width: 280px; }
+ .steno-keyboard-cell__label { font-size: 0.65rem; }
+ .hints-panel {
+ width: 180px;
+ max-height: unset;
+ position: static;
+ padding: 0.5rem;
+ flex-shrink: 0;
+ }
+ .hints-panel__header { font-size: 0.6rem; margin-bottom: 0.35rem; }
+ .hint-item { padding: 0.25rem 0.4rem; }
+ .hint-item__stroke { font-size: 0.75rem; }
+ .hint-item__meta { display: none; }
+ .session-stats {
+ padding: 0.35rem 0.75rem;
+ gap: 0.75rem;
+ font-size: 0.75rem;
+ }
+ .session-stat__value { font-size: 0.75rem; }
+}
+
+/* ===== LOGIN PAGE ===== */
+.login-page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+}
+
+.login-card {
+ background: var(--base);
+ border-radius: var(--radius-lg);
+ padding: 3rem 2.5rem;
+ max-width: 400px;
+ width: 100%;
+ text-align: center;
+ box-shadow: var(--neu-raised-lg);
+}
+
+.login-brand {
+ margin-bottom: 2rem;
+}
+
+.login-logo {
+ width: 56px;
+ height: 56px;
+ color: var(--accent);
+ margin-bottom: 0.75rem;
+}
+
+.login-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25rem;
+}
+
+.login-subtitle {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+}
+
+.login-error {
+ background: var(--error);
+ color: var(--text-on-color);
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius-sm);
+ margin-bottom: 1.5rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.btn--google {
+ width: 100%;
+ padding: 0.75rem 1.5rem;
+ background: var(--base);
+ color: var(--text-primary);
+ font-size: 0.95rem;
+ gap: 0.75rem;
+ box-shadow: var(--neu-raised);
+ margin-bottom: 1.5rem;
+}
+.btn--google:hover {
+ box-shadow: var(--neu-raised-lg);
+ transform: translateY(-2px);
+}
+
+.google-icon {
+ flex-shrink: 0;
+}
+
+.login-footer {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+/* ===== NAV USER ===== */
+.nav-user {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.nav-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ box-shadow: var(--neu-raised-sm);
+}
+
+.nav-link--logout {
+ color: var(--text-muted);
+ font-size: 0.8rem;
+}
+
+/* ===== DICTIONARY BANNER ===== */
+.dict-banner {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 1.25rem;
+ background: var(--base);
+ border-radius: var(--radius-md);
+ box-shadow: var(--neu-pressed);
+ border-left: 4px solid var(--warning);
+ margin-bottom: 0;
+}
+
+.dict-banner__icon {
+ flex-shrink: 0;
+ color: var(--warning);
+}
+
+.dict-banner__text {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+.dict-banner__text strong {
+ color: var(--text-primary);
+}
+
+/* ===== DICTIONARY MANAGEMENT ===== */
+.dict-upload-row {
+ margin-bottom: 1rem;
+}
+
+.dict-upload-group {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.dict-upload-btn {
+ cursor: pointer;
+}
+
+.dict-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.dict-item {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.625rem 0.875rem;
+ background: var(--base);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--neu-raised-sm);
+ transition: all var(--transition);
+}
+.dict-item:hover {
+ box-shadow: var(--neu-raised);
+}
+
+.dict-item--missing {
+ opacity: 0.6;
+}
+.dict-item--missing .dict-item__count {
+ color: var(--warning);
+}
+
+.dict-item__order {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: var(--accent);
+ color: var(--text-on-color);
+ font-size: 0.7rem;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.dict-item__name {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dict-item__count {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.dict-item__toggle {
+ flex-shrink: 0;
+ cursor: pointer;
+}
+
+.dict-item__toggle input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.dict-item__delete {
+ padding: 0.25rem 0.4rem;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+.dict-item__delete:hover {
+ color: var(--error);
+}
+
+.dict-empty {
+ text-align: center;
+ padding: 1.5rem;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+.upload-progress {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ margin-bottom: 0.75rem;
+ background: var(--base);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--neu-pressed);
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.dict-status {
+ margin-top: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-sm);
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+@media (max-width: 768px) {
+ .login-card { padding: 2rem 1.5rem; }
+ .nav-user { flex-direction: column; gap: 0.25rem; }
+ .nav-avatar { width: 40px; height: 40px; }
+ .dict-upload-group { flex-direction: column; }
+ .dict-item { flex-wrap: wrap; }
+ .dict-item__name { flex-basis: calc(100% - 100px); }
+}
diff --git a/static/js/app.js b/static/js/app.js
@@ -0,0 +1,1319 @@
+(function(){var m=document.cookie.match(/(?:^| )theme=([^;]+)/);document.documentElement.setAttribute('data-theme',m?m[1]:'dark')})();
+
+function getCookie(name) {
+ const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
+ return match ? match[2] : null;
+}
+
+function setCookie(name, value, days) {
+ const d = new Date();
+ d.setTime(d.getTime() + (days * 86400000));
+ document.cookie = name + '=' + value + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
+}
+
+function getCSSVar(name) {
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+}
+
+/* ===== UTILITY FUNCTIONS ===== */
+
+function escapeHtml(str) {
+ if (typeof str !== 'string') return '';
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+function formatTime(ms) {
+ if (ms < 1000) return ms + 'ms';
+ if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
+ const minutes = Math.floor(ms / 60000);
+ const seconds = Math.floor((ms % 60000) / 1000);
+ return minutes + ':' + String(seconds).padStart(2, '0');
+}
+
+function formatWpm(wpm) {
+ if (typeof wpm !== 'number' || isNaN(wpm)) return '0.0';
+ return wpm.toFixed(1);
+}
+
+function formatAccuracy(pct) {
+ if (typeof pct !== 'number' || isNaN(pct)) return '0%';
+ return Math.round(pct) + '%';
+}
+
+function formatDuration(seconds) {
+ if (!seconds || seconds < 60) return (seconds || 0) + 's';
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return minutes + 'm';
+ const hours = Math.floor(minutes / 60);
+ const remainMin = minutes % 60;
+ return hours + 'h ' + remainMin + 'm';
+}
+
+function accuracyClass(pct) {
+ if (pct >= 90) return 'good';
+ if (pct >= 70) return 'mid';
+ return 'low';
+}
+
+
+/* ===== MONKEYTYPE-STYLE PRACTICE SESSION ===== */
+
+class PracticeSession {
+ constructor(mode, lessonId) {
+ this.mode = mode;
+ this.lessonId = lessonId;
+ this.sentenceCount = 10;
+ this.sessionId = null;
+ this.words = [];
+ this.wordIndex = 0;
+ this.typedChars = '';
+ this._expectsSpace = false;
+ this._spaceOk = null;
+ this._activeSpaceEl = null;
+ this._punctSpaceEl = null;
+ this._expectedBuf = '';
+ this._expectedBufStack = [];
+ this._missedSpaces = new Set();
+ this.consumedLength = 0;
+ this.consumedStack = [];
+ this.wordStartTime = null;
+ this.sessionStartTime = null;
+ this.timerInterval = null;
+ this.ended = false;
+
+ this.stats = {
+ attempted: 0,
+ correct: 0,
+ streak: 0,
+ bestStreak: 0,
+ totalTimeMs: 0,
+ results: [],
+ };
+
+ this.els = {
+ loading: document.getElementById('loading'),
+ area: document.getElementById('practice-area'),
+ typingDisplay: document.getElementById('typing-display'),
+ typingWords: document.getElementById('typing-words'),
+ typingInput: document.getElementById('typing-input'),
+ stenoHint: document.getElementById('steno-hint'),
+ stenoStrokeText: document.getElementById('steno-stroke-text'),
+ hintsPanel: document.getElementById('hints-panel'),
+ hintsList: document.getElementById('hints-list'),
+ phraseHints: document.getElementById('phrase-hints'),
+ phraseHintsList: document.getElementById('phrase-hints-list'),
+ typedTextDisplay: document.getElementById('typed-text-display'),
+ stenoKeyboards: document.getElementById('steno-keyboards'),
+ statTime: document.getElementById('stat-time'),
+ statAccuracy: document.getElementById('stat-accuracy'),
+ statStreak: document.getElementById('stat-streak'),
+ statWpm: document.getElementById('stat-wpm'),
+ statProgress: document.getElementById('stat-progress'),
+ summary: document.getElementById('session-summary'),
+ summaryAccuracy: document.getElementById('summary-accuracy'),
+ summaryWords: document.getElementById('summary-words'),
+ summaryWpm: document.getElementById('summary-wpm'),
+ summaryStreak: document.getElementById('summary-streak'),
+ summaryWeakWords: document.getElementById('summary-weak-words'),
+ endBtn: document.getElementById('end-session-btn'),
+ };
+
+ this._bindEvents();
+ this._setupConfig();
+ }
+
+ _bindEvents() {
+ // The hidden input captures all keystrokes
+ this.els.typingInput.addEventListener('input', () => this._onInput());
+
+ this.els.typingInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Tab' || e.key === 'Escape') {
+ e.preventDefault();
+ }
+ });
+
+ // Click on the typing display focuses the input
+ this.els.typingDisplay.addEventListener('click', () => {
+ this.els.typingInput.focus();
+ });
+
+ this.els.typingInput.addEventListener('focus', () => {
+ this.els.typingDisplay.classList.add('typing-display--focused');
+ });
+ this.els.typingInput.addEventListener('blur', () => {
+ this.els.typingDisplay.classList.remove('typing-display--focused');
+ });
+
+ this.els.endBtn.addEventListener('click', () => this.end());
+
+ // Keep input focused
+ document.addEventListener('keydown', (e) => {
+ if (this.ended) return;
+ if (e.target.tagName === 'INPUT' && e.target !== this.els.typingInput) return;
+ if (document.activeElement !== this.els.typingInput) {
+ this.els.typingInput.focus();
+ }
+ });
+ }
+
+ _setupConfig() {
+ const configEl = document.getElementById('session-config');
+ const startBtn = document.getElementById('start-session-btn');
+ if (!configEl || !startBtn) {
+ this.start();
+ return;
+ }
+
+ const btns = configEl.querySelectorAll('.session-config__btn');
+ btns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ btns.forEach(b => b.classList.remove('session-config__btn--active'));
+ btn.classList.add('session-config__btn--active');
+ this.sentenceCount = parseInt(btn.dataset.count) || 10;
+ });
+ });
+
+ startBtn.addEventListener('click', () => {
+ configEl.innerHTML = '<div class="spinner"></div><p>Loading session...</p>';
+ this.start();
+ });
+ }
+
+ async start() {
+ try {
+ const body = { mode: this.mode };
+ if (this.lessonId) {
+ body.lesson_id = this.lessonId;
+ body.sentence_count = this.sentenceCount;
+ }
+
+ const resp = await fetch('/api/session/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = await resp.json();
+
+ if (data.error) {
+ this.els.loading.innerHTML = '<p style="color: var(--error);">' + escapeHtml(data.error) + '</p>';
+ return;
+ }
+
+ this.sessionId = data.session_id;
+ this.words = data.words || [];
+ this.isSentence = !!data.is_sentence;
+
+ this.els.loading.style.display = 'none';
+ this.els.area.style.display = 'flex';
+
+ if (typeof gsap !== 'undefined') {
+ const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
+ tl.from('.typing-display', { y: 20, opacity: 0, duration: 0.4 })
+ .from('.steno-hint', { y: 20, opacity: 0, duration: 0.4 }, '-=0.25')
+ .from('.hints-panel', { x: 20, opacity: 0, duration: 0.4 }, '-=0.2')
+ .from('.session-stats', { y: 15, opacity: 0, duration: 0.35 }, '-=0.15');
+ }
+
+ this._renderWords();
+ this._updateHints();
+ this._updateStats();
+
+ this.els.typingInput.focus();
+ } catch (err) {
+ this.els.loading.innerHTML = '<p style="color: var(--error);">Failed to start session.</p>';
+ }
+ }
+
+ _renderWords() {
+ const container = this.els.typingWords;
+ container.innerHTML = '';
+ this._activeSpaceEl = null;
+ this._punctSpaceEl = null;
+
+ // Determine which upcoming words are covered by phrase hints
+ const phraseSet = new Set();
+ const currentWord = this.words[this.wordIndex];
+ if (currentWord && currentWord.phrase_hints) {
+ currentWord.phrase_hints.forEach(ph => {
+ for (let j = 0; j < ph.words_covered; j++) {
+ phraseSet.add(this.wordIndex + j);
+ }
+ });
+ }
+
+ this.words.forEach((w, i) => {
+ const wordEl = document.createElement('span');
+ wordEl.className = 'tw-word';
+ wordEl.dataset.index = i;
+
+ if (i < this.wordIndex) {
+ const result = this.stats.results[i];
+ if (result && result.correct) {
+ wordEl.classList.add('tw-word--correct');
+ } else {
+ wordEl.classList.add('tw-word--incorrect');
+ }
+ wordEl.textContent = w.word;
+ } else if (i === this.wordIndex) {
+ wordEl.classList.add('tw-word--active');
+ this._renderActiveWord(wordEl, w.word);
+ } else {
+ wordEl.textContent = w.word;
+ if (phraseSet.has(i)) {
+ wordEl.classList.add('tw-word--phrase');
+ }
+ }
+
+ if (w.is_punct) wordEl.classList.add('tw-word--punct');
+ container.appendChild(wordEl);
+
+ if (i < this.words.length - 1) {
+ const next = this.words[i + 1];
+ if (!next.is_punct) {
+ if (this.isSentence) {
+ const spaceEl = document.createElement('span');
+ if (i + 1 < this.wordIndex && this._missedSpaces.has(i + 1)) {
+ spaceEl.className = 'tw-space-sep tw-space-sep--missed';
+ } else if (i + 1 < this.wordIndex) {
+ spaceEl.className = 'tw-space-sep tw-space-sep--done';
+ } else {
+ spaceEl.className = 'tw-space-sep';
+ }
+ spaceEl.textContent = '␣';
+ if (i + 1 === this.wordIndex) {
+ this._activeSpaceEl = spaceEl;
+ }
+ // Track space after active punct token
+ if (i === this.wordIndex && w.is_punct) {
+ this._punctSpaceEl = spaceEl;
+ }
+ container.appendChild(spaceEl);
+ } else {
+ container.appendChild(document.createTextNode(' '));
+ }
+ }
+ }
+ });
+
+ this._scrollToActive();
+ }
+
+ _renderActiveWord(wordEl, expectedWord) {
+ wordEl.innerHTML = '';
+ const typed = this.typedChars;
+
+ for (let i = 0; i < expectedWord.length; i++) {
+ // Insert cursor before the next untyped character
+ if (i === typed.length) {
+ const cursor = document.createElement('span');
+ cursor.className = 'tw-cursor';
+ wordEl.appendChild(cursor);
+ }
+
+ const charSpan = document.createElement('span');
+ if (i < typed.length) {
+ if (typed[i] === expectedWord[i]) {
+ charSpan.className = 'tw-char tw-char--correct';
+ } else {
+ charSpan.className = 'tw-char tw-char--incorrect';
+ }
+ charSpan.textContent = expectedWord[i];
+ } else {
+ charSpan.className = 'tw-char';
+ charSpan.textContent = expectedWord[i];
+ }
+ wordEl.appendChild(charSpan);
+ }
+
+ // Extra typed characters beyond the word length
+ if (typed.length > expectedWord.length) {
+ for (let i = expectedWord.length; i < typed.length; i++) {
+ const charSpan = document.createElement('span');
+ charSpan.className = 'tw-char tw-char--extra';
+ charSpan.textContent = typed[i];
+ wordEl.appendChild(charSpan);
+ }
+ const cursor = document.createElement('span');
+ cursor.className = 'tw-cursor';
+ wordEl.appendChild(cursor);
+ }
+ }
+
+ _scrollToActive() {
+ const active = this.els.typingWords.querySelector('.tw-word--active');
+ if (!active) return;
+
+ const container = this.els.typingDisplay;
+ const padding = parseFloat(getComputedStyle(container).paddingTop) || 0;
+ container.scrollTop = Math.max(0, active.offsetTop - padding);
+ }
+
+ _onInput() {
+ if (this.ended) return;
+ if (this.isSentence) return this._onInputSentence();
+
+ const raw = this.els.typingInput.value;
+
+ // Undo: Plover's * sends backspaces — if input shrank past consumed boundary, revert words
+ let didUndo = false;
+ while (raw.length < this.consumedLength && this.wordIndex > 0) {
+ this.wordIndex--;
+ this.consumedLength = this.consumedStack.pop() || 0;
+ const last = this.stats.results.pop();
+ if (last) {
+ this.stats.attempted--;
+ this.stats.totalTimeMs -= last.timeMs;
+ if (last.correct) this.stats.correct--;
+ }
+ this.stats.streak = 0;
+ for (let i = this.stats.results.length - 1; i >= 0; i--) {
+ if (this.stats.results[i].correct) this.stats.streak++;
+ else break;
+ }
+ didUndo = true;
+ }
+ if (didUndo) {
+ this.wordStartTime = performance.now();
+ this._renderWords();
+ this._updateHints();
+ this._updateStats();
+ }
+
+ if (this.wordIndex >= this.words.length) return;
+
+ // Current word's text = everything past consumed, with leading whitespace stripped
+ let current = raw.substring(this.consumedLength);
+ const trimmed = current.replace(/^\s+/, '');
+
+ // Ignore empty input before session starts (Plover focus noise)
+ if (!trimmed && !this.sessionStartTime) return;
+
+ if (!this.sessionStartTime && trimmed) {
+ this.sessionStartTime = performance.now();
+ this.wordStartTime = performance.now();
+ this.timerInterval = setInterval(() => {
+ if (this.sessionStartTime && !this.ended) {
+ const elapsed = performance.now() - this.sessionStartTime;
+ this.els.statTime.textContent = formatTime(Math.round(elapsed));
+ }
+ }, 200);
+ }
+
+ this.typedChars = trimmed;
+ if (this.els.typedTextDisplay) {
+ this.els.typedTextDisplay.textContent = trimmed || ' ';
+ }
+ const expected = this.words[this.wordIndex].word;
+
+ // Multi-word output: typed text contains expected word + space + more
+ if (trimmed.length > expected.length) {
+ const head = trimmed.substring(0, expected.length);
+ const tail = trimmed.substring(expected.length);
+ if (head === expected && /^\s/.test(tail)) {
+ this.typedChars = head;
+ this.consumedStack.push(this.consumedLength);
+ this.consumedLength = raw.length - tail.replace(/^\s+/, '').length;
+ this._submitCurrentWord();
+ this._onInput();
+ return;
+ }
+ }
+
+ // Auto-match: exact match (case matters for steno capitalization)
+ if (trimmed === expected) {
+ this.consumedStack.push(this.consumedLength);
+ this.consumedLength = raw.length;
+ this._submitCurrentWord();
+ return;
+ }
+
+ // Update character feedback on active word
+ const activeEl = this.els.typingWords.querySelector('.tw-word--active');
+ if (activeEl) {
+ this._renderActiveWord(activeEl, expected);
+ }
+ this._updateStats();
+ }
+
+ _onInputSentence() {
+ const raw = this.els.typingInput.value;
+ const buf = raw.trimStart();
+
+ if (!buf && !this.sessionStartTime) return;
+
+ if (!this.sessionStartTime && buf) {
+ this.sessionStartTime = performance.now();
+ this.wordStartTime = performance.now();
+ this.timerInterval = setInterval(() => {
+ if (this.sessionStartTime && !this.ended) {
+ const elapsed = performance.now() - this.sessionStartTime;
+ this.els.statTime.textContent = formatTime(Math.round(elapsed));
+ }
+ }, 200);
+ }
+
+ if (this.wordIndex >= this.words.length) return;
+
+ // Undo detection: buffer no longer matches our tracked expected prefix
+ while (this.wordIndex > 0 && this._expectedBufStack.length > 0) {
+ if (buf.length < this._expectedBuf.length ||
+ buf.substring(0, this._expectedBuf.length) !== this._expectedBuf) {
+ this._expectedBuf = this._expectedBufStack.pop();
+ this.wordIndex--;
+ this._missedSpaces.delete(this.wordIndex);
+ const last = this.stats.results.pop();
+ if (last) {
+ this.stats.attempted--;
+ this.stats.totalTimeMs -= last.timeMs;
+ if (last.correct) this.stats.correct--;
+ }
+ this.stats.streak = 0;
+ for (let i = this.stats.results.length - 1; i >= 0; i--) {
+ if (this.stats.results[i].correct) this.stats.streak++;
+ else break;
+ }
+ this.wordStartTime = performance.now();
+ this._renderWords();
+ this._updateHints();
+ this._updateStats();
+ } else {
+ break;
+ }
+ }
+
+ // Advance through tokens, tolerating missed spaces
+ let advanced = false;
+ while (this.wordIndex < this.words.length) {
+ const token = this.words[this.wordIndex];
+ const needsSpace = !token.is_punct && this._expectedBuf.length > 0;
+ const withSpace = this._expectedBuf + (needsSpace ? ' ' : '') + token.word;
+ const noSpace = needsSpace ? (this._expectedBuf + token.word) : null;
+
+ if (buf.length >= withSpace.length &&
+ buf.substring(0, withSpace.length) === withSpace) {
+ this._expectedBufStack.push(this._expectedBuf);
+ this._expectedBuf = withSpace;
+ this.typedChars = token.word;
+ this._submitCurrentWord();
+ advanced = true;
+ } else if (noSpace && buf.length >= noSpace.length &&
+ buf.substring(0, noSpace.length) === noSpace) {
+ this._expectedBufStack.push(this._expectedBuf);
+ this._expectedBuf = noSpace;
+ this._missedSpaces.add(this.wordIndex);
+ this.typedChars = token.word;
+ this._submitCurrentWord();
+ advanced = true;
+ } else {
+ break;
+ }
+ }
+
+ if (advanced) return;
+
+ // Show partial-typing feedback on the active token
+ const token = this.words[this.wordIndex];
+ const afterPrev = buf.substring(this._expectedBuf.length);
+
+ const expectsSpace = !token.is_punct && this._expectedBuf.length > 0;
+ this._expectsSpace = expectsSpace;
+ if (expectsSpace) {
+ this._spaceOk = afterPrev.length > 0 ? afterPrev[0] === ' ' : null;
+ this.typedChars = afterPrev.replace(/^\s?/, '');
+ } else {
+ this._spaceOk = null;
+ this.typedChars = token.is_punct ? afterPrev : afterPrev.replace(/^\s+/, '');
+ }
+
+ if (this.els.typedTextDisplay) {
+ this.els.typedTextDisplay.textContent = this.typedChars || ' ';
+ }
+
+ // Update space separator colors
+ if (this._activeSpaceEl) {
+ if (this._spaceOk === true) {
+ this._activeSpaceEl.className = 'tw-space-sep tw-space-sep--correct';
+ } else if (this._spaceOk === false) {
+ this._activeSpaceEl.className = 'tw-space-sep tw-space-sep--incorrect';
+ } else {
+ this._activeSpaceEl.className = 'tw-space-sep';
+ }
+ }
+ // Highlight space after active punct to show the stroke includes it
+ if (this._punctSpaceEl && token.is_punct && this.typedChars.length > 0) {
+ this._punctSpaceEl.className = 'tw-space-sep tw-space-sep--pending';
+ }
+
+ const activeEl = this.els.typingWords.querySelector('.tw-word--active');
+ if (activeEl) {
+ this._renderActiveWord(activeEl, token.word);
+ }
+ this._updateStats();
+ }
+
+ async _submitCurrentWord() {
+ if (this.wordIndex >= this.words.length) return;
+
+ const word = this.words[this.wordIndex];
+ const typed = this.typedChars;
+ const timeMs = Math.round(performance.now() - this.wordStartTime);
+ const correct = typed === word.word;
+
+ this.stats.attempted++;
+ this.stats.totalTimeMs += timeMs;
+
+ if (correct) {
+ this.stats.correct++;
+ this.stats.streak++;
+ if (this.stats.streak > this.stats.bestStreak) {
+ this.stats.bestStreak = this.stats.streak;
+ }
+ } else {
+ this.stats.streak = 0;
+ }
+
+ this.stats.results.push({
+ word: word.word,
+ typed: typed,
+ correct: correct,
+ timeMs: timeMs,
+ strokes: word.word_hints && word.word_hints[0] ? word.word_hints[0].stroke : '',
+ });
+
+ // Submit to server (fire and forget for speed)
+ fetch('/api/word/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ session_id: this.sessionId,
+ word: word.word,
+ typed: typed,
+ time_ms: timeMs,
+ word_index: this.wordIndex,
+ }),
+ }).catch(() => {});
+
+ // Advance (don't clear input — Plover manages the text buffer)
+ this.wordIndex++;
+ this.typedChars = '';
+ if (this.els.typedTextDisplay) {
+ this.els.typedTextDisplay.textContent = ' ';
+ }
+ this.wordStartTime = performance.now();
+
+ this._updateStats();
+
+ if (this.wordIndex >= this.words.length) {
+ this.end();
+ return;
+ }
+
+ this._renderWords();
+ this._updateHints();
+ }
+
+ _updateHints() {
+ if (this.wordIndex >= this.words.length) return;
+
+ const word = this.words[this.wordIndex];
+ const wordHints = word.word_hints || [];
+ const phraseHints = word.phrase_hints || [];
+
+ // Phrase hints in side panel (already sorted longest-first by server)
+ if (phraseHints.length > 0 && this.els.phraseHintsList) {
+ this.els.phraseHints.style.display = 'block';
+ let html = '';
+ phraseHints.forEach(ph => {
+ const strokesStr = ph.strokes.join(' or ');
+ html += '<div class="phrase-hint-item' + (ph.is_single_stroke ? ' phrase-hint-item--single' : '') + '">';
+ html += '<span class="phrase-hint-item__phrase">"' + escapeHtml(ph.phrase) + '"</span>';
+ html += '<span class="phrase-hint-item__words">' + ph.words_covered + ' words</span>';
+ if (strokesStr) {
+ html += '<span class="phrase-hint-item__stroke">' + escapeHtml(strokesStr) + '</span>';
+ }
+ html += '</div>';
+ });
+ this.els.phraseHintsList.innerHTML = html;
+ } else if (this.els.phraseHints) {
+ this.els.phraseHints.style.display = 'none';
+ }
+
+ // Build keyboard visualizations
+ this._renderKeyboards(phraseHints, wordHints, word);
+
+ // Word-level strokes in side panel
+ if (wordHints.length > 0) {
+ let html = '';
+ wordHints.forEach((hint, i) => {
+ const cls = i === 0 ? 'hint-item hint-item--best' : 'hint-item';
+ const strokeLabel = hint.stroke_count === 1 ? '1 stroke' : hint.stroke_count + ' strokes';
+ html += '<div class="' + cls + '" data-hint-index="' + i + '">';
+ html += '<span class="hint-item__stroke">' + escapeHtml(hint.stroke) + '</span>';
+ const labelTag = hint.label ? ' <span class="hint-item__label">' + escapeHtml(hint.label) + '</span>' : '';
+ html += '<span class="hint-item__meta">' + strokeLabel + ', ' + hint.key_count + ' keys' + labelTag + '</span>';
+ html += '</div>';
+ });
+ this.els.hintsList.innerHTML = html;
+
+ this.els.hintsList.querySelectorAll('.hint-item').forEach(el => {
+ el.addEventListener('click', () => {
+ const idx = parseInt(el.dataset.hintIndex);
+ if (wordHints[idx] && wordHints[idx].keys) {
+ this._renderKeyboards(phraseHints, wordHints, word, idx);
+ }
+ });
+ });
+ } else if (word.fingerspell) {
+ this.els.hintsList.innerHTML = '<div class="hint-item hint-item--empty">Fingerspell: spell out each letter</div>';
+ } else {
+ this.els.hintsList.innerHTML = '<div class="hint-item hint-item--empty">No strokes found</div>';
+ }
+
+ // Punctuation hints below word strokes
+ const punctHints = word.punct_hints || [];
+ if (punctHints.length > 0) {
+ let punctHtml = '';
+ punctHints.forEach(ph => {
+ punctHtml += '<div class="hint-item hint-item--punct">';
+ punctHtml += '<span class="hint-item__stroke">' + escapeHtml(ph.char) + ' → ' + escapeHtml(ph.stroke) + '</span>';
+ punctHtml += '</div>';
+ });
+ this.els.hintsList.innerHTML += punctHtml;
+ }
+
+ // Capitalization hints
+ const capHints = word.cap_hints || [];
+ if (capHints.length > 0) {
+ const capLabels = {
+ 'cap_next': 'Capitalize',
+ 'cap_next_attach': 'Cap (no space)',
+ 'cap_retro': 'Retro cap',
+ 'upper_next': 'ALL CAPS',
+ 'upper_next_attach': 'ALL CAPS (no space)',
+ 'upper_retro': 'Retro ALL CAPS',
+ 'lower_next': 'Lowercase',
+ 'lower_retro': 'Retro lowercase',
+ };
+ let capHtml = '';
+ capHints.forEach(ch => {
+ const label = capLabels[ch.type] || ch.type;
+ capHtml += '<div class="hint-item hint-item--cap">';
+ capHtml += '<span class="hint-item__stroke">' + escapeHtml(label) + ' → ' + escapeHtml(ch.stroke) + '</span>';
+ capHtml += '</div>';
+ });
+ this.els.hintsList.innerHTML += capHtml;
+ }
+ }
+
+ _renderKeyboards(phraseHints, wordHints, word, wordHintIdx) {
+ const container = this.els.stenoKeyboards;
+ container.innerHTML = '';
+ if (wordHintIdx === undefined) wordHintIdx = 0;
+
+ const bestPhrase = phraseHints.length > 0 ? phraseHints[0] : null;
+ const bestWord = wordHints.length > 0 ? wordHints[wordHintIdx] : null;
+
+ if (bestPhrase && bestPhrase.keys && bestPhrase.keys.length > 0) {
+ const strokeParts = (bestPhrase.strokes[0] || '').split('/');
+ this._appendKeyboardGroup(container, 'Phrase', bestPhrase.strokes[0], bestPhrase.keys, 'phrase', strokeParts);
+ }
+
+ if (bestWord && bestWord.keys && bestWord.keys.length > 0) {
+ const strokeParts = bestWord.stroke.split('/');
+ this._appendKeyboardGroup(container, 'Word', bestWord.stroke, bestWord.keys, 'word', strokeParts);
+ } else if (word.fingerspell) {
+ const section = document.createElement('div');
+ section.className = 'steno-keyboard-section';
+ section.innerHTML = '<div class="steno-hint__header">'
+ + '<span class="steno-hint__title">Word</span>'
+ + '<span class="steno-stroke">fingerspell</span>'
+ + '</div>';
+ container.appendChild(section);
+ }
+ }
+
+ _appendKeyboardGroup(container, label, strokeText, keyGroups, type, strokeParts) {
+ const isPhrase = type === 'phrase';
+ const titleClass = isPhrase ? ' steno-hint__title--phrase' : '';
+ const strokeClass = isPhrase ? ' steno-stroke--phrase' : '';
+
+ if (keyGroups.length === 1) {
+ const section = document.createElement('div');
+ section.className = 'steno-keyboard-section';
+ section.innerHTML = '<div class="steno-hint__header">'
+ + '<span class="steno-hint__title' + titleClass + '">' + escapeHtml(label) + '</span>'
+ + '<span class="steno-stroke' + strokeClass + '">' + escapeHtml(strokeText) + '</span>'
+ + '</div>';
+ const kbWrap = document.createElement('div');
+ kbWrap.className = 'steno-hint__keyboard';
+ const svg = this._createKeyboardSVG(isPhrase);
+ this._highlightKeysOn(svg, keyGroups[0]);
+ kbWrap.appendChild(svg);
+ section.appendChild(kbWrap);
+ container.appendChild(section);
+ } else {
+ const section = document.createElement('div');
+ section.className = 'steno-keyboard-section steno-keyboard-section--multi';
+ section.innerHTML = '<div class="steno-hint__header">'
+ + '<span class="steno-hint__title' + titleClass + '">' + escapeHtml(label) + '</span>'
+ + '<span class="steno-stroke' + strokeClass + '">' + escapeHtml(strokeText) + '</span>'
+ + '</div>';
+ const row = document.createElement('div');
+ row.className = 'steno-keyboards-row';
+ keyGroups.forEach((keys, i) => {
+ const cell = document.createElement('div');
+ cell.className = 'steno-keyboard-cell';
+ if (strokeParts && strokeParts[i]) {
+ const partLabel = document.createElement('div');
+ partLabel.className = 'steno-keyboard-cell__label' + (isPhrase ? ' steno-keyboard-cell__label--phrase' : '');
+ partLabel.textContent = strokeParts[i];
+ cell.appendChild(partLabel);
+ }
+ const svg = this._createKeyboardSVG(isPhrase);
+ this._highlightKeysOn(svg, keys);
+ cell.appendChild(svg);
+ row.appendChild(cell);
+ });
+ section.appendChild(row);
+ container.appendChild(section);
+ }
+ }
+
+ _createKeyboardSVG(isPhrase) {
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(ns, 'svg');
+ svg.setAttribute('class', 'steno-keyboard' + (isPhrase ? ' steno-keyboard--phrase' : ''));
+ svg.setAttribute('viewBox', '0 0 520 170');
+ const keys = [
+ { x: 10, y: 5, w: 500, h: 28, key: '#', lx: 260, ly: 24, small: true },
+ { x: 10, y: 40, w: 44, h: 72, key: 'S-', lx: 32, ly: 80 },
+ { x: 58, y: 40, w: 44, h: 34, key: 'T-', lx: 80, ly: 62 },
+ { x: 106, y: 40, w: 44, h: 34, key: 'P-', lx: 128, ly: 62 },
+ { x: 154, y: 40, w: 44, h: 34, key: 'H-', lx: 176, ly: 62 },
+ { x: 210, y: 40, w: 44, h: 72, key: '*', lx: 232, ly: 80 },
+ { x: 266, y: 40, w: 44, h: 34, key: '-F', lx: 288, ly: 62 },
+ { x: 314, y: 40, w: 44, h: 34, key: '-P', lx: 336, ly: 62 },
+ { x: 362, y: 40, w: 44, h: 34, key: '-L', lx: 384, ly: 62 },
+ { x: 410, y: 40, w: 44, h: 34, key: '-T', lx: 432, ly: 62 },
+ { x: 458, y: 40, w: 44, h: 34, key: '-D', lx: 480, ly: 62 },
+ { x: 58, y: 78, w: 44, h: 34, key: 'K-', lx: 80, ly: 100 },
+ { x: 106, y: 78, w: 44, h: 34, key: 'W-', lx: 128, ly: 100 },
+ { x: 154, y: 78, w: 44, h: 34, key: 'R-', lx: 176, ly: 100 },
+ { x: 266, y: 78, w: 44, h: 34, key: '-R', lx: 288, ly: 100 },
+ { x: 314, y: 78, w: 44, h: 34, key: '-B', lx: 336, ly: 100 },
+ { x: 362, y: 78, w: 44, h: 34, key: '-G', lx: 384, ly: 100 },
+ { x: 410, y: 78, w: 44, h: 34, key: '-S', lx: 432, ly: 100 },
+ { x: 458, y: 78, w: 44, h: 34, key: '-Z', lx: 480, ly: 100 },
+ { x: 130, y: 120, w: 52, h: 34, key: 'A', lx: 156, ly: 142 },
+ { x: 186, y: 120, w: 52, h: 34, key: 'O', lx: 212, ly: 142 },
+ { x: 282, y: 120, w: 52, h: 34, key: 'E', lx: 308, ly: 142 },
+ { x: 338, y: 120, w: 52, h: 34, key: 'U', lx: 364, ly: 142 },
+ ];
+ const labelMap = {
+ '#': '#', 'S-': 'S', 'T-': 'T', 'P-': 'P', 'H-': 'H', '*': '*',
+ '-F': 'F', '-P': 'P', '-L': 'L', '-T': 'T', '-D': 'D',
+ 'K-': 'K', 'W-': 'W', 'R-': 'R',
+ '-R': 'R', '-B': 'B', '-G': 'G', '-S': 'S', '-Z': 'Z',
+ 'A': 'A', 'O': 'O', 'E': 'E', 'U': 'U',
+ };
+ keys.forEach(k => {
+ const rect = document.createElementNS(ns, 'rect');
+ rect.setAttribute('x', k.x);
+ rect.setAttribute('y', k.y);
+ rect.setAttribute('width', k.w);
+ rect.setAttribute('height', k.h);
+ rect.setAttribute('rx', '4');
+ rect.setAttribute('class', 'steno-key');
+ rect.setAttribute('data-key', k.key);
+ svg.appendChild(rect);
+ const text = document.createElementNS(ns, 'text');
+ text.setAttribute('x', k.lx);
+ text.setAttribute('y', k.ly);
+ text.setAttribute('class', 'steno-key-label' + (k.small ? ' steno-key-label--small' : ''));
+ text.textContent = labelMap[k.key] || k.key;
+ svg.appendChild(text);
+ });
+ return svg;
+ }
+
+ _highlightKeysOn(svgEl, keys) {
+ if (!keys || !svgEl) return;
+ keys.forEach(key => {
+ const el = svgEl.querySelector('.steno-key[data-key="' + CSS.escape(key) + '"]');
+ if (el) {
+ el.classList.add('active');
+ const nextText = el.nextElementSibling;
+ if (nextText && nextText.tagName === 'text') {
+ nextText.style.fill = getCSSVar('--text-on-color');
+ }
+ }
+ });
+ }
+
+ _updateStats() {
+ const elapsed = performance.now() - (this.sessionStartTime || performance.now());
+ this.els.statTime.textContent = formatTime(Math.round(elapsed));
+
+ const accuracy = this.stats.attempted > 0
+ ? (this.stats.correct / this.stats.attempted) * 100
+ : 0;
+ this.els.statAccuracy.textContent = formatAccuracy(accuracy);
+ this.els.statStreak.textContent = this.stats.streak;
+
+ const avgWpm = this.stats.totalTimeMs > 0 && this.stats.attempted > 0
+ ? (this.stats.attempted / (this.stats.totalTimeMs / 60000))
+ : 0;
+ this.els.statWpm.textContent = formatWpm(avgWpm) + ' WPM';
+
+ this.els.statProgress.textContent = this.wordIndex + '/' + this.words.length;
+ }
+
+ async end() {
+ if (this.ended) return;
+ this.ended = true;
+ if (this.timerInterval) {
+ clearInterval(this.timerInterval);
+ this.timerInterval = null;
+ }
+
+ try {
+ const resp = await fetch('/api/session/end', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ session_id: this.sessionId }),
+ });
+ await resp.json();
+ } catch (err) {}
+
+ this._showSummary();
+ }
+
+ _showSummary() {
+ const accuracy = this.stats.attempted > 0
+ ? (this.stats.correct / this.stats.attempted) * 100
+ : 0;
+ const avgWpm = this.stats.totalTimeMs > 0 && this.stats.attempted > 0
+ ? (this.stats.attempted / (this.stats.totalTimeMs / 60000))
+ : 0;
+
+ const accClass = accuracyClass(accuracy);
+ this.els.summaryAccuracy.textContent = formatAccuracy(accuracy);
+ this.els.summaryAccuracy.className = 'summary-accuracy summary-accuracy--' + accClass;
+
+ this.els.summaryWords.textContent = this.stats.correct + ' / ' + this.stats.attempted;
+ this.els.summaryWpm.textContent = formatWpm(avgWpm);
+ this.els.summaryStreak.textContent = this.stats.bestStreak;
+
+ const errors = this.stats.results.filter(r => !r.correct);
+ const slowest = this.stats.results
+ .filter(r => r.correct)
+ .sort((a, b) => b.timeMs - a.timeMs)
+ .slice(0, 5);
+
+ if (errors.length > 0) {
+ let html = '<div class="summary-weak-words__title">Errors (' + errors.length + ')</div>';
+ html += '<div class="summary-errors__list">';
+ errors.slice(0, 15).forEach(w => {
+ html += '<div class="error-detail">';
+ html += '<div class="error-detail__words">';
+ html += '<span class="error-detail__expected">' + escapeHtml(w.word) + '</span>';
+ html += '<span class="error-detail__arrow">→</span>';
+ html += '<span class="error-detail__typed">' + escapeHtml(w.typed || '(empty)') + '</span>';
+ html += '</div>';
+ if (w.strokes) {
+ html += '<span class="error-detail__stroke">' + escapeHtml(w.strokes) + '</span>';
+ }
+ html += '</div>';
+ });
+ html += '</div>';
+ if (slowest.length > 0) {
+ html += '<div class="summary-weak-words__title" style="margin-top:1rem;">Slowest Correct</div>';
+ html += '<div class="summary-weak-words__list">';
+ slowest.forEach(w => {
+ html += '<span class="weak-word-chip">';
+ html += '<span class="weak-word-chip__word">' + escapeHtml(w.word) + '</span>';
+ html += '<span class="weak-word-chip__stroke">' + formatTime(w.timeMs) + '</span>';
+ html += '</span>';
+ });
+ html += '</div>';
+ }
+ this.els.summaryWeakWords.innerHTML = html;
+ } else {
+ this.els.summaryWeakWords.innerHTML = '<p style="text-align:center;color:var(--success);font-weight:600;">Perfect session!</p>';
+ }
+
+ this.els.summary.style.display = 'flex';
+
+ if (typeof gsap !== 'undefined') {
+ const stl = gsap.timeline({ defaults: { clearProps: 'all' } });
+ stl.from('.modal', { y: 30, scale: 0.95, opacity: 0, duration: 0.35, ease: 'back.out(1.2)' })
+ .from('.summary-accuracy', { scale: 0.5, opacity: 0, duration: 0.5, ease: 'back.out(1.5)' }, '-=0.1')
+ .from('.summary-stats .summary-stat', { y: 15, opacity: 0, duration: 0.3, stagger: 0.08, ease: 'power2.out' }, '-=0.2');
+ }
+ }
+}
+
+
+/* ===== STATS PAGE ===== */
+
+async function loadStats() {
+ let overview, daily, heatmap, words, sessions;
+
+ try {
+ const results = await Promise.allSettled([
+ fetch('/api/stats/overview').then(r => r.json()),
+ fetch('/api/stats/daily?days=30').then(r => r.json()),
+ fetch('/api/stats/heatmap').then(r => r.json()),
+ fetch('/api/stats/words?sort=accuracy&order=asc&limit=50').then(r => r.json()),
+ fetch('/api/stats/sessions?limit=20').then(r => r.json())
+ ]);
+
+ overview = results[0].status === 'fulfilled' ? results[0].value : null;
+ daily = results[1].status === 'fulfilled' ? results[1].value : null;
+ heatmap = results[2].status === 'fulfilled' ? results[2].value : null;
+ words = results[3].status === 'fulfilled' ? results[3].value : null;
+ sessions = results[4].status === 'fulfilled' ? results[4].value : null;
+ } catch (err) {
+ console.error('Failed to load stats:', err);
+ return;
+ }
+
+ if (overview) renderOverview(overview);
+ if (daily) {
+ renderWpmChart(daily);
+ renderDailyChart(daily);
+ }
+ if (heatmap) renderHeatmap(heatmap);
+ if (words) renderWordTable(words);
+ if (sessions) renderSessionTable(sessions);
+}
+
+function renderOverview(data) {
+ const setEl = (id, val) => {
+ const el = document.getElementById(id);
+ if (el) el.textContent = val;
+ };
+
+ setEl('overview-sessions', data.total_sessions || 0);
+ setEl('overview-words', data.total_words || 0);
+ setEl('overview-mastered', data.words_mastered || 0);
+ setEl('overview-accuracy', formatAccuracy(data.avg_accuracy || 0));
+ setEl('overview-wpm', formatWpm(data.avg_wpm || 0));
+ setEl('overview-time', formatDuration(data.total_time_seconds || 0));
+ setEl('overview-streak', data.best_streak || 0);
+}
+
+function renderWpmChart(daily) {
+ const ctx = document.getElementById('wpm-chart');
+ if (!ctx) return;
+
+ const labels = daily.map(d => d.date);
+ const wpmData = daily.map(d => d.avg_wpm || 0);
+ const accData = daily.map(d => d.accuracy || 0);
+
+ new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: 'WPM',
+ data: wpmData,
+ borderColor: getCSSVar('--chart-blue'),
+ backgroundColor: getCSSVar('--accent-glow'),
+ borderWidth: 2,
+ fill: true,
+ tension: 0.3,
+ yAxisID: 'y',
+ pointRadius: 3,
+ pointBackgroundColor: getCSSVar('--chart-blue')
+ },
+ {
+ label: 'Accuracy %',
+ data: accData,
+ borderColor: getCSSVar('--chart-green'),
+ backgroundColor: getCSSVar('--success-glow'),
+ borderWidth: 2,
+ fill: false,
+ tension: 0.3,
+ yAxisID: 'y1',
+ pointRadius: 3,
+ pointBackgroundColor: getCSSVar('--chart-green'),
+ borderDash: [5, 5]
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { mode: 'index', intersect: false },
+ plugins: { legend: { labels: { color: getCSSVar('--text-secondary'), font: { size: 12 } } } },
+ scales: {
+ x: { ticks: { color: getCSSVar('--text-muted'), maxRotation: 45, font: { size: 10 } }, grid: { color: getCSSVar('--border-light') } },
+ y: { type: 'linear', position: 'left', title: { display: true, text: 'WPM', color: getCSSVar('--chart-blue') }, ticks: { color: getCSSVar('--chart-blue') }, grid: { color: getCSSVar('--border-light') }, beginAtZero: true },
+ y1: { type: 'linear', position: 'right', title: { display: true, text: 'Accuracy %', color: getCSSVar('--chart-green') }, ticks: { color: getCSSVar('--chart-green') }, grid: { drawOnChartArea: false }, min: 0, max: 100 }
+ }
+ }
+ });
+}
+
+function renderDailyChart(daily) {
+ const ctx = document.getElementById('daily-chart');
+ if (!ctx) return;
+
+ const labels = daily.map(d => d.date);
+ const correctData = daily.map(d => d.words_correct || 0);
+ const incorrectData = daily.map(d => (d.words_total || 0) - (d.words_correct || 0));
+
+ new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: labels,
+ datasets: [
+ { label: 'Correct', data: correctData, backgroundColor: getCSSVar('--chart-green'), borderRadius: 3 },
+ { label: 'Incorrect', data: incorrectData, backgroundColor: getCSSVar('--chart-red'), borderRadius: 3 }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { labels: { color: getCSSVar('--text-secondary'), font: { size: 12 } } } },
+ scales: {
+ x: { stacked: true, ticks: { color: getCSSVar('--text-muted'), maxRotation: 45, font: { size: 10 } }, grid: { color: getCSSVar('--border-light') } },
+ y: { stacked: true, ticks: { color: getCSSVar('--text-secondary') }, grid: { color: getCSSVar('--border-light') }, beginAtZero: true, title: { display: true, text: 'Words', color: getCSSVar('--text-secondary') } }
+ }
+ }
+ });
+}
+
+function renderHeatmap(data) {
+ const container = document.getElementById('heatmap-container');
+ if (!container) return;
+
+ const days = Array.isArray(data) ? data : (data.days || []);
+ const countMap = {};
+ let maxCount = 1;
+ days.forEach(d => {
+ countMap[d.date] = d.count || 0;
+ if (d.count > maxCount) maxCount = d.count;
+ });
+
+ const today = new Date();
+ const oneDay = 86400000;
+ const startDate = new Date(today.getTime() - 364 * oneDay);
+ const startDow = startDate.getDay();
+ const alignedStart = new Date(startDate.getTime() - startDow * oneDay);
+ const totalDays = Math.ceil((today.getTime() - alignedStart.getTime()) / oneDay) + 1;
+ const totalWeeks = Math.ceil(totalDays / 7);
+
+ const months = [];
+ let lastMonth = -1;
+ for (let w = 0; w < totalWeeks; w++) {
+ const d = new Date(alignedStart.getTime() + w * 7 * oneDay);
+ const m = d.getMonth();
+ if (m !== lastMonth) {
+ lastMonth = m;
+ months.push({ week: w, label: d.toLocaleDateString('en-US', { month: 'short' }) });
+ }
+ }
+
+ let html = '<div class="heatmap-months" style="padding-left:0;">';
+ months.forEach((m, i) => {
+ const nextWeek = (i + 1 < months.length) ? months[i + 1].week : totalWeeks;
+ const colSpan = nextWeek - m.week;
+ html += '<span class="heatmap-month" style="width:' + (colSpan * 17) + 'px;">' + escapeHtml(m.label) + '</span>';
+ });
+ html += '</div><div class="heatmap">';
+
+ for (let w = 0; w < totalWeeks; w++) {
+ for (let d = 0; d < 7; d++) {
+ const date = new Date(alignedStart.getTime() + (w * 7 + d) * oneDay);
+ const dateStr = date.toISOString().split('T')[0];
+ const count = countMap[dateStr] || 0;
+ let level = 0;
+ if (count > 0) {
+ const ratio = count / maxCount;
+ if (ratio <= 0.25) level = 1;
+ else if (ratio <= 0.5) level = 2;
+ else if (ratio <= 0.75) level = 3;
+ else level = 4;
+ }
+ const isFuture = date > today;
+ html += '<div class="heatmap-cell' + (isFuture ? ' heatmap-cell--future' : '') + '" data-level="' + (isFuture ? 0 : level) + '" title="' + escapeHtml(dateStr + ': ' + count + ' words') + '"></div>';
+ }
+ }
+ html += '</div>';
+ html += '<div class="heatmap-legend"><span>Less</span>';
+ for (let i = 0; i <= 4; i++) {
+ html += '<div class="heatmap-legend__cell heatmap-cell" data-level="' + i + '" style="display:inline-block;"></div>';
+ }
+ html += '<span>More</span></div>';
+ container.innerHTML = html;
+}
+
+function renderWordTable(data) {
+ const tbody = document.getElementById('word-table-body');
+ if (!tbody) return;
+ const words = Array.isArray(data) ? data : (data.words || []);
+ if (words.length === 0) {
+ tbody.innerHTML = '<tr><td colspan="5" class="table-empty">No word data yet. Start practicing!</td></tr>';
+ return;
+ }
+ let html = '';
+ words.forEach(w => {
+ const accCls = accuracyClass(w.accuracy || 0);
+ html += '<tr><td><strong>' + escapeHtml(w.word) + '</strong></td>';
+ html += '<td class="steno-stroke">' + escapeHtml(w.strokes || '--') + '</td>';
+ html += '<td>' + (w.attempts || 0) + '</td>';
+ html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(w.accuracy || 0) + '</td>';
+ html += '<td>' + formatTime(w.avg_time || 0) + '</td></tr>';
+ });
+ tbody.innerHTML = html;
+ _setupTableSort('word-table', words);
+}
+
+function _setupTableSort(tableId, data) {
+ const table = document.getElementById(tableId);
+ if (!table) return;
+ const headers = table.querySelectorAll('th[data-sort]');
+ let currentSort = null;
+ let currentOrder = 'asc';
+
+ headers.forEach(th => {
+ th.addEventListener('click', () => {
+ const field = th.dataset.sort;
+ if (currentSort === field) {
+ currentOrder = currentOrder === 'asc' ? 'desc' : 'asc';
+ } else {
+ currentSort = field;
+ currentOrder = 'asc';
+ }
+ const sorted = [...data].sort((a, b) => {
+ let va = a[field], vb = b[field];
+ if (typeof va === 'string') va = va.toLowerCase();
+ if (typeof vb === 'string') vb = vb.toLowerCase();
+ if (va < vb) return currentOrder === 'asc' ? -1 : 1;
+ if (va > vb) return currentOrder === 'asc' ? 1 : -1;
+ return 0;
+ });
+ const tbody = table.querySelector('tbody');
+ let html = '';
+ sorted.forEach(w => {
+ const accCls = accuracyClass(w.accuracy || 0);
+ html += '<tr><td><strong>' + escapeHtml(w.word) + '</strong></td>';
+ html += '<td class="steno-stroke">' + escapeHtml(w.strokes || '--') + '</td>';
+ html += '<td>' + (w.attempts || 0) + '</td>';
+ html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(w.accuracy || 0) + '</td>';
+ html += '<td>' + formatTime(w.avg_time || 0) + '</td></tr>';
+ });
+ tbody.innerHTML = html;
+ });
+ });
+}
+
+function renderSessionTable(data) {
+ const tbody = document.getElementById('session-table-body');
+ if (!tbody) return;
+ const sessions = Array.isArray(data) ? data : (data.sessions || []);
+ if (sessions.length === 0) {
+ tbody.innerHTML = '<tr><td colspan="6" class="table-empty">No sessions yet.</td></tr>';
+ return;
+ }
+ let html = '';
+ sessions.forEach(s => {
+ const accCls = accuracyClass(s.accuracy || 0);
+ html += '<tr>';
+ html += '<td>' + escapeHtml(s.date || s.date_str || '--') + '</td>';
+ html += '<td><span class="badge badge--' + escapeHtml(s.mode || '') + '">' + escapeHtml(s.mode || '--') + '</span></td>';
+ html += '<td>' + (s.words_correct || 0) + ' / ' + (s.words_attempted || 0) + '</td>';
+ html += '<td class="accuracy-cell accuracy--' + accCls + '">' + formatAccuracy(s.accuracy || 0) + '</td>';
+ html += '<td>' + formatWpm(s.wpm || 0) + '</td>';
+ html += '<td>' + formatDuration(s.duration_seconds || 0) + '</td>';
+ html += '</tr>';
+ });
+ tbody.innerHTML = html;
+}
+
+
+/* ===== PAGE INITIALIZATION ===== */
+
+document.addEventListener('DOMContentLoaded', () => {
+ const practiceEl = document.getElementById('practice-container');
+ if (practiceEl) {
+ const mode = practiceEl.dataset.mode;
+ const lessonId = practiceEl.dataset.lesson || null;
+ new PracticeSession(mode, lessonId);
+ }
+
+ const statsEl = document.getElementById('stats-container');
+ if (statsEl) {
+ loadStats();
+ }
+
+ // Mobile nav hamburger
+ const hamburger = document.getElementById('nav-hamburger');
+ const navLinks = document.getElementById('nav-links');
+ if (hamburger && navLinks) {
+ hamburger.addEventListener('click', () => {
+ navLinks.classList.toggle('open');
+ hamburger.classList.toggle('active');
+ });
+ navLinks.querySelectorAll('.nav-link').forEach(link => {
+ link.addEventListener('click', () => {
+ navLinks.classList.remove('open');
+ hamburger.classList.remove('active');
+ });
+ });
+ }
+
+ // Theme toggle
+ const themeToggle = document.getElementById('theme-toggle');
+ const themeIcon = document.getElementById('theme-icon');
+ function updateThemeIcon() {
+ if (!themeIcon) return;
+ const t = document.documentElement.getAttribute('data-theme') || 'dark';
+ themeIcon.textContent = t === 'dark' ? '☀' : '☽';
+ }
+ if (themeToggle) {
+ updateThemeIcon();
+ themeToggle.addEventListener('click', () => {
+ const cur = document.documentElement.getAttribute('data-theme') || 'dark';
+ const next = cur === 'dark' ? 'light' : 'dark';
+ document.documentElement.setAttribute('data-theme', next);
+ setCookie('theme', next, 365);
+ updateThemeIcon();
+ });
+ }
+
+ // GSAP page entrance animations
+ if (typeof gsap !== 'undefined') {
+ gsap.from('.nav-brand', { x: -10, opacity: 0, duration: 0.3, ease: 'power2.out', clearProps: 'all' });
+ gsap.from('.nav-links li', { y: -8, opacity: 0, duration: 0.25, stagger: 0.05, delay: 0.1, ease: 'power2.out', clearProps: 'all' });
+
+ if (document.querySelector('.dashboard')) {
+ const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
+ tl.from('.hero-stats .stat-card', { y: 10, opacity: 0, duration: 0.35, stagger: 0.03 }, 0)
+ .from('.dashboard .section-title', { opacity: 0, duration: 0.35 }, 0)
+ .from('.lesson-grid', { opacity: 0, duration: 0.35 }, 0)
+ .from('.lesson-card', { y: 6, opacity: 0, duration: 0.35, stagger: 0.02 }, 0)
+ .from('.mode-grid', { opacity: 0, duration: 0.35 }, 0)
+ .from('.mode-card', { y: 6, opacity: 0, duration: 0.35, stagger: 0.02 }, 0)
+ .from('.today-progress, .recent-sessions', { y: 6, opacity: 0, duration: 0.35 }, 0);
+ }
+
+ if (document.querySelector('.stats-page')) {
+ const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
+ tl.from('.stats-page .page-title', { y: -10, opacity: 0, duration: 0.35 })
+ .from('.stats-overview .stat-card', { y: 20, opacity: 0, duration: 0.35, stagger: 0.06 }, '-=0.15')
+ .from('.stats-section', { y: 25, opacity: 0, duration: 0.4, stagger: 0.1 }, '-=0.1');
+ }
+
+ if (document.querySelector('.settings-page')) {
+ const tl = gsap.timeline({ defaults: { ease: 'power2.out', clearProps: 'all' } });
+ tl.from('.settings-page .page-title', { y: -10, opacity: 0, duration: 0.35 })
+ .from('.settings-section', { y: 20, opacity: 0, duration: 0.35, stagger: 0.1 }, '-=0.15');
+ }
+ }
+});
diff --git a/templates/base.html b/templates/base.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="dark">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{% block title %}StenoDojo{% 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=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
+ <link rel="stylesheet" href="/static/css/style.css?v={{ cache_bust }}">
+ <script>
+ (function(){var m=document.cookie.match(/(?:^| )theme=([^;]+)/);document.documentElement.setAttribute('data-theme',m?m[1]:'dark')})();
+ </script>
+ <script src="/static/js/app.js?v={{ cache_bust }}" defer></script>
+</head>
+<body>
+ <nav class="navbar">
+ <div class="nav-inner">
+ <a href="/" class="nav-brand">
+ <svg class="nav-logo" viewBox="0 0 24 24" fill="none">
+ <rect x="1" y="3" width="9" height="11" rx="2.5" fill="currentColor" opacity="0.6"/>
+ <rect x="14" y="3" width="9" height="11" rx="2.5" fill="currentColor"/>
+ <rect x="5" y="16" width="14" height="6" rx="2.5" fill="currentColor" opacity="0.8"/>
+ </svg>
+ StenoDojo
+ </a>
+ <button class="nav-hamburger" id="nav-hamburger" aria-label="Toggle menu">
+ <span></span><span></span><span></span>
+ </button>
+ <ul class="nav-links" id="nav-links">
+ <li><a href="/" class="nav-link">Dashboard</a></li>
+ <li><a href="/practice?mode=common" class="nav-link">Practice</a></li>
+ <li><a href="/stats" class="nav-link">Stats</a></li>
+ <li><a href="/settings" class="nav-link">Settings</a></li>
+ <li>
+ <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
+ <span id="theme-icon"></span>
+ </button>
+ </li>
+ {% if current_user %}
+ <li class="nav-user">
+ {% if current_user.avatar_url %}
+ <img src="{{ current_user.avatar_url }}" alt="" class="nav-avatar">
+ {% endif %}
+ <a href="/logout" class="nav-link nav-link--logout">Logout</a>
+ </li>
+ {% endif %}
+ </ul>
+ </div>
+ </nav>
+
+ <main class="main-content">
+ {% block content %}{% endblock %}
+ </main>
+
+ {% block scripts %}{% endblock %}
+</body>
+</html>
diff --git a/templates/dashboard.html b/templates/dashboard.html
@@ -0,0 +1,174 @@
+{% extends "base.html" %}
+
+{% block title %}Dashboard - StenoDojo{% endblock %}
+
+{% block content %}
+<div class="dashboard">
+ {% if not has_dicts %}
+ <div class="dict-banner">
+ <div class="dict-banner__icon">
+ <svg viewBox="0 0 24 24" fill="none" width="28" height="28">
+ <rect x="1" y="3" width="9" height="11" rx="2.5" fill="currentColor" opacity="0.6"/>
+ <rect x="14" y="3" width="9" height="11" rx="2.5" fill="currentColor"/>
+ <rect x="5" y="16" width="14" height="6" rx="2.5" fill="currentColor" opacity="0.8"/>
+ </svg>
+ </div>
+ <div class="dict-banner__text">
+ <strong>No dictionaries loaded.</strong> Upload your Plover config in <a href="/settings">Settings</a> to get stroke hints and unlock all practice modes.
+ </div>
+ </div>
+ {% endif %}
+ <section class="hero-stats">
+ <div class="stat-card stat-card--streak">
+ <div class="stat-card__icon streak-fire"></div>
+ <div class="stat-card__value">{{ streak }}</div>
+ <div class="stat-card__label">Day Streak</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__icon stat-icon--words"></div>
+ <div class="stat-card__value">{{ today.words }}</div>
+ <div class="stat-card__label">Words Today
+ {% if today.words > 0 %}
+ <span class="stat-card__sub">{{ today.accuracy | round(0) | int }}% accuracy</span>
+ {% endif %}
+ </div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__icon stat-icon--mastered"></div>
+ <div class="stat-card__value">{{ words_mastered }}</div>
+ <div class="stat-card__label">Words Mastered</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__icon stat-icon--review"></div>
+ <div class="stat-card__value">{{ review_due }}</div>
+ <div class="stat-card__label">Review Due
+ {% if review_due > 0 %}
+ <a href="/practice?mode=review" class="stat-card__action">Start Review</a>
+ {% endif %}
+ </div>
+ </div>
+ </section>
+
+ <section>
+ <h2 class="section-title">Guided Lessons</h2>
+ <div class="lesson-grid">
+ {% for lesson in lessons %}
+ <a href="/practice?mode=lesson&lesson={{ lesson.id }}" class="lesson-card lesson-card--{{ lesson.level }}">
+ <div class="lesson-card__order">{{ lesson.order }}</div>
+ <div class="lesson-card__info">
+ <div class="lesson-card__title">{{ lesson.title }}</div>
+ <div class="lesson-card__subtitle">{{ lesson.subtitle }}</div>
+ {% if lesson.progress %}
+ {% set prog = lesson.progress %}
+ {% set pct = (prog.mastered / prog.total_words * 100) | round(0) | int if prog.total_words > 0 else 0 %}
+ <div class="lesson-progress">
+ <div class="lesson-progress__bar">
+ <div class="lesson-progress__fill lesson-progress__fill--practiced" style="width: {{ (prog.practiced / prog.total_words * 100) | round(0) | int if prog.total_words > 0 else 0 }}%"></div>
+ <div class="lesson-progress__fill lesson-progress__fill--mastered" style="width: {{ pct }}%"></div>
+ </div>
+ <div class="lesson-progress__text">
+ <span>{{ prog.mastered }}/{{ prog.total_words }} mastered</span>
+ {% if prog.avg_wpm > 0 %}
+ <span>{{ prog.avg_wpm }} WPM</span>
+ {% endif %}
+ </div>
+ </div>
+ {% endif %}
+ </div>
+ <div class="lesson-card__level badge badge--{{ lesson.level }}">{{ lesson.level }}</div>
+ </a>
+ {% endfor %}
+ </div>
+ </section>
+
+ <section class="quick-start">
+ <h2 class="section-title">Free Practice</h2>
+ <div class="mode-grid">
+ <a href="/practice?mode=common" class="mode-card">
+ <div class="mode-card__icon mode-icon--common"></div>
+ <div class="mode-card__title">Common Words</div>
+ <div class="mode-card__desc">Most frequent words</div>
+ </a>
+ <a href="/practice?mode=speed" class="mode-card">
+ <div class="mode-card__icon mode-icon--speed"></div>
+ <div class="mode-card__title">Speed Drill</div>
+ <div class="mode-card__desc">Single-stroke words</div>
+ </a>
+ <a href="/practice?mode=phrases" class="mode-card">
+ <div class="mode-card__icon mode-icon--phrases"></div>
+ <div class="mode-card__title">Phrases</div>
+ <div class="mode-card__desc">Multi-word phrases</div>
+ </a>
+ <a href="/practice?mode=sentences" class="mode-card">
+ <div class="mode-card__icon mode-icon--sentences"></div>
+ <div class="mode-card__title">Sentences</div>
+ <div class="mode-card__desc">Full sentences</div>
+ </a>
+ <a href="/practice?mode=new" class="mode-card">
+ <div class="mode-card__icon mode-icon--new"></div>
+ <div class="mode-card__title">New Words</div>
+ <div class="mode-card__desc">Words you haven't seen</div>
+ </a>
+ <a href="/practice?mode=weak" class="mode-card">
+ <div class="mode-card__icon mode-icon--weak"></div>
+ <div class="mode-card__title">Weak Words</div>
+ <div class="mode-card__desc">Your trouble spots</div>
+ </a>
+ <a href="/practice?mode=review" class="mode-card">
+ <div class="mode-card__icon mode-icon--review"></div>
+ <div class="mode-card__title">Review</div>
+ <div class="mode-card__desc">Spaced repetition</div>
+ </a>
+ <a href="/practice?mode=random" class="mode-card">
+ <div class="mode-card__icon mode-icon--random"></div>
+ <div class="mode-card__title">Random</div>
+ <div class="mode-card__desc">Mix of all words</div>
+ </a>
+ </div>
+ </section>
+
+ <section class="today-progress">
+ <h2 class="section-title">Today's Progress</h2>
+ <div class="progress-container">
+ <div class="progress-bar">
+ <div class="progress-bar__fill" style="width: {{ [today.words / 100 * 100, 100] | min }}%"></div>
+ </div>
+ <div class="progress-bar__label">{{ today.words }} / 100 words</div>
+ </div>
+ </section>
+
+ <section class="recent-sessions">
+ <h2 class="section-title">Recent Sessions</h2>
+ {% if recent_sessions %}
+ <div class="table-wrapper">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>Mode</th>
+ <th>Date</th>
+ <th>Words</th>
+ <th>Accuracy</th>
+ <th>WPM</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for session in recent_sessions[:5] %}
+ <tr>
+ <td><span class="badge badge--{{ session.mode }}">{{ session.mode }}</span></td>
+ <td>{{ session.date_str }}</td>
+ <td>{{ session.words_correct }} / {{ session.words_attempted }}</td>
+ <td class="accuracy-cell accuracy--{{ 'good' if session.accuracy >= 90 else ('mid' if session.accuracy >= 70 else 'low') }}">{{ session.accuracy | round(0) | int }}%</td>
+ <td>{{ session.wpm | round(1) }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ {% else %}
+ <div class="empty-state">
+ <p>No sessions yet. Start practicing to see your progress here.</p>
+ </div>
+ {% endif %}
+ </section>
+</div>
+{% endblock %}
diff --git a/templates/login.html b/templates/login.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="dark">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Sign In - StenoDojo</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=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
+ <link rel="stylesheet" href="/static/css/style.css?v={{ cache_bust }}">
+ <script>
+ (function(){var m=document.cookie.match(/(?:^| )theme=([^;]+)/);document.documentElement.setAttribute('data-theme',m?m[1]:'dark')})();
+ </script>
+</head>
+<body>
+ <div class="login-page">
+ <div class="login-card">
+ <div class="login-brand">
+ <svg class="login-logo" viewBox="0 0 24 24" fill="none">
+ <rect x="1" y="3" width="9" height="11" rx="2.5" fill="currentColor" opacity="0.6"/>
+ <rect x="14" y="3" width="9" height="11" rx="2.5" fill="currentColor"/>
+ <rect x="5" y="16" width="14" height="6" rx="2.5" fill="currentColor" opacity="0.8"/>
+ </svg>
+ <h1 class="login-title">StenoDojo</h1>
+ <p class="login-subtitle">Stenography Practice</p>
+ </div>
+
+ {% if error %}
+ <div class="login-error">{{ error }}</div>
+ {% endif %}
+
+ <a href="/login/google" class="btn btn--google">
+ <svg class="google-icon" viewBox="0 0 24 24" width="20" height="20">
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
+ <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
+ </svg>
+ Sign in with Google
+ </a>
+
+ <p class="login-footer">Your email must be verified by Google to sign in.</p>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/templates/partials/word_card.html b/templates/partials/word_card.html
@@ -0,0 +1,2 @@
+{# This partial is not used - practice is handled entirely via JS/fetch #}
+{# Kept as placeholder for potential future HTMX-based features #}
diff --git a/templates/practice.html b/templates/practice.html
@@ -0,0 +1,104 @@
+{% extends "base.html" %}
+
+{% block title %}{{ mode_label }} - StenoDojo{% endblock %}
+
+{% block content %}
+<div id="practice-container" data-mode="{{ mode }}" data-lesson="{{ lesson_id }}">
+ <div class="practice-header">
+ <a href="/" class="btn btn--ghost practice-back">← Dashboard</a>
+ <h1 class="practice-mode-label">{{ mode_label }}</h1>
+ <button id="end-session-btn" class="btn btn--danger practice-end">End</button>
+ </div>
+
+ <div id="loading" class="practice-loading">
+ {% if lesson_id %}
+ <div id="session-config" class="session-config">
+ <label class="session-config__label">Session Length</label>
+ <div class="session-config__options">
+ <button class="session-config__btn" data-count="5">Short (5)</button>
+ <button class="session-config__btn session-config__btn--active" data-count="10">Medium (10)</button>
+ <button class="session-config__btn" data-count="20">Long (20)</button>
+ </div>
+ <button id="start-session-btn" class="btn btn--primary session-config__start">Start Practice</button>
+ </div>
+ {% else %}
+ <div class="spinner"></div>
+ <p>Loading session...</p>
+ {% endif %}
+ </div>
+
+ <div id="practice-area" class="practice-area" style="display: none;">
+ <div class="practice-main">
+ <div class="practice-left">
+ <div id="typing-display" class="typing-display">
+ <div id="typing-words" class="typing-words"></div>
+ </div>
+ <input type="text" id="typing-input" class="typing-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
+ <div id="typed-text-display" class="typed-text-display"></div>
+
+ <div id="steno-hint" class="steno-hint">
+ <div id="steno-keyboards" class="steno-keyboards"></div>
+ </div>
+ </div>
+
+ <div id="hints-panel" class="hints-panel">
+ <div id="phrase-hints" class="phrase-hints" style="display: none;">
+ <div class="phrase-hints__header">Phrase Shortcuts</div>
+ <div id="phrase-hints-list" class="phrase-hints-list"></div>
+ </div>
+ <div class="hints-panel__header">Strokes</div>
+ <div id="hints-list" class="hints-list"></div>
+ </div>
+ </div>
+
+ <div id="session-stats" class="session-stats">
+ <div class="session-stat">
+ <span class="session-stat__icon session-stat__icon--time"></span>
+ <span id="stat-time" class="session-stat__value">0.0s</span>
+ </div>
+ <div class="session-stat">
+ <span class="session-stat__icon session-stat__icon--accuracy"></span>
+ <span id="stat-accuracy" class="session-stat__value">0%</span>
+ </div>
+ <div class="session-stat">
+ <span class="session-stat__icon session-stat__icon--streak"></span>
+ <span id="stat-streak" class="session-stat__value">0</span>
+ </div>
+ <div class="session-stat">
+ <span class="session-stat__icon session-stat__icon--wpm"></span>
+ <span id="stat-wpm" class="session-stat__value">0 WPM</span>
+ </div>
+ <div class="session-stat">
+ <span class="session-stat__icon session-stat__icon--progress"></span>
+ <span id="stat-progress" class="session-stat__value">0/0</span>
+ </div>
+ </div>
+ </div>
+
+ <div id="session-summary" class="modal-overlay" style="display: none;">
+ <div class="modal">
+ <h2 class="modal__title">Session Complete</h2>
+ <div id="summary-accuracy" class="summary-accuracy"></div>
+ <div class="summary-stats">
+ <div class="summary-stat">
+ <span class="summary-stat__label">Words</span>
+ <span id="summary-words" class="summary-stat__value"></span>
+ </div>
+ <div class="summary-stat">
+ <span class="summary-stat__label">Avg WPM</span>
+ <span id="summary-wpm" class="summary-stat__value"></span>
+ </div>
+ <div class="summary-stat">
+ <span class="summary-stat__label">Best Streak</span>
+ <span id="summary-streak" class="summary-stat__value"></span>
+ </div>
+ </div>
+ <div id="summary-weak-words" class="summary-weak-words"></div>
+ <div class="modal__actions">
+ <a href="/practice?mode={{ mode }}{% if lesson_id %}&lesson={{ lesson_id }}{% endif %}" class="btn btn--primary">Practice Again</a>
+ <a href="/" class="btn btn--ghost">Back to Dashboard</a>
+ </div>
+ </div>
+ </div>
+</div>
+{% endblock %}
diff --git a/templates/settings.html b/templates/settings.html
@@ -0,0 +1,295 @@
+{% extends "base.html" %}
+
+{% block title %}Settings - StenoDojo{% endblock %}
+
+{% block content %}
+<div id="settings-container" class="settings-page">
+ <h1 class="page-title">Settings</h1>
+
+ <div class="settings-section">
+ <h2 class="section-title">Dictionaries</h2>
+ <p class="setting-help" style="margin-bottom: 1rem;">Select your Plover config directory — only plover.cfg and referenced dictionaries will be uploaded.</p>
+
+ <div class="dict-upload-row">
+ <div class="dict-upload-group">
+ <label class="btn btn--primary dict-upload-btn">
+ Select Plover Config Directory
+ <input type="file" id="folder-upload" webkitdirectory hidden>
+ </label>
+ </div>
+ </div>
+
+ <div id="upload-progress" class="upload-progress" style="display: none;">
+ <div class="spinner" style="width: 20px; height: 20px; border-width: 2px;"></div>
+ <span id="upload-progress-text">Uploading...</span>
+ </div>
+
+ <div id="dict-list" class="dict-list">
+ {% if dictionaries %}
+ {% for d in dictionaries %}
+ <div class="dict-item" data-id="{{ d.id }}">
+ <span class="dict-item__order">{{ d.dict_order + 1 }}</span>
+ <span class="dict-item__name">{{ d.display_name or d.filename }}</span>
+ <span class="dict-item__count">{{ d.entry_count }} entries</span>
+ <label class="dict-item__toggle">
+ <input type="checkbox" class="dict-toggle-cb" data-id="{{ d.id }}" {{ 'checked' if d.enabled else '' }}>
+ </label>
+ <button class="dict-item__delete btn btn--ghost btn--small" data-id="{{ d.id }}">✕</button>
+ </div>
+ {% endfor %}
+ {% else %}
+ <div class="dict-empty">No dictionaries configured. Select your Plover config directory to get started.</div>
+ {% endif %}
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h2 class="section-title">Practice</h2>
+ <div class="setting-row">
+ <label class="setting-label" for="words-per-session">Words Per Session</label>
+ <div class="setting-input-group">
+ <input type="number" id="words-per-session" class="setting-input setting-input--short" value="{{ settings.words_per_session }}" min="10" max="200">
+ <button id="save-words-count" class="btn btn--primary btn--small">Save</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="settings-section settings-section--danger">
+ <h2 class="section-title">Data Management</h2>
+ <div class="setting-row">
+ <div>
+ <div class="setting-label">Clear All Practice Data</div>
+ <p class="setting-help">This will permanently delete all session history, word stats, and progress. This cannot be undone.</p>
+ </div>
+ <button id="clear-data-btn" class="btn btn--danger">Clear All Data</button>
+ </div>
+ </div>
+
+ {% if current_user and not dev_mode %}
+ <div class="settings-section">
+ <h2 class="section-title">Account</h2>
+ <div class="setting-row">
+ <div>
+ <div class="setting-label">{{ current_user.email }}</div>
+ <p class="setting-help">Signed in as {{ current_user.name or current_user.email }}</p>
+ </div>
+ <a href="/logout" class="btn btn--ghost">Sign Out</a>
+ </div>
+ </div>
+ {% endif %}
+
+ <div id="settings-toast" class="settings-toast" style="display: none;"></div>
+</div>
+
+<script>
+document.addEventListener('DOMContentLoaded', () => {
+ const toast = document.getElementById('settings-toast');
+ const progressEl = document.getElementById('upload-progress');
+ const progressText = document.getElementById('upload-progress-text');
+
+ function showToast(msg, type) {
+ toast.textContent = msg;
+ toast.className = 'settings-toast settings-toast--' + type;
+ toast.style.display = 'block';
+ if (typeof gsap !== 'undefined') {
+ gsap.fromTo(toast, { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.3, ease: 'power2.out' });
+ setTimeout(() => {
+ gsap.to(toast, { y: 20, opacity: 0, duration: 0.25, onComplete: () => { toast.style.display = 'none'; } });
+ }, 3000);
+ } else {
+ setTimeout(() => { toast.style.display = 'none'; }, 3000);
+ }
+ }
+
+ function showProgress(msg) {
+ progressText.textContent = msg;
+ progressEl.style.display = 'flex';
+ }
+ function hideProgress() {
+ progressEl.style.display = 'none';
+ }
+
+ function renderDictList(dicts) {
+ const container = document.getElementById('dict-list');
+ if (!dicts || dicts.length === 0) {
+ container.innerHTML = '<div class="dict-empty">No dictionaries configured. Select your Plover config directory to get started.</div>';
+ return;
+ }
+ let html = '';
+ dicts.forEach(d => {
+ const statusClass = d.uploaded ? '' : ' dict-item--missing';
+ const statusLabel = d.uploaded ? d.entry_count + ' entries' : 'not uploaded';
+ html += '<div class="dict-item' + statusClass + '" data-id="' + d.id + '">';
+ html += '<span class="dict-item__order">' + (d.order + 1) + '</span>';
+ html += '<span class="dict-item__name">' + (d.display_name || d.filename) + '</span>';
+ html += '<span class="dict-item__count">' + statusLabel + '</span>';
+ html += '<label class="dict-item__toggle"><input type="checkbox" class="dict-toggle-cb" data-id="' + d.id + '"' + (d.enabled ? ' checked' : '') + '></label>';
+ html += '<button class="dict-item__delete btn btn--ghost btn--small" data-id="' + d.id + '">✕</button>';
+ html += '</div>';
+ });
+ container.innerHTML = html;
+ bindDictEvents();
+ }
+
+ function bindDictEvents() {
+ document.querySelectorAll('.dict-toggle-cb').forEach(cb => {
+ cb.addEventListener('change', async () => {
+ const id = cb.dataset.id;
+ try {
+ await fetch('/api/dictionaries/' + id + '/toggle', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({enabled: cb.checked})
+ });
+ showToast('Dictionary ' + (cb.checked ? 'enabled' : 'disabled') + '.', 'success');
+ } catch(e) {
+ showToast('Failed to toggle dictionary.', 'error');
+ }
+ });
+ });
+
+ document.querySelectorAll('.dict-item__delete').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ if (!confirm('Delete this dictionary?')) return;
+ const id = btn.dataset.id;
+ try {
+ await fetch('/api/dictionaries/' + id, { method: 'DELETE' });
+ btn.closest('.dict-item').remove();
+ showToast('Dictionary deleted.', 'success');
+ } catch(e) {
+ showToast('Failed to delete.', 'error');
+ }
+ });
+ });
+ }
+ bindDictEvents();
+
+ function parsePloverCfg(text) {
+ const match = text.match(/^\[System:\s*English Stenotype\]\s*$/m);
+ if (!match) return [];
+ const afterSection = text.slice(match.index + match[0].length);
+ const dictLine = afterSection.match(/^dictionaries\s*=\s*(.+)$/m);
+ if (!dictLine) return [];
+ try {
+ return JSON.parse(dictLine[1]);
+ } catch(e) {
+ return [];
+ }
+ }
+
+ // ── Folder upload with client-side filtering ──
+ document.getElementById('folder-upload').addEventListener('change', async (e) => {
+ const allFiles = e.target.files;
+ if (!allFiles || !allFiles.length) return;
+
+ let cfgFile = null;
+ let cfgPrefix = '';
+ const filesByPath = {};
+
+ for (let i = 0; i < allFiles.length; i++) {
+ const f = allFiles[i];
+ const path = f.webkitRelativePath || f.name;
+ filesByPath[path] = f;
+ if (f.name === 'plover.cfg') {
+ cfgFile = f;
+ cfgPrefix = path.slice(0, -'plover.cfg'.length);
+ }
+ }
+
+ if (!cfgFile) {
+ showToast('No plover.cfg found in that directory.', 'error');
+ e.target.value = '';
+ return;
+ }
+
+ showProgress('Reading config...');
+
+ const cfgText = await cfgFile.text();
+ const entries = parsePloverCfg(cfgText);
+
+ if (!entries.length) {
+ hideProgress();
+ showToast('No dictionaries found in plover.cfg.', 'error');
+ e.target.value = '';
+ return;
+ }
+
+ const form = new FormData();
+ form.append('config', cfgFile, 'plover.cfg');
+
+ let matched = 0;
+ for (const entry of entries) {
+ const dictPath = entry.path || '';
+ if (!dictPath) continue;
+
+ let file = filesByPath[cfgPrefix + dictPath];
+ if (!file) {
+ const basename = dictPath.split('/').pop().split('\\').pop();
+ for (const [fpath, fobj] of Object.entries(filesByPath)) {
+ if (fobj.name === basename) { file = fobj; break; }
+ }
+ }
+ if (file) {
+ form.append('dicts', file, file.webkitRelativePath || file.name);
+ matched++;
+ }
+ }
+
+ showProgress('Uploading plover.cfg + ' + matched + ' dictionaries...');
+
+ try {
+ const r = await fetch('/api/dictionaries/upload-folder', { method: 'POST', body: form });
+ const d = await r.json();
+ hideProgress();
+ if (d.ok) {
+ renderDictList(d.dictionaries);
+ if (d.missing > 0) {
+ showToast(d.saved + ' dictionaries loaded, ' + d.missing + ' referenced files not found.', 'success');
+ } else {
+ showToast('All ' + d.saved + ' dictionaries loaded!', 'success');
+ }
+ } else {
+ showToast(d.error || 'Upload failed.', 'error');
+ }
+ } catch(err) {
+ hideProgress();
+ showToast('Failed to upload.', 'error');
+ }
+ e.target.value = '';
+ });
+
+ document.getElementById('save-words-count').addEventListener('click', async () => {
+ const count = parseInt(document.getElementById('words-per-session').value);
+ if (isNaN(count) || count < 10 || count > 200) {
+ showToast('Must be between 10 and 200.', 'error');
+ return;
+ }
+ try {
+ const r = await fetch('/api/settings', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({words_per_session: count})
+ });
+ const d = await r.json();
+ if (d.ok) showToast('Words per session updated.', 'success');
+ } catch(e) {
+ showToast('Failed to save.', 'error');
+ }
+ });
+
+ document.getElementById('clear-data-btn').addEventListener('click', async () => {
+ if (!confirm('Are you sure you want to delete ALL practice data? This cannot be undone.')) return;
+ try {
+ const r = await fetch('/api/data/clear', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'}
+ });
+ const d = await r.json();
+ if (d.ok) showToast('All data cleared.', 'success');
+ } catch(e) {
+ showToast('Failed to clear data.', 'error');
+ }
+ });
+});
+</script>
+{% endblock %}
diff --git a/templates/stats.html b/templates/stats.html
@@ -0,0 +1,103 @@
+{% extends "base.html" %}
+
+{% block title %}Statistics - StenoDojo{% endblock %}
+
+{% block content %}
+<div id="stats-container" class="stats-page">
+ <h1 class="page-title">Statistics</h1>
+
+ <section id="stats-overview" class="stats-overview">
+ <div class="stat-card">
+ <div class="stat-card__value" id="overview-sessions">--</div>
+ <div class="stat-card__label">Total Sessions</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__value" id="overview-words">--</div>
+ <div class="stat-card__label">Words Practiced</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__value" id="overview-mastered">--</div>
+ <div class="stat-card__label">Words Mastered</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__value" id="overview-accuracy">--</div>
+ <div class="stat-card__label">Avg Accuracy</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__value" id="overview-wpm">--</div>
+ <div class="stat-card__label">Avg WPM</div>
+ </div>
+ <div class="stat-card">
+ <div class="stat-card__value" id="overview-time">--</div>
+ <div class="stat-card__label">Total Time</div>
+ </div>
+ <div class="stat-card stat-card--streak">
+ <div class="stat-card__icon streak-fire"></div>
+ <div class="stat-card__value" id="overview-streak">--</div>
+ <div class="stat-card__label">Best Streak</div>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2 class="section-title">WPM & Accuracy (Last 30 Days)</h2>
+ <div class="chart-container">
+ <canvas id="wpm-chart"></canvas>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2 class="section-title">Daily Activity (Last 30 Days)</h2>
+ <div class="chart-container">
+ <canvas id="daily-chart"></canvas>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2 class="section-title">Practice Heatmap</h2>
+ <div id="heatmap-container" class="heatmap-container">
+ <div class="heatmap-loading">Loading heatmap...</div>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2 class="section-title">Word Performance</h2>
+ <div class="table-wrapper">
+ <table id="word-table" class="table">
+ <thead>
+ <tr>
+ <th data-sort="word" class="sortable">Word</th>
+ <th data-sort="strokes" class="sortable">Strokes</th>
+ <th data-sort="attempts" class="sortable">Attempts</th>
+ <th data-sort="accuracy" class="sortable">Accuracy</th>
+ <th data-sort="avg_time" class="sortable">Avg Time</th>
+ </tr>
+ </thead>
+ <tbody id="word-table-body">
+ <tr><td colspan="5" class="table-empty">Loading...</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </section>
+
+ <section class="stats-section">
+ <h2 class="section-title">Session History</h2>
+ <div class="table-wrapper">
+ <table id="session-table" class="table">
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Mode</th>
+ <th>Words</th>
+ <th>Accuracy</th>
+ <th>WPM</th>
+ <th>Duration</th>
+ </tr>
+ </thead>
+ <tbody id="session-table-body">
+ <tr><td colspan="6" class="table-empty">Loading...</td></tr>
+ </tbody>
+ </table>
+ </div>
+ </section>
+</div>
+{% endblock %}