diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ecd5b2f..f1c3c95 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -158,10 +158,29 @@ Add edges in `data/{subject}-curriculum.json`:
## Add a new subject
-1. `data/catalog.json` — add to `subjects`.
-2. Create `data/{id}-curriculum.json` (copy structure from `math-curriculum.json`).
-3. Wire the subject in `themes/openfreebooks/templates/catalog.html` (until auto-wire lands).
-4. Add `content/{subject}/` when you have chapters.
+1. `data/catalog.json` — add the subject to `subjects`.
+2. Create `data/{id}-curriculum.json` (same schema as `math-curriculum.json`; `strands` + optional `graph.edges`).
+3. Add `content/{subject}/` when you have live chapters.
+4. Run `bun run build:js` (this syncs chapter content and auto-registers subject CSS/JS modules).
+
+### Subject scaffold (copyable)
+
+```text
+data/
+ {subject}-curriculum.json
+content/{subject}/
+ subject.scss # optional shared styles
+ subject.ts # optional shared JS init
+ {chapter-slug}/
+ _index.md # template = "chapter.html", extra.subject = "{subject}"
+ core.html # required
+ supplement.html # optional
+ chapter.scss # optional chapter-only styles
+ assets/ # optional
+ widgets/*.tsx # optional
+```
+
+No catalog/chapter template edits are required for new subjects when the subject is present in `data/catalog.json` and `data/{subject}-curriculum.json` exists.
---
diff --git a/README.md b/README.md
index 41f5905..180e7b7 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Open Free Books
+> Work in Progress. I am looking for contributors to help build this project. Educators welcome.
+
Free, open-source textbooks for every learner — from elementary school through university. The site is fully static, open on GitHub, and deployed to Cloudflare.
**Live site:** [openfreebooks.org](https://openfreebooks.org)
diff --git a/content/english/_index.md b/content/english/_index.md
new file mode 100644
index 0000000..06a4475
--- /dev/null
+++ b/content/english/_index.md
@@ -0,0 +1,5 @@
++++
+title = "English"
+description = "Browse English chapters in the curriculum catalog."
+sort_by = "weight"
++++
diff --git a/content/english/reading-comprehension-foundations/_index.md b/content/english/reading-comprehension-foundations/_index.md
new file mode 100644
index 0000000..8c70a27
--- /dev/null
+++ b/content/english/reading-comprehension-foundations/_index.md
@@ -0,0 +1,60 @@
++++
+title = "Reading comprehension foundations"
+description = "Find the main idea, key details, and evidence in short texts."
+template = "chapter.html"
+weight = 1
+[extra]
+subject = "english"
+chapter_id = "reading-comprehension-foundations"
+strand = "Reading & Writing Foundations"
++++
+
+
+
+ Reading comprehension means understanding what a text says directly and what it suggests
+ indirectly. In this chapter you will practice spotting the
+ main idea, finding
+ supporting details, and selecting
+ text evidence.
+
+
+
Main idea and key details
+
+ The main idea is the most important message in a paragraph or passage.
+ Key details are specific facts or examples that support that message.
+
+
+
+
Quick strategy
+
+ Ask: “If I had to summarize this paragraph in one sentence, what would I say?” Then check
+ which details in the text prove your sentence.
+
+
+
+
Use evidence, not guesses
+
+ Good answers point to exact words or phrases in the passage. You can quote directly or
+ paraphrase, but your answer should always connect back to the text.
+
+
+
+ Checkpoint: main idea
+
+
+ Read this sentence: “After weeks of practice, the choir finally sang in harmony during the
+ final rehearsal.” What is the main idea, and which words are the strongest evidence?
+
+
+
+
+
+ Checkpoint: evidence
+
+
+ A student writes: “The character is brave.” Improve this answer by adding one short quote
+ from the text and explaining how that quote proves bravery.
+
+
+
+
diff --git a/content/english/reading-comprehension-foundations/core.html b/content/english/reading-comprehension-foundations/core.html
new file mode 100644
index 0000000..9f76039
--- /dev/null
+++ b/content/english/reading-comprehension-foundations/core.html
@@ -0,0 +1,49 @@
+
+
+ Reading comprehension means understanding what a text says directly and what it suggests
+ indirectly. In this chapter you will practice spotting the
+ main idea, finding
+ supporting details, and selecting
+ text evidence.
+
+
+
Main idea and key details
+
+ The main idea is the most important message in a paragraph or passage.
+ Key details are specific facts or examples that support that message.
+
+
+
+
Quick strategy
+
+ Ask: “If I had to summarize this paragraph in one sentence, what would I say?” Then check
+ which details in the text prove your sentence.
+
+
+
+
Use evidence, not guesses
+
+ Good answers point to exact words or phrases in the passage. You can quote directly or
+ paraphrase, but your answer should always connect back to the text.
+
+
+
+ Checkpoint: main idea
+
+
+ Read this sentence: “After weeks of practice, the choir finally sang in harmony during the
+ final rehearsal.” What is the main idea, and which words are the strongest evidence?
+
+
+
+
+
+ Checkpoint: evidence
+
+
+ A student writes: “The character is brave.” Improve this answer by adding one short quote
+ from the text and explaining how that quote proves bravery.
+
+
+
+
diff --git a/content/english/subject.scss b/content/english/subject.scss
new file mode 100644
index 0000000..a9b8239
--- /dev/null
+++ b/content/english/subject.scss
@@ -0,0 +1,10 @@
+// Shared styles for English chapters.
+// Keep chapter-specific rules in each chapter folder's chapter.scss.
+
+.english-key-term {
+ padding: 0.05rem 0.32rem;
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ font-family: var(--mono);
+ font-size: 0.85em;
+}
diff --git a/content/faq.md b/content/faq.md
index cc04d88..5ca7dd3 100644
--- a/content/faq.md
+++ b/content/faq.md
@@ -2,3 +2,5 @@
title = "FAQ"
template = "faq.html"
+++
+
+We use AI to help draft and improve material, but we publish durable HTML chapters so learning stays accessible even if token pricing or model access changes in the future.
diff --git a/content/physics/_index.md b/content/physics/_index.md
new file mode 100644
index 0000000..617657c
--- /dev/null
+++ b/content/physics/_index.md
@@ -0,0 +1,5 @@
++++
+title = "Physics"
+description = "Browse physics chapters in the curriculum catalog."
+sort_by = "weight"
++++
diff --git a/content/physics/force-newtons-laws/_index.md b/content/physics/force-newtons-laws/_index.md
new file mode 100644
index 0000000..2c83f0d
--- /dev/null
+++ b/content/physics/force-newtons-laws/_index.md
@@ -0,0 +1,122 @@
++++
+title = "Force and Newton's laws"
+description = "Use free-body diagrams, F = ma, and motion graphs to predict how objects move."
+template = "chapter.html"
+weight = 1
+[extra]
+subject = "physics"
+chapter_id = "force-newtons-laws"
+strand = "Force and Motion"
++++
+
+
+
+ In HKDSE Physics, force and motion are linked by three core ideas:
+ inertia,
+ net force, and
+ acceleration. This chapter gives you one practical flow:
+ draw forces, find the net force, then predict acceleration with Newton's second law.
+
+
+
Newton's three laws
+
+ -
+ First law: if net force is zero, velocity stays constant (including rest).
+
+ -
+ Second law: acceleration is proportional to net force and inversely proportional
+ to mass.
+
+ -
+ Third law: forces come in equal and opposite interaction pairs.
+
+
+
+
+
+
Problem-solving workflow
+
+ - Choose one object as your system.
+ - Sketch all external forces in a free-body diagram.
+ - Resolve forces by direction and compute net force.
+ - Use \(a = F_{\text{net}} / m\), then connect to motion equations.
+
+
+
Interactive force lab
+
+ Adjust mass, pulling force, and surface friction. The demo computes friction, net force,
+ acceleration, and the motion state for a 3-second interval from rest.
+
+
+
+
+
+
+
Interactive motion graphs
+
+ Change initial velocity, acceleration, and elapsed time to inspect
+ v-t and s-t behavior.
+ This connects Newton's second law to kinematics directly.
+
+
+
+
+
+
+
Worked checkpoints
+
+ These quick prompts are designed for class discussion or self-check before moving to momentum
+ and energy.
+
+
+
+
+ A 4 kg trolley is pulled right with 18 N while friction is 10 N left. What is its acceleration?
+
+
+
+ Net force \(= 18 - 10 = 8\text{ N}\) to the right, so
+ \(a = F_{\text{net}}/m = 8/4 = 2\text{ m s}^{-2}\) to the right.
+
+
+
+
+
+
+ Why can a book stay at rest on a table even though gravity is acting?
+
+
+
+ Gravity pulls down, but the table exerts an equal normal force upward. Net force is zero, so
+ acceleration is zero.
+
+
+
+
+
+
+ A rocket pushes exhaust gas backward. Which Newton's law explains forward thrust?
+
+
+
+ Newton's third law. The rocket pushes gas backward; the gas pushes the rocket forward with an
+ equal and opposite force.
+
+
+
+
+
+
+ In the motion lab, what happens to the velocity-time line when acceleration is set to zero?
+
+
+
+ The line becomes horizontal, showing constant velocity. Position then changes linearly with
+ time.
+
+
+
+
diff --git a/content/physics/force-newtons-laws/chapter.scss b/content/physics/force-newtons-laws/chapter.scss
new file mode 100644
index 0000000..5c7ab7a
--- /dev/null
+++ b/content/physics/force-newtons-laws/chapter.scss
@@ -0,0 +1,201 @@
+.physics-widget {
+ margin: 20px 0;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--card-bg);
+ padding: 14px;
+}
+
+.physics-widget__layout {
+ display: grid;
+ gap: 12px;
+}
+
+.physics-widget__section {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--paper) 72%, transparent);
+ padding: 10px;
+}
+
+.physics-widget__title {
+ margin: 0 0 8px;
+ font-size: 1rem;
+}
+
+.physics-slider-row {
+ display: grid;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+
+.physics-slider-row label {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 10px;
+ font-size: 0.86rem;
+}
+
+.physics-slider-row input[type="range"] {
+ width: 100%;
+}
+
+.physics-value {
+ font-family: var(--mono);
+ font-weight: 700;
+}
+
+.force-lab__summary {
+ margin: 8px 0 0;
+ font-size: 0.9rem;
+}
+
+.force-lab__status-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.force-lab__state-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.28rem 0.55rem;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, #f59e0b 40%, var(--line));
+ background: color-mix(in srgb, #f59e0b 16%, transparent);
+ color: color-mix(in srgb, #b45309 70%, var(--ink));
+ font-size: 0.73rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.force-lab__state-pill--moving {
+ border-color: color-mix(in srgb, #10b981 45%, var(--line));
+ background: color-mix(in srgb, #10b981 18%, transparent);
+ color: color-mix(in srgb, #065f46 74%, var(--ink));
+}
+
+.force-lab__time {
+ font-family: var(--mono);
+ font-size: 0.76rem;
+ color: var(--soft-ink);
+}
+
+.force-lab__diagram-wrap {
+ margin: 0;
+ padding: 4px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--paper) 86%, transparent);
+}
+
+.force-lab__canvas {
+ --force-canvas-bg: color-mix(in srgb, var(--paper) 90%, #e2e8f0);
+ --force-canvas-grid: color-mix(in srgb, var(--line) 72%, transparent);
+ --force-canvas-ink: color-mix(in srgb, var(--ink) 90%, #111827);
+
+ width: 100%;
+ display: block;
+ border-radius: 8px;
+ background: var(--force-canvas-bg);
+}
+
+.force-lab__timeline {
+ margin-top: 10px;
+ display: grid;
+ grid-template-columns: auto auto minmax(0, 1fr);
+ gap: 8px;
+ align-items: center;
+}
+
+.force-lab__btn {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--card-bg);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ font-weight: 700;
+ padding: 0.42rem 0.62rem;
+ cursor: pointer;
+}
+
+.force-lab__btn--play {
+ border-color: color-mix(in srgb, #2563eb 50%, var(--line));
+ background: color-mix(in srgb, #2563eb 14%, transparent);
+}
+
+.force-lab__scrubber {
+ width: 100%;
+}
+
+.motion-lab__stats {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.motion-lab__stat {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 8px;
+ background: color-mix(in srgb, var(--paper) 70%, transparent);
+}
+
+.motion-lab__stat-label {
+ display: block;
+ font-size: 0.72rem;
+ color: var(--soft-ink);
+}
+
+.motion-lab__stat-value {
+ display: block;
+ margin-top: 2px;
+ font-family: var(--mono);
+ font-weight: 700;
+}
+
+.motion-lab__plot {
+ width: 100%;
+ max-width: 520px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--paper);
+}
+
+.motion-lab__legend {
+ margin: 8px 0 0;
+ font-size: 0.76rem;
+ color: var(--soft-ink);
+}
+
+.motion-lab__legend-line {
+ display: inline-flex;
+ align-items: center;
+ margin-right: 12px;
+}
+
+.motion-lab__legend-line::before {
+ content: "";
+ width: 18px;
+ height: 2px;
+ margin-right: 6px;
+}
+
+.motion-lab__legend-line--velocity::before {
+ background: #0ea5e9;
+}
+
+.motion-lab__legend-line--position::before {
+ background: #f59e0b;
+}
+
+@media (min-width: 900px) {
+ .physics-widget__layout {
+ grid-template-columns: minmax(260px, 1fr) minmax(0, 1.2fr);
+ align-items: start;
+ }
+}
diff --git a/content/physics/force-newtons-laws/core.html b/content/physics/force-newtons-laws/core.html
new file mode 100644
index 0000000..508e612
--- /dev/null
+++ b/content/physics/force-newtons-laws/core.html
@@ -0,0 +1,55 @@
+
+
+ In HKDSE Physics, force and motion are linked by three core ideas:
+ inertia,
+ net force, and
+ acceleration. This chapter gives you one practical flow:
+ draw forces, find the net force, then predict acceleration with Newton's second law.
+
+
+
Newton's three laws
+
+ -
+ First law: if net force is zero, velocity stays constant (including rest).
+
+ -
+ Second law: acceleration is proportional to net force and inversely proportional
+ to mass.
+
+ -
+ Third law: forces come in equal and opposite interaction pairs.
+
+
+
+
+
+
Problem-solving workflow
+
+ - Choose one object as your system.
+ - Sketch all external forces in a free-body diagram.
+ - Resolve forces by direction and compute net force.
+ - Use \(a = F_{\text{net}} / m\), then connect to motion equations.
+
+
+
Interactive force lab
+
+ Adjust mass, pulling force, and surface friction. The demo computes friction, net force,
+ acceleration, and the motion state for a 3-second interval from rest.
+
+
+
+
+
+
+
Interactive motion graphs
+
+ Change initial velocity, acceleration, and elapsed time to inspect
+ v-t and s-t behavior.
+ This connects Newton's second law to kinematics directly.
+
+
+
+
diff --git a/content/physics/force-newtons-laws/supplement.html b/content/physics/force-newtons-laws/supplement.html
new file mode 100644
index 0000000..e7e5256
--- /dev/null
+++ b/content/physics/force-newtons-laws/supplement.html
@@ -0,0 +1,55 @@
+
+
Worked checkpoints
+
+ These quick prompts are designed for class discussion or self-check before moving to momentum
+ and energy.
+
+
+
+
+ A 4 kg trolley is pulled right with 18 N while friction is 10 N left. What is its acceleration?
+
+
+
+ Net force \(= 18 - 10 = 8\text{ N}\) to the right, so
+ \(a = F_{\text{net}}/m = 8/4 = 2\text{ m s}^{-2}\) to the right.
+
+
+
+
+
+
+ Why can a book stay at rest on a table even though gravity is acting?
+
+
+
+ Gravity pulls down, but the table exerts an equal normal force upward. Net force is zero, so
+ acceleration is zero.
+
+
+
+
+
+
+ A rocket pushes exhaust gas backward. Which Newton's law explains forward thrust?
+
+
+
+ Newton's third law. The rocket pushes gas backward; the gas pushes the rocket forward with an
+ equal and opposite force.
+
+
+
+
+
+
+ In the motion lab, what happens to the velocity-time line when acceleration is set to zero?
+
+
+
+ The line becomes horizontal, showing constant velocity. Position then changes linearly with
+ time.
+
+
+
+
diff --git a/content/physics/force-newtons-laws/widgets/force-lab.tsx b/content/physics/force-newtons-laws/widgets/force-lab.tsx
new file mode 100644
index 0000000..4780dee
--- /dev/null
+++ b/content/physics/force-newtons-laws/widgets/force-lab.tsx
@@ -0,0 +1,441 @@
+import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
+
+const G = 9.81;
+const DURATION = 3;
+
+type PhysicsState = {
+ state: "Static" | "Moving";
+ normalForce: number;
+ maxStaticFriction: number;
+ friction: number;
+ netForce: number;
+ acceleration: number;
+ velocity: number;
+ displacement: number;
+};
+
+function round(value: number, digits = 2) {
+ const p = 10 ** digits;
+ return Math.round(value * p) / p;
+}
+
+function format(value: number, unit = "") {
+ const n = Math.abs(value) < 1e-9 ? 0 : round(value, 2);
+ return `${Number.isInteger(n) ? n : n.toFixed(2)}${unit}`;
+}
+
+function drawArrow(
+ ctx: CanvasRenderingContext2D,
+ fromX: number,
+ fromY: number,
+ toX: number,
+ toY: number,
+ color: string,
+ width: number
+) {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ ctx.moveTo(fromX, fromY);
+ ctx.lineTo(toX, toY);
+ ctx.stroke();
+
+ const angle = Math.atan2(toY - fromY, toX - fromX);
+ const head = 10;
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.moveTo(toX, toY);
+ ctx.lineTo(
+ toX - head * Math.cos(angle - Math.PI / 6),
+ toY - head * Math.sin(angle - Math.PI / 6)
+ );
+ ctx.lineTo(
+ toX - head * Math.cos(angle + Math.PI / 6),
+ toY - head * Math.sin(angle + Math.PI / 6)
+ );
+ ctx.closePath();
+ ctx.fill();
+}
+
+export default function ForceLab() {
+ const [massKg, setMassKg] = createSignal(10);
+ const [appliedForceN, setAppliedForceN] = createSignal(60);
+ const [muStatic, setMuStatic] = createSignal(0.5);
+ const [muKinetic, setMuKinetic] = createSignal(0.3);
+ const [time, setTime] = createSignal(0);
+ const [playing, setPlaying] = createSignal(false);
+
+ let simCanvasRef: HTMLCanvasElement | undefined;
+ let raf = 0;
+ let lastTime = 0;
+ let lastCanvasWidth = 0;
+ let lastCanvasHeight = 0;
+
+ const physicsAt = (t: number): PhysicsState => {
+ const normalForce = massKg() * G;
+ const maxStaticFriction = muStatic() * normalForce;
+ const startsMoving = appliedForceN() > maxStaticFriction;
+
+ if (!startsMoving) {
+ return {
+ state: "Static",
+ normalForce,
+ maxStaticFriction,
+ friction: appliedForceN(),
+ netForce: 0,
+ acceleration: 0,
+ velocity: 0,
+ displacement: 0,
+ };
+ }
+
+ const friction = muKinetic() * normalForce;
+ const netForce = appliedForceN() - friction;
+ const acceleration = netForce / massKg();
+ return {
+ state: "Moving",
+ normalForce,
+ maxStaticFriction,
+ friction,
+ netForce,
+ acceleration,
+ velocity: acceleration * t,
+ displacement: 0.5 * acceleration * t * t,
+ };
+ };
+
+ const current = createMemo(() => physicsAt(time()));
+
+ const pause = () => {
+ setPlaying(false);
+ if (raf) cancelAnimationFrame(raf);
+ raf = 0;
+ };
+
+ const play = () => {
+ if (time() >= DURATION) setTime(0);
+ setPlaying(true);
+ lastTime = 0;
+ const step = (stamp: number) => {
+ if (!playing()) return;
+ if (!lastTime) lastTime = stamp;
+ const dt = (stamp - lastTime) / 1000;
+ lastTime = stamp;
+ setTime((prev) => {
+ const next = Math.min(DURATION, prev + dt);
+ if (next >= DURATION) {
+ setPlaying(false);
+ }
+ return next;
+ });
+ if (playing()) raf = requestAnimationFrame(step);
+ };
+ raf = requestAnimationFrame(step);
+ };
+
+ const reset = () => {
+ pause();
+ setTime(0);
+ };
+
+ createEffect(() => {
+ const uk = muKinetic();
+ const us = muStatic();
+ if (uk > us) {
+ setMuStatic(uk);
+ }
+ });
+
+ createEffect(() => {
+ const canvas = simCanvasRef;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const rect = canvas.getBoundingClientRect();
+ const width = Math.max(420, Math.floor(rect.width || 640));
+ const height = 250;
+
+ if (width !== lastCanvasWidth || height !== lastCanvasHeight) {
+ lastCanvasWidth = width;
+ lastCanvasHeight = height;
+ canvas.width = Math.floor(width * dpr);
+ canvas.height = Math.floor(height * dpr);
+ canvas.style.height = `${height}px`;
+ }
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ const p = current();
+ const style = getComputedStyle(canvas);
+ const bg = style.getPropertyValue("--force-canvas-bg").trim() || "#f8fafc";
+ const grid = style.getPropertyValue("--force-canvas-grid").trim() || "#d7dde6";
+ const ink = style.getPropertyValue("--force-canvas-ink").trim() || "#111827";
+
+ ctx.clearRect(0, 0, width, height);
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, width, height);
+
+ const groundY = height - 58;
+ const scalePxPerM = 26;
+ const visualShift = p.displacement * scalePxPerM * 0.9;
+ const gridSize = 28;
+ const gridPhase = ((visualShift % gridSize) + gridSize) % gridSize;
+
+ ctx.strokeStyle = grid;
+ ctx.lineWidth = 1;
+ for (let x = -gridPhase; x < width; x += gridSize) {
+ const px = Math.round(x) + 0.5;
+ ctx.beginPath();
+ ctx.moveTo(px, 0);
+ ctx.lineTo(px, groundY);
+ ctx.stroke();
+ }
+
+ ctx.strokeStyle = ink;
+ ctx.lineWidth = 2;
+ ctx.lineJoin = "round";
+ const toothWidth = 12;
+ const toothHeight = 8;
+ const toothPeriod = toothWidth * 2;
+ const toothPhase = ((visualShift % toothPeriod) + toothPeriod) % toothPeriod;
+
+ ctx.beginPath();
+ let started = false;
+ for (let i = -4; ; i++) {
+ const x = i * toothWidth - toothPhase;
+ if (x > width + toothWidth * 4) break;
+ const y = i % 2 === 0 ? groundY : groundY + toothHeight;
+ if (!started) {
+ ctx.moveTo(x, y);
+ started = true;
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.stroke();
+
+ const blockSize = Math.max(44, Math.min(86, 52 * Math.sqrt(massKg() / 10)));
+ const blockX = width * 0.33;
+ const blockY = groundY - blockSize;
+
+ const grad = ctx.createLinearGradient(blockX, blockY, blockX + blockSize, blockY + blockSize);
+ grad.addColorStop(0, "#ef4444");
+ grad.addColorStop(1, "#b91c1c");
+ ctx.fillStyle = grad;
+ ctx.fillRect(blockX, blockY, blockSize, blockSize);
+ ctx.strokeStyle = ink;
+ ctx.lineWidth = 3;
+ ctx.strokeRect(blockX, blockY, blockSize, blockSize);
+
+ const centerY = blockY + blockSize / 2;
+ const maxForce = Math.max(30, appliedForceN(), p.friction);
+ const pullLen = 40 + (appliedForceN() / maxForce) * 130;
+ const fricLen = 40 + (p.friction / maxForce) * 130;
+
+ drawArrow(
+ ctx,
+ blockX + blockSize,
+ centerY - 12,
+ blockX + blockSize + pullLen,
+ centerY - 12,
+ "#2563eb",
+ 4
+ );
+ drawArrow(ctx, blockX, centerY + 12, blockX - fricLen, centerY + 12, "#ef4444", 4);
+
+ const vertLen = Math.max(24, Math.min(72, (massKg() * G) / 2.8));
+ drawArrow(
+ ctx,
+ blockX + blockSize / 2,
+ blockY,
+ blockX + blockSize / 2,
+ blockY - vertLen,
+ "#06b6d4",
+ 2.5
+ );
+ drawArrow(
+ ctx,
+ blockX + blockSize / 2,
+ blockY + blockSize,
+ blockX + blockSize / 2,
+ blockY + blockSize + vertLen,
+ "#f97316",
+ 2.5
+ );
+
+ ctx.fillStyle = "#1e293b";
+ ctx.font = "600 14px Inter, sans-serif";
+ ctx.textAlign = "left";
+ ctx.fillText("F", blockX + blockSize + pullLen + 8, centerY - 14);
+ ctx.fillStyle = "#7f1d1d";
+ ctx.fillText("f_fric", blockX - fricLen - 56, centerY + 8);
+
+ ctx.fillStyle = "#164e63";
+ ctx.font = "500 11px Inter, sans-serif";
+ ctx.textAlign = "center";
+ ctx.fillText(`F_N ${p.normalForce.toFixed(0)} N`, blockX + blockSize / 2, blockY - vertLen - 8);
+ ctx.fillStyle = "#7c2d12";
+ ctx.fillText(
+ `W ${p.normalForce.toFixed(0)} N`,
+ blockX + blockSize / 2,
+ blockY + blockSize + vertLen + 14
+ );
+
+ ctx.fillStyle = "#0f172a";
+ ctx.font = "700 11px Inter, sans-serif";
+ ctx.textAlign = "center";
+ ctx.fillText(`${massKg().toFixed(1)} kg`, blockX + blockSize / 2, blockY + blockSize / 2 + 3);
+ });
+
+ onCleanup(() => {
+ if (raf) cancelAnimationFrame(raf);
+ });
+
+ return (
+
+ );
+}
diff --git a/content/physics/force-newtons-laws/widgets/motion-lab.tsx b/content/physics/force-newtons-laws/widgets/motion-lab.tsx
new file mode 100644
index 0000000..23ae01e
--- /dev/null
+++ b/content/physics/force-newtons-laws/widgets/motion-lab.tsx
@@ -0,0 +1,196 @@
+import { createMemo, createSignal } from "solid-js";
+
+const PLOT_W = 520;
+const PLOT_H = 280;
+const PAD_X = 48;
+const PAD_Y = 24;
+const MAX_TIME = 12;
+
+function round(value: number, digits = 2) {
+ const p = 10 ** digits;
+ return Math.round(value * p) / p;
+}
+
+function format(value: number, unit = "") {
+ const n = round(value, 2);
+ return `${Number.isInteger(n) ? n : n.toFixed(2)}${unit}`;
+}
+
+function normalize(value: number, min: number, max: number) {
+ if (Math.abs(max - min) < 1e-9) return 0.5;
+ return (value - min) / (max - min);
+}
+
+export default function MotionLab() {
+ const [initialVelocity, setInitialVelocity] = createSignal(2);
+ const [acceleration, setAcceleration] = createSignal(1.2);
+ const [time, setTime] = createSignal(6);
+
+ const finalVelocity = createMemo(
+ () => initialVelocity() + acceleration() * time()
+ );
+ const displacement = createMemo(
+ () => initialVelocity() * time() + 0.5 * acceleration() * time() * time()
+ );
+
+ const samples = createMemo(() => {
+ const points = [];
+ for (let t = 0; t <= MAX_TIME; t += 0.2) {
+ const v = initialVelocity() + acceleration() * t;
+ const s = initialVelocity() * t + 0.5 * acceleration() * t * t;
+ points.push({ t, v, s });
+ }
+ return points;
+ });
+
+ const yRange = createMemo(() => {
+ const allV = samples().map((p) => p.v);
+ const allS = samples().map((p) => p.s);
+ const min = Math.min(...allV, ...allS, 0);
+ const max = Math.max(...allV, ...allS, 0);
+ return { min, max };
+ });
+
+ const toX = (t: number) =>
+ PAD_X + (t / MAX_TIME) * (PLOT_W - PAD_X * 2);
+ const toY = (y: number) =>
+ PAD_Y + (1 - normalize(y, yRange().min, yRange().max)) * (PLOT_H - PAD_Y * 2);
+
+ const velocityPath = createMemo(
+ () =>
+ samples()
+ .map((p, i) => `${i === 0 ? "M" : "L"} ${toX(p.t)} ${toY(p.v)}`)
+ .join(" ")
+ );
+
+ const displacementPath = createMemo(
+ () =>
+ samples()
+ .map((p, i) => `${i === 0 ? "M" : "L"} ${toX(p.t)} ${toY(p.s)}`)
+ .join(" ")
+ );
+
+ return (
+
+ );
+}
diff --git a/content/physics/subject.scss b/content/physics/subject.scss
new file mode 100644
index 0000000..bb698e3
--- /dev/null
+++ b/content/physics/subject.scss
@@ -0,0 +1,12 @@
+.physics-key-term {
+ padding: 0.05rem 0.34rem;
+ border-radius: 4px;
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+ font-family: var(--mono);
+ font-size: 0.85em;
+}
+
+.physics-unit {
+ font-family: var(--mono);
+ font-size: 0.9em;
+}
diff --git a/data/catalog.json b/data/catalog.json
index 7dae402..e27515d 100644
--- a/data/catalog.json
+++ b/data/catalog.json
@@ -4,6 +4,8 @@
"curriculums": ["Foundation", "DSE", "IB", "A-Level", "AP", "IGCSE", "Common Core"],
"subjects": [
{ "id": "math", "name": "Mathematics", "banner": "/catalog/banners/math.webp" },
+ { "id": "english", "name": "English" },
+ { "id": "physics", "name": "Physics", "banner": "/catalog/banners/physics.jpg" },
{ "id": "science", "name": "Science" },
{ "id": "humanities", "name": "Humanities" },
{ "id": "computing", "name": "Computing" }
diff --git a/data/computing-curriculum.json b/data/computing-curriculum.json
new file mode 100644
index 0000000..075cf57
--- /dev/null
+++ b/data/computing-curriculum.json
@@ -0,0 +1,8 @@
+{
+ "title": "Computing",
+ "intro": "Planned computing pathways will appear here.",
+ "graph": {
+ "edges": []
+ },
+ "strands": []
+}
diff --git a/data/english-curriculum.json b/data/english-curriculum.json
new file mode 100644
index 0000000..32c50b9
--- /dev/null
+++ b/data/english-curriculum.json
@@ -0,0 +1,39 @@
+{
+ "title": "English",
+ "intro": "Build reading, writing, speaking, and analysis skills through progressive chapter pathways.",
+ "graph": {
+ "edges": []
+ },
+ "strands": [
+ {
+ "id": "reading-writing-foundations",
+ "title": "Reading & Writing Foundations",
+ "chapters": [
+ {
+ "slug": "reading-comprehension-foundations",
+ "title": "Reading comprehension foundations",
+ "description": "Find main ideas, supporting details, and text evidence in short passages.",
+ "status": "live",
+ "curriculumCoverage": {
+ "Foundation": "core",
+ "IGCSE": "core",
+ "Common Core": "core",
+ "IB": "related"
+ }
+ },
+ {
+ "slug": "paragraph-writing-basics",
+ "title": "Paragraph writing basics",
+ "description": "Write focused paragraphs with a clear topic sentence, evidence, and explanation.",
+ "status": "planned",
+ "curriculumCoverage": {
+ "Foundation": "core",
+ "IGCSE": "core",
+ "Common Core": "core",
+ "IB": "related"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/data/faq.json b/data/faq.json
index d61d401..9528b7a 100644
--- a/data/faq.json
+++ b/data/faq.json
@@ -97,8 +97,9 @@
"type": "standard",
"question": "Is the content AI-generated?",
"answer": [
- "No. Chapters are authored HTML with care: figures, interactives, and maps where they help understanding — not bulk-generated slop or print PDFs pasted online.",
- "Core curriculum changes slowly; one carefully reviewed book, shared openly, beats regenerating the same chapter thousands of times in a data center."
+ "We use AI as a drafting and editing tool, but we do not rely on AI chat tokens as the final product layer. We publish reviewed, hand-curated chapters as open HTML so learning materials stay readable without paid AI access.",
+ "Token costs matter. Today some companies appear to offer \"free\" AI access, but that is often market-share strategy in an early stage. If prices rise later, learners with less money or weaker infrastructure could lose access.",
+ "The open web is more durable: HTML and JavaScript are stable foundations likely to remain usable for decades. Building high-quality, future-proof educational materials now — with AI assistance but web-native outputs — is the practical long-term approach."
]
},
{
diff --git a/data/humanities-curriculum.json b/data/humanities-curriculum.json
new file mode 100644
index 0000000..f1fb9d2
--- /dev/null
+++ b/data/humanities-curriculum.json
@@ -0,0 +1,8 @@
+{
+ "title": "Humanities",
+ "intro": "Planned humanities pathways will appear here.",
+ "graph": {
+ "edges": []
+ },
+ "strands": []
+}
diff --git a/data/physics-curriculum.json b/data/physics-curriculum.json
new file mode 100644
index 0000000..ea35e6c
--- /dev/null
+++ b/data/physics-curriculum.json
@@ -0,0 +1,215 @@
+{
+ "title": "Physics",
+ "intro": "HKDSE-aligned physics pathways across compulsory and elective modules.",
+ "graph": {
+ "edges": [
+ { "from": "thermal-energy-temperature", "to": "heat-transfer-and-insulation" },
+ { "from": "heat-transfer-and-insulation", "to": "change-of-state-latent-heat" },
+ { "from": "change-of-state-latent-heat", "to": "gas-laws-kinetic-theory" },
+ { "from": "kinematics-motion-graphs", "to": "force-newtons-laws" },
+ { "from": "force-newtons-laws", "to": "momentum-impulse-collisions" },
+ { "from": "force-newtons-laws", "to": "work-energy-power" },
+ { "from": "force-newtons-laws", "to": "uniform-circular-motion-gravitation" },
+ { "from": "wave-basics-superposition", "to": "light-optics" },
+ { "from": "wave-basics-superposition", "to": "sound-doppler-effect" },
+ { "from": "electrostatics-electric-fields", "to": "circuits-domestic-electricity" },
+ { "from": "circuits-domestic-electricity", "to": "electromagnetism-induction" },
+ { "from": "radioactivity-half-life", "to": "nuclear-energy-fission-fusion" },
+ { "from": "uniform-circular-motion-gravitation", "to": "astronomy-and-space-science" },
+ { "from": "work-energy-power", "to": "energy-and-use-of-energy" },
+ { "from": "gas-laws-kinetic-theory", "to": "atomic-world-quantum-intro" },
+ { "from": "nuclear-energy-fission-fusion", "to": "atomic-world-quantum-intro" },
+ { "from": "electromagnetism-induction", "to": "medical-physics" },
+ { "from": "nuclear-energy-fission-fusion", "to": "medical-physics" }
+ ]
+ },
+ "strands": [
+ {
+ "id": "heat-and-gases",
+ "title": "I. Heat and Gases",
+ "chapters": [
+ {
+ "slug": "thermal-energy-temperature",
+ "title": "Thermal energy, temperature, and internal energy",
+ "description": "Distinguish heat, temperature, and internal energy using particle and energy models.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IGCSE": "core", "A-Level": "core", "AP": "related" }
+ },
+ {
+ "slug": "heat-transfer-and-insulation",
+ "title": "Heat transfer: conduction, convection, and radiation",
+ "description": "Compare thermal transfer processes and evaluate insulation strategies in real systems.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IGCSE": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "change-of-state-latent-heat",
+ "title": "Change of state and latent heat",
+ "description": "Model phase changes with heating curves and latent heat calculations.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IGCSE": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "gas-laws-kinetic-theory",
+ "title": "Gas laws and kinetic theory",
+ "description": "Connect Boyle's and Charles's laws to particle motion and absolute temperature.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ }
+ ]
+ },
+ {
+ "id": "force-and-motion",
+ "title": "II. Force and Motion",
+ "chapters": [
+ {
+ "slug": "kinematics-motion-graphs",
+ "title": "Position, velocity, acceleration, and motion graphs",
+ "description": "Interpret and connect displacement-time, velocity-time, and acceleration-time graphs.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "force-newtons-laws",
+ "title": "Force and Newton's laws",
+ "description": "Use free-body diagrams and Newton's laws to predict acceleration and motion changes.",
+ "status": "live",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "momentum-impulse-collisions",
+ "title": "Momentum, impulse, and collisions",
+ "description": "Apply conservation of momentum to collisions and recoil situations.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "work-energy-power",
+ "title": "Work, energy, and power",
+ "description": "Relate force to energy transfer and analyze efficiency and power in systems.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "uniform-circular-motion-gravitation",
+ "title": "Uniform circular motion and gravitation",
+ "description": "Model circular motion and gravitational attraction in satellites and planetary systems.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "extension", "A-Level": "core", "AP": "related" }
+ }
+ ]
+ },
+ {
+ "id": "wave-motion",
+ "title": "III. Wave Motion",
+ "chapters": [
+ {
+ "slug": "wave-basics-superposition",
+ "title": "Wave properties and superposition",
+ "description": "Describe wave quantities and apply reflection, refraction, diffraction, and interference.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "light-optics",
+ "title": "Light, lenses, and optical models",
+ "description": "Use ray and wave models to analyze image formation and optical instruments.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "sound-doppler-effect",
+ "title": "Sound, pitch, loudness, and Doppler effect",
+ "description": "Connect sound wave behavior to frequency, intensity, and moving-source effects.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "related" }
+ }
+ ]
+ },
+ {
+ "id": "electricity-and-magnetism",
+ "title": "IV. Electricity and Magnetism",
+ "chapters": [
+ {
+ "slug": "electrostatics-electric-fields",
+ "title": "Electrostatics, electric fields, and potential",
+ "description": "Model electric forces and potential differences in static-charge systems.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "circuits-domestic-electricity",
+ "title": "Circuits and domestic electricity",
+ "description": "Analyze circuit behavior, power consumption, and safety in household systems.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IGCSE": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "electromagnetism-induction",
+ "title": "Magnetism and electromagnetic induction",
+ "description": "Explain magnetic effects of current and induction in generators and transformers.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ }
+ ]
+ },
+ {
+ "id": "radioactivity-and-nuclear-energy",
+ "title": "V. Radioactivity and Nuclear Energy",
+ "chapters": [
+ {
+ "slug": "radioactivity-half-life",
+ "title": "Radiation, radioactivity, and half-life",
+ "description": "Classify ionizing radiation and model radioactive decay quantitatively.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "nuclear-energy-fission-fusion",
+ "title": "Nuclear models, fission, and fusion",
+ "description": "Compare fission and fusion through binding energy and mass-energy conversion.",
+ "status": "planned",
+ "curriculumCoverage": { "DSE": "core", "IB": "extension", "A-Level": "core", "AP": "core" }
+ }
+ ]
+ },
+ {
+ "id": "elective-part",
+ "title": "Elective Part (choose two)",
+ "chapters": [
+ {
+ "slug": "astronomy-and-space-science",
+ "title": "Astronomy and space science",
+ "description": "Study celestial coordinates, orbital motion, and cosmology foundations.",
+ "status": "planned",
+ "tier": "non-foundation",
+ "curriculumCoverage": { "DSE": "extension", "IB": "core", "A-Level": "related", "AP": "related" }
+ },
+ {
+ "slug": "atomic-world-quantum-intro",
+ "title": "Atomic world and quantum foundations",
+ "description": "Bridge atomic models, photoelectric effect, and wave-particle duality.",
+ "status": "planned",
+ "tier": "non-foundation",
+ "curriculumCoverage": { "DSE": "extension", "IB": "core", "A-Level": "core", "AP": "core" }
+ },
+ {
+ "slug": "energy-and-use-of-energy",
+ "title": "Energy and use of energy",
+ "description": "Evaluate energy resources, efficiency, and sustainability trade-offs.",
+ "status": "planned",
+ "tier": "non-foundation",
+ "curriculumCoverage": { "DSE": "extension", "IB": "related", "A-Level": "core", "AP": "related" }
+ },
+ {
+ "slug": "medical-physics",
+ "title": "Medical physics",
+ "description": "Connect imaging technologies and radiation safety to physics principles.",
+ "status": "planned",
+ "tier": "non-foundation",
+ "curriculumCoverage": { "DSE": "extension", "IB": "related", "A-Level": "related", "AP": "related" }
+ }
+ ]
+ }
+ ]
+}
diff --git a/data/science-curriculum.json b/data/science-curriculum.json
new file mode 100644
index 0000000..06645ec
--- /dev/null
+++ b/data/science-curriculum.json
@@ -0,0 +1,8 @@
+{
+ "title": "Science",
+ "intro": "Planned science pathways will appear here.",
+ "graph": {
+ "edges": []
+ },
+ "strands": []
+}
diff --git a/static/catalog/banners/physics.jpg b/static/catalog/banners/physics.jpg
new file mode 100644
index 0000000..ff1fb5b
Binary files /dev/null and b/static/catalog/banners/physics.jpg differ
diff --git a/themes/openfreebooks/templates/catalog.html b/themes/openfreebooks/templates/catalog.html
index 92a800d..5885d75 100644
--- a/themes/openfreebooks/templates/catalog.html
+++ b/themes/openfreebooks/templates/catalog.html
@@ -15,12 +15,13 @@
"curriculums": {{ catalog.curriculums | json_encode() | safe }},
"subjects": [
{% for subject in catalog.subjects %}
+ {% set subject_curriculum = load_data(path="data/" ~ subject.id ~ "-curriculum.json", format="json") %}
{
"id": {{ subject.id | json_encode() | safe }},
"name": {{ subject.name | json_encode() | safe }}{% if subject.banner %},
"banner": {{ subject.banner | json_encode() | safe }}{% endif %},
- "strands": {% if subject.id == "math" %}{{ math_catalog.strands | json_encode() | safe }}{% else %}[]{% endif %}{% if subject.id == "math" and math_catalog.graph %},
- "graph": {{ math_catalog.graph | json_encode() | safe }}{% endif %}
+ "strands": {{ subject_curriculum.strands | default(value=[]) | json_encode() | safe }}{% if subject_curriculum.graph %},
+ "graph": {{ subject_curriculum.graph | json_encode() | safe }}{% endif %}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
diff --git a/themes/openfreebooks/templates/chapter.html b/themes/openfreebooks/templates/chapter.html
index 536924e..879cd24 100644
--- a/themes/openfreebooks/templates/chapter.html
+++ b/themes/openfreebooks/templates/chapter.html
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% block title %}
-{% set subject_catalog = load_data(path="data/math-curriculum.json", format="json") %}
+{% set subject = section.extra.subject | default(value="math") %}
+{% set subject_catalog = load_data(path="data/" ~ subject ~ "-curriculum.json", format="json") %}
{{ section.title }} | {{ subject_catalog.title }} | {{ config.extra.site_title }}
{% endblock title %}
@@ -26,10 +27,10 @@
{% endblock extra_head %}
{% block content %}
-{% set subject = section.extra.subject %}
+{% set subject = section.extra.subject | default(value="math") %}
{% set slug = section.extra.chapter_id | default(value=section.slug) %}
{% set chapter_key = subject ~ "/" ~ slug %}
-{% set subject_catalog = load_data(path="data/math-curriculum.json", format="json") %}
+{% set subject_catalog = load_data(path="data/" ~ subject ~ "-curriculum.json", format="json") %}
{% set catalog_subject_url = get_url(path="/catalog/") ~ "?subject=" ~ subject %}
diff --git a/themes/openfreebooks/templates/partials/contributing-content.html b/themes/openfreebooks/templates/partials/contributing-content.html
index d222bbe..bb637ec 100644
--- a/themes/openfreebooks/templates/partials/contributing-content.html
+++ b/themes/openfreebooks/templates/partials/contributing-content.html
@@ -41,6 +41,7 @@ content/{subject}/{slug}/ → live page (one folder)
Compare (?view=compare, Mathematics only) — curricula comparison doc (HTML partial, not JSON).
Curriculum names (DSE, IB, …) appear only in the catalog, never inside lesson prose.
Every chapter needs a description (1–2 sentences; map clamps to three lines).
+ Adding a subject should only require updates in data/ and content/; templates stay unchanged.
@@ -77,7 +78,7 @@ content/{subject}/{slug}/ → live page (one folder)
2. Chapter folder
Add _index.md, core.html, optional supplement.html, assets/, widgets/*.tsx. Copy content/math/quadratic-equations/.
3. Build
- bun run build:js syncs HTML and bundles widgets — no theme or main.tsx edits.
+ bun run build:js syncs HTML and bundles widgets — no theme template or main.tsx edits.