diff --git a/.agents/skills/ofb/SKILL.md b/.agents/skills/ofb/SKILL.md
index 939edd6..c5c7605 100644
--- a/.agents/skills/ofb/SKILL.md
+++ b/.agents/skills/ofb/SKILL.md
@@ -110,7 +110,7 @@ Prerequisites: **only** `graph.edges` (required, global DAG, cross-strand OK). L
Live math example:
1. `content/math/quadratic-equations/_index.md` — front matter, `template = "math/chapter.html"`, `[extra] chapter_id`
-2. `templates/partials/math/quadratic-equations-content.html` — prose + KaTeX
+2. `templates/partials/math/quadratic-equations-core-content.html` — prose + KaTeX (supplement partial for history, derivation, etc.)
3. `templates/math/chapter.html` — `{% if section.extra.chapter_id == "…" %}` includes partial
4. Optional: `frontend/src/components/quadratic-explorer.tsx` + mount in `main.tsx`
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f3aa55a..de69391 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -120,7 +120,7 @@ Today, chapter bodies live as HTML partials under the theme, not Markdown:
{% endif %}
```
-Use existing classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `quadratic-equations-content.html`.
+Use existing classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `quadratic-equations-core-content.html`.
**Do not** mention DSE, IB, or other curriculum names in the chapter body.
diff --git a/frontend/src/components/chapter-heading-nav.tsx b/frontend/src/components/chapter-heading-nav.tsx
new file mode 100644
index 0000000..8e9d505
--- /dev/null
+++ b/frontend/src/components/chapter-heading-nav.tsx
@@ -0,0 +1,270 @@
+import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
+
+type NavHeading = {
+ id: string;
+ label: string;
+ level: 2 | 3;
+};
+
+/** Align with sticky site header (see .book-prose__heading scroll-margin-top). */
+const SCROLL_OFFSET = 96;
+const CLICK_LOCK_FALLBACK_MS = 900;
+
+function collectHeadings(article: HTMLElement): NavHeading[] {
+ const nodes = article.querySelectorAll(
+ ".book-prose h2.book-prose__heading[id], .book-prose h3.book-prose__heading[id]"
+ );
+ return Array.from(nodes).map((node) => ({
+ id: node.id,
+ label: node.textContent?.trim() ?? node.id,
+ level: node.tagName === "H3" ? 3 : 2,
+ }));
+}
+
+function headingDocumentTop(el: HTMLElement) {
+ return el.getBoundingClientRect().top + window.scrollY;
+}
+
+function pickActiveId(items: NavHeading[]): string | null {
+ if (items.length === 0) return null;
+
+ const position = window.scrollY + SCROLL_OFFSET;
+ let active = items[0].id;
+
+ for (const item of items) {
+ const el = document.getElementById(item.id);
+ if (!el) continue;
+ if (headingDocumentTop(el) <= position + 2) {
+ active = item.id;
+ } else {
+ break;
+ }
+ }
+
+ return active;
+}
+
+export function ChapterHeadingNav() {
+ const [headings, setHeadings] = createSignal([]);
+ const [activeId, setActiveId] = createSignal(null);
+ const [railOpen, setRailOpen] = createSignal(false);
+ const [sheetOpen, setSheetOpen] = createSignal(false);
+ const [isMobile, setIsMobile] = createSignal(false);
+
+ let scrollToHeading: ((id: string) => void) | undefined;
+
+ onMount(() => {
+ const article = document.querySelector(".book-chapter");
+ if (!article) return;
+
+ const items = collectHeadings(article);
+ setHeadings(items);
+
+ const hash = window.location.hash.slice(1);
+ const hashMatch = hash && items.some((item) => item.id === hash);
+ setActiveId(hashMatch ? hash : pickActiveId(items));
+
+ let lockedId: string | null = null;
+ let lockTimer: number | undefined;
+ let rafId: number | undefined;
+ let scrollEndHandler: (() => void) | null = null;
+
+ const releaseLock = () => {
+ lockedId = null;
+ if (scrollEndHandler) {
+ window.removeEventListener("scrollend", scrollEndHandler);
+ scrollEndHandler = null;
+ }
+ if (lockTimer !== undefined) {
+ window.clearTimeout(lockTimer);
+ lockTimer = undefined;
+ }
+ setActiveId(pickActiveId(items));
+ };
+
+ const syncActive = () => {
+ if (rafId !== undefined) cancelAnimationFrame(rafId);
+ rafId = requestAnimationFrame(() => {
+ rafId = undefined;
+ if (lockedId) {
+ setActiveId(lockedId);
+ return;
+ }
+ setActiveId(pickActiveId(items));
+ });
+ };
+
+ scrollToHeading = (id: string) => {
+ const el = document.getElementById(id);
+ if (!el) return;
+
+ lockedId = id;
+ setActiveId(id);
+
+ if (scrollEndHandler) {
+ window.removeEventListener("scrollend", scrollEndHandler);
+ }
+ if (lockTimer !== undefined) {
+ window.clearTimeout(lockTimer);
+ }
+
+ const top = Math.max(0, headingDocumentTop(el) - SCROLL_OFFSET);
+ window.scrollTo({ top, behavior: "smooth" });
+ history.replaceState(null, "", `#${id}`);
+
+ scrollEndHandler = () => releaseLock();
+ window.addEventListener("scrollend", scrollEndHandler, { once: true });
+ lockTimer = window.setTimeout(releaseLock, CLICK_LOCK_FALLBACK_MS);
+ };
+
+ const onScroll = () => syncActive();
+ window.addEventListener("scroll", onScroll, { passive: true });
+
+ const mq = window.matchMedia("(max-width: 899px)");
+ const updateMobile = () => setIsMobile(mq.matches);
+ updateMobile();
+ mq.addEventListener("change", updateMobile);
+
+ if (hashMatch) {
+ requestAnimationFrame(() => scrollToHeading?.(hash));
+ }
+
+ onCleanup(() => {
+ window.removeEventListener("scroll", onScroll);
+ mq.removeEventListener("change", updateMobile);
+ releaseLock();
+ if (rafId !== undefined) cancelAnimationFrame(rafId);
+ scrollToHeading = undefined;
+ });
+ });
+
+ createEffect(() => {
+ if (!isMobile()) {
+ setSheetOpen(false);
+ } else {
+ setRailOpen(false);
+ }
+ });
+
+ const jump = (id: string) => {
+ if (scrollToHeading) {
+ scrollToHeading(id);
+ } else {
+ const el = document.getElementById(id);
+ if (el) {
+ const top = Math.max(0, headingDocumentTop(el) - SCROLL_OFFSET);
+ window.scrollTo({ top, behavior: "smooth" });
+ history.replaceState(null, "", `#${id}`);
+ setActiveId(id);
+ }
+ }
+ setSheetOpen(false);
+ };
+
+ const NavList = (props: { class?: string }) => (
+
+
+ {(item) => (
+
+ jump(item.id)}
+ >
+ {item.label}
+
+
+ )}
+
+
+ );
+
+ return (
+ 0}>
+
+
+ setRailOpen(true)}
+ onMouseLeave={() => setRailOpen(false)}
+ onFocusIn={() => setRailOpen(true)}
+ onFocusOut={(e) => {
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
+ setRailOpen(false);
+ }
+ }}
+ >
+
+
+ {(item) => (
+ jump(item.id)}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ setSheetOpen((open) => !open)}
+ >
+ On this page
+
+
+ setSheetOpen(false)}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/quadratic-application-demos.tsx b/frontend/src/components/quadratic-application-demos.tsx
new file mode 100644
index 0000000..d91b3d4
--- /dev/null
+++ b/frontend/src/components/quadratic-application-demos.tsx
@@ -0,0 +1,750 @@
+import katex from "katex";
+import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
+import { computeRoots, discriminant, formatNum } from "../lib/math-graph";
+
+const HORIZONTAL_SPEED = 12;
+
+function renderLatex(latex: string) {
+ return katex.renderToString(latex, { throwOnError: false, strict: "ignore" });
+}
+
+function SliderRow(props: {
+ id: string;
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ step: number;
+ unit?: string;
+ disabled?: boolean;
+ onInput: (v: number) => void;
+}) {
+ const display = () =>
+ props.unit ? `${formatNum(props.value)} ${props.unit}` : formatNum(props.value);
+
+ return (
+
+
+ {props.label}
+ {display()}
+
+ props.onInput(Number(e.currentTarget.value))}
+ />
+
+ );
+}
+
+export function ProjectileDemo() {
+ const [v0y, setV0y] = createSignal(20);
+ const [g, setG] = createSignal(10);
+ const [h0, setH0] = createSignal(0);
+ const [t, setT] = createSignal(0);
+ const [playing, setPlaying] = createSignal(false);
+
+ let canvasRef: HTMLCanvasElement | undefined;
+ let rafId: number | undefined;
+ let playStart = 0;
+
+ const heightAt = (time: number) => h0() + v0y() * time - 0.5 * g() * time * time;
+ const distanceAt = (time: number) => HORIZONTAL_SPEED * time;
+
+ const landingTime = createMemo(() => {
+ const a = -0.5 * g();
+ const d = discriminant(a, v0y(), h0());
+ const roots = computeRoots(a, v0y(), h0(), d);
+ const positive = roots.filter((r) => r > 0.01);
+ if (positive.length > 0) return Math.max(...positive);
+ return Math.max(2, (2 * v0y()) / g());
+ });
+
+ const maxX = createMemo(() => distanceAt(landingTime()) * 1.1 + 5);
+ const maxY = createMemo(() => {
+ const peak = h0() + (v0y() * v0y()) / (2 * g());
+ return Math.max(peak * 1.15, h0() + 2, 5);
+ });
+
+ const drawScene = (time: number) => {
+ const canvas = canvasRef;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const w = 400;
+ const h = 240;
+ const pad = { l: 36, r: 12, t: 12, b: 28 };
+ const plotW = w - pad.l - pad.r;
+ const plotH = h - pad.t - pad.b;
+
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ const mx = maxX();
+ const my = maxY();
+
+ const toX = (x: number) => pad.l + (x / mx) * plotW;
+ const toY = (y: number) => pad.t + plotH - (y / my) * plotH;
+
+ ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff";
+ ctx.fillRect(0, 0, w, h);
+
+ ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5";
+ ctx.lineWidth = 1;
+ for (let i = 0; i <= 4; i++) {
+ const y = (my * i) / 4;
+ const py = toY(y);
+ ctx.beginPath();
+ ctx.moveTo(pad.l, py);
+ ctx.lineTo(w - pad.r, py);
+ ctx.stroke();
+ }
+
+ ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e";
+ ctx.lineWidth = 2;
+ const groundY = toY(0);
+ ctx.beginPath();
+ ctx.moveTo(pad.l, groundY);
+ ctx.lineTo(w - pad.r, groundY);
+ ctx.stroke();
+
+ ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
+ ctx.lineWidth = 2.5;
+ ctx.beginPath();
+ const steps = 80;
+ const tEnd = landingTime();
+ for (let i = 0; i <= steps; i++) {
+ const ti = (tEnd * i) / steps;
+ const px = toX(distanceAt(ti));
+ const py = toY(Math.max(0, heightAt(ti)));
+ if (i === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.stroke();
+
+ const bx = distanceAt(time);
+ const by = heightAt(time);
+ const cx = toX(bx);
+ const cy = toY(Math.max(0, by));
+
+ ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
+ ctx.beginPath();
+ ctx.arc(cx, cy, 8, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676";
+ ctx.font = "11px IBM Plex Mono, monospace";
+ ctx.fillText("x (m)", w / 2 - 12, h - 6);
+ ctx.save();
+ ctx.translate(10, h / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.fillText("h (m)", 0, 0);
+ ctx.restore();
+ };
+
+ const stopPlayback = () => {
+ if (rafId !== undefined) {
+ cancelAnimationFrame(rafId);
+ rafId = undefined;
+ }
+ setPlaying(false);
+ };
+
+ const startPlayback = () => {
+ stopPlayback();
+ setT(0);
+ setPlaying(true);
+ playStart = performance.now();
+
+ const tick = (now: number) => {
+ const elapsed = (now - playStart) / 1000;
+ const tEnd = landingTime();
+ if (elapsed >= tEnd) {
+ setT(tEnd);
+ drawScene(tEnd);
+ stopPlayback();
+ return;
+ }
+ setT(elapsed);
+ drawScene(elapsed);
+ rafId = requestAnimationFrame(tick);
+ };
+
+ rafId = requestAnimationFrame(tick);
+ };
+
+ createEffect(() => {
+ v0y();
+ g();
+ h0();
+ t();
+ if (!playing()) drawScene(t());
+ });
+
+ onCleanup(() => stopPlayback());
+
+ return (
+
+
+
{
+ canvasRef = el;
+ drawScene(0);
+ }}
+ class="projectile-scenario__canvas"
+ width={400}
+ height={240}
+ role="img"
+ aria-label="Side view of projectile path with horizontal and vertical position"
+ />
+
+ t = {formatNum(t())} s · x = {formatNum(distanceAt(t()))} m · h ={" "}
+ {formatNum(Math.max(0, heightAt(t())))} m
+
+
+ Horizontal speed is fixed at {HORIZONTAL_SPEED} m/s. Height follows
+ \(h = h_0 + v_0 t - \frac{1}{2}gt^2\).
+
+
+
+
+
+
+
+
+
+ (playing() ? stopPlayback() : startPlayback())}
+ >
+ {playing() ? "Stop" : "Start"}
+
+ {
+ stopPlayback();
+ setT(0);
+ drawScene(0);
+ }}
+ >
+ Reset
+
+
+
+
+ );
+}
+
+const PROFIT_PRICE_MIN = 5;
+const PROFIT_PRICE_MAX = 20;
+/** Π(p) = -2p² + 40p - 150 with Q(p) = 40 - 2p and fixed cost C = $150 */
+const PROFIT_OPTIMAL_PRICE = 10;
+const PROFIT_FIXED_COST = 150;
+
+const demandQuantity = (price: number) => Math.max(0, 40 - 2 * price);
+const demandPrice = (quantity: number) => Math.max(0, (40 - quantity) / 2);
+const profitAtPrice = (price: number) => -2 * price * price + 40 * price - 150;
+const revenueAtQ = (q: number) => q * demandPrice(q);
+const costAtQ = (_q: number) => PROFIT_FIXED_COST;
+const profitAtQ = (q: number) => revenueAtQ(q) - costAtQ(q);
+
+function drawProfitChart(
+ canvas: HTMLCanvasElement,
+ price: number,
+ quantity: number,
+ revenue: number,
+ cost: number,
+ profit: number
+) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const w = 400;
+ const h = 240;
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ const bg = getComputedStyle(canvas).getPropertyValue("--math-canvas-bg").trim() || "#fff";
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, w, h);
+
+ const pad = { l: 44, r: 44, t: 20, b: 40 };
+ const plotW = w - pad.l - pad.r;
+ const plotH = h - pad.t - pad.b;
+
+ const qMax = 42;
+ const pMax = 22;
+ let dollarMin = -120;
+ let dollarMax = 420;
+ for (let q = 0; q <= 40; q += 1) {
+ dollarMin = Math.min(dollarMin, profitAtQ(q), revenueAtQ(q));
+ dollarMax = Math.max(dollarMax, profitAtQ(q), revenueAtQ(q), costAtQ(q));
+ }
+ const dollarSpan = dollarMax - dollarMin || 1;
+
+ const toX = (q: number) => pad.l + (q / qMax) * plotW;
+ const toPriceY = (p: number) => pad.t + plotH - (p / pMax) * plotH;
+ const toDollarY = (d: number) => pad.t + plotH - ((d - dollarMin) / dollarSpan) * plotH;
+
+ const grid = getComputedStyle(canvas).getPropertyValue("--math-grid").trim() || "#e5e5e5";
+ const axis = getComputedStyle(canvas).getPropertyValue("--math-axis").trim() || "#a8a29e";
+ const ink = getComputedStyle(canvas).getPropertyValue("--ink").trim() || "#1a1a1a";
+ const muted = getComputedStyle(canvas).getPropertyValue("--muted").trim() || "#767676";
+
+ ctx.strokeStyle = grid;
+ ctx.lineWidth = 1;
+ for (let i = 0; i <= 4; i++) {
+ const py = pad.t + (plotH * i) / 4;
+ ctx.beginPath();
+ ctx.moveTo(pad.l, py);
+ ctx.lineTo(w - pad.r, py);
+ ctx.stroke();
+ }
+
+ ctx.strokeStyle = axis;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(pad.l, pad.t);
+ ctx.lineTo(pad.l, h - pad.b);
+ ctx.lineTo(w - pad.r, h - pad.b);
+ ctx.stroke();
+
+ const drawCurve = (
+ fn: (q: number) => number,
+ toY: (v: number) => number,
+ color: string,
+ width = 2,
+ dash: number[] = []
+ ) => {
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.setLineDash(dash);
+ ctx.beginPath();
+ for (let i = 0; i <= 80; i++) {
+ const q = (40 * i) / 80;
+ const px = toX(q);
+ const py = toY(fn(q));
+ if (i === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.stroke();
+ ctx.setLineDash([]);
+ };
+
+ drawCurve(demandPrice, toPriceY, getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", 2.5);
+ drawCurve(revenueAtQ, toDollarY, "#4a6b4a", 2);
+ drawCurve(costAtQ, toDollarY, "#a8a29e", 2);
+ drawCurve(
+ profitAtQ,
+ toDollarY,
+ getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e",
+ 2.5
+ );
+
+ const qOpt = demandQuantity(PROFIT_OPTIMAL_PRICE);
+ const pxOpt = toX(qOpt);
+ const pyOptP = toPriceY(PROFIT_OPTIMAL_PRICE);
+ const pyOptPi = toDollarY(profitAtQ(qOpt));
+ ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--success").trim() || "#2a7d52";
+ ctx.beginPath();
+ ctx.arc(pxOpt, pyOptP, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(pxOpt, pyOptPi, 5, 0, Math.PI * 2);
+ ctx.fill();
+
+ const qx = toX(quantity);
+ const py = toPriceY(price);
+ const pRev = toDollarY(revenue);
+ const pCost = toDollarY(cost);
+ const pProfit = toDollarY(profit);
+
+ ctx.strokeStyle = muted;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.moveTo(qx, pad.t);
+ ctx.lineTo(qx, h - pad.b);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb";
+ ctx.beginPath();
+ ctx.arc(qx, py, 7, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = "#4a6b4a";
+ ctx.beginPath();
+ ctx.arc(qx, pRev, 5, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = "#a8a29e";
+ ctx.beginPath();
+ ctx.arc(qx, pCost, 5, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e";
+ ctx.beginPath();
+ ctx.arc(qx, pProfit, 7, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.font = "10px IBM Plex Mono, monospace";
+ ctx.fillStyle = ink;
+ ctx.textAlign = "center";
+ ctx.fillText("Quantity Q (books)", w / 2, h - 6);
+
+ ctx.save();
+ ctx.translate(12, h / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.textAlign = "center";
+ ctx.fillText("Price P ($)", 0, 0);
+ ctx.restore();
+
+ ctx.save();
+ ctx.translate(w - 10, h / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.textAlign = "center";
+ ctx.fillStyle = muted;
+ ctx.fillText("$ (R, C, Π)", 0, 0);
+ ctx.restore();
+
+ ctx.font = "9px IBM Plex Mono, monospace";
+ ctx.textAlign = "left";
+ const legendX = pad.l + 6;
+ let legendY = pad.t + 6;
+ const legend = [
+ { color: getComputedStyle(canvas).getPropertyValue("--math-root").trim() || "#2563eb", label: "Demand" },
+ { color: "#4a6b4a", label: "Revenue" },
+ { color: "#a8a29e", label: "Cost" },
+ { color: getComputedStyle(canvas).getPropertyValue("--math-curve").trim() || "#9a6b2e", label: "Profit Π" },
+ ];
+ for (const item of legend) {
+ ctx.fillStyle = item.color;
+ ctx.fillRect(legendX, legendY, 10, 3);
+ ctx.fillStyle = ink;
+ ctx.fillText(item.label, legendX + 14, legendY + 4);
+ legendY += 14;
+ }
+}
+
+export function ProfitDemo() {
+ const [price, setPrice] = createSignal(10);
+ let canvasRef: HTMLCanvasElement | undefined;
+
+ const quantity = createMemo(() => demandQuantity(price()));
+ const revenue = createMemo(() => price() * quantity());
+ const cost = createMemo(() => costAtQ(quantity()));
+ const profit = createMemo(() => revenue() - cost());
+ const atOptimum = createMemo(() => Math.abs(price() - PROFIT_OPTIMAL_PRICE) < 0.26);
+
+ const formulaHtml = createMemo(() => renderLatex(`\\Pi = -2p^2 + 40p - 150`));
+
+ const redraw = () => {
+ if (!canvasRef) return;
+ drawProfitChart(
+ canvasRef,
+ price(),
+ quantity(),
+ revenue(),
+ cost(),
+ profit()
+ );
+ };
+
+ createEffect(() => {
+ price();
+ quantity();
+ revenue();
+ cost();
+ profit();
+ redraw();
+ });
+
+ return (
+
+
+
+ A textbook shop sets price ${formatNum(price())} . Higher price means
+ lower demand: {formatNum(quantity())} books sold (demand curve). Revenue,
+ cost, and profit Π are read off the curves at that quantity.
+
+
{
+ canvasRef = el;
+ redraw();
+ }}
+ class="profit-scenario__canvas"
+ width={400}
+ height={240}
+ role="img"
+ aria-label="Demand, revenue, cost, and profit curves with quantity on the horizontal axis"
+ />
+
+
+
+
+
+
+ Books sold
+ {formatNum(quantity())}
+
+
+ Revenue
+ ${formatNum(revenue())}
+
+
+ Cost
+ ${formatNum(cost())}
+
+
+ Profit Π
+ ${formatNum(profit())}
+
+
+
+ {atOptimum()
+ ? `Maximum profit at P = $${formatNum(PROFIT_OPTIMAL_PRICE)} (Q = ${formatNum(demandQuantity(PROFIT_OPTIMAL_PRICE))}, Π = $${formatNum(profitAtPrice(PROFIT_OPTIMAL_PRICE))}).`
+ : "Profit is positive between break-even prices P = $5 and P = $15. Drag toward the peak of the gold curve."}
+
+
+ Blue: demand (P vs Q). Green, gray, gold: revenue, cost, and profit in dollars (right
+ scale).
+
+
+
+ );
+}
+
+export function AreaDemo() {
+ const [perimeter, setPerimeter] = createSignal(20);
+ const [widthUnits, setWidthUnits] = createSignal(5);
+ const [dragging, setDragging] = createSignal(false);
+
+ let gridRef: HTMLDivElement | undefined;
+
+ const halfP = createMemo(() => Math.round(perimeter() / 2));
+ const lengthUnits = createMemo(() => halfP() - widthUnits());
+ const area = createMemo(() => widthUnits() * lengthUnits());
+ const maxArea = createMemo(() => {
+ const h = halfP();
+ let best = 0;
+ for (let w = 1; w < h; w += 1) {
+ best = Math.max(best, w * (h - w));
+ }
+ return best;
+ });
+ const isMax = createMemo(() => area() === maxArea());
+
+ const clampWidth = (w: number, h: number) =>
+ Math.max(1, Math.min(h - 1, Math.round(w)));
+
+ const widthFromPointer = (clientX: number) => {
+ const grid = gridRef;
+ if (!grid) return widthUnits();
+ const h = halfP();
+ const bounds = grid.getBoundingClientRect();
+ const cell = bounds.width / h;
+ const col = Math.floor((clientX - bounds.left) / cell) + 1;
+ return clampWidth(col, h);
+ };
+
+ const onCornerPointerDown = (e: PointerEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragging(true);
+ setWidthUnits(widthFromPointer(e.clientX));
+
+ const onMove = (ev: PointerEvent) => setWidthUnits(widthFromPointer(ev.clientX));
+ const onUp = () => {
+ setDragging(false);
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ window.removeEventListener("pointercancel", onUp);
+ };
+
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ window.addEventListener("pointercancel", onUp);
+ };
+
+ const changePerimeter = (p: number) => {
+ const newHalf = Math.round(p / 2);
+ const oldHalf = halfP();
+ const ratio = oldHalf > 0 ? widthUnits() / oldHalf : 0.5;
+ setPerimeter(p);
+ setWidthUnits(clampWidth(Math.round(ratio * newHalf), newHalf));
+ };
+
+ createEffect(() => {
+ const h = halfP();
+ const clamped = clampWidth(widthUnits(), h);
+ if (widthUnits() !== clamped) setWidthUnits(clamped);
+ });
+
+ return (
+
+
+
+
+ Each square is 1 m. Perimeter P = {perimeter()} m (fixed while resizing). Drag the
+ corner — snaps to whole metres.
+
+
+
+
+ {() => }
+
+
+
+
+ w = {widthUnits()} m
+
+
+ ℓ = {lengthUnits()} m
+
+
+
+
+
+ 2w + 2ℓ = {perimeter()} m · w + ℓ = {halfP()} m
+
+
+
+
+
+
+
+
+ Area A
+ {area()} m²
+
+
+ Largest possible
+ {maxArea()} m²
+
+
+
+ {isMax()
+ ? `Maximum area on this grid — ${widthUnits()} m × ${lengthUnits()} m (A = ${area()} m²).`
+ : "\u00a0"}
+
+
+
+ );
+}
diff --git a/frontend/src/lib/math-graph.ts b/frontend/src/lib/math-graph.ts
new file mode 100644
index 0000000..28f60e9
--- /dev/null
+++ b/frontend/src/lib/math-graph.ts
@@ -0,0 +1,195 @@
+export const VIEW_SIZE = 400;
+const PAD = 36;
+
+export type GraphBounds = {
+ xMin: number;
+ xMax: number;
+ yMin: number;
+ yMax: number;
+};
+
+export type GraphColors = {
+ grid: string;
+ axis: string;
+ curve: string;
+ marker: string;
+ root: string;
+ bg: string;
+};
+
+export function formatNum(n: number, digits = 2) {
+ if (Number.isInteger(n)) return String(n);
+ const p = 10 ** digits;
+ return String(Math.round(n * p) / p);
+}
+
+export function discriminant(a: number, b: number, c: number) {
+ return b * b - 4 * a * c;
+}
+
+export function computeRoots(a: number, b: number, c: number, delta: number) {
+ if (delta < 0 || Math.abs(a) < 1e-9) return [] as number[];
+ if (delta === 0) return [-b / (2 * a)];
+ const sqrtDelta = Math.sqrt(delta);
+ return [(-b + sqrtDelta) / (2 * a), (-b - sqrtDelta) / (2 * a)].sort((x, y) => x - y);
+}
+
+export function natureOfRoots(delta: number) {
+ if (delta > 0) return "Two distinct real roots";
+ if (delta === 0) return "One repeated real root";
+ return "No real roots";
+}
+
+export function readGraphColors(canvas: HTMLCanvasElement): GraphColors {
+ const style = getComputedStyle(canvas);
+ return {
+ grid: style.getPropertyValue("--math-grid").trim() || "#e5e5e5",
+ axis: style.getPropertyValue("--math-axis").trim() || "#a8a29e",
+ curve: style.getPropertyValue("--math-curve").trim() || "#9a6b2e",
+ marker: style.getPropertyValue("--math-vertex").trim() || "#4a6b4a",
+ root: style.getPropertyValue("--math-root").trim() || "#2563eb",
+ bg: style.getPropertyValue("--math-canvas-bg").trim() || "#ffffff",
+ };
+}
+
+function toCanvasX(x: number, bounds: GraphBounds) {
+ const w = VIEW_SIZE - PAD * 2;
+ return PAD + ((x - bounds.xMin) / (bounds.xMax - bounds.xMin)) * w;
+}
+
+function toCanvasY(y: number, bounds: GraphBounds) {
+ const h = VIEW_SIZE - PAD * 2;
+ return VIEW_SIZE - PAD - ((y - bounds.yMin) / (bounds.yMax - bounds.yMin)) * h;
+}
+
+function niceStep(span: number) {
+ const raw = span / 5;
+ const mag = 10 ** Math.floor(Math.log10(raw));
+ const norm = raw / mag;
+ if (norm < 1.5) return mag;
+ if (norm < 3.5) return 2 * mag;
+ if (norm < 7.5) return 5 * mag;
+ return 10 * mag;
+}
+
+export function drawGraph(
+ canvas: HTMLCanvasElement,
+ bounds: GraphBounds,
+ fn: (x: number) => number,
+ options?: {
+ roots?: number[];
+ highlightX?: number;
+ highlightLabel?: string;
+ xLabel?: string;
+ yLabel?: string;
+ }
+) {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = VIEW_SIZE * dpr;
+ canvas.height = VIEW_SIZE * dpr;
+ canvas.style.width = `${VIEW_SIZE}px`;
+ canvas.style.height = `${VIEW_SIZE}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ const colors = readGraphColors(canvas);
+
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, VIEW_SIZE, VIEW_SIZE);
+
+ const xStep = niceStep(bounds.xMax - bounds.xMin);
+ const yStep = niceStep(bounds.yMax - bounds.yMin);
+
+ ctx.strokeStyle = colors.grid;
+ ctx.lineWidth = 1;
+ for (let x = Math.ceil(bounds.xMin / xStep) * xStep; x <= bounds.xMax; x += xStep) {
+ const px = toCanvasX(x, bounds);
+ ctx.beginPath();
+ ctx.moveTo(px, PAD);
+ ctx.lineTo(px, VIEW_SIZE - PAD);
+ ctx.stroke();
+ }
+ for (let y = Math.ceil(bounds.yMin / yStep) * yStep; y <= bounds.yMax; y += yStep) {
+ const py = toCanvasY(y, bounds);
+ ctx.beginPath();
+ ctx.moveTo(PAD, py);
+ ctx.lineTo(VIEW_SIZE - PAD, py);
+ ctx.stroke();
+ }
+
+ const xZero = toCanvasX(0, bounds);
+ const yZero = toCanvasY(0, bounds);
+ if (0 >= bounds.xMin && 0 <= bounds.xMax) {
+ ctx.strokeStyle = colors.axis;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(xZero, PAD);
+ ctx.lineTo(xZero, VIEW_SIZE - PAD);
+ ctx.stroke();
+ }
+ if (0 >= bounds.yMin && 0 <= bounds.yMax) {
+ ctx.strokeStyle = colors.axis;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(PAD, yZero);
+ ctx.lineTo(VIEW_SIZE - PAD, yZero);
+ ctx.stroke();
+ }
+
+ ctx.strokeStyle = colors.curve;
+ ctx.lineWidth = 3;
+ ctx.lineCap = "round";
+ ctx.beginPath();
+ const samples = 120;
+ let started = false;
+ for (let i = 0; i <= samples; i++) {
+ const x = bounds.xMin + ((bounds.xMax - bounds.xMin) * i) / samples;
+ const y = fn(x);
+ const px = toCanvasX(x, bounds);
+ const py = toCanvasY(y, bounds);
+ if (!started) {
+ ctx.moveTo(px, py);
+ started = true;
+ } else {
+ ctx.lineTo(px, py);
+ }
+ }
+ ctx.stroke();
+
+ const drawDot = (x: number, y: number, fill: string, radius = 6) => {
+ ctx.fillStyle = fill;
+ ctx.beginPath();
+ ctx.arc(toCanvasX(x, bounds), toCanvasY(y, bounds), radius, 0, Math.PI * 2);
+ ctx.fill();
+ };
+
+ for (const root of options?.roots ?? []) {
+ if (root >= bounds.xMin && root <= bounds.xMax) {
+ drawDot(root, 0, colors.root);
+ }
+ }
+
+ if (options?.highlightX !== undefined) {
+ const hx = options.highlightX;
+ const hy = fn(hx);
+ if (hx >= bounds.xMin && hx <= bounds.xMax && hy >= bounds.yMin && hy <= bounds.yMax) {
+ drawDot(hx, hy, colors.marker, 7);
+ }
+ }
+
+ ctx.fillStyle = colors.axis;
+ ctx.font = "11px IBM Plex Mono, monospace";
+ ctx.textAlign = "center";
+ if (options?.xLabel) {
+ ctx.fillText(options.xLabel, VIEW_SIZE / 2, VIEW_SIZE - 10);
+ }
+ if (options?.yLabel) {
+ ctx.save();
+ ctx.translate(12, VIEW_SIZE / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.fillText(options.yLabel, 0, 0);
+ ctx.restore();
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 14262fe..1c1f754 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -78,13 +78,40 @@ if (copyPageRoot) {
});
}
-const quadraticRoot = document.getElementById("quadratic-explorer");
-if (quadraticRoot) {
+const hasQuadraticChapter =
+ document.getElementById("quadratic-explorer") ||
+ document.getElementById("projectile-demo") ||
+ document.getElementById("profit-demo") ||
+ document.getElementById("area-demo");
+
+const chapterNavRoot = document.getElementById("chapter-heading-nav");
+if (chapterNavRoot) {
+ void import("./components/chapter-heading-nav").then(({ ChapterHeadingNav }) => {
+ render(() => , chapterNavRoot);
+ });
+}
+
+if (hasQuadraticChapter) {
void Promise.all([
import("./components/quadratic-explorer"),
+ import("./components/quadratic-application-demos"),
import("./lib/render-math"),
- ]).then(([{ QuadraticExplorer }, { renderBookMath }]) => {
- render(() => , quadraticRoot);
+ ]).then(([{ QuadraticExplorer }, demos, { renderBookMath }]) => {
+ const quadraticRoot = document.getElementById("quadratic-explorer");
+ if (quadraticRoot) {
+ render(() => , quadraticRoot);
+ }
+ const appDemos: Array<[string, () => import("solid-js").JSX.Element]> = [
+ ["projectile-demo", demos.ProjectileDemo],
+ ["profit-demo", demos.ProfitDemo],
+ ["area-demo", demos.AreaDemo],
+ ];
+ for (const [id, Demo] of appDemos) {
+ const root = document.getElementById(id);
+ if (root) {
+ render(() => , root);
+ }
+ }
requestAnimationFrame(() => renderBookMath());
});
}
diff --git a/spec.md b/spec.md
index 35d4885..739402f 100644
--- a/spec.md
+++ b/spec.md
@@ -13,3 +13,28 @@ we don't i18n. i18n will be solved in future by big companies.
search is supported by pagefind.
we have a catalog of subjects. Each subject will have chapters. Each chapters can be labelled (multiple) cirriculum. E.g. DSE, IB, A level. so that user knows if a chapter is needed in a cirriculum. Each subject should show all chapters by default (all cirriculum). chapters should be sequential in a tree structure. users can filter by themselves. in the chapter we don't specify or mention the cirriculum anymore.
+
+we need to have a question bank feature. since this is purely static sites, question answer needs to be determined and able to grade in advanced.
+
+1. multiple-choice
+2. short-answer
+3. fill-in-the-blank
+4. choice
+5. ranking
+
+question bank not only show answer but also steps and calcultion
+
+## Chapters requirements
+
+### Maths
+
+each chapter needs to have the following other than the core content:
+
+1. history - when did people first study this? why? how?
+2. derivation / calculation
+3. check point / thought provoking questions
+4. real life applications
+5. questions bank - some exams questions from easy, intermediate, difficult, hardcore
+6. reference (if any)
+
+the order is that core content (with some interactive demos) -> history -> derivation -> check point -> applications -> question bank
\ No newline at end of file
diff --git a/themes/openfreebooks/sass/_book.scss b/themes/openfreebooks/sass/_book.scss
index 67e1210..49b59f0 100644
--- a/themes/openfreebooks/sass/_book.scss
+++ b/themes/openfreebooks/sass/_book.scss
@@ -242,7 +242,45 @@ a.book-chapter-link:focus-visible {
.book-prose__heading {
margin: 2em 0 0.6em;
+}
+
+.book-prose h2.book-prose__heading {
font-size: 1.35rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.book-prose h3.book-prose__heading {
+ margin-top: 1.35em;
+ font-size: 1.1rem;
+ font-weight: 500;
+ color: var(--soft-ink);
+}
+
+.book-prose h2.book-prose__heading,
+.book-prose h3.book-prose__heading {
+ scroll-margin-top: 96px;
+}
+
+.book-figure {
+ margin: 1.5em 0 1.75em;
+ max-width: 100%;
+}
+
+.book-figure__img {
+ display: block;
+ width: 100%;
+ height: auto;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+ box-shadow: 0 8px 24px var(--shadow-soft);
+}
+
+.book-figure__caption {
+ margin: 0.65em 0 0;
+ font-size: 0.88rem;
+ line-height: 1.5;
+ color: var(--muted);
}
.book-formula {
diff --git a/themes/openfreebooks/sass/_chapter-heading-nav.scss b/themes/openfreebooks/sass/_chapter-heading-nav.scss
new file mode 100644
index 0000000..59b8396
--- /dev/null
+++ b/themes/openfreebooks/sass/_chapter-heading-nav.scss
@@ -0,0 +1,224 @@
+.chapter-heading-nav {
+ pointer-events: none;
+}
+
+.chapter-heading-nav > * {
+ pointer-events: auto;
+}
+
+.chapter-heading-nav__rail-wrap {
+ position: fixed;
+ top: 50%;
+ right: 20px;
+ z-index: 40;
+ transform: translateY(-50%);
+}
+
+.chapter-heading-nav__rail {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 10px;
+ padding: 10px 6px;
+}
+
+.chapter-heading-nav__tick {
+ display: block;
+ width: 18px;
+ height: 3px;
+ padding: 0;
+ border: none;
+ border-radius: 999px;
+ background: var(--line);
+ cursor: pointer;
+ transition: width 160ms ease, background 160ms ease, opacity 160ms ease;
+}
+
+.chapter-heading-nav__tick--h3 {
+ width: 12px;
+ opacity: 0.75;
+}
+
+.chapter-heading-nav__tick--active {
+ width: 26px;
+ background: var(--ink);
+}
+
+.chapter-heading-nav__tick--h3.chapter-heading-nav__tick--active {
+ width: 18px;
+}
+
+.chapter-heading-nav__popover {
+ position: absolute;
+ right: 36px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: min(260px, calc(100vw - 80px));
+ padding: 14px 12px;
+ border: 1px solid var(--card-border);
+ border-radius: 12px;
+ background-color: #f5f4f1;
+ color: var(--ink);
+ isolation: isolate;
+ box-shadow: 0 18px 48px var(--shadow-soft);
+}
+
+[data-theme="dark"] .chapter-heading-nav__popover {
+ background-color: #1a1917;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .chapter-heading-nav__popover {
+ background-color: #1a1917;
+ }
+}
+
+.chapter-heading-nav__popover-title {
+ margin: 0 0 10px;
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.chapter-heading-nav__list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.chapter-heading-nav__item--h3 .chapter-heading-nav__link {
+ padding-left: 14px;
+ font-size: 0.86rem;
+}
+
+.chapter-heading-nav__link {
+ display: block;
+ width: 100%;
+ padding: 6px 8px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--soft-ink);
+ font: inherit;
+ font-size: 0.9rem;
+ text-align: left;
+ cursor: pointer;
+ transition: background 120ms ease, color 120ms ease;
+}
+
+.chapter-heading-nav__link:hover,
+.chapter-heading-nav__item--active .chapter-heading-nav__link {
+ background: rgba(154, 107, 46, 0.1);
+ color: var(--ink);
+}
+
+.chapter-heading-nav__fab {
+ position: fixed;
+ right: 16px;
+ bottom: 20px;
+ z-index: 45;
+ padding: 10px 16px;
+ border: 1px solid var(--card-border);
+ border-radius: 999px;
+ background-color: #f5f4f1;
+ isolation: isolate;
+ box-shadow: 0 8px 24px var(--shadow-soft);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+[data-theme="dark"] .chapter-heading-nav__fab {
+ background-color: #1a1917;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .chapter-heading-nav__fab {
+ background-color: #1a1917;
+ }
+}
+
+.chapter-heading-nav__backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 46;
+ border: none;
+ background: rgba(26, 26, 26, 0.45);
+ cursor: pointer;
+}
+
+[data-theme="dark"] .chapter-heading-nav__backdrop {
+ background: rgba(0, 0, 0, 0.62);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .chapter-heading-nav__backdrop {
+ background: rgba(0, 0, 0, 0.62);
+ }
+}
+
+.chapter-heading-nav__sheet {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 47;
+ max-height: 70vh;
+ padding: 16px 18px 28px;
+ overflow-y: auto;
+ border-radius: 16px 16px 0 0;
+ border: 1px solid var(--card-border);
+ background-color: #f5f4f1;
+ color: var(--ink);
+ isolation: isolate;
+ box-shadow: 0 -18px 48px var(--shadow-soft);
+}
+
+[data-theme="dark"] .chapter-heading-nav__sheet {
+ background-color: #1a1917;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .chapter-heading-nav__sheet {
+ background-color: #1a1917;
+ }
+}
+
+.chapter-heading-nav__sheet-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.chapter-heading-nav__sheet-close {
+ padding: 4px 10px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--muted);
+ font-size: 1.4rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+@media (max-width: 899px) {
+ .chapter-heading-nav__rail-wrap {
+ display: none;
+ }
+}
+
+@media (min-width: 900px) {
+ .chapter-heading-nav__fab,
+ .chapter-heading-nav__backdrop,
+ .chapter-heading-nav__sheet {
+ display: none;
+ }
+}
diff --git a/themes/openfreebooks/sass/_grain.scss b/themes/openfreebooks/sass/_grain.scss
index 093bfe3..b87f80b 100644
--- a/themes/openfreebooks/sass/_grain.scss
+++ b/themes/openfreebooks/sass/_grain.scss
@@ -13,7 +13,10 @@ body::before {
/* Top-layer modals must stay opaque; grain blend would otherwise show through. */
.contributor-dialog[open],
.contributor-dialog[open] .contributor-dialog-panel,
-pagefind-modal dialog.pf-modal[open] {
+pagefind-modal dialog.pf-modal[open],
+.chapter-heading-nav__popover,
+.chapter-heading-nav__sheet,
+.chapter-heading-nav__fab {
mix-blend-mode: normal;
}
diff --git a/themes/openfreebooks/sass/_math-widget.scss b/themes/openfreebooks/sass/_math-widget.scss
index 008cf73..697200a 100644
--- a/themes/openfreebooks/sass/_math-widget.scss
+++ b/themes/openfreebooks/sass/_math-widget.scss
@@ -1,3 +1,11 @@
+.math-widget--application {
+ margin-top: 1.25rem;
+}
+
+.math-widget--application .math-widget__title {
+ font-size: 1.15rem;
+}
+
.math-widget {
--math-grid: #e5e5e5;
--math-axis: #a8a29e;
diff --git a/themes/openfreebooks/sass/_scenario-demo.scss b/themes/openfreebooks/sass/_scenario-demo.scss
new file mode 100644
index 0000000..d0395ee
--- /dev/null
+++ b/themes/openfreebooks/sass/_scenario-demo.scss
@@ -0,0 +1,466 @@
+.scenario-demo {
+ --math-grid: #e5e5e5;
+ --math-axis: #a8a29e;
+ --math-curve: #9a6b2e;
+ --math-root: #2563eb;
+ --math-canvas-bg: #ffffff;
+
+ display: grid;
+ gap: 20px;
+ margin-top: 1.25rem;
+ padding: 22px;
+ border: 1px solid var(--card-border);
+ border-radius: 14px;
+ background: var(--card-bg);
+ box-shadow: 0 10px 32px var(--shadow-soft);
+}
+
+[data-theme="dark"] .scenario-demo {
+ --math-grid: #3a3834;
+ --math-axis: #8f8a80;
+ --math-curve: #c4924a;
+ --math-root: #60a5fa;
+ --math-canvas-bg: #242120;
+}
+
+@media (min-width: 768px) {
+ .scenario-demo {
+ grid-template-columns: 1fr 1fr;
+ align-items: start;
+ }
+}
+
+.scenario-demo__stage {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-height: 220px;
+}
+
+.scenario-demo__panel {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.scenario-demo__stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
+ gap: 10px;
+}
+
+.scenario-demo__stat {
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(154, 107, 46, 0.04);
+}
+
+.scenario-demo__stat--highlight {
+ border-color: var(--accent);
+ background: rgba(154, 107, 46, 0.1);
+}
+
+.scenario-demo__stat-label {
+ display: block;
+ margin-bottom: 4px;
+ font-family: var(--mono);
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.scenario-demo__stat-value {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--ink);
+}
+
+.scenario-demo__note {
+ margin: 0;
+ font-size: 0.88rem;
+ line-height: 1.5;
+ color: var(--soft-ink);
+}
+
+.scenario-demo__note--muted {
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.scenario-demo__controls {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.scenario-demo__control {
+ margin-bottom: 8px;
+}
+
+.scenario-demo__label {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 6px;
+ font-size: 0.86rem;
+ color: var(--soft-ink);
+}
+
+.scenario-demo__value {
+ font-family: var(--mono);
+ font-weight: 700;
+ color: var(--accent);
+}
+
+.scenario-demo__slider {
+ width: 100%;
+ accent-color: var(--accent);
+}
+
+.scenario-demo__slider:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.scenario-demo__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.scenario-demo__btn {
+ padding: 10px 18px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--card-bg);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.scenario-demo__btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.scenario-demo__btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.scenario-demo__btn--primary {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: #fff;
+}
+
+.scenario-demo__btn--primary:hover:not(:disabled) {
+ filter: brightness(1.05);
+ color: #fff;
+}
+
+.scenario-demo__live {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ color: var(--soft-ink);
+}
+
+.scenario-demo__formula {
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: rgba(154, 107, 46, 0.05);
+}
+
+.scenario-demo__formula-label {
+ margin: 0 0 8px;
+ font-family: var(--mono);
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.scenario-demo__formula-math {
+ font-size: 1rem;
+}
+
+.scenario-demo__stage--wide {
+ grid-column: 1 / -1;
+}
+
+@media (min-width: 768px) {
+ .scenario-demo__stage--wide {
+ grid-column: auto;
+ }
+}
+
+/* Projectile */
+.projectile-scenario__canvas {
+ display: block;
+ width: 100%;
+ max-width: 400px;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+}
+
+.projectile-scenario__track {
+ position: relative;
+ flex: 1;
+ min-height: 200px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ overflow: hidden;
+}
+
+.projectile-scenario__sky {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(180deg, rgba(96, 165, 250, 0.2) 0%, rgba(154, 107, 46, 0.06) 100%);
+}
+
+.projectile-scenario__ground {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 12px;
+ background: #6b5a45;
+}
+
+.projectile-scenario__ball {
+ position: absolute;
+ left: 50%;
+ width: 22px;
+ height: 22px;
+ margin-left: -11px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 30% 30%, #f97316, #c2410c);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+ transition: bottom 120ms ease-out;
+}
+
+.projectile-scenario__ground-hit {
+ position: absolute;
+ bottom: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 2px 8px;
+ border-radius: 6px;
+ background: var(--card-bg);
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ color: var(--accent);
+}
+
+/* Profit */
+.profit-scenario__shop {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ padding: 24px;
+ border-radius: 12px;
+ border: 1px dashed var(--accent);
+ background: rgba(154, 107, 46, 0.06);
+ text-align: center;
+}
+
+.profit-scenario__sign {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.profit-scenario__price-tag {
+ margin: 0;
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--accent);
+ line-height: 1;
+}
+
+.profit-scenario__per {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--muted);
+}
+
+.profit-scenario__units {
+ margin: 8px 0 0;
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.profit-scenario__scenario {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.55;
+ color: var(--soft-ink);
+}
+
+.profit-scenario__canvas {
+ display: block;
+ width: 100%;
+ max-width: 400px;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+}
+
+/* Area */
+.area-scenario__garden {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: rgba(74, 107, 74, 0.08);
+}
+
+.area-scenario__fence-label {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.area-scenario__grid-wrap {
+ --cell-size: calc(100% / var(--grid-units, 10));
+ position: relative;
+ width: min(100%, 280px);
+ aspect-ratio: 1;
+ margin-inline: auto;
+ border: 2px solid #6b5a45;
+ border-radius: 8px;
+ touch-action: none;
+}
+
+.area-scenario__grid {
+ display: grid;
+ grid-template-columns: repeat(var(--grid-units, 10), 1fr);
+ grid-template-rows: repeat(var(--grid-units, 10), 1fr);
+ width: 100%;
+ height: 100%;
+ gap: 1px;
+ padding: 4px;
+ background: #6b5a45;
+ border-radius: 6px;
+}
+
+.area-scenario__cell {
+ display: block;
+ background: rgba(134, 168, 106, 0.45);
+ border-radius: 1px;
+}
+
+.area-scenario__rect {
+ position: absolute;
+ left: 4px;
+ bottom: 4px;
+ box-sizing: border-box;
+ border: 2px solid var(--accent);
+ border-radius: 3px;
+ background: rgba(154, 107, 46, 0.55);
+ transition:
+ width 80ms ease,
+ height 80ms ease;
+}
+
+.area-scenario__rect--dragging {
+ transition: none;
+}
+
+.area-scenario__rect--max {
+ border-color: var(--success);
+ background: rgba(42, 125, 82, 0.45);
+}
+
+.area-scenario__corner {
+ position: absolute;
+ right: -7px;
+ bottom: -7px;
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ border: 2px solid var(--paper);
+ border-radius: 50%;
+ background: var(--accent);
+ cursor: nwse-resize;
+ touch-action: none;
+}
+
+.area-scenario__dim {
+ position: absolute;
+ font-family: var(--mono);
+ font-size: 0.65rem;
+ color: var(--ink);
+ white-space: nowrap;
+}
+
+.area-scenario__dim--w {
+ top: -18px;
+ left: 0;
+}
+
+.area-scenario__dim--l {
+ top: 4px;
+ right: 4px;
+}
+
+.area-scenario__badge {
+ margin: 0;
+ padding: 6px 10px;
+ border-radius: 8px;
+ background: rgba(42, 125, 82, 0.15);
+ color: var(--success);
+ font-size: 0.82rem;
+ font-weight: 600;
+ text-align: center;
+}
+
+.area-scenario__badge--panel {
+ min-height: 2.75rem;
+ visibility: hidden;
+ opacity: 0;
+}
+
+.area-scenario__badge--panel.area-scenario__badge--visible {
+ visibility: visible;
+ opacity: 1;
+}
+
+.profit-scenario__optimum {
+ margin: 0;
+ padding: 8px 10px;
+ border-radius: 8px;
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: var(--muted);
+ background: rgba(154, 107, 46, 0.06);
+}
+
+.profit-scenario__optimum--on {
+ color: var(--success);
+ font-weight: 600;
+ background: rgba(42, 125, 82, 0.15);
+}
diff --git a/themes/openfreebooks/sass/main.scss b/themes/openfreebooks/sass/main.scss
index 11bc7bc..12300b8 100644
--- a/themes/openfreebooks/sass/main.scss
+++ b/themes/openfreebooks/sass/main.scss
@@ -12,3 +12,5 @@
@import "catalog";
@import "search";
@import "math-widget";
+@import "scenario-demo";
+@import "chapter-heading-nav";
diff --git a/themes/openfreebooks/templates/math/chapter.html b/themes/openfreebooks/templates/math/chapter.html
index b903c40..fc7e16e 100644
--- a/themes/openfreebooks/templates/math/chapter.html
+++ b/themes/openfreebooks/templates/math/chapter.html
@@ -35,8 +35,10 @@
{% if section.extra.chapter_id == "quadratic-equations" %}
- {% include "partials/math/quadratic-equations-content.html" %}
+
+ {% include "partials/math/quadratic-equations-core-content.html" %}
+ {% include "partials/math/quadratic-equations-supplement-content.html" %}
{% endif %}
diff --git a/themes/openfreebooks/templates/partials/contributing-content.html b/themes/openfreebooks/templates/partials/contributing-content.html
index a5cccaa..7e8a093 100644
--- a/themes/openfreebooks/templates/partials/contributing-content.html
+++ b/themes/openfreebooks/templates/partials/contributing-content.html
@@ -73,7 +73,7 @@ themes/.../partials/... → chapter HTML bodies (today)
2. Zola section
Create content/{subject}/{slug}/_index.md with chapter_id matching the slug.
3. Chapter HTML
- Add themes/openfreebooks/templates/partials/math/your-slug-content.html and wire it in math/chapter.html. Copy patterns from quadratic-equations-content.html.
+ Add themes/openfreebooks/templates/partials/math/your-slug-content.html and wire it in math/chapter.html. Copy patterns from quadratic-equations-core-content.html.
4. Optional widget
Add a Solid component under frontend/src/components/ and mount it in main.tsx. Run bun run build:js.
diff --git a/themes/openfreebooks/templates/partials/math/quadratic-equations-content.html b/themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html
similarity index 88%
rename from themes/openfreebooks/templates/partials/math/quadratic-equations-content.html
rename to themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html
index c4d279c..ea9f3d8 100644
--- a/themes/openfreebooks/templates/partials/math/quadratic-equations-content.html
+++ b/themes/openfreebooks/templates/partials/math/quadratic-equations-core-content.html
@@ -5,7 +5,7 @@
formula, or by reading information from the graph of \(y = ax^2 + bx + c\).
- The discriminant
+ The discriminant
The discriminant \(\Delta\) (delta) is defined below. It tells you how many
real roots the equation has without fully solving it.
@@ -16,7 +16,7 @@
\[ \Delta = b^2 - 4ac \]
- Nature of roots
+ Nature of roots
@@ -51,7 +51,7 @@
\[ x = \frac{-b \pm \sqrt{\Delta}}{2a} \]
- Try it yourself
+ Try it yourself
Use the explorer below to change \(a\), \(b\), and \(c\). Watch how \(\Delta\) and the
parabola change, and read off the nature of the roots.
diff --git a/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html b/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html
new file mode 100644
index 0000000..216b7b5
--- /dev/null
+++ b/themes/openfreebooks/templates/partials/math/quadratic-equations-supplement-content.html
@@ -0,0 +1,162 @@
+
+
History
+
+ People have worked on problems that lead to quadratic equations for thousands of years.
+ Babylonian clay tablets from around 1800 BCE record area and length puzzles — for example,
+ finding the sides of a rectangle when you know its area and perimeter. Those situations
+ produce equations in one unknown whose highest power is two.
+
+
+
+
+
+ A Babylonian clay tablet (about 1800 BCE) with a problem equivalent to finding the
+ sides of a rectangle from its area and perimeter.
+
+
+
+
+ Early methods were often geometric: you imagine completing a rectangle to make a square,
+ which is the same idea behind completing the square today. In the 9th century,
+ al-Khwarizmi wrote systematic rules for many types of quadratic problems,
+ sorted by which coefficients were present. He did not use modern symbols like \(x^2\), but
+ the logic matches what we still teach.
+
+
+ During the Renaissance, mathematicians such as Cardano and
+ Tartaglia pushed further into solving cubics and quartics. Along the way they
+ met equations whose discriminants were negative — no real roots, yet the algebra still
+ demanded answers. That tension helped open the door to complex numbers , a
+ topic you will meet in a later chapter.
+
+
+
Derivation
+
+ The quadratic formula comes from rearranging \(ax^2 + bx + c = 0\) and completing the square.
+ Below is a guided derivation; routine algebra between steps is left for you to check.
+
+
+ Start with \(a \neq 0\) and divide every term by \(a\):
+ \[ x^2 + \frac{b}{a}x + \frac{c}{a} = 0. \]
+ Move the constant to the right:
+ \[ x^2 + \frac{b}{a}x = -\frac{c}{a}. \]
+
+
+ The expression \(x^2 + \frac{b}{a}x\) is almost a perfect square. Add and subtract
+ \(\left(\frac{b}{2a}\right)^2\) on the left (equivalently, add that square to both sides):
+ \[ x^2 + \frac{b}{a}x + \left(\frac{b}{2a}\right)^2 = -\frac{c}{a} + \left(\frac{b}{2a}\right)^2. \]
+ The left side factors as a square:
+ \[ \left(x + \frac{b}{2a}\right)^2 = \frac{b^2 - 4ac}{4a^2}. \]
+
+
+ Take square roots (when the right-hand side is non-negative you get real solutions):
+ \[ x + \frac{b}{2a} = \pm \frac{\sqrt{b^2 - 4ac}}{2a}. \]
+ Solve for \(x\):
+
+
+
+ The quantity under the square root is exactly the discriminant:
+ \(\Delta = b^2 - 4ac\). So the formula is \(x = \frac{-b \pm \sqrt{\Delta}}{2a}\) whenever
+ \(\Delta \geq 0\). If \(\Delta > 0\) you get two distinct real roots; if \(\Delta = 0\) the
+ \(\pm\) gives the same root twice; if \(\Delta < 0\) there is no real square root and the
+ equation has no real solutions — matching the nature-of-roots table above.
+
+
+
Checkpoints
+
+ Pause and think before moving on. There are no answers on this page — discuss with a
+ classmate or write your reasoning in a notebook.
+
+
+
+
Checkpoint
+
+ In the explorer, keep \(a\) and \(b\) fixed and slowly change \(c\) so that \(\Delta\) goes
+ from positive to zero. What happens to the two x-intercepts on the graph?
+
+
+
+
+
Checkpoint
+
+ Can \(ax^2 + bx + c = 0\) with \(a \neq 0\) have three distinct real solutions? Why or why not?
+
+
+
+
+
Checkpoint
+
+ Suppose \(a > 0\) and \(\Delta < 0\). Where does the entire parabola \(y = ax^2 + bx + c\)
+ sit relative to the x-axis?
+
+
+
+
+
Checkpoint
+
+ A ball is thrown straight upward from ground level. Without using numbers, describe a
+ situation where \(\Delta > 0\) for height versus time, and another where \(\Delta = 0\).
+
+
+
+
+
Checkpoint
+
+ After completing the square you had \(\left(x + \frac{b}{2a}\right)^2 = \frac{\Delta}{4a^2}\).
+ Why must the sign of \(\Delta\) match whether the parabola crosses the x-axis?
+
+
+
+
Applications
+
+
Projectile motion
+
+ Near Earth’s surface, height \(h\) (in metres) of an object thrown upward often follows
+ \[ h = -\tfrac{1}{2}gt^2 + v_0 t + h_0, \]
+ where \(t\) is time in seconds, \(g \approx 9.8\) is gravitational acceleration,
+ \(v_0\) is initial upward speed, and \(h_0\) is starting height. This is a quadratic in
+ \(t\). Setting \(h = 0\) asks when the object is at ground level; the solutions are the
+ landing times. The discriminant tells you whether there are two crossing times
+ (\(\Delta > 0\)), one (\(\Delta = 0\)), or none (\(\Delta < 0\)) — for example if the
+ model never reaches the ground in the time you care about.
+
+
+
+
+
Profit and pricing
+
+ A shop might find that when it charges \(p\) dollars per item, revenue is \(p\) times
+ quantity sold, while cost has a fixed part plus a cost per item. Profit (revenue minus cost)
+ is often a quadratic in \(p\) that opens downward — there is a best price at the vertex.
+ For example, with profit \(\Pi\) in dollars,
+ \[ \Pi(p) = -2p^2 + 40p - 150. \]
+ Setting \(\Pi = 0\) asks for break-even prices; \(\Delta\) tells you whether two
+ break-even prices exist, one, or none.
+
+
+
+
+
Area with fixed perimeter
+
+ A rectangle with perimeter \(P\) can be described by width \(x\) and length
+ \(\frac{P}{2} - x\). Its area is
+ \[ A(x) = x\left(\frac{P}{2} - x\right) = -\!x^2 + \frac{P}{2}x, \]
+ a downward-opening parabola. The largest area occurs at the vertex, not at the extremes
+ \(x = 0\) or \(x = \frac{P}{2}\) (which give zero area).
+
+
+
+