An applied-first learning app for maths and science. The differentiator is not gamification — it is sequence: the real-world application comes first, the theory second, and the assessment returns to the real world in a different domain.
This repository is currently Phase 0: two de-risking spikes that exist to kill or confirm the architecture before the platform gets built. Spike 0a is implemented. Spike 0b is specified and not started.
The architecture lives at docs/architecture/technical-architecture-v0.2.md. It is the canonical copy — the reference this codebase is written against — and everything else in docs/ hangs off it:
| File | What it is |
|---|---|
docs/architecture/technical-architecture-v0.2.md |
The architecture. Pedagogy layer, content engine, paper-free solver, client, backend, risks, decision log. |
docs/roadmap.md |
Solo/part-time delivery plan. Supersedes the phase durations in §10 of the architecture. |
docs/phase-0a.md |
Feel-spike gate criteria and the record to fill in. |
docs/phase-0b.md |
Solver-spike brief. Not started. |
CLAUDE.md at the repo root points any agent session at the same document, so it is picked up without being asked for.
You need a development build. Expo Go will not work — @shopify/react-native-skia and react-native-worklets are native modules that are not bundled into it, and Skia is the entire point of the spike.
npm install
# iOS — needs Xcode + CocoaPods
npm run ios
# Android — needs Android Studio + an SDK and an attached device/emulator
npm run androidexpo run:* generates the native projects on first run (ios/, android/ — both gitignored) and builds them. That first build takes a while; afterwards npm start attaches the Metro bundler to the installed dev client in seconds.
npm run typecheck # tsc --noEmit, strict
npm run lint # eslint flat config
npm run bench # headless model-step benchmark, no device needednpm run bench runs with Node's built-in type stripping (Node 22.6+) and does not need node_modules at all.
There is no babel.config.js, on purpose. babel-preset-expo auto-registers react-native-worklets/plugin when the package is installed. Adding a config that lists the plugin explicitly registers the transform twice and breaks worklets in ways that surface as baffling runtime errors rather than build failures. babel-preset-expo also lives under node_modules/expo/node_modules/, so a hand-written config referencing it by name fails to resolve from the project root. Leave it absent.
Shared values use .get() / .set(), never .value. The React Compiler lint rules treat sharedValue.value = x as mutating a hook return. .get()/.set() is the compiler-safe Reanimated API and it is used consistently throughout. Don't mix the two styles.
Open the app → Feel spike — gradient descent.
| # | Question | How it is answered | Gate |
|---|---|---|---|
| 1 | Does maths responding to a finger feel alive on a real device? | Drag the η slider. Watch the fitted line move with your thumb, not after it. | Subjective, but honest — if it feels like a form field, the architecture is wrong. |
| 2 | Does the model step hold its budget? | The HUD above the gate button. step p95, live. |
p95 ≤ 4 ms with frame rate ≥ 50 fps, on the worst device you intend to support. |
| 3 | Can beat 2 hand structured data to beat 3? | Press Explain what just happened. The first sentence names what you did. | The opening line is derived, never authored. |
Question 3 is the one most likely to be skipped and the most expensive to retrofit. The productive-failure research is unambiguous that instruction must build on the learner's own generated solution — generic instruction after a generic attempt produces none of the effect. That makes attempt capture a data-flow requirement running widget runtime → lesson player → content renderer, not a content-authoring nicety.
The iOS simulator will pass every budget trivially and tell you nothing. Test on:
- the oldest Android phone you are willing to support, in a release build (
npx expo run:android --variant release); - with the device warm, not freshly rebooted;
- with the descent running, not paused.
Record step p50, step p95 and fps from the HUD. If p95 is over 4 ms, run npm run bench — it measures the maths in isolation. The gap between the two numbers is plumbing (worklet dispatch, shared-value churn, Skia draw calls), and plumbing is what you would then go fix.
index.js entry point — loads Skia before the app graph (web)
app/ expo-router routes
_layout.tsx stack, dark theme, gesture root
index.tsx spike menu
spike/gradient-descent.tsx BEATS 1-2: hook + struggle
spike/consolidate.tsx BEAT 3: theory, opened by naming your attempt
src/
theme/tokens.ts validated palette; read from worklets, keep React out
widgets/
models/gradientDescent.ts pure step function — no React, no shared values
runtime/
types.ts Widget := model + bindings + views + capture
useModelLoop.ts the frame loop. The invariant lives here.
perf.ts ring buffers, percentiles, budget warning
useSharedMirror.ts UI-thread value -> React state, rate limited
loadSkia.ts native: no-op, Skia is linked in
loadSkia.web.ts web: awaits the CanvasKit WASM module
views/
LossSurface.tsx Skia: field rasterised once, path animated
FitPanel.tsx Skia: same state, second encoding
PerfHud.tsx the gate, on screen
controls/LogSlider.tsx pan gesture -> shared value, zero JS in the loop
narration/rules.ts "you just caused this" sentences, as data
capture/attemptStore.ts structured struggle data + strategy clustering
ui/Button.tsx
bench/step-budget.ts headless step benchmark
scripts/copy-canvaskit.mjs postinstall: canvaskit.wasm -> public/ (gitignored)
docs/ phase gates and the solo delivery plan
The code is built around a short list of rules, each of which exists because breaking it re-opens a problem this phase was built to close: the interaction loop never touches the JS thread, expensive work is precomputed at mount, the struggle beat writes the structured data the consolidation beat reads, colour follows the entity rather than its rank, models stay pure enough to run in bare Node, and widget code keeps running on the web.
They are written out in full, with the reasoning, in CLAUDE.md. They live in one place on purpose — the same reason the repository map above is not repeated there. A list maintained in two files is a list that will disagree with itself, and these are exactly the rules you cannot afford to read a stale version of.
For the argument underneath them, see §6 of the architecture.
No backend, no auth, no sync, no content pipeline, no YAML authoring, no FSRS, no analytics vendor, no beat 4. All of that is Phase 1 and later. The spike is not a small version of the product; it is a test rig for the two claims the product cannot survive being wrong about.