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("