From a2679aa4ded2915e41d83ce0a1c9ab33cd27acc1 Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Wed, 20 May 2026 18:07:39 -0700 Subject: [PATCH] Refactor math chapter structure and styling: Update .gitignore to exclude specific CSS directories, enhance CONTRIBUTING.md with new guidelines for subject-specific assets, and streamline chapter content organization. Modify curriculum data to include new chapter dependencies and improve frontend logic for loading shared styles and scripts. Adjust styles for better visual consistency across math chapters. --- .agents/skills/ofb/SKILL.md | 13 +- .agents/skills/ofb/math-chapter-patterns.md | 39 + .agents/skills/zola/zola-solid.md | 13 + .gitignore | 2 +- CONTRIBUTING.md | 23 +- content/math/basic-math-notation/chapter.scss | 29 +- content/math/counting-and-numbers/_index.md | 30 +- .../math/counting-and-numbers/chapter.scss | 528 ++++----- content/math/counting-and-numbers/core.html | 26 +- .../widgets/big-numbers-scale.tsx | 1039 ++++++++++++++--- content/math/subject.scss | 106 ++ content/math/subject.ts | 12 + content/math/zero-and-one/_index.md | 137 +++ content/math/zero-and-one/chapter.scss | 123 ++ content/math/zero-and-one/core.html | 87 ++ content/math/zero-and-one/supplement.html | 38 + .../zero-and-one/widgets/zero-one-board.tsx | 218 ++++ data/math-curriculum.json | 24 +- frontend/src/main.tsx | 14 + scripts/apply-math-coverage.ts | 12 + scripts/sync-chapters.ts | 87 ++ themes/openfreebooks/sass/_book.scss | 24 + themes/openfreebooks/templates/chapter.html | 5 + .../partials/math-curricula-compare.html | 5 +- 24 files changed, 2104 insertions(+), 530 deletions(-) create mode 100644 .agents/skills/ofb/math-chapter-patterns.md create mode 100644 content/math/subject.scss create mode 100644 content/math/subject.ts create mode 100644 content/math/zero-and-one/_index.md create mode 100644 content/math/zero-and-one/chapter.scss create mode 100644 content/math/zero-and-one/core.html create mode 100644 content/math/zero-and-one/supplement.html create mode 100644 content/math/zero-and-one/widgets/zero-one-board.tsx 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.