Features

Every feature reads the same course files; nothing needs its own content. A lesson does not appear in a feature whose data it lacks. All progress is per course: switching course switches the home page, mastery, vocabulary, games and history with it. Progress is also shared across the modes: a lesson passed in Sprint mode on the bus is passed in Study mode at home (see Progress across modes).

The modes

Once a course is open, the home page leads with today’s goal (#558): Sprint mode’s ring, large, filled by the effort points earned today in any mode (see Progress across modes, “The daily goal”; what earns what is behind its (i)), a line about the day, chips for the day streak, lessons passed of the total, terms collected and the skipped lessons when there are any, and Continue: , which opens the lesson every mode resumes at in the mode last used (“Where you left off”). Under it, in the same card, the unit of that lesson (“Unit n of m”, a bar of its lessons passed and Open the learning map). Then come four modes plus AI tutoring. Each mode card shows its name, a one-line tag, one stat, its submenu and a big button; what the mode is lives behind the card’s (i), in a modal. The top bar only carries the course switcher and the account links.

  • Sprint mode (/sprint): phone-first five-minute sessions with the daily goal’s ring and a streak. Its submenu is the path, the review mix and the lightning round; its home card names the next stop (the lesson to resume at).
  • Study mode (/study opens its Lessons tab): read the lessons and test yourself. Lessons, Practice, Listening, Map, Vocabulary, History.

In a language course, every word that matters can be tapped to hear it (see Tap to hear).

  • Play mode (/play): games and one-off exercises. Word Match, the paradigm drill, Numbers by ear, Date Dash and Flashcards (the Play mode bar also links the Guide). “Play” is its one name: the home card, the mode bar, the page title and the game pages’ breadcrumb (Play › Word Match).
  • Guide (/guide): guided lessons, one stage at a time (see the Guide row below). Its home card shows stages complete of those not skipped (from the same rule as /guide); Start the Guide / Continue the Guide opens the current stage, See your stages the list once every stage is done. Its icon is the mode_guide slot (🧭). “Guide” is its one name (home card, page heading, Play mode bar); a stage is one lesson inside it.
  • AI tutoring (/tutor): a one-to-one lesson over chat with a tutor that runs on the learner’s own API key. Same exercises and progress as Practice, but the tutor teaches first and sets them when you are ready. Its prompt says the learner may be as young as the Terms’ minimum age (SKRONGLE_MIN_AGE): it stays on the course, declines sexual, self-harm, violent, hateful or illegal turns and goes back to the lesson, and never asks for personal details (tutor::safety_rules, #919).

The modes’ old paths (/d, /d/…, /k, /g, /games, /games/…) redirect to the new ones with their query strings, so old bookmarks still work.

FeatureRouteWhat it isReads
Courses/courses, /courses/{id}/settings“What would you like to learn?”: your projects (each shows a thin progress bar under its title, pale gold for lessons passed and full gold for lessons mastered, and under it two small chips with the same two numbers and the unit count; when you last opened it sits beside Continue. A chip turns from grey towards gold as more of the course is done, shows its name on hover, and opens a modal with the stat when clicked. The modal fetches what is next as it opens (GET /courses/{id}/stat/{metric}): for lessons passed, the next lesson to pass, the last one passed and each unit’s count; for lessons mastered, up to three lessons to practise next, fading ones first. Each lesson named opens the course at it, and an (i) says what the stat counts. Each card wears its own theme, with a gear for its settings: level, motivator, theme, profile picture, background), your own courses (private; greyed out while being generated), ready-made courses, Create a coursemanifests
Generate with AIthe editor’s Generate the course dialog (POST /courses/{id}/generate) and progress modalNotes or homework in, a whole private course out, written with the learner’s own API key; see Your own courseseverything (it writes it)
Starting levelThe course’s settings: the level step at the end of its start wizard, then the gear on /coursesA course whose units are levels asks a learner who opens it for the first time which level to start at; the lessons below it are skipped; the level can be changed later without losing progress (see Starting level)levels, units
Home/Today’s goal as a ring with the streak, lessons passed and terms collected, Continue where you left off, the current unit’s progress, then the four modes (Sprint, Study, Play, Guide) and AI tutoring, with one stat each, their submenus and an (i) explaining eacheverything
Landing/ (no session)The sales page for visitors: what the app is, a lesson file next to the features it switches on, the three ways to get a course, the built-in courses with their unit and lesson counts, register / log inmanifests
Lessons/lessons, /lessons/{slug}One unit at a time (#837): a tab per unit with its passed count and bar, 12 lessons a page with page links (?unit=<id>&page=<n>; without ?page= the page holding the next lesson to do, without ?unit= the unit holding it; ?unit=all lists every unit on one page). A filter box searches every unit (every word typed must appear in a lesson’s title, group or summary; a search that finds nothing says so; without script it is ?q=); under the box, Look up “…” in the words opens /lookup?q= (#348): the words of the whole course whose term or meaning has a word starting with each word typed (accents and case ignored), and the forms of its paradigm tables (a form names its item, set and slots, and a form two verbs share is listed for both), at most 20, exact terms first; each with its meaning, the lessons that teach it (linked), Collected with the flashcard tally when you have it, and a 🔊 in a language course; a quiet header (#999): the breadcrumb with a small mastery ring, the status pills and an (i) on one line, then the title, the summary and one main button (I’ve read it, quiz me until the lesson is seen, then Practise) beside a “More ways” menu (Practise before the lesson is seen, Listen and type when the lesson has listening, Tutor when AI is on, the Guide, a Sprint session). The (i) opens the mastery details, the focus and question counts and the pass rule; the footer has its button and the same (i); the lesson text (read as a page), vocabulary table (hover a term for its meaning, an example for its translation), paradigm tables, conversations (#989: each one as two characters talking, the role’s name over its line, A on the left and B on the right, drawn with two of the six people portraits that are not the learner’s own picture, in the worn theme’s version; a name keeps its portrait across lessons; the meaning under each line), a “My note” box (#336: the learner’s own private note on the lesson, plain text up to 500 characters, saved as they type, in Download my data; its (i) says so), and the two-question reading check that marks the lesson as readcontent, vocabulary (example_meaning for the hover hint), conjugations, conversations, quiz
Quizzes/practice, /practice?lesson=Adaptive micro-quizzes: 5–10 questions from the lessons you have read, weighted towards weak topics; or one lesson on requestquiz
Listening/listening, /listening/play?lesson=Hear a sentence, type it, see it checked word by word; one lesson or a mixed roundlistening
Guide/guide, /guide/{slug}The short-attention-span version: each lesson as a stage of small steps (cards, quick checks, flashcards, tips); starscontent, quiz, vocabulary, guide
Sprint mode/sprint, /sprint/playPhone-first five-minute sessions of short exercises: bites, sort / spot / build / ladder / pick / match exercises (a sort judges each tap at once, a ladder is graded only at Check), five listening exercises (tap what you hear, fill in what you hear, what do you hear, select the matching audio, listen and match), an audio switch, streaks, daily goal, lightning roundsprint, quiz, vocabulary, conjugations, conversations, listening
AI tutoring/tutor, /tutor?lesson=A one-to-one tutor over chat: teaches the lesson, checks you follow, sets the adaptive quiz’s questions when it judges you ready, and reacts to the app’s grading; you answer in the chat (typed or a tapped choice) and can ask anything in between; needs an API key from the profilecontent, vocabulary, quiz
Play/play (also /g)The Play mode hub: Word Match (timed pairs, shared leaderboard), the paradigm drill (type the form for a picture), Numbers by ear (hear a number, type the digits), Date Dash, plus links to flashcards and the Guidevocabulary, conjugations, terms.lang, calendar
Map/mapEvery lesson of every unit in order with your mastery; how far you are from mastering the unit; one “you are here” on the whole map, at the lesson every mode resumes at (where you left off)units, mastery
Vocabulary/vocabulary, /vocabulary/studyTerms collected by answering questions, studied as flashcards with per-term review statsvocabulary, quiz[].teaches
History/historyEvery answer and every finished gamemastery, activity
Mistakes/mistakes (linked from the Study card on the course home)Quiz questions whose latest answer was wrong, newest first. Each row has the question, your last wrong answer and the right one. A question leaves the list once you answer it right. Reads the last 500 answers and shows up to 50 (#338). A lesson without a quiz never appearsquiz
Themes/courses/{id}/settings (a course’s theme, with its picture and motivator), /theme/delete, /theme.css, /themes/{id}/…A skin for the whole app (data/themes/<id>/: colours, font, art, praise strings, profile pictures) and, where a lesson has it, example sentences and exercises in the theme’s world, shown first in every modetheme files, lesson themes.<id>
MotivatorsPOST /courses/{id}/settings (motivator)How progress is dressed up, per course: draws the top of the course home page and the reward line at the end of every lesson or session (src/motivators.rs); Statistics and metrics by defaultnone (app-level)
Progress API/api/progress…Lesson passes and answers reported by AI agents that teach the learner outside the app (see Progress across modes)everything
Docs/docs, /llms.txtThis documentation, served from the binary: “Using Skrongle” (learners, authors, LLMs) and “Working on Skrongle” (developers) in the sidebar, each page also as raw Markdown, the list of pages beside the page and after it on a phone; /llms.txt points an LLM at the course format and schema—

Your own courses

Anyone can make a course, and it is theirs alone: a course made in the app lives in the database as the same JSON files a built-in course has (courses + course_files), and only its owner sees it, opens it or changes it (anyone else gets a 404), unless the owner shares it (below). Courses are private; publishing them to a catalogue is planned (courses.visibility is reserved for it).

A course made in the app gets every feature its files have data for, exactly like Spanish: the three modes, the tutor, mastery, passes, streaks, the daily goal, history. The language-only games (Listening, Numbers by ear, tap to hear, the listening exercises of Sprint mode) appear only when its manifest says it is a language course. Themes are not offered with them yet: no themed material (the theme’s colours, a setting of the account, still apply).

One button on the course list, Create a course (POST /courses/new), makes a starter course called Untitled course (one example lesson, the format to copy) and opens its page with the Generate the course dialog open. Close it to write the course yourself from the lesson table; the title at the top of the page is renamed by clicking it and saved with the table, like any other change (the editor saves as you edit; see Saving as you edit below).

  • Generate the course (the dialog, POST /courses/{id}/generate): “drop your homework in, get a study app out”. It writes into this same course (same id and page), replacing the starter files. The dialog is on offer, under Use AI and at /courses/{id}?generate=1, while the course is a draft: the starter course with only names changed (courses::draft_of). The course title, lesson titles and lessons added by name alone are names; the AI is told them, keeps a title the learner chose (only Untitled course takes the outline’s) and plans the named lessons first, in order. A title alone is enough to ask for. Any other change (a lesson’s content, the settings, an upload) ends the draft: from then on the course is changed with Only add or Allow overall changes instead. A generation that failed before its outline offers the dialog again; to start over a generated course, delete it and create a new one.

    1. What do you want to learn?: a sentence or two (level, what the test covers), 20 to 2,000 characters.
    2. Your material (optional): class notes, homework problems, a syllabus, pasted or dropped in as text files (.txt, .md, .csv, .tex …; 20 to 60,000 characters, read in the browser). The model is told to teach exactly that material, in its notation, and to treat it as content, never as instructions.
    3. What kind of course: let the AI decide, maths or science (number and formula answers), a language (listening, conversations, tap to hear), or facts and concepts. Each kind has its own rules for the model (which question types, which Sprint games). Left to the AI, the outline names the kind it chose. The kind is saved with the outline, and every later request for the course (each lesson, Resume, Revise, Only add) gets only that kind’s rules, so a course on therapy models is not asked for “tap the wrong word” spots in every lesson. A course with no saved kind (made before this, or by hand) counts as a language course when its language features are on, and otherwise gets all three sets of rules.
    4. How much to write (#1119), with an “i” that explains the three: Full course (the default; the outline plans as many lessons as the subject and material need, at most 15), Preview (the course is exactly one unit with one full lesson: the outline is cut to its first lesson and that lesson’s unit) or Outline (every unit and lesson, but each lesson’s content is one ## section listing the concepts it will teach, with one example question in quiz and no vocabulary or Sprint games). The choice is saved with the outline (depth), so a Resume writes the same kind of lessons. With the server’s key a Full or Outline course reserves for 6 lessons up front and a Preview for one; the meter still checks every request.
    5. Your AI provider only when two or more keys are saved on the profile: a choice between those. With one saved key the dialog says nothing about providers and uses it, with the provider’s default model. With no key at all the dialog opens greyed out (every field and Generate disabled) under a line linking to the profile’s API keys, so nobody writes a prompt only to be stopped at the end. Keys are added on the profile only; the server still takes a pasted api_key (with model and base_url) from API clients. A base_url needs a pasted key: a saved key only goes to its provider’s own address. That URL must use port 443 or 80 and resolve to public addresses only, unless the operator sets SKRONGLE_ALLOW_PRIVATE_AI_URLS=1 (dev mode allows it too); redirects are never followed. Under How much to write, an estimate: “6 lessons · about 5 minutes · roughly CA$1.48” (#443). It counts an outline request and one request per lesson (the depth’s expected count: 6, or 1 for a Preview), each carrying the format reference and the material (ai::job_tokens, the sum Skrongle AI reserves), at about 45 seconds a lesson (job_minutes, src/routes/courses/generate.rs). An own key is priced at its provider’s default model from the metering price table (SKRONGLE_AI_PRICES), turned from US into Canadian dollars at a fixed 1.40 (CAD_CENTS_PER_USD); a model the table does not list (it would get the high * rate) shows only the lessons and the time. Skrongle AI shows a share of the month (“about 12 % of this month’s Skrongle AI”), never dollars. static/gen-estimate.js works it out again as the count, the material or the provider changes; the (i) beside it says how it is worked out and that it is rough.
    6. Advanced: edit the instructions. The system prompt every request carries is shown and can be edited, behind a warning: instructions that fight the format waste API credits on retries and make courses that do not load, and pasted instructions can make the model write things you did not ask for. The rules for the kind of course, the course format reference and the outline are always added after them; Reset puts the default back.
    7. Effort (#918), the button beside Generate (generate-effort), opens a small dialog (generate-effort-dialog) with three levels: Fast, Balanced (the default) and Thorough. Every provider gets the same model at every level, with output_config.effort (Anthropic) or reasoning_effort (OpenAI, Gemini) set to low, medium or high (ai::Effort, src/ai/budget.rs). Thorough lets each request write twice as much (max_tokens 32,000) and reserves twice the output on Skrongle AI, so a job does not run past what it held. The level posted is saved on the account (users.ai_effort) and is where the dialog starts next time. It applies to Generate the course and its Resume only; the tutor, Use AI and translations send no effort.

    Under the two boxes a live count reads “45 / 2,000 characters” (trimmed, counted as the server counts): red with “· at least 20” below ai::MIN_PROMPT (the material, being optional, may also stay empty), red with “· at most …” above the maximum (maxlength stops typing there), normal text in between. The form needs one of the two filled in (or a course title), and each one that is filled in within its limits. The browser checks that before posting, with the server’s words (generate_in refuses the same): a refusal opens in place, without a reload. The error eases open at the top of the dialog, which stays put (it is held at the top of the screen, not centred) and scrolls up to it smoothly; the box to fix turns red and keeps the focus, and the error closes again once it is fixed. Other refusals (no key, too many jobs) come back from the server with the dialog open and the error at its top.

    On submit the course is handed to the job at once and the page comes back (/courses/{id}?job=<job>) with a progress modal: the phase and a progress bar, followed live, and Cancel; it cannot be closed while the job runs, and it is back whenever the course’s page is opened until the job ends. When the course is ready the modal goes away, leaving the course’s page with its lessons. On the course list the course is greyed out as “generating”, with the same phase and bar and a Watch link to its page. The job runs on its own OS thread: one request for the outline (the manifest and the lesson plan, saved straight away), then one per lesson, each validated with the normal loader and sent back to the model with the validator’s message up to twice. Each lesson request also lists the terms and section headings of the lessons written before it (about 2,000 characters at most), a map of the course (each unit with its lessons in order, this one marked), and the whole lesson just before and just after it (the plan for one not written yet), so a later lesson builds on them and uses the same words instead of teaching them again. Until it finishes the course cannot be opened or edited. Cancel stops after the request in flight. A run that stops (an error, a cancel, a server restart) keeps its outline and every lesson written, but a half-written course is never shown: a course whose first generation stopped has no editor (its pages redirect to the course list). Its card shows a red ! by the title (“An error occurred during generation.” on hover) that opens a modal with the provider’s or the validator’s own text and nothing else; the plain explanation with a next step (ai::plain_failure) is in the generate dialog. A provider account whose credit or usage limit is used up (Anthropic’s 400 “usage limits” / “credit balance”, OpenAI’s 429 insufficient_quota) reads “Your AI provider account has run out of credit or reached its usage limit. Check your plan and billing with the provider, then try again.” (#297; before, “turned the request down” or “busy”). The card also has Delete, and one button that opens the dialog on the course list (/courses?generate=<id>): Resume when the outline is saved (it writes only the missing lessons), else Retry (start over, with the first run’s wish and notes filled in). A save that still reaches a stopped run with its outline (a stale editor tab, the file form, the upload API, Undo) leaves it stopped, with its reason and Resume, even when the files then load (courses::revalidate); Resume keeps the files that load, edits included. Only a run that stopped before its outline becomes a course when its files are fixed by hand. When the run stops while its progress modal is open, the page goes straight to that dialog, with the reason at its top. The key is used for the job and dropped with it; it is never stored with the course. One job per learner, two in all (a slot is free once the job’s thread has ended), at most 10 started per hour and 40 per day per learner, and an hour per job (SKRONGLE_AI_JOB_BUDGET_SECS). What the AI writes keeps to the editor’s caps: at most 15 lessons in an outline, each file at most 1 MiB, at most 400 files, and a model reply over 1 MiB is not read. Once the course is ready its notes are deleted within the hour (SKRONGLE_KEEP_NOTES=1 keeps them for later revisions); a stopped course keeps them so it can resume, and is deleted after SKRONGLE_FAILED_COURSE_RETENTION_DAYS (30) days without a resume or edit. A line under the notes box asks not to paste sensitive personal data (Use AI’s box carries it too).

  • Use AI (add content with AI). One box on a course’s own page (POST /courses/{id}/ai, text + mode), for any course of your own that loads and is free. Like a generation it has no status page, and the page does not change: the form is sent in the background and the job’s modal comes up over the course page, which stays as it is but inert (the lesson table, Use AI and the rest can’t be used) until the job ends (without JavaScript the post lands on /courses/{id}?job=<job>, the same page). Coming back while it runs draws the same workspace, inert, under the modal; the modal’s Cancel is the only one. Generating… with the phase and a progress bar while it runs (the page polls /api/generate/{job}), and a Cancel that stops it after the request in flight; when the job ends the page reloads: a job that changed the course opens Review AI changes (below) on what it changed, with the new lessons in the lesson table and Undo under it once it is closed (with Don’t show this automatically next time ticked, the page says the AI has finished instead); a job that stopped shows the error in plain words (a cancel, a reply that could not be used, a refused Only add) with Close. The course page shows the modal again whenever the course is being revised, so coming back to it finds the job still running. The old status page’s address (/courses/generate/{job}) leads to the course page too, for any job. It uses the learner’s first saved provider key (profile, AI API keys); it never asks for a key or a model, and names it only when two or more keys are saved. Without a saved key it shows only a red line linking to the profile, and the server refuses the same way. One switch, Allow changes to existing content, top right of the Use AI box, picks the mode (an “i” beside it explains it in two sentences). A second switch, Outline only (with its own “i”), makes the lessons the job writes, the added one or the rewritten and new ones, outlines like the Generate dialog’s Outline. The text takes 20 to 1,000 characters for Only add and up to 4,000 for a change; a live count under the box follows the mode, red outside those limits (as on the Generate dialog), and the server refuses the same lengths:

    • Off, Only add (the default): one new lesson, written and placed by the AI (see the lesson table below). The result is checked in code, not trusted to the prompt (arrange::only_adds): every existing file stays, course.json byte for byte and each lesson as it was except its order; anything else and nothing is saved.
    • On, Allow overall changes (any finished course, made with AI, by hand or uploaded; disabled while a job runs or the course does not load): a revision from feedback, below.
  • Change it with feedback (Allow overall changes). The learner says what should change or what to focus on (“more word problems”, “simpler English”, “drop the history lesson”). One request plans the revision against the course as it is: each lesson is kept, rewritten (same id and slug, so progress stays with it) or new, and a lesson left out is removed. Then one request per lesson to write, with the current file and the feedback. Kept lessons stay byte for byte (except that a prerequisite pointing at a removed lesson is dropped, and order is set when the plan puts lessons in a new order). While it runs the course is revising: it still opens and works as it was, but its files can’t be changed. Nothing is saved until the whole new course validates; then the files are swapped in at once, so a failure, a cancel or a restart leaves the course exactly as it was. The previous files are kept for Undo (see below). Download all files (/courses/{id}/export.json) is one JSON object: "version": "1.0" first, then every file by its path.

  • The lesson table. Every course of your own (made by hand or with AI) that loads shows its lessons as a table on its page (/courses/{id}): number, name and delete, no file names (without JavaScript, an Edit link per row opens its file instead). The first row is the course settings (course.json): it can’t be moved or deleted, and its JSON opens in the row. While its JSON is valid, typing in it reshapes the table: the course title, units added, renamed or put in a new order (each taking its lessons along); a unit taken out that still holds lessons stays, marked, until they move. Units are header rows (even just one; a fold arrow and the title, no icon) with their lessons indented under them; a unit row folds, and its + adds a lesson to that unit. A lesson row’s ⧉ duplicates it (#454): the lesson as it stands in the table (its JSON as typed, if open) as “ (copy)”, right below, saved with the table like a new lesson. The copy’s <code>id</code> and <code>slug</code> end in <code>-copy</code> (<code>-copy-2</code> and so on when taken), and so do its question and conversation ids (themed ones too); the guide’s question references follow. Its <code>conjugations</code> are left out, since an item and set belong to one lesson; <code>prerequisites</code> stay. <strong>+ Add unit</strong>, on the Course settings row right of its gear, adds a unit at the end, named on the spot (“New unit”; Escape drops it): it goes into the settings’ <code>units</code> (id: the next number when every unit id is a number, else a slug of the name) and saves like a settings edit. <strong>Paste a word list</strong>, beside the table’s heading (language courses only; it follows the Language features switch), takes lines like <code>word — meaning</code>, <code>word, meaning</code>, <code>word = meaning</code> or tab-separated columns from a spreadsheet, shows the pairs it read under the course’s <code>terms</code> / <code>meanings</code> labels and lists every line it could not read (no separator, a repeated word, over 500 words) with its number. <em>Add the lesson</em> puts a new row at the end of the last unit with the words as its <code>vocabulary</code> and a table of them as its <code>content</code>, saved like any new row (<code>src/word_list.rs</code>, <code>POST /api/courses/{id}/words</code>, which stores nothing). No model is involved. An empty unit is a header with no rows, which learners never see. Every unit but the last has a ✕: an empty one goes at once; one holding lessons asks first (“Warning: this will delete all lessons in the unit. Are you sure?”, Cancel / Confirm) and takes its lessons with it, in one save that Undo takes back. A unit holding every lesson of the course cannot go. Drag a row by its grip (mouse or finger; or focus the grip and use ↑ / ↓) to reorder it; dropped on a unit row it goes to the end of that unit. Click a name to rename it; ▸ opens the lesson’s JSON in the row to edit there, or the same file as a form (<strong>Forms mode</strong>, when the table’s <strong>JSON</strong> switch is off; see below); a unit’s <strong>+</strong> adds a blank lesson (id, slug, title, unit, group and one card of <code>content</code>) named on the spot; its drawer works at once, in either mode, before any save (the save fills in the unit and group, and an id or slug left empty). Every save sends the whole table, settings included (<code>POST /api/courses/{id}/lessons</code>): renames and JSON edits land in their files, deleted lessons go (with their ids in other lessons’ <code>prerequisites</code>), <code>order</code> and <code>unit</code> are rewritten along the list, and the course must load or nothing changes. A learner who has started the course is asked before a delete (it is saved at once). The last lesson can’t be deleted. A course that does not load (or is being generated) shows its files instead, to fix by hand. No model is called. <em>Only add</em> in <strong>Use AI</strong> (under the table) takes a short brief: one request (<code>add-lesson</code>) writes the lesson and says which lesson it comes after, choosing the unit itself; it is validated with the whole course (up to three tries) and joins it only if it all loads, as a job like a revision (the course keeps working; the course page’s modal follows it).</p> </li> <li> <p><strong>Editor settings.</strong> The gear on the course settings row opens a dialog of three switches. <strong>JSON</strong> (below) and <strong>Line numbers</strong> are remembered in the browser; Line numbers (off at first, shown only with JSON on) numbers the lines of every JSON box in a gutter that scrolls with it, as vim’s <code>:set number</code> does. <strong>Language features</strong> (shown only with JSON off) is a fact about the course, not the browser: it writes <code>language_features</code> into <code>course.json</code> and saves it with the table. On, the lesson editor shows every section and field; off, it hides what only a language course fills, so a course that teaches terms in the learner’s own language is not asked for translations: the Listening and Conversations sections and <strong>+ Build</strong> while they are empty (a section with items stays), a word’s and a bite’s example meaning, a conversation line’s meaning (kept when it has text), and the check that an example wants a meaning. The same field turns Listening, the Sprint listening exercises and sentence builds, tap to hear and Numbers by ear on or off for learners (<code>Manifest::is_language</code>). Unset, it follows the <code>lang</code> codes: on when <code>terms.lang</code> differs from <code>meanings.lang</code>. An (i) by the switch opens the explanation.</p> </li> <li> <p><strong>Forms mode.</strong> The <strong>JSON</strong> switch (in the gear’s dialog) sets every drawer, open or opened later, to JSON (on) or a form (off); the choice is remembered in the browser. A lesson’s form is the <strong>lesson editor</strong> (#841, <code>static/lesson-editor.js</code>), three columns in the drawer:</p> <ul> <li>an <strong>outline</strong> of the file on the left (Lesson, Text, Vocabulary, Quiz, Listening, Sprint, Conversations, Conjugations when the course has a paradigm, Guide, Themes; Listening and Conversations only with the language features on or items in them), each with its count; a section opens to its items, each with a kind badge (<code>A·B·C</code>, <code>___</code>, <code>T/F</code>, <code>123</code>, <code>ƒ(x)</code>; <code>BITE</code>, <code>SORT</code>, <code>SPOT</code>, <code>BUILD</code>, <code>ICON</code>) and a ⚠ on an item the course rules would refuse; <strong>+ Question</strong>, <strong>+ Word</strong>, … at the bottom of each section, and <em>Edit as JSON</em> at the end (the JSON switch);</li> <li>a <strong>form for the one item picked</strong>, written by hand per kind and grouped <em>Ask / Answer / After answering</em> (or the kind’s own groups: <em>Scene / Lines</em>, <em>Verb / Forms / Art</em>, <em>Buckets and items</em>…), showing only that kind’s fields. The right answer is picked, never retyped: a radio beside each option of a multiple-choice question (a dashed <em>Another option…</em> box grows the list from the first letter typed; retyping the right option keeps it the answer), <strong>True / False</strong> buttons, the wrong word of a spot by a click on the sentence’s words, a sort’s items in bucket columns (dragged, or ◂ ▸). The type switch in a question’s heading reshapes the form (options with text in them stay in the file for a switch back; tolerance and unit go when a question stops being numeric). <strong>Checks</strong> appear under the field as you type: green for a rule met (<em>3 options, shuffled for each quiz</em>, <em>“está” appears once</em>), amber for advice (no explanation yet), red for what the course rules would refuse on save (the same rules as <code>ContentStore::from_files</code>, in <code>itemChecks</code>), which the outline flags too; a save the server refuses opens the first item the rules refuse. Ids are never shown (the lesson’s id, slug, unit and order, a question’s or conversation’s id: edit them as JSON; a new item’s id is made up, <code><lesson>-q1</code> for a question, <code><lesson>-conv-1</code> for a conversation). ↑ ↓ in the footer move the item, <em>Duplicate</em> copies it (with a new id), <em>Delete</em> asks first. The Guide section lists the hand-ordered steps (or offers <em>Order the steps by hand</em> from the derived ones, and <em>Back to derived steps</em>); a section or question step is picked from this lesson’s own headings and questions. The Lesson section has the title, summary, focus, group and <em>Read first</em> (other lessons of the course, by title); Text is the Markdown with the cards it makes listed under it. Themes keeps the schema form (below);</li> <li>a <strong>live preview</strong> on the right (<code>static/lesson-preview.js</code>): the item on a phone-sized screen as the learner sees it, in <strong>Study</strong>, <strong>Sprint</strong> or <strong>Guide</strong> (the toggle offers the modes that show the kind; another section opens in the mode that uses its kind most), drawn again as the item is typed and playable: a quiz question’s <em>Check</em> grades the answer with the course’s own checker (<code>POST /api/courses/{id}/preview-answer</code>, the question as it stands, nothing recorded), a Sprint sort, spot or build by the session’s rules in the browser, a listening sentence read aloud by the browser and checked word by word. Fill-in tiles are the answer among words of the lesson; a conversation shows its first line of 3–9 words as a Sprint build; a conjugation table shows in Study and as the Sprint ladder. Below about 720 px of drawer width the columns become <strong>Outline / Edit / Preview</strong> tabs (picking an item in the outline goes to its form). The course editor’s page is wider than the rest of the app (1240 px) to make room. The course settings (and a lesson’s Themes section) use the <strong>schema form</strong> (<code>static/schema-form.js</code>), built in the browser from <code>docs/course.schema.json</code> (fetched from <code>/docs/course.schema.json</code>), so a field added to the schema appears without code: text boxes (Markdown fields as text areas), numbers, checkboxes for booleans, selects for enums; lists with <strong>+ Add</strong>, ↑ / ↓ and ✕; nested objects, maps by key (<code>themes</code>) as folding sections, labels from the field names and hints from the schema’s descriptions; ids are not shown (a unit’s id is made up, <code>unit-2</code>). Both modes edit one document: each change in the form is written back into the JSON box, so switching keeps the changes and both save the same way. Fields the editor does not know are kept untouched. Opening the form changes nothing; the first edit rewrites the file with two-space indentation. The drawer opens in the mode used last (kept in the browser). A new lesson is edited as a form once saved.</li> </ul> </li> <li> <p><strong>Saving as you edit</strong> (#151, #190). The editor has no Save button: it uses the profile’s <code>static/autosave.js</code> (see <a href="#profile" data-testid="docs-link">Profile</a>). A move, rename, delete or a new lesson (once named) is saved a second after it is made; typing in a lesson’s or the settings’ JSON, or in their form, three seconds after the last keystroke, or as soon as focus leaves that drawer, or the tab is hidden or closed. The circle by the course title sums it up: green ✓ saved, yellow while waiting or saving, red ! when something is not saved, with the reason in red under the title. JSON that does not parse is not sent (the rest of the table still is) and stays in its box to be fixed; a table the course rules refuse (a unit that is gone, an id taken, a course that would not load) is refused whole by the server (<code>422</code>) and nothing changes. A failed save is tried again every few seconds; a page that is behind (the course changed in another tab, or an AI job holds it) is refused (<code>409</code>) and asks you to reload, keeping its edits on screen until you do. Leaving with anything unsaved asks first. <strong>Use AI</strong> and <strong>Generate</strong> save what is waiting before they start, and while a job runs the locked editor saves nothing. One page’s saves make one <strong>Undo</strong> step (a new one after ten minutes), so Undo takes back a stretch of editing, not a keystroke; the Undo and Redo icons and a draft’s Generate dialog are kept up to date without a reload. Without JavaScript the table’s <em>Edit</em> links open the file editor, whose form still has its <strong>Save and validate</strong> button.</p> </li> <li> <p><strong>Error messages</strong> (#228). Whatever refuses an edit (the page’s own JSON check as you type, the lesson table’s rules in <code>src/arrange.rs</code>, or the course rules of <code>ContentStore::from_files</code>), it comes out as one problem (<code>src/problems.rs</code>): its kind in plain words (<em>Not valid JSON</em>, <em>Against the course rules</em>, <em>Not saved</em>), where it is (the lesson by its title or the course settings, the field, the line, and the column for a parse error), what is wrong and how to fix it (for a reference, what it must name, the valid values and a “did you mean” for a near miss; for a duplicate id, both lessons and a free id). The server sends it as <code>422 {"errors": {"table": text}, "problems": [{kind, file, row, lesson_id, lesson_title, field, line, column, message, hint, text}]}</code>. The drawer’s status and the line under the title say the same sentence; the lesson’s drawer opens (its folder unfolds), the row is tinted red (the colour of a removed line in <em>Review AI changes</em>) and so is the problem’s line in the JSON box, with its number marked when line numbers are on; in Forms mode the lesson editor opens the section the problem names and outlines the field, and <em>Go to line N</em> (or a click on the line under the title) selects that line in the JSON. While such a problem stands, <strong>Undo</strong> (enabled even with no saved step) takes back the changes that cannot be saved (#288): what is valid and waiting is saved first, then the table and its drawers go back to the course as saved. After that, Undo undoes saved steps again. The file page and a course that does not load (its card, the editor’s banner) word it the same way.</p> </li> <li> <p><strong>Feature usage report</strong> (#328). A round bar-chart button beside the gear on the Course settings row opens a modal, <em>Feature usage</em>, that shows how much of the <a href="/docs/course-format" data-testid="docs-link" data-slug="course-format">course format</a> the course uses (handy for seeing what the AI made of it). Units are folding sections (the first one open) whose header counts its lessons and the features and question types at least one of them uses (“2 lessons · 7 of 22 features · 3 of 5 question types”); each lesson folds open to two lists, <em>Question types</em> (the quiz types of its <code>quiz</code>: multiple choice, fill in the blank, true or false, numeric, expression) and <em>Lesson features</em> (lesson text, <code>##</code> cards, tables in the text, summary, focus, prerequisites, vocabulary, example sentences, question hints, answer explanations, <code>teaches</code>, paradigm tables, conversations, listening, a hand-ordered Guide, the Sprint icon, bites, sorts, spots, builds, theme material), each with a green check when the lesson uses it and a grey dash when not. It is computed on the server from the loaded course (<code>src/report.rs</code>, <code>feature_usage</code>), drawn into the editor page with the lesson table (<code>templates/course_report.html</code>, no extra request) and drawn again after each save. Folding works without JavaScript; without it the button is a link that shows the report in place.</p> </li> <li> <p><strong>Things to check</strong> (#445). The top of the <em>Feature usage</em> modal lists course lints: quick checks with no AI, made on the server each time the editor page is drawn (<code>src/lint.rs</code>). A red count on the report button says how many there are; the (i) beside the heading says what they are. None of them blocks a save; the validator still refuses broken files. Each row names the lesson and what to look at:</p> <ul> <li>a prerequisite that comes after the lesson (or is the lesson itself);</li> <li>a quiz of 1 to 3 questions, where one wrong answer puts a run under the 70 % pass mark (<a href="/docs/progress" data-testid="docs-link" data-slug="progress">progress</a>);</li> <li>a multiple choice question whose answer stands in its prompt, unless the prompt names another option too (“ser or estar?”);</li> <li>fill in the blank answers of several words with no <code>accepted_answers</code>;</li> <li>a lesson whose Sprint session would be bites only (no quiz, vocabulary, listening, conversations, paradigm tables, sorts, spots or builds);</li> <li>a vocabulary term taught in a third lesson (or later), naming the lessons that taught it first. An AI review of each lesson is planned on top of these (#445).</li> </ul> </li> <li> <p><strong>Undo and Redo.</strong> Each of these changes to a course as a whole (feedback, one visit’s edits to the lesson table, a lesson added with AI) keeps the files from before it as one step, ten per course at most. Two icons under the table (circular arrows, left and right; hovering says <em>Undo</em> / <em>Redo</em>, and each is greyed out when it has nothing to do) take back the newest step, or put back the change the last Undo took away. Neither asks first, since the other one reverses it; the notice after says what it was (“Removed “Simplifying””). Both act in place: the table takes its new rows, the title, icons, notice and Use AI are drawn again, open drawers and folded units stay as they were, and the page does not reload (without JavaScript the forms post and the page reloads). The table changes over a fifth of a second: rows that stay keep still (one that changes place slides up or down to it), rows the step adds fade in, rows it takes away fade out where they were, and the table’s edge follows; nothing moves sideways and nothing dims, and with reduced motion the rows swap outright. The change itself is the button’s accessible name. Any new change (a save, an AI job) clears what could be redone, as in a text editor. Edits made by hand since a change are undone with it (and Redo brings them back).</p> </li> <li> <p><strong>Review AI changes.</strong> Opens by itself when an Only add or Allow overall changes job finishes, unless the learner ticked <em>Don’t show this automatically next time</em> in it (saved on the account at once, <code>users.review_quiet</code>; unticking it there turns it back on). Once the AI has generated, revised or added to a course, the <em>Review changes</em> button next to <strong>Use AI</strong>’s <em>Submit</em> opens it again (<code>/courses/{id}?review=1</code>).</p> <ul> <li>It reads like the lesson table: one sentence on what the run did (“The AI added 1 lesson and changed 2.”), then <em>Course settings</em> and the lessons it touched in course order, numbered as the course page numbers them (removed ones last), by title, never by file name. Each has a light green, amber or red <em>New</em>, <em>Changed</em> or <em>Removed</em> tag and what the AI did in words (“Rewrote the reading; added 2 quiz questions.”).</li> <li>A lesson opens with its ▸ (<em>Open all</em> / <em>Close all</em>; a run of one lesson starts open) to its changes: what was there on the left (red where it went), what the AI wrote on the right (green), the part of a replaced line that differs marked, long unchanged stretches folded. A lesson the AI added (or removed) as a whole is one column. Lesson JSON reads as labelled text (“Question What is ½ of 4?”, nested items indented, brackets as thin rows); <em>Show the files as JSON</em> shows the lines as they are in the file. Labels are the course format’s keys in plain words (#282, <code>review.rs</code> <code>LABELS</code>): <code>prompt</code> is <em>Question</em>, <code>content</code> <em>Reading</em>, <code>correct_answer</code> <em>Answer</em>, <code>options</code> <em>Choices</em>, <code>slug</code> <em>Web address</em>, <code>question_id</code> and <code>id</code> <em>Id</em>, and so on; any other key in words (<code>example_meaning</code> → <em>Example meaning</em>). Lines that only identify or file a lesson (Id, Web address, Group, Comes after, Place in its unit) are dimmed unless they changed.</li> <li><strong>Leaving things out.</strong> A red − appears beside a changed line on hover (always, faintly, on touch screens): clicking it leaves that change out, the AI’s line struck through and the old one plain again; dragging from it up or down leaves out every changed line it passes over as one group (unchanged lines are skipped); while dragging only the band shows, then the group’s one − on its first row. Every − is one size, the thin bracket rows’ too. A line that opens a list or an item (“Quiz”, a question) takes the lines up to its closing bracket with it. A yellow −, always shown, marks what is left out and brings back its whole group. The − beside a lesson’s name leaves the whole lesson out (for a removed one: keeps it) and folds it; brought back, it opens again with the lines left out inside it still left out.</li> <li><strong>Editing in place</strong> (#287), like in a text editor. Any line on the right of a lesson’s JSON (the AI’s, or an unchanged one shown around a change) can be rewritten as text: click it (or Enter, when it has the focus), and the raw line opens in a box (<code>"title": "Halves",</code>), key, value, brackets and all. Enter is a new line, so one line can become several (a new key, another list item); ⌘/Ctrl+Enter or leaving the box keeps it, Escape drops it, and an emptied box changes nothing (the − leaves a line out). The lesson must still read as JSON once the edits are in: commas before a bracket or between items are mended, anything else is refused at Accept by lesson name (“Your edits to “…” leave it unreadable”), the review staying open with the edit to fix. Removed lessons are not editable.</li> <li><strong>An edited line is always in.</strong> It turns blue (band, text and a bar on its left), is read like any other line (its key as a label), loses its −, and a drag skips it. Editing a line that was left out takes it back in. While a lesson has an edited line its − is disabled, its title saying why, and so is the − of a line that opens a block (a list, a question) holding one. <em>Show the files as JSON</em> shows the line as Accept will write it, and the footer counts “1 line edited”. The server keeps the same rule: an edited line, and its lesson, are written even if the form also says to leave them out.</li> <li><strong>Undo / Redo of edits.</strong> The first edit brings an Undo / Redo pair into the modal’s footer (the editor’s icons). Each kept edit is one step; Undo puts the text before it back (back to the AI’s text, the line is green again and has its − back), Redo applies it again. Ctrl/⌘+Z and Ctrl/⌘+Shift+Z or Ctrl+Y do the same while no field has the focus. The history lives in the page until Accept; after Accept the course’s own Undo takes the whole review back in one step. A reopened review shows the accepted edits blue and still editable, with an empty history: typing the line back as the AI wrote it makes it green again.</li> <li><strong>Accept</strong> (<em>Accept the rest</em> while something is left out) writes the course as the AI left it minus what is left out, with your edits, as one Undo step (“Left out part of the AI’s changes”, “Edited the AI’s changes”, or both); with nothing left out or edited it changes nothing. What was left out and edited is kept with the run, so the review shows it again and can bring it back. A lesson changed by hand since the run (other than by an earlier Accept) is refused by name; changes the lesson table makes (its <code>order</code>, written into every lesson on any table save, its unit, a rename, the prerequisites of a deleted lesson) don’t count, #280: they are kept and the review’s choice goes in around them, unless it changes the same field. Also refused: a lesson that would no longer read as JSON or a course that would no longer load; the review stays open with the boxes as they were. Close leaves everything as it is (asking first when something was left out or edited but not accepted).</li> <li>It shows what the AI wrote at the time, not later hand edits, and only the latest run (the next one replaces it). Without JavaScript it is a page: every − is a checkbox of the Accept form, each line its own group.</li> <li><strong>After Undo</strong> of the run (#281: every file it changed is as it was before, and the review had not left it all out), the review still shows what the AI did, read-only: an <em>Undone</em> tag by the title with an (i) (“Redo on the course page puts them back”), no −, no editable lines and no Accept (an Accept sent anyway is refused). After Redo it works again.</li> </ul> </li> </ul> <p><strong>Exercises beyond languages.</strong> Quiz questions come in five types (<code>multiple-choice</code>, <code>true-false</code>, <code>fill-in-blank</code>, <code>numeric</code>, <code>expression</code>; see <a href="/docs/course-format#quiz" data-testid="docs-link" data-slug="course-format">the course format</a>), so a maths course can ask for a number or a formula and grade it by value: <code>3/4</code> for <code>0.75</code>, <code>x = 2 or x = -3</code> for the roots <code>2; -3</code>, <code>2(x+1)</code> for <code>2x + 2</code>. Numbers can have a tolerance and a unit. Every mode serves them: Practice, the lesson’s reading check and the Guide show an answer box with the unit and without the accent bar; Sprint mode offers a number as four tiles (the answer and the usual slips: off by one, the wrong sign, a power of ten, doubled or halved) with <em>Type it instead</em>, and asks for a formula as a typed answer; the tutor counts a typed number or formula as an attempt and is told the app checks by value. The checker is <code>src/mathcheck.rs</code>, a small parser and evaluator with no dependencies: equivalence is decided by evaluating both expressions at fixed sample points.</p> <h3 id="sharing-a-course">Sharing a course</h3> <p><strong>Share</strong> on the course page (<code>src/share.rs</code>, <code>src/routes/share.rs</code>) opens a dialog: type e-mail addresses (one per line or separated by commas, up to 20 at a time and 50 per course) and <em>Send invitations</em>. Every new address gets a message, “<em>Owner</em> shared a course with you on Skrongle: <em>title</em>”, with a link (<code>/courses/shared/<token></code>); the dialog lists every address with <em>invited</em> or <em>opened</em> and a <em>Remove</em> button. The owner’s own address is refused, and an address already on the list gets no second message. On a server without mail (#633, no <code>SKRONGLE_MAIL_PROVIDERS</code>) the dialog says so, its button reads <em>Make invitation links</em>, and nothing is mailed: the page that comes back shows each new address’s link (<code>course-share-link</code>) with <em>Copy</em> (<code>course-share-link-copy</code>), once, since only the token’s hash is stored. The share rows are made the same way; the owner passes the links on.</p> <p>Who may open a shared course: the account that opened its link (the first account to open a link keeps it, so a forwarded link lets nobody else in; the owner opening their own link, from an inbox say, takes nothing and lands on the Share dialog), and any account whose <strong>confirmed</strong> e-mail address is the invited one, link or no link, so a learner who already has an account with that address finds the course on their list as soon as it is shared. An unconfirmed address does not count: anyone can register with someone else’s address.</p> <p>For the recipient the course is <strong>read only</strong>: it is on their course list (under <em>Shared with you</em> until opened, then under <em>Your projects</em>) with a small filled circle after the title, which opens a note saying whose course it is; they open it and learn from it with their own progress, passes and history, like any course, but there is no <em>Edit files</em>: the editor, the files, the export and the AI forms stay the owner’s (a 404 for anyone else). The owner’s later changes reach them at once (it is the same course, not a copy). Themes are not offered with made courses, shared or not.</p> <p>Opening the link <strong>signed out</strong> leads to the login page, which says <em>It looks like you don’t have an account with us! Please login or signup to continue viewing your course: title</em> above the form (the register page says the same, and the two link to each other with the token). Logging in or registering there goes on to the link, which puts the course on the list and opens it, also through the two-factor code step and with <em>Continue with Google</em> (sign-in, or a new account’s confirm step): a short-lived <code>share_next</code> cookie carries the token there (#1016). <em>Cancel</em> on the code step goes back to the login page for the same link.</p> <p><strong>Removing</strong> an address takes the course off that learner’s list and out of their session (their progress rows stay for the day it is shared again). Deleting the course deletes its shares. A link that no longer matches a share (the address was removed, or invited again with a new link) leads to the course list with <em>This link no longer works. Ask the course’s owner to share the course with you again.</em> (<code>?notice=share-gone</code>, #1082). Signed out, the login page says the same and signing in goes on to that list.</p> <h3 id="copying-a-course">Copying a course</h3> <p>The copy icon at the top right of a course’s settings dialog, beside the X (<code>course_copy.html</code>; <code>POST /courses/{id}/copy</code>, #629), makes a new private course of the learner’s own with every file of the original (<code>course.json</code>, the lessons, the pictures), called <em>Copy of <title></em> (cut to 80 characters), and opens it in the editor. Its hover text says <em>Create a copy of this course.</em> The dialog is the one behind the card’s gear of a course the learner has opened; the Start wizard of a course not opened yet has no copy, translate or pencil icon, and the card itself has no Copy button. On a course of the learner’s own a pencil icon sits left of the copy icon (<em>Edit this course’s files.</em>) and opens the course editor; the card has no <em>Edit files</em> button either. Built-in courses and the learner’s own can be copied; a course shared with them cannot (it is the owner’s, so its dialog has no icon), and one still being generated or stopped half way cannot either (<em>A course can be copied once it has been written.</em>). Only the manifest changes: its <code>id</code> becomes the copy’s and its <code>title</code> the new name; its <code>subject</code> and everything else stay. The original is not touched, and nothing of the learner’s progress goes with the copy (carrying it over is #347). Keeping a copy in step with its original is up to the learner.</p> <p>A copy is a course like any other: it counts against the course limits (<code>Limits</code> in <code>src/courses.rs</code>: courses per account, a course’s bytes, an account’s bytes), checked in the same <code>Db::call</code> that stores it. Over a limit nothing is made and the course list says which limit (<code>?notice=</code> <code>course-count-<n></code>, <code>copy-too-big-<bytes></code> or <code>copy-no-room-<bytes></code>). A copy of a built-in course is served plain, like every made course (themes are offered with built-in courses only); its lessons keep their <code>themes</code> material all the same.</p> <h3 id="copying-and-translating-a-course">Copying and translating a course</h3> <p><strong>Copy and translate to…</strong>, an icon left of the copy icon in the same settings dialogs (not with AI off), opens a dialog of its own: the language to translate into, the AI to use (when there is more than one: the saved keys and Skrongle AI, as Generate offers them) and <strong>Start</strong>; what gets translated is behind its (i). The languages are the app’s own (<code>i18n/*.yaml</code>, each in its own name), less the one the course is already explained in (<code>meanings.lang</code>). Start (<code>POST /courses/{id}/translate</code>, #630) makes a copy as Copy does, called <em><title> (<language>)</em>, e.g. <em>Spanish (Français)</em>, and hands it to an AI job that translates it in place, one request per file (the manifest first, then each lesson): the editor follows it in the same progress modal as a generation, the course list greys its card out with Cancel, and the copy cannot be opened until it is done. The original is never changed.</p> <p>What is translated is the learner’s side: explanations, meanings, instructions, quiz questions and answers, titles, the manifest’s labels and units. A language course keeps what it teaches as it is (its <code>terms</code>, examples, conjugations, listening sentences, conversation lines, sort and spot sentences stay, and the model is told to leave that language alone inside mixed texts too); a course that teaches no language is translated entirely. The model never rewrites a file: the app collects its texts (<code>src/translate.rs</code>), each distinct one once, sends them numbered and puts each translation back wherever that text was, so ids, references and the structure stay, and a <code>correct_answer</code> that is one of the <code>options</code> stays one of them. A guide step’s <code>section</code> follows its translated heading. The manifest’s <code>meanings.lang</code> becomes the new language. Each file is then validated with the rest of the course before it is saved, like any lesson (a reply that cannot be used or does not load is asked for again, twice).</p> <p>The job is a generation in every other way (<code>src/ai.rs</code>): its own OS thread, one job per learner, <code>SKRONGLE_AI_JOBS</code> at once and the rest waiting in line, the hourly and daily AI job quotas, and the same metering (<code>src/metering.rs</code>): on Skrongle AI the job’s estimate is reserved first (one lesson request a file), every call is recorded, and what it did not spend comes back; an own key is the learner’s. Its calls carry the purpose <code>translate-course</code> (the file’s path as the key). The copy’s <code>courses.source</code> is <code>translate</code> and its notes hold the job’s plan (the language, and the files done so far), so a stopped translation keeps what it translated: its card has the red ! with the reason and <strong>Resume translation</strong>, which opens the same dialog without the language and translates the files not done yet. Keeping the copy in step with its original is up to the learner.</p> <h2 id="themes">Themes</h2> <p>A theme is a skin for the whole app: a folder under <code>data/themes/<id>/</code> (see <a href="/docs/course-format#themes" data-testid="docs-link" data-slug="course-format">the course format</a>) with colours, a font, art, praise strings and profile pictures. The app ships <em>The Realm of Eldmark</em> (an original high-fantasy world), <em>Meadowbrook</em> (an original farm town) and <em>Rustwake</em> (an original wasteland). A theme is a course’s setting (<code>projects.theme</code>, below): while the course is the active one, every page but the course list and the profile wears its theme. Outside a course (the course list, no active course) the app wears Default; the profile has no theme of its own and always wears Default (#577, #951). See <a href="/docs/themes#a-courses-own-theme" data-testid="docs-link" data-slug="themes">Themes</a>.</p> <p><strong>A course’s settings</strong> (#565, #574, #587): the <strong>level</strong> to start at (a course with levels, see <a href="#starting-level" data-testid="docs-link">Starting level</a>), the <strong>motivator</strong> worn in the course (see <a href="#motivators" data-testid="docs-link">Motivators</a>), the <strong>theme</strong>, the <strong>profile picture</strong> (the eight built-in portraits, <code>src/profile.rs</code>, drawn by the sprite in <code>templates/avatars.html</code>; no uploads) and its <strong>background</strong> (the palette’s eight patterned colours, then the theme’s scenes), stored in <code>projects.motivator</code>, <code>projects.theme</code>, <code>projects.avatar</code> and <code>projects.avatar_color</code> (<code>templates/course_settings.html</code>, <code>static/course-settings.js</code>, <code>POST /courses/{id}/settings</code>, where a field left out keeps its value). The pictures and backgrounds are drawn in the theme picked, and a big preview above the pictures follows each pick. Without <code>SKRONGLE_THEMES_ENABLED=1</code> there is no character art (#1153): the theme, picture and background are left out of the settings and the wizard (the stored choices are kept), the account badge in the top bar is the gear, conversation lines show only the speaker’s name, and the verb drill’s person slots show the course’s own slot pictures.</p> <ul> <li><strong>First start: a wizard.</strong> Clicking a course’s <strong>Start learning</strong> (or <em>Open</em>) button on the course list for the first time opens the settings as a guided dialog over the list, one step a page, with an arrow on either side to slide between them and dots for where you are: <em>Choose a motivator</em>, <em>Choose a theme</em> (each with an (i) saying what it is and that it can be changed later), <em>Choose your profile picture</em> with <em>Choose a background</em> under it on one step (the big preview of the picture on top), then <em>Where do you want to start?</em> (a course with levels only, last since #1106, with its placement test when it has one; one slim row a level, the lesson count on the right). <strong>Get Started!</strong> sits under every step and opens the course with those settings (<code>POST /courses/open/{id}</code> with <code>wizard=1</code>); a step not reached keeps its default (#896). Closing the wizard opens nothing: the course stays on the list, and Start shows the wizard again.</li> <li><strong>Opened another way</strong> (a share link, the editor’s open icon, no script), the same wizard opens over the course’s home page on its first visit. There <strong>Get Started!</strong> saves and draws the home page again, and closing it keeps the settings the course started with. Either way the course is set up (<code>projects.set_up</code>, v51) and the wizard never opens again; courses opened before #574 count as set up.</li> <li><strong>After that: one sheet</strong> (#576, <code>templates/course_settings.html</code>, a separate dialog from the wizard). The gear on an opened course’s card in the course list opens every setting in view at once: a header with the course’s badge (the picture on its background, with the theme’s banner behind it) and the title, then a row a setting with its label on the left: the level as a joined ladder of its titles with a note on the one chosen (its description, “From the beginning” for the first, its lesson count), motivator and theme as chips, the picture as a row of discs with the name of the one chosen under it, the background as swatches, then <strong>Save</strong>. Picking a theme redraws the whole dialog in it, so the header is the preview. An (i) by the title says every setting can be swapped any time without resetting anything. Closing it without Save keeps what was saved.</li> </ul> <p>Every course starts with the account’s picture and motivator (<code>users.avatar</code>, <code>users.avatar_color</code>, <code>users.motivator</code>, which nothing edits any more). In a course the top-right corner shows the course’s picture; on the course list, and with no active course, it shows a gear. Themes a learner made themselves (private to them, after the built-in ones) are worn the same way; no page deletes one since #577 (<code>POST /theme/delete</code>); see <a href="/docs/themes#themes-you-make" data-testid="docs-link" data-slug="themes">Themes</a>. With a theme on:</p> <ul> <li><strong>Looks.</strong> Its colour tokens and font apply to every page, Sprint mode included, through a stylesheet built from the theme file (<code>/theme.css</code>); its banner sits at the top of the course home page and the Sprint mode home, its emblem next to the course title; its praise strings (English) replace the course’s, unless the course has lines of its own for the theme (<code>course.json</code> <code>themes.<id>.strings</code>, in the course’s language); its <code>icons</code> replace the app’s own little icons (streak, XP, the modes, the path’s padlocks and mastery marks, the session verdicts) and its <code>lesson_icons</code> the default icons of lessons that set none, while a lesson’s theme block may give the lesson’s own stop a themed <code>icon</code>.</li> <li><strong>Profile picture.</strong> The theme’s <code>avatars</code> are its own versions of the eight built-in pictures, by id. The learner’s choice (per course, <code>projects.avatar</code>) does not change: with the theme on, the course settings’ picture step and the top bar show the theme’s version of it (Sofía becomes Nym the wood elf), and taking the theme off shows the plain picture again. A picture the theme has no version of keeps its plain look. The picture’s background is a slot of the palette, so the theme’s <code>avatar_colors</code> recolour it the same way (a theme without them offers the default eight), and the theme’s <code>avatar_backgrounds</code> add scenes of its world (forests, farmland, a wasteland horizon) that are offered only while it is worn. Picture names are English whatever course is open: the profile never depends on a course (<a href="/docs/themes#themes-courses-and-profiles" data-testid="docs-link" data-slug="themes">Theming</a>).</li> <li><strong>Material.</strong> Each lesson that carries a <code>themes.<id></code> block is served merged: its themed bites, listening sentences, sort/spot/build exercises, quiz questions and conversations go in front of the lesson’s own, themed examples replace the examples of the terms they re-example, and new themed terms join the vocabulary. Sprint mode’s session builder, which shuffles and takes the first few of each kind, puts the themed items first; the lesson page marks themed examples with the theme’s icon; the practice quiz, Listening, the Guide and Word Match read the merged lesson like any other.</li> <li><strong>Priority.</strong> The lesson’s own material is never removed, a lesson without a block for the theme is unchanged, and a course with no themed material at all is shown in the theme’s colours. Mastery, vocabulary and history are the same records whichever theme is on (themed questions and terms are more of them).</li> </ul> <p>The course registry builds one extra <code>ContentStore</code> per (course, theme) in use, with the theme’s material merged and validated at load, so every feature downstream sees themed lessons through the usual <code>Project</code> extractor.</p> <h2 id="progress-across-modes">Progress across modes</h2> <p>A lesson is <strong>passed</strong> once it has been <em>seen</em> (read to the end in Study mode, or taught by a Sprint mode session, a Guide stage or a tutor) and one sitting of its exercises scored <strong>70 %</strong> or more over at least 3 answers (all of them for a lesson with fewer quiz questions). Every mode reports to the same record, <code>src/progress.rs</code>, so passing anywhere counts everywhere:</p> <ul> <li><strong>Study mode</strong>: passing the lesson page’s reading check marks it seen (a lesson without quiz questions has an <em>I’ve read it</em> button instead). A practice round reports a run for each lesson it asked about, and the summary shows which lessons passed. A listening round on one lesson is a run too. The lessons list, the listening list, the lesson page and the map show a ✓ for passed lessons. The lists’ mastery pill (its tooltip says <em>Mastery</em>) appears only once the lesson has answers, so a lesson reads “✓ Passed · ✓ Read” or just “Unread”, never “Passed · Not started”. The home page counts them.</li> <li><strong>Sprint mode</strong>: a lesson session is a seen and a run together. A stop on the path is complete once its lesson is passed in any mode, and the next stop unlocks then. A session under 70 % leaves the stop open.</li> <li><strong>Play mode</strong>: a Guide stage works like a Sprint mode session and shares the same complete/locked state. Word Match on one lesson’s terms is a run.</li> <li><strong>AI tutoring</strong>: once a session has set enough of a lesson’s exercises, their score is a run (skips count as misses). The tutor’s prompt carries the rule, the chat shows “✓ Lesson passed”, and the tutor is told so it can congratulate the learner.</li> <li><strong>AI agents</strong> outside the app report seen lessons, graded answers and passes through <code>/api/progress</code>, under the same rule. The rule, the agents’ brief and the API are on <a href="/docs/progress" data-testid="docs-link" data-slug="progress">their own page</a>.</li> </ul> <p>Mastery (below) is separate: it decays and keeps steering practice, while a pass is a milestone that stays.</p> <h2 id="motivators">Motivators</h2> <p>A motivator decides how a learner’s progress is dressed up: what keeps them coming back. Where a theme skins the app, a motivator owns two places: the top of the course home page (its <strong>hero</strong>) and one short <strong>reward line</strong> at the end of a lesson or session in every mode. The learner picks one per course in <a href="#themes" data-testid="docs-link">the course’s settings</a> (the wizard of its first visit, then the gear on its card; the (i) says what each one does), stored in <code>projects.motivator</code> (‘’ for the default; #574). A new course starts with <code>users.motivator</code>, the account-wide choice from before #574, which nothing edits any more. Motivators are app-level code, not course data: a course file cannot switch one on.</p> <table><thead><tr><th>Motivator</th><th>id</th><th>What the home page leads with</th><th>Reward line</th></tr></thead><tbody> <tr><td>Statistics and metrics (default)</td><td><code>stats</code></td><td>The daily goal’s ring, the streak, lessons passed of the course, terms collected, <em>Continue: <lesson></em> and the current unit’s bar</td><td>“✓ Passed · 14 / 20 today · 🔥 6” (unit finished or daily goal met in place of the first parts when they happen)</td></tr> <tr><td>XP (#561)</td><td><code>xp</code></td><td>A character level badge (“Lv 7”), the bar to the next level (“340 / 700 XP”), <em>Continue: <lesson></em> and today’s daily goal counted in XP (“today: 120 / 200 XP”: effort points ×10, the same goal as everyone’s); its (i) has the effort table in XP and the bonuses</td><td>“+45 XP”, plus “Level up! Lv 8” when the run crossed one or more levels</td></tr> <tr><td>Skrongle Cash</td><td><code>skc</code></td><td>The course’s SKC balance with SKC’s own glyph, today’s SKC against the daily goal in SKC (“today: 18 / 20 SKC”, the effort-point goal at SKC rates), <em>Continue: <lesson></em>; a <em>Shop</em> button and the shelf of badges bought (#569); its (i) lists the rates and explains the shop in one line</td><td>“+27 SKC · +10 SKC daily goal · +5 SKC lesson passed” (the run’s SKC, then each bonus that landed)</td></tr> <tr><td>Social</td><td><code>social</code></td><td>Up to four anonymous comparisons with the course’s other learners (“You put in more effort than 70 % of learners in this course this week”, “23 people are studying this course today”, “6 learners worked on Greetings in the last hour” for the lesson to continue at, “14 learners kept their streak going today. You’re one of them.”), <em>Today: 14 / 20, more than 60 % of today’s learners so far</em>, <em>Continue: <lesson></em>; the (i) says what is compared and that nobody is named</td><td>“You’re one of 41 who passed this lesson this week.” when the run passed its lesson, else “More effort than 70 % of this course’s learners this week.”; nothing below the minimum crowd</td></tr> </tbody></table> <p><strong>The reward line</strong> (#568) is the same element on every end screen: <code><p class="motivator-reward" data-testid="motivator-reward" data-motivator="<id>"></code>, drawn by the server on the request’s task (so it is translated) and inserted by the page. A run that earned nothing (nothing logged, passed or reached) shows no line, never “+0”. Where each mode gets it:</p> <table><thead><tr><th>End screen</th><th>Endpoint</th><th>How the run’s start is known</th></tr></thead><tbody> <tr><td>Sprint mode session</td><td><code>POST /api/sprint/finish</code> → <code>reward</code></td><td><code>duration_ms</code></td></tr> <tr><td>Study practice round</td><td><code>POST /api/practice/finish</code> → <code>{ runs, reward }</code></td><td><code>duration_ms</code></td></tr> <tr><td>Study reading check</td><td><code>POST /api/lesson-check</code> → <code>reward</code></td><td>the request</td></tr> <tr><td>Study “I’ve read it”</td><td><code>POST /lessons/{slug}/read</code> redirects to <code>/lessons/{slug}?read=<time></code>; the page shows the line (for 10 minutes)</td><td><code>read</code></td></tr> <tr><td>Study listening round</td><td><code>POST /api/listening/finish</code> → <code>reward</code></td><td>the request</td></tr> <tr><td>Guide stage</td><td><code>POST /api/guide/step</code> → <code>reward</code> when <code>finished</code></td><td><code>duration_ms</code> (time on the stage page)</td></tr> <tr><td>Play games (Word Match, drill, numbers, dates)</td><td><code>POST /api/games/*/finish</code> → <code>reward</code></td><td>the request, or <code>duration_ms</code> for the drill</td></tr> <tr><td>AI tutor</td><td>the answer entry (<code>reward</code>) that passed a lesson or used up the exercises</td><td>since the last line, or the session’s start</td></tr> </tbody></table> <p>What a run earned is a <code>motivators::RunOutcome</code>: the effort points it logged (<code>points</code>), whether it passed a lesson (<code>passed</code>), took today’s effort to the daily goal (<code>goal_met</code>) or passed a unit’s last lesson (<code>unit_finished</code>), the same as an <code>earnings::Tally</code> (<code>earned</code>), today’s effort (<code>today</code>) and the streak. <code>motivators::reward(conn, user, course, tz, since, lesson)</code> measures it from the shared tables (everything in <code>[since, now]</code>) and asks the worn motivator for its line.</p> <p><strong>Earnings</strong> (<code>src/earnings.rs</code>). A motivator that counts in units of its own (XP, a currency) prices what was earned; nothing is priced in advance: <code>earnings::between(conn, user, course, tz, from, to)</code> counts, in a window, the effort points logged (<code>activity_log</code>), lessons passed (<code>lesson_passes.passed_at</code>; passes from before #558 count too), days whose daily goal was reached (the moment a day’s points reached the goal fell in the window) and units finished (every lesson of the unit passed, the last in the window). It reads the log for that window only (from the start of its first day), so a run’s tally (<code>RunOutcome::earned</code>) and <code>earnings::today</code> cost a day of rows at most. <code>Rates::earned(&tally)</code> prices a tally: <code>XP_RATES</code> (10 per effort point, +50 per pass, +100 per daily goal, +250 per unit) and <code>SKC_RATES</code> (1 / 5 / 10 / 25) are tunable constants; changing one re-prices everything already earned. The counting itself is the pure <code>earnings::tally(&facts, from, to, goal)</code>, unit-tested apart from the database.</p> <p>The course total (<code>earnings::total</code>, what XP and SKC lead with) is never added up from the whole log (#589): what grows without bound lives in one <code>earnings</code> row per learner and course, the <code>earnings::Ledger</code>: the effort points ever logged, the days whose goal was reached, the local day last active, that day’s points and the day streak ending on it. <code>activity::record</code> moves it with every activity logged (the learner’s time zone, <code>CurrentUser::tz</code> from the <code>tz</code> cookie, decides which day the effort lands on), and the streak every mode shows (<code>sprint::day_streak</code>) is read from it. A learner with activity from before the row existed gets it added up from their history once, on first use, and kept from then on. Passes and units finished are counted from <code>lesson_passes</code>, one row per lesson of the course at most. The history’s retention (<code>SKRONGLE_RETAIN_HISTORY_DAYS</code>) therefore never takes XP or SKC away.</p> <p><strong>XP</strong> (<code>src/motivators/xp.rs</code>) is per course and prices nothing in advance: a learner’s XP is <code>XP_RATES.earned(&earnings::total(..))</code>, so XP earned while another motivator was worn is there on switching. Its character level (not a course’s CEFR starting level) follows <code>level_of(xp)</code>: level 1 at 0 XP, and going from level n to n + 1 costs <code>LEVEL_STEP · n</code> XP (100 · n; level n starts at 50 · n · (n − 1)). Changing <code>LEVEL_STEP</code> or <code>XP_RATES</code> re-levels everyone. A run’s “+N XP” is <code>XP_RATES.earned(&run.earned)</code>; “Level up!” names the level the course total is at after the run when it is above the level of the total less the run.</p> <p><strong>Skrongle Cash</strong> (<code>src/motivators/skc.rs</code>, #562) is play money, one balance per course, priced at <code>earnings::SKC_RATES</code> from <code>earnings::total</code>: it keeps counting while another motivator is worn. The balance shown is earned less spent (<code>motivators::skc::balance</code>).</p> <p>The shop (<code>src/motivators/shop.rs</code>, <code>src/routes/shop.rs</code>, #569) sells badges. A badge only changes looks. Nothing bought touches passes, the streak, effort points or lessons. A badge belongs to one course. It is bought with that course’s SKC and shows only on that course’s SKC hero, never on the profile, the course cards or the top bar. <code>/shop</code> (course-scoped, through <code>Project</code>) is reached only from the hero’s <em>Shop</em> button and sends a course that does not wear SKC home; it lists the catalogue with prices and <em>Buy</em>, and the learner’s shelf. <code>POST /api/shop/buy</code> (<code>item</code>) checks the price and the balance on the server; a badge already owned or one the balance cannot pay is refused and nothing is charged. The shop then opens with a modal that says why (400 when the course does not wear SKC). The catalogue is <code>data/badges/catalogue.json</code> (<code>id</code>, <code>price</code>; six badges, 50 to 1500 SKC), each with plain art <code>data/badges/<id>.svg</code> and a title <code>motivator.skc.badge.<id></code> in every <code>i18n/<code>.yaml</code>. A badge is bought once and kept in <code>motivator_purchases</code> (user, course, item, the price paid), which a switch of motivator never clears. It draws in the worn theme’s look when the theme has <code>badges/<id>.svg</code>, plain otherwise (<a href="/docs/themes#shop-badges" data-testid="docs-link" data-slug="themes">Theming</a>); no refunds, no consumables. Its glyph is an arcade ticket with a spark (an original shape, not a currency sign), drawn in <code>currentColor</code> so it follows every theme and colour scheme: <code>motivators::skc::symbol()</code> draws it and <code>motivators::skc::amount(n)</code> an amount with it; use them wherever an SKC amount shows. “Skrongle Cash” and “SKC” are i18n keys (<code>motivator.skc.title</code>, <code>.short</code>), left untranslated in every language.</p> <p><strong>Social</strong> (#563, <code>src/motivators/social.rs</code>) compares the learner with everyone else in the course, anonymously: effort points (<code>activity_log</code>) over the last seven days and since the learner’s midnight, and passes (<code>lesson_passes.passed_at</code>) over the last seven days, and who opened or worked on the lesson to continue at in the last hour (<code>lesson_touches.at</code>, #501), counting every learner whatever motivator they wear. Nobody is named or shown, the learner included; there is no leaderboard. A comparison or count appears only when at least <code>MIN_CROWD</code> (5) learners, the learner among them, were active in its window; below that the hero is just <em>Today: n / goal</em> and <em>Continue</em>, and the reward line is left out. “More than N %” is the share of the other active learners with fewer points (rounded down). The others’ numbers are read per course into memory, as index ranges over that course’s last eight days (#602, #603) and its last hour of lesson touches, and reused for five minutes (<code>SKRONGLE_SOCIAL_CACHE_SECS</code>); the learner’s own are always fresh. Social stores nothing. The streak line (#505) counts the learners whose streak in the <code>earnings</code> ledger is two days or more and ended on the learner’s local day (the row’s <code>day</code>, read through <code>idx_earnings_course_day</code>), adding “You’re one of them” when the learner’s own streak counts; a first day doesn’t keep a streak. It hides below <code>MIN_CROWD</code> like every other count.</p> <p><strong>What a motivator may not do.</strong> The measures underneath are the same whichever is worn: lesson passes (<code>progress.rs</code>), the daily goal’s effort points and the resume point (<code>activity.rs</code>), mastery and vocabulary. A motivator only reads them. One that counts something of its own (XP, coins) works it out from those shared tables, so it keeps counting while another motivator is worn; anything it stores itself (coins spent, items bought) goes in its own tables, keyed by user and course, and switching never clears it. Switching therefore loses nothing, and switching back finds everything earned in the meantime.</p> <p><strong>Adding one</strong> (<code>src/motivators.rs</code>): a type implementing <code>Motivator</code> (id, icon, <code>load(conn, user, course, tz)</code> for anything it keeps of its own or works out with <code>earnings</code>, and <code>reward</code> for its line (<code>RunOutcome::tz</code> for <code>earnings</code> windows), both run inside <code>Db::call</code>), <code>Hero</code> (renders the home page’s top from <code>motivators::Progress</code>, the shared measures) and <code>Reward</code> (renders the line’s inner HTML from the <code>RunOutcome</code> it was given, escaping anything not its own markup), both on the request’s task so their text is translated; counting in units of its own goes through <code>earnings</code> with a <code>Rates</code> of its own; an entry in <code>motivators::ALL</code>; its hero template under <code>templates/motivators/</code> (the section keeps <code>data-testid="home-today"</code> and sets <code>data-motivator</code>); <code>motivator.<id>.title</code>, <code>.about</code> and its line’s texts (<code>motivator.<id>.reward.*</code>) in every <code>i18n/<code>.yaml</code>; a row in the table above and an e2e spec that reads its line (<code>e2e/specs/smoke/motivator-reward.spec.ts</code> shows how). The id is stored, so never rename one; an id the app no longer has falls back to the default.</p> <h2 id="starting-level">Starting level</h2> <p>A course whose manifest says <code>"levels": true</code> (its units are levels such as CEFR A1, A2…, not chapters that build on each other) lets a learner who already knows some of it start further in. The last step of the course’s start wizard (#587, #1106, <a href="#themes" data-testid="docs-link">a course’s settings</a>) asks <em>Where do you want to start?</em>: one card per unit that has lessons, the first one (“from the beginning”) chosen. The whole card is the choice (a visually hidden radio inside it): the chosen card is outlined in the accent colour, grows slightly and shows a check mark (no motion under <code>prefers-reduced-motion</code>). The choice is stored per project (<code>projects.start_unit</code>, ‘’ for the beginning) and can be changed at any time (see <a href="#changing-level" data-testid="docs-link">Changing level</a>). A course without <code>levels</code>, or with fewer than two units that have lessons, never shows the level step. The dev login (<code>/dev/login?open=…</code>) opens a course without asking.</p> <p>Starting at A2 does not pretend A1 was learnt. Nothing is passed and no score is given: the learner starts with zero points. The A1 lessons are <strong>skipped</strong> (<code>progress::is_skipped</code>): still open to read, but</p> <ul> <li>recommended practice, the review mix, the lightning round, mixed listening rounds and the AI tutor’s picks never draw from them, and “read next” points at the first A2 lesson;</li> <li>the current unit is counted from A2, so the map and the home page say A2 and the map’s “you are here” starts there;</li> <li>the Sprint mode path and the Guide mark them <em>skipped</em> (not locked, never the current stop) and open the first A2 stop;</li> <li>lists show a “Skipped” status instead of “Not started”, and the totals (“3 of 18 lessons passed”) leave them out, with “21 skipped” beside them;</li> <li><code>/api/progress</code> gives each lesson a <code>skipped</code> flag and the course’s <code>start_unit</code>, so an agent does not teach them unasked.</li> </ul> <p>A skipped lesson the learner studies anyway (reads it, plays it, answers its questions) is theirs from then on, like any other lesson. Paradigm drill items unlock by passes, so the skipped lessons’ items stay locked until those lessons are passed.</p> <h3 id="placement-test">Placement test</h3> <p>A course with levels may have a placement test (#1106, <code>placement.json</code>, see <a href="/docs/course-format#placementjson-placement-test" data-testid="docs-link" data-slug="course-format">the course format</a>). Its level step then also offers <strong>Take the placement test</strong> (<code>templates/placement_test.html</code>, <code>static/placement.js</code>): the level cards make way for one multiple-choice question at a time (“Question 2 of 9”, <code>GET /courses/{id}/placement</code>, no answers sent), a level’s written questions or ones drawn from its lessons. The last pick posts the answers (<code>POST /courses/{id}/placement</code>, <code>placement::mark</code>), and the step shows <em>Your result: start at A2</em> above the cards with that card chosen, so <strong>Get Started!</strong> saves it as any chosen level is. The learner may still pick another. <strong>Show report card</strong> opens a table of each tested level’s right answers (“2 / 3”), green when all were right, yellow for some, red for none. <strong>Take it again</strong> draws a new sitting. Nothing is stored until the wizard is saved; the gear on <code>/courses</code> changes the level later as usual.</p> <p>The rule: a level answered all right can be skipped; the first level with a wrong answer is the suggested start. With every answer right it is the level after the last one tested.</p> <p>In the course editor, the gear on the <strong>Course settings</strong> row shows two boxes alike: first JSON, Line numbers and Language features, then, last, a <strong>Levels</strong> switch (<code>course.json</code> <code>levels</code>); with it on, a <strong>Placement test</strong> switch below it, off until turned on, adds <code>placement.json</code> (a blank test drawing 3 questions per unit) and shows a <strong>Placement test</strong> row right under Course settings, whose drawer edits the file as JSON. Turning either switch off takes the file out with the next save (Undo brings it back). Both rows are there in JSON and Forms mode: they are about the course, not its language.</p> <h3 id="changing-level">Changing level</h3> <p>The level is not only a starting point (#333). The course’s settings dialog on <code>/courses</code> (the gear) shows the levels first, the current one chosen (<code>courses-settings-level</code>); saving another level changes it (<code>POST /courses/{id}/settings</code>, <code>start_unit</code>). That is the only place it changes (#587: no <code>/start</code> page, no picker on the lessons page). Only the course has to have levels (above).</p> <p>Changing level moves the learner’s position and nothing else:</p> <ul> <li>passes, mastery, read checks, history, XP and streaks stay as they are; nothing is marked passed and nothing is deleted;</li> <li>lessons below the new level that were never touched become skipped (as above); ones the learner already studied stay theirs, passed or not, and keep appearing in practice;</li> <li>every mode continues from the first open lesson <strong>of the new level</strong>: saving a level touches its first lesson (<code>activity::touch</code>, in the mode last used), so it is the lesson every mode resumes at (<code>activity::resume</code>: Sprint mode’s Continue, the Guide’s current stop, the map’s “you are here”), and a begun-but-unpassed lesson below it does not lock the path (<code>guide::stages</code>). When that lesson is passed, the next open one after it is; only when everything from the new level on is complete does the position go back to a begun lesson below it;</li> <li>moving down again (to the beginning) brings the skipped lessons back as “Not started”, with whatever progress they had.</li> </ul> <h2 id="mastery-the-engine-under-quizzes-and-the-map">Mastery (the engine under Quizzes and the Map)</h2> <p>Per (user, course, lesson): a <code>mastery_score</code> from 0 to 100.</p> <ul> <li><strong>Correct:</strong> <code>score += (100 - score) * (0.20 + 0.04 * min(streak, 5))</code>. Diminishing returns, boosted by the current streak; six right in a row take a fresh lesson past 80 (“mastered”).</li> <li><strong>Wrong:</strong> <code>score *= 0.65</code>, streak resets.</li> <li><strong>Decay:</strong> the <em>effective</em> score shown and used for selection is <code>score * max(0.4, 1 - 0.015 * (days_since_practice - 3))</code>, so a lesson not touched for a month drifts back towards “needs practice”.</li> <li><strong>Status:</strong> ≥ 80 mastered · 50–79 in progress · < 50 needs practice · untested. Under 50 counts as “needs practice” only after a wrong answer or once the score has faded; a few answers, all right, leave the lesson in progress.</li> <li><strong>Current unit:</strong> the first unit whose average effective mastery is below 80 or that still has untested lessons, counting from the learner’s <a href="#starting-level" data-testid="docs-link">starting level</a>.</li> </ul> <p><strong>The learning cycle</strong> is read → check → practise. A lesson page ends with a reading check: two of its quiz questions (one if it only has one), chosen at random with recently-correct ones avoided, answered together (<code>POST /api/lesson-check</code>). Each answer is graded and recorded exactly like a practice answer, so mastery and history move. Both right marks the lesson as <em>read</em> (<code>user_progress.read_at</code>; a miss explains and offers two fresh questions) and shows a “✓ Read” pill on the lesson and in the lesson list. Reading is not mastery: two right answers leave the lesson around 36%, in progress, until practice takes it past 80. Lessons practised before the check existed count as read. A lesson taught in another mode (a Sprint mode session, a Guide stage, the AI tutor, an agent) counts as read too: read and <a href="#progress-across-modes" data-testid="docs-link">seen</a> are one thing.</p> <p>Under its footer a lesson page links the previous and the next lesson in course order (unit, then <code>order</code>, then title, as the lesson list shows them), passing over the levels below the learner’s <a href="#starting-level" data-testid="docs-link">starting level</a>; the first lesson has no previous link and the last no next one.</p> <p><strong>Quiz selection:</strong> <em>recommended</em> practice (<code>/practice</code> with no lesson) only draws from read lessons; when none are read it points to the next unread lesson of the current unit instead. Practising a single lesson, Sprint mode, the Guide, Listening and the AI tutor are not gated. Each candidate lesson gets weight <code>(100 - effective) + 8</code>, <code>+15</code> if never tested, ×0.25 if one unit ahead of the current unit (×0.05 beyond), ×0.35 if its prerequisites are below 40. Questions are drawn by weighted sampling without replacement; anything answered correctly in the last 24 h is only reused once fresh questions run out.</p> <p>Sprint mode’s rule exercises (sort, spot, build, ladder) count as quiz answers for the lesson; its vocabulary exercises (pick, match) collect and review terms.</p> <h2 id="music-notation">Music notation</h2> <p>A lesson can show music on a staff (#1176). Its Markdown holds a fenced block marked <code>abc</code> with the music in ABC notation (<a href="/docs/course-format#music-notation" data-testid="docs-link" data-slug="course-format">course format</a>); the lesson page and the Guide’s cards draw each block as an SVG staff in its place, in the text colour, scaled to the width of the screen. The drawing is done in the browser by abcjs (<code>static/vendor/abcjs-basic-min.js</code>, MIT, about 500 KB), which is compiled into the binary like every static file, so Skrongle mini draws it offline. A page loads it only when its lesson has an <code>abc</code> block (<code>Lesson::has_notation</code>), and <code>static/notation.js</code> swaps each block for its drawing. If a block can’t be drawn, its ABC text stays as a code block. Sprint mode, the editor’s preview and the other modes show the ABC text. There are no music questions or sound yet.</p> <h2 id="tap-to-hear">Tap to hear</h2> <p>On every page of a language course (<code>language_features</code> in <code>course.json</code>, or unset and <code>terms.lang</code> differs from <code>meanings.lang</code>), the words in the course’s language can be tapped and are read aloud by the browser’s own speech synthesis, with the voice chosen in the games’ voice pickers (or the best installed one): the terms and examples in a lesson’s vocabulary table and in the paradigm table, the <strong>bold</strong> key words, <em>italic</em> examples and table cells of the lesson text (a cell like “rojo — red” says only the part before the dash), the terms on the Vocabulary page, the flashcards (a 🔊 button beside the term, in the Guide and in Vocabulary study), the correct answer in quiz feedback, and the sentences on the Listening summary. <code>static/say.js</code> does it for any element with a <code>data-say</code> attribute or matching its list; there is no audio to host. It is silent while the audio switch (below) is off. Hovering a tappable word shows its translation where the page has one (a vocabulary term’s meaning, an example’s <code>example_meaning</code>), else a “Tap to hear it” hint. A phone has no hover, so tapping a word also shows its meaning in a small bubble under it for a few seconds, until the next tap or a scroll. It shows even with the audio switch off. The meaning comes from the cell’s own translation, else the meaning column of its vocabulary row. A <strong>bold</strong> word in the lesson text uses the same term in the page’s vocabulary table (the whole term, any case). A word with no known meaning only speaks. Nothing that speaks ever reads out emoji, arrows or other pictographs (<code>speech.js</code> strips them before every utterance), and a nested 🔊 button is not part of its card’s text. <em>Italics</em> in lesson text are taken to be the course’s language, so an English aside must not be italicised.</p> <h2 id="listening-hear-it-type-it">Listening (hear it, type it)</h2> <p>Dictation, in Study mode. Each lesson carries <code>listening</code> sentences; the browser’s own speech synthesis reads one in the course’s language (<code>terms.lang</code>, any installed voice) and the learner types it. <em>Replay</em> repeats it; <em>Slower</em> says it again in two-word pieces with a pause between them, and the speed setting offers the same in short phrases or word by word, because browsers barely honour low speech rates for system voices (on macOS, Chrome’s slowest rate is only about half as fast again). The server checks it word by word (<code>POST /api/listening/check</code>): the sentence comes back with every word marked right, accent slip, wrong (what was typed instead is shown struck through), missing or extra; case and punctuation are ignored; a sentence counts as right only when every word is exact. Then the translation and the lesson’s <code>note</code>, which is either a grammar pointer or the new word the sentence slipped in (“✦ New word: la oficina = the office”). <em>Reveal</em> shows the sentence and counts as a miss.</p> <p>A lesson round is all of that lesson’s sentences in a random order; the <em>mixed round</em> draws from every lesson with listening material, weighted by mastery exactly like the practice quiz (weak and untested lessons first, the next unit peeks in), never repeating a sentence. Finished rounds go to the activity log (<code>listening</code>, board = lesson id or <code>all</code>) and show in History; mastery scores are not touched, since typing a sentence is not the same as answering the lesson’s questions. A round on one lesson does count as a run towards passing it. Only language courses have it, and only lessons with sentences appear.</p> <h2 id="guide">Guide</h2> <p>Each lesson is a stage of steps: concept cards (one <code>## </code> section each), quick checks (quiz questions, graded through the normal answer API so mastery moves too), flashcard hands and tips. Finishing a stage unlocks the next, earns stars (3 for ≥ 90 % right, 2 for ≥ 70 %, else 1) and XP (5 per card, 8 per flashcard hand, 10 per correct answer, 2 per wrong one). Finishing a stage marks the lesson seen and reports its quick checks as a run, so 70 % or more passes the lesson. A stage shows as complete, and the next one unlocks, once the lesson is passed in any mode.</p> <h2 id="sprint-mode">Sprint mode</h2> <p>A session is ~16 exercises: a bite, then rule games, quiz questions, vocabulary and listening interleaved. Fill-in-blank questions become word tiles (the right answer plus tempting wrong ones: the accent-less spelling, other forms of the same item, other fill-in-blank answers from the lesson, then words from its listening sentences and vocabulary terms), with “type it instead” as the alternative. A gap with fewer than three tiles that differ by more than accents gets none: the learner types it. The home screen shows a daily ring (20 answers), the day streak, XP and the path of lessons grouped by unit. <em>Review mix</em> draws from the adaptive quiz over the lessons the learner has read (weak spots first); when those hold fewer than a round (8 questions), the rest come from the next unread lessons on the path, each tagged “A taste of what’s next · <lesson>” (<code>sprint.session.taste_tag</code>), and its rule games, words and listening come from read lessons only. <em>Lightning</em> is 60 seconds of one-tap questions.</p> <p><strong>Sort it</strong> judges each tap at once (#1148). The right bucket takes the word on top of the pile, shown in green; a wrong bucket flashes red, the word shakes and stays on top of the pile, and that counts as a wrong tap. The sort is checked by itself once the pile is empty: right with no wrong taps, wrong with any, and then the sheet says “All sorted, but with 2 wrong taps”. History reads it the same way: “Sorted 6 of 6 correctly” for a clean run, “2 wrong taps, then all 6 sorted correctly” (✗) when there were taps.</p> <p><strong>The ladder</strong> (a lesson’s <code>conjugations</code> with the course’s paradigm) is graded only at Check; nothing shakes or is judged on the way. Every tap on a form puts it on the glowing rung, the first empty one, right or wrong; tap a filled rung to send its form back to the bank. Check opens once every rung is filled. Each rung then shows right (green) or wrong (red and struck through, with the right form beside it). The sheet gives the score (“1 of 3 forms right”), with the key when anything is wrong; the ladder is right when every form is in place. History reads the run as that score. Ladder rows stored before grading moved to Check (#640) also count the wrong taps, and put them first when the end state was all right (“1 wrong tap, then all 3 forms right”), so the words never contradict the row’s ✗.</p> <p><strong>Audio.</strong> In a language course only (as for Tap to hear; elsewhere Sprint is silent, with no speaker buttons and no audio switch), the browser’s speech synthesis reads words aloud, as on the full site: every tile, option or word in the course’s language is read out when tapped (word tiles, multiple-choice options, the forms of a ladder, the words of a spot-the-mistake sentence, the term tiles of <em>match the pairs</em>), a term shown in the course’s language (the “this means…” exercise, a bite’s example) is read out and can be tapped to hear it again, the collected words and the correct answer on the result sheet have a speaker, and a sentence (build, tap what you hear) is read out once it is checked. Options in the learner’s language and the written side of <em>listen and match</em> stay silent. The 🔊 button in the top bar is the audio switch, off, nothing is read and the listening exercises are skipped; every listening exercise also has <em>Can’t listen now</em>, which switches audio off for an hour. The switch is shared with the full site (<code>speech:audio</code> in <code>localStorage</code>). The smaller 🎵 button is for the sound effects (off by default). Listening exercises are also skipped when the browser has no voice for the language.</p> <p><strong>Listening exercises</strong> (language courses only), four per lesson session and two in a review mix, of their own. Every one starts with a big speaker (auto-played) and a 🐢 button that says it slowly, in two-word pieces. Sentence ones come from the lesson’s <code>listening</code> sentences and count towards mastery like a rule game; word ones come from its <code>vocabulary</code> and count as flashcard reviews.</p> <ul> <li><strong>Tap what you hear</strong>: the sentence from word tiles, plus three words from the lesson’s other sentences as decoys (sentences of 2–10 words). Typing a sentence from the audio alone proved too hard for a phone session; that stays in Study mode’s Listening practice.</li> <li><strong>Fill in what you hear</strong>: the sentence is shown with one word missing (a vocabulary term if the sentence has one, else a word of four letters or more); pick it from up to four tiles: the word, its accent-less spelling and words of a similar length from the lesson’s other sentences.</li> <li><strong>What do you hear?</strong>: a term is spoken; pick it among written options (its accent-less spelling and look-alike terms of the lesson). The variant <strong>What does it mean?</strong> speaks a sentence and asks for its meaning among the meanings of the lesson’s other sentences.</li> <li><strong>Select the matching audio</strong>: a meaning is shown; three speakers each say a term, pick the one that says it.</li> <li><strong>Listen and match</strong>: five speakers on one side, the written terms on the other; a matched speaker turns into its word.</li> </ul> <p>Finished listening exercises appear in History like the other Sprint mode exercises (<code>hbuild:<lesson>:<n></code>, <code>hgap:<lesson>:<n>:<word></code>, <code>hmean:…</code>).</p> <h2 id="games">Games</h2> <p><strong>Word Match</strong> deals a lesson’s vocabulary (or 25 random terms from the whole course) in rounds of five pairs. The clock runs on the server between start and finish; every wrong pair costs a second; top 10 per board is shared by all users of the course.</p> <p><strong>The drill</strong> (Conjugation Drill for Spanish) shows a picture for the item and a picture for the slot; type the form. A slot with a <code>who</code> in the paradigm is drawn with profile pictures (#879): the learner’s own picture for <code>me</code>, the learner and two others for <code>us</code>, three others farther away for <code>them</code>, and so on. The others are picked at random from the theme worn’s pictures and are never the learner’s. A slot without <code>who</code> shows its ASCII <code>art</code>. Items unlock when their lesson is passed in any mode, or mastered in Practice. The setup page (<code>/play/conjugation</code>) shows one set at a time (#837): a tab per set with its unlocked count, the first set with something unlocked open by default (<code>?set=<key></code> picks one), and the lessons that unlock the rest 8 a page (<code>&page=<n></code>). Only courses with a <code>paradigm</code> in their manifest have it.</p> <p><strong>Numbers by ear</strong> (<code>/play/numbers</code>) says a random number and you type its digits. The browser’s own speech synthesis does the talking, in the manifest’s <code>terms.lang</code>, so there is no audio to host and any installed voice for that language can be picked (Spain or Mexico for Spanish, say); speed is adjustable. Ranges: 0–10, 0–20, 0–100, 0–1 000, 0–10 000, 0–1 000 000, years 1900–2030, or a custom pair. Wide ranges mix magnitudes so short numbers still come up. <em>Replay</em> (R) repeats the number, <em>Reveal</em> (Q) shows it and counts as a miss; <code>12k</code> and <code>1.5m</code> are accepted for 12 000 and 1 500 000, as are thousand separators. A round is 10, 20, 50 or endless numbers; a wrong answer resets the streak. Finished rounds go to the activity log with the range and the best streak; there is no leaderboard. Only language courses have it: those whose <code>terms.lang</code> is set and differs from <code>meanings.lang</code>.</p> <p><strong>Date Dash</strong> (<code>/play/dates</code>) drills the days of the week and the months until they are automatic: a 30, 60 or 120-second sprint (or 20 prompts) of one-tap questions. The names in the course’s language come from the manifest’s <code>calendar</code>; the learner’s own names come from the browser (Intl, in <code>meanings.lang</code>), so the game is not tied to any language pair. Prompts are mixed: see a day or month, tap it in the course’s language; see it in the course’s language, tap the meaning; “the day after <em>martes</em>” and “the month before <em>enero</em>” for the sequence; a calendar page (weekday, day, month, year) to turn into a date, the weekday first, then the month, with the manifest’s <code>format</code> filling in as you go (the first full date of every round is today’s); and, when the browser has a voice for the language, prompts spoken aloud, including a whole date to find among four calendar pages whose decoys differ in weekday, month or year. Drills: days, months, both, full dates, everything. Answering is one tap or one key (1–9, 0, -, =); a right answer moves on after a third of a second, a wrong one shows the right tile and the translation for just over a second. Points: 100 plus up to 100 for speed (full bonus under 0.4 s, nothing after 3.4 s), times a combo multiplier that steps up every four right answers in a row (×2, ×3, ×4) and resets on a miss. Each day and month carries a weight: a miss multiplies it by 2.5, a slow right answer by 1.3, a quick one by 0.6, and the next prompt is drawn by weight, so the round keeps returning to what is not yet automatic. The summary lists what was missed and the slowest items, with a speaker button when a voice exists. Finished rounds go to the activity log with the drill, the length and the points; the hub and the setup page show the personal best. Only courses with a <code>calendar</code> have it.</p> <h2 id="profile">Profile</h2> <p>The picture in the top-right corner opens the account menu: profile settings, switch course, documentation, log out. <code>/profile</code> is independent of the active course and is where per-user settings live; new customisation options for a learner belong on this page. A list of links on the left (above the cards on a phone) jumps to each section; a new section gets an <code>id</code> and a link there, named like the section’s heading. The link of the section in view is marked (<code>aria-current="location"</code>, bold with an accent bar; <code>static/profile.js</code>), after a jump and while scrolling.</p> <p><strong>Language.</strong> The Personal information card has the language the app is shown in (the same picker the login and register pages have); it saves at once and the page reloads in the new language. See <a href="/docs/i18n" data-testid="docs-link" data-slug="i18n">i18n</a>.</p> <p><strong>Saving as you edit.</strong> The names, the theme, the AI API keys and the Dev options have no Save button: each change posts to <code>POST /api/profile</code> (only the fields that changed; all or nothing, a refused field comes back as <code>422 {"errors": {field: message}}</code>). Names save one second after the last keystroke, or when the field loses focus; an API key three seconds after the last keystroke, or at once when more than five characters arrive together (a paste); a theme or a checkbox saves at once. A field whose change is saved shows a small green check (on the chosen chip or picture for a choice) until it is edited again. The circle at the right of the heading sums up the page: green ✓ saved, yellow while saving, red ! when something is not saved. A field that does not validate turns red with its message under it, at once and without being sent; a lost connection says so in red at the top of the page and retries every few seconds and when the browser comes back online. While anything is unsaved, a link or form on the page asks first in a dialog that says why, and reloading or closing the tab gets the browser’s own prompt. <code>static/autosave.js</code> does all of this for any page; <code>static/profile.js</code> wires up the fields (the course editor uses it too: see <em>Saving as you edit</em> under <a href="#your-own-courses" data-testid="docs-link">Your own courses</a>). The password dialog, <em>Remove key</em> and account deletion keep their explicit buttons. The page needs JavaScript.</p> <ul> <li><strong>No picture and no theme</strong>: both are a course’s settings (see <em>A course’s settings</em> under <a href="#themes" data-testid="docs-link">Themes</a>; #565, #577), so the page shows neither, and <code>/api/profile</code> refuses <code>theme</code>.</li> <li><strong>Personal information</strong>: first and last name (optional, up to 60 characters). The site greets by first name and the menu shows “First Last” (“Learner” until a name is set); leaderboards show the first name and the last initial (“Ana L.”). An (i) button by the “Personal information” heading opens a dialog that says so, with the learner’s own “Ana L.” (updated as they type), and that the address is private.</li> <li><strong>E-mail address</strong>: what the account signs in with (there are no usernames). Asked for at registration (Google sign-in brings its own) and fixed from then on. The profile shows it read-only (“No e-mail on file” for an account from before addresses were asked for).</li> <li><strong>One account per address</strong> (#277, <code>auth::google_match</code>): <em>Continue with Google</em> with no Google account connected yet, whose (Google-verified) address an account already has, never makes a second account. If that account’s address is confirmed, Google is connected to it and the sign-in lands on the profile with “Google account connected …” (the password, if any, still works). With two-factor sign-in on, Google is connected only once the code is accepted, and the notice shows then; a sign-in given up at the code leaves Google unconnected (#677). If it is not confirmed, the login page refuses (“An account with this address exists, but its e-mail address isn’t confirmed …”): sign in with the password, confirm, then connect Google from the profile. An account with a different Google account connected is refused too. The other way round, <code>/register</code> refuses an address that signs in with Google only and says to use <em>Continue with Google</em>. Duplicates made before #277 are left alone; the oldest account with the address decides.</li> <li><strong>Account</strong>: member since, how the account signs in, a link to connect a Google account (locally, dev mode stands in for Google: see <a href="/docs/testing#google-sign-in" data-testid="docs-link" data-slug="testing">testing</a>), and for password accounts a <em>Change password</em> button that opens a dialog (current password, the new one twice). The browser keeps it open until the two new ones match and are 8 characters or more; a refusal from the server (wrong current password) comes back with the dialog open and the reason in it. Saved, the dialog closes and the page says “Password changed.” (<code>static/profile.js</code> opens and closes both dialogs.) <em>Connect a Google account</em> (<code>profile-google-connect</code>) opens a dialog that asks for the password (<code>profile-google-link-dialog</code>, <code>POST /profile/google/link</code>) before the trip to Google (#360); the callback links only a trip this account started (<code>oauth::StepUps</code>, in memory, 10 minutes, once), so a signed-in <code>GET /auth/google</code> just goes to the profile. A linked account with a password has <em>Unlink</em> (<code>profile-google-unlink-open</code>, the password again, <code>POST /profile/google/unlink</code>); a Google-only account can’t unlink its only way in. The Google address itself is not stored, so the card says “Google account connected”. <em>Sign out everywhere else</em> (<code>profile-sign-out-others</code>, <code>POST /profile/sessions/end-others</code>, #375, explained behind its (i)) ends every other session; changing the password or two-factor sign-in and linking or unlinking Google do the same.</li> <li><strong>Privacy and consent</strong> (#974, <code>profile-consent</code>): one row per consent with its date, explained behind an (i). The Terms version accepted and when, with links to the Terms and the privacy policy (see <a href="#terms-of-service" data-testid="docs-link">Terms of Service</a>); the age confirmation (the date of the first Terms acceptance, read-only: taking it back means deleting the account); the phone number for sign-in codes with a link to the field that changes or removes it (hosted only); Google sign-in, connected or not, opening the same connect and disconnect dialogs as the Account card (where Google is offered); e-mail, service messages only (hosted only; an optional e-mail such as reminders gets a switch here, off by default); and <em>Do Not Show Me AI Features</em>, “Shown since …” or “Hidden since …” with the profile’s only AI switch (below; on hides them, <code>POST /profile/ai</code> returns here), a line when an own API key is on file and, where hosted AI is offered, how much of this month’s allowance is used. Optional consents are rows in <code>consents</code> (<code>src/consent.rs</code>): given at <code>granted_at</code>, taken back at <code>withdrawn_at</code>, a new row each time one is given again; they are in <em>Download my data</em> and go with the account.</li> <li><strong>Proof that it is the owner</strong> (<code>prove_owner</code> in <code>src/routes/profile.rs</code>): every change a stolen session must not make asks for it: the password (checked like <code>/login</code>: 10 per account per 15 minutes, a wrong one counting against sign-in, refused while sign-in is blocked, #378); a Google-only account has none, so a code from its second step, or with no second step <em>Confirm with Google</em> (<code>POST /profile/google/confirm</code>: a new trip to Google for the account it signs in with, which lets the next change through once, #376). It guards the password change, deleting the account, linking and unlinking Google and every two-factor change.</li> <li><strong>Do Not Show Me AI Features</strong>: an On/Off toggle pill in the Privacy and consent card (#147, #974, <code>users.ai_off</code>, off for every account unless the “Hide all AI features” pill on the registration form or the Google sign-up’s confirm step was turned on: <code>templates/ai_switch.html</code>, a checkbox <code>no_ai</code> dressed as the pill, its info dot a hover tooltip). The card’s (i) says what it covers. Switching it on posts <code>POST /profile/ai</code> (<code>off=1</code>) at once, or first opens a confirm dialog when an AI API key is on file, because hiding the features deletes every saved key. On, the account is shown none of those features: no AI tutoring card on the home page or tutor links on a lesson, no <em>Generate with AI</em> card, <em>Resume</em> or <em>Watch</em> on the course picker, no <em>Use AI</em> form or resume button on the course editor, and no <em>AI API keys</em> section here (the keys were deleted). Their routes refuse too: <code>CurrentUser::require_ai</code> sends a page request to this section with a notice, and <code>require_ai_api</code> answers a JSON route with 403. A tutor conversation under way is dropped. Switching back off needs no confirmation (<code>off=0</code>). What is left never contacts an AI company.</li> <li><strong>AI API keys</strong>: one key each for Claude (Anthropic), OpenAI and Google (Gemini), used by AI tutoring, <em>Generate the course</em> and <em>Use AI</em> (the card says so in one line; how keys are stored is behind its (i)). A key is checked for the provider’s prefix (so a key pasted into the wrong box is caught), encrypted at rest (<code>src/secrets.rs</code>, see <a href="/docs/architecture#security-notes" data-testid="docs-link" data-slug="architecture">architecture</a>) and never shown again; the page shows its last four characters, and a key can be replaced or removed at any time. Each provider is a row folded shut (name and status); its triangle opens the box to paste a key into and <em>Remove key</em>. Features call <code>secrets::get(conn, &state.vault, user_id, provider)</code> to use one.</li> <li><strong>Subscription</strong>: the learner’s plan (Free, Basic or Plus), its renewal or end date, <em>Choose a plan</em> (the plans modal: pick Basic or Plus, then pay on Stripe Checkout), <em>Cancel subscription</em>, and for Stripe customers <em>Card and invoices, on Stripe</em> (the Billing Portal). See <a href="#subscriptions" data-testid="docs-link">Subscriptions</a>.</li> <li><strong>Your data</strong>: <em>Download my data</em> (#430): <em>Prepare my data</em> (<code>POST /profile/export</code>) queues an export; the <code>export</code> job on the background runner (<code>src/exports.rs</code>, <code>src/jobs.rs</code>) writes every row stored for the account, by table, with the course files, to a file a page at a time, and e-mails a one-time link (<code>GET /profile/export/download?token=…</code>, 24 hours, signed in as that account only; the file is deleted once sent). Once it is ready the Data section also shows <em>Download my data</em> (<code>profile-export-download</code>, <code>POST /profile/export/download</code>: the same file, once, for the account signed in). Where the finished file lives (#871, <code>src/storage.rs</code>): on Skrongle mini and the dev server it stays on the server’s disk and the download streams it. On the hosted site it is uploaded to a private S3 bucket (<code>SKRONGLE_S3_BUCKET</code>) and the local copy deleted; after the same session and link checks, the download redirects to a pre-signed S3 link that works for 60 seconds, and the app deletes the bucket’s copy five minutes later. The bucket deletes anything under <code>exports/</code> after two days. Not in Skrongle mini. On a server without mail (#633) that button is the only way: no link is mailed and the (i) says so. One export in flight and one a day per account; the (i) next to the heading says where it stands. The file has the columns <code>privacy::EXPORTED</code> lists: never the password hash, session tokens, keys (only which providers have one), codes, links or the text of mails and texts. And <em>Delete account</em> (<code>POST /profile/delete</code>): the password, or for a Google-only account its e-mail address typed out and a code from its second step (<code>profile-delete-code</code>, <em>Send code</em> <code>profile-delete-send</code> for a texted or e-mailed one) or, with none, <em>Confirm with Google</em> (<code>profile-delete-google</code>, then <code>profile-delete-google-done</code>; #376); refused while a Stripe subscription would keep billing. It does not delete at once (#970, <code>src/account_deletion.rs</code>): it sets <code>users.delete_at</code> 14 days out (or to the end of a cancelled plan’s paid time, if later), ends every session of the account and, where the server sends mail, mails the date (<code>account-deletion</code>). The login page says the date (<code>login-notice</code>). Signing in again works as usual, but every page then leads to <code>/account/deletion</code> (<code>account-deletion</code>, date <code>account-deletion-date</code>, <em>Cancel deletion</em> <code>account-deletion-cancel</code>, <em>Log out</em> <code>account-deletion-logout</code>; API routes answer 403), and the account’s shared courses are hidden from their recipients meanwhile. The hourly job <code>housekeeping.deletions</code> then removes the account and everything it owns. A superuser’s delete (#967) is still immediate. What is kept and for how long: <a href="/docs/data-inventory" data-testid="docs-link" data-slug="data-inventory">data inventory</a>; the policy is at <code>/privacy</code> (a draft, <a href="/docs/privacy" data-testid="docs-link" data-slug="privacy">privacy</a>).</li> <li><strong>Superuser page</strong> (<code>/superuser</code>, superuser accounts only, #220): actions that reach any account, for people running the app; the account menu links to it. An account is a superuser when <code>users.superuser</code> is 1. With <code>SKRONGLE_SIGNUP</code> <code>invite</code> or <code>closed</code> (#634) the first account made on a server with no accounts becomes one (the owner, made only through the owner link from the log; one query in the registration transaction, password or Google); otherwise no page sets it: run <code>./spanish-learning make-owner <email></code> (#635; see <a href="/docs/self-hosting" data-testid="docs-link" data-slug="self-hosting">self-hosting</a>, “If you lock yourself out”). In dev mode the dev user (<code>dev@example.com</code>) always is one. For anyone else the menu has no link, and the page and its forms answer 404 (<code>src/routes/superuser.rs</code>). <ul> <li><strong>Release</strong> (#934, <code>src/waitlist.rs</code>, <code>/superuser/release</code>; on the hosted site only, 404 on Skrongle mini or without mail): a card on <code>/superuser</code> says whether the waitlist is on and opens the page. <strong>Turn on the waitlist</strong> / <strong>Turn off the waitlist</strong> (a confirm dialog, <code>POST /superuser/release/switch</code>) takes effect at once and survives a restart (<code>release_settings</code>). While it is on, <code>/</code> for anyone signed out is the launch’s marketing page (<code>templates/waitlist.html</code>, <code>static/waitlist.css</code>, #1071): full-width bands for a course built from one sentence, a chatbot’s answer against a course, the modes, every built-in theme in its own colours, font, banner, profile pictures, badges and praise (drawn from the theme files), the motivators and a course file, with the e-mail field at the top and the bottom (<code>waitlist-form</code>, <code>waitlist-form-end</code>). No Create account anywhere, no Log in unless <code>SKRONGLE_WAITLIST_LOGIN=1</code>, and <code>/register</code> without an invitation goes back to <code>/</code>. <code>open</code> acts as <code>invite</code>. <strong>Join the waitlist</strong> (<code>POST /waitlist</code>) mails a confirmation link (<code>/waitlist/confirm?token=</code>, at most 3 a day to one address) and shows “check your e-mail” whether or not the address was new; addresses never confirmed go after 30 days. The page counts joined, not confirmed, waiting, invited and signed up, and lists everyone in the order they joined (50 to a page) with their status. <strong>Invite</strong> (a waiting person) and <strong>Resend</strong> (invited or expired; the old link stops working) mail an invitation tied to that address, good for 14 days (<code>POST /superuser/release/invite</code>); <strong>Invite the next ones</strong> with a number (100 by default, at most 1,000) does the same for the next confirmed people not yet invited (<code>POST /superuser/release/invite-next</code>), stopping when the mail ceiling is full. The register page fills in the invited address; another address is refused (“This invitation is for a different e-mail address”), with a password or Google. An account made through one gets <code>users.beta = 1</code>, which nothing else reads yet. Turning the waitlist off is the launch: the normal home page and sign-up return, and open invitations still work.</li> <li><strong>Accounts</strong> (#634, <code>src/signup.rs</code>): the (i) says which sign-up mode the server is in (<code>SKRONGLE_SIGNUP</code>: <code>open</code>, the default and the hosted site’s; <code>invite</code>; <code>closed</code>), how invitations work and how many accounts there are. In <code>invite</code> mode, <strong>Make an invitation link</strong> (<code>POST /superuser/invites</code>, 404 in other modes) shows <code><SKRONGLE_PUBLIC_URL or the request's host>/register?invite=<token></code> once, with <strong>Copy</strong>; the token is 32 random bytes, hex, stored only as its SHA-256 in <code>signup_invites</code>, and works once, for 7 days. The open invitations are listed (made, expires) with <strong>Revoke</strong> (<code>POST /superuser/invites/revoke</code>). Below, every account, newest first, 25 to a page (<code>?page=N</code>; Newer / Older links and “Page 1 of 3” once there is more than one): e-mail address, created, last sign-in (the newest session on record; sessions last 30 days), and three actions per row, each opening a dialog with the address, what it does and a confirm button. Nothing is sent to the account; each writes one <code>info</code> log line (which superuser, which account id, what; never the password). Two are the owner’s (#635, <code>src/owner.rs</code>): <ul> <li><strong>Temporary password</strong> (<code>POST /superuser/temp-password</code>, <code>email=</code>): a generated 14-character password (letters and digits without look-alikes) is set, and shown once in a dialog with <strong>Copy</strong> (<code>Cache-Control: no-store</code>). Every session of the account ends (a superuser resetting their own keeps the current one), and <code>users.must_change_password</code> is set: after signing in with it, the <code>CurrentUser</code> extractor sends the account to <code>/password/change?next=…</code> before any other page (<code>/api/*</code> answers 403), like the Terms gate. That page asks for the new password twice (not the old one), ends other sessions, clears the flag and goes on to <code>next</code>. Choosing a password anywhere clears the flag (<code>profile::set_password_hash</code>).</li> <li><strong>Turn off two-factor</strong> (<code>POST /superuser/two-factor-off</code>): <code>users.two_factor</code> is cleared, and the account’s <code>totp_keys</code>, waiting sign-ins (<code>two_factor_challenges</code>) and change confirmations (<code>two_factor_confirmations</code>) are deleted. A confirmed phone number stays. An address no account has comes back as <code>No account has the address …</code>. The same three actions exist as commands on the binary for an owner who can’t sign in: <code>reset-password</code>, <code>disable-2fa</code>, <code>make-owner</code> (<a href="/docs/self-hosting" data-testid="docs-link" data-slug="self-hosting">self-hosting</a>, “If you lock yourself out”).</li> </ul> </li> <li><strong>Sign-up</strong> under <code>invite</code> and <code>closed</code>: while the server has no account, every start makes a new <strong>owner link</strong> (<code>signup::owner_link</code>: a <code>signup_invites</code> row with no <code>created_by</code>, one year, replacing the last) and logs it as a warning, <code>Owner link, …: <url>/register?invite=<token></code> (<code>SKRONGLE_PUBLIC_URL</code>, else <code>http://<hostname>.local:<port></code>, or the loopback address the server listens on). So whoever reaches the server first can’t claim it: only someone who reads the log can. <code>/register</code> without it is the closed page saying the server has no owner yet; with it, the form with a line saying this account is the owner’s (Google too, as for invitations). Once an account exists, unused owner links are deleted at startup and refused meanwhile. After that, <code>/register</code> without a usable link is a short page (“Sign-ups are closed”, ask whoever runs the server; no address, no form) and <code>POST /register</code> answers 403 with it; a used, expired or unknown link says which. <code>/register?invite=<token></code> shows the form, which carries the token; its “Sign up with Google” carries it too (<code>/auth/google?invite=</code> puts it in a <code>signup_invite</code> cookie for the confirm step). A new Google account is refused the same way at the callback and again at the confirm step; existing accounts always sign in. Under <code>closed</code> an invitation never works. Rate limits are as for any registration.</li> <li><strong>Reset email verification</strong> (<code>POST /superuser/reset-email-verification</code>, the third row action): the account is marked unconfirmed again and sent a new confirmation link, exactly as at registration; links sent before stop working. The notice says so (<code>No account has the address …</code> when none does) and, with the dev mailbox, links to the new message. The log records which superuser reset which account. Signing in through <code>/dev/login</code> confirms the dev user again.</li> <li><strong>Coming change to the terms</strong> (#966, hosted only): while a material entry of <code>docs/terms-changes.json</code> has a later <code>effective</code> date and some account with an address has not had its e-mail, the page shows “The terms have changed.” and a card with <strong>Send the announcement</strong>. Its confirm dialog says how many accounts it goes to; <code>POST /superuser/terms-announce</code> queues them (<code>terms::announce_upcoming</code>) and the notice says how many were queued. Once everyone has it, the notice and the card go away.</li> <li><strong>Moderation</strong> (#967, the hosted site only; on Skrongle mini these are 404 and not shown, since its owner has the database). What the Terms say we may do, each behind a confirm dialog and logged as which superuser did what to which account or course id (never the content): <ul> <li><strong>Suspend</strong> (row action, <code>POST /superuser/suspend</code>): the account’s sessions and waiting sign-ins end, <code>users.suspended_at</code> is set, no session of it resolves, and signing in (password, Google, the code step) is refused with a 403 that names the contact address (<code>SKRONGLE_LEGAL_EMAIL</code>). Its courses stop being shared out (<code>share::can_open</code>, <code>share::shared_with</code>). <strong>Lift suspension</strong> (<code>POST /superuser/unsuspend</code>) undoes it.</li> <li><strong>Delete account</strong> (row action, <code>POST /superuser/delete-account</code>): the learner’s own deletion (<code>privacy::delete_account</code>), for a breach of the Terms, a child’s data or a privacy request. Not undone; refused while a Stripe subscription is still billing. Its dialog asks for a reason, kept in the privacy log below.</li> <li>A superuser can’t suspend or delete their own account here.</li> <li><strong>Courses</strong> (#1034, <code>routes/superuser/courses.rs</code>, <code>course_flags/table.rs</code>): every made course, 25 to a page, with its title and id, owner, created, last edited, flag (Clear, Flagged, Overridden, Taken down) and what flagged it (Term list or AI check). Flagged courses come first, then the most recently edited. The search box matches the title, the id or the owner’s address (any case); the picker shows All, Flagged, Needs review, Overridden or Taken down (<code>?course_q=…&course_show=…&course_page=…</code>, kept on both tables’ pager links). A course’s title opens its dialog: id, owner, flag, when it was last checked and by which check, each match (the word, the lesson, the file and field, the text around it; the word and the text masked as on the owner’s card, held down to read them; more than five as a count that opens them all, ten to a page), the AI’s report when there is one, and any override note with who wrote it and when. Its buttons each open a confirm dialog: <strong>Override the flag</strong> (flagged only; <code>POST /superuser/courses/override</code>) needs a short note (up to 500 characters) and sets <code>course_flags.state</code> to overridden with the note, who and when: the course starts and shares again, and later checks keep the override for the same words (a new word flags it again). <strong>Clear the override</strong> (<code>POST /superuser/courses/clear-override</code>) sets it back to flagged, drops the note and checks the files again at once, so a course whose words are gone comes out clear. <strong>Take down</strong> and <strong>Put back</strong> are below. <strong>Delete the course</strong> (<code>POST /superuser/courses/delete</code>) removes it, its files, flag and shares; not undone, and not offered while it is being generated or revised. Its <code>content_reports</code> rows stay. <strong>Suspend the owner</strong> is the account’s Suspend (not offered for your own courses). <strong>E-mail the owner</strong> (#1035; flagged or taken-down courses whose owner has an address) opens a form drafted in the owner’s language (<code>users.lang</code>, <code>course_flags/owner_mail.rs</code>): the course title, that it can’t be started or shared, and the contact address (<code>SKRONGLE_LEGAL_EMAIL</code>). For a flagged course it says the content check flagged it and links to the site’s course list (<code>SKRONGLE_PUBLIC_URL/courses</code>) to sign in and see what to fix; it never names the words, the quotes or the AI’s report. For a taken-down course it says so. It never names who reported the course. The superuser edits it and presses <em>Send</em> (<code>POST /superuser/courses/email-owner</code>, purpose <code>course-flag-owner</code>, through the outbox, so the dev mailbox shows it). Nothing sends it on its own. The course remembers when (<code>courses.owner_emailed_at</code>, v75): the row says “owner e-mailed” with the date, and the form warns before a second one. The log line names the superuser, the owner and the kind of e-mail, never the text. Not in Skrongle mini (no e-mail).</li> <li><strong>Take down</strong> (from a course’s dialog, <code>POST /superuser/courses/take-down</code>). It stops opening for its owner and everyone it is shared with (<code>courses::load_ready_files</code> gives nothing), drops off the sharees’ lists, and its owner’s card shows a red ! whose dialog says it was removed for breaking the Terms, with the contact address; its editor is a 403 with the same text. The shares stay, so <strong>Put back</strong> (<code>POST /superuser/courses/put-back</code>) restores everything.</li> <li><strong>Blocked addresses</strong>: <strong>Block an address</strong> takes an address or a range (<code>203.0.113.0/24</code>, IPv6 too; no wider than /8 or /16). Every request from it gets a plain 403 before any session lookup (<code>moderation::block</code>, the client address as the rate limits read it). The list lives in memory, reloaded when the page changes it; <strong>Remove</strong> takes a block off. Blocking your own address locks you out until the row is deleted from <code>blocked_ips</code> and the server restarted.</li> <li><strong>Listed terms</strong> (#1033, part of #919): a made course with a word from <code>data/moderation/terms.txt</code> is <strong>flagged</strong> (<code>course_flags</code>, <code>src/course_flags.rs</code>). The list is short on purpose: slurs and hate phrases that are never fine in a language course, not words that are ordinary in another language (“negro”, “coger”, “retard”). Matching is whole word, case-insensitive and ignores accents, over every text field of every file. It runs when a course’s files change (editor save, upload, AI generation or revision, copy or translation, through <code>courses::revalidate</code>), when the course is loaded and before a share, and only if the files or the list changed since the last check (a SHA-256 of both). Built-in courses are not checked; Skrongle mini checks nothing. A flagged course doesn’t load (<code>courses::load_ready_files</code> gives nothing), so <strong>Start</strong> is refused (<code>POST /courses/open/{id}</code> goes to the editor with a notice naming the words, masked, and the lessons) and it drops off the lists of everyone it is shared with. <strong>Share</strong> is refused too (<code>share-flagged</code>). The owner’s card has a red ! (<code>courses-card-flagged</code>) whose dialog lists each word with its lesson and field, with <strong>Edit the course to fix it</strong>. A page never shows a matched word as it is: it shows its first letter and asterisks (“w******”, <code>templates/masked_word.html</code>), the hint “Click to reveal word” on hover, and the word only while it is held down with the mouse, a finger, Space or Enter (<code>static/modal.js</code>). More than five matches show as a count (“12 matches”) that opens the whole list, ten to a page (<code>templates/flag_matches.html</code>). Saving the course without the word clears the flag on its own.</li> <li><strong>AI check, stage 1</strong> (#1036, part of #919; <code>src/content_check.rs</code>, tables <code>content_scores</code> and <code>content_category_scores</code>): Jev (TypeSafe’s model, the one <code>scripts/fact-check.sh</code> uses) scores every part of a made course on eleven categories, one yes/no question each, all asked in one call (#1046, <code>content_check::CATEGORIES</code>): misinformation, violence, self-harm, weapons, firearms, hate speech toward any group, trivialising genocide or mass tragedies, adult content, content that exploits minors, trivialising sexual crimes, and anything else against the Terms of Use (harassing a person, crime, scams). A part is one lesson file, or a piece of up to 12,000 characters of a long one; its text fields only, not ids or pictures. Each part’s score per category, hash, model, cost, rules version and the categories it scored high on are stored; a part whose text didn’t change is not sent again (unless the rules changed, #1039), so an edit costs one lesson. Each category has its own threshold (all 0.5 for now, picked from the calibration run in <a href="/docs/testing#content-check-calibration" data-testid="docs-link" data-slug="testing">testing</a>). A course whose parts are below every threshold passes. A part at a threshold or more is queued for stage 2 (<code>review = 'queued'</code>, below), and while it waits the course shows as <strong>Needs review</strong> in the course table, after the flagged ones, with the categories that scored high next to its state, and in its dialog a table of the highest score per category (one category per row, the scores lined up on the right, a row at or over its threshold in red) and the parts that scored high with their categories. Stage 1 blocks nothing. It runs when an AI generation, revision or added lesson finishes (a <code>content_check.scan</code> job, within a minute) and before a share: Share waits for the scan (a few seconds; nothing to send when nothing changed, at most 20 s, after which the share goes ahead and the scan is retried as a job). The owner can start their own course while it runs. Hand edits are scanned by the nightly run (below). It costs about $0.0003 a lesson, so a course of eight lessons is $0.0025 and the whole built-in Spanish course would be $0.029 (the text is read once per call, so eleven questions cost about 16 % more than the two of version 1; a call per category would cost 4.7 times as much). Built-in courses are not scanned. Hosted only: the hosted server needs <code>TYPESAFE_API_KEY</code>; not in Skrongle mini. The dev server scores with a stand-in that answers from <code>data/test-fixtures/jev/content-check.json</code> (“hurting yourself is the answer” scores high on self-harm, “people like them are vermin” on hate, “el caballo es negro” on hate on purpose, a false friend stage 2 passes); <code>SKRONGLE_JEV_MODE=live</code> with the key calls the real Jev.</li> <li><strong>AI check, stage 2</strong> (#1037, part of #919; <code>src/content_check/review.rs</code>): a <code>content_check.review</code> job, queued by a scan that leaves a part queued (Share wakes the jobs thread, so it starts at once). The hosted model (Skrongle AI’s key, the courses model, <code>ai::chat</code> with purpose <code>content-review</code>) gets the parts that scored high with the categories they scored high on, the whole lesson around a piece of a long one, the course title and languages, the list of its lessons and the superuser’s override note with the earlier report, if there is one. Its prompt says that language courses teach words that look bad out of context, and that the note is to be followed unless the content clearly changed or goes beyond it. It may read other lessons of the course, twice at most, three at a time; then it answers JSON: <code>harmful</code> or <code>fine</code>, which category it breaks, the exact quotes and a report of 2 to 4 sentences. A harmful verdict flags the course (<code>course_flags</code>, source <code>ai</code>, the report as the reason, each quote located to its file and JSON field), so it doesn’t start or share. The superuser’s dialog shows the report and the quotes under the category they break; the owner’s red ! says “The AI check found text that can’t be in a course:” and lists each quote with its lesson and why (“encourages self-harm”, “attacks a group of people”). Both mask each quote like a listed word (first letter, asterisks, “Click to reveal text” on hover, shown while held down). A course a superuser overrode is flagged again only by new content the model finds harmful in spite of the note, and the report says so; the note stays. A fine verdict marks the parts <code>fine</code> on their hash, so the same text is not reviewed again until it changes; the course stays as it was. An AI flag clears when a later scan finds none of its harmful parts (the owner edits the text and presses Share). Each call is logged with its cost and recorded in <code>ai_usage</code> under the course owner, but it is the server’s own cost: it counts in <strong>Skrongle AI today</strong> and not in the owner’s month (migration 76). Past the daily cap the review waits and is tried again. A bad answer (no verdict, a harmful one without a quote) is retried as a job. Each part of each high-scoring course is one review of 1 to 3 calls, around $0.01 to $0.03 on the default courses model; at the calibrated threshold no ordinary lesson and no part of the built-in courses went to stage 2 (<a href="/docs/testing#content-check-calibration" data-testid="docs-link" data-slug="testing">testing</a>). Needs Skrongle AI as well as Jev; not in Skrongle mini. The dev server’s simulated model answers from <code>data/test-fixtures/llm/content-review.json</code>.</li> <li><strong>Nightly check</strong> (#1038, part of #919; <code>src/content_check/nightly.rs</code>): a <code>content_check.nightly</code> job runs at midnight Pacific time (<code>America/Vancouver</code>) unless <code>SKRONGLE_CONTENT_CHECK_TZ</code> names another zone. It checks every made course that was never checked or was edited since its last check (<code>content_checks.checked_at</code>, the time a scan last read its files; the scans after a generation and before a share count). Oldest edit first, at most <code>SKRONGLE_CONTENT_CHECK_PER_NIGHT</code> courses (200 by default); the rest wait for the next night. Each course goes through the listed-term check, then Jev, then the AI review of what scored high, one course at a time on the jobs thread. An owner who edits all afternoon is scanned once, and only the lessons that changed are sent. A course whose check fails (Jev or the model down) is queued as its own scan or review job and retried by the jobs retry rules; the night goes on. The log line gives how many were checked, passed, flagged, still waiting for the review, failed and left over, and the cost. Then the job queues itself for the next midnight. Taken-down courses and courses being written wait. The course table shows each course’s <strong>Last AI check</strong> (its date, or “Not checked yet”). Hosted only; not in Skrongle mini. On the dev server, <code>/dev/jobs</code> lists the queued <code>content_check.nightly</code> row and its Run now button runs it.</li> <li><strong>Check versions</strong> (#1039, part of #919): the term list and the AI check’s rules each have a version (<code>TERMS_VERSION</code>, <code>AI_CHECK_VERSION</code>), raised by hand when a change could flip a verdict. Every check saves the version it ran under. A Jev score counts only at the current AI version, and the nightly run first checks every made course whose term check is older than <code>TERMS_VERSION</code> against the list again (no cap). The course table’s <strong>Rules</strong> column shows “Terms v1” and “AI v1” and highlights a version older than the current one. CI fails a PR that changes the rules without a bump. The <a href="/docs/content-check-versions" data-testid="docs-link" data-slug="content-check-versions">changelog</a> lists every version. Hosted only; not in Skrongle mini.</li> <li><strong>AI rechecks</strong> (#1040, part of #919): a course nobody edits is never scanned again, so it would keep a verdict from old rules. Once its AI check is 3 or more versions behind <code>AI_CHECK_VERSION</code> (<code>SKRONGLE_CONTENT_CHECK_RECHECK_BEHIND</code> changes the 3), the nightly run checks it again through Jev and the AI review, after the new and edited courses and within the same nightly cap, oldest check first. Not after every bump, because each recheck costs money. A course’s AI version is the oldest version among its stored Jev scores. An override stays unless the review finds the course harmful in spite of its note; a course flagged by a recheck has a report that starts “Rechecked under AI check version N (it was last checked under version M).” A course that passes gets the current version and drops out. The log line adds how many were rechecked, flagged, still waiting for a recheck, and what the rechecks cost. The course table’s filter has “AI check 3 or more versions behind (count)”. Hosted only; not in Skrongle mini.</li> <li><strong>The check in its own process</strong> (#1041, part of #919; <code>src/content_check/worker.rs</code>): with <code>SKRONGLE_CONTENT_CHECK=external</code> the server queues the scans, reviews and the nightly run but doesn’t run them, and needs no Jev key. <code>skrongle content-check</code>, started beside it with the same <code>SKRONGLE_DB</code>, <code>TYPESAFE_API_KEY</code> and <code>SKRONGLE_AI_KEY</code>, takes them from the jobs table. Share waits up to 20 s for the worker’s scan, as it waits for its own. A worker stopped mid-check leaves the job to be taken again after its 10-minute lease. A course the worker flags stops opening within a minute. The course table’s heading gets a <strong>Content checker</strong> (i): when the worker last checked in and how many scans and reviews wait. While the worker is silent for a day, or was never seen, a red banner at the top of /superuser says so (“The content checker hasn’t checked in since …” or “has never checked in”, with the number of checks waiting) and its <strong>Details</strong> opens the same dialog. The worker runs on the server’s computer for now: running it on another computer waits for the shared database (#581). Without the setting (<code>internal</code>, the default) the server runs the checks itself. Hosted only; not in Skrongle mini. On the dev server: <code>SKRONGLE_CONTENT_CHECK=external ./.dev-start …</code>, then <code>./.dev-start --content-check</code> in another terminal runs the worker against it.</li> </ul> </li> <li><strong>Privacy requests</strong> (#981, the hosted site only, like moderation): for a request that came by e-mail from someone who can’t use Profile → Your data. Each action asks for a reason (up to 200 characters) and adds a row to the log on the card (<code>privacy_actions</code>, newest 50: when, the action, the reason, which superuser), as Delete account does. The log names no learner; the reason should point at the private request log. The (i) says to check who is asking first. How to verify, the deadlines, appeals and the request log are in <a href="/docs/privacy-requests" data-testid="docs-link" data-slug="privacy-requests">privacy requests</a>. <ul> <li><strong>Send an account its data</strong> (<code>POST /superuser/privacy/export</code>, address and reason): the profile’s export (<code>exports::request</code>) for that account, with its one-a-day limit. The link goes to the account’s own address, never to the address the request came from, and opens only signed in to that account (a locked-out learner gets a temporary password first).</li> <li><strong>Forget an address</strong> (<code>POST /superuser/privacy/forget</code>): for a person with no account whose address is in a course share invitation. Every invitation to it (<code>course_shares</code>) and every mail to it still in <code>mail_outbox</code> are deleted. An address that belongs to an account is refused (delete the account instead). The address is not echoed back in the redirect or the notice. Copyright notices sent signed out (<code>content_reports</code>) are kept as a record.</li> </ul> </li> <li><strong>Breach tools</strong> (#1068, the hosted site only, like moderation; not in Skrongle mini): the mass actions of the incident runbook (<code>ops/incident-response.md</code>) as buttons, under Privacy requests. Each dialog asks who (every account, or only the addresses pasted in a box, split on commas, spaces or new lines), a reason (into the privacy log, as <code>sign-out</code> or <code>notice</code>) and a typed word (<code>confirm</code> in English, the page’s language otherwise) before it runs; a wrong word does nothing. <ul> <li><strong>Sign accounts out</strong> (<code>POST /superuser/breach/sign-out</code>): their sessions, sign-ins waiting for a second-step code and unused password-reset links are deleted, as Suspend does for one account (<code>breach::sign_out</code>). The superuser’s own session on this device stays. Passwords are not reset: they are argon2id hashes, so the runbook signs everyone out after a leaked database and asks learners to change a password they use elsewhere.</li> <li><strong>E-mail a notice</strong> (<code>POST /superuser/breach/notice</code>): a plain-text subject and message, as typed, to every account with a confirmed address (or the listed ones that have one), through the mail outbox (purpose <code>breach-notice</code>, so <code>SKRONGLE_MAIL_PER_HOUR</code> applies and a mail over it waits for a later hour). <code>breach_notices</code> records each account and notice text in the same transaction, so pressing Send again with the same text mails nobody twice and carries on where the hourly limit stopped. The notice says how many were queued, how many had it already and how many still wait.</li> </ul> </li> <li><strong>Skrongle AI today, everyone</strong> (a card at the top, servers with Skrongle AI, #623): a bar and “$3.20 of $10.00 · resets at midnight UTC”, the day’s hosted spend of every learner (<code>ai_spend_days</code>) against the daily cap (<code>SKRONGLE_AI_DAILY_CAP</code>); the (i) says what the cap does. This is the only place the server-wide figure shows: learners see their own month, as a bar without dollars.</li> </ul> </li> </ul> <p>Each AI form says where the learner’s words go (<code>data-testid="ai-disclosure"</code>): “Your notes are sent to your AI provider using your key.” on <em>Generate</em> and <em>Use AI</em>, naming the provider (“… sent to Claude (Anthropic) …”) only when two or more keys are saved, and “Your messages are sent to Claude (Anthropic) …” in the tutor.</p> <h2 id="terms-of-service">Terms of Service</h2> <p><code>/terms</code> (public; <code>/terms.md</code> for the Markdown) shows <code>docs/terms.md</code>, the Terms and Conditions of the hosted site (first published 2026-10-25, from a Termly export). <code>/docs/terms</code> redirects there; the docs nav leaves it out.</p> <ul> <li> <p><strong>Accepting.</strong> Registration has two required boxes, “I am at least [age] years old” (<code>age</code>; <code>SKRONGLE_MIN_AGE</code>, default 14) and “I agree to the Terms and Privacy Policy” (<code>accept</code>; <code>templates/terms_check.html</code>, each sentence with a rounded check box on its right in the AI pill’s style); the server refuses the form without both, naming the missing one. A new Google account is not created at the callback but at a confirm step (<code>/auth/google/confirm</code>: the Google mark and address you are signing up as, the same two boxes, the same “Hide all AI features” pill; a missing tick comes back under the boxes, not as a banner); the verified identity waits in memory for ten minutes. Each acceptance is a row in <code>terms_acceptances</code> (user, version, time; no IP address), so the history is kept.</p> </li> <li> <p><strong>The changelog.</strong> Every version of the built-in text is an entry in <code>docs/terms-changes.json</code> (#966): <code>version</code>, <code>announced</code> and <code>effective</code> dates (<code>YYYY-MM-DD</code>, UTC), <code>material</code> (it affects learners’ rights or a paid plan) and a <code>summary</code> of a few plain sentences, in English like the terms. <code>terms::version()</code> is the newest entry in effect today, and the “Last updated” line at the top of <code>docs/terms.md</code> is the newest entry’s date. Changing the text means adding an entry in the same PR: the <code>terms-lint</code> CI job (<code>scripts/terms-lint.sh</code>) fails a PR that edits one without the other, and <code>cargo test terms</code> checks the entries (dates in order, a summary, 30 days between <code>announced</code> and <code>effective</code> for a material change, the “Last updated” line).</p> </li> <li> <p><strong>New versions.</strong> An account that accepted an earlier version sees a notice over the first page it opens (<code>templates/terms_notice.html</code>, <code>data-testid="terms-gate"</code>, <code>data-tier</code>): the summaries of every change since, and a link to <code>/terms</code>.</p> <ul> <li><em>Minor change</em>: <strong>Continue</strong> (<code>POST /terms/dismiss</code>) closes it and records acceptance of the current version; it can stay open while the learner reads, and nothing is blocked. Escape hides it for that page only.</li> <li><em>Material change</em> (or a version from before the changelog): the accept box and button of the gate, no way to close it, and a line pointing to log out or close the account from the profile. It is on every page but the profile; <code>/api/*</code> gets <code>403</code> and any other form (except deleting the account) is sent to <code>/terms/accept</code> until it is accepted.</li> <li><em>30 days ahead</em>: a material entry with a later <code>effective</code> date is e-mailed once to every account with an address (purpose <code>terms-change</code>: the date, the summary, a link to <code>/terms</code>) when a superuser presses <strong>Send the announcement</strong> on <code>/superuser</code>; nothing sends it on its own. <code>terms_announcements</code> records who got it, so pressing again only mails accounts made since (or held back by the hourly mail ceiling). It only takes effect, and the notice only appears, on its date. Not in Skrongle mini (no e-mail).</li> </ul> <p>An account that never accepted any version (one from before the terms) is sent to <code>/terms/accept</code>, and back to the page it asked for afterwards; <code>/api/*</code> gets <code>403</code>. Pages that work logged out (<code>MaybeUser</code>: the landing page, docs, <code>/terms</code>, login) show no notice and are not gated. <code>/dev/login?terms=old</code> (material) and <code>?terms=minor</code> leave the dev user one version behind, for the browser tests.</p> </li> <li> <p><strong>Your own text.</strong> A server with its own text (<code>SKRONGLE_TERMS=<file></code>, Skrongle mini) has no changelog: it uses the file’s SHA-256 as the version, so any edit sends every account to <code>/terms/accept</code> (no summary, no e-mail).</p> </li> <li> <p><strong>Links.</strong> The footer on every page, the landing page, login and register link Terms and Privacy (<code>/privacy</code>, its own page); the profile shows the version accepted and when, and its “read” link opens the terms in a dialog over the profile (the link still goes to <code>/terms</code> in a new tab or without JavaScript).</p> </li> </ul> <h2 id="content-reports-and-copyright-notices">Content reports and copyright notices</h2> <p>Hosted only (not in Skrongle mini, which has no mail). <code>src/content_reports.rs</code>, <code>src/routes/reports.rs</code>, table <code>content_reports</code> (v68, #968).</p> <ul> <li><strong>Report this course.</strong> The circle on a course someone shared with the learner (<code>/courses</code>, <code>courses-card-shared</code>) opens its “shared with you” dialog, which has <em>Report this course</em> (<code>content-report-open</code>). The form (<code>templates/content_report_dialog.html</code>) asks for a reason (harmful, copyright, other), what is wrong, and the learner’s address, filled in from the account. <code>POST /courses/{id}/report</code> stores the report, mails the operator (purpose <code>content-report</code>) and comes back to <code>/courses?notice=reported</code>. Built-in courses, the learner’s own and courses they cannot open are a 404. Ten reports an hour per account.</li> <li><strong>Copyright form.</strong> <code>/legal/copyright</code> (public, linked from <code>/terms</code>): name, address, the work, where it is (a link to the course; the first word that is a course id names the course), what is copied, and the good-faith statement, which must be ticked. Five tries a minute from one address, counted in dev mode too (<code>RateLimits::hit_always</code>); the sixth gets a 429. Without mail the page shows only <code>SKRONGLE_LEGAL_EMAIL</code> and a post is a 404.</li> <li><strong>The operator</strong> is <code>SKRONGLE_LEGAL_EMAIL</code>, or every superuser with an address when it is unset. The mail has the report and a link to <code>/superuser#reports</code>.</li> <li><strong>Superuser page.</strong> <em>Reports</em> lists the open ones, oldest first, with <em>Forward to owner</em> (<code>POST /superuser/reports/forward</code>, purpose <code>content-report-forward</code>; the reporter’s name and address only on a copyright notice) and <em>Resolve</em> (<code>POST /superuser/reports/resolve</code>). What the operator does with them: the <a href="/docs/content-reports" data-testid="docs-link" data-slug="content-reports">runbook</a>.</li> </ul> <h2 id="registration-and-password-reset">Registration and password reset</h2> <p><code>/register</code> (e-mail address, password, the <a href="#terms-of-service" data-testid="docs-link">Terms</a> boxes, the “Hide all AI features” pill) never says whether an address has an account (#379, <code>src/reset.rs</code>):</p> <ul> <li><strong>The answer</strong> to a valid form is always the same page, “Check your mail” (<code>register-sent</code>, with the address typed and a <em>Sign in</em> button, <code>register-sent-login</code>). Registering does not sign in: the learner signs in on <code>/login</code> with the password they chose (confirmed or not). The password is hashed (argon2) whatever happens next, so the answer takes as long either way. Form mistakes (a missing box, a short password, a malformed address) still come back on the form.</li> <li><strong>A new address</strong>: the account is made and the <a href="#e-mail-verification" data-testid="docs-link">confirmation link</a> is sent.</li> <li><strong>An address that has an account</strong>: nothing is made or changed. The address is sent “You already have a Skrongle account” (outbox purpose <code>register-taken</code>, at most 3 per address per hour) with a link to sign in and one to <code>/reset</code>.</li> <li><strong>An unconfirmed account old enough</strong>: a password account whose address was never confirmed, made <code>SKRONGLE_UNCONFIRMED_DAYS</code> (default 7; <code>0</code> never) days or more ago, with no subscription that would keep billing, gives the address up: registering it again deletes that account and everything in it (<code>privacy::delete_account</code>, as <em>Delete account</em> does) and makes the new one. Nobody can hold an address they cannot read.</li> <li><strong>Not in Skrongle mini.</strong> All of this hides behind a mail, so without a mail provider (<code>caps().mail</code>, #633) <code>/register</code> works as before: it says when an address has an account, signs the new account in at once and replaces no account. <code>/reset</code> is a 404 there and the login page has no <em>Forgot your password?</em> link; the owner sets a temporary password instead (the Superuser page, under <a href="#profile" data-testid="docs-link">Profile</a>).</li> </ul> <p><strong>Password reset</strong> (<code>/reset</code>, linked from the login page as <em>Forgot your password?</em>, <code>login-reset-link</code>):</p> <ul> <li><strong>Asking</strong> (<code>reset-email</code>, <code>reset-submit</code>): the page always says “If that address has an account, a link was sent” (<code>reset-sent</code>). An account (password or Google-only) gets a message (outbox purpose <code>password-reset</code>) with <code><SKRONGLE_PUBLIC_URL or the request's host>/reset?token=…</code>. At most 3 links per address and 10 requests per network per hour; past the address’s limit nothing is sent and the page says the same.</li> <li><strong>The link</strong>: 32 random bytes, hex; only the SHA-256 is stored, in <code>password_resets</code>. It works once, for an hour, and only while the account still has the address it was sent to; a new link voids the unused older ones. It opens a form for the new password (<code>reset-password</code>, <code>reset-save</code>) naming the account (<code>reset-account</code>); a used, expired or unknown link gets <code>reset-invalid</code> with <em>Send a new link</em>.</li> <li><strong>Saving</strong> sets the password, confirms the address (the link proved it is read), ends every session of the account (this browser’s too) and shows “Your password is changed” (<code>reset-done</code>). It signs nobody in, so <a href="#two-factor-sign-in" data-testid="docs-link">two-factor sign-in</a> still applies at the next login. New passwords tried with links: 10 per network per 10 minutes.</li> <li><strong>Dev mode</strong>: both messages are in the dev mailbox (<code>/dev/inbox</code>) and the reset link is logged at <code>info</code>. Rows are deleted 7 days after they were made (hourly housekeeping).</li> </ul> <h2 id="e-mail-verification">E-mail verification</h2> <p>A link proves the learner owns the address the account signs in with (<code>src/verify.rs</code>). Only <a href="#two-factor-sign-in" data-testid="docs-link">two-factor sign-in</a> is gated on it (e-mail codes need a confirmed address); login, registration, learning and the rest of the profile work the same confirmed or not (<code>CurrentUser::email_verified</code>), except that an account never confirmed gives its address up to a new registration after <code>SKRONGLE_UNCONFIRMED_DAYS</code> (see <a href="#registration-and-password-reset" data-testid="docs-link">Registration and password reset</a>).</p> <ul> <li><strong>When a link is sent</strong> (outbox purpose <code>email-verify</code>, through <code>Mailer::queue</code>): right after registering with e-mail and password; when a superuser resets the account’s verification (the Superuser page, it starts unconfirmed again); and on <em>Send the e-mail again</em> (<code>POST /profile/verify-email/resend</code>, 5 per account per hour, past that it silently sends nothing). Google accounts are confirmed when created, and when Google is linked to an account with the same address.</li> <li><strong>The link</strong> is <code><SKRONGLE_PUBLIC_URL or the request's host>/verify-email?token=…</code>: 32 random bytes, hex; only the SHA-256 is stored, in <code>email_verifications</code>. It works once, for 24 hours, and only while the account still has the address it was sent to; a new link voids the unused older ones. Opening it (signed in or not, in any browser) sets <code>users.email_verified_at</code> and shows “Your e-mail address is confirmed”; it signs nobody in. A used, expired or unknown link gets a neutral page, with <em>Send a new link</em> when signed in.</li> <li><strong>Where it shows</strong>: inside the profile’s address field, a green check (<code>email-verified</code>, “Verified” on hover) or a red ! (<code>email-unverified</code>) that opens a dialog, “Email not yet verified. Check your inbox for the link.”, with <em>Resend</em> (<code>email-verify-resend</code>). No other page mentions it.</li> <li><strong>Without mail</strong> (#633, no <code>SKRONGLE_MAIL_PROVIDERS</code> outside dev mode): no link is issued or queued (<code>verify::send</code> logs and returns), the address field shows an (i) (<code>email-no-mail-info</code>) whose dialog says the server cannot send mail, in place of the red ! and <em>Resend</em>; <em>Send the e-mail again</em> and the superuser’s reset are not offered.</li> <li><strong>Dev mode</strong>: the message, link included, is on <code>/dev/mail</code> (a page in a browser, JSON for scripts) and the link is logged at <code>info</code>; the dev user is confirmed. Rows are deleted 7 days after they were made (hourly housekeeping); existing accounts start unconfirmed.</li> </ul> <h2 id="two-factor-sign-in">Two-factor sign-in</h2> <p>After the password (or <em>Continue with Google</em>) an account can be asked for a code too (#292, <code>src/two_factor.rs</code>). One method at a time (<code>users.two_factor</code>), chosen on the profile’s <strong>Two-factor authentication</strong> card (<code>profile-two-factor[data-method]</code>, chips <code>profile-two-factor-method[data-method]</code> for Off, E-mail, Text message and Authenticator app; picking one posts <code>POST /profile/two-factor</code> and the page comes back saying what is on). Choosing another method replaces the one in use.</p> <p><strong>Only what the server can send</strong> (#633). Without a mail provider the E-mail chip is left out, without a text message provider the Text message chip (and the profile’s phone field with its dialogs); the authenticator app needs neither. Posting such a method anyway is refused (422) with a message naming the setting. An account already on a method whose channel the server has lost cannot sign in: after the password (or Google) the login page comes back (403) saying so and naming <code>SKRONGLE_MAIL_PROVIDERS</code> or <code>SKRONGLE_SMS_PROVIDERS</code>; no code is made.</p> <p><strong>Every change asks who it is</strong> (#374). Choosing Off, another method or, with it off, any method (or the app’s <em>Remove</em>) opens <em>Confirm it’s you</em> (<code>two-factor-confirm-dialog</code>, saying the change in <code>two-factor-confirm-what</code>): the account’s password (<code>two-factor-confirm-password</code>, <em>Confirm</em> <code>two-factor-confirm-submit</code>), checked on the server like <code>/login</code> (10 tries per account per 15 minutes; wrong ones count against the sign-in limit). A missing or wrong password is refused (403) with the dialog open again and <code>two-factor-confirm-error</code>. A Google-only account has no password, so it types a code from its current method instead (<code>two-factor-confirm-code</code>): the authenticator’s, or one texted or e-mailed by <em>Send code</em> (<code>two-factor-confirm-send</code>, <code>POST /profile/two-factor/code</code>, purpose <code>two-factor-confirm</code>, 10 minutes, five tries, 5 sends per 15 minutes). A Google-only account with it off has neither, so the dialog offers <em>Confirm with Google</em> (<code>two-factor-confirm-with-google</code>); back from Google it opens again saying so (<code>two-factor-confirm-google-done</code>) and <em>Confirm</em> makes the change. Once made, every other session of the account ends and the account’s address is e-mailed (purpose <code>two-factor-changed</code>), so an owner whose session someone else used hears of it. Recovery codes are not offered yet. Without JavaScript the chips post with <em>Save</em> and the page comes back with the dialog shown as a form.</p> <ul> <li><strong>E-mail</strong>: a 6-digit code e-mailed to the account’s address (outbox purpose <code>login-code</code>). Not offered until the address is <a href="#e-mail-verification" data-testid="docs-link">confirmed</a>: the chip is disabled, with a red ! (<code>profile-two-factor-email-unavailable</code>) whose dialog has <em>Resend</em>.</li> <li><strong>Text message</strong>: the same code texted to the profile’s phone number (purpose <code>login-code</code>, through the SMS outbox and its limits). Not offered until the number is verified: a red ! (<code>profile-two-factor-sms-unavailable</code>) whose dialog adds a number or sends the code. A number that replied STOP (the provider refuses texts to it, Twilio’s 21610; #969) gets its codes by e-mail instead: <code>/login/code</code> polls <code>/login/code/status</code> (<code>static/login_code.js</code>), asks for a new code once the text is refused, and then says the code went to the address and why (<code>login-code-fallback</code>). Codes to confirm a change go by e-mail too. The profile’s phone field shows a red ! (<code>phone-opted-out</code>) whose dialog (<code>phone-stopped-dialog</code>) says to text START and send a code; the first text that gets through, or a new number, turns texts back on. Hosted only: Skrongle mini has no texts.</li> <li><strong>Authenticator app</strong> (Google Authenticator and the like; RFC 6238 TOTP, SHA-1, 6 digits, 30 s, <code>src/totp.rs</code>): picking it starts the set-up, a dialog (<code>two-factor-totp-dialog</code>) with a QR code of the <code>otpauth://totp/Skrongle:<address>?…</code> link for the phone’s app to scan (<code>two-factor-totp-qr</code>, inline SVG drawn by <code>totp::qr_svg</code> with the <code>qrcode</code> crate, no image library; #304; the link itself is its <code>data-uri</code>), how to scan it behind an (i) by the dialog’s title (<code>profile-2fa-totp-qr-info</code>, its modal <code>profile-2fa-totp-qr-info-dialog</code>; the dialog has no other text), the setup key in groups of four on one row, for typing it by hand (<code>two-factor-totp-secret</code>), and, in dev mode, <em>Dev: add it to the dev authenticator</em> (<code>two-factor-totp-dev</code>, the “scan” into <a href="/docs/testing#two-factor-codes-the-dev-authenticator" data-testid="docs-link" data-slug="testing">the dev authenticator</a>). The app’s first code (<code>two-factor-totp-code</code>, <em>Turn on</em>) makes it the method. One app per account; <em>Remove</em> (<code>profile-two-factor-totp-remove</code>) forgets it and turns two-factor sign-in off (so it asks for the password like Off). The app’s secret is stored encrypted, like the AI API keys. Leaving it for another method removes it too: its secret is not kept unused.</li> <li><strong>Signing in</strong>: once the password (or Google) is right, an account with a method gets no session yet but a <code>twofa</code> cookie (10 minutes) naming a challenge, and <code>/login/code</code> (<code>login-code[data-method]</code>) asks for the code (<code>login-code-input</code>, <code>login-code-submit</code>), saying where it went (<code>login-code-sent-to</code>: <code>a•••@example.org</code>, <code>•••• 23</code>, or the app). <em>Send a new code</em> (<code>login-code-resend</code>, 5 per account per 15 minutes) and <em>Cancel</em> (<code>login-code-cancel</code>). The session is made only after the right code. In dev mode a small window floats at the bottom (<code>login-code-dev</code>) framing <a href="/docs/testing#two-factor-sign-in-the-dev-device" data-testid="docs-link" data-slug="testing">the dev device the code went to</a>: this sign-in’s texts, mail or authenticator entry, each with a <em>Use</em> button that types the code.</li> <li><strong>The rules</strong>: a texted or e-mailed code works for 10 minutes and five tries (<code>login-code-error</code> says how many are left; the fifth wrong one ends the sign-in); a new code voids the old one but keeps the tries already used (five per sign-in, not per code). Codes are stored only as SHA-256 with a random salt and the cookie’s token, itself stored only as its SHA-256. An authenticator code is taken one step (30 s) either side of now, and each step only once, so a code seen over a shoulder cannot be replayed. Wrong codes count towards the sign-in limits like wrong passwords. There is no “remember this device”: every sign-in asks.</li> <li><code>/dev/login</code> skips the second step (it is the dev shortcut); the real <code>/login</code> never does.</li> </ul> <h3 id="the-phone-number">The phone number</h3> <p>Profile → Personal information has a <strong>Phone number</strong> (<code>profile-phone</code>), saved as you type like the names. US and Canada only for now (#496): the field carries the <code>+1</code> in its left edge (<code>profile-phone-country</code>, also the field’s description, so a screen reader reads it with the label), takes digits only and punctuates them as they are typed, so ten digits become <code>(555) 555 - 5555</code> without anyone typing a bracket. A <code>+1</code> or <code>1</code> pasted in front is dropped, since it is the only country code taken. The ten digits travel as E.164 (<code>+15555555555</code>) and are stored that way (<code>users.phone</code>, <code>sms::normalize_us_number</code>); the field shows them punctuated again on the next visit (<code>sms::format_us_number</code>, which leaves an international number stored before #496 as it is). Half a number, or an area code starting with 0 or 1, is not sent. A number pasted with another country’s code is kept as it is and marked too, rather than reshaped into a <code>+1</code> number nobody can be texted on; the server refuses the same three cases (<code>sms::normalize_us_number</code>), so a client that skips the field cannot store one either, and refuses a number its own <code>SMS_COUNTRIES</code> leaves out.</p> <p>What is wrong is said by a red ! in the field’s corner (<code>profile-phone-invalid</code>), not by text under the field: its tip says it, and it opens a dialog with it (<code>phone-problem-dialog</code>, <code>phone-problem-why</code>). Screen readers get the same words from the field’s description (<code>profile-phone-error</code>, which takes no room on the page). The ! waits for a break, either two seconds without a keystroke or the field losing focus, since every number is too short before its last digit. It fades in over half a second and goes the moment the number is right again. While it shows, it takes the corner from the verified check and the unverified !, which are about the number on file. Nothing appears at the top of the page: a field that says what is wrong itself is not repeated in the page’s message (<code>profile-save-error</code>), which is left to a problem with no field of its own (a save that failed, no connection).</p> <p>A new number is unverified: a red ! inside the field (<code>phone-unverified</code>) opens a dialog (<code>phone-unverified-dialog</code>) with <em>Send code</em> (<code>phone-verify-send</code>, <code>POST /profile/phone/send</code>: a 6-digit code texted with purpose <code>phone-verify</code>, 10 minutes, five tries, 5 sends per account per 15 minutes plus the SMS limits), the code field (<code>phone-verify-code</code>) and <em>Verify</em> (<code>phone-verify-submit</code>). Once verified the field shows a green check (<code>phone-verified</code>) and text-message sign-in can be chosen. While text-message sign-in is on, the number cannot change (the field says to choose another method first).</p> <p>Only an account with a confirmed e-mail address (or a Google account) may add a number or have it texted (#431): until then the field is disabled and an (i) inside it (<code>phone-locked-info</code>) opens a dialog (<code>phone-locked-dialog</code>) that explains why, with <em>Resend the link</em> (<code>phone-locked-resend</code>). The server refuses the number and the code regardless. A number verified on one account cannot be saved, texted or verified on another (#363; a unique index on verified numbers holds it), and a number outside the server’s countries (<code>SKRONGLE_SMS_COUNTRIES</code>) is refused when it is saved. Texts are limited per account, per number and for the whole server, counted from a ledger that outlives deleted accounts (#362); a number’s sign-in codes have an allowance of their own, so verification texts cannot use it up. <code>docs/self-hosting.md</code> “Text messages” has the numbers.</p> <h2 id="subscriptions">Subscriptions</h2> <p><strong>Plans can be off.</strong> With <code>SKRONGLE_PLANS=off</code> (a self-hosted server; <code>deploy/skrongle.service</code> sets it) there are no plans: every account counts as Plus (<code>billing::plans_on</code>, asked by <code>CurrentUser</code>’s plan, <code>RequirePremium</code>, the meter and the profile), the profile has no Subscription section, nav link or plans modal, every <code>/billing/*</code> path (the Stripe webhook too) answers 404 and Stripe settings are ignored. The rest of this section is about plans on, the default.</p> <p>Three plans (<code>billing::Plan</code>, <code>src/billing.rs</code>, <code>src/routes/billing.rs</code>): <strong>Free</strong>, <strong>Skrongle Basic</strong> (CA$3 a month: supports the project, gets the paid features as they land) and <strong>Skrongle Plus</strong> (CA$15 a month: Basic, and <a href="#skrongle-ai-allowances-and-credits" data-testid="docs-link">Skrongle AI</a> included each month). “Premium” means any paid plan.</p> <ul> <li><strong>Choosing a plan.</strong> The profile’s Subscription card has <em>Choose a plan</em> (Free) or <em>Upgrade to Plus</em> (Basic), which opens a modal with the two plan cards (<code>plan-card[data-plan]</code>: name, price, what is included) and <em>Pay with Stripe</em>. Picking a card prices the button; it posts the plan to <code>/billing/checkout</code>, which sends the learner to Stripe Checkout (below). There are no card fields on our page: the card is typed on Stripe’s. A learner who already pays (an upgrade, or taking back a cancellation) is sent to Stripe’s Billing Portal instead, where plans change. The pretend card payment of #315 (<code>POST /billing/subscribe</code>, <code>plan_subscriptions</code>) was removed in #963, with its table (v83): it gave plans away free.</li> <li><strong>What the card shows.</strong> The plan’s name (<code>profile-plan</code>), the renewal date (<code>profile-renewal</code>, under “Renewal date”; under “Ends on” once cancelled), <em>Cancel subscription</em> while the plan renews, and for a Stripe customer a link to the Billing Portal (card and invoices). Prices appear only in the chooser.</li> <li><strong>Cancelling.</strong> <em>Cancel subscription</em> opens one confirm dialog (<code>cancel-dialog</code>): an optional reason (a radio with four: <code>billing::CANCEL_REASONS</code>), an optional feedback box, and <em>Confirm cancellation</em>; there is no second “are you sure?”. <code>POST /billing/cancel</code> asks Stripe to end the subscription at its period’s end (<code>cancel_at_period_end=true</code>); the learner keeps the plan until then, and Stripe’s webhook confirms it. The reason and words go to <code>plan_cancellations</code>.</li> <li><strong>Where the plan comes from.</strong> <code>plan_sql</code> decides, in this order: a Stripe subscription in a paying status (<code>active</code>, <code>trialing</code>, <code>past_due</code>) is the plan sold at its price (<code>subscriptions.plan</code>); an unexpired <strong>grant</strong> (<code>plan_grants</code>: Plus given by hand, or by the dev toggle) is Plus. Everyone else is Free. Nothing else can make a learner paid. <code>CurrentUser::plan</code> and <code>::premium</code> carry the answer on every request; templates read <code>user.premium</code> to show or hide entry points.</li> <li><strong>Gating a feature.</strong> Add <code>_: billing::RequirePremium</code> to the handler’s arguments. A free learner’s page request is redirected to <code>/profile?upgrade=1#subscription</code> (the plans modal opens, with “That feature comes with a subscription.”), an <code>/api/*</code> request gets <code>402 {"error":"subscription required"}</code>. Keep the rule from the course features: what a free learner cannot use does not appear, or appears with the offer. <code>?plans=1</code> opens the modal from anywhere (the <em>Upgrade to Plus</em> buttons where Skrongle AI runs out).</li> <li><strong>Stripe</strong> handles every payment. <code>/billing/checkout {plan}</code> creates a Stripe Checkout session for that plan’s monthly price and redirects there. The account’s Stripe customer is created before its first Checkout and stored (<code>users.stripe_customer</code>), so every Checkout (two tabs, a double submit, a resubscription) shares it; the subscription’s metadata carries the account’s random <code>billing_ref</code>, never its id. While a payment is still being confirmed (an <code>incomplete</code> subscription) Checkout refuses to start another. Stripe sends the learner back to <code>/profile?billing=done</code> or <code>?billing=cancelled</code>.</li> <li><strong>Managing.</strong> <em>Card and invoices, on Stripe</em> posts to <code>/billing/portal</code>, which opens Stripe’s Billing Portal for the customer: card, invoices, cancelling. Cancelling there keeps the plan until the end of the paid period.</li> <li><strong>Webhooks decide the plan.</strong> The plan changes only when Stripe’s signed webhook (<code>POST /billing/webhook</code>) reports a <code>customer.subscription.*</code> event; the browser coming back from Checkout proves nothing. <strong>The plan is the one sold at the subscription’s price</strong> (<code>StripeConfig::plan_for_price</code>: <code>SKRONGLE_STRIPE_PRICE_ID</code> is Plus, <code>SKRONGLE_STRIPE_PRICE_ID_BASIC</code> is Basic), never the metadata, which anyone with a Stripe account could set; a paying subscription at any other price is ignored (#963). Each event carries the whole subscription and overwrites the learner’s row; older events than the last one applied are skipped, since Stripe does not promise order. An event is ignored when its <code>billing_ref</code> names no account, its customer is not the account’s, or the subscription is older than the account (a deleted account’s subscription never reaches anyone else). A second subscription paying beside the first is not tracked: the server logs a warning naming both, to refund or cancel in Stripe.</li> </ul> <h3 id="setting-up-stripe">Setting up Stripe</h3> <ol> <li>In the Stripe dashboard create two <strong>recurring monthly prices</strong> in CAD: Plus (CA$15, <code>SKRONGLE_STRIPE_PRICE_ID</code>) and Basic (CA$3, <code>SKRONGLE_STRIPE_PRICE_ID_BASIC</code>); note their ids (<code>price_…</code>). Both are required on the hosted site and must differ: the webhook tells the plans apart by price.</li> <li>Under <em>Developers → Webhooks</em> add an endpoint at <code><SKRONGLE_PUBLIC_URL>/billing/webhook</code> listening to <code>customer.subscription.created</code>, <code>customer.subscription.updated</code> and <code>customer.subscription.deleted</code>; note its signing secret (<code>whsec_…</code>).</li> <li>Turn on the <em>Customer portal</em> (Settings → Billing → Customer portal) and allow cancelling, updating the payment method and switching plans between the two prices (an upgrade from Basic to Plus happens there).</li> <li>Set <code>SKRONGLE_STRIPE_SECRET_KEY</code>, <code>SKRONGLE_STRIPE_WEBHOOK_SECRET</code>, <code>SKRONGLE_STRIPE_PRICE_ID</code>, <code>SKRONGLE_STRIPE_PRICE_ID_BASIC</code> and <code>SKRONGLE_PUBLIC_URL</code> (optionally <code>SKRONGLE_STRIPE_PRICE_LABEL</code>, e.g. <code>€4 a month</code>, for the button). All four or none; see <a href="/docs/architecture#configuration" data-testid="docs-link" data-slug="architecture">configuration</a>. On the AWS server they are Parameter Store settings (<code>scripts/aws-env.sh set …</code>, <a href="/docs/aws-setup" data-testid="docs-link" data-slug="aws-setup">AWS setup</a> step 8).</li> </ol> <p>Locally, dev mode needs none of this: without <code>SKRONGLE_STRIPE_*</code> it points the app at <a href="/docs/testing#payments-the-dev-payment-provider" data-testid="docs-link" data-slug="testing">the dev payment provider</a> (<code>/dev/payments</code>, #314), a stand-in that speaks Stripe’s API with fake keys, so the plans modal pays through it as it would through Stripe; it hosts a Checkout page (<strong>Pay</strong>, <strong>Fail</strong>, <strong>Cancel</strong>) and a Billing Portal, and sends signed webhooks to <code>/billing/webhook</code>; its Payments section in the dev mailbox renews, fails, cancels and refunds. To try the real Stripe in test mode, set the four settings and forward webhooks with the Stripe CLI: <code>stripe listen --forward-to localhost:9000/billing/webhook</code> prints the <code>whsec_…</code> to use. The dev toggle grants premium either way, and <code>/dev/login?plan=basic|plus|free</code> buys or cancels a plan through the dev provider (see <a href="/docs/testing#dev-routes" data-testid="docs-link" data-slug="testing">testing</a>).</p> <p><strong>Granting premium by hand</strong> (no Stripe needed):</p> <pre><code class="language-sh">sqlite3 /var/lib/spanish-learning/learn.db \ "INSERT OR REPLACE INTO plan_grants (user_id, granted_at, expires_at, note) SELECT id, unixepoch(), NULL, 'friend' FROM users WHERE email = 'ana@example.org';" </code></pre> <p><code>expires_at</code> is a Unix time, or <code>NULL</code> for no end; <code>DELETE FROM plan_grants WHERE user_id = …</code> takes it back.</p> <h2 id="skrongle-ai-allowances-and-credits">Skrongle AI, allowances and credits</h2> <p>The tutor and the course generator can run on the server’s own AI key (<em>Skrongle AI</em>), so a learner without a key can still use them, within their plan’s monthly allowance (<code>src/metering.rs</code>, <code>src/routes/hosted.rs</code>). Without <code>SKRONGLE_AI_KEY</code> none of this is offered and every AI feature works on the learner’s own keys. The numbers (allowances, prices, cap, packs, models) are config values; the defaults and the margin math are in <a href="/docs/pricing" data-testid="docs-link" data-slug="pricing">Pricing</a>: <strong>Plus (CA$15 a month) includes $1.50 of list-price AI; Free and Basic get none</strong> and bring their own keys. A new account can get a one-time amount of credits when its address is first confirmed (<code>SKRONGLE_AI_ALLOWANCE_FREE</code>, none by default). <strong>Credit packs</strong> are sold on every plan, as pay-as-you-go Skrongle AI on top of a plan or instead of one (#931). Learners never see dollar amounts: what is left is a bar and percentages.</p> <p>With <code>SKRONGLE_PLANS=off</code> (<a href="#subscriptions" data-testid="docs-link">Subscriptions</a>) the server’s key is shared by every account: Skrongle AI is offered to everyone, no monthly allowance applies (<code>metering::UNMETERED</code>), only the daily cap and the rate limit guard it, credit packs are not sold, and “Skrongle AI left this month” (the profile’s bar, the tutor’s and generator’s line) is left out. Own keys always work beside it (no “use my keys” switch). <code>ai_usage</code> rows are written as ever.</p> <ul> <li><strong>One key, our meter.</strong> Anthropic’s workspaces and keys cannot be made per learner from the app, so the server keeps one key and meters each learner itself; Anthropic’s Cost API is for reconciling the month (<a href="/docs/pricing#reconciling-with-anthropic" data-testid="docs-link" data-slug="pricing">Pricing</a>).</li> <li><strong>The same client as an own key.</strong> A Skrongle AI call is an ordinary <code>ai::Provider</code> (same <code>ai::Call</code>, wire format, timeouts and error handling) whose key is the server’s and whose <code>source</code> is <code>Hosted</code>. Every call, own key or not, goes through <code>Meter::call</code>: with the server’s key it checks the allowance first, and it writes the <code>ai_usage</code> row either way.</li> <li><strong>The server’s key</strong> (<code>SKRONGLE_AI_KEY</code>, for <code>SKRONGLE_AI_PROVIDER</code>, default <code>anthropic</code>, at <code>SKRONGLE_AI_BASE_URL</code> or the provider’s default) is read at boot, removed from the environment and kept in memory sealed by the same vault as the learners’ keys; it is opened only to make one call. Each purpose has its model: <code>SKRONGLE_AI_MODEL_TUTOR</code> (default Claude Haiku 5.5) for the tutor, <code>SKRONGLE_AI_MODEL_COURSES</code> (default Claude Sonnet 5.5) for generating, revising and adding lessons.</li> <li><strong>Every call is recorded</strong> in <code>ai_usage</code>: learner, course, job, purpose (<code>tutor-turn</code>, <code>generate-lesson</code>, …), provider, model, input / output / cached tokens, cost and key source. A hosted call is costed from the price table (<code>SKRONGLE_AI_PRICES</code>, dollars per million tokens, Anthropic’s list prices by default; unknown models at the dearest <code>*</code> rate; a cache write counts as 1.25 input tokens); a call on the learner’s own key is recorded at cost 0 and never limited. Amounts are micro-dollars.</li> <li><strong>Who is offered it.</strong> A learner on Plus (the month’s allowance, <code>SKRONGLE_AI_ALLOWANCE_PLUS</code>, default $1.50, see Subscriptions) or a learner on any plan who holds credits (#931); Free and Basic have no allowance. It is offered only for a use the month could pay for at all: the allowance plus credits must reach the estimate of that use’s smallest request (<code>Meter::cheapest</code>: one tutor reply, a course’s outline and one lesson, or one added lesson; <code>AiStatus::covers</code>). Anyone else is never offered Skrongle AI for it (#283): the tutor, the Generate dialog and <em>Use AI</em> show only their own keys (or “add an API key”), the API refuses <code>provider: hosted</code>, and the profile has no <em>Skrongle AI this month</em> card. With no key either, the home page’s AI tutoring card shows a red ! in place of its (i) (tip: “No AI keys set”), and <em>Start a conversation</em> (or the !) opens the tutor’s “add an API key” page as a modal instead of going to <code>/tutor</code>; a learner with a usable key or Skrongle AI for the tutor goes straight there. That modal, the <code>/tutor</code> page itself and the Generate dialog’s “add a key” line say why (none included, or too little for this) and what to do instead: an own key (with the pill on, on Plus), or Plus when Plus’s allowance would cover it, with an <em>Upgrade to Plus</em> button (<code>templates/not_included.html</code>, <code>hosted::NotIncluded</code>). A Plus allowance too small for a course says so in <em>Skrongle AI this month</em>’s (i). Running out during the month is different: the choice stays, and the refusal offers the ways on.</li> <li><strong>Plus and own keys.</strong> On Plus, Skrongle AI comes first: the tutor, the Generate dialog and <em>Use AI</em> use it, and the learner’s own keys are not used at all unless the profile’s pill <em>use my keys when my Skrongle AI limit is hit</em> is on (<code>users.own_keys_fallback</code>, <code>POST /profile/own-keys</code>, <code>hosted::own_keys_usable</code>). On, the saved keys are offered after Skrongle AI, and when a hosted call or job is refused for want of allowance the first saved key takes over: the tutor switches the conversation to it and says so (<code>notice</code> in the turn’s JSON), a generation or revision starts on it instead. The API keys card shows only the pill while it is off, the key rows when it is on (<code>profile-own-keys-switch</code>, <code>profile-own-keys-body</code>).</li> <li><strong>Allowance.</strong> Dollars of AI a calendar month (UTC). Spending order: the allowance first, then prepaid credits. The check happens <strong>before</strong> every hosted call, never during one: a reply is never cut off. The tutor estimates a turn (its prompt plus the longest reply) and does not call the model when it is not covered; the turn’s grading still happens and the chat says why, with links to upgrade (free plan, Stripe on), to buy credits (when sold) and to add an own key (<code>tutor-out-of-ai</code>, <code>tutor-upgrade</code> to the plans modal, <code>tutor-buy-credits</code>, <code>tutor-own-key</code>).</li> <li><strong>Jobs reserve.</strong> Generating, revising or adding a lesson on the server’s key estimates the whole job up front (<code>ai::job_tokens</code>: the format reference and the material with each request, a plan, lessons of about 6 000 output tokens) and holds that much. The Generate dialog says what is left and about what a lesson costs (“AI left this month: $1.20 · about $0.08 a lesson”, <code>generate-ai-left</code>), and refuses a job the learner cannot cover before it starts, with the ways on as buttons under the message (<code>ai-out</code>: <code>ai-out-upgrade</code> to the plans modal with Plus’s price, <code>ai-out-buy</code> per pack with its price, <code>ai-out-own-key</code>); <em>Use AI</em> on a course does the same. Each call settles against the hold; when the job ends, however it ends, the rest is released. A job that outruns its estimate goes on while the learner has allowance or credits, and stops between lessons (keeping what it wrote, ready to resume) when nothing is left. Holds live in memory, like the jobs.</li> <li><strong>When the server’s key fails.</strong> A refused key or an exhausted account on the server’s side is not the learner’s to fix: they read “Skrongle AI is not available right now. Try again later, or add your own API key on your profile.” and the log has an error line for the operator. Other failures (busy, timed out, an unusable reply) read as they do on an own key.</li> <li><strong>Guards.</strong> <code>SKRONGLE_AI_DAILY_CAP</code> (default $10): hosted spend across everyone per UTC day, holds included; when it is reached hosted calls are refused until midnight and the log carries an <code>ALERT</code> error line (once a day). <code>SKRONGLE_AI_RATE_PER_MINUTE</code> (default 12): hosted requests one account may start in a minute (tutor calls and job starts; a job’s own calls are not counted).</li> <li><strong>What the learner sees.</strong> Never a dollar amount (#315). The tutor offers <em>Skrongle AI (included)</em> (first on Plus, after any own keys otherwise), and its header shows “Skrongle AI left this month: 83 %” (<code>tutor-ai-left</code>), updated after each turn. The <strong>Generate the course</strong> dialog offers <em>Skrongle AI</em> (the only choice, so not shown, when it is alone) and says what is left and about what a lesson takes (“… · about 6 % a lesson”, <code>generate-ai-left</code>); <strong>Use AI</strong> on a course says the same (<code>course-ai-left</code>). The profile’s <em>Skrongle AI this month</em> card (<code>#ai-usage</code>, Plus only) is a bar (<code>profile-ai-bar</code>, <code>aria-valuenow</code> the percent used, <code>data-left</code>) with “83 % left · fills up again on 1 November 2026”; its (i) explains the allowance and the own-keys pill. The percentages are of the month’s total: the allowance plus any credits held or spent; a job’s hold counts as used. <em>Hide all AI features</em> hides all of this (the section too) like every other AI feature.</li> <li><strong>Credits.</strong> Prepaid packs (<code>SKRONGLE_AI_CREDIT_PACKS</code>, <code>price_id:credit dollars:label</code>, e.g. <code>price_123:3.00:$5</code>) are sold as one-off Stripe Checkout payments (<code>POST /billing/credits</code>; 400 when Stripe or the pack is not configured), offered only when Stripe is configured, on every plan: on Plus they are spent after the allowance, on Free and Basic they are the only Skrongle AI (#931). The signed webhook adds them: <code>checkout.session.completed</code> (or <code>checkout.session.async_payment_succeeded</code>) for a session with <code>metadata.kind = ai-credits</code>, once per session (<code>ai_credit_purchases</code>); a <code>charge.refunded</code> (refund in the Stripe dashboard) takes back the refunded share. Credits do not expire. Add those event types to the webhook endpoint and create one-off prices for the packs.</li> <li><strong>In dev</strong> (<code>./.dev-start</code>, <code>scripts/e2e.sh</code>) the server’s key is the simulator’s platform key <code>sk-ant-dev-platform</code>, which the dev-mode default Anthropic base URL sends to <code>/dev/ai</code>: its own counters, limits and failure injection on the <strong>Platform key</strong> card there.</li> </ul> <h2 id="install-as-an-app">Install as an app</h2> <p>A phone or desktop browser can add Skrongle to the home screen (“Install app” in Chrome, “Add to Home Screen” in Safari). It then opens full-screen at the home page, with the Skrongle icon. Every page links <code>/manifest.webmanifest</code> (<code>web_manifest</code> in <code>src/routes/mod.rs</code>), which carries the base path in <code>start_url</code>, <code>scope</code> and the icon paths (<code>static/icon-192.png</code>, <code>static/icon-512.png</code>). The same in the hosted site and Skrongle mini; a browser only offers it on an HTTPS page or localhost.</p> <p>There is no service worker and nothing is cached for offline use. Sprint grades each answer on the server, so studying offline is a separate piece of work (#345).</p> <h2 id="adding-features">Adding features</h2> <p>Every feature is a Rust module (<code>src/<feature>.rs</code> for the engine, <code>src/routes/<feature>.rs</code> for the handlers, <code>templates/<feature>.html</code>, optionally <code>static/<feature>.js</code>). Handlers take the <code>Project</code> extractor, which carries the signed-in user and the active course (<code>Arc<ContentStore></code>), and persist with a <code>course_id</code>. New lesson fields go in <code>src/content.rs</code> (with validation) and in <a href="/docs/course-format" data-testid="docs-link" data-slug="course-format">the course format</a>. Keep the two in step; the format page is what LLMs are given to write courses. A feature that course files can switch on must also be added to the test course (<code>data/test-courses/test-course/</code>, see <a href="/docs/testing#the-test-course" data-testid="docs-link" data-slug="testing">testing</a>) and to the browser tests that use it, in the same change. Every piece of text the feature shows (templates, handler messages, what its script draws) goes into <code>i18n/en.yaml</code> and every other <code>i18n/<code>.yaml</code> as a key, never into the markup or code: see <a href="/docs/i18n" data-testid="docs-link" data-slug="i18n">i18n</a>.</p> </article> </div> </main> <footer class="site-footer"> <div class="container muted small">Skrongle · <a href="/terms" data-testid="footer-terms">terms</a> · <a href="/privacy" data-testid="footer-privacy">privacy</a> · <a href="/cookies" data-testid="footer-cookies">cookies</a> </div> </footer> <script> // A link to the page you are on says so (aria-current), for screen readers // and tests alike; the Study and Play mode bars set it on the server already. (function () { var here = location.pathname.replace(/\/$/, '') + location.search; document.querySelectorAll('a[href]:not([aria-current])').forEach(function (a) { if (a.origin === location.origin && !a.hash && a.pathname.replace(/\/$/, '') + a.search === here) a.setAttribute('aria-current', 'page'); }); })(); (function () { var m = document.getElementById('account-menu'); if (!m) return; document.addEventListener('click', function (e) { if (m.open && !m.contains(e.target)) m.open = false; }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && m.open) { m.open = false; m.querySelector('summary').focus(); } }); })(); </script> <script> // The app's text for the scripts: I18N holds the keys under common.js (every // page) and what a page adds with Object.assign(I18N, …); t(key, {name: value}) // and tn(key, n) read it (i18n/README.md). window.I18N = {"common.js.all_saved":"All changes saved","common.js.close":"Close","common.js.could_not_save_reload":"Your changes could not be saved. Reload the page.","common.js.leave_anyway":"Leave anyway","common.js.leave_fields":"Some fields need attention and are not saved. Leave and lose those changes?","common.js.leave_latest":"Your latest changes are not saved yet. Leave and lose them?","common.js.leave_lose_these":"{why} Leave and lose those changes?","common.js.leave_title":"Leave this page?","common.js.meaning":"Meaning","common.js.no_voice":"(no {language} voice)","common.js.not_saved":"Changes not saved","common.js.notation_label":"Music notation","common.js.saving":"Saving…","common.js.server_error":"Could not save your changes (the server said {status}). Trying again…","common.js.signed_out":"You have been signed out. Sign in again in another tab, then keep editing.","common.js.stay":"Stay","common.js.tap_to_hear":"Tap to hear it","common.js.unreachable":"Could not reach the server. Your changes are not saved yet; trying again…"}; window.t = function (key, args) { var s = I18N[key]; if (s == null) { console.warn('i18n: no text for ' + key); s = key; } return args ? s.replace(/\{(\w+)\}/g, function (m, n) { return n in args ? args[n] : m; }) : s; }; window.tn = function (key, n) { return t(key + (n === 1 ? '.one' : '.other'), { n: n }); }; </script> <script> // A phone's mode bar scrolls sideways (#423): bring the current tab into view. document.querySelectorAll('.modebar ul').forEach(function (u) { var a = u.querySelector('[aria-current]'); if (!a || u.scrollWidth <= u.clientWidth) return; var ur = u.getBoundingClientRect(), ar = a.getBoundingClientRect(); if (ar.right > ur.right || ar.left < ur.left) u.scrollLeft += ar.left - ur.left - (ur.width - ar.width) / 2; }); </script> <script src="/static/modal.js?v=a63a84e628" defer></script> <script src="/static/speech.js?v=a63a84e628" defer></script> <script src="/static/say.js?v=a63a84e628" defer></script> </body> </html>