diff --git a/.agents/skills/ofb/SKILL.md b/.agents/skills/ofb/SKILL.md index f38a5cb..ab042c8 100644 --- a/.agents/skills/ofb/SKILL.md +++ b/.agents/skills/ofb/SKILL.md @@ -110,10 +110,15 @@ Prerequisites: **only** `graph.edges` (required, global DAG, cross-strand OK). L Live chapters live under `content/{subject}/{slug}/`: 1. `data/{subject}-curriculum.json` — catalog metadata (`status`, `curriculums`, `graph.edges`) -2. `_index.md` — front matter only (`template = "chapter.html"`, `[extra] subject`, `chapter_id`, `strand`) -3. `core.html` (required), optional `supplement.html`, `assets/`, `widgets/*.tsx`, optional `chapter.scss` -4. `bun run sync:chapters` merges HTML into `_index.md`, copies assets to `static/chapters/…`, generates `frontend/src/generated/chapter-widgets.ts` -5. `templates/chapter.html` renders `{{ section.content | safe }}`; widgets use `data-widget` + auto-mount in `main.tsx` +2. **Subject shared** (when a subject has multiple chapters): `content/{subject}/subject.scss` → `/css/subjects/{subject}.css`; `content/{subject}/subject.ts` → `initSubject()` via `frontend/src/generated/subject-modules.ts`. Math HTML conventions: [math-chapter-patterns.md](math-chapter-patterns.md). +3. `_index.md` — front matter only (`template = "chapter.html"`, `[extra] subject`, `chapter_id`, `strand`) +4. `core.html` (required), optional `supplement.html`, `assets/`, `widgets/*.tsx`, optional `chapter.scss` (chapter-only styles — do not duplicate subject.scss) +5. `bun run sync:chapters` merges HTML into `_index.md`, copies assets to `static/chapters/…`, compiles subject + chapter CSS, generates `chapter-widgets.ts` and `subject-modules.ts`, writes `data/_generated/subjects-meta.json` +6. `templates/chapter.html` loads subject CSS then chapter CSS; `main.tsx` runs subject init then mounts widgets + +**Checkpoints (all subjects):** use `book-question`, `book-question__prompt`, `book-question__solution` (styled in theme `_book.scss`). Never use `book-question__answer`. + +**Math checkpoints / dots:** follow [math-chapter-patterns.md](math-chapter-patterns.md); shared dot classes in `content/math/subject.scss`. Reference: `content/math/quadratic-equations/`. diff --git a/.agents/skills/ofb/math-chapter-patterns.md b/.agents/skills/ofb/math-chapter-patterns.md new file mode 100644 index 0000000..4bb40a1 --- /dev/null +++ b/.agents/skills/ofb/math-chapter-patterns.md @@ -0,0 +1,39 @@ +# Mathematics chapter patterns + +Use these conventions in `core.html` and `supplement.html` so every math chapter looks and behaves the same. Shared styles live in [`content/math/subject.scss`](../../content/math/subject.scss); shared setup in [`content/math/subject.ts`](../../content/math/subject.ts). + +## Prose and sections + +- Wrapper: `
` +- Section titles: `

` +- In-chapter links: plain `` inside `.book-prose` (theme styles them) + +## Checkpoints (required shape) + +Copy from any live chapter supplement (e.g. `content/math/basic-math-notation/supplement.html`): + +```html +

Checkpoints

+
+ Your question here. +
+

Answer: …

+
+
+``` + +Do **not** use `book-question__answer` — only `book-question__solution` is styled. + +## Dot pictures + +- Use `counting-dots`, `counting-dots__dot`, `counting-dots--empty`, `counting-dots__none` for amount pictures (see `counting-and-numbers`). +- Use `notation-dots` for smaller chains in notation chapters. + +## Widgets + +- Mount: `
` +- Widget file: `widgets/widget-name.tsx` with default export + +## Chapter-only CSS + +Put styles used in **one** chapter under `chapter.scss`. Move duplicated blocks into `subject.scss` instead. diff --git a/.agents/skills/zola/zola-solid.md b/.agents/skills/zola/zola-solid.md index 2a6f79f..b0f8a82 100644 --- a/.agents/skills/zola/zola-solid.md +++ b/.agents/skills/zola/zola-solid.md @@ -72,6 +72,19 @@ Mounted from `catalog.html` via `catalog-app.tsx` (in main bundle). Map view laz **Mermaid theming:** use `theme: "base"` and hex `themeVariables` only; CSS cannot override Mermaid’s inline cluster fills. See [zola-catalog.md](zola-catalog.md). +## Subject shared assets (chapters) + +For subjects with multiple chapters (e.g. mathematics), colocate shared code under `content/{subject}/`: + +| File | Build output | Loaded by | +|------|----------------|-----------| +| `subject.scss` | `static/css/subjects/{subject}.css` | `chapter.html` when `subjects-meta.json` has `hasCss` | +| `subject.ts` | chunk via Vite | `main.tsx` → `generated/subject-modules.ts` → `initSubject(article)` | + +Run `bun run sync:chapters` to compile CSS and regenerate `subject-modules.ts` + `data/_generated/subjects-meta.json`. Chapter-only styles stay in `content/{subject}/{slug}/chapter.scss`. + +Math conventions: `.agents/skills/ofb/math-chapter-patterns.md`. + ## KaTeX (math) | Use case | API | Where | diff --git a/.gitignore b/.gitignore index ca877d8..7b2e797 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,5 @@ themes/openfreebooks/static/ # Chapter sync output (bun run sync:chapters) frontend/src/generated/ data/_generated/ -static/css/chapters/ +static/css/ static/chapters/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5029ba8..ecd5b2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,14 +87,17 @@ In `data/{subject}-curriculum.json`, set `"status": "live"` and include `descrip Create `content/{subject}/{slug}/`: ```text -content/math/your-chapter-slug/ -├── _index.md # page metadata (see below) -├── core.html # required — main lesson HTML -├── supplement.html # optional — history, checkpoints, extra demos -├── chapter.scss # optional — chapter-specific styles -├── assets/ # optional — images (synced to static/chapters/…) -└── widgets/ # optional — Solid widgets (auto-mounted) - └── my-widget.tsx # default export; use data-widget="my-widget" in HTML +content/math/ # subject folder (mathematics today) +├── subject.scss # shared CSS for all math chapters → /css/subjects/math.css +├── subject.ts # shared JS init for all math chapters +└── your-chapter-slug/ + ├── _index.md # page metadata (see below) + ├── core.html # required — main lesson HTML + ├── supplement.html # optional — history, checkpoints, extra demos + ├── chapter.scss # optional — styles for this chapter only + ├── assets/ # optional — images (synced to static/chapters/…) + └── widgets/ # optional — Solid widgets (auto-mounted) + └── my-widget.tsx # default export; data-widget="my-widget" in HTML ``` **`_index.md` front matter** (page fields only — catalog fields stay in JSON): @@ -115,6 +118,8 @@ strand = "Number & Algebra" ### 3. HTML and widgets - Use shared classes: `book-prose`, `book-prose__heading`, `book-callout`, etc. Copy from `content/math/quadratic-equations/`. +- **Checkpoints:** `
` with `book-question__prompt` and `book-question__solution` (not `__answer`). See `.agents/skills/ofb/math-chapter-patterns.md`. +- Put styles reused across several chapters in `content/{subject}/subject.scss`, not in each `chapter.scss`. - **Do not** mention DSE, IB, or other curriculum names in chapter HTML. - Mount widgets with `
` (filename without `.tsx` = `data-widget` value). - **Math (KaTeX):** write `\( … \)` and `\[ … \]` in HTML; prose is auto-rendered on chapter load. In widgets use `import { renderLatex } from "@ofb/katex"` — do not add a per-widget `renderLatex` helper. @@ -131,7 +136,7 @@ zola serve - `/math/your-chapter-slug/` — page renders. - `bun run build` — full production build. -No theme edits, no `main.tsx` edits, no per-chapter template branches. +New subjects with shared CSS/JS: add `content/{subject}/subject.scss` and/or `subject.ts`; `sync:chapters` wires them automatically. No per-chapter template branches. --- diff --git a/content/math/basic-math-notation/chapter.scss b/content/math/basic-math-notation/chapter.scss index a1b0036..9c9951d 100644 --- a/content/math/basic-math-notation/chapter.scss +++ b/content/math/basic-math-notation/chapter.scss @@ -37,34 +37,7 @@ color: var(--muted); } -// Dot pictures for compare chains (same idea as counting chapter) -.notation-dots { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 4px; - vertical-align: middle; -} - -.notation-dots__dot { - display: block; - width: 10px; - height: 10px; - border-radius: 50%; - background: #0f766e; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12); - flex-shrink: 0; -} - -.notation-dots--chain { - max-width: 88px; - justify-content: center; -} - -.notation-dots--decimal .notation-dots__dot { - width: 14px; - height: 14px; -} +// notation-dots: content/math/subject.scss .notation-compare-chain { margin: 1.25rem 0; diff --git a/content/math/counting-and-numbers/_index.md b/content/math/counting-and-numbers/_index.md index 4641482..76f21bf 100644 --- a/content/math/counting-and-numbers/_index.md +++ b/content/math/counting-and-numbers/_index.md @@ -1,8 +1,8 @@ +++ title = "Counting and numbers" -description = "Count from zero, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions." +description = "Count objects, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions." template = "chapter.html" -weight = 0 +weight = 1 [extra] subject = "math" chapter_id = "counting-and-numbers" @@ -11,10 +11,11 @@ strand = "Number & Algebra"

- Look around you. How many birds? How many cups? You can answer by - counting — pointing at one thing at a time until everything has been pointed at. - This chapter uses pictures of dots to stand for real things, like marbles or - fingers. + You already met 0 and 1 in + Zero and one. Now look around: how many birds? how many cups? + You answer by counting — pointing at one thing at a time until everything has + been pointed at. This chapter uses pictures of dots to stand for real things, + like marbles or fingers.

What is counting?

@@ -27,20 +28,13 @@ strand = "Number & Algebra" the number of fingers matches the number of objects.

-

Zero

+

Zero and one (recap)

- When there is nothing to point at, we write 0 and say - zero. An empty plate, no toys in the box — zero dots: + 0 means nothing to count; 1 means exactly one. When you count + aloud you still say zero before one, then two, and so on. If you want the full picture of + nothing versus one — and how that pairs with false and true — revisit + Zero and one.

-

- (none) -

-

Zero comes before one when we count: first nothing, then one, then two, and so on.

- -
-

Remember

-

Zero means “none here,” not “forgot to count.”

-

Numbers 1 to 10

diff --git a/content/math/counting-and-numbers/chapter.scss b/content/math/counting-and-numbers/chapter.scss index 915a5f2..a3cc682 100644 --- a/content/math/counting-and-numbers/chapter.scss +++ b/content/math/counting-and-numbers/chapter.scss @@ -1,77 +1,4 @@ -// Figurative dots and patterns in chapter prose -.counting-dots { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 5px; - vertical-align: middle; -} - -.counting-dots__dot { - display: block; - width: 12px; - height: 12px; - border-radius: 50%; - background: #0f766e; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12); - flex-shrink: 0; -} - -.counting-dots--empty .counting-dots__none { - font-size: 0.85rem; - font-style: italic; - color: var(--muted); -} - -.counting-dots--compare { - max-width: 100px; -} - -.counting-dots--pair { - display: inline-flex; - flex-wrap: wrap; - gap: 8px; - max-width: 200px; -} - -.counting-dots__pair { - display: inline-flex; - gap: 4px; - padding: 4px 6px; - border-radius: 6px; - background: rgba(20, 184, 166, 0.12); - border: 1px dashed rgba(15, 118, 110, 0.35); -} - -.counting-dots--odd { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - max-width: 220px; -} - -.counting-dots__singleton { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 4px 8px; - border-radius: 6px; - background: rgba(234, 88, 12, 0.14); - border: 2px solid rgba(180, 83, 9, 0.45); -} - -.counting-dots__singleton .counting-dots__dot { - background: #c2410c; -} - -.counting-pattern-ladder--odd .counting-pattern-step { - background: rgba(234, 88, 12, 0.06); -} - -.counting-number-table td:last-child { - min-width: 110px; -} +// counting-dots and shared dot rules: content/math/subject.scss .counting-compare { display: flex; @@ -473,235 +400,314 @@ gap: 6px; } -// Big numbers scale widget -.big-numbers-scale__picker { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: center; +// Big numbers scale widget (canvas + audio) +.book-prose .math-widget-mount:has(.big-numbers-scale) { + max-width: none; } -.big-numbers-scale__btn { - padding: 6px 12px; +.big-numbers-scale { + --big-scale-canvas-bg: var(--paper); + --big-scale-grid: color-mix(in srgb, var(--line) 70%, transparent); + --big-scale-star: color-mix(in srgb, var(--soft-ink) 48%, transparent); + + width: min(1320px, 96vw); + max-width: none; +} + +[data-theme="dark"] .big-numbers-scale, +:root:not([data-theme="light"]) .big-numbers-scale { + --big-scale-canvas-bg: color-mix(in srgb, var(--card-bg) 90%, black); + --big-scale-grid: color-mix(in srgb, var(--line) 78%, rgba(148, 163, 184, 0.2)); + --big-scale-star: color-mix(in srgb, var(--soft-ink) 62%, rgba(255, 255, 255, 0.18)); +} + +.big-numbers-scale__layout { + display: grid; + gap: 14px; +} + +.big-numbers-scale__canvas-panel, +.big-numbers-scale__controls, +.big-numbers-scale__section { + min-width: 0; +} + +.big-numbers-scale__canvas-panel { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--paper); + overflow: hidden; +} + +.big-numbers-scale__toolbar, +.big-numbers-scale__slider-wrap { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); +} + +.big-numbers-scale__slider-wrap { + border-top: 1px solid var(--line); + border-bottom: 0; + flex-direction: column; + align-items: stretch; +} + +.big-numbers-scale__slider-meta { + display: flex; + justify-content: space-between; + font-family: var(--mono); + font-size: 0.75rem; + color: var(--soft-ink); +} + +.big-numbers-scale__slider { + width: 100%; +} + +.big-numbers-scale__tool-btn, +.big-numbers-scale__autoplay-btn, +.speed-btn, +.preset-step-btn { border: 1px solid var(--line); border-radius: 8px; - font-family: var(--mono); - font-size: 0.8rem; background: var(--card-bg); color: var(--ink); + font-family: var(--mono); cursor: pointer; } -.big-numbers-scale__btn--active { - border-color: #0f766e; +.big-numbers-scale__tool-btn, +.big-numbers-scale__autoplay-btn { + padding: 7px 10px; + font-size: 0.76rem; +} + +.big-numbers-scale__autoplay-btn { + border-color: rgba(20, 184, 166, 0.45); background: rgba(20, 184, 166, 0.15); font-weight: 700; } -.big-numbers-scale__display { - margin: 16px 0 8px; - text-align: center; +.big-numbers-scale__autoplay-btn--pause { + border-color: rgba(234, 88, 12, 0.45); + background: rgba(234, 88, 12, 0.16); } -.big-numbers-scale__number { +.big-numbers-scale__badge { + padding: 3px 7px; + border-radius: 999px; + border: 1px solid var(--line); font-family: var(--mono); - font-size: 2rem; - font-weight: 700; -} - -.big-numbers-scale__compare { - margin: 0 0 12px; - font-size: 0.9rem; - text-align: center; + font-size: 0.72rem; color: var(--soft-ink); } -.big-numbers-scale__visual { - display: flex; - justify-content: center; - min-height: 120px; - margin: 12px 0; - padding: 16px; - border: 1px dashed var(--line); +.big-numbers-scale__hud { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + padding: 10px 12px; +} + +.big-numbers-scale__hud-card { + padding: 8px; + border: 1px solid var(--line); + border-radius: 10px; + background: color-mix(in srgb, var(--card-bg) 84%, #0b1220); +} + +.big-numbers-scale__hud-card--right { + text-align: right; +} + +.big-numbers-scale__hud-label { + display: block; + font-size: 0.7rem; + color: var(--soft-ink); +} + +.big-numbers-scale__hud-value { + display: block; + margin-top: 2px; + font-family: var(--mono); + font-size: 1.35rem; + font-weight: 700; +} + +.big-numbers-scale__hud-value--small { + font-size: 1.05rem; +} + +.big-numbers-scale__hud-sub { + display: block; + margin-top: 2px; + font-size: 0.72rem; + color: var(--soft-ink); +} + +.big-numbers-scale__tier-badge { + display: inline-block; + margin-top: 4px; + padding: 2px 6px; + border-radius: 6px; + font-size: 0.66rem; + font-family: var(--mono); + font-weight: 700; + color: #08111f; + background: #14b8a6; +} + +.big-numbers-scale__tier-badge--green { + background: #14b8a6; +} +.big-numbers-scale__tier-badge--purple { + background: #8b5cf6; +} +.big-numbers-scale__tier-badge--orange { + background: #f97316; +} +.big-numbers-scale__tier-badge--yellow { + background: #f59e0b; +} +.big-numbers-scale__tier-badge--cyan { + background: #06b6d4; +} + +.big-numbers-scale__canvas-wrap { + position: relative; + height: 400px; + background: var(--big-scale-canvas-bg); +} + +.big-numbers-scale__canvas { + width: 100%; + height: 100%; + display: block; + cursor: grab; +} + +.big-numbers-scale__canvas:active { + cursor: grabbing; +} + +.big-numbers-scale__status { + position: absolute; + left: 12px; + bottom: 12px; + padding: 6px 10px; + border-radius: 8px; + background: rgba(2, 6, 23, 0.85); + border: 1px solid var(--line); + color: var(--soft-ink); + font-size: 0.74rem; + opacity: 0; + transform: translateY(5px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.big-numbers-scale__status--visible { + opacity: 1; + transform: translateY(0); +} + +.big-numbers-scale__controls { + display: grid; + gap: 10px; +} + +.big-numbers-scale__section { + border: 1px solid var(--line); border-radius: 10px; background: var(--card-bg); + padding: 10px; } -.big-numbers-scale__grid { - display: grid; - gap: 4px; -} - -.big-numbers-scale__grid--small .big-numbers-scale__cell { - width: 6px; - height: 6px; -} - -.big-numbers-scale__cell { - width: 10px; - height: 10px; - border-radius: 50%; - background: #0f766e; -} - -.big-numbers-scale__blocks { - display: flex; - flex-wrap: wrap; - gap: 6px; - justify-content: center; - max-width: 280px; -} - -.big-numbers-scale__block { - padding: 6px 8px; - border-radius: 4px; - font-family: var(--mono); - font-size: 0.65rem; - font-weight: 700; - background: rgba(20, 184, 166, 0.2); - border: 1px solid rgba(15, 118, 110, 0.35); -} - -.big-numbers-scale__block--hundred { - min-width: 36px; - text-align: center; -} - -.big-numbers-scale__visual-caption { - flex: 1 1 100%; - margin: 10px 0 0; +.big-numbers-scale__section-title { + margin: 0 0 8px; font-size: 0.82rem; - line-height: 1.45; - text-align: center; color: var(--soft-ink); } -.big-numbers-scale__visual-caption--inline { - margin: 8px 0 0; -} - -.big-numbers-scale__math-hint { - font-family: var(--mono); - font-size: 0.85em; -} - -// 1 million: 100 × 10,000 -.big-numbers-scale__million { - width: 100%; -} - -.big-numbers-scale__mega-grid { +.big-numbers-scale__preset-grid { display: grid; - grid-template-columns: repeat(10, 1fr); - gap: 3px; - max-width: 280px; - margin: 0 auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; } -.big-numbers-scale__mega-cell { - display: flex; - align-items: center; - justify-content: center; - min-height: 22px; - border-radius: 3px; - font-family: var(--mono); - font-size: 0.5rem; - font-weight: 700; - color: #0f766e; - background: rgba(20, 184, 166, 0.22); - border: 1px solid rgba(15, 118, 110, 0.35); -} - -// 1 billion: mini million × 1000 → grid of 1M -.big-numbers-scale__billion { - width: 100%; -} - -.big-numbers-scale__billion-compare { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 10px 14px; -} - -.big-numbers-scale__billion-unit { +.preset-step-btn { display: flex; flex-direction: column; - align-items: center; - gap: 6px; - padding: 8px; - border-radius: 8px; - background: rgba(20, 184, 166, 0.06); - border: 1px solid var(--line); + align-items: flex-start; + gap: 2px; + padding: 7px; + min-height: 58px; } -.big-numbers-scale__billion-unit--wide { - flex: 1 1 200px; - max-width: 320px; - background: rgba(234, 88, 12, 0.06); - border-color: rgba(180, 83, 9, 0.35); +.preset-step-btn__power { + font-size: 0.65rem; + color: var(--soft-ink); + font-family: var(--mono); } -.big-numbers-scale__billion-unit-label { - font-size: 0.72rem; +.preset-step-btn__label { + font-size: 0.78rem; font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; +} + +.preset-step-btn--active { + border-color: rgba(20, 184, 166, 0.55); + background: rgba(20, 184, 166, 0.15); +} + +.big-numbers-scale__speed-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.speed-btn { + padding: 6px 8px; + font-size: 0.75rem; +} + +.speed-btn--active { + border-color: rgba(20, 184, 166, 0.55); + background: rgba(20, 184, 166, 0.18); + font-weight: 700; +} + +.big-numbers-scale__nav-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; +} + +.big-numbers-scale__stat-text, +.big-numbers-scale__analogy { + margin: 0; + font-size: 0.78rem; color: var(--soft-ink); } -.big-numbers-scale__mini-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 2px; -} - -.big-numbers-scale__mini-cell { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 18px; - border-radius: 2px; +.big-numbers-scale__stat-value { + margin: 8px 0; + padding: 8px; + border-radius: 8px; + border: 1px solid var(--line); font-family: var(--mono); - font-size: 0.45rem; font-weight: 700; - color: #0f766e; - background: rgba(20, 184, 166, 0.25); } -.big-numbers-scale__billion-times { - font-family: var(--mono); - font-size: 1rem; - font-weight: 700; - color: var(--accent); -} - -.big-numbers-scale__billion-equals { - font-family: var(--mono); - font-size: 1.25rem; - font-weight: 700; - color: var(--muted); -} - -.big-numbers-scale__giga-grid { - display: grid; - grid-template-columns: repeat(10, 1fr); - gap: 2px; - width: 100%; -} - -.big-numbers-scale__giga-cell { - display: flex; - align-items: center; - justify-content: center; - min-height: 18px; - border-radius: 2px; - font-family: var(--mono); - font-size: 0.48rem; - font-weight: 700; - color: #9a3412; - background: rgba(234, 88, 12, 0.28); - border: 1px solid rgba(180, 83, 9, 0.4); +@media (min-width: 960px) { + .big-numbers-scale__layout { + grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr); + } + .big-numbers-scale__hud { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/content/math/counting-and-numbers/core.html b/content/math/counting-and-numbers/core.html index 5a89b4d..a193666 100644 --- a/content/math/counting-and-numbers/core.html +++ b/content/math/counting-and-numbers/core.html @@ -1,9 +1,10 @@

- Look around you. How many birds? How many cups? You can answer by - counting — pointing at one thing at a time until everything has been pointed at. - This chapter uses pictures of dots to stand for real things, like marbles or - fingers. + You already met 0 and 1 in + Zero and one. Now look around: how many birds? how many cups? + You answer by counting — pointing at one thing at a time until everything has + been pointed at. This chapter uses pictures of dots to stand for real things, + like marbles or fingers.

What is counting?

@@ -16,20 +17,13 @@ the number of fingers matches the number of objects.

-

Zero

+

Zero and one (recap)

- When there is nothing to point at, we write 0 and say - zero. An empty plate, no toys in the box — zero dots: + 0 means nothing to count; 1 means exactly one. When you count + aloud you still say zero before one, then two, and so on. If you want the full picture of + nothing versus one — and how that pairs with false and true — revisit + Zero and one.

-

- (none) -

-

Zero comes before one when we count: first nothing, then one, then two, and so on.

- -
-

Remember

-

Zero means “none here,” not “forgot to count.”

-

Numbers 1 to 10

diff --git a/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx b/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx index 6d8cdd8..829bbfd 100644 --- a/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx +++ b/content/math/counting-and-numbers/widgets/big-numbers-scale.tsx @@ -1,202 +1,865 @@ -import { createMemo, createSignal, For, Show } from "solid-js"; - -type VisualKind = "one" | "ten" | "hundred" | "thousand" | "million" | "billion"; - -type ScaleStep = { - id: string; - label: string; - value: number; - display: string; - example: string; - visual: VisualKind; -}; - -const STEPS: ScaleStep[] = [ - { - id: "one", - label: "1", - value: 1, - display: "1", - example: "One apple on a plate.", - visual: "one", - }, - { - id: "ten", - label: "10", - value: 10, - display: "10", - example: "Fingers on two hands — easy to count one by one.", - visual: "ten", - }, - { - id: "hundred", - label: "100", - value: 100, - display: "100", - example: "A full stadium section; too many to count quickly without grouping.", - visual: "hundred", - }, - { - id: "thousand", - label: "1 thousand", - value: 1_000, - display: "1,000", - example: "A long walk in steps; a big school’s students.", - visual: "thousand", - }, - { - id: "million", - label: "1 million", - value: 1_000_000, - display: "1,000,000", - example: "A large city’s population — one dot per person would fill this whole grid.", - visual: "million", - }, - { - id: "billion", - label: "1 billion", - value: 1_000_000_000, - display: "1,000,000,000", - example: "About how many people live on Earth — one thousand times a whole city-million.", - visual: "billion", - }, -]; - -function DotGrid(props: { count: number; columns: number; small?: boolean }) { - const cells = () => Array.from({ length: props.count }); - return ( -

- ); -} - -/** 10×10 = 100 cells; each stands for 10,000 → 1 million total */ -function MillionVisual() { - const cells = Array.from({ length: 100 }); - return ( - - ); -} - -/** Mini million (4×4 of 10k) beside ×1000 beside 10×10 grid of 1M blocks */ -function BillionVisual() { - const millionTiles = Array.from({ length: 16 }); - const billionTiles = Array.from({ length: 100 }); - return ( - - ); -} +import { onCleanup, onMount } from "solid-js"; function BigNumbersScale() { - const [index, setIndex] = createSignal(2); - const step = createMemo(() => STEPS[index()]!); - const prev = createMemo(() => STEPS[Math.max(0, index() - 1)]); - const scaleRatio = createMemo(() => { - const p = prev().value; - const c = step().value; - if (p === c) return null; - return c / p; + let rootRef: HTMLDivElement | undefined; + + onMount(() => { + const root = rootRef; + if (!root) return; + + const byId = (id: string) => root.querySelector(`#${id}`); + const canvas = byId("scaleCanvas"); + const canvasContainer = byId("canvasContainer"); + const logSlider = byId("logSlider"); + const currentValDisplay = byId("currentValDisplay"); + const tierInfoBadge = byId("tierInfoBadge"); + const dotCountDisplay = byId("dotCountDisplay"); + const tierValueLabel = byId("tierValueLabel"); + const powerIndicator = byId("powerIndicator"); + const analogyValueText = byId("analogyValueText"); + const timeStatValue = byId("timeStatValue"); + const analogyText = byId("analogyText"); + const btnPlayPause = byId("btnPlayPause"); + const btnPrevStep = byId("btnPrevStep"); + const btnNextStep = byId("btnNextStep"); + const stateIndicator = byId("stateIndicator"); + const stateIndicatorText = byId("stateIndicatorText"); + const soundToggle = byId("soundToggle"); + const soundIcon = byId("soundIcon"); + const soundText = byId("soundText"); + const presetStepsContainer = byId("presetStepsContainer"); + + if ( + !canvas || + !canvasContainer || + !logSlider || + !currentValDisplay || + !tierInfoBadge || + !dotCountDisplay || + !tierValueLabel || + !powerIndicator || + !analogyValueText || + !timeStatValue || + !analogyText || + !btnPlayPause || + !btnPrevStep || + !btnNextStep || + !stateIndicator || + !stateIndicatorText || + !soundToggle || + !soundIcon || + !soundText || + !presetStepsContainer + ) { + return; + } + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const STEPS = [ + { id: "one", label: "1", value: 1, power: "10⁰", tone: "green", example: "A single heartbeat. The primary unit of all structures.", rawTime: 1 }, + { id: "ten", label: "10", value: 10, power: "10¹", tone: "green", example: "The fingers on two hands. The basic structural unit of decimal arithmetic.", rawTime: 10 }, + { id: "hundred", label: "100", value: 100, power: "10²", tone: "purple", example: "Grains of salt in a pinch. Page count of a short story.", rawTime: 100 }, + { id: "thousand", label: "1,000", value: 1_000, power: "10³", tone: "purple", example: "Words on a book page. The steps taken in a casual 10-minute walk.", rawTime: 1_000 }, + { id: "ten_thousand", label: "10,000", value: 10_000, power: "10⁴", tone: "orange", example: "Total stars visible to the naked human eye under a perfectly clear night sky.", rawTime: 10_000 }, + { id: "hundred_thousand", label: "100,000", value: 100_000, power: "10⁵", tone: "orange", example: "The total number of hairs on a full human head. Capacity of a massive stadium.", rawTime: 100_000 }, + { id: "million", label: "1 Million", value: 1_000_000, power: "10⁶", tone: "yellow", example: "Characters in a massive novel. Total running steps in a standard marathon.", rawTime: 1_000_000 }, + { id: "ten_million", label: "10 Million", value: 10_000_000, power: "10⁷", tone: "yellow", example: "Population of a major mega-city like Tokyo, Seoul, or London.", rawTime: 10_000_000 }, + { id: "hundred_million", label: "100 Million", value: 100_000_000, power: "10⁸", tone: "cyan", example: "Total books published in human history. Heartbeats of a cat over its lifetime.", rawTime: 100_000_000 }, + { id: "billion", label: "1 Billion", value: 1_000_000_000, power: "10⁹", tone: "cyan", example: "Grains of sand in a typical children's playground. Light travel distance in meters in 3.3 seconds.", rawTime: 1_000_000_000 }, + ] as const; + + const CORES = { + green: { hex: "#14b8a6", rgb: [20, 184, 166] as [number, number, number], glow: "rgba(20, 184, 166, 0.4)" }, + purple: { hex: "#8b5cf6", rgb: [139, 92, 246] as [number, number, number], glow: "rgba(139, 92, 246, 0.4)" }, + orange: { hex: "#f97316", rgb: [249, 115, 22] as [number, number, number], glow: "rgba(249, 115, 22, 0.4)" }, + yellow: { hex: "#f59e0b", rgb: [245, 158, 11] as [number, number, number], glow: "rgba(245, 158, 11, 0.4)" }, + cyan: { hex: "#06b6d4", rgb: [6, 182, 212] as [number, number, number], glow: "rgba(6, 182, 212, 0.4)" }, + } as const; + + const TIERS = [ + { index: 0, unitValue: 1, colorName: "green", label: "Units (Green)" }, + { index: 1, unitValue: 100, colorName: "purple", label: "Hundreds (Purple)" }, + { index: 2, unitValue: 10_000, colorName: "orange", label: "Ten-Thousands (Orange)" }, + { index: 3, unitValue: 1_000_000, colorName: "yellow", label: "Millions (Yellow)" }, + { index: 4, unitValue: 100_000_000, colorName: "cyan", label: "Hundred-Millions (Cyan)" }, + ] as const; + + type VisualMode = "NORMAL" | "COMPRESSING" | "EXPANDING"; + type Speed = "slow" | "normal" | "fast"; + + const state = { + currentValue: 1, + targetValue: 1, + isPlaying: false, + speed: "normal" as Speed, + speedMult: 1, + currentTierIdx: 0, + visualMode: "NORMAL" as VisualMode, + transitionProgress: 0, + panX: 0, + panY: 0, + isDragging: false, + startX: 0, + startY: 0, + particles: [] as Particle[], + ambientStars: [] as CosmicStar[], + shockwaves: [] as Shockwave[], + isScrubbing: false, + }; + + class SoundSynth { + ctx: AudioContext | null = null; + muted = true; + lastTickTime = 0; + + init() { + if (!this.ctx) { + this.ctx = new (window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext)(); + } + if (this.ctx.state === "suspended") { + void this.ctx.resume(); + } + } + + toggleMute() { + this.init(); + this.muted = !this.muted; + return this.muted; + } + + playTick(value: number) { + if (this.muted || !this.ctx) return; + const now = this.ctx.currentTime; + if (now - this.lastTickTime < 0.04) return; + this.lastTickTime = now; + + const osc = this.ctx.createOscillator(); + const gainNode = this.ctx.createGain(); + osc.connect(gainNode); + gainNode.connect(this.ctx.destination); + + const logVal = Math.log10(value || 1); + const baseFreq = 600 - logVal * 50; + + osc.type = logVal > 6 ? "triangle" : "sine"; + osc.frequency.setValueAtTime(baseFreq, this.ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(baseFreq * 0.2, this.ctx.currentTime + 0.08); + + gainNode.gain.setValueAtTime(0.08, this.ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.08); + + osc.start(); + osc.stop(this.ctx.currentTime + 0.09); + } + + playCompress() { + if (this.muted || !this.ctx) return; + const osc = this.ctx.createOscillator(); + const gainNode = this.ctx.createGain(); + osc.connect(gainNode); + gainNode.connect(this.ctx.destination); + + osc.type = "sawtooth"; + osc.frequency.setValueAtTime(350, this.ctx.currentTime); + osc.frequency.linearRampToValueAtTime(80, this.ctx.currentTime + 0.5); + + gainNode.gain.setValueAtTime(0.12, this.ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.5); + + osc.start(); + osc.stop(this.ctx.currentTime + 0.55); + } + + playExpand() { + if (this.muted || !this.ctx) return; + const osc = this.ctx.createOscillator(); + const gainNode = this.ctx.createGain(); + osc.connect(gainNode); + gainNode.connect(this.ctx.destination); + + osc.type = "triangle"; + osc.frequency.setValueAtTime(80, this.ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(440, this.ctx.currentTime + 0.5); + + gainNode.gain.setValueAtTime(0.15, this.ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.5); + + osc.start(); + osc.stop(this.ctx.currentTime + 0.55); + } + } + + const audio = new SoundSynth(); + + const getTierForValue = (val: number) => { + for (let i = TIERS.length - 1; i >= 0; i -= 1) { + if (val >= TIERS[i]!.unitValue) return TIERS[i]!; + } + return TIERS[0]!; + }; + + const formatNumber = (num: number) => Math.round(num).toLocaleString(); + + const formatTimeAnalogy = (seconds: number) => { + if (seconds < 60) return `${seconds.toFixed(0)} Second${seconds !== 1 ? "s" : ""}`; + const minutes = seconds / 60; + if (minutes < 60) return `${minutes.toFixed(1)} Minute${minutes !== 1 ? "s" : ""}`; + const hours = minutes / 60; + if (hours < 24) return `${hours.toFixed(1)} Hour${hours !== 1 ? "s" : ""}`; + const days = hours / 24; + if (days < 365) return `${days.toFixed(1)} Day${days !== 1 ? "s" : ""}`; + const years = days / 365; + return `${years.toFixed(1)} Year${years !== 1 ? "s" : ""}`; + }; + + const lerpColor = (c1: [number, number, number], c2: [number, number, number], factor: number) => { + const r = Math.round(c1[0] + factor * (c2[0] - c1[0])); + const g = Math.round(c1[1] + factor * (c2[1] - c1[1])); + const b = Math.round(c1[2] + factor * (c2[2] - c1[2])); + return `rgb(${r}, ${g}, ${b})`; + }; + + class Particle { + index: number; + tierIdx: number; + gx = 0; + gy = 0; + x = 0; + y = 0; + radius = 5; + alpha = 1; + colorInfo = CORES.green; + + constructor(index: number, tierIdx: number) { + this.index = index; + this.tierIdx = tierIdx; + this.reset(); + } + + reset() { + const tier = TIERS[this.tierIdx]!; + const col = this.index % 10; + const row = Math.floor(this.index / 10); + const spacing = 32; + this.gx = (col - 4.5) * spacing; + this.gy = (row - 4.5) * spacing; + this.x = 0; + this.y = 0; + this.radius = this.tierIdx % 2 === 0 ? 5 : 4; + this.colorInfo = CORES[tier.colorName]; + } + + update() { + if (state.visualMode === "NORMAL") { + const k = 0.15 * state.speedMult; + this.x += (this.gx - this.x) * k; + this.y += (this.gy - this.y) * k; + this.radius = this.tierIdx % 2 === 0 ? 5 : 4; + this.alpha += (1 - this.alpha) * 0.15; + return; + } + + if (state.visualMode === "COMPRESSING") { + const t = state.transitionProgress; + this.x = this.gx * (1 - t); + this.y = this.gy * (1 - t); + this.radius = Math.max(1, (this.tierIdx % 2 === 0 ? 5 : 4) * (1 - t)); + this.alpha = 1 - t; + return; + } + + const t = state.transitionProgress; + this.x = this.gx * t; + this.y = this.gy * t; + this.radius = Math.max(0.1, (this.tierIdx % 2 === 0 ? 5 : 4) * t); + this.alpha = t; + } + + draw(offsetX: number, offsetY: number) { + ctx.save(); + ctx.translate(offsetX, offsetY); + + let currentHex = this.colorInfo.hex; + if (state.visualMode === "COMPRESSING") { + const nextTier = TIERS[Math.min(TIERS.length - 1, this.tierIdx + 1)]!; + currentHex = lerpColor(this.colorInfo.rgb, CORES[nextTier.colorName].rgb, state.transitionProgress); + } else if (state.visualMode === "EXPANDING") { + const prevTier = TIERS[Math.max(0, this.tierIdx - 1)]!; + currentHex = lerpColor(CORES[prevTier.colorName].rgb, this.colorInfo.rgb, state.transitionProgress); + } + + ctx.shadowBlur = state.visualMode === "COMPRESSING" ? 15 : 8; + ctx.shadowColor = currentHex; + ctx.fillStyle = currentHex; + ctx.globalAlpha = Math.max(0, Math.min(1, this.alpha)); + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); + ctx.fill(); + + ctx.shadowBlur = 0; + ctx.font = "600 7px var(--mono)"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillStyle = "rgba(255, 255, 255, 0.85)"; + ctx.fillText(formatNumber(TIERS[this.tierIdx]!.unitValue), this.x, this.y + this.radius + 3); + ctx.restore(); + } + } + + class CosmicStar { + x = 0; + y = 0; + depth = 0; + size = 0; + alpha = 0; + speed = 0; + constructor() { + this.reset(); + } + reset() { + this.x = (Math.random() - 0.5) * window.innerWidth; + this.y = (Math.random() - 0.5) * window.innerHeight; + this.depth = Math.random() * 0.9 + 0.1; + this.size = Math.random() * 1.5 + 0.5; + this.alpha = Math.random() * 0.5 + 0.1; + this.speed = Math.random() * 0.05 + 0.01; + } + update() { + this.y += this.speed * state.speedMult; + if (this.y > window.innerHeight / 2) this.y = -window.innerHeight / 2; + } + draw(cx: number, cy: number, starColor: string) { + ctx.fillStyle = starColor; + ctx.globalAlpha = this.alpha; + ctx.beginPath(); + ctx.arc(cx + this.x, cy + this.y, this.size * this.depth, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + } + } + + class Shockwave { + radius = 1; + maxRadius = 120 + Math.random() * 100; + alpha = 1; + color = "#8b5cf6"; + update() { + this.radius += 5 * state.speedMult; + this.alpha = 1 - this.radius / this.maxRadius; + } + draw(cx: number, cy: number) { + if (this.alpha <= 0) return; + ctx.save(); + ctx.strokeStyle = this.color; + ctx.globalAlpha = this.alpha; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, this.radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + } + + for (let i = 0; i < 150; i += 1) { + state.ambientStars.push(new CosmicStar()); + } + + const updatePresetsUI = () => { + root.querySelectorAll(".preset-step-btn").forEach((btn) => { + const val = Number(btn.dataset.value); + const active = Math.abs(state.targetValue - val) < 0.1; + btn.classList.toggle("preset-step-btn--active", active); + }); + }; + + const setTargetValue = (val: number) => { + state.targetValue = Math.max(1, Math.min(1_000_000_000, val)); + updatePresetsUI(); + }; + + const showStatusIndicator = (text: string) => { + stateIndicatorText.textContent = text; + stateIndicator.classList.add("big-numbers-scale__status--visible"); + }; + + const hideStatusIndicator = () => { + stateIndicator.classList.remove("big-numbers-scale__status--visible"); + }; + + const syncParticles = () => { + const tier = TIERS[state.currentTierIdx]!; + + if (state.visualMode === "COMPRESSING") { + if (state.particles.length !== 100) { + state.particles = []; + for (let i = 0; i < 100; i += 1) { + const p = new Particle(i, state.currentTierIdx); + p.x = p.gx; + p.y = p.gy; + state.particles.push(p); + } + } + return; + } + + if (state.visualMode === "EXPANDING") { + if (state.particles.length !== 100) { + state.particles = []; + const lowerTierIdx = Math.max(0, state.currentTierIdx - 1); + for (let i = 0; i < 100; i += 1) { + const p = new Particle(i, lowerTierIdx); + p.x = 0; + p.y = 0; + state.particles.push(p); + } + } + return; + } + + const relativeVal = state.currentValue / tier.unitValue; + const numDots = Math.min(100, Math.max(1, Math.floor(relativeVal))); + + state.particles.forEach((p) => { + if (p.tierIdx !== state.currentTierIdx) { + p.tierIdx = state.currentTierIdx; + p.colorInfo = CORES[tier.colorName]; + } + }); + + if (state.particles.length !== numDots) { + if (state.particles.length < numDots) { + const toAdd = numDots - state.particles.length; + for (let i = 0; i < toAdd; i += 1) { + const idx = state.particles.length; + const p = new Particle(idx, state.currentTierIdx); + p.x = 0; + p.y = 0; + p.alpha = 0; + state.particles.push(p); + } + } else { + state.particles.splice(numDots); + } + } + }; + + const updateState = () => { + const activeTier = TIERS[state.currentTierIdx]!; + const diff = state.targetValue - state.currentValue; + + if (Math.abs(diff) < 0.01) { + state.currentValue = state.targetValue; + state.visualMode = "NORMAL"; + hideStatusIndicator(); + } else if (state.visualMode === "NORMAL") { + if (diff > 0 && state.currentValue >= activeTier.unitValue * 100) { + if (state.currentTierIdx < TIERS.length - 1) { + state.visualMode = "COMPRESSING"; + state.transitionProgress = 0; + audio.playCompress(); + showStatusIndicator("Compressing 100 units..."); + } else { + state.currentValue = state.targetValue; + } + } else if (diff < 0 && state.currentValue <= activeTier.unitValue && state.currentTierIdx > 0) { + state.visualMode = "EXPANDING"; + state.transitionProgress = 0; + audio.playExpand(); + showStatusIndicator("Expanding unit dot into 100 component dots..."); + } else { + const stepSign = Math.sign(diff); + const logDistance = Math.max(1, Math.log10(Math.abs(diff) / activeTier.unitValue)); + const baseVelocity = 0.15 * activeTier.unitValue * state.speedMult; + const increment = stepSign * Math.min(Math.abs(diff), baseVelocity * Math.pow(1.5, logDistance)); + const dotsBefore = Math.floor(state.currentValue / activeTier.unitValue); + state.currentValue += increment; + const dotsAfter = Math.floor(state.currentValue / activeTier.unitValue); + if (dotsBefore !== dotsAfter && increment !== 0) { + audio.playTick(state.currentValue); + } + } + } + + if (state.visualMode === "COMPRESSING") { + state.transitionProgress += 0.02 * state.speedMult; + if (state.transitionProgress >= 1) { + state.currentTierIdx += 1; + state.currentValue = TIERS[state.currentTierIdx]!.unitValue; + state.visualMode = "NORMAL"; + hideStatusIndicator(); + const sw = new Shockwave(); + sw.color = CORES[TIERS[state.currentTierIdx]!.colorName].hex; + state.shockwaves.push(sw); + audio.playTick(state.currentValue); + } + } + + if (state.visualMode === "EXPANDING") { + state.transitionProgress += 0.02 * state.speedMult; + if (state.transitionProgress >= 1) { + state.currentTierIdx -= 1; + state.currentValue = TIERS[state.currentTierIdx]!.unitValue * 100; + state.visualMode = "NORMAL"; + hideStatusIndicator(); + const sw = new Shockwave(); + sw.color = CORES[TIERS[state.currentTierIdx]!.colorName].hex; + state.shockwaves.push(sw); + audio.playTick(state.currentValue); + } + } + + syncParticles(); + state.particles.forEach((p) => p.update()); + state.ambientStars.forEach((s) => s.update()); + state.shockwaves = state.shockwaves.filter((sw) => sw.alpha > 0); + state.shockwaves.forEach((sw) => sw.update()); + + if (!state.isScrubbing) { + logSlider.value = Math.log10(state.currentValue).toFixed(2); + } + }; + + const draw = () => { + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const styles = getComputedStyle(root); + const canvasBg = styles.getPropertyValue("--big-scale-canvas-bg").trim() || "var(--paper)"; + const gridColor = styles.getPropertyValue("--big-scale-grid").trim() || "rgba(30, 41, 59, 0.22)"; + const starColor = styles.getPropertyValue("--big-scale-star").trim() || "rgba(148, 163, 184, 0.35)"; + + ctx.fillStyle = canvasBg; + ctx.fillRect(0, 0, width, height); + + const cx = width / 2 + state.panX; + const cy = height / 2 + state.panY; + + state.ambientStars.forEach((s) => s.draw(cx, cy, starColor)); + + ctx.save(); + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + const gridSpacing = 40; + const gridCols = Math.ceil(width / gridSpacing); + const gridRows = Math.ceil(height / gridSpacing); + const startX = state.panX % gridSpacing; + const startY = state.panY % gridSpacing; + for (let i = 0; i < gridCols; i += 1) { + ctx.beginPath(); + ctx.moveTo(startX + i * gridSpacing, 0); + ctx.lineTo(startX + i * gridSpacing, height); + ctx.stroke(); + } + for (let j = 0; j < gridRows; j += 1) { + ctx.beginPath(); + ctx.moveTo(0, startY + j * gridSpacing); + ctx.lineTo(width, startY + j * gridSpacing); + ctx.stroke(); + } + ctx.restore(); + + state.shockwaves.forEach((sw) => sw.draw(cx, cy)); + state.particles.forEach((p) => p.draw(cx, cy)); + }; + + const updateHUD = () => { + const activeTier = TIERS[state.currentTierIdx]!; + currentValDisplay.textContent = formatNumber(state.currentValue); + tierInfoBadge.textContent = `${activeTier.label.toUpperCase()} — UNIT VALUE = ${formatNumber(activeTier.unitValue)}`; + tierInfoBadge.className = "big-numbers-scale__tier-badge"; + tierInfoBadge.classList.add(`big-numbers-scale__tier-badge--${activeTier.colorName}`); + const currentUnitDots = Math.floor(state.currentValue / activeTier.unitValue); + dotCountDisplay.textContent = `${currentUnitDots} Dot${currentUnitDots !== 1 ? "s" : ""}`; + tierValueLabel.innerHTML = `Each dot represents ${formatNumber(activeTier.unitValue)}`; + powerIndicator.textContent = `10^${Math.log10(state.currentValue).toFixed(2)}`; + const stepMeta = STEPS.find((s) => s.value <= state.currentValue && s.value * 10 > state.currentValue) || STEPS[0]; + analogyText.textContent = `"${stepMeta.example}"`; + analogyValueText.textContent = formatNumber(state.currentValue); + timeStatValue.textContent = formatTimeAnalogy(state.currentValue); + }; + + const resizeCanvas = () => { + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(canvasContainer.clientWidth * dpr); + canvas.height = Math.floor(canvasContainer.clientHeight * dpr); + canvas.style.width = `${canvasContainer.clientWidth}px`; + canvas.style.height = `${canvasContainer.clientHeight}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + + STEPS.forEach((step) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "preset-step-btn"; + btn.dataset.value = `${step.value}`; + btn.innerHTML = `${step.power}${step.label}`; + btn.addEventListener("click", () => { + setTargetValue(step.value); + audio.playTick(step.value); + }); + presetStepsContainer.appendChild(btn); + }); + updatePresetsUI(); + + const speedButtons = root.querySelectorAll(".speed-btn"); + speedButtons.forEach((btn) => { + btn.addEventListener("click", (e) => { + const target = e.currentTarget as HTMLButtonElement; + const speed = (target.dataset.speed || "normal") as Speed; + state.speed = speed; + speedButtons.forEach((b) => b.classList.remove("speed-btn--active")); + target.classList.add("speed-btn--active"); + if (speed === "slow") { + state.speedMult = 0.35; + byId("speedIndicatorText")!.textContent = "Speed: Slow"; + } else if (speed === "normal") { + state.speedMult = 1; + byId("speedIndicatorText")!.textContent = "Speed: Normal"; + } else { + state.speedMult = 3; + byId("speedIndicatorText")!.textContent = "Speed: Fast"; + } + }); + }); + + soundToggle.addEventListener("click", () => { + const muted = audio.toggleMute(); + if (muted) { + soundIcon.textContent = "🔇"; + soundText.textContent = "Muted"; + } else { + soundIcon.textContent = "🔊"; + soundText.textContent = "Active"; + } + }); + + logSlider.addEventListener("input", (e) => { + const val = Math.pow(10, Number((e.target as HTMLInputElement).value)); + setTargetValue(val); + }); + logSlider.addEventListener("mousedown", () => { + state.isScrubbing = true; + }); + logSlider.addEventListener("mouseup", () => { + state.isScrubbing = false; + }); + logSlider.addEventListener("touchstart", () => { + state.isScrubbing = true; + }); + logSlider.addEventListener("touchend", () => { + state.isScrubbing = false; + }); + + btnPrevStep.addEventListener("click", () => { + let closestSmallerStep = STEPS[0]!.value; + for (let i = STEPS.length - 1; i >= 0; i -= 1) { + if (STEPS[i]!.value < state.targetValue) { + closestSmallerStep = STEPS[i]!.value; + break; + } + } + setTargetValue(closestSmallerStep); + audio.playTick(closestSmallerStep); + }); + + btnNextStep.addEventListener("click", () => { + let closestLargerStep = STEPS[STEPS.length - 1]!.value; + for (let i = 0; i < STEPS.length; i += 1) { + if (STEPS[i]!.value > state.targetValue) { + closestLargerStep = STEPS[i]!.value; + break; + } + } + setTargetValue(closestLargerStep); + audio.playTick(closestLargerStep); + }); + + btnPlayPause.addEventListener("click", () => { + state.isPlaying = !state.isPlaying; + if (state.isPlaying) { + btnPlayPause.textContent = "Pause Autoplay"; + btnPlayPause.classList.add("big-numbers-scale__autoplay-btn--pause"); + showStatusIndicator("Autoplay running..."); + } else { + btnPlayPause.textContent = "Auto Increment"; + btnPlayPause.classList.remove("big-numbers-scale__autoplay-btn--pause"); + hideStatusIndicator(); + } + }); + + const toLocal = (evtX: number, evtY: number) => { + const rect = canvas.getBoundingClientRect(); + return { x: evtX - rect.left, y: evtY - rect.top }; + }; + + canvas.addEventListener("mousedown", (e) => { + state.isDragging = true; + const p = toLocal(e.clientX, e.clientY); + state.startX = p.x - state.panX; + state.startY = p.y - state.panY; + }); + + canvas.addEventListener("mousemove", (e) => { + if (!state.isDragging) return; + const p = toLocal(e.clientX, e.clientY); + state.panX = p.x - state.startX; + state.panY = p.y - state.startY; + }); + + window.addEventListener("mouseup", () => { + state.isDragging = false; + }); + + canvas.addEventListener("touchstart", (e) => { + if (e.touches.length !== 1) return; + state.isDragging = true; + const p = toLocal(e.touches[0]!.clientX, e.touches[0]!.clientY); + state.startX = p.x - state.panX; + state.startY = p.y - state.panY; + }); + + canvas.addEventListener("touchmove", (e) => { + if (!state.isDragging || e.touches.length !== 1) return; + const p = toLocal(e.touches[0]!.clientX, e.touches[0]!.clientY); + state.panX = p.x - state.startX; + state.panY = p.y - state.startY; + }); + + canvas.addEventListener("touchend", () => { + state.isDragging = false; + }); + + let rafId = 0; + let lastTime = performance.now(); + const loop = (now: number) => { + const delta = now - lastTime; + lastTime = now; + if (state.isPlaying && state.visualMode === "NORMAL") { + const activeTier = getTierForValue(state.currentValue); + const multiplier = state.speed === "slow" ? 0.35 : state.speed === "normal" ? 1 : 3; + const logDistance = Math.max(1, Math.log10(Math.abs(state.targetValue - state.currentValue) / activeTier.unitValue)); + const stepIncrement = activeTier.unitValue * 0.12 * multiplier * Math.pow(1.5, logDistance); + if (state.targetValue < 1_000_000_000) { + setTargetValue(state.targetValue + stepIncrement); + } else { + state.isPlaying = false; + setTargetValue(1); + btnPlayPause.click(); + } + } + void delta; + updateState(); + draw(); + updateHUD(); + rafId = requestAnimationFrame(loop); + }; + + resizeCanvas(); + setTargetValue(1); + rafId = requestAnimationFrame(loop); + window.addEventListener("resize", resizeCanvas); + + onCleanup(() => { + cancelAnimationFrame(rafId); + window.removeEventListener("resize", resizeCanvas); + }); }); return ( -
+

- How big is the number? + Cosmic scales

-
-
- - {(s, i) => ( - - )} - -
- -

- {step().display} -

- - 0}> -

- About {scaleRatio()?.toLocaleString()} times bigger than {prev().display} -

-
- -
- -
); diff --git a/content/math/subject.scss b/content/math/subject.scss new file mode 100644 index 0000000..c015d10 --- /dev/null +++ b/content/math/subject.scss @@ -0,0 +1,106 @@ +// Shared styles for all mathematics chapters (compiled to /css/subjects/math.css). +// Chapter-specific rules stay in each chapter's chapter.scss. + +// --- Figurative dot pictures (counting, zero/one, tables) --- +.counting-dots { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + vertical-align: middle; +} + +.counting-dots__dot { + display: block; + width: 12px; + height: 12px; + border-radius: 50%; + background: #0f766e; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} + +.counting-dots--empty .counting-dots__none { + font-size: 0.85rem; + font-style: italic; + color: var(--muted); +} + +.counting-dots--compare { + max-width: 100px; +} + +.counting-dots--pair { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + max-width: 200px; +} + +.counting-dots__pair { + display: inline-flex; + gap: 4px; + padding: 4px 6px; + border-radius: 6px; + background: rgba(20, 184, 166, 0.12); + border: 1px dashed rgba(15, 118, 110, 0.35); +} + +.counting-dots--odd { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + max-width: 220px; +} + +.counting-dots__singleton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + border-radius: 6px; + background: rgba(234, 88, 12, 0.14); + border: 2px solid rgba(180, 83, 9, 0.45); +} + +.counting-dots__singleton .counting-dots__dot { + background: #c2410c; +} + +.counting-pattern-ladder--odd .counting-pattern-step { + background: rgba(234, 88, 12, 0.06); +} + +.counting-number-table td:last-child { + min-width: 110px; +} + +// --- Notation chapter dot chains --- +.notation-dots { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + vertical-align: middle; +} + +.notation-dots__dot { + display: block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #0f766e; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} + +.notation-dots--chain { + max-width: 88px; + justify-content: center; +} + +.notation-dots--decimal .notation-dots__dot { + width: 14px; + height: 14px; +} diff --git a/content/math/subject.ts b/content/math/subject.ts new file mode 100644 index 0000000..5da7910 --- /dev/null +++ b/content/math/subject.ts @@ -0,0 +1,12 @@ +/** + * Shared JavaScript for all mathematics chapters. + * Loaded on every /math/{slug}/ page via main.tsx + generated subject-modules.ts. + */ + +/** Normalize checkpoint markup and run any math-only setup on the chapter article. */ +export function initMathSubject(root: HTMLElement): void { + for (const panel of root.querySelectorAll(".book-question__answer")) { + panel.classList.remove("book-question__answer"); + panel.classList.add("book-question__solution"); + } +} diff --git a/content/math/zero-and-one/_index.md b/content/math/zero-and-one/_index.md new file mode 100644 index 0000000..b520692 --- /dev/null +++ b/content/math/zero-and-one/_index.md @@ -0,0 +1,137 @@ ++++ +title = "Zero and one" +description = "0 is nothing; 1 is one. Learn to tell them apart — the same skill as telling false from true — before counting larger amounts." +template = "chapter.html" +weight = 0 +[extra] +subject = "math" +chapter_id = "zero-and-one" +strand = "Number & Algebra" ++++ + +
+

+ Mathematics begins with a simple question: how many? The smallest answers are + 0 and 1. Before you count birds, cups, or billions, you need to + be sure you can tell nothing from one. +

+ +

Zero — nothing

+

+ When there is nothing to point at, we write 0 and say + zero. An empty tray, a switched-off light with no glow, silence — no dot to + count: +

+

+ (none) +

+

+ Zero is not “forgot to look.” It means none here — the answer when the amount + is nothing. +

+ +

One — exactly one

+

+ When there is a single thing to point at once, we write 1 and say + one. One apple on the plate, one tap, one dot: +

+ +

One is not “a few” and not “many.” It is exactly one.

+ +

Tell them apart

+

0 and 1 sit side by side. Mixing them up changes the whole answer.

+
+
+

0

+

zero

+ +

nothing to count

+
+
+

1

+

one

+ +

one thing to count

+
+
+ +

True and false

+

+ You can also ask whether a statement matches the world. “The cup is on the + table.” — if it is there, the statement is true. If the cup is not there, the + statement is false. +

+

+ For a single yes-or-no question about one thing, the same care applies: +

+
    +
  • False fits when the answer is no — none of what we named, like 0.
  • +
  • True fits when the answer is yes, exactly once — like 1 when we mean one clear match.
  • +
+

+ Later you will meet longer counts and trickier logic. The habit starts here: look carefully, then + decide — is it 0 or 1? false or true? +

+ +
+

The essence

+

+ Telling 0 from 1 is the same kind of attention as telling + false from true. That distinction is the foundation for every + number and for understanding claims about the world. +

+
+ +
+ +

+ When you are steady with 0 and 1, continue to + Counting and numbers to count past one and meet larger + amounts. +

+
+ +
+

History

+

+ People counted with fingers and marks long before anyone wrote 0 as a number. + In ancient India and among the Maya, thinkers found a symbol for nothing — not + just “empty space on the page,” but a number you could calculate with. +

+

+ That step mattered: without zero, place value and modern arithmetic are much harder. The idea + “nothing is something we can name” is as deep as the idea “one is one.” +

+ +

Checkpoints

+
+ You look in a box and see no dots. How many? +
+

Answer: \(0\) (zero) — nothing to count.

+
+
+
+ You see exactly one dot. How many? +
+

Answer: \(1\) (one).

+
+
+
+ Is the sentence “2 is less than 1” true or false? +
+

Answer: False — two is not less than one.

+
+
+ +

References

+
    +
  • Ifrah, Georges — The Universal History of Numbers (origins of zero and counting).
  • +
  • Crossley & Henry — early Indian arithmetic including zero.
  • +
+
diff --git a/content/math/zero-and-one/chapter.scss b/content/math/zero-and-one/chapter.scss new file mode 100644 index 0000000..a90b14b --- /dev/null +++ b/content/math/zero-and-one/chapter.scss @@ -0,0 +1,123 @@ +// Dot pictures: counting-dots in subject.scss + +.zero-one-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin: 20px 0; + padding: 16px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--card-bg); +} + +.zero-one-compare__cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px 10px; + border-radius: 10px; + text-align: center; +} + +.zero-one-compare__cell--zero { + background: rgba(100, 116, 139, 0.08); + border: 1px dashed var(--line); +} + +.zero-one-compare__cell--one { + background: rgba(20, 184, 166, 0.1); + border: 1px solid rgba(15, 118, 110, 0.35); +} + +.zero-one-compare__digit { + margin: 0; + font-family: var(--mono); + font-size: 2rem; + font-weight: 700; + color: var(--ink); +} + +.zero-one-compare__word { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--soft-ink); +} + +.zero-one-compare__hint { + margin: 0; + font-size: 0.85rem; + color: var(--soft-ink); +} + +.zero-one-board__scenario { + margin: 0 0 12px; + font-size: 1.05rem; + line-height: 1.45; +} + +.zero-one-board__visual { + display: flex; + align-items: center; + justify-content: center; + min-height: 72px; + margin: 12px 0; + padding: 16px; + border: 1px dashed var(--line); + border-radius: 10px; + background: var(--card-bg); + transition: border-color 0.15s ease, background 0.15s ease; +} + +.zero-one-board__visual--correct { + border-color: rgba(15, 118, 110, 0.55); + background: rgba(20, 184, 166, 0.08); +} + +.zero-one-board__visual--incorrect { + border-color: rgba(180, 83, 9, 0.45); + background: rgba(234, 88, 12, 0.06); +} + +.zero-one-board__count { + margin: 0; + font-family: var(--mono); + font-size: 2rem; + font-weight: 700; + color: var(--ink); +} + +.zero-one-board__choices { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; +} + +.zero-one-board__choice--active { + border-color: #0f766e; + background: rgba(20, 184, 166, 0.15); + font-weight: 700; +} + +.zero-one-board__feedback { + margin: 12px 0 0; + min-height: 1.4em; + text-align: center; + font-size: 0.95rem; + color: var(--soft-ink); +} + +.zero-one-board__feedback--ok { + color: #0f766e; + font-weight: 600; +} + +.zero-one-board__feedback--nope { + color: #9a3412; + font-weight: 600; +} diff --git a/content/math/zero-and-one/core.html b/content/math/zero-and-one/core.html new file mode 100644 index 0000000..edca9b4 --- /dev/null +++ b/content/math/zero-and-one/core.html @@ -0,0 +1,87 @@ +
+

+ Mathematics begins with a simple question: how many? The smallest answers are + 0 and 1. Before you count birds, cups, or billions, you need to + be sure you can tell nothing from one. +

+ +

Zero — nothing

+

+ When there is nothing to point at, we write 0 and say + zero. An empty tray, a switched-off light with no glow, silence — no dot to + count: +

+

+ (none) +

+

+ Zero is not “forgot to look.” It means none here — the answer when the amount + is nothing. +

+ +

One — exactly one

+

+ When there is a single thing to point at once, we write 1 and say + one. One apple on the plate, one tap, one dot: +

+ +

One is not “a few” and not “many.” It is exactly one.

+ +

Tell them apart

+

0 and 1 sit side by side. Mixing them up changes the whole answer.

+
+
+

0

+

zero

+ +

nothing to count

+
+
+

1

+

one

+ +

one thing to count

+
+
+ +

True and false

+

+ You can also ask whether a statement matches the world. “The cup is on the + table.” — if it is there, the statement is true. If the cup is not there, the + statement is false. +

+

+ For a single yes-or-no question about one thing, the same care applies: +

+
    +
  • False fits when the answer is no — none of what we named, like 0.
  • +
  • True fits when the answer is yes, exactly once — like 1 when we mean one clear match.
  • +
+

+ Later you will meet longer counts and trickier logic. The habit starts here: look carefully, then + decide — is it 0 or 1? false or true? +

+ +
+

The essence

+

+ Telling 0 from 1 is the same kind of attention as telling + false from true. That distinction is the foundation for every + number and for understanding claims about the world. +

+
+ +
+ +

+ When you are steady with 0 and 1, continue to + Counting and numbers to count past one and meet larger + amounts. +

+
diff --git a/content/math/zero-and-one/supplement.html b/content/math/zero-and-one/supplement.html new file mode 100644 index 0000000..f0886a2 --- /dev/null +++ b/content/math/zero-and-one/supplement.html @@ -0,0 +1,38 @@ +
+

History

+

+ People counted with fingers and marks long before anyone wrote 0 as a number. + In ancient India and among the Maya, thinkers found a symbol for nothing — not + just “empty space on the page,” but a number you could calculate with. +

+

+ That step mattered: without zero, place value and modern arithmetic are much harder. The idea + “nothing is something we can name” is as deep as the idea “one is one.” +

+ +

Checkpoints

+
+ You look in a box and see no dots. How many? +
+

Answer: \(0\) (zero) — nothing to count.

+
+
+
+ You see exactly one dot. How many? +
+

Answer: \(1\) (one).

+
+
+
+ Is the sentence “2 is less than 1” true or false? +
+

Answer: False — two is not less than one.

+
+
+ +

References

+
    +
  • Ifrah, Georges — The Universal History of Numbers (origins of zero and counting).
  • +
  • Crossley & Henry — early Indian arithmetic including zero.
  • +
+
diff --git a/content/math/zero-and-one/widgets/zero-one-board.tsx b/content/math/zero-and-one/widgets/zero-one-board.tsx new file mode 100644 index 0000000..c49cebf --- /dev/null +++ b/content/math/zero-and-one/widgets/zero-one-board.tsx @@ -0,0 +1,218 @@ +import { createMemo, createSignal, For } from "solid-js"; + +type Scenario = { + id: string; + prompt: string; + correctAmount: 0 | 1; + showTruth?: boolean; + correctTruth?: boolean; +}; + +const SCENARIOS: Scenario[] = [ + { + id: "empty-box", + prompt: "The box has no marbles inside.", + correctAmount: 0, + }, + { + id: "one-apple", + prompt: "There is exactly one apple on the plate.", + correctAmount: 1, + }, + { + id: "light-off", + prompt: "The lamp is off — no light showing.", + correctAmount: 0, + }, + { + id: "light-on", + prompt: "The lamp is on — one steady light.", + correctAmount: 1, + }, + { + id: "statement-false", + prompt: "Statement: “The door is open.” (The door is shut.)", + correctAmount: 0, + showTruth: true, + correctTruth: false, + }, + { + id: "statement-true", + prompt: "Statement: “There is one bell ringing.” (You hear one bell.)", + correctAmount: 1, + showTruth: true, + correctTruth: true, + }, +]; + +function ZeroOneBoard() { + const [index, setIndex] = createSignal(0); + const [picked, setPicked] = createSignal<0 | 1 | null>(null); + const [truthPicked, setTruthPicked] = createSignal(null); + + const scenario = createMemo(() => SCENARIOS[index() % SCENARIOS.length]); + const atEnd = createMemo(() => index() >= SCENARIOS.length - 1); + + const amountCorrect = createMemo(() => { + const p = picked(); + return p !== null && p === scenario().correctAmount; + }); + + const truthCorrect = createMemo(() => { + const s = scenario(); + if (!s.showTruth) return true; + const t = truthPicked(); + return t !== null && t === s.correctTruth; + }); + + const answered = createMemo(() => { + const s = scenario(); + if (picked() === null) return false; + if (s.showTruth && truthPicked() === null) return false; + return true; + }); + + const allCorrect = createMemo(() => answered() && amountCorrect() && truthCorrect()); + + const feedback = createMemo(() => { + if (!answered()) return "Choose how many, then Next when you are ready."; + if (allCorrect()) { + return atEnd() + ? "Well done — you can tell 0 from 1. Continue to Counting and numbers." + : "Correct. Try the next situation."; + } + if (!amountCorrect()) { + return picked() === 0 + ? "Not quite — this situation is one, not zero." + : "Not quite — this situation is zero, not one."; + } + return truthPicked() === true + ? "Check the statement again — it does not match the world (false)." + : "Check the statement again — it matches the world (true)."; + }); + + const pickAmount = (n: 0 | 1) => { + setPicked(n); + }; + + const pickTruth = (value: boolean) => { + setTruthPicked(value); + }; + + const goNext = () => { + if (!answered() || !allCorrect()) return; + if (atEnd()) return; + setIndex((i) => i + 1); + setPicked(null); + setTruthPicked(null); + }; + + const resetAll = () => { + setIndex(0); + setPicked(null); + setTruthPicked(null); + }; + + return ( +
+

+ Practice: zero or one +

+
+

+ Situation {index() + 1} of {SCENARIOS.length}. Pick the amount that fits. When a statement + is shown, also pick true or false. +

+

{scenario().prompt}

+ +
+

+ {picked() === null ? "?" : picked()} +

+ +
+ +
+ + +
+ + {scenario().showTruth ? ( +
+ + +
+ ) : null} + + + +
+ + +
+
+
+ ); +} + +export default ZeroOneBoard; diff --git a/data/math-curriculum.json b/data/math-curriculum.json index c2b03cf..b8ba26c 100644 --- a/data/math-curriculum.json +++ b/data/math-curriculum.json @@ -3,6 +3,10 @@ "intro": "Topic units across three strands, shaped for learners worldwide. The map shows required prerequisites; the list follows a readable teaching order; Compare explains how chapters relate to popular curricula.", "graph": { "edges": [ + { + "from": "zero-and-one", + "to": "counting-and-numbers" + }, { "from": "counting-and-numbers", "to": "basic-math-notation" @@ -206,10 +210,28 @@ "id": "number-algebra", "title": "Number & Algebra", "chapters": [ + { + "slug": "zero-and-one", + "title": "Zero and one", + "description": "0 is nothing; 1 is one. Learn to tell them apart — the same skill as telling false from true — before counting larger amounts.", + "status": "live", + "curriculumCoverage": { + "Foundation": "core", + "IGCSE": "core", + "Common Core": "core", + "Singapore": "core", + "UK GCSE": "core", + "DSE": "related", + "IB": "related", + "A-Level": "related", + "AP": "related", + "Gaokao": "related" + } + }, { "slug": "counting-and-numbers", "title": "Counting and numbers", - "description": "Count from zero, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions.", + "description": "Count objects, spot patterns, add, multiply, and divide; then meet place value and big numbers from hundreds to billions.", "status": "live", "curriculumCoverage": { "Foundation": "core", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef9e41..de27647 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -134,7 +134,21 @@ if (chapterNavRoot) { }); } +function mountSubjectInit() { + const article = document.querySelector("[data-chapter]"); + const chapterKey = article?.dataset.chapter; + if (!article || !chapterKey) return; + + const subject = chapterKey.split("/")[0]; + void import("./generated/subject-modules").then(({ subjectInitLoaders }) => { + const loader = subjectInitLoaders[subject]; + if (!loader) return; + void loader().then((mod) => mod.initSubject(article)); + }); +} + initBookMath(); +mountSubjectInit(); mountChapterWidgets(); const catalogRoot = document.getElementById("catalog-app"); diff --git a/scripts/apply-math-coverage.ts b/scripts/apply-math-coverage.ts index c069f9a..52078e6 100644 --- a/scripts/apply-math-coverage.ts +++ b/scripts/apply-math-coverage.ts @@ -13,6 +13,18 @@ const JSON_PATH = path.join(ROOT, "data/math-curriculum.json"); /** slug → framework label → role */ const COVERAGE_BY_SLUG: Record = { + "zero-and-one": { + Foundation: "core", + IGCSE: "core", + "Common Core": "core", + Singapore: "core", + "UK GCSE": "core", + DSE: "related", + IB: "related", + "A-Level": "related", + AP: "related", + Gaokao: "related", + }, "counting-and-numbers": { Foundation: "core", IGCSE: "core", diff --git a/scripts/sync-chapters.ts b/scripts/sync-chapters.ts index 502fc21..e287bfa 100644 --- a/scripts/sync-chapters.ts +++ b/scripts/sync-chapters.ts @@ -8,8 +8,11 @@ const CONTENT = join(ROOT, "content"); const GENERATED_META = join(ROOT, "data/_generated/chapters-meta.json"); const GENERATED_NAV = join(ROOT, "data/_generated/chapter-nav.json"); const GENERATED_WIDGETS = join(ROOT, "frontend/src/generated/chapter-widgets.ts"); +const GENERATED_SUBJECT_MODULES = join(ROOT, "frontend/src/generated/subject-modules.ts"); +const GENERATED_SUBJECTS_META = join(ROOT, "data/_generated/subjects-meta.json"); const STATIC_ROOT = join(ROOT, "static"); const CHAPTER_CSS_DIR = join(STATIC_ROOT, "css/chapters"); +const SUBJECT_CSS_DIR = join(STATIC_ROOT, "css/subjects"); type ChapterMeta = { hasSupplement: boolean; @@ -44,6 +47,15 @@ type ChapterNavFile = { byKey: Record; }; +type SubjectMeta = { + hasCss: boolean; + hasJs: boolean; +}; + +type SubjectsMetaFile = { + subjects: Record; +}; + function parseChapterTemplate(markdown: string): boolean { return /template\s*=\s*["']chapter\.html["']/.test(markdown); } @@ -107,6 +119,65 @@ async function compileChapterScss( return true; } +async function compileSubjectScss(subject: string, scssPath: string): Promise { + const outPath = join(SUBJECT_CSS_DIR, `${subject}.css`); + await mkdir(SUBJECT_CSS_DIR, { recursive: true }); + const result = await $`sass ${scssPath} ${outPath} --style=compressed --no-source-map`.quiet(); + if (result.exitCode !== 0) { + console.error(`Failed to compile ${scssPath}`); + process.exit(1); + } + return true; +} + +async function discoverSubjects(): Promise> { + const subjects: Array<{ id: string; dir: string }> = []; + let entries; + try { + entries = await readdir(CONTENT, { withFileTypes: true }); + } catch { + return subjects; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + subjects.push({ id: entry.name, dir: join(CONTENT, entry.name) }); + } + return subjects; +} + +async function syncSubject(subject: { id: string; dir: string }): Promise { + const scssPath = join(subject.dir, "subject.scss"); + const tsPath = join(subject.dir, "subject.ts"); + let hasCss = false; + let hasJs = false; + if (existsSync(scssPath)) { + hasCss = await compileSubjectScss(subject.id, scssPath); + } + if (existsSync(tsPath)) { + hasJs = true; + } + return { hasCss, hasJs }; +} + +function buildSubjectModuleRegistry( + subjects: Array<{ id: string; dir: string; hasJs: boolean }> +): string { + const withJs = subjects.filter((s) => s.hasJs); + const lines: string[] = [ + "// Auto-generated by scripts/sync-chapters.ts — do not edit.", + "", + "export type SubjectModule = { initSubject: (root: HTMLElement) => void };", + "", + "export const subjectInitLoaders: Record Promise> = {", + ]; + for (const { id, dir } of withJs) { + const relFromGenerated = relative(dirname(GENERATED_SUBJECT_MODULES), dir).replace(/\\/g, "/"); + lines.push(` "${id}": () => import("${relFromGenerated}/subject.ts"),`); + } + lines.push("};", ""); + return lines.join("\n"); +} + async function discoverChapters(): Promise< Array<{ subject: string; slug: string; dir: string }> > { @@ -273,6 +344,22 @@ async function main() { const chapterNav = await buildChapterNav(chapters); await writeFile(GENERATED_NAV, `${JSON.stringify(chapterNav, null, 2)}\n`); + const subjectEntries: Array<{ id: string; dir: string; hasJs: boolean }> = []; + const subjectsMeta: SubjectsMetaFile = { subjects: {} }; + for (const subject of await discoverSubjects()) { + const meta = await syncSubject(subject); + subjectsMeta.subjects[subject.id] = meta; + subjectEntries.push({ id: subject.id, dir: subject.dir, hasJs: meta.hasJs }); + if (meta.hasCss || meta.hasJs) { + console.log( + `Synced subject ${subject.id} (css: ${meta.hasCss ? "yes" : "no"}, js: ${meta.hasJs ? "yes" : "no"})` + ); + } + } + await writeFile(GENERATED_SUBJECTS_META, `${JSON.stringify(subjectsMeta, null, 2)}\n`); + await mkdir(dirname(GENERATED_SUBJECT_MODULES), { recursive: true }); + await writeFile(GENERATED_SUBJECT_MODULES, buildSubjectModuleRegistry(subjectEntries)); + console.log(`Synced ${chapters.length} chapter(s).`); } diff --git a/themes/openfreebooks/sass/_book.scss b/themes/openfreebooks/sass/_book.scss index 24bb3b4..54e9604 100644 --- a/themes/openfreebooks/sass/_book.scss +++ b/themes/openfreebooks/sass/_book.scss @@ -240,6 +240,18 @@ a.book-chapter-link:focus-visible { margin: 0 0 1.1em; } +.book-prose a { + color: var(--accent); + font-weight: 600; + text-decoration: none; + text-underline-offset: 3px; +} + +.book-prose a:hover, +.book-prose a:focus-visible { + text-decoration: underline; +} + .book-prose__heading { margin: 2em 0 0.6em; } @@ -433,6 +445,18 @@ a.book-chapter-link:focus-visible { margin: 0.75rem 0 0; } +// Legacy/alternate markup — prefer book-question__solution in chapter HTML +.book-question__answer { + padding: 0 18px 16px; + border-top: 1px solid var(--card-border); + font-size: 0.95rem; + color: var(--soft-ink); +} + +.book-question__answer p { + margin: 0.75rem 0 0; +} + .ratio-demo__bars { display: flex; width: 100%; diff --git a/themes/openfreebooks/templates/chapter.html b/themes/openfreebooks/templates/chapter.html index d35ce0f..536924e 100644 --- a/themes/openfreebooks/templates/chapter.html +++ b/themes/openfreebooks/templates/chapter.html @@ -14,7 +14,12 @@ {% set slug = section.extra.chapter_id | default(value=section.slug) %} {% set chapter_key = subject ~ "/" ~ slug %} {% set chapters_meta = load_data(path="data/_generated/chapters-meta.json", format="json") %} +{% set subjects_meta = load_data(path="data/_generated/subjects-meta.json", format="json") %} {% set chapter_meta = chapters_meta.chapters[chapter_key] %} +{% set subject_meta = subjects_meta.subjects[subject] | default(value=false) %} +{% if subject_meta and subject_meta.hasCss %} + +{% endif %} {% if chapter_meta.hasCss %} {% endif %} diff --git a/themes/openfreebooks/templates/partials/math-curricula-compare.html b/themes/openfreebooks/templates/partials/math-curricula-compare.html index b515fb4..6d16379 100644 --- a/themes/openfreebooks/templates/partials/math-curricula-compare.html +++ b/themes/openfreebooks/templates/partials/math-curricula-compare.html @@ -142,10 +142,7 @@

Chapter coverage

- Each cell comes from curriculumCoverage in - math-curriculum.json (per-framework core, - extension, or related). Catalog filter chips still match any - framework with a non-empty mapping. List and map badges show framework labels only. + Catalog filter chips still match any framework with a non-empty mapping. List and map badges show framework labels only.