Enhance curriculum structure and add new subjects: Update CONTRIBUTING.md with detailed guidelines for adding new subjects and their associated assets. Introduce English and Physics subjects in the catalog, along with their respective curriculum files and chapter structures. Implement shared styles for English and Physics chapters, and add interactive widgets for physics concepts. Update FAQ to clarify the use of AI in content creation, ensuring accessibility and durability of educational materials.

This commit is contained in:
Hananoshika Yomaru 2026-05-20 19:10:55 -07:00
parent a2679aa4de
commit 5f14020cfa
26 changed files with 1530 additions and 12 deletions

View file

@ -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.
---

View file

@ -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)

View file

@ -0,0 +1,5 @@
+++
title = "English"
description = "Browse English chapters in the curriculum catalog."
sort_by = "weight"
+++

View file

@ -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"
+++
<div class="book-prose">
<p>
Reading comprehension means understanding what a text says directly and what it suggests
indirectly. In this chapter you will practice spotting the
<span class="english-key-term">main idea</span>, finding
<span class="english-key-term">supporting details</span>, and selecting
<span class="english-key-term">text evidence</span>.
</p>
<h2 id="main-idea" class="book-prose__heading">Main idea and key details</h2>
<p>
The <strong>main idea</strong> is the most important message in a paragraph or passage.
<strong>Key details</strong> are specific facts or examples that support that message.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Quick strategy</p>
<p>
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.
</p>
</div>
<h2 id="evidence" class="book-prose__heading">Use evidence, not guesses</h2>
<p>
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.
</p>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: main idea</summary>
<div class="book-question__solution">
<p>
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?
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evidence</summary>
<div class="book-question__solution">
<p>
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.
</p>
</div>
</details>
</div>

View file

@ -0,0 +1,49 @@
<div class="book-prose">
<p>
Reading comprehension means understanding what a text says directly and what it suggests
indirectly. In this chapter you will practice spotting the
<span class="english-key-term">main idea</span>, finding
<span class="english-key-term">supporting details</span>, and selecting
<span class="english-key-term">text evidence</span>.
</p>
<h2 id="main-idea" class="book-prose__heading">Main idea and key details</h2>
<p>
The <strong>main idea</strong> is the most important message in a paragraph or passage.
<strong>Key details</strong> are specific facts or examples that support that message.
</p>
<div class="book-callout" role="note">
<p class="book-callout__label">Quick strategy</p>
<p>
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.
</p>
</div>
<h2 id="evidence" class="book-prose__heading">Use evidence, not guesses</h2>
<p>
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.
</p>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: main idea</summary>
<div class="book-question__solution">
<p>
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?
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">Checkpoint: evidence</summary>
<div class="book-question__solution">
<p>
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.
</p>
</div>
</details>
</div>

View file

@ -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;
}

View file

@ -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.

View file

@ -0,0 +1,5 @@
+++
title = "Physics"
description = "Browse physics chapters in the curriculum catalog."
sort_by = "weight"
+++

View file

@ -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"
+++
<div class="book-prose">
<p>
In HKDSE Physics, force and motion are linked by three core ideas:
<span class="physics-key-term">inertia</span>,
<span class="physics-key-term">net force</span>, and
<span class="physics-key-term">acceleration</span>. This chapter gives you one practical flow:
draw forces, find the net force, then predict acceleration with Newton's second law.
</p>
<h2 id="newtons-laws" class="book-prose__heading">Newton's three laws</h2>
<ol>
<li>
<strong>First law:</strong> if net force is zero, velocity stays constant (including rest).
</li>
<li>
<strong>Second law:</strong> acceleration is proportional to net force and inversely proportional
to mass.
</li>
<li>
<strong>Third law:</strong> forces come in equal and opposite interaction pairs.
</li>
</ol>
<div class="book-callout book-callout--formula" role="note">
<p class="book-callout__label">Core equation</p>
<p class="book-formula">\[ \vec{F}_{\text{net}} = m\vec{a} \]</p>
</div>
<h2 id="workflow" class="book-prose__heading">Problem-solving workflow</h2>
<ol>
<li>Choose one object as your system.</li>
<li>Sketch all external forces in a free-body diagram.</li>
<li>Resolve forces by direction and compute net force.</li>
<li>Use \(a = F_{\text{net}} / m\), then connect to motion equations.</li>
</ol>
<h2 id="interactive-force-lab" class="book-prose__heading">Interactive force lab</h2>
<p>
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.
</p>
</div>
<div class="physics-widget-mount" data-widget="force-lab" data-pagefind-ignore></div>
<div class="book-prose">
<h2 id="interactive-motion-lab" class="book-prose__heading">Interactive motion graphs</h2>
<p>
Change initial velocity, acceleration, and elapsed time to inspect
<span class="physics-key-term">v-t</span> and <span class="physics-key-term">s-t</span> behavior.
This connects Newton's second law to kinematics directly.
</p>
</div>
<div class="physics-widget-mount" data-widget="motion-lab" data-pagefind-ignore></div>
<div class="book-prose">
<h2 id="examples" class="book-prose__heading">Worked checkpoints</h2>
<p>
These quick prompts are designed for class discussion or self-check before moving to momentum
and energy.
</p>
<details class="book-question">
<summary class="book-question__prompt">
A 4 kg trolley is pulled right with 18 N while friction is 10 N left. What is its acceleration?
</summary>
<div class="book-question__solution">
<p>
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.
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">
Why can a book stay at rest on a table even though gravity is acting?
</summary>
<div class="book-question__solution">
<p>
Gravity pulls down, but the table exerts an equal normal force upward. Net force is zero, so
acceleration is zero.
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">
A rocket pushes exhaust gas backward. Which Newton's law explains forward thrust?
</summary>
<div class="book-question__solution">
<p>
Newton's third law. The rocket pushes gas backward; the gas pushes the rocket forward with an
equal and opposite force.
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">
In the motion lab, what happens to the velocity-time line when acceleration is set to zero?
</summary>
<div class="book-question__solution">
<p>
The line becomes horizontal, showing constant velocity. Position then changes linearly with
time.
</p>
</div>
</details>
</div>

View file

@ -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;
}
}

View file

@ -0,0 +1,55 @@
<div class="book-prose">
<p>
In HKDSE Physics, force and motion are linked by three core ideas:
<span class="physics-key-term">inertia</span>,
<span class="physics-key-term">net force</span>, and
<span class="physics-key-term">acceleration</span>. This chapter gives you one practical flow:
draw forces, find the net force, then predict acceleration with Newton's second law.
</p>
<h2 id="newtons-laws" class="book-prose__heading">Newton's three laws</h2>
<ol>
<li>
<strong>First law:</strong> if net force is zero, velocity stays constant (including rest).
</li>
<li>
<strong>Second law:</strong> acceleration is proportional to net force and inversely proportional
to mass.
</li>
<li>
<strong>Third law:</strong> forces come in equal and opposite interaction pairs.
</li>
</ol>
<div class="book-callout book-callout--formula" role="note">
<p class="book-callout__label">Core equation</p>
<p class="book-formula">\[ \vec{F}_{\text{net}} = m\vec{a} \]</p>
</div>
<h2 id="workflow" class="book-prose__heading">Problem-solving workflow</h2>
<ol>
<li>Choose one object as your system.</li>
<li>Sketch all external forces in a free-body diagram.</li>
<li>Resolve forces by direction and compute net force.</li>
<li>Use \(a = F_{\text{net}} / m\), then connect to motion equations.</li>
</ol>
<h2 id="interactive-force-lab" class="book-prose__heading">Interactive force lab</h2>
<p>
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.
</p>
</div>
<div class="physics-widget-mount" data-widget="force-lab" data-pagefind-ignore></div>
<div class="book-prose">
<h2 id="interactive-motion-lab" class="book-prose__heading">Interactive motion graphs</h2>
<p>
Change initial velocity, acceleration, and elapsed time to inspect
<span class="physics-key-term">v-t</span> and <span class="physics-key-term">s-t</span> behavior.
This connects Newton's second law to kinematics directly.
</p>
</div>
<div class="physics-widget-mount" data-widget="motion-lab" data-pagefind-ignore></div>

View file

@ -0,0 +1,55 @@
<div class="book-prose">
<h2 id="examples" class="book-prose__heading">Worked checkpoints</h2>
<p>
These quick prompts are designed for class discussion or self-check before moving to momentum
and energy.
</p>
<details class="book-question">
<summary class="book-question__prompt">
A 4 kg trolley is pulled right with 18 N while friction is 10 N left. What is its acceleration?
</summary>
<div class="book-question__solution">
<p>
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.
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">
Why can a book stay at rest on a table even though gravity is acting?
</summary>
<div class="book-question__solution">
<p>
Gravity pulls down, but the table exerts an equal normal force upward. Net force is zero, so
acceleration is zero.
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">
A rocket pushes exhaust gas backward. Which Newton's law explains forward thrust?
</summary>
<div class="book-question__solution">
<p>
Newton's third law. The rocket pushes gas backward; the gas pushes the rocket forward with an
equal and opposite force.
</p>
</div>
</details>
<details class="book-question">
<summary class="book-question__prompt">
In the motion lab, what happens to the velocity-time line when acceleration is set to zero?
</summary>
<div class="book-question__solution">
<p>
The line becomes horizontal, showing constant velocity. Position then changes linearly with
time.
</p>
</div>
</details>
</div>

View file

@ -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 (
<section class="physics-widget force-lab" aria-labelledby="force-lab-title">
<h2 id="force-lab-title" class="physics-widget__title">
Force lab: animated friction simulation
</h2>
<div class="physics-widget__layout">
<div class="physics-widget__section">
<div class="physics-slider-row">
<label for="force-lab-mass">
<span>Mass</span>
<span class="physics-value">{format(massKg(), " kg")}</span>
</label>
<input
id="force-lab-mass"
type="range"
min={1}
max={40}
step={0.5}
value={massKg()}
onInput={(e) => {
reset();
setMassKg(Number(e.currentTarget.value));
}}
/>
</div>
<div class="physics-slider-row">
<label for="force-lab-applied">
<span>Pull force</span>
<span class="physics-value">{format(appliedForceN(), " N")}</span>
</label>
<input
id="force-lab-applied"
type="range"
min={0}
max={200}
step={1}
value={appliedForceN()}
onInput={(e) => {
reset();
setAppliedForceN(Number(e.currentTarget.value));
}}
/>
</div>
<div class="physics-slider-row">
<label for="force-lab-us">
<span>Static friction μs</span>
<span class="physics-value">{format(muStatic())}</span>
</label>
<input
id="force-lab-us"
type="range"
min={0}
max={1}
step={0.02}
value={muStatic()}
onInput={(e) => {
reset();
setMuStatic(Number(e.currentTarget.value));
}}
/>
</div>
<div class="physics-slider-row">
<label for="force-lab-uk">
<span>Kinetic friction μk</span>
<span class="physics-value">{format(muKinetic())}</span>
</label>
<input
id="force-lab-uk"
type="range"
min={0}
max={1}
step={0.02}
value={muKinetic()}
onInput={(e) => {
reset();
setMuKinetic(Number(e.currentTarget.value));
}}
/>
</div>
</div>
<div class="physics-widget__section">
<div class="force-lab__status-row">
<span
class="force-lab__state-pill"
classList={{ "force-lab__state-pill--moving": current().state === "Moving" }}
>
{current().state === "Moving" ? "Moving / Sliding" : "Static State"}
</span>
<span class="force-lab__time">{format(time(), "s")} / 3.00s</span>
</div>
<div class="force-lab__diagram-wrap">
<canvas
ref={(el) => {
simCanvasRef = el;
}}
class="force-lab__canvas"
/>
</div>
<div class="force-lab__timeline">
<button
type="button"
class="force-lab__btn force-lab__btn--play"
onClick={() => (playing() ? pause() : play())}
>
{playing() ? "Pause" : "Play"}
</button>
<button type="button" class="force-lab__btn" onClick={reset}>
Reset
</button>
<input
class="force-lab__scrubber"
type="range"
min={0}
max={DURATION}
step={0.01}
value={time()}
onInput={(e) => {
pause();
setTime(Number(e.currentTarget.value));
}}
/>
</div>
<p class="force-lab__summary">
<strong>Friction threshold:</strong> {format(current().maxStaticFriction, " N")}
<br />
<strong>Current friction:</strong> {format(current().friction, " N")}
<br />
<strong>Net force:</strong> {format(current().netForce, " N")}
<br />
<strong>Acceleration:</strong> {format(current().acceleration, " m s⁻²")}
<br />
<strong>Velocity:</strong> {format(current().velocity, " m s⁻¹")} · <strong>Displacement:</strong>{" "}
{format(current().displacement, " m")}
</p>
</div>
</div>
</section>
);
}

View file

@ -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 (
<section class="physics-widget motion-lab" aria-labelledby="motion-lab-title">
<h2 id="motion-lab-title" class="physics-widget__title">
Motion lab: velocity-time and displacement-time
</h2>
<div class="physics-widget__layout">
<div class="physics-widget__section">
<div class="physics-slider-row">
<label for="motion-lab-u">
<span>Initial velocity u</span>
<span class="physics-value">{format(initialVelocity(), " m s⁻¹")}</span>
</label>
<input
id="motion-lab-u"
type="range"
min={-8}
max={16}
step={0.2}
value={initialVelocity()}
onInput={(e) => setInitialVelocity(Number(e.currentTarget.value))}
/>
</div>
<div class="physics-slider-row">
<label for="motion-lab-a">
<span>Acceleration a</span>
<span class="physics-value">{format(acceleration(), " m s⁻²")}</span>
</label>
<input
id="motion-lab-a"
type="range"
min={-6}
max={6}
step={0.1}
value={acceleration()}
onInput={(e) => setAcceleration(Number(e.currentTarget.value))}
/>
</div>
<div class="physics-slider-row">
<label for="motion-lab-t">
<span>Elapsed time t</span>
<span class="physics-value">{format(time(), " s")}</span>
</label>
<input
id="motion-lab-t"
type="range"
min={0}
max={MAX_TIME}
step={0.1}
value={time()}
onInput={(e) => setTime(Number(e.currentTarget.value))}
/>
</div>
<div class="motion-lab__stats">
<div class="motion-lab__stat">
<span class="motion-lab__stat-label">v = u + at</span>
<span class="motion-lab__stat-value">{format(finalVelocity(), " m s⁻¹")}</span>
</div>
<div class="motion-lab__stat">
<span class="motion-lab__stat-label">s = ut + 1/2at²</span>
<span class="motion-lab__stat-value">{format(displacement(), " m")}</span>
</div>
<div class="motion-lab__stat">
<span class="motion-lab__stat-label">Direction now</span>
<span class="motion-lab__stat-value">
{finalVelocity() > 0 ? "right" : finalVelocity() < 0 ? "left" : "instant rest"}
</span>
</div>
</div>
</div>
<div class="physics-widget__section">
<svg
class="motion-lab__plot"
viewBox={`0 0 ${PLOT_W} ${PLOT_H}`}
role="img"
aria-label="Velocity-time and displacement-time graph"
>
<rect x="0" y="0" width={PLOT_W} height={PLOT_H} fill="transparent" />
<line
x1={PAD_X}
y1={PLOT_H - PAD_Y}
x2={PLOT_W - PAD_X}
y2={PLOT_H - PAD_Y}
stroke="var(--line)"
/>
<line
x1={PAD_X}
y1={PAD_Y}
x2={PAD_X}
y2={PLOT_H - PAD_Y}
stroke="var(--line)"
/>
<line
x1={toX(time())}
y1={PAD_Y}
x2={toX(time())}
y2={PLOT_H - PAD_Y}
stroke="color-mix(in srgb, var(--soft-ink) 55%, transparent)"
stroke-dasharray="4 4"
/>
<path d={velocityPath()} fill="none" stroke="#0ea5e9" stroke-width="3" />
<path d={displacementPath()} fill="none" stroke="#f59e0b" stroke-width="3" />
<circle cx={toX(time())} cy={toY(finalVelocity())} r="4" fill="#0ea5e9" />
<circle cx={toX(time())} cy={toY(displacement())} r="4" fill="#f59e0b" />
<text x={PLOT_W - PAD_X + 4} y={PLOT_H - PAD_Y + 4} font-size="12" fill="var(--soft-ink)">
t
</text>
<text x={PAD_X - 18} y={PAD_Y - 4} font-size="12" fill="var(--soft-ink)">
y
</text>
</svg>
<p class="motion-lab__legend" aria-hidden="true">
<span class="motion-lab__legend-line motion-lab__legend-line--velocity">velocity</span>
<span class="motion-lab__legend-line motion-lab__legend-line--position">displacement</span>
</p>
</div>
</div>
</section>
);
}

View file

@ -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;
}

View file

@ -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" }

View file

@ -0,0 +1,8 @@
{
"title": "Computing",
"intro": "Planned computing pathways will appear here.",
"graph": {
"edges": []
},
"strands": []
}

View file

@ -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"
}
}
]
}
]
}

View file

@ -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."
]
},
{

View file

@ -0,0 +1,8 @@
{
"title": "Humanities",
"intro": "Planned humanities pathways will appear here.",
"graph": {
"edges": []
},
"strands": []
}

View file

@ -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" }
}
]
}
]
}

View file

@ -0,0 +1,8 @@
{
"title": "Science",
"intro": "Planned science pathways will appear here.",
"graph": {
"edges": []
},
"strands": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

View file

@ -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 %}
]

View file

@ -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 %}
<section class="book-page book-page--chapter">
<div class="container">

View file

@ -41,6 +41,7 @@ content/{subject}/{slug}/ → live page (one folder)
<li><strong>Compare</strong> (<code>?view=compare</code>, Mathematics only) — curricula comparison doc (HTML partial, not JSON).</li>
<li>Curriculum names (DSE, IB, …) appear <strong>only in the catalog</strong>, never inside lesson prose.</li>
<li>Every chapter needs a <code>description</code> (1–2 sentences; map clamps to three lines).</li>
<li>Adding a subject should only require updates in <code>data/</code> and <code>content/</code>; templates stay unchanged.</li>
</ul>
</div>
@ -77,7 +78,7 @@ content/{subject}/{slug}/ → live page (one folder)
<h3 class="contributing-section__subtitle">2. Chapter folder</h3>
<p>Add <code>_index.md</code>, <code>core.html</code>, optional <code>supplement.html</code>, <code>assets/</code>, <code>widgets/*.tsx</code>. Copy <code>content/math/quadratic-equations/</code>.</p>
<h3 class="contributing-section__subtitle">3. Build</h3>
<p><code>bun run build:js</code> syncs HTML and bundles widgets — no theme or <code>main.tsx</code> edits.</p>
<p><code>bun run build:js</code> syncs HTML and bundles widgets — no theme template or <code>main.tsx</code> edits.</p>
</div>
<aside class="contributing-card-demo" aria-label="Map chapter card example">