A study app for NCEA Level 1 (NZ) subjects, built for a specific student. Currently: the four NCEA science-strand subjects (Biology, Chemistry, Physics, Earth & Space) plus a fifth, Geography, which so far is just a link out to a standalone page (see "Current state" for both this and how the four science subjects used to be bundled under a single "Science" subject before being flattened out in v0.6.0). Designed so more subjects can be added later without restructuring.
This file exists so a new chat can pick up work without re-deriving the
decisions below. Read this before touching app.jsx.
- Version: 0.11.0 (in
APP_VERSIONinapp.jsx, and matchingCACHE_NAMEinsw.js) - v0.11.0 fixed a real bug in the v0.7.0 Learn stepper: a fact could
be paired with a question that tests a concept the next fact hasn't
taught yet. Reported by the user against
gen-basics's first fact (which had 2 attached questions; the 2nd asked for "allele", a term only defined in that fact's own band-2 child, not shown until the Merit tab). Root cause:buildLearnStepspairs a band's facts with that band's questions purely by position (seedistributeQuestionsbelowbandOfinapp.jsx) — it has no idea what a fact actually teaches or a question actually tests. This is fine as long as every band-N question's concept is actually covered by a band-≤N fact. It had quietly stopped being true in several places after the v0.8.0/ v0.9.0 genetics and physical-properties passes, because splitting one wordy fact into a short parent + short children (for the "concise facts" pass) sometimes pushed the specific vocab a band-1 QUESTION needed down into a band-2 CHILD, or scrambled the fact/question array order enough that positional pairing landed on the wrong topic.- The fix is always in the content, never in the algorithm.
distributeQuestions's even-split-by-position behaviour is correct and was left alone; every fix here was one of: (a) re-banding — flip a child fact'sbandfrom 2 to 1 when a band-1 question depends on it (e.g.gen-basics's "Alleles are versions of a gene" and "Chromosomes carry genes" children;prop-network's "Diamond: a 3D network"/"Graphite: 2D layers" bond-count children), (b) reordering facts and/or thequestionsarray so the position-based pairing lines up with which fact actually teaches which question (prop-metallic,prop-molecularneeded both facts AND questions reordered — tracked by hand againstdistributeQuestions'sceil(i·Q/F)formula), or (c) adding a missing fact where a question tested a concept that was never taught at all, not even in a wrong band (prop-ionichad dropped the terms "cation"/"anion" and never defined "electrolyte";prop-molecularnever stated molecular substances have low melting/boiling points as a plain band-1 observation, only the band-2 "why";prop-polymersnever said polymers are poor electrical conductors at all). - A generally useful pattern found while fixing this: a
content-free "intro" parent fact with no question of its own,
placed before children that DO have matching questions, always
steals the first one —
distributeQuestionsfills front-to-back, so the empty wrapper "wins" the slot meant for its first real child. (gen-tracking's old "Tracking inheritance" wrapper did this to "Punnett squares"; fixed by deleting the wrapper and promoting each tool to its own top-level band-1 fact.) Avoid these wrappers, or give them their own question, when authoring new content. - Verification method: dumped
SUBJECTSto JSON, re-implementedcollectFactsByBand/distributeQuestions/buildLearnStepsin a throwaway Node script, and printed the exact fact→question step sequenceLearnScreenwould produce for every genetics and physical-properties submodule atmaxBand: 3— this is the reliable way to check this, not eyeballing the JSX. A cheap heuristic (does a normalized substring of the question's answer appear anywhere in the cumulative fact text up to that band?) is useful for a first pass but has both false positives (distractor options, paraphrased answers) and false negatives (a term used in passing without being formally taught) — don't trust it alone, always eyeball the actual step sequence for anything it flags and anything semantically adjacent to what a user reports. - Known gap, not yet fixed: this same class of bug likely also
exists in the biology/chemistry/physics/earthspace content that
predates the Learn stepper (cells, ecology, atoms, reactions,
forces, energy, earthsystems, solarsystem) — those submodules were
authored back when Learn was a static accordion and Revise was a
fully decoupled quiz, so nothing ever required a question's concept
to be taught by a specific, earlier fact. The same heuristic script
flagged ~70 more candidates across those modules when run
unscoped; none were fixed as part of this pass (out of scope — the
user's report was about genetics specifically, and physical-properties
was fixed because the same recent editing pass caused it). Worth a
dedicated pass later, ideally re-using the dump-to-JSON +
buildLearnStepssimulation approach above rather than manual tracing, which is extremely easy to get wrong by hand once a submodule has more than 2-3 facts (see theprop-metallic/prop-molecularfixes above, which needed multiple rounds of simulated tracing to get the ordering right).
- The fix is always in the content, never in the algorithm.
- v0.10.0 added a fifth subject, Geography, and with it a new
externalUrlsubmodule pattern for content that's deliberately NOT integrated into the app — just linked out to a standalone static page.SUBJECTS.geographyhas one module (case-studies) with one submodule (hotspot-oahu, id matches the uploadedhotspot-oahu.htmlat the repo root) that carriesexternalUrl: "hotspot-oahu.html"alongside emptylearn: []/questions: []arrays (kept as empty arrays rather than omitted, sincesubmoduleBandScoreetc. call.filteronsubmodule.questionsunconditionally).SubmoduleListScreencheckssm.externalUrlper row: if set, the row callswindow.open(url, "_blank", "noopener,noreferrer")instead ofonOpenSubmodule(so it never enters the Learn/Revise flow at all), and renders anExternalLinkicon + "Opens in new tab" hint instead of the mastery checkmark/index number and the three per-band score rings (which would otherwise always read empty, since there's no quiz content to score).- A new
hasQuizContentcheck (module.submodules.some(sm => !sm.externalUrl && sm.questions.length > 0)) hides the ring/"N of M topics mastered" header and the "Revise this whole module" button entirely when a module has nothing scorable — otherwise Revise would open on an empty, permanently-disabled question pool. This is generic (works for any future link-only module), not hardcoded to Geography specifically. - Added a new
--geographyCSS accent var (teal,#4FC1BA) alongside the existing per-subject colours, and a new hand-rolledExternalLinkicon next to the other inline icons near the top ofapp.jsx. hotspot-oahu.htmladded tosw.js'sAPP_SHELLprecache list so it works offline too, same as the rest of the app shell.- Nothing about the existing subject/module/submodule/progress plumbing changed for the four science subjects — this is additive.
- v0.9.0 tightened the Physical Properties module (Chemistry) the
same way as the v0.8.0 genetics pass below, but content-complete
already (no missing concepts vs. the source file this time) — just
wordy. All 6
prop-*submodules'learnfacts were split from dense multi-sentence bodies into short, single-idea parent/child facts, questions left untouched. Verified post-edit: average fact body ~17 words, longest 32 words — down from ~38 words average and several 45–61-word single facts beforehand. Also closed one small pre-existing content gap while in there: added a "metals generally have high melting points" fact toprop-metallic(a related question already existed,prop_q23, but nothing taught it explicitly). - v0.8.0 expanded and tightened the Genetics & Variation module
(Biology), reviewed against
content/92022_NCEA_L1_CB1.3_Genetics_and_Variation.txt: added a newgen-populationsubmodule (mating patterns, migration, allele frequencies — genuinely missing before, not just thin), added phylogenetic trees togen-trackingand carrier-status/sickle-cell togen-basics(both named as tracking/example concepts in the source but absent from the app), and added 10 new questions (gen_q17–gen_q26) across all five submodules. Every existinglearnfact body was also rewritten shorter — multi-sentence, multi-concept bodies were split into separate one-idea facts (parent/child nodes) rather than trimmed in place, since the v0.7.0 Learn-mode stepper now shows one fact per screen and long paragraphs are much more noticeable there than they were in the old accordion. Verified post-edit: average fact body is ~15 words, longest is 26 words (one sentence) — down from several 40–50-word, 2–3-sentence bodies beforehand. - v0.7.0 turned Learn mode into an interleaved fact→question
stepper. It used to be a static expandable accordion
(
LearnNode/teaser, both now deleted) showing all of a submodule'slearntree at once. NowLearnScreenwalks through one step at a time — a short fact, then zero or more of the submodule'squestions(the same question objects/bank Revise uses, answered inline with identical immediate feedback), then Continue to the next fact.- Pairing is automatic, not hand-authored. There's still no data
field linking a specific fact to a specific question (facts and
questions are two independent arrays per submodule, sharing only a
bandtag) —collectFactsByBand()bucketslearnnodes by band via a depth-first walk,distributeQuestions()spreads that band's questions across that band's facts as evenly as possible (ceil(i·Q/F)slice boundaries), andbuildLearnSteps()composes both into the flat step listLearnScreensteps through. This recomputes fresh from whatever's inSUBJECTSevery time — nothing is persisted about the pairing, so it needs zero upkeep as content is added/changed, though which fact a given question lands under can shift when nearby content changes (harmless, since nothing depends on that). - Fact:question ratio is skewed, unevenly, per band — this is
exactly why an even-distribution formula was needed rather than
naive 1:1 pairing: band 1 has few facts but many questions per
submodule (e.g.
atoms-bonding: 1 fact, 6 questions — all 6 show as consecutive question-steps after that one fact), band 2 has many facts but often zero questions (e.g.cells-basics: 4 facts, 0 questions — those facts just show with a plain Continue button, no question step), band 3 is close to 1:1. - Learn-mode answers do NOT touch
state.progress/questionStats. Revise remains the only assessed mode. Learn keeps a local, non-persisted tally (resultscomponent state) shown as an informal recap ("N of M correct... this didn't change your proficiency score") at the end of the walkthrough.SCHEMA_VERSIONdid NOT need to bump for this — nothing about the progress shape changed, unlike the v0.6.0 flatten below. styles.learnNodeBtn(a clickable accordion row) was renamed tostyles.learnFactCard(a static card) since the new fact display isn't a button.
- Pairing is automatic, not hand-authored. There's still no data
field linking a specific fact to a specific question (facts and
questions are two independent arrays per submodule, sharing only a
- v0.6.0 flattened the subject hierarchy: the old single
sciencesubject wrapping four areas (Biology, Chemistry, Physics, Earth & Space) as a middle layer is gone. Each former area is now its own top-level entry inSUBJECTS— Home lists Biology/Chemistry/Physics/ Earth & Space directly, and tapping one goes straight to that subject's module list (no more intermediate "choose an area" screen). See "Data model" below for the new shape.SCHEMA_VERSIONbumped to2andloadState/handleImportrun a one-timemigrateProgress()step that pulls oldprogress.science.<areaId>.*data back out toprogress.<areaId>.*so existing saved progress isn't lost. - Live now: Biology, Chemistry, Physics, Earth & Space as four top-level subjects (content counts below are from before the v0.5.0 Physical Properties/genetics passes and the v0.6.0 flatten — see the "Stale note" under Content coverage).
- Two parallel copies of the app exist — see "Two builds" below.
They should be kept in sync by hand; there's no shared source yet.
Note: this may itself be stale — only a single
app.jsxexists at the repo root as of this writing, not apwa/folder plusstudy-app.jsx; worth confirming next time this section is touched.
-
study-app.jsx(outside thepwa/folder) — the version used as a Claude.ai sandbox artifact for live preview during development. Useslucide-reacticons and ES module imports. State is in-memory only — Claude's artifact sandbox disallowslocalStorage, so progress resets on reload. This is a sandbox limitation, not a bug. -
pwa/app.jsx— the real, deployable build. No build step: loads React/ReactDOM/Babel from a CDN via<script>tags inindex.html, uses hand-rolled inline SVG icons (nolucide-reactdependency, so it still works offline once cached), and persists state to reallocalStorage. This is the one that actually gets hosted and installed on a phone.Structural differences from
study-app.jsx: noimportstatements (globals via CDN instead), inline icon components near the top instead of a lucide import,loadState()/useEffectpersistence instead ofuseState(emptyState), safe-area-inset padding on the root container, a different Settings-screen footer line ("saved automatically on this device" vs "kept for this session"), and aReactDOM.createRoot(...).render(...)call at the bottom instead ofexport default. Every other line — content, scoring, components, screens, routing — is identical text between the two files.When you change content or logic, decide whether it needs to land in one or both files. Content/scoring/screen changes: both. Sandbox-only concerns (icon imports, etc.): just
study-app.jsx.
SUBJECTS = {
biology: {
id, name, blurb, accent, available: bool,
modules: [
{
id, name, blurb,
submodules: [
{
id, name, blurb,
learn: [ { title, body, band, children: [...] } ], // recursive tree
questions: [ { id, band, type: "mcq"|"text", prompt, options?, answer?, answers? } ],
},
],
},
],
},
chemistry: { ... },
physics: { ... },
earthspace: { ... },
}
(Pre-v0.6.0, this was SUBJECTS.science.areas[], with a science
subject wrapping the four of these as areas. Flattened so each is now a
top-level SUBJECTS entry — see "Current state" above. HomeScreen now
maps over Object.values(SUBJECTS) instead of hardcoding
SUBJECTS.science, so adding a genuinely new subject — Maths, English —
is just adding another top-level key.)
learnnodes can nest arbitrarily viachildren, but in practice submodules currently use 1–2 top-level nodes with 2–4 children each, plus one additional band-3 top-level node (see "Difficulty bands" below).type: "mcq"questions useoptions(array) +answer(exact string match).type: "text"questions useanswers(array of accepted strings) — matched vianormalizeAnswer()(lowercase, strip punctuation, collapse whitespace), so minor phrasing/typo variance is forgiven. Always include a couple of phrasing variants inanswers(e.g.["light-year", "light year", "light-years", "light years"]).
Every learn node and every question carries a numeric band (1, 2, or
3), matching NCEA's Achieved/Merit/Excellence tiers. The BANDS
constant near the top of app.jsx holds the level → code → label
mapping (1 → "A" → "Achieved", etc.). bandOf(item) reads
item.band, defaulting to 1 if absent, so nothing breaks if a future
content addition forgets to set it.
Band is a property of content, not of saved state. questionStats
is still keyed by question id alone (see "Question ID convention"
below) — scoring functions look up each question's band from the
content tree at render time, filter by it, and aggregate. This means
no SCHEMA_VERSION bump and no migration code was needed to add
this feature, consistent with how the submodule restructuring was
handled previously.
How bands behave differs between Learn and Revise, by design:
- Learn mode is cumulative.
LearnScreenholds amaxBandstate (viaBandTabs, single-select) and steps through every node withband <= maxBand(interleaved with questions — see the v0.7.0 note above). Selecting "Merit" includes Achieved + Merit content; Excellence adds the rest. This matches how the concepts actually build on each other. Changing the band tab regenerates the step list and restarts the walkthrough from step 1. - Revise mode is exclusive and multi-select. Before a Revise
session starts,
ReviseSetupScreenshowsBandCheckboxes— the student ticks exactly which band(s) they want questions from (all three are checked by default). The resulting question pool isquestions.filter(q => selectedBands.includes(bandOf(q))). A band with zero questions in the current pool is shown disabled with "No questions yet" rather than being hidden.
Screen flow changed slightly to fit the Revise checkbox step — see "Screen flow" below.
Tagging approach used for existing content (v0.4.0 pass):
- All 33 submodules' existing learn nodes: top-level nodes tagged band 1, their children tagged band 2 (children were already the more detailed/applied material, so this was a natural fit without rewriting).
- All 112 pre-existing questions: tagged band 2 if the prompt contains the word "why" (an "explain the reasoning" question), band 1 otherwise. This is a heuristic, not a hand-reviewed classification — worth a manual pass eventually to catch questions that are Merit-level in substance without literally containing "why", and to promote some existing band-1 questions to band 2 where they fit.
- 33 new Excellence-tier (band 3) items were hand-written, one
question + one learn node per submodule, so every submodule has real
content at all three bands rather than an empty Excellence tab. These
are apply/evaluate/justify-style, not just recall — e.g. predicting
bond type from an unfamiliar pair of elements, or explaining why a
rocket accelerates in a vacuum. New question IDs continue each
module's numbering (e.g.
cells_q15–cells_q18,forces_q15–forces_q19) — see ID convention rules below, unchanged.
Known gap: band coverage is uneven — every submodule has some Achieved, Merit, and Excellence content, but Merit is thin (often just the pre-existing learn-node children plus a handful of "why" questions) and Excellence is exactly one question/node deep per submodule. A proper content pass adding more Merit and Excellence questions per submodule (aiming for 3-4 per band, similar to the original band-1 density) is the natural next step, area by area.
Design choices made without further confirmation, worth reviewing:
- The "mastered" checkmark badge on the submodule checklist
(
SubmoduleListScreen) reflects Achieved-band mastery specifically (submoduleBandScore(sm, 1, progress) >= MASTERY_THRESHOLD), not an average across all bands — since Achieved is the NCEA pass threshold. Merit/Excellence scores are shown alongside as three small per-band rings per row, but don't affect the checkmark. - The overall (non-band)
submoduleScore()/moduleScore()/subjectScore()functions were kept, now defined as the average of whichever bands have content (via a sharedavg()helper) — used for the coarse rings on Home/Module screens where a single number is more useful than three. (Pre-v0.6.0 there was also anareaScore()in betweenmoduleScore()andsubjectScore(); it's gone now that subject and area are the same thing — see "Current state".)
Question IDs follow {moduleId}_q{n}, e.g. cells_q1...cells_q18
(the last four of which are the new band-3 questions added in v0.4.0).
Progress is stored keyed by these IDs, not by submodule structure or
band.
state.progress[subjectId][moduleId].questionStats["cells_q1"]
= { seen, correct, ema }
(Pre-v0.6.0 this was state.progress[subjectId][areaId][moduleId],
with subjectId always "science" — see "Current state" above for the
flatten and the progress migration this required.)
Rules for future content edits (unchanged, still apply):
- Adding new questions: always use the next free
_qNnumber. Never reuse or renumber existing IDs. - Removing a question: just stop referencing its ID in the content tree. The orphaned stat sits harmlessly unused.
- Renaming a
subject/moduleid(notname) breaks existing saved progress for that branch. Changename, notid. (There's no separatearealevel any more as of v0.6.0 — each former area'sidis now the subject's ownid, so the same rule just applies one level up the path than it used to.) - Submodule
ids are not part of the progress key path, so they're safe to rename/reorganise freely. - Changing a question's
bandafter the fact is safe — it just reclassifies which band bucket that question's existing EMA shows up under. No state migration needed, same as everything else about bands.
- Each question tracks an EMA (exponential moving average) of
correctness, 0–100:
ema = ema*0.65 + (correct?100:0)*0.35, seeded by the first attempt. submoduleBandScore(submodule, band, moduleProgress)— average EMA across a submodule's questions at that band only, including unattempted ones as 0 (so it reflects coverage, not just accuracy). Returnsnullif the submodule has no questions at that band.moduleBandScore/areaBandScore/subjectBandScore— average of the level below at that band, via the sharedavg()helper, which filters outnulls rather than treating them as 0 (so a submodule with no Excellence content yet doesn't drag down the Excellence average for the whole module).submoduleScore/moduleScore/areaScore/subjectScore(no band argument) — the overall/coarse versions, now defined asavg()across whichever of the three band scores are non-null. Used where a single number is more useful (Home, Area, Module list rings).MASTERY_THRESHOLD = 80— now checked per band where relevant (see "mastered" badge note above).lastStudiedis still a timestamp on the module level, set whenever a Revise session finishes, regardless of which band(s) were tested.
Home → Modules → Submodule checklist → Learn | Revise
↳ Learn: band tabs (cumulative),
opens on whichever band was
selected on the Mode screen, steps
through fact→question(s)→fact...
↳ Revise: band checkboxes (exclusive,
multi-select) → quiz session
↳ "Revise this whole module"
(pools all submodules' questions,
same band-checkbox step,
submoduleId: "ALL")
New screen added in v0.4.0: revise-setup, which sits between the
Mode-choice screen (or the "Revise this whole module" button) and the
quiz itself. It renders ReviseSetupScreen, which shows
BandCheckboxes and a "Start (N questions)" button; the chosen bands
array is passed forward via nav state ({ ...current, screen: "revise", bands }) to ReviseScreen, which filters its question pool before
shuffling.
Navigation is still a simple stack (nav array of {screen, ...params},
push/pop), unchanged in shape — the new screen is just another entry
in the same if/else routing chain in the root Orbit component.
ReviseScreen still just needs an object with .name and
.questions for module, plus a new bands array prop (defaults to
[1, 2, 3] if somehow missing). Both a real submodule and the
synthetic "revise all" pseudo-object satisfy that shape.
BandBadge({ level, accent, size })— small pill showing "A"/ "M"/"E". Used on learn nodes above band 1, and on the in-quiz header when more than one band is being tested in the same session.BandTabs({ selected, onSelect, accent, scores })— single-select row of three tabs, each showing that band's proficiency %. Used inModeChoiceScreen(informational, plus drives what Learn opens to) andLearnScreen(drives the cumulative content filter).BandCheckboxes({ selectedSet, onToggle, accent, counts })— multi-select checkbox list, one row per band, disabled when a band has zero questions in the current pool. Used only inReviseSetupScreen.
| Area | Modules | Submodules | Questions (band 1 / 2 / 3) |
|---|---|---|---|
| Biology | Cells & Organisation, Ecology & Ecosystems | 4 + 5 | ~26 / ~6 / 9 |
| Chemistry | Atoms/Elements/Periodic Table, Chemical Reactions | 4 + 4 | ~26 / ~2 / 8 |
| Physics | Forces & Motion, Energy & Waves | 5 + 3 | ~26 / ~2 / 8 |
| Earth & Space | Earth Systems & Climate, Solar System & Beyond | 4 + 4 | ~26 / ~2 / 8 |
(Approximate band 1/2 split per area — exact counts depend on how many existing prompts happened to contain "why"; band 3 counts are exact, one per submodule.)
Stale note (post-v0.4.0): a genetics/variation module (Biology) and a
Physical Properties module (Chemistry, AS92023) were added after this
table was written, and it was never refreshed for either — the row
counts above under-count Biology and Chemistry. The Physical Properties
module specifically (added in the v0.5.0 pass, sourced from
content/92023_NCEA_L1_CB1.4_Physical_Properties_AI_Summary.txt) has 6
submodules (intro, ionic, metallic/alloys, molecular, covalent network,
polymers) and 47 questions, evenly built at 3/3/2 band-1/2/3 per
submodule rather than the thin 1-question-per-band pattern described
below — a full re-audit of this table is still owed.
Content is loosely based on NCEA Level 1 Science's four knowledge strands (Biology / Chemistry / Physics / Earth & Space Science). It is not a literal transcription of NZQA achievement standards — the two inquiry-based internal standards (1.1, 1.2) don't fit a fact-recall quiz format, so they were deliberately left out in favour of knowledge-strand content. Worth flagging to the student/parent that this supplements but doesn't replace official NCEA assessment prep, and that the Achieved/Merit/Excellence bands here are a study aid loosely modelled on NCEA's grading language, not a certified mapping to actual standard-specific grade boundaries.
- Petrol/navy dark theme (
--bg: #12181F), amber accent (--accent: #F2B705), soft per-area accent colours (biology green, chemistry blue, physics violet, earth orange) — all defined as CSS custom properties on the root container inOrbit's return statement. Band UI reuses these same per-area accent colours (no separate band colour scale) — bands are differentiated by label/badge, not hue. - Fraunces (serif, headings) + IBM Plex Sans (UI) + IBM Plex Mono
(numbers/scores), loaded via Google Fonts — injected via JS in the
sandbox build, via
<link>inindex.htmlfor the PWA build. - No Tailwind — plain inline styles + a shared
stylesobject at the bottom of the file, plus one<style>tag for global resets.
- No bundler.
index.htmlloads React/ReactDOM/Babel-standalone fromunpkg.com, thenapp.jsxvia<script type="text/babel">. manifest.json: name "Orbit — Level 1 Science", short_name "Orbit".- Icons are hand-drawn (Pillow, not an SVG→PNG conversion — ImageMagick
in the build environment lacked the
rsvg-convertdelegate) — an atom mark (three orbits + nucleus) in the app's amber-on-petrol palette. Sizes: 192, 512, 512 maskable, 180 (apple-touch-icon), 32 (favicon). Not regenerated in the v0.4.0 pass — reuse the existing icon files when merging this update in. sw.js: cache-first for same-origin app-shell files, network-first falling back to cache for CDN assets (React/fonts). Cache name isorbit-shell-v{APP_VERSION}— bump bothAPP_VERSIONinapp.jsxand the matching one insw.jstogether when shipping a content/logic update, or the service worker won't invalidate its cache and users won't see the update. Both are now0.4.0.- Deployment: any static host with HTTPS (GitHub Pages, Netlify,
Cloudflare Pages). See
README.mdfor install steps. Not deployed anywhere yet as of this writing — all testing so far has been via the Claude sandbox artifact preview only.
- Band content depth — see "Known gap" under Difficulty bands above. Merit is thin, Excellence is exactly one item deep per submodule. Next content pass should even this out.
- Existing-question band tagging was heuristic ("why" in the prompt → Merit), not hand-reviewed. Worth a manual pass per area.
- Not yet deployed anywhere real.
localStoragepersistence and offline caching are implemented but untested outside the local file structure — worth a real device test once hosted. - No automated check that content edits preserve question IDs (manual rule only — see "Question ID convention" above).
No way to add a second subject yet through the UI...Fixed in v0.6.0 —HomeScreennow maps overObject.values(SUBJECTS)instead of hardcodingSUBJECTS.science, as part of the area-flatten described under "Current state". Adding a real additional subject (Maths, English, ...) is now just adding another top-levelSUBJECTSkey withavailable: true.- No settings for adjusting
MASTERY_THRESHOLD, the EMA decay weighting, or which band drives the "mastered" badge — all hardcoded constants/decisions, deliberately not user-configurable for now.