From 81b316e08d6e2dd0a9724579537e70f452e32383 Mon Sep 17 00:00:00 2001 From: Hananoshika Yomaru Date: Tue, 19 May 2026 05:34:25 -0700 Subject: [PATCH] Enhance contributing guidelines in CONTRIBUTING.md with a readable link, update curriculum JSON structure to include chapter tiers, and improve catalog chapter card display with tier badges. Update package.json for testing scripts and add contributing URL in zola.toml. Refactor frontend components and styles for better integration of new features. --- .agents/skills/ofb/SKILL.md | 2 +- .agents/skills/ofb/curriculum-data.md | 24 +- CONTRIBUTING.md | 80 +- content/contributing.md | 4 + data/catalog.types.ts | 4 + data/math-curriculum.json | 212 +- .../src/components/catalog-chapter-card.tsx | 13 +- .../src/components/contributing-demos.tsx | 166 + frontend/src/lib/catalog-badge.ts | 8 + frontend/src/lib/catalog-canvas-theme.ts | 1 + frontend/src/lib/catalog-to-canvas.test.ts | 193 + frontend/src/lib/catalog-to-canvas.ts | 197 +- frontend/src/main.tsx | 8 + package.json | 3 +- themes/openfreebooks/sass/_catalog.scss | 6 + themes/openfreebooks/sass/_contributing.scss | 378 ++ themes/openfreebooks/sass/main.scss | 1 + themes/openfreebooks/static/js/bundle.js | 4 +- .../static/js/catalog-app-Bi3XX9IX.js | 2 + .../static/js/catalog-app-CKWl-z4W.js | 2 + .../static/js/catalog-app-DUOHXUDq.js | 2 + .../static/js/catalog-canvas-view-BQ3Gt75K.js | 450 ++ .../static/js/catalog-canvas-view-Bwd11lkP.js | 450 ++ .../static/js/catalog-canvas-view-ob17uQ4y.js | 450 ++ .../static/js/contributing-demos-BWfY4_xa.js | 1 + .../static/js/particle-background-Rc9MuW13.js | 4265 +++++++++++++++++ .../static/js/quadratic-explorer-CDHnWNXg.js | 1 + themes/openfreebooks/templates/base.html | 1 + .../openfreebooks/templates/contributing.html | 14 + themes/openfreebooks/templates/index.html | 2 +- .../partials/contributing-content.html | 149 + .../partials/contributors-block.html | 2 +- zola.toml | 1 + 33 files changed, 6944 insertions(+), 152 deletions(-) create mode 100644 content/contributing.md create mode 100644 frontend/src/components/contributing-demos.tsx create mode 100644 frontend/src/lib/catalog-to-canvas.test.ts create mode 100644 themes/openfreebooks/sass/_contributing.scss create mode 100644 themes/openfreebooks/static/js/catalog-app-Bi3XX9IX.js create mode 100644 themes/openfreebooks/static/js/catalog-app-CKWl-z4W.js create mode 100644 themes/openfreebooks/static/js/catalog-app-DUOHXUDq.js create mode 100644 themes/openfreebooks/static/js/catalog-canvas-view-BQ3Gt75K.js create mode 100644 themes/openfreebooks/static/js/catalog-canvas-view-Bwd11lkP.js create mode 100644 themes/openfreebooks/static/js/catalog-canvas-view-ob17uQ4y.js create mode 100644 themes/openfreebooks/static/js/contributing-demos-BWfY4_xa.js create mode 100644 themes/openfreebooks/static/js/particle-background-Rc9MuW13.js create mode 100644 themes/openfreebooks/static/js/quadratic-explorer-CDHnWNXg.js create mode 100644 themes/openfreebooks/templates/contributing.html create mode 100644 themes/openfreebooks/templates/partials/contributing-content.html diff --git a/.agents/skills/ofb/SKILL.md b/.agents/skills/ofb/SKILL.md index 3ae0a1c..b8ad8af 100644 --- a/.agents/skills/ofb/SKILL.md +++ b/.agents/skills/ofb/SKILL.md @@ -87,7 +87,7 @@ Detailed checklists: [curriculum-data.md](curriculum-data.md). Human-facing guid **Canvas viewer quirks:** use `container.get(internal.InteractionHandler).onClick` (not `viewer.onClick`); edge colors must be 8-digit hex (no `rgba` on node/edge color fields); wheel on map uses capture-phase `stopPropagation` unless Ctrl/Cmd so the page can scroll. -Implicit edges: sequential links inside each strand. Explicit: `graph.edges` for branches. Both merged; deduped. +Prerequisites: **only** `graph.edges` (required, global DAG, cross-strand OK). Longest-path levels. No implicit strand-list chains. List order ≠ map order. ## Chapter content (current pattern) diff --git a/.agents/skills/ofb/curriculum-data.md b/.agents/skills/ofb/curriculum-data.md index bd29bd1..adc34ae 100644 --- a/.agents/skills/ofb/curriculum-data.md +++ b/.agents/skills/ofb/curriculum-data.md @@ -32,8 +32,9 @@ Example: `data/math-curriculum.json`. "intro": "Optional; was used by old book page", "graph": { "edges": [ - { "from": "quadratic-equations", "to": "sequences" }, - { "from": "quadratic-equations", "to": "functions-graphs" } + { "from": "quadratic-equations", "to": "sequences-series" }, + { "from": "quadratic-equations", "to": "functions-graphs" }, + { "from": "functions-graphs", "to": "linear-programming" } ] }, "strands": [ @@ -63,13 +64,26 @@ Example: `data/math-curriculum.json`. | `description` | yes | 1–2 sentences; shown in list and map cards (clamped to 3 lines on map) | | `status` | yes | `live` (link to chapter) or `planned` (no link) | | `curriculums` | yes | Non-empty array; values must exist in `catalog.json` `curriculums` | +| `tier` | no | `foundation` (default) or `non-foundation` — shows an **Extension** badge on cards | + +### Graph model (map) + +| Rule | Detail | +|------|--------| +| Scope | DSE compulsory topic units (~25–35 chapters); primary/junior not in JSON yet | +| Edges | **Required** prerequisites only; `graph.edges` is the sole source (no auto chain from strand array order) | +| Scope of `from`/`to` | Any chapter slug — **cross-strand** edges allowed (arrows may cross columns) | +| Levels | **Longest-path** from roots: `level = 1 + max(level(parent))`; roots at 0 | +| List order | `strands[].chapters[]` array order is for the **list** tab only (authoring / ToC) | +| Roots | Multiple level-0 chapters per strand OK; edgeless placeholders OK while drafting | ### Graph edges - `from` / `to` are **chapter slugs** (not node ids). -- Edges must form a **DAG** (no cycles). Map builder runs cycle detection. -- Strand order still adds implicit edges: each chapter → next chapter in the same strand (deduped). -- Use explicit edges for **branches** (one chapter → multiple next chapters). +- Edges must form a **DAG** (no cycles). Map builder throws on cycles. +- One prerequisite → add one edge. Multiple parents → multiple edges into the same `to`. +- Branches: one `from` → many `to` (e.g. quadratics → sequences and functions-graphs). +- Merges: many `from` → one `to` (e.g. functions + sequences → linear programming). ### Wiring into catalog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49a37bc..1d64348 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,8 @@ # Contributing to Open Free Books -Thank you for helping grow free, open textbooks. This guide focuses on **curriculum, subjects, and chapters** — the most common contribution. +Thank you for helping grow free, open textbooks. + +**Readable guide (with examples):** [openfreebooks.org/contributing](https://openfreebooks.org/contributing/) — or locally after `zola serve` → [http://127.0.0.1:1111/contributing/](http://127.0.0.1:1111/contributing/) **Prerequisites:** [Zola](https://www.getzola.org/), [Bun](https://bun.sh/). See [README.md](README.md) for dev setup. @@ -11,7 +13,7 @@ Thank you for helping grow free, open textbooks. This guide focuses on **curricu | I want to… | Start here | |------------|------------| | Add a **planned** chapter (roadmap only) | [§ Planned chapter](#add-a-planned-chapter-roadmap) | -| Publish a **live** chapter (readable page) | [§ Live chapter](#publish-a-live-chapter) | +| Publish a **live** chapter (readable page) | [§ Publish a live chapter](#publish-a-live-chapter) | | Add a **branch** in the catalog map | [§ Chapter graph](#chapter-graph-branches) | | Add a new **subject** | [§ New subject](#add-a-new-subject) | | Add a **curriculum** filter (e.g. GCSE) | [§ New curriculum label](#add-a-curriculum-label) | @@ -24,17 +26,26 @@ Thank you for helping grow free, open textbooks. This guide focuses on **curricu ```text data/catalog.json → subjects in the sidebar + filter chips (DSE, IB, …) -data/{subject}-curriculum.json → strands, chapters, curriculum tags, optional graph +data/{subject}-curriculum.json → strands, chapters, curriculum tags, graph.edges content/{subject}/{slug}/ → actual chapter page (when status is "live") themes/.../partials/... → chapter HTML content (today) ``` -- **Catalog** (`/catalog/`) is the place to browse everything. - - **List** — numbered chapter cards; live chapters link to the whole card. - - **Map** — strand columns on a pannable JSON Canvas graph; live chapters link from the **title only** (with `→` and a hover underline). -- **`/math/`** redirects to `/catalog/?subject=math` — there is no separate math book homepage. +- **Catalog** (`/catalog/`) — **List** (numbered cards; live = whole card links) and **Map** (`?view=tree`; live = title link only with `→` and hover underline). +- **`/math/`** redirects to `/catalog/?subject=math`. - **Curriculum names** (DSE, IB, …) appear only in the catalog — not inside chapter text ([spec.md](spec.md)). -- Every chapter needs a short **`description`** in curriculum JSON (shown in list and map; map clamps description to three lines). +- Every chapter needs a **`description`** in curriculum JSON (map clamps to three lines). + +### Map graph rules (summary) + +| Rule | Detail | +|------|--------| +| Edges | **Only** `graph.edges` — no automatic chain from strand `chapters[]` order | +| Meaning | Each edge is a **required** prerequisite (`from` → `to`) | +| Scope | Cross-strand edges allowed | +| Layout | **Longest-path** levels; siblings at the same level stack vertically in a column | +| DAG | No cycles — build throws if the graph cycles | +| `tier` | Optional `foundation` (default) or `non-foundation` (Extension badge) | --- @@ -52,10 +63,13 @@ Use this when the chapter is not written yet but should appear in the catalog. "title": "Human-readable title", "description": "One or two sentences for the catalog list and map card.", "status": "planned", - "curriculums": ["DSE"] + "curriculums": ["DSE"], + "tier": "non-foundation" } ``` +(`tier` is optional — omit for foundation topics; `non-foundation` shows an **Extension** badge.) + 4. Use a **kebab-case** `slug` (letters, numbers, hyphens only). 5. Set `curriculums` to every syllabus tag that applies. Tags must exist in `data/catalog.json` → `curriculums`. 6. Run `zola build` and check `/catalog/?subject=math` (or your subject). @@ -70,7 +84,7 @@ A live chapter needs **catalog data** and a **page**. ### 1. Catalog entry -In `data/{subject}-curriculum.json`, set `"status": "live"` and include a `description` for the chapter (see [planned chapter](#add-a-planned-chapter-roadmap)). +In `data/{subject}-curriculum.json`, set `"status": "live"` and include a `description` (see [planned chapter](#add-a-planned-chapter-roadmap)). ### 2. Zola section @@ -128,19 +142,18 @@ Then run `bun run build:js`. ## Chapter graph (branches) -The **Map** tab (`?view=tree`) shows chapters as a directed graph on a canvas (strand = column, arrows = prerequisites / sequence). +The **Map** tab (`?view=tree`) shows chapters as a directed graph (strand = column, arrows = prerequisites). -- Chapters appear as cards with title, description, and curriculum badges. -- **Live** chapters: click the **title** to open the chapter (not the whole card). -- **Planned** chapters: shown with a “Coming soon” badge; no link. -- By default, chapters in a strand link in **list order** (chapter 1 → chapter 2 → …). -- For **branches** (one chapter leads to several next chapters), add `graph.edges`: +- Prerequisites are **only** declared in `graph.edges` (strand list order does not create arrows). +- For **branches** or **merges**, add edges: ```json "graph": { "edges": [ - { "from": "quadratic-equations", "to": "sequences" }, - { "from": "quadratic-equations", "to": "functions-graphs" } + { "from": "quadratic-equations", "to": "sequences-series" }, + { "from": "quadratic-equations", "to": "functions-graphs" }, + { "from": "functions-graphs", "to": "linear-programming" }, + { "from": "sequences-series", "to": "linear-programming" } ] } ``` @@ -148,12 +161,10 @@ The **Map** tab (`?view=tree`) shows chapters as a directed graph on a canvas (s Rules: - `from` and `to` are chapter **slugs**. -- The graph must **not contain cycles** (no circular prerequisites). -- Edges must reference slugs that exist in `strands`. +- The graph must **not contain cycles**. +- Edges may cross strands. -Open `/catalog/?subject=math&view=tree` to verify layout. Scroll the page normally; use Ctrl/Cmd + wheel on the map to zoom. Pan by clicking the map first if the “click to interact” banner appears. - -If a map card looks **clipped** at the bottom (badges or description cut off), that is a layout bug — long titles and descriptions need enough node height. Report it or see [curriculum-data.md](.agents/skills/ofb/curriculum-data.md) for how heights are measured. +Open `/catalog/?subject=math&view=tree` to verify. Use Ctrl/Cmd + wheel on the map to zoom. --- @@ -169,9 +180,9 @@ Example: adding **Science**. 2. **`data/science-curriculum.json`** — create with `title`, `strands`, and chapters (copy structure from `math-curriculum.json`). -3. **`themes/openfreebooks/templates/catalog.html`** — today you must wire the new file into `#catalog-data` (see `math` block). *This step should get easier; see README contributor tasks.* +3. **`themes/openfreebooks/templates/catalog.html`** — wire the new file into `#catalog-data` (see `math` block). -4. **`content/science/`** — add `_index.md` (redirect template like math) when you have a section. +4. **`content/science/`** — add `_index.md` when you have a section. 5. **Homepage** — optional card in `themes/openfreebooks/templates/index.html`. @@ -190,13 +201,26 @@ Example: adding **GCSE**. --- +## Development & tests + +```bash +bun install +bun run build:js # after frontend/ changes +zola serve +bun test # catalog map layout (catalog-to-canvas) +bun run build +``` + +- Commit `themes/openfreebooks/static/js/` after `build:js`. +- Do **not** commit `public/`. + +--- + ## Pull requests - Keep changes focused (one subject or a few chapters per PR is fine). - Run `bun run build` before opening the PR. -- Do **not** commit `public/` or copied HTML under `themes/openfreebooks/static/`. -- **Do** commit Vite output under `themes/openfreebooks/static/js/` (`bundle.js` and hashed chunks such as `catalog-app-*.js`, `catalog-canvas-view-*.js`) after `bun run build:js`. -- Describe what you added in the PR: subject, chapters, live vs planned, and which curricula apply. +- Describe what you added: subject, chapters, live vs planned, and which curricula apply. Questions? Open a [GitHub discussion](https://github.com/hananoshikayomaru/openfreebooks) or issue. diff --git a/content/contributing.md b/content/contributing.md new file mode 100644 index 0000000..47aae08 --- /dev/null +++ b/content/contributing.md @@ -0,0 +1,4 @@ ++++ +title = "Contribute" +template = "contributing.html" ++++ diff --git a/data/catalog.types.ts b/data/catalog.types.ts index 40c784b..b7f37c1 100644 --- a/data/catalog.types.ts +++ b/data/catalog.types.ts @@ -1,9 +1,13 @@ +export type CatalogChapterTier = "foundation" | "non-foundation"; + export type CatalogChapter = { slug: string; title: string; description: string; status: "live" | "planned"; curriculums: string[]; + /** DSE compulsory tier; omit = foundation. */ + tier?: CatalogChapterTier; }; export type CatalogStrand = { diff --git a/data/math-curriculum.json b/data/math-curriculum.json index 0681564..4ff6b4d 100644 --- a/data/math-curriculum.json +++ b/data/math-curriculum.json @@ -1,13 +1,46 @@ { "title": "Mathematics", - "subtitle": "Secondary level (S4–S6)", - "intro": "Structured around three core strands. Chapters marked as available can be read now; others are on the roadmap.", + "subtitle": "HKDSE Compulsory Part (S4–S6)", + "intro": "Topic units across three strands. The map shows required prerequisites; the list follows a readable teaching order.", "graph": { "edges": [ - { "from": "quadratic-equations", "to": "sequences" }, + { "from": "directed-numbers-rates", "to": "polynomials-expansion-factorisation" }, + { "from": "polynomials-expansion-factorisation", "to": "algebraic-fractions-formulas" }, + { "from": "polynomials-expansion-factorisation", "to": "linear-equations-two-unknowns" }, + { "from": "linear-equations-two-unknowns", "to": "linear-inequalities-one-unknown" }, + { "from": "polynomials-expansion-factorisation", "to": "laws-of-indices" }, + { "from": "laws-of-indices", "to": "quadratic-equations" }, + { "from": "polynomials-expansion-factorisation", "to": "factor-remainder-theorems" }, + { "from": "quadratic-equations", "to": "factor-remainder-theorems" }, + { "from": "quadratic-equations", "to": "complex-numbers" }, { "from": "quadratic-equations", "to": "functions-graphs" }, - { "from": "sequences", "to": "linear-programming" }, - { "from": "functions-graphs", "to": "linear-programming" } + { "from": "quadratic-equations", "to": "sequences-series" }, + { "from": "functions-graphs", "to": "exponential-logarithmic" }, + { "from": "functions-graphs", "to": "variations" }, + { "from": "functions-graphs", "to": "sequences-series" }, + { "from": "functions-graphs", "to": "linear-programming" }, + { "from": "sequences-series", "to": "linear-programming" }, + { "from": "polynomials-expansion-factorisation", "to": "coordinate-geometry-foundations" }, + { "from": "quadratic-equations", "to": "coordinate-circles" }, + { "from": "functions-graphs", "to": "advanced-trigonometry" }, + { "from": "coordinate-geometry-foundations", "to": "deductive-geometry" }, + { "from": "coordinate-geometry-foundations", "to": "coordinate-circles" }, + { "from": "deductive-geometry", "to": "congruence-similarity" }, + { "from": "congruence-similarity", "to": "pythagoras-theorem" }, + { "from": "pythagoras-theorem", "to": "introduction-trigonometry" }, + { "from": "introduction-trigonometry", "to": "mensuration-3d" }, + { "from": "introduction-trigonometry", "to": "advanced-trigonometry" }, + { "from": "coordinate-circles", "to": "circle-properties" }, + { "from": "deductive-geometry", "to": "circle-properties" }, + { "from": "coordinate-circles", "to": "loci" }, + { "from": "circle-properties", "to": "loci" }, + { "from": "statistical-charts-averages", "to": "measures-dispersion" }, + { "from": "statistical-charts-averages", "to": "basic-probability" }, + { "from": "basic-probability", "to": "permutations-combinations-probability" }, + { "from": "measures-dispersion", "to": "normal-distribution-z-scores" }, + { "from": "measures-dispersion", "to": "statistical-literacy" }, + { "from": "basic-probability", "to": "statistical-literacy" }, + { "from": "permutations-combinations-probability", "to": "statistical-literacy" } ] }, "strands": [ @@ -15,6 +48,48 @@ "id": "number-algebra", "title": "Number & Algebra", "chapters": [ + { + "slug": "directed-numbers-rates", + "title": "Directed numbers, rates & ratios", + "description": "Operate with negative numbers and solve ratio, rate, and proportion problems in context.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "polynomials-expansion-factorisation", + "title": "Polynomials: expansion & factorisation", + "description": "Expand products, collect like terms, and factorise expressions using common factors and identities.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "algebraic-fractions-formulas", + "title": "Algebraic fractions & formulas", + "description": "Simplify algebraic fractions and change the subject of formulas in applied contexts.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "linear-equations-two-unknowns", + "title": "Linear equations in two unknowns", + "description": "Solve simultaneous linear equations by substitution, elimination, and graphical interpretation.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "linear-inequalities-one-unknown", + "title": "Linear inequalities in one unknown", + "description": "Solve and represent linear inequalities on the number line and in word problems.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "laws-of-indices", + "title": "Laws of indices", + "description": "Apply index laws to simplify expressions with integer and rational exponents.", + "status": "planned", + "curriculums": ["DSE"] + }, { "slug": "quadratic-equations", "title": "Quadratic equations", @@ -23,11 +98,20 @@ "curriculums": ["DSE"] }, { - "slug": "sequences", - "title": "Sequences & series", - "description": "Work with arithmetic and geometric sequences, sigma notation, and simple convergence ideas.", + "slug": "factor-remainder-theorems", + "title": "Factor & remainder theorems", + "description": "Use the factor and remainder theorems to factorise higher-degree polynomials and solve related equations.", "status": "planned", - "curriculums": ["DSE"] + "curriculums": ["DSE"], + "tier": "non-foundation" + }, + { + "slug": "complex-numbers", + "title": "Complex numbers", + "description": "Represent complex numbers in Cartesian form and perform operations including conjugates and division.", + "status": "planned", + "curriculums": ["DSE"], + "tier": "non-foundation" }, { "slug": "functions-graphs", @@ -36,12 +120,35 @@ "status": "planned", "curriculums": ["DSE"] }, + { + "slug": "exponential-logarithmic", + "title": "Exponential & logarithmic functions", + "description": "Explore laws of logarithms, exponential growth and decay, and sketch related graphs.", + "status": "planned", + "curriculums": ["DSE"], + "tier": "non-foundation" + }, + { + "slug": "sequences-series", + "title": "Sequences & series", + "description": "Work with arithmetic and geometric sequences, sigma notation, and finite summation formulas.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "variations", + "title": "Variations", + "description": "Model direct, inverse, joint, and partial variation and solve combined variation problems.", + "status": "planned", + "curriculums": ["DSE"] + }, { "slug": "linear-programming", "title": "Linear programming", "description": "Graph linear inequalities, identify feasible regions, and optimise objective functions in two variables.", "status": "planned", - "curriculums": ["DSE"] + "curriculums": ["DSE"], + "tier": "non-foundation" } ] }, @@ -50,32 +157,77 @@ "title": "Measures, Shape & Space", "chapters": [ { - "slug": "trigonometry", - "title": "Advanced trigonometry", - "description": "Use radians, identities, and laws of sines and cosines to solve triangles and model periodic phenomena.", + "slug": "coordinate-geometry-foundations", + "title": "Coordinate geometry foundations", + "description": "Plot points, find distances and midpoints, and work with slopes of lines in the Cartesian plane.", "status": "planned", "curriculums": ["DSE"] }, { - "slug": "coordinate-geometry", + "slug": "deductive-geometry", + "title": "Deductive geometry", + "description": "Prove angle and side relationships for intersecting lines, triangles, and polygons.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "congruence-similarity", + "title": "Congruence & similarity", + "description": "Apply congruence and similarity criteria to justify properties of triangles and scale figures.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "pythagoras-theorem", + "title": "Pythagoras' theorem", + "description": "Use Pythagoras' theorem and its converse in 2D problems and coordinate settings.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "introduction-trigonometry", + "title": "Introduction to trigonometry", + "description": "Define sine, cosine, and tangent ratios and solve right-angled triangle problems.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "mensuration-3d", + "title": "Mensuration in 3D", + "description": "Find volumes and surface areas of prisms, cylinders, pyramids, cones, and spheres.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "coordinate-circles", "title": "Coordinate geometry of circles", "description": "Find equations of circles, tangents, and intersections using coordinate methods and distance.", "status": "planned", "curriculums": ["DSE"] }, + { + "slug": "advanced-trigonometry", + "title": "Advanced trigonometry", + "description": "Use sine and cosine rules, radians, and trigonometric graphs in 2D and 3D applications.", + "status": "planned", + "curriculums": ["DSE"], + "tier": "non-foundation" + }, { "slug": "circle-properties", "title": "Properties of circles", "description": "Apply chord, tangent, and angle theorems in circles to prove results and solve geometry problems.", "status": "planned", - "curriculums": ["DSE"] + "curriculums": ["DSE"], + "tier": "non-foundation" }, { "slug": "loci", "title": "Loci", "description": "Describe and construct loci in the plane, linking geometric constraints to algebraic equations.", "status": "planned", - "curriculums": ["DSE"] + "curriculums": ["DSE"], + "tier": "non-foundation" } ] }, @@ -84,25 +236,41 @@ "title": "Data Handling", "chapters": [ { - "slug": "dispersion", + "slug": "statistical-charts-averages", + "title": "Charts & averages", + "description": "Organise data with tables and charts and calculate mean, median, and mode.", + "status": "planned", + "curriculums": ["DSE"] + }, + { + "slug": "measures-dispersion", "title": "Measures of dispersion", "description": "Interpret range, interquartile range, variance, and standard deviation for comparing data sets.", "status": "planned", "curriculums": ["DSE"] }, { - "slug": "probability", - "title": "Permutations, combinations & probability", - "description": "Count outcomes with permutations and combinations, then compute probabilities in finite sample spaces.", + "slug": "basic-probability", + "title": "Basic probability", + "description": "Calculate theoretical and experimental probabilities for simple events and complements.", "status": "planned", "curriculums": ["DSE"] }, { - "slug": "normal-distribution", + "slug": "permutations-combinations-probability", + "title": "Permutations, combinations & probability", + "description": "Count outcomes with permutations and combinations, then compute probabilities in finite sample spaces.", + "status": "planned", + "curriculums": ["DSE"], + "tier": "non-foundation" + }, + { + "slug": "normal-distribution-z-scores", "title": "Normal distribution & z-scores", "description": "Model continuous data with the normal curve, standardise with z-scores, and read probabilities from tables.", "status": "planned", - "curriculums": ["DSE"] + "curriculums": ["DSE"], + "tier": "non-foundation" }, { "slug": "statistical-literacy", diff --git a/frontend/src/components/catalog-chapter-card.tsx b/frontend/src/components/catalog-chapter-card.tsx index 724ab0e..10a15be 100644 --- a/frontend/src/components/catalog-chapter-card.tsx +++ b/frontend/src/components/catalog-chapter-card.tsx @@ -1,6 +1,6 @@ import { For, Show } from "solid-js"; import type { CatalogChapter } from "../../../data/catalog.types"; -import { curriculumBadgeClass } from "../lib/catalog-badge"; +import { curriculumBadgeClass, tierBadgeLabel } from "../lib/catalog-badge"; export type CatalogChapterCardProps = { chapter: CatalogChapter; @@ -24,6 +24,14 @@ function appendMeta(parent: HTMLElement, chapter: CatalogChapter) { meta.appendChild(badge); } + const tierLabel = tierBadgeLabel(chapter.tier); + if (tierLabel) { + const tier = document.createElement("span"); + tier.className = "catalog-badge catalog-badge--extension"; + tier.textContent = tierLabel; + meta.appendChild(tier); + } + if (chapter.status === "planned") { const soon = document.createElement("span"); soon.className = "book-chapter-badge book-chapter-badge--planned"; @@ -154,6 +162,9 @@ export function CatalogChapterCard(props: CatalogChapterCardProps) { {curriculum} )} + + {(label) => {label()}} + Coming soon diff --git a/frontend/src/components/contributing-demos.tsx b/frontend/src/components/contributing-demos.tsx new file mode 100644 index 0000000..e0f16f9 --- /dev/null +++ b/frontend/src/components/contributing-demos.tsx @@ -0,0 +1,166 @@ +import { createSignal, Show } from "solid-js"; +import { render } from "solid-js/web"; + +const GRAPH_EDGES: Array<{ from: string; to: string }> = [ + { from: "quadratic", to: "sequences" }, + { from: "quadratic", to: "functions" }, + { from: "sequences", to: "linear-programming" }, + { from: "functions", to: "linear-programming" }, +]; + +function GraphPrereqDemo() { + const [focus, setFocus] = createSignal(null); + + const nodeLit = (id: string) => { + const active = focus(); + if (!active) return false; + if (id === active) return true; + return GRAPH_EDGES.some( + (edge) => + (edge.from === active && edge.to === id) || (edge.from === id && edge.to === active) + ); + }; + + const toggle = (id: string) => setFocus((prev) => (prev === id ? null : id)); + + return ( +
+

+ Click a topic to highlight its prerequisites and follow-on chapters. +

+
+ +
+
+ ); +} + +function CatalogViewsDemo() { + const [view, setView] = createSignal<"list" | "map">("list"); + + return ( +
+
+ + +
+
+ +
+ Number & Algebra + Directed numbers + + Quadratic equations + +
+ Sequences + Functions +
+
+

+ Arrows come from graph.edges only — strand order in JSON does not draw + links. +

+
+ } + > +
    +
  1. + 01 + Quadratic equations + DSE +
  2. +
  3. + 02 + Sequences & series + Coming soon +
  4. +
+

+ List order follows the chapters[] array — your table of contents. +

+ +
+ + ); +} + +export function mountContributingDemos() { + const graphRoot = document.getElementById("contributing-demo-graph"); + if (graphRoot) { + render(() => , graphRoot); + } + + const viewsRoot = document.getElementById("contributing-demo-views"); + if (viewsRoot) { + render(() => , viewsRoot); + } +} diff --git a/frontend/src/lib/catalog-badge.ts b/frontend/src/lib/catalog-badge.ts index 17e314a..f082df9 100644 --- a/frontend/src/lib/catalog-badge.ts +++ b/frontend/src/lib/catalog-badge.ts @@ -1,3 +1,5 @@ +import type { CatalogChapterTier } from "../../../data/catalog.types"; + export function curriculumBadgeClass(curriculum: string): string { const map: Record = { DSE: "catalog-badge--dse", @@ -8,3 +10,9 @@ export function curriculumBadgeClass(curriculum: string): string { }; return map[curriculum] ?? "catalog-badge--default"; } + +/** Shown on cards when tier is non-foundation; foundation chapters have no extra badge. */ +export function tierBadgeLabel(tier?: CatalogChapterTier): string | null { + if (tier === "non-foundation") return "Extension"; + return null; +} diff --git a/frontend/src/lib/catalog-canvas-theme.ts b/frontend/src/lib/catalog-canvas-theme.ts index ba42a52..f6f4d03 100644 --- a/frontend/src/lib/catalog-canvas-theme.ts +++ b/frontend/src/lib/catalog-canvas-theme.ts @@ -17,6 +17,7 @@ export const CANVAS_NODE_DARK = { } as const; export function siteCanvasTheme(): "light" | "dark" { + if (typeof document === "undefined") return "light"; const attr = document.documentElement.getAttribute("data-theme"); if (attr === "dark" || attr === "light") return attr; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; diff --git a/frontend/src/lib/catalog-to-canvas.test.ts b/frontend/src/lib/catalog-to-canvas.test.ts new file mode 100644 index 0000000..a23d888 --- /dev/null +++ b/frontend/src/lib/catalog-to-canvas.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from "bun:test"; +import type { JSONCanvas } from "json-canvas-viewer"; +import type { + CatalogChapter, + CatalogGraphEdge, + CatalogSubject, +} from "../../../data/catalog.types"; +import { + CHAPTER_NODE_WIDTH, + chapterNodeId, + chapterSlugFromNodeId, + relayoutCanvasChapterHeights, + subjectToCanvas, +} from "./catalog-to-canvas"; + +const GAP_Y = 48; + +function chapter( + slug: string, + overrides: Partial = {} +): CatalogChapter { + return { + slug, + title: slug, + description: "Test chapter description for layout.", + status: "planned", + curriculums: ["DSE"], + ...overrides, + }; +} + +function miniSubject(chapters: CatalogChapter[], edges: CatalogGraphEdge[] = []): CatalogSubject { + return { + id: "math", + name: "Mathematics", + strands: [ + { + id: "number-algebra", + title: "Number & Algebra", + chapters, + }, + ], + graph: { edges }, + }; +} + +function textNode(canvas: JSONCanvas, slug: string) { + return (canvas.nodes ?? []).find((node) => node.id === chapterNodeId(slug) && node.type === "text"); +} + +function bottomY(node: { y: number; height?: number }) { + return node.y + (node.height ?? 0); +} + +describe("chapterNodeId", () => { + test("maps slug to node id and back", () => { + expect(chapterNodeId("quadratic-equations")).toBe("chapter-quadratic-equations"); + expect(chapterSlugFromNodeId("chapter-quadratic-equations")).toBe("quadratic-equations"); + expect(chapterSlugFromNodeId("group-strand")).toBeNull(); + }); +}); + +describe("subjectToCanvas", () => { + test("returns null when no chapters match filter", () => { + const subject = miniSubject([chapter("a")]); + expect(subjectToCanvas(subject, () => false)).toBeNull(); + }); + + test("uses only explicit graph edges (no implicit strand chain)", () => { + const subject = miniSubject( + [chapter("first"), chapter("second")], + [] + ); + const canvas = subjectToCanvas(subject, () => true); + expect(canvas?.edges ?? []).toHaveLength(0); + }); + + test("assigns longest-path levels on y axis", () => { + const subject = miniSubject( + [chapter("a"), chapter("b"), chapter("c")], + [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + ] + ); + const canvas = subjectToCanvas(subject, () => true)!; + const a = textNode(canvas, "a")!; + const b = textNode(canvas, "b")!; + const c = textNode(canvas, "c")!; + + expect(b.y).toBeGreaterThan(bottomY(a)); + expect(c.y).toBeGreaterThan(bottomY(b)); + }); + + test("stacks siblings that share a level in one column", () => { + const subject = miniSubject( + [chapter("alpha"), chapter("beta"), chapter("merge")], + [ + { from: "alpha", to: "merge" }, + { from: "beta", to: "merge" }, + ] + ); + const canvas = subjectToCanvas(subject, () => true)!; + const alpha = textNode(canvas, "alpha")!; + const beta = textNode(canvas, "beta")!; + const merge = textNode(canvas, "merge")!; + + expect(alpha.x).toBe(beta.x); + expect(beta.y).toBeGreaterThanOrEqual(bottomY(alpha) + GAP_Y); + expect(merge.y).toBeGreaterThan(bottomY(alpha)); + expect(merge.y).toBeGreaterThan(bottomY(beta)); + }); + + test("places strand columns at increasing x", () => { + const subject: CatalogSubject = { + id: "math", + name: "Mathematics", + strands: [ + { + id: "number-algebra", + title: "Number & Algebra", + chapters: [chapter("na")], + }, + { + id: "data-handling", + title: "Data Handling", + chapters: [chapter("dh")], + }, + ], + graph: { edges: [] }, + }; + const canvas = subjectToCanvas(subject, () => true)!; + const na = textNode(canvas, "na")!; + const dh = textNode(canvas, "dh")!; + + expect(dh.x).toBeGreaterThan(na.x); + expect(dh.x - na.x).toBeGreaterThanOrEqual(CHAPTER_NODE_WIDTH); + }); + + test("throws when the graph has a cycle", () => { + const subject = miniSubject( + [chapter("a"), chapter("b"), chapter("c")], + [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + { from: "c", to: "a" }, + ] + ); + expect(() => subjectToCanvas(subject, () => true)).toThrow(/cycle/i); + }); +}); + +describe("relayoutCanvasChapterHeights", () => { + test("handles canvas with undefined nodes and edges", () => { + expect(relayoutCanvasChapterHeights({}, new Map())).toBeNull(); + }); + + test("returns null when layout unchanged", () => { + const subject = miniSubject([chapter("solo")], []); + const canvas = subjectToCanvas(subject, () => true)!; + const node = textNode(canvas, "solo")!; + const heights = new Map([["solo", node.height ?? 0]]); + expect(relayoutCanvasChapterHeights(canvas, heights)).toBeNull(); + }); + + test("bumps y when a taller height is supplied for a sibling stack", () => { + const subject = miniSubject( + [chapter("top"), chapter("bottom")], + [] + ); + const canvas = subjectToCanvas(subject, () => true)!; + const top = textNode(canvas, "top")!; + const bottom = textNode(canvas, "bottom")!; + + const firstSlug = top.y <= bottom.y ? "top" : "bottom"; + const secondSlug = firstSlug === "top" ? "bottom" : "top"; + const first = textNode(canvas, firstSlug)!; + const second = textNode(canvas, secondSlug)!; + + const taller = (first.height ?? 0) + 80; + const fitted = relayoutCanvasChapterHeights( + canvas, + new Map([ + [firstSlug, taller], + [secondSlug, second.height ?? 0], + ]) + ); + + expect(fitted).not.toBeNull(); + const moved = textNode(fitted!, secondSlug)!; + expect(moved.y).toBeGreaterThanOrEqual(first.y + taller + GAP_Y); + }); +}); diff --git a/frontend/src/lib/catalog-to-canvas.ts b/frontend/src/lib/catalog-to-canvas.ts index 47496c7..2493f2b 100644 --- a/frontend/src/lib/catalog-to-canvas.ts +++ b/frontend/src/lib/catalog-to-canvas.ts @@ -59,13 +59,6 @@ function buildEdgeList( add(edge.from, edge.to, edge.label); } - for (const strand of subject.strands) { - const chapters = strand.chapters.filter((ch) => visible.has(ch.slug)); - for (let i = 0; i < chapters.length - 1; i++) { - add(chapters[i].slug, chapters[i + 1].slug); - } - } - return edges; } @@ -100,64 +93,99 @@ function assertAcyclic(edges: CatalogGraphEdge[], slugs: Iterable): void } } +/** Longest-path rank: level(to) >= level(from) + 1 for every required edge; roots at 0. */ function assignLevels(edges: CatalogGraphEdge[], slugs: string[]): Map { - const inDegree = new Map(); - const adj = new Map(); - - for (const slug of slugs) { - inDegree.set(slug, 0); - adj.set(slug, []); - } - - for (const { from, to } of edges) { - adj.get(from)?.push(to); - inDegree.set(to, (inDegree.get(to) ?? 0) + 1); - } - const levels = new Map(); - const queue: Array<{ slug: string; level: number }> = []; - for (const slug of slugs) { - if ((inDegree.get(slug) ?? 0) === 0) { - queue.push({ slug, level: 0 }); - } + levels.set(slug, 0); } - if (queue.length === 0 && slugs.length > 0) { - queue.push({ slug: slugs[0], level: 0 }); - } - - while (queue.length > 0) { - const { slug, level } = queue.shift()!; - levels.set(slug, Math.max(level, levels.get(slug) ?? 0)); - - for (const next of adj.get(slug) ?? []) { - const nextLevel = level + 1; - levels.set(next, Math.max(nextLevel, levels.get(next) ?? 0)); - inDegree.set(next, (inDegree.get(next) ?? 1) - 1); - if ((inDegree.get(next) ?? 0) === 0) { - queue.push({ slug: next, level: nextLevel }); + for (let pass = 0; pass < slugs.length; pass++) { + let changed = false; + for (const { from, to } of edges) { + const next = (levels.get(from) ?? 0) + 1; + if (next > (levels.get(to) ?? 0)) { + levels.set(to, next); + changed = true; } } - } - - for (const slug of slugs) { - if (!levels.has(slug)) { - levels.set(slug, 0); - } + if (!changed) break; } return levels; } -function yOffsetInColumn(level: number, levelHeights: Map): number { +function yOffsetInColumn(level: number, levelRowHeights: Map): number { let y = 0; for (let l = 0; l < level; l++) { - y += (levelHeights.get(l) ?? 0) + GAP_Y; + y += (levelRowHeights.get(l) ?? 0) + GAP_Y; } return y; } +type ChapterLayoutSlot = { + slug: string; + columnX: number; + level: number; + height: number; + sortIndex: number; +}; + +/** Stack chapters that share a level within one strand column (avoids overlapping cards). */ +function layoutChapterPositions( + slots: ChapterLayoutSlot[] +): Map { + const byColumn = new Map(); + for (const slot of slots) { + const column = byColumn.get(slot.columnX) ?? []; + column.push(slot); + byColumn.set(slot.columnX, column); + } + + const positions = new Map(); + + for (const [columnX, columnSlots] of byColumn) { + const byLevel = new Map(); + for (const slot of columnSlots) { + const levelSlots = byLevel.get(slot.level) ?? []; + levelSlots.push(slot); + byLevel.set(slot.level, levelSlots); + } + + const levelRowHeights = new Map(); + for (const [level, levelSlots] of byLevel) { + levelSlots.sort((a, b) => a.sortIndex - b.sortIndex); + let rowHeight = 0; + for (let i = 0; i < levelSlots.length; i++) { + rowHeight += levelSlots[i].height; + if (i < levelSlots.length - 1) rowHeight += GAP_Y; + } + levelRowHeights.set(level, rowHeight); + } + + for (const [level, levelSlots] of byLevel) { + levelSlots.sort((a, b) => a.sortIndex - b.sortIndex); + let y = yOffsetInColumn(level, levelRowHeights); + for (const slot of levelSlots) { + positions.set(slot.slug, { x: columnX, y, height: slot.height }); + y += slot.height + GAP_Y; + } + } + } + + return positions; +} + +function buildStrandChapterOrder(subject: CatalogSubject): Map { + const order = new Map(); + for (const strand of subject.strands) { + strand.chapters.forEach((chapter, index) => { + order.set(chapter.slug, index); + }); + } + return order; +} + function layoutNodes( subject: CatalogSubject, visible: Map, @@ -181,34 +209,23 @@ function layoutNodes( subject.id ); - const levelMaxHeightByStrand = new Map>(); - for (const [slug] of visible) { - const strandId = slugToStrandId.get(slug); - if (!strandId) continue; - const level = levels.get(slug) ?? 0; - const height = heightBySlug.get(slug) ?? 0; - if (!levelMaxHeightByStrand.has(strandId)) { - levelMaxHeightByStrand.set(strandId, new Map()); - } - const levelHeights = levelMaxHeightByStrand.get(strandId)!; - levelHeights.set(level, Math.max(levelHeights.get(level) ?? 0, height)); - } - - const positions = new Map(); + const strandChapterOrder = buildStrandChapterOrder(subject); + const slots: ChapterLayoutSlot[] = []; for (const [slug] of visible) { const strandId = slugToStrandId.get(slug); - const columnX = strandId ? strandColumnX.get(strandId) : 0; - const level = levels.get(slug) ?? 0; - const levelHeights = strandId ? levelMaxHeightByStrand.get(strandId) : undefined; - const height = heightBySlug.get(slug) ?? 0; - positions.set(slug, { - x: columnX ?? 0, - y: yOffsetInColumn(level, levelHeights ?? new Map()), - height, + const columnX = strandId ? (strandColumnX.get(strandId) ?? 0) : 0; + slots.push({ + slug, + columnX, + level: levels.get(slug) ?? 0, + height: heightBySlug.get(slug) ?? 0, + sortIndex: strandChapterOrder.get(slug) ?? 0, }); } + const positions = layoutChapterPositions(slots); + const groups: JSONCanvasNode[] = []; const nodes: JSONCanvasNode[] = []; const colors = canvasNodeColors(); @@ -281,7 +298,7 @@ function toCanvasEdges(edgeList: CatalogGraphEdge[]): JSONCanvasEdge[] { function edgesFromCanvas(canvas: JSONCanvas): CatalogGraphEdge[] { const edges: CatalogGraphEdge[] = []; - for (const edge of canvas.edges) { + for (const edge of canvas.edges ?? []) { const from = chapterSlugFromNodeId(edge.fromNode); const to = chapterSlugFromNodeId(edge.toNode); if (!from || !to) continue; @@ -295,7 +312,8 @@ export function relayoutCanvasChapterHeights( canvas: JSONCanvas, heightBySlug: Map ): JSONCanvas | null { - const chapterNodes = canvas.nodes.filter( + const canvasNodes = canvas.nodes ?? []; + const chapterNodes = canvasNodes.filter( (node) => node.type === "text" && node.id.startsWith("chapter-") ); if (chapterNodes.length === 0) return null; @@ -312,36 +330,35 @@ export function relayoutCanvasChapterHeights( mergedHeights.set(slug, Math.max(node.height ?? 0, heightBySlug.get(slug) ?? 0)); } - const levelMaxHeightByColumn = new Map>(); - for (const node of chapterNodes) { - const slug = chapterSlugFromNodeId(node.id); - if (!slug) continue; - const level = levels.get(slug) ?? 0; - const height = mergedHeights.get(slug) ?? 0; - if (!levelMaxHeightByColumn.has(node.x)) { - levelMaxHeightByColumn.set(node.x, new Map()); - } - const levelHeights = levelMaxHeightByColumn.get(node.x)!; - levelHeights.set(level, Math.max(levelHeights.get(level) ?? 0, height)); - } + const slots: ChapterLayoutSlot[] = chapterNodes.map((node, index) => { + const slug = chapterSlugFromNodeId(node.id) ?? ""; + return { + slug, + columnX: node.x, + level: levels.get(slug) ?? 0, + height: mergedHeights.get(slug) ?? node.height ?? 0, + sortIndex: node.y * 1000 + index, + }; + }); + + const positions = layoutChapterPositions(slots); let changed = false; const updatedChapters = chapterNodes.map((node) => { const slug = chapterSlugFromNodeId(node.id); if (!slug) return node; - const level = levels.get(slug) ?? 0; - const levelHeights = levelMaxHeightByColumn.get(node.x) ?? new Map(); - const height = mergedHeights.get(slug) ?? node.height ?? 0; - const y = yOffsetInColumn(level, levelHeights); - if (y !== node.y || height !== node.height) changed = true; - return { ...node, y, height }; + const pos = positions.get(slug); + if (!pos) return node; + const height = pos.height; + if (pos.y !== node.y || height !== node.height) changed = true; + return { ...node, y: pos.y, height }; }); if (!changed) return null; const chapterById = new Map(updatedChapters.map((node) => [node.id, node])); - let nodes = canvas.nodes.map((node) => { + let nodes = canvasNodes.map((node) => { if (node.type === "text" && node.id.startsWith("chapter-")) { return chapterById.get(node.id) ?? node; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ae4f951..29cd0ae 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,6 +13,7 @@ function readConfig() { homeUrl: "/", browseUrl: "/catalog/", aboutUrl: "/about/", + contributingUrl: "/contributing/", githubUrl: "https://github.com/openfreebooks/openfreebooks", }; } @@ -21,6 +22,7 @@ function readConfig() { homeUrl: string; browseUrl: string; aboutUrl: string; + contributingUrl: string; githubUrl: string; }; } @@ -92,4 +94,10 @@ if (catalogRoot) { }); } +if (document.getElementById("contributing-demo-graph") || document.getElementById("contributing-demo-views")) { + void import("./components/contributing-demos").then(({ mountContributingDemos }) => { + mountContributingDemos(); + }); +} + mountFooterYear(); diff --git a/package.json b/package.json index 009cf31..caeafcd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build:js": "vite build", "build": "rm -f themes/openfreebooks/static/main.css public/main.css && bun run build:js && zola build", "dev:js": "vite build --watch", - "scan:size": "bun scripts/scan-output-size.ts" + "scan:size": "bun scripts/scan-output-size.ts", + "test": "bun test frontend/src/lib" }, "dependencies": { "defuddle": "^0.18.1", diff --git a/themes/openfreebooks/sass/_catalog.scss b/themes/openfreebooks/sass/_catalog.scss index dc5c3dd..64b6f7c 100644 --- a/themes/openfreebooks/sass/_catalog.scss +++ b/themes/openfreebooks/sass/_catalog.scss @@ -607,6 +607,12 @@ a.catalog-chapter-card:focus-visible .catalog-chapter-card__number { border-color: rgba(244, 63, 94, 0.25); } +.catalog-badge--extension { + color: #9a3412; + background: rgba(234, 88, 12, 0.1); + border-color: rgba(234, 88, 12, 0.28); +} + .catalog-badge--default { color: var(--soft-ink); background: var(--paper); diff --git a/themes/openfreebooks/sass/_contributing.scss b/themes/openfreebooks/sass/_contributing.scss new file mode 100644 index 0000000..1b6dc83 --- /dev/null +++ b/themes/openfreebooks/sass/_contributing.scss @@ -0,0 +1,378 @@ +.contributing-page { + padding: 54px 0 80px; + border-top: 1px solid var(--line); +} + +.contributing-page__title { + margin-bottom: 16px; +} + +.contributing-page__lead { + margin: 0 0 36px; + max-width: var(--prose-width); + color: var(--soft-ink); + font-size: 1.05rem; + line-height: 1.72; +} + +.contributing-page__lead a { + color: var(--accent); + font-weight: 600; + text-decoration: none; +} + +.contributing-page__lead a:hover, +.contributing-page__lead a:focus-visible { + text-decoration: underline; + text-underline-offset: 3px; +} + +.contributing-quick-nav { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 14px; + margin: 0 0 48px; +} + +.contributing-quick-nav__card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 18px 20px; + border: 1px solid var(--card-border); + border-radius: 12px; + background: var(--card-bg); + text-decoration: none; + color: inherit; + box-shadow: 0 10px 28px var(--shadow-soft); + transition: border-color 200ms ease, transform 200ms ease; +} + +.contributing-quick-nav__card:hover, +.contributing-quick-nav__card:focus-visible { + border-color: var(--accent); + transform: translateY(-2px); +} + +.contributing-quick-nav__label { + font-family: var(--mono); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +.contributing-quick-nav__text { + font-size: 0.95rem; + line-height: 1.45; + color: var(--soft-ink); +} + +.contributing-section { + margin-bottom: 56px; + scroll-margin-top: 96px; +} + +.contributing-section--cta { + margin-bottom: 0; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.contributing-section__title { + margin: 0 0 16px; + font-family: var(--font-heading); + font-size: clamp(1.45rem, 2.4vw, 2rem); + font-weight: 400; + line-height: 1.2; +} + +.contributing-section__subtitle { + margin: 24px 0 8px; + font-family: var(--font-heading); + font-size: 1.2rem; + font-weight: 400; +} + +.contributing-prose { + max-width: var(--prose-width); + color: var(--soft-ink); + font-size: 1rem; + line-height: 1.72; +} + +.contributing-prose p, +.contributing-prose ol, +.contributing-prose ul { + margin: 0 0 1rem; +} + +.contributing-prose li + li { + margin-top: 0.35rem; +} + +.contributing-prose a { + color: var(--accent); + font-weight: 600; + text-decoration: none; +} + +.contributing-prose a:hover, +.contributing-prose a:focus-visible { + text-decoration: underline; + text-underline-offset: 3px; +} + +.contributing-code-block { + margin: 0 0 1.25rem; + padding: 16px 18px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--paper); +} + +.contributing-code-block code { + font-family: var(--mono); + font-size: 0.82rem; + line-height: 1.55; + color: var(--ink); +} + +.contributing-demo-slot { + margin-top: 28px; +} + +.contributing-demo__hint { + margin: 0 0 14px; + font-size: 0.9rem; + color: var(--muted); +} + +.contributing-card-demo { + margin-top: 28px; + padding: 22px 24px; + border: 1px solid var(--card-border); + border-radius: 12px; + background: var(--card-bg); + max-width: 22rem; +} + +.contributing-card-demo .catalog-chapter-card--map { + margin: 0; + pointer-events: auto; +} + +.contributing-page__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; +} + +/* Interactive graph demo */ +.contributing-graph-demo__layout { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 28px 20px; + border: 1px solid var(--card-border); + border-radius: 12px; + background: var(--paper); +} + +.contributing-graph-demo__node { + min-width: 10rem; + padding: 10px 14px; + border: 1px solid var(--card-border); + border-radius: 10px; + background: var(--card-bg); + font-family: var(--font-heading); + font-size: 0.95rem; + color: var(--ink); + cursor: pointer; + transition: + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease; +} + +.contributing-graph-demo__node:hover, +.contributing-graph-demo__node:focus-visible { + border-color: var(--accent); + outline: none; +} + +.contributing-graph-demo__node.is-lit { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(154, 107, 46, 0.2); + background: rgba(154, 107, 46, 0.08); +} + +.contributing-graph-demo__row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; +} + +.contributing-graph-demo__fork, +.contributing-graph-demo__join { + display: flex; + align-items: center; + justify-content: center; + gap: 48px; + font-size: 1.1rem; + color: var(--muted); + line-height: 1; +} + +/* List / map view demo */ +.contributing-views-demo { + border: 1px solid var(--card-border); + border-radius: 12px; + overflow: hidden; + background: var(--card-bg); +} + +.contributing-views-demo__tabs { + display: flex; + border-bottom: 1px solid var(--line); +} + +.contributing-views-demo__tab { + flex: 1; + padding: 12px 16px; + border: none; + background: transparent; + font-family: var(--mono); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + cursor: pointer; + transition: color 180ms ease, background-color 180ms ease; +} + +.contributing-views-demo__tab.is-active { + color: var(--accent); + background: rgba(154, 107, 46, 0.08); +} + +.contributing-views-demo__tab:hover, +.contributing-views-demo__tab:focus-visible { + color: var(--ink); + outline: none; +} + +.contributing-views-demo__panel { + padding: 22px 24px 18px; + min-height: 12rem; +} + +.contributing-views-demo__list { + list-style: none; + margin: 0; + padding: 0; +} + +.contributing-views-demo__list li { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + padding: 14px 16px; + border: 1px solid var(--card-border); + border-radius: 10px; + background: var(--paper); +} + +.contributing-views-demo__list-num { + font-family: var(--mono); + font-size: 0.75rem; + font-weight: 700; + color: var(--muted); +} + +.contributing-views-demo__list-title { + flex: 1; + font-family: var(--font-heading); + font-size: 1rem; +} + +.contributing-views-demo__list-badge { + padding: 4px 8px; + border-radius: 6px; + font-family: var(--mono); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #1d4ed8; + background: rgba(59, 130, 246, 0.12); +} + +.contributing-views-demo__list-badge.is-muted { + color: var(--muted); + background: rgba(118, 118, 118, 0.12); +} + +.contributing-views-demo__map-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.contributing-views-demo__strand { + padding: 6px 12px; + border-radius: 8px; + font-family: var(--mono); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink); + background: rgba(154, 107, 46, 0.15); +} + +.contributing-views-demo__map-node { + min-width: 9rem; + padding: 8px 12px; + border: 1px solid var(--card-border); + border-radius: 8px; + background: var(--paper); + font-size: 0.82rem; + text-align: center; +} + +.contributing-views-demo__map-node.is-small { + min-width: 7rem; + font-size: 0.78rem; +} + +.contributing-views-demo__map-arrow, +.contributing-views-demo__map-fork { + color: var(--muted); + font-size: 1rem; +} + +.contributing-views-demo__map-row { + display: flex; + gap: 20px; +} + +.contributing-views-demo__caption { + margin: 16px 0 0; + font-size: 0.88rem; + color: var(--muted); + line-height: 1.5; +} + +.contributing-views-demo__caption code { + font-family: var(--mono); + font-size: 0.8rem; +} diff --git a/themes/openfreebooks/sass/main.scss b/themes/openfreebooks/sass/main.scss index 20f2955..9f3f935 100644 --- a/themes/openfreebooks/sass/main.scss +++ b/themes/openfreebooks/sass/main.scss @@ -6,6 +6,7 @@ @import "why-sections"; @import "contributors"; @import "credits"; +@import "contributing"; @import "about-manifest"; @import "book"; @import "catalog"; diff --git a/themes/openfreebooks/static/js/bundle.js b/themes/openfreebooks/static/js/bundle.js index 6464108..2c112cf 100644 --- a/themes/openfreebooks/static/js/bundle.js +++ b/themes/openfreebooks/static/js/bundle.js @@ -1,2 +1,2 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["js/quadratic-explorer-BiORaq7B.js","js/katex-Dc8nsIP1.js","js/render-math-H0D_R-AC.js","js/katex.css"])))=>i.map(i=>d[i]); -const Ge="modulepreload",Ke=function(e){return"/"+e},ve={},J=function(t,n,r){let s=Promise.resolve();if(n&&n.length>0){let l=function(a){return Promise.all(a.map(f=>Promise.resolve(f).then(u=>({status:"fulfilled",value:u}),u=>({status:"rejected",reason:u}))))};document.getElementsByTagName("link");const i=document.querySelector("meta[property=csp-nonce]"),c=(i==null?void 0:i.nonce)||(i==null?void 0:i.getAttribute("nonce"));s=l(n.map(a=>{if(a=Ke(a),a in ve)return;ve[a]=!0;const f=a.endsWith(".css"),u=f?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${a}"]${u}`))return;const m=document.createElement("link");if(m.rel=f?"stylesheet":Ge,f||(m.as="script"),m.crossOrigin="",m.href=a,c&&m.setAttribute("nonce",c),document.head.appendChild(m),f)return new Promise((h,v)=>{m.addEventListener("load",h),m.addEventListener("error",()=>v(new Error(`Unable to preload CSS for ${a}`)))})}))}function o(l){const i=new Event("vite:preloadError",{cancelable:!0});if(i.payload=l,window.dispatchEvent(i),!i.defaultPrevented)throw l}return s.then(l=>{for(const i of l||[])i.status==="rejected"&&o(i.reason);return t().catch(o)})},d={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return we(this.context.count)},getNextContextId(){return we(this.context.count++)}};function we(e){const t=String(e),n=t.length-1;return d.context.id+(n?String.fromCharCode(96+n):"")+t}function D(e){d.context=e}const Oe=!1,Ye=(e,t)=>e===t,We=Symbol("solid-track"),z={equals:Ye};let Be=Me;const R=1,ee=2,Ne={owned:null,cleanups:null,context:null,owner:null},fe={};var g=null;let X=null,Je=null,b=null,A=null,B=null,se=0;function Y(e,t){const n=b,r=g,s=e.length===0,o=t===void 0?r:t,l=s?Ne:{owned:null,cleanups:null,context:o?o.context:null,owner:o},i=s?e:()=>e(()=>P(()=>Q(l)));g=l,b=null;try{return U(i,!0)}finally{b=n,g=r}}function T(e,t){t=t?Object.assign({},z,t):z;const n={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},r=s=>(typeof s=="function"&&(s=s(n.value)),qe(n,s));return[Re.bind(n),r]}function Ee(e,t,n){const r=le(e,t,!0,R);G(r)}function M(e,t,n){const r=le(e,t,!1,R);G(r)}function Qe(e,t,n){Be=it;const r=le(e,t,!1,R),s=H&&pe(H);s&&(r.suspense=s),r.user=!0,B?B.push(r):G(r)}function L(e,t,n){n=n?Object.assign({},z,n):z;const r=le(e,t,!0,0);return r.observers=null,r.observerSlots=null,r.comparator=n.equals||void 0,G(r),Re.bind(r)}function Xe(e){return e&&typeof e=="object"&&"then"in e}function Ze(e,t,n){let r,s,o;r=!0,s=e,o={};let l=null,i=fe,c=null,a=!1,f="initialValue"in o,u=typeof r=="function"&&L(r);const m=new Set,[h,v]=(o.storage||T)(o.initialValue),[E,p]=T(void 0),[y,x]=T(void 0,{equals:!1}),[C,_]=T(f?"ready":"unresolved");d.context&&(c=d.getNextContextId(),o.ssrLoadFrom==="initial"?i=o.initialValue:d.load&&d.has(c)&&(i=d.load(c)));function k(w,S,N,$){return l===w&&(l=null,$!==void 0&&(f=!0),(w===i||S===i)&&o.onHydrated&&queueMicrotask(()=>o.onHydrated($,{value:S})),i=fe,ce(S,N)),S}function ce(w,S){U(()=>{S===void 0&&v(()=>w),_(S!==void 0?"errored":f?"ready":"unresolved"),p(S);for(const N of m.keys())N.decrement();m.clear()},!1)}function ue(){const w=H&&pe(H),S=h(),N=E();if(N!==void 0&&!l)throw N;return b&&!b.user&&w&&Ee(()=>{y(),l&&(w.resolved&&X||m.has(w)||(w.increment(),m.add(w)))}),S}function ae(w=!0){if(w!==!1&&a)return;a=!1;const S=u?u():r;if(S==null||S===!1){k(l,P(h));return}let N;const $=i!==fe?i:P(()=>{try{return s(S,{value:h(),refetching:w})}catch(K){N=K}});if(N!==void 0){k(l,void 0,Z(N),S);return}else if(!Xe($))return k(l,$,void 0,S),$;return l=$,"v"in $?($.s===1?k(l,$.v,void 0,S):k(l,void 0,Z($.v),S),$):(a=!0,queueMicrotask(()=>a=!1),U(()=>{_(f?"refreshing":"pending"),x()},!1),$.then(K=>k($,K,void 0,S),K=>k($,void 0,Z(K),S)))}Object.defineProperties(ue,{state:{get:()=>C()},error:{get:()=>E()},loading:{get(){const w=C();return w==="pending"||w==="refreshing"}},latest:{get(){if(!f)return ue();const w=E();if(w&&!l)throw w;return h()}}});let ye=g;return u?Ee(()=>(ye=g,ae(!1))):ae(!1),[ue,{refetch:w=>et(ye,()=>ae(w)),mutate:v}]}function P(e){if(b===null)return e();const t=b;b=null;try{return e()}finally{b=t}}function oe(e){Qe(()=>P(e))}function ie(e){return g===null||(g.cleanups===null?g.cleanups=[e]:g.cleanups.push(e)),e}function ze(){return g}function et(e,t){const n=g,r=b;g=e,b=null;try{return U(t,!0)}catch(s){me(s)}finally{g=n,b=r}}const[Ut,Ft]=T(!1);function tt(e){B.push.apply(B,e),e.length=0}function De(e,t){const n=Symbol("context");return{id:n,Provider:lt(n),defaultValue:e}}function pe(e){let t;return g&&g.context&&(t=g.context[e.id])!==void 0?t:e.defaultValue}function nt(e){const t=L(e),n=L(()=>he(t()));return n.toArray=()=>{const r=n();return Array.isArray(r)?r:r!=null?[r]:[]},n}let H;function rt(){return H||(H=De())}function Re(){if(this.sources&&this.state)if(this.state===R)G(this);else{const e=A;A=null,U(()=>ne(this),!1),A=e}if(b){const e=this.observers;if(!e||e[e.length-1]!==b){const t=e?e.length:0;b.sources?(b.sources.push(this),b.sourceSlots.push(t)):(b.sources=[this],b.sourceSlots=[t]),e?(e.push(b),this.observerSlots.push(b.sources.length-1)):(this.observers=[b],this.observerSlots=[b.sources.length-1])}}return this.value}function qe(e,t,n){let r=e.value;return(!e.comparator||!e.comparator(r,t))&&(e.value=t,e.observers&&e.observers.length&&U(()=>{for(let s=0;s1e6)throw A=[],new Error},!1)),t}function G(e){if(!e.fn)return;Q(e);const t=se;st(e,e.value,t)}function st(e,t,n){let r;const s=g,o=b;b=g=e;try{r=e.fn(t)}catch(l){return e.pure&&(e.state=R,e.owned&&e.owned.forEach(Q),e.owned=null),e.updatedAt=n+1,me(l)}finally{b=o,g=s}(!e.updatedAt||e.updatedAt<=n)&&(e.updatedAt!=null&&"observers"in e?qe(e,r):e.value=r,e.updatedAt=n)}function le(e,t,n,r=R,s){const o={fn:e,state:r,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:g,context:g?g.context:null,pure:n};return g===null||g!==Ne&&(g.owned?g.owned.push(o):g.owned=[o]),o}function te(e){if(e.state===0)return;if(e.state===ee)return ne(e);if(e.suspense&&P(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;n--)if(e=t[n],e.state===R)G(e);else if(e.state===ee){const r=A;A=null,U(()=>ne(e,t[0]),!1),A=r}}function U(e,t){if(A)return e();let n=!1;t||(A=[]),B?n=!0:B=[],se++;try{const r=e();return ot(n),r}catch(r){n||(B=null),A=null,me(r)}}function ot(e){if(A&&(Me(A),A=null),e)return;const t=B;B=null,t.length&&U(()=>Be(t),!1)}function Me(e){for(let t=0;t=0;t--)Q(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)Q(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function Z(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function me(e,t=g){throw Z(e)}function he(e){if(typeof e=="function"&&!e.length)return he(e());if(Array.isArray(e)){const t=[];for(let n=0;ns=P(()=>(g.context={...g.context,[e]:r.value},nt(()=>r.children))),void 0),s}}const ct=Symbol("fallback");function Se(e){for(let t=0;t1?[]:null;return ie(()=>Se(o)),()=>{let c=e()||[],a=c.length,f,u;return c[We],P(()=>{let h,v,E,p,y,x,C,_,k;if(a===0)l!==0&&(Se(o),o=[],r=[],s=[],l=0,i&&(i=[])),n.fallback&&(r=[ct],s[0]=Y(ce=>(o[0]=ce,n.fallback())),l=1);else if(l===0){for(s=new Array(a),u=0;u=x&&_>=x&&r[C]===c[_];C--,_--)E[_]=s[C],p[_]=o[C],i&&(y[_]=i[C]);for(h=new Map,v=new Array(_+1),u=_;u>=x;u--)k=c[u],f=h.get(k),v[u]=f===void 0?-1:f,h.set(k,u);for(f=x;f<=C;f++)k=r[f],u=h.get(k),u!==void 0&&u!==-1?(E[u]=s[f],p[u]=o[f],i&&(y[u]=i[f]),u=v[u],h.set(k,u)):o[f]();for(u=x;ue(t||{}))}function Vt(e){let t,n;const r=s=>{const o=d.context;if(o){const[i,c]=T();d.count||(d.count=0),d.count++,(n||(n=e())).then(a=>{!d.done&&D(o),d.count--,c(()=>a.default),D()}),t=i}else if(!t){const[i]=Ze(()=>(n||(n=e())).then(c=>c.default));t=i}let l;return L(()=>(l=t())?P(()=>{if(!o||d.done)return l(s);const i=d.context;D(o);const c=l(s);return D(i),c}):"")};return r.preload=()=>n||((n=e()).then(s=>t=()=>s.default),n),r}const at=e=>`Stale read from <${e}>.`;function xe(e){const t="fallback"in e&&{fallback:()=>e.fallback};return L(ut(()=>e.each,e.children,t||void 0))}function ft(e){const t=e.keyed,n=L(()=>e.when,void 0,void 0),r=t?n:L(n,void 0,{equals:(s,o)=>!s==!o});return L(()=>{const s=r();if(s){const o=e.children;return typeof o=="function"&&o.length>0?P(()=>o(t?s:()=>{if(!P(r))throw at("Show");return n()})):o}return e.fallback},void 0,void 0)}const dt=De();function jt(e){let t=0,n,r,s,o,l;const[i,c]=T(!1),a=rt(),f={increment:()=>{++t===1&&c(!0)},decrement:()=>{--t===0&&c(!1)},inFallback:i,effects:[],resolved:!1},u=ze();if(d.context&&d.load){const v=d.getContextId();let E=d.load(v);if(E&&(typeof E!="object"||E.s!==1?s=E:d.gather(v)),s&&s!=="$$f"){const[p,y]=T(void 0,{equals:!1});o=p,s.then(()=>{if(d.done)return y();d.gather(v),D(r),y(),D()},x=>{l=x,y()})}}const m=pe(dt);m&&(n=m.register(f.inFallback));let h;return ie(()=>h&&h()),I(a.Provider,{value:f,get children(){return L(()=>{if(l)throw l;if(r=d.context,o)return o(),o=void 0;r&&s==="$$f"&&D();const v=L(()=>e.children);return L(E=>{const p=f.inFallback(),{showContent:y=!0,showFallback:x=!0}=n?n():{};if((!p||s&&s!=="$$f")&&y)return f.resolved=!0,h&&h(),h=r=s=void 0,tt(f.effects),v();if(x)return h?E:Y(C=>(h=C,r&&(D({id:r.id+"F",count:0}),r=void 0),e.fallback),u)})})}})}const Ht=e=>L(()=>e());function ht(e,t,n){let r=n.length,s=t.length,o=r,l=0,i=0,c=t[s-1].nextSibling,a=null;for(;lf-i){const v=t[l];for(;i{s=o,t===document?e():O(t,e(),t.firstChild?null:void 0,n)},r.owner),()=>{s(),t.textContent=""}}function V(e,t,n,r){let s;const o=()=>{const i=document.createElement("template");return i.innerHTML=e,i.content.firstChild},l=()=>(s||(s=o())).cloneNode(!0);return l.cloneNode=l,l}function Fe(e,t=window.document){const n=t[Ce]||(t[Ce]=new Set);for(let r=0,s=e.length;re(t,n))}function O(e,t,n,r){if(n!==void 0&&!r&&(r=[]),typeof t!="function")return re(e,t,r,n);M(s=>re(e,t(),s,n),r)}function be(e){return!!d.context&&!d.done&&(!e||e.isConnected)}function ke(e,t,n){const r=t.trim().split(/\s+/);for(let s=0,o=r.length;sa===e))return;let t=e.target;const n=`$$${e.type}`,r=e.target,s=e.currentTarget,o=c=>Object.defineProperty(e,"target",{configurable:!0,value:c}),l=()=>{const c=t[n];if(c&&!t.disabled){const a=t[`${n}Data`];if(a!==void 0?c.call(t,a,e):c.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&o(t.host),!0},i=()=>{for(;l()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),d.registry&&!d.done&&(d.done=_$HY.done=!0),e.composedPath){const c=e.composedPath();o(c[0]);for(let a=0;a{let c=t();for(;typeof c=="function";)c=c();n=re(e,c,n,r)}),()=>n;if(Array.isArray(t)){const c=[],a=n&&Array.isArray(n);if(ge(c,t,n,s))return M(()=>n=re(e,c,n,r,!0)),()=>n;if(o){if(!c.length)return n;if(r===void 0)return n=[...e.childNodes];let f=c[0];if(f.parentNode!==e)return n;const u=[f];for(;(f=f.nextSibling)!==r;)u.push(f);return n=u}if(c.length===0){if(n=j(e,n,r),i)return n}else a?n.length===0?Ae(e,c,r):ht(e,n,c):(n&&j(e),Ae(e,c));n=c}else if(t.nodeType){if(o&&t.parentNode)return n=i?[t]:t;if(Array.isArray(n)){if(i)return n=j(e,n,r,t);j(e,n,null,t)}else n==null||n===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);n=t}}return n}function ge(e,t,n,r){let s=!1;for(let o=0,l=t.length;o=0;l--){const i=t[l];if(s!==i){const c=i.parentNode===e;!o&&!l?c?e.replaceChild(s,i):e.insertBefore(s,n):c&&i.remove()}else o=!0}}else e.insertBefore(s,n);return[s]}const Ve="openfreebooks-theme";function mt(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function bt(){const e=localStorage.getItem(Ve);return e==="light"||e==="dark"?e:null}function je(){return bt()??mt()}function He(e){document.documentElement.setAttribute("data-theme",e)}function yt(e){localStorage.setItem(Ve,e),He(e)}function vt(){He(je())}var wt=V("