diff --git a/README.md b/README.md
index 93a93575..cab0bc4a 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,23 @@ Validate structure + referential integrity:
node scripts/validate.mjs
```
+## Learning Map explorer
+
+This repository includes a dependency-free browser explorer that can track learning progress privately on one device. It supports multiple child profiles, `learning` and `known` states, evidence-based assessments, subject/age/search filters, and dimming or hiding concepts a child already knows.
+
+Two complementary views share the same local profile:
+
+- **Graph** visualizes all prerequisite relationships and opens the evidence checklist for any concept.
+- **Logbook** summarizes known, learning, assessed, and not-yet-started concepts; suggests next topics whose hard prerequisites are complete; shows subject journeys; and keeps a chronological activity trail.
+
+```bash
+npm run serve
+```
+
+Then open [http://localhost:4173/explorer/](http://localhost:4173/explorer/). A child's name is requested once and substituted automatically into every assessment prompt. Profiles, progress, and the most recent 500 logbook activities are versioned and stored only in the browser's `localStorage`; the explorer makes no network requests after loading the taxonomy JSON. Use **Manage** beside the profile picker to explicitly edit a name, delete a profile, or reset its progress and activity trail.
+
+The explorer is intentionally a static ES-module application with no build step. Its persistence layer lives in `explorer/src/profile-store.js`, independently of the graph renderer, so a server-backed profile adapter can replace it later without changing the taxonomy data or graph interaction.
+
## License
This dataset is **multi-licensed** — read this before you use or redistribute it.
diff --git a/explorer/index.html b/explorer/index.html
new file mode 100644
index 00000000..ba40c723
--- /dev/null
+++ b/explorer/index.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+ Marble Learning Map
+
+
+
+
+
+
+
+
+
+
+
+ Learning overview
+ Loading the taxonomy…
+
+
+
+
+
+ Not started
+ Learning
+ Known
+ Assessed
+
+
+
+
+
+
+ +
+ −
+ ⌂
+
+
Drag to move · Scroll to zoom · Select a dot to inspect
+
+ No concepts match these filters
+ Clear filters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/explorer/src/app.js b/explorer/src/app.js
new file mode 100644
index 00000000..3f79c537
--- /dev/null
+++ b/explorer/src/app.js
@@ -0,0 +1,656 @@
+import { GraphView, SUBJECT_COLORS } from "./graph-view.js";
+import { filterLogbookTopics, getProgressStats, getRecommendedTopics, getSubjectJourneys } from "./logbook.js";
+import { ProfileStore } from "./profile-store.js";
+import { assessmentPromptFor, loadTaxonomy } from "./taxonomy.js";
+
+const elements = Object.fromEntries(
+ [
+ "profile-select", "add-profile", "manage-profile", "search", "search-results", "subject-filter", "age-filter", "mastery-filter",
+ "profile-greeting", "progress-summary", "progress-bar", "graph", "zoom-in", "zoom-out", "reset-view",
+ "empty-state", "clear-filters", "details-empty", "details-content", "profile-dialog", "dialog-title",
+ "profile-name", "profile-name-field", "save-profile", "profile-danger", "danger-copy", "reset-progress", "delete-profile", "toast",
+ "workspace", "graph-mode", "logbook-mode", "logbook-view", "logbook-content", "profile-dialog-actions",
+ "managed-profile-summary", "managed-profile-name", "managed-profile-avatar", "edit-profile-name", "name-once-note", "celebration",
+ ].map((id) => [id, document.getElementById(id)]),
+);
+
+const store = new ProfileStore();
+let taxonomy;
+let graph;
+let selectedId = null;
+let dialogMode = "add";
+let toastTimer;
+let currentView = "graph";
+let logbookFilter = "all";
+let logbookSearch = "";
+let logbookLimit = 60;
+
+function activeProgress() {
+ return store.activeProfile?.progress ?? {};
+}
+
+function showToast(message) {
+ elements.toast.textContent = message;
+ elements.toast.classList.add("visible");
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => elements.toast.classList.remove("visible"), 2600);
+}
+
+function makeElement(tag, { className, text, attrs = {} } = {}) {
+ const element = document.createElement(tag);
+ if (className) element.className = className;
+ if (text !== undefined) element.textContent = text;
+ for (const [name, value] of Object.entries(attrs)) element.setAttribute(name, value);
+ return element;
+}
+
+function topicButton(topic, relationship) {
+ const button = makeElement("button", { className: "relationship-item", attrs: { type: "button" } });
+ const copy = makeElement("span");
+ copy.append(makeElement("strong", { text: topic.name }), makeElement("small", { text: `${topic.subject} · ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` }));
+ button.append(copy, makeElement("span", { className: "relationship-arrow", text: relationship === "prerequisite" ? "←" : "→" }));
+ button.addEventListener("click", () => selectTopic(topic.id));
+ return button;
+}
+
+function renderProfiles() {
+ const state = store.state;
+ elements["profile-select"].replaceChildren();
+ if (!state.profiles.length) {
+ elements["profile-select"].append(new Option("No child profile", ""));
+ } else {
+ for (const profile of state.profiles) elements["profile-select"].append(new Option(profile.name, profile.id));
+ elements["profile-select"].value = state.activeProfileId;
+ }
+ elements["manage-profile"].disabled = !state.activeProfileId;
+ renderSummary();
+ renderDetails();
+ renderLogbook();
+ applyFilters();
+}
+
+function renderSummary() {
+ if (!taxonomy) return;
+ const profile = store.activeProfile;
+ const stats = getProgressStats(taxonomy.topics, profile?.progress ?? {});
+ const percent = Math.round((stats.known / stats.total) * 100);
+ elements["profile-greeting"].textContent = profile ? `${profile.name}'s learning map` : "Learning overview";
+ elements["progress-summary"].textContent = profile
+ ? `${stats.known} known · ${stats.learning} learning · ${stats.assessed} assessed · ${stats.notStarted} not started`
+ : "Create a private child profile to start tracking progress.";
+ elements["progress-bar"].style.width = `${percent}%`;
+ const track = elements["progress-bar"].parentElement;
+ track.setAttribute("aria-valuenow", String(percent));
+ track.setAttribute("aria-valuetext", `${stats.known} of ${stats.total} concepts known`);
+}
+
+function filters() {
+ return {
+ query: elements.search.value.trim().toLocaleLowerCase(),
+ subject: elements["subject-filter"].value,
+ age: Number(elements["age-filter"].value) || null,
+ masteryMode: elements["mastery-filter"].value,
+ };
+}
+
+function renderSearchResults() {
+ if (!taxonomy) return;
+ const query = elements.search.value.trim().toLocaleLowerCase();
+ const results = elements["search-results"];
+ results.replaceChildren();
+ if (query.length < 2 || document.activeElement !== elements.search) {
+ results.hidden = true;
+ elements.search.setAttribute("aria-expanded", "false");
+ return;
+ }
+
+ const matches = taxonomy.topics
+ .filter((topic) => `${topic.name} ${topic.domain} ${topic.description}`.toLocaleLowerCase().includes(query))
+ .sort((left, right) => {
+ const leftStarts = left.name.toLocaleLowerCase().startsWith(query) ? 0 : 1;
+ const rightStarts = right.name.toLocaleLowerCase().startsWith(query) ? 0 : 1;
+ return leftStarts - rightStarts || right.centrality - left.centrality || left.name.localeCompare(right.name);
+ })
+ .slice(0, 8);
+
+ if (!matches.length) {
+ results.append(makeElement("p", { text: "No matching concepts" }));
+ } else {
+ for (const topic of matches) {
+ const button = makeElement("button", { attrs: { type: "button", role: "option" } });
+ const dot = makeElement("i");
+ dot.style.background = SUBJECT_COLORS[topic.subject] || "#92a3bb";
+ const copy = makeElement("span");
+ copy.append(makeElement("strong", { text: topic.name }), makeElement("small", { text: `${topic.subject} · ${topic.domain}` }));
+ button.append(dot, copy);
+ button.addEventListener("mousedown", (event) => event.preventDefault());
+ button.addEventListener("click", () => {
+ results.hidden = true;
+ elements.search.setAttribute("aria-expanded", "false");
+ selectTopic(topic.id);
+ });
+ results.append(button);
+ }
+ }
+ results.hidden = false;
+ elements.search.setAttribute("aria-expanded", "true");
+}
+
+const SUBJECT_GLYPHS = {
+ Computing: "⌘",
+ English: "Aa",
+ History: "⌛",
+ "Learning to Learn": "✦",
+ "Life Skills": "☀",
+ Mathematics: "π",
+ "Personal & Social Development": "♥",
+ Science: "⚗",
+};
+
+function celebrate() {
+ if (globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
+ const container = elements.celebration;
+ container.replaceChildren();
+ const colors = ["#7df0bd", "#fff3a6", "#7c6df2", "#eb6f92", "#4e83e6"];
+ for (let index = 0; index < 24; index += 1) {
+ const piece = makeElement("i");
+ piece.style.setProperty("--x", `${(Math.random() - 0.5) * 520}px`);
+ piece.style.setProperty("--y", `${-120 - Math.random() * 330}px`);
+ piece.style.setProperty("--r", `${Math.random() * 720 - 360}deg`);
+ piece.style.setProperty("--delay", `${Math.random() * 0.16}s`);
+ piece.style.background = colors[index % colors.length];
+ container.append(piece);
+ }
+ container.classList.remove("playing");
+ requestAnimationFrame(() => container.classList.add("playing"));
+ setTimeout(() => container.replaceChildren(), 1400);
+}
+
+function switchView(view) {
+ currentView = view;
+ const logbook = view === "logbook";
+ elements.workspace.classList.toggle("logbook-mode", logbook);
+ elements["logbook-view"].hidden = !logbook;
+ elements["graph-mode"].classList.toggle("active", !logbook);
+ elements["graph-mode"].setAttribute("aria-pressed", String(!logbook));
+ elements["logbook-mode"].classList.toggle("active", logbook);
+ elements["logbook-mode"].setAttribute("aria-pressed", String(logbook));
+ if (logbook) renderLogbook();
+ else requestAnimationFrame(() => graph?.resize());
+}
+
+function openFromLogbook(topicId) {
+ switchView("graph");
+ selectTopic(topicId);
+}
+
+function logbookSection(title, description) {
+ const section = makeElement("section", { className: "logbook-section" });
+ const heading = makeElement("div", { className: "logbook-section-heading" });
+ const copy = makeElement("div");
+ copy.append(makeElement("h2", { text: title }), makeElement("p", { text: description }));
+ heading.append(copy);
+ section.append(heading);
+ return { section, heading };
+}
+
+function logbookStatus(entry) {
+ if (entry?.assessment?.verified) return { label: "Assessed", className: "assessed", icon: "★" };
+ if (entry?.status === "mastered") return { label: "Known", className: "known", icon: "✓" };
+ if (entry?.status === "learning") return { label: "Learning", className: "learning", icon: "↗" };
+ return { label: "Not yet", className: "not-started", icon: "○" };
+}
+
+function renderLogbook() {
+ if (!taxonomy) return;
+ const content = elements["logbook-content"];
+ content.replaceChildren();
+ const profile = store.activeProfile;
+ if (!profile) {
+ const empty = makeElement("div", { className: "logbook-empty" });
+ empty.append(
+ makeElement("span", { className: "logbook-empty-icon", text: "✦" }),
+ makeElement("h1", { text: "Every adventure needs a learner" }),
+ makeElement("p", { text: "Create one private profile, then the logbook will organize everything they know, are learning, and can try next." }),
+ );
+ const add = makeElement("button", { className: "button primary", text: "Create child profile", attrs: { type: "button" } });
+ add.addEventListener("click", () => openProfileDialog("add"));
+ empty.append(add);
+ content.append(empty);
+ return;
+ }
+
+ const progress = profile.progress;
+ const stats = getProgressStats(taxonomy.topics, progress);
+ const points = stats.known * 10 + stats.assessed * 5 + stats.learning * 2;
+ const level = Math.floor(points / 100) + 1;
+ const levelProgress = points % 100;
+
+ const hero = makeElement("header", { className: "logbook-hero" });
+ const heroCopy = makeElement("div");
+ heroCopy.append(
+ makeElement("p", { className: "eyebrow", text: `${profile.name}'s learning adventure` }),
+ makeElement("h1", { text: "Learning Logbook" }),
+ makeElement("p", { text: `One friendly place to see what ${profile.name} knows, what is in progress, and which adventure could come next.` }),
+ );
+ const levelCard = makeElement("div", { className: "level-card", attrs: { title: "Learning points come from concepts tracked in this browser." } });
+ const levelRing = makeElement("div", { className: "level-ring" });
+ levelRing.style.setProperty("--level-progress", `${levelProgress * 3.6}deg`);
+ levelRing.append(makeElement("strong", { text: String(level) }), makeElement("small", { text: "LEVEL" }));
+ const pointCopy = makeElement("span");
+ pointCopy.append(makeElement("strong", { text: `${points} points` }), makeElement("small", { text: `${100 - levelProgress} to level ${level + 1}` }));
+ levelCard.append(levelRing, pointCopy);
+ hero.append(heroCopy, levelCard);
+ content.append(hero);
+
+ const statsGrid = makeElement("div", { className: "logbook-stats" });
+ for (const [value, label, icon, kind] of [
+ [stats.known, "Known", "✓", "known"],
+ [stats.learning, "Learning", "↗", "learning"],
+ [stats.assessed, "Assessed", "★", "assessed"],
+ [stats.notStarted, "Not yet", "○", "not-started"],
+ ]) {
+ const card = makeElement("button", { className: `logbook-stat ${kind}`, attrs: { type: "button" } });
+ card.append(makeElement("i", { text: icon }), makeElement("strong", { text: String(value) }), makeElement("span", { text: label }));
+ card.addEventListener("click", () => {
+ logbookFilter = kind === "not-started" ? "not-started" : kind;
+ document.getElementById("logbook-ledger")?.scrollIntoView({ behavior: "smooth" });
+ renderLogbook();
+ setTimeout(() => document.getElementById("logbook-ledger")?.scrollIntoView({ behavior: "smooth" }), 0);
+ });
+ statsGrid.append(card);
+ }
+ content.append(statsGrid);
+
+ const recommendations = getRecommendedTopics(taxonomy, progress, 5);
+ const adventure = logbookSection("Next adventures", `A suggested path based on what ${profile.name} is learning and which prerequisites are complete.`);
+ const path = makeElement("div", { className: "adventure-path" });
+ for (const [index, topic] of recommendations.entries()) {
+ const entry = progress[topic.id];
+ const step = makeElement("article", { className: `path-step${entry?.status === "learning" ? " current" : ""}` });
+ const orb = makeElement("button", { className: "path-orb", attrs: { type: "button", "aria-label": `Open ${topic.name}` } });
+ orb.style.setProperty("--subject-color", SUBJECT_COLORS[topic.subject] || "#92a3bb");
+ orb.append(makeElement("span", { text: SUBJECT_GLYPHS[topic.subject] || "✦" }));
+ orb.addEventListener("click", () => openFromLogbook(topic.id));
+ const copy = makeElement("div", { className: "path-copy" });
+ copy.append(
+ makeElement("small", { text: entry?.status === "learning" ? "Continue learning" : index === 0 ? "Suggested next" : `Step ${index + 1}` }),
+ makeElement("h3", { text: topic.name }),
+ makeElement("p", { text: `${topic.subject} · ${topic.domain} · ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` }),
+ );
+ const action = makeElement("button", {
+ className: `button ${entry?.status === "learning" ? "secondary" : "path-action"}`,
+ text: entry?.status === "learning" ? "Continue" : "Start",
+ attrs: { type: "button" },
+ });
+ action.addEventListener("click", () => {
+ if (entry?.status === "learning") return openFromLogbook(topic.id);
+ store.setProgress(topic.id, "learning");
+ showToast(`${profile.name} started ${topic.name}!`);
+ });
+ step.append(orb, copy, action);
+ path.append(step);
+ }
+ adventure.section.append(path);
+ content.append(adventure.section);
+
+ const subjectSection = logbookSection("Subject journeys", "Progress across the whole taxonomy. Open any journey back in the graph.");
+ const journeys = makeElement("div", { className: "subject-journeys" });
+ for (const journey of getSubjectJourneys(taxonomy.topics, progress)) {
+ const percent = Math.round((journey.known / journey.total) * 100);
+ const card = makeElement("button", { className: "journey-card", attrs: { type: "button" } });
+ const icon = makeElement("i", { text: SUBJECT_GLYPHS[journey.subject] || "✦" });
+ icon.style.background = SUBJECT_COLORS[journey.subject] || "#92a3bb";
+ const copy = makeElement("span");
+ copy.append(
+ makeElement("strong", { text: journey.subject }),
+ makeElement("small", { text: `${journey.known} known · ${journey.learning} learning · ${journey.total} total` }),
+ );
+ const bar = makeElement("span", { className: "journey-progress" });
+ const fill = makeElement("i");
+ fill.style.width = `${Math.max(percent, journey.known ? 2 : 0)}%`;
+ fill.style.background = SUBJECT_COLORS[journey.subject] || "#92a3bb";
+ bar.append(fill);
+ card.append(icon, copy, makeElement("b", { text: `${percent}%` }), bar);
+ card.addEventListener("click", () => {
+ elements["subject-filter"].value = journey.subject;
+ elements.search.value = "";
+ applyFilters();
+ switchView("graph");
+ });
+ journeys.append(card);
+ }
+ subjectSection.section.append(journeys);
+ content.append(subjectSection.section);
+
+ const ledger = logbookSection("The complete logbook", "Search every concept or focus on what is known, in progress, assessed, or not started yet.");
+ ledger.section.id = "logbook-ledger";
+ const controls = makeElement("div", { className: "ledger-controls" });
+ const search = makeElement("input", { attrs: { type: "search", placeholder: "Search the logbook…", "aria-label": "Search the logbook" } });
+ search.value = logbookSearch;
+ const statusFilter = makeElement("select", { attrs: { "aria-label": "Logbook status" } });
+ for (const [value, label] of [["all", "All concepts"], ["learning", "Learning"], ["known", "Known"], ["assessed", "Assessed"], ["not-started", "Not yet"]]) {
+ statusFilter.append(new Option(label, value, false, logbookFilter === value));
+ }
+ const resultCount = makeElement("span", { className: "ledger-count" });
+ controls.append(search, statusFilter, resultCount);
+ ledger.heading.append(controls);
+ const rows = makeElement("div", { className: "ledger-rows" });
+ const loadMore = makeElement("button", { className: "button secondary load-more", text: "Show more", attrs: { type: "button" } });
+
+ function updateLedger() {
+ const matches = filterLogbookTopics(taxonomy.topics, progress, { status: logbookFilter, query: logbookSearch });
+ resultCount.textContent = `${matches.length} concept${matches.length === 1 ? "" : "s"}`;
+ rows.replaceChildren();
+ if (!matches.length) rows.append(makeElement("p", { className: "ledger-empty", text: "Nothing matches yet. Try another filter or search." }));
+ for (const topic of matches.slice(0, logbookLimit)) {
+ const entry = progress[topic.id];
+ const status = logbookStatus(entry);
+ const row = makeElement("article", { className: "ledger-row" });
+ const subjectDot = makeElement("i", { className: "ledger-subject" });
+ subjectDot.style.background = SUBJECT_COLORS[topic.subject] || "#92a3bb";
+ const topicCopy = makeElement("button", { className: "ledger-topic", attrs: { type: "button" } });
+ topicCopy.append(makeElement("strong", { text: topic.name }), makeElement("small", { text: `${topic.subject} · ${topic.domain} · ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` }));
+ topicCopy.addEventListener("click", () => openFromLogbook(topic.id));
+ const badge = makeElement("span", { className: `ledger-status ${status.className}` });
+ badge.append(makeElement("i", { text: status.icon }), document.createTextNode(status.label));
+ const actions = makeElement("div", { className: "ledger-actions" });
+ if (entry?.status !== "learning") {
+ const learn = makeElement("button", { text: "Learn", attrs: { type: "button", title: "Mark as learning" } });
+ learn.addEventListener("click", () => { store.setProgress(topic.id, "learning"); showToast(`${profile.name} is learning ${topic.name}`); });
+ actions.append(learn);
+ }
+ if (entry?.status !== "mastered") {
+ const know = makeElement("button", { className: "know-action", text: "Know it", attrs: { type: "button", title: "Mark as known" } });
+ know.addEventListener("click", () => { store.setProgress(topic.id, "mastered"); celebrate(); showToast(`Wonderful — ${profile.name} knows ${topic.name}!`); });
+ actions.append(know);
+ }
+ const assess = makeElement("button", { text: "Assess", attrs: { type: "button", title: "Open the evidence checklist" } });
+ assess.addEventListener("click", () => openFromLogbook(topic.id));
+ actions.append(assess);
+ row.append(subjectDot, topicCopy, badge, actions);
+ rows.append(row);
+ }
+ loadMore.hidden = matches.length <= logbookLimit;
+ }
+ search.addEventListener("input", (event) => { logbookSearch = event.target.value; logbookLimit = 60; updateLedger(); });
+ statusFilter.addEventListener("change", (event) => { logbookFilter = event.target.value; logbookLimit = 60; updateLedger(); });
+ loadMore.addEventListener("click", () => { logbookLimit += 60; updateLedger(); });
+ updateLedger();
+ ledger.section.append(rows, loadMore);
+ content.append(ledger.section);
+
+ const recent = logbookSection("Recent moments", `A private activity trail for ${profile.name}, stored only in this browser.`);
+ const timeline = makeElement("div", { className: "activity-timeline" });
+ const recentActivities = [...profile.activities].reverse().slice(0, 12);
+ if (!recentActivities.length) {
+ timeline.append(makeElement("p", { className: "activity-empty", text: "New learning moments will appear here as you update the logbook." }));
+ } else {
+ const labels = {
+ learning: ["↗", "started learning"],
+ mastered: ["✓", "marked as known"],
+ assessed: ["★", "completed an assessment for"],
+ cleared: ["○", "moved back to not started"],
+ };
+ for (const activity of recentActivities) {
+ const topic = taxonomy.byId.get(activity.topicId);
+ if (!topic) continue;
+ const [icon, verb] = labels[activity.action];
+ const item = makeElement("button", { className: `activity-item ${activity.action}`, attrs: { type: "button" } });
+ item.append(makeElement("i", { text: icon }), makeElement("span", { text: `${profile.name} ${verb} “${topic.name}”` }), makeElement("time", { text: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(activity.at)), attrs: { datetime: activity.at } }));
+ item.addEventListener("click", () => openFromLogbook(topic.id));
+ timeline.append(item);
+ }
+ }
+ recent.section.append(timeline);
+ content.append(recent.section);
+}
+
+function applyFilters() {
+ if (!taxonomy || !graph) return;
+ const { query, subject, age, masteryMode } = filters();
+ const progress = activeProgress();
+ const visibleIds = new Set();
+ for (const topic of taxonomy.topics) {
+ const known = progress[topic.id]?.status === "mastered";
+ const searchText = `${topic.name} ${topic.description} ${topic.domain}`.toLocaleLowerCase();
+ if (query && !searchText.includes(query)) continue;
+ if (subject && topic.subject !== subject) continue;
+ if (age && (age < topic.ageRangeStart || age > topic.ageRangeEnd)) continue;
+ if (masteryMode === "hide" && known) continue;
+ if (masteryMode === "only" && !known) continue;
+ visibleIds.add(topic.id);
+ }
+ elements["empty-state"].hidden = visibleIds.size > 0;
+ graph.update({ visibleIds, progress, masteryMode, selectedId });
+}
+
+function statusButton(label, value, current, topicId) {
+ const button = makeElement("button", {
+ className: `status-button${current === value ? " active" : ""}`,
+ text: label,
+ attrs: { type: "button", "aria-pressed": String(current === value) },
+ });
+ button.addEventListener("click", () => {
+ if (!store.activeProfile) return openProfileDialog("add");
+ store.setProgress(topicId, value);
+ const name = store.activeProfile?.name ?? "Your learner";
+ if (value === "mastered") celebrate();
+ showToast(value === null ? "Progress cleared" : value === "learning" ? `${name} started a new adventure!` : `Wonderful — ${name} knows this!`);
+ });
+ return button;
+}
+
+function sectionHeading(title, count) {
+ const heading = makeElement("div", { className: "section-heading" });
+ heading.append(makeElement("h3", { text: title }), makeElement("span", { text: String(count) }));
+ return heading;
+}
+
+function renderDetails() {
+ if (!taxonomy) return;
+ const topic = taxonomy.byId.get(selectedId);
+ elements["details-empty"].hidden = Boolean(topic);
+ elements["details-content"].hidden = !topic;
+ if (!topic) return;
+
+ const profile = store.activeProfile;
+ const entry = profile?.progress[topic.id];
+ const current = entry?.status ?? null;
+ const content = elements["details-content"];
+ content.replaceChildren();
+
+ const header = makeElement("header", { className: "topic-header" });
+ const kicker = makeElement("div", { className: "topic-kicker" });
+ const color = SUBJECT_COLORS[topic.subject] || "#92a3bb";
+ const subjectDot = makeElement("i", { className: "subject-dot" });
+ subjectDot.style.background = color;
+ kicker.append(subjectDot, document.createTextNode(`${topic.subject} · ${topic.domain}`));
+ header.append(kicker, makeElement("h1", { text: topic.name }), makeElement("p", { text: topic.description }));
+ const chips = makeElement("div", { className: "chips" });
+ chips.append(
+ makeElement("span", { text: `Ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` }),
+ makeElement("span", { text: topic.type.toLowerCase().replaceAll("_", " ") }),
+ );
+ if (entry?.assessment?.verified) chips.append(makeElement("span", { className: "verified-chip", text: "✓ Assessed" }));
+ header.append(chips);
+
+ const progressSection = makeElement("section", { className: "detail-section progress-section" });
+ progressSection.append(makeElement("h2", { text: profile ? `What does ${profile.name} know?` : "Track this concept" }));
+ const statusControl = makeElement("div", { className: "status-control" });
+ statusControl.append(
+ statusButton("Not started", null, current, topic.id),
+ statusButton("Learning", "learning", current, topic.id),
+ statusButton("Knows it", "mastered", current, topic.id),
+ );
+ progressSection.append(statusControl);
+ if (!profile) progressSection.append(makeElement("p", { className: "inline-note", text: "Selecting a status will first create a private child profile." }));
+
+ const assessment = makeElement("section", { className: "detail-section assessment-card" });
+ assessment.append(makeElement("p", { className: "eyebrow", text: "Quick assessment" }));
+ assessment.append(makeElement("h2", { text: assessmentPromptFor(topic, profile?.name) }));
+ assessment.append(makeElement("p", {
+ className: "assessment-help",
+ text: profile
+ ? `${profile.name}'s name is added automatically. Check each piece of evidence you observed; this records your judgement rather than scoring the child.`
+ : "The profile name will be added automatically. Check each piece of evidence you observed; this records your judgement rather than scoring the child.",
+ }));
+ const checklist = makeElement("div", { className: "evidence-list" });
+ const savedEvidence = new Set(entry?.assessment?.evidence ?? []);
+ const checkboxes = topic.evidence.map((evidence, index) => {
+ const label = makeElement("label");
+ const input = makeElement("input", { attrs: { type: "checkbox" } });
+ input.checked = savedEvidence.has(index);
+ label.append(input, makeElement("span", { text: evidence }));
+ checklist.append(label);
+ return input;
+ });
+ const assessButton = makeElement("button", {
+ className: "button assessment-action",
+ text: entry?.assessment?.verified ? "Update assessment" : "Confirm assessment & mark known",
+ attrs: { type: "button" },
+ });
+ function syncAssessButton() {
+ const complete = checkboxes.length > 0 && checkboxes.every(({ checked }) => checked);
+ assessButton.disabled = !complete;
+ assessButton.title = complete ? "" : "Check every observed outcome to confirm this assessment";
+ }
+ checkboxes.forEach((checkbox) => checkbox.addEventListener("change", syncAssessButton));
+ syncAssessButton();
+ assessButton.addEventListener("click", () => {
+ if (!store.activeProfile) return openProfileDialog("add");
+ store.setProgress(topic.id, "mastered", { verified: true, evidence: checkboxes.map((_, index) => index) });
+ celebrate();
+ showToast(`Assessment complete — great work, ${store.activeProfile?.name}!`);
+ });
+ assessment.append(checklist, assessButton);
+
+ content.append(header, progressSection, assessment);
+
+ const prereqs = taxonomy.prerequisites.get(topic.id).filter(({ topic: linkedTopic }) => linkedTopic);
+ const unlocks = taxonomy.unlocks.get(topic.id).filter(({ topic: linkedTopic }) => linkedTopic);
+ for (const [title, items, relationship] of [
+ ["Builds on", prereqs, "prerequisite"],
+ ["Unlocks", unlocks, "unlock"],
+ ]) {
+ const section = makeElement("section", { className: "detail-section relationships" });
+ section.append(sectionHeading(title, items.length));
+ if (items.length) {
+ for (const { topic: linkedTopic } of items.slice(0, 12)) section.append(topicButton(linkedTopic, relationship));
+ if (items.length > 12) section.append(makeElement("p", { className: "inline-note", text: `And ${items.length - 12} more connected concepts.` }));
+ } else {
+ section.append(makeElement("p", { className: "inline-note", text: relationship === "prerequisite" ? "No prerequisite is recorded." : "No direct unlock is recorded." }));
+ }
+ content.append(section);
+ }
+}
+
+function selectTopic(topicId) {
+ selectedId = topicId;
+ graph.select(topicId);
+ renderDetails();
+ if (globalThis.innerWidth < 900) document.getElementById("details").scrollIntoView({ behavior: "smooth", block: "start" });
+}
+
+function openProfileDialog(mode) {
+ dialogMode = mode;
+ const profile = store.activeProfile;
+ const adding = mode === "add";
+ elements["dialog-title"].textContent = adding ? "Add a child" : "Manage profile";
+ elements["profile-name"].value = adding ? "" : profile?.name ?? "";
+ elements["profile-name-field"].hidden = !adding;
+ elements["profile-dialog-actions"].hidden = !adding;
+ elements["managed-profile-summary"].hidden = adding;
+ elements["name-once-note"].hidden = !adding;
+ elements["profile-danger"].hidden = adding;
+ elements["managed-profile-name"].textContent = profile?.name ?? "";
+ elements["managed-profile-avatar"].textContent = profile?.name?.slice(0, 1).toLocaleUpperCase() ?? "";
+ elements["danger-copy"].textContent = profile ? `Reset or remove ${profile.name}'s browser-only data.` : "";
+ elements["profile-dialog"].showModal();
+ if (adding) setTimeout(() => elements["profile-name"].focus(), 0);
+}
+
+function clearFilters() {
+ elements.search.value = "";
+ elements["subject-filter"].value = "";
+ elements["age-filter"].value = "";
+ elements["mastery-filter"].value = "dim";
+ applyFilters();
+}
+
+function bindEvents() {
+ elements["graph-mode"].addEventListener("click", () => switchView("graph"));
+ elements["logbook-mode"].addEventListener("click", () => switchView("logbook"));
+ elements["profile-select"].addEventListener("change", (event) => store.setActive(event.target.value));
+ elements["add-profile"].addEventListener("click", () => openProfileDialog("add"));
+ elements["manage-profile"].addEventListener("click", () => openProfileDialog("manage"));
+ elements.search.addEventListener("input", () => { applyFilters(); renderSearchResults(); });
+ elements.search.addEventListener("focus", renderSearchResults);
+ elements.search.addEventListener("blur", () => setTimeout(renderSearchResults, 0));
+ for (const element of [elements["subject-filter"], elements["age-filter"], elements["mastery-filter"]]) element.addEventListener("input", applyFilters);
+ elements["clear-filters"].addEventListener("click", clearFilters);
+ elements["zoom-in"].addEventListener("click", () => graph.zoom(1.25));
+ elements["zoom-out"].addEventListener("click", () => graph.zoom(0.8));
+ elements["reset-view"].addEventListener("click", () => graph.fit());
+ elements["edit-profile-name"].addEventListener("click", () => {
+ dialogMode = "rename";
+ elements["dialog-title"].textContent = "Edit child's name";
+ elements["managed-profile-summary"].hidden = true;
+ elements["profile-name-field"].hidden = false;
+ elements["profile-dialog-actions"].hidden = false;
+ elements["profile-name"].value = store.activeProfile?.name ?? "";
+ setTimeout(() => elements["profile-name"].focus(), 0);
+ });
+ elements["save-profile"].addEventListener("click", (event) => {
+ event.preventDefault();
+ if (!elements["profile-name"].reportValidity()) return;
+ try {
+ if (dialogMode === "add") store.addProfile(elements["profile-name"].value);
+ else store.renameActive(elements["profile-name"].value);
+ elements["profile-dialog"].close();
+ showToast(dialogMode === "add" ? "Child profile created" : "Profile updated");
+ } catch (error) {
+ elements["profile-name"].setCustomValidity(error.message);
+ elements["profile-name"].reportValidity();
+ elements["profile-name"].setCustomValidity("");
+ }
+ });
+ elements["reset-progress"].addEventListener("click", () => {
+ const profile = store.activeProfile;
+ if (profile && confirm(`Reset all learning progress for ${profile.name}? This cannot be undone.`)) {
+ store.resetActiveProgress();
+ elements["profile-dialog"].close();
+ showToast("Progress reset");
+ }
+ });
+ elements["delete-profile"].addEventListener("click", () => {
+ const profile = store.activeProfile;
+ if (profile && confirm(`Delete ${profile.name}'s profile and all progress from this browser?`)) {
+ store.deleteActive();
+ elements["profile-dialog"].close();
+ showToast("Profile deleted");
+ }
+ });
+ globalThis.addEventListener("storage", (event) => {
+ if (event.key === "marble-taxonomy:learner-profiles") store.reload();
+ });
+}
+
+async function initialize() {
+ bindEvents();
+ try {
+ taxonomy = await loadTaxonomy();
+ for (const subject of taxonomy.subjects) elements["subject-filter"].append(new Option(subject, subject));
+ for (let age = taxonomy.minAge; age <= taxonomy.maxAge; age += 1) elements["age-filter"].append(new Option(`Age ${age}`, String(age)));
+ graph = new GraphView(elements.graph, taxonomy, selectTopic);
+ store.subscribe(renderProfiles);
+ renderProfiles();
+ if (!store.activeProfile) openProfileDialog("add");
+ } catch (error) {
+ elements["progress-summary"].textContent = error.message;
+ elements["details-empty"].querySelector("h1").textContent = "The explorer could not start";
+ elements["details-empty"].querySelector("p").textContent = "Serve the repository over HTTP with `npm run serve`, then reload this page.";
+ }
+}
+
+initialize();
diff --git a/explorer/src/graph-view.js b/explorer/src/graph-view.js
new file mode 100644
index 00000000..a4ae7934
--- /dev/null
+++ b/explorer/src/graph-view.js
@@ -0,0 +1,217 @@
+const SUBJECT_COLORS = {
+ Computing: "#7c6df2",
+ English: "#eb6f92",
+ History: "#d09b46",
+ "Learning to Learn": "#4b9ca8",
+ "Life Skills": "#71a064",
+ Mathematics: "#4e83e6",
+ "Personal & Social Development": "#bd68bf",
+ Science: "#28a982",
+};
+
+function hash(value) {
+ let result = 2166136261;
+ for (let index = 0; index < value.length; index += 1) {
+ result ^= value.charCodeAt(index);
+ result = Math.imul(result, 16777619);
+ }
+ return (result >>> 0) / 4294967295;
+}
+
+export function layoutTopics(topics, subjects, minAge, maxAge) {
+ const subjectIndex = new Map(subjects.map((subject, index) => [subject, index]));
+ const width = 2400;
+ const laneHeight = 290;
+ const height = Math.max(1000, subjects.length * laneHeight);
+ const ageSpan = Math.max(1, maxAge - minAge);
+ const nodes = topics.map((topic) => {
+ const midpoint = (topic.ageRangeStart + topic.ageRangeEnd) / 2;
+ const x = 120 + ((midpoint - minAge) / ageSpan) * (width - 240) + (hash(topic.id) - 0.5) * 80;
+ const lane = subjectIndex.get(topic.subject) ?? 0;
+ const y = lane * laneHeight + laneHeight / 2 + (hash(`${topic.domain}:${topic.id}`) - 0.5) * (laneHeight - 52);
+ return { ...topic, x, y, radius: 3.2 + Math.min(4.8, Math.sqrt(topic.centrality || 0) * 7) };
+ });
+ return { nodes, byId: new Map(nodes.map((node) => [node.id, node])), width, height };
+}
+
+export class GraphView {
+ #canvas;
+ #context;
+ #layout;
+ #dependencies;
+ #visibleIds = new Set();
+ #progress = {};
+ #masteryMode = "dim";
+ #selectedId = null;
+ #onSelect;
+ #transform = { x: 0, y: 0, scale: 1 };
+ #drag = null;
+ #resizeObserver;
+
+ constructor(canvas, taxonomy, onSelect) {
+ this.#canvas = canvas;
+ this.#context = canvas.getContext("2d");
+ this.#layout = layoutTopics(taxonomy.topics, taxonomy.subjects, taxonomy.minAge, taxonomy.maxAge);
+ this.#dependencies = taxonomy.dependencies;
+ this.#visibleIds = new Set(taxonomy.topics.map(({ id }) => id));
+ this.#onSelect = onSelect;
+ this.#bindEvents();
+ this.#resizeObserver = new ResizeObserver(() => this.resize());
+ this.#resizeObserver.observe(canvas.parentElement);
+ }
+
+ #bindEvents() {
+ this.#canvas.addEventListener("pointerdown", (event) => {
+ this.#canvas.setPointerCapture(event.pointerId);
+ this.#drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: this.#transform.x, y: this.#transform.y };
+ this.#canvas.classList.add("dragging");
+ });
+ this.#canvas.addEventListener("pointermove", (event) => {
+ if (!this.#drag || event.pointerId !== this.#drag.pointerId) return;
+ this.#transform.x = this.#drag.x + event.clientX - this.#drag.startX;
+ this.#transform.y = this.#drag.y + event.clientY - this.#drag.startY;
+ this.draw();
+ });
+ this.#canvas.addEventListener("pointerup", (event) => {
+ if (!this.#drag) return;
+ const distance = Math.hypot(event.clientX - this.#drag.startX, event.clientY - this.#drag.startY);
+ if (distance < 5) this.#pick(event.offsetX, event.offsetY);
+ this.#drag = null;
+ this.#canvas.classList.remove("dragging");
+ });
+ this.#canvas.addEventListener("wheel", (event) => {
+ event.preventDefault();
+ this.zoom(event.deltaY < 0 ? 1.13 : 0.885, event.offsetX, event.offsetY);
+ }, { passive: false });
+ }
+
+ resize() {
+ const rect = this.#canvas.parentElement.getBoundingClientRect();
+ const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
+ this.#canvas.width = Math.round(rect.width * ratio);
+ this.#canvas.height = Math.round(rect.height * ratio);
+ this.#canvas.style.width = `${rect.width}px`;
+ this.#canvas.style.height = `${rect.height}px`;
+ this.#context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ if (this.#transform.scale === 1 && this.#transform.x === 0) this.fit();
+ else this.draw();
+ }
+
+ fit() {
+ const width = this.#canvas.clientWidth;
+ const height = this.#canvas.clientHeight;
+ const scale = Math.min(width / this.#layout.width, height / this.#layout.height) * 0.92;
+ this.#transform = {
+ scale,
+ x: (width - this.#layout.width * scale) / 2,
+ y: (height - this.#layout.height * scale) / 2,
+ };
+ this.draw();
+ }
+
+ zoom(factor, centerX = this.#canvas.clientWidth / 2, centerY = this.#canvas.clientHeight / 2) {
+ const previous = this.#transform.scale;
+ const scale = Math.max(0.18, Math.min(4, previous * factor));
+ const worldX = (centerX - this.#transform.x) / previous;
+ const worldY = (centerY - this.#transform.y) / previous;
+ this.#transform = { scale, x: centerX - worldX * scale, y: centerY - worldY * scale };
+ this.draw();
+ }
+
+ update({ visibleIds, progress, masteryMode, selectedId }) {
+ this.#visibleIds = visibleIds;
+ this.#progress = progress;
+ this.#masteryMode = masteryMode;
+ this.#selectedId = selectedId;
+ this.draw();
+ }
+
+ select(topicId) {
+ this.#selectedId = topicId;
+ const node = this.#layout.byId.get(topicId);
+ if (node) {
+ const screenX = node.x * this.#transform.scale + this.#transform.x;
+ const screenY = node.y * this.#transform.scale + this.#transform.y;
+ if (screenX < 80 || screenX > this.#canvas.clientWidth - 80 || screenY < 80 || screenY > this.#canvas.clientHeight - 80) {
+ this.#transform.x = this.#canvas.clientWidth / 2 - node.x * this.#transform.scale;
+ this.#transform.y = this.#canvas.clientHeight / 2 - node.y * this.#transform.scale;
+ }
+ }
+ this.draw();
+ }
+
+ #pick(screenX, screenY) {
+ const x = (screenX - this.#transform.x) / this.#transform.scale;
+ const y = (screenY - this.#transform.y) / this.#transform.scale;
+ const maxDistance = 14 / this.#transform.scale;
+ let nearest = null;
+ let nearestDistance = maxDistance;
+ for (const node of this.#layout.nodes) {
+ if (!this.#visibleIds.has(node.id)) continue;
+ const distance = Math.hypot(node.x - x, node.y - y);
+ if (distance < nearestDistance) {
+ nearest = node;
+ nearestDistance = distance;
+ }
+ }
+ if (nearest) this.#onSelect(nearest.id);
+ }
+
+ draw() {
+ const context = this.#context;
+ const width = this.#canvas.clientWidth;
+ const height = this.#canvas.clientHeight;
+ context.clearRect(0, 0, width, height);
+ context.save();
+ context.translate(this.#transform.x, this.#transform.y);
+ context.scale(this.#transform.scale, this.#transform.scale);
+
+ const selectedNeighbors = new Set();
+ if (this.#selectedId) {
+ for (const edge of this.#dependencies) {
+ if (edge.topicId === this.#selectedId) selectedNeighbors.add(edge.prerequisiteId);
+ if (edge.prerequisiteId === this.#selectedId) selectedNeighbors.add(edge.topicId);
+ }
+ }
+
+ context.lineWidth = 0.7 / Math.max(0.4, this.#transform.scale);
+ for (const edge of this.#dependencies) {
+ if (!this.#visibleIds.has(edge.topicId) || !this.#visibleIds.has(edge.prerequisiteId)) continue;
+ const from = this.#layout.byId.get(edge.prerequisiteId);
+ const to = this.#layout.byId.get(edge.topicId);
+ const highlighted = edge.topicId === this.#selectedId || edge.prerequisiteId === this.#selectedId;
+ context.strokeStyle = highlighted ? "rgba(255,255,255,.72)" : "rgba(132,152,184,.10)";
+ context.lineWidth = (highlighted ? 2.1 : 0.7) / Math.max(0.45, this.#transform.scale);
+ context.beginPath();
+ context.moveTo(from.x, from.y);
+ context.lineTo(to.x, to.y);
+ context.stroke();
+ }
+
+ for (const node of this.#layout.nodes) {
+ if (!this.#visibleIds.has(node.id)) continue;
+ const entry = this.#progress[node.id];
+ const selected = node.id === this.#selectedId;
+ const related = selectedNeighbors.has(node.id);
+ const dimmed = this.#masteryMode === "dim" && entry?.status === "mastered" && !selected;
+ context.globalAlpha = dimmed ? 0.18 : selected || related ? 1 : 0.82;
+ context.fillStyle = SUBJECT_COLORS[node.subject] || "#92a3bb";
+ context.beginPath();
+ context.arc(node.x, node.y, selected ? node.radius + 4 : related ? node.radius + 2 : node.radius, 0, Math.PI * 2);
+ context.fill();
+
+ if (entry?.status === "learning" || entry?.status === "mastered") {
+ context.globalAlpha = dimmed ? 0.24 : 1;
+ context.strokeStyle = entry.assessment?.verified ? "#fff3a6" : entry.status === "mastered" ? "#7df0bd" : "#ffb454";
+ context.lineWidth = (entry.assessment?.verified ? 3 : 2) / Math.max(0.55, this.#transform.scale);
+ context.beginPath();
+ context.arc(node.x, node.y, node.radius + 3.5, 0, Math.PI * 2);
+ context.stroke();
+ }
+ }
+ context.restore();
+ context.globalAlpha = 1;
+ }
+}
+
+export { SUBJECT_COLORS };
diff --git a/explorer/src/logbook.js b/explorer/src/logbook.js
new file mode 100644
index 00000000..51924259
--- /dev/null
+++ b/explorer/src/logbook.js
@@ -0,0 +1,59 @@
+export function getProgressStats(topics, progress) {
+ const entries = Object.values(progress);
+ const known = entries.filter(({ status }) => status === "mastered").length;
+ const learning = entries.filter(({ status }) => status === "learning").length;
+ const assessed = entries.filter(({ assessment }) => assessment?.verified).length;
+ return { known, learning, assessed, notStarted: topics.length - known - learning, total: topics.length };
+}
+
+export function getSubjectJourneys(topics, progress) {
+ const journeys = new Map();
+ for (const topic of topics) {
+ const journey = journeys.get(topic.subject) ?? { subject: topic.subject, total: 0, known: 0, learning: 0, assessed: 0 };
+ const entry = progress[topic.id];
+ journey.total += 1;
+ if (entry?.status === "mastered") journey.known += 1;
+ if (entry?.status === "learning") journey.learning += 1;
+ if (entry?.assessment?.verified) journey.assessed += 1;
+ journeys.set(topic.subject, journey);
+ }
+ return [...journeys.values()].sort((left, right) => right.known - left.known || left.subject.localeCompare(right.subject));
+}
+
+export function getRecommendedTopics(taxonomy, progress, limit = 5) {
+ const mastered = new Set(Object.entries(progress).filter(([, entry]) => entry.status === "mastered").map(([id]) => id));
+ const learning = new Set(Object.entries(progress).filter(([, entry]) => entry.status === "learning").map(([id]) => id));
+ const unlockedByMastery = new Set(
+ taxonomy.dependencies.filter(({ prerequisiteId }) => mastered.has(prerequisiteId)).map(({ topicId }) => topicId),
+ );
+
+ return taxonomy.topics
+ .filter((topic) => !mastered.has(topic.id))
+ .filter((topic) => taxonomy.prerequisites.get(topic.id).filter(({ strength }) => strength === "hard").every(({ prerequisiteId }) => mastered.has(prerequisiteId)))
+ .sort((left, right) => {
+ const leftPriority = learning.has(left.id) ? 0 : unlockedByMastery.has(left.id) ? 1 : 2;
+ const rightPriority = learning.has(right.id) ? 0 : unlockedByMastery.has(right.id) ? 1 : 2;
+ return leftPriority - rightPriority || left.ageRangeStart - right.ageRangeStart || right.centrality - left.centrality || left.name.localeCompare(right.name);
+ })
+ .slice(0, limit);
+}
+
+export function filterLogbookTopics(topics, progress, { status = "all", query = "" } = {}) {
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ return topics
+ .filter((topic) => {
+ const entry = progress[topic.id];
+ if (status === "known" && entry?.status !== "mastered") return false;
+ if (status === "learning" && entry?.status !== "learning") return false;
+ if (status === "not-started" && entry) return false;
+ if (status === "assessed" && !entry?.assessment?.verified) return false;
+ if (normalizedQuery && !`${topic.name} ${topic.subject} ${topic.domain}`.toLocaleLowerCase().includes(normalizedQuery)) return false;
+ return true;
+ })
+ .sort((left, right) => {
+ const leftUpdated = progress[left.id]?.updatedAt ?? "";
+ const rightUpdated = progress[right.id]?.updatedAt ?? "";
+ if (leftUpdated || rightUpdated) return rightUpdated.localeCompare(leftUpdated);
+ return left.ageRangeStart - right.ageRangeStart || left.subject.localeCompare(right.subject) || left.name.localeCompare(right.name);
+ });
+}
diff --git a/explorer/src/profile-store.js b/explorer/src/profile-store.js
new file mode 100644
index 00000000..949cf06a
--- /dev/null
+++ b/explorer/src/profile-store.js
@@ -0,0 +1,209 @@
+export const STORAGE_KEY = "marble-taxonomy:learner-profiles";
+export const STORAGE_VERSION = 2;
+export const PROGRESS_STATUSES = new Set(["learning", "mastered"]);
+export const ACTIVITY_ACTIONS = new Set(["learning", "mastered", "assessed", "cleared"]);
+const ACTIVITY_LIMIT = 500;
+
+function makeId() {
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
+ return `profile-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
+function cleanName(name) {
+ return String(name ?? "").trim().slice(0, 60);
+}
+
+export function createEmptyState() {
+ return { version: STORAGE_VERSION, activeProfileId: null, profiles: [] };
+}
+
+function sanitizeAssessment(value) {
+ if (!value || typeof value !== "object" || value.verified !== true) return undefined;
+ return {
+ verified: true,
+ evidence: Array.isArray(value.evidence) ? value.evidence.filter(Number.isInteger) : [],
+ assessedAt: typeof value.assessedAt === "string" ? value.assessedAt : new Date(0).toISOString(),
+ };
+}
+
+function sanitizeActivities(value) {
+ if (!Array.isArray(value)) return [];
+ return value
+ .filter((activity) =>
+ activity &&
+ typeof activity.id === "string" &&
+ typeof activity.topicId === "string" &&
+ activity.topicId.startsWith("mt_") &&
+ ACTIVITY_ACTIONS.has(activity.action) &&
+ typeof activity.at === "string",
+ )
+ .slice(-ACTIVITY_LIMIT)
+ .map(({ id, topicId, action, at }) => ({ id, topicId, action, at }));
+}
+
+export function sanitizeState(value) {
+ if (!value || typeof value !== "object" || !Array.isArray(value.profiles)) return createEmptyState();
+
+ const ids = new Set();
+ const profiles = [];
+ for (const candidate of value.profiles) {
+ const id = typeof candidate?.id === "string" && candidate.id ? candidate.id : makeId();
+ const name = cleanName(candidate?.name);
+ if (!name || ids.has(id)) continue;
+ ids.add(id);
+
+ const progress = {};
+ if (candidate.progress && typeof candidate.progress === "object") {
+ for (const [topicId, entry] of Object.entries(candidate.progress)) {
+ if (!topicId.startsWith("mt_") || !PROGRESS_STATUSES.has(entry?.status)) continue;
+ const assessment = sanitizeAssessment(entry.assessment);
+ progress[topicId] = {
+ status: entry.status,
+ updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : new Date(0).toISOString(),
+ ...(assessment ? { assessment } : {}),
+ };
+ }
+ }
+
+ profiles.push({
+ id,
+ name,
+ createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : new Date(0).toISOString(),
+ updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(),
+ progress,
+ activities: sanitizeActivities(candidate.activities),
+ });
+ }
+
+ const requestedActiveId = typeof value.activeProfileId === "string" ? value.activeProfileId : null;
+ return {
+ version: STORAGE_VERSION,
+ activeProfileId: profiles.some(({ id }) => id === requestedActiveId) ? requestedActiveId : profiles[0]?.id ?? null,
+ profiles,
+ };
+}
+
+export class ProfileStore {
+ #storage;
+ #now;
+ #listeners = new Set();
+ #state;
+
+ constructor(storage = globalThis.localStorage, now = () => new Date()) {
+ this.#storage = storage;
+ this.#now = now;
+ this.#state = this.#read();
+ }
+
+ #read() {
+ try {
+ return sanitizeState(JSON.parse(this.#storage.getItem(STORAGE_KEY) || "null"));
+ } catch {
+ return createEmptyState();
+ }
+ }
+
+ #commit(nextState) {
+ this.#state = sanitizeState(nextState);
+ this.#storage.setItem(STORAGE_KEY, JSON.stringify(this.#state));
+ for (const listener of this.#listeners) listener(this.state);
+ return this.state;
+ }
+
+ get state() {
+ return structuredClone(this.#state);
+ }
+
+ get activeProfile() {
+ const profile = this.#state.profiles.find(({ id }) => id === this.#state.activeProfileId);
+ return profile ? structuredClone(profile) : null;
+ }
+
+ subscribe(listener) {
+ this.#listeners.add(listener);
+ return () => this.#listeners.delete(listener);
+ }
+
+ reload() {
+ this.#state = this.#read();
+ for (const listener of this.#listeners) listener(this.state);
+ }
+
+ addProfile(name) {
+ const cleanedName = cleanName(name);
+ if (!cleanedName) throw new Error("Please enter a name.");
+ const timestamp = this.#now().toISOString();
+ const profile = { id: makeId(), name: cleanedName, createdAt: timestamp, updatedAt: timestamp, progress: {}, activities: [] };
+ return this.#commit({ ...this.#state, activeProfileId: profile.id, profiles: [...this.#state.profiles, profile] });
+ }
+
+ setActive(profileId) {
+ if (!this.#state.profiles.some(({ id }) => id === profileId)) return this.state;
+ return this.#commit({ ...this.#state, activeProfileId: profileId });
+ }
+
+ renameActive(name) {
+ const cleanedName = cleanName(name);
+ if (!cleanedName) throw new Error("Please enter a name.");
+ const timestamp = this.#now().toISOString();
+ return this.#commit({
+ ...this.#state,
+ profiles: this.#state.profiles.map((profile) =>
+ profile.id === this.#state.activeProfileId ? { ...profile, name: cleanedName, updatedAt: timestamp } : profile,
+ ),
+ });
+ }
+
+ deleteActive() {
+ const profiles = this.#state.profiles.filter(({ id }) => id !== this.#state.activeProfileId);
+ return this.#commit({ ...this.#state, profiles, activeProfileId: profiles[0]?.id ?? null });
+ }
+
+ setProgress(topicId, status, { evidence = [], verified = false } = {}) {
+ if (!this.#state.activeProfileId) throw new Error("Create a child profile first.");
+ if (!topicId.startsWith("mt_")) throw new Error("Invalid topic identifier.");
+ if (status !== null && !PROGRESS_STATUSES.has(status)) throw new Error("Invalid progress status.");
+
+ const activeProfile = this.#state.profiles.find(({ id }) => id === this.#state.activeProfileId);
+ const previous = activeProfile?.progress[topicId];
+ if (!verified && (previous?.status ?? null) === status) return this.state;
+
+ const timestamp = this.#now().toISOString();
+ const action = verified ? "assessed" : status === "learning" ? "learning" : status === "mastered" ? "mastered" : "cleared";
+ return this.#commit({
+ ...this.#state,
+ profiles: this.#state.profiles.map((profile) => {
+ if (profile.id !== this.#state.activeProfileId) return profile;
+ const progress = { ...profile.progress };
+ if (status === null) {
+ delete progress[topicId];
+ } else {
+ const existingAssessment = progress[topicId]?.status === status ? progress[topicId].assessment : undefined;
+ progress[topicId] = {
+ status,
+ updatedAt: timestamp,
+ ...(verified
+ ? { assessment: { verified: true, evidence: [...new Set(evidence.filter(Number.isInteger))], assessedAt: timestamp } }
+ : existingAssessment ? { assessment: existingAssessment } : {}),
+ };
+ }
+ const activities = [
+ ...profile.activities,
+ { id: makeId(), topicId, action, at: timestamp },
+ ].slice(-ACTIVITY_LIMIT);
+ return { ...profile, progress, activities, updatedAt: timestamp };
+ }),
+ });
+ }
+
+ resetActiveProgress() {
+ if (!this.#state.activeProfileId) return this.state;
+ const timestamp = this.#now().toISOString();
+ return this.#commit({
+ ...this.#state,
+ profiles: this.#state.profiles.map((profile) =>
+ profile.id === this.#state.activeProfileId ? { ...profile, progress: {}, activities: [], updatedAt: timestamp } : profile,
+ ),
+ });
+ }
+}
diff --git a/explorer/src/taxonomy.js b/explorer/src/taxonomy.js
new file mode 100644
index 00000000..9ac46054
--- /dev/null
+++ b/explorer/src/taxonomy.js
@@ -0,0 +1,36 @@
+export async function loadTaxonomy(baseUrl = "../data") {
+ const [topicResponse, dependencyResponse] = await Promise.all([
+ fetch(`${baseUrl}/topics.json`),
+ fetch(`${baseUrl}/dependencies.json`),
+ ]);
+ if (!topicResponse.ok || !dependencyResponse.ok) throw new Error("The taxonomy data could not be loaded.");
+ const [{ topics }, { dependencies }] = await Promise.all([topicResponse.json(), dependencyResponse.json()]);
+ return buildTaxonomy(topics, dependencies);
+}
+
+export function buildTaxonomy(topics, dependencies) {
+ const byId = new Map(topics.map((topic) => [topic.id, topic]));
+ const prerequisites = new Map(topics.map(({ id }) => [id, []]));
+ const unlocks = new Map(topics.map(({ id }) => [id, []]));
+
+ for (const dependency of dependencies) {
+ prerequisites.get(dependency.topicId)?.push({ ...dependency, topic: byId.get(dependency.prerequisiteId) });
+ unlocks.get(dependency.prerequisiteId)?.push({ ...dependency, topic: byId.get(dependency.topicId) });
+ }
+
+ return {
+ topics,
+ dependencies,
+ byId,
+ prerequisites,
+ unlocks,
+ subjects: [...new Set(topics.map(({ subject }) => subject))].sort(),
+ minAge: Math.min(...topics.map(({ ageRangeStart }) => ageRangeStart)),
+ maxAge: Math.max(...topics.map(({ ageRangeEnd }) => ageRangeEnd)),
+ };
+}
+
+export function assessmentPromptFor(topic, childName) {
+ const replacement = childName || "the child";
+ return topic.assessmentPrompt.replaceAll("{{name}}", replacement);
+}
diff --git a/explorer/styles.css b/explorer/styles.css
new file mode 100644
index 00000000..433072b1
--- /dev/null
+++ b/explorer/styles.css
@@ -0,0 +1,295 @@
+:root {
+ color-scheme: dark;
+ --bg: #08111f;
+ --surface: #0d192a;
+ --surface-2: #12233a;
+ --line: rgba(173, 195, 226, 0.14);
+ --muted: #9eacc2;
+ --text: #f5f8fc;
+ --accent: #7df0bd;
+ --accent-ink: #062619;
+ --danger: #ff8c96;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-synthesis: none;
+}
+
+* { box-sizing: border-box; }
+html, body { height: 100%; }
+body { margin: 0; background: var(--bg); color: var(--text); overflow: hidden; }
+button, input, select { font: inherit; }
+button, select { cursor: pointer; }
+button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+
+.app-header { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--line); background: rgba(8, 17, 31, .92); }
+.brand { display: flex; align-items: center; gap: 11px; color: var(--text); text-decoration: none; }
+.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: linear-gradient(145deg, #8cf4c6, #5cd6d4); color: #09221a; font-weight: 900; box-shadow: 0 8px 28px rgba(70, 224, 179, .2); }
+.brand strong, .brand small { display: block; }
+.brand strong { font-size: 15px; }
+.brand small { margin-top: 2px; color: var(--muted); font-size: 11px; letter-spacing: .02em; }
+.profile-controls { display: flex; gap: 8px; align-items: center; }
+.view-switch { display: flex; gap: 3px; padding: 3px; border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.035); }
+.view-switch button { min-height: 34px; padding: 0 14px; border: 0; border-radius: 8px; color: var(--muted); background: transparent; font-size: 11px; font-weight: 750; }
+.view-switch button span { margin-right: 5px; }
+.view-switch button.active { color: var(--text); background: var(--surface-2); box-shadow: 0 4px 13px rgba(0,0,0,.22); }
+
+select, input { min-height: 38px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: var(--surface-2); padding: 0 11px; }
+select:disabled, button:disabled { cursor: not-allowed; opacity: .48; }
+.button { min-height: 38px; padding: 0 15px; border-radius: 9px; border: 1px solid transparent; color: var(--text); background: transparent; font-weight: 650; }
+.button.primary, .assessment-action { background: var(--accent); color: var(--accent-ink); }
+.button.secondary { border-color: var(--line); background: rgba(255,255,255,.035); }
+.button.danger { background: rgba(255, 94, 107, .14); border-color: rgba(255, 94, 107, .35); color: var(--danger); }
+.button.text-danger { color: var(--danger); }
+.icon-button { display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-size: 20px; }
+
+.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 390px; height: calc(100% - 70px); }
+.graph-shell { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); border-right: 1px solid var(--line); }
+.toolbar { display: flex; align-items: end; gap: 10px; padding: 13px 16px; background: #0a1626; border-bottom: 1px solid var(--line); }
+.toolbar label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; font-weight: 700; }
+.toolbar .search-container { position: relative; min-width: 190px; flex: 1; }
+.toolbar .search-field { position: relative; display: block; }
+.search-field svg { position: absolute; left: 11px; bottom: 11px; width: 17px; fill: none; stroke: var(--muted); stroke-width: 1.8; }
+.search-field input { width: 100%; padding-left: 36px; }
+.search-results { position: absolute; z-index: 5; top: calc(100% + 7px); left: 0; right: 0; max-height: 370px; overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 11px; background: #13233a; box-shadow: 0 18px 50px rgba(0,0,0,.45); }
+.search-results button { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: center; width: 100%; padding: 9px; border: 0; border-radius: 7px; text-align: left; color: var(--text); background: transparent; }
+.search-results button:hover, .search-results button:focus-visible { background: rgba(255,255,255,.065); }
+.search-results i { width: 9px; height: 9px; border-radius: 50%; }
+.search-results strong, .search-results small { display: block; }
+.search-results strong { font-size: 11px; }
+.search-results small { margin-top: 3px; color: var(--muted); font-size: 9px; }
+.search-results p { margin: 8px; color: var(--muted); font-size: 11px; }
+
+.summary-bar { display: grid; grid-template-columns: auto minmax(100px, 1fr) auto; align-items: center; gap: 16px; padding: 11px 17px; background: rgba(13,25,42,.88); border-bottom: 1px solid var(--line); }
+.progress-copy strong, .progress-copy span { display: block; }
+.progress-copy strong { font-size: 13px; }
+.progress-copy span { color: var(--muted); font-size: 11px; margin-top: 3px; }
+.progress-track { height: 5px; border-radius: 10px; background: rgba(255,255,255,.08); overflow: hidden; }
+.progress-track span { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, #56d4bd, var(--accent)); transition: width .25s ease; }
+.legend { display: flex; gap: 10px; font-size: 10px; color: var(--muted); white-space: nowrap; }
+.legend span { display: flex; align-items: center; gap: 4px; }
+.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #72839d; }
+.dot.learning { border: 2px solid #ffb454; background: transparent; }
+.dot.mastered { border: 2px solid #7df0bd; background: transparent; }
+.dot.assessed { border: 2px solid #fff3a6; background: #7df0bd; }
+
+.canvas-wrap { position: relative; min-height: 0; overflow: hidden; background: radial-gradient(circle at 60% 40%, rgba(31,65,96,.36), transparent 55%), #07111e; }
+#graph { display: block; touch-action: none; cursor: grab; }
+#graph.dragging { cursor: grabbing; }
+.graph-actions { position: absolute; top: 14px; right: 14px; display: grid; gap: 6px; }
+.icon-button.glass { background: rgba(9, 21, 36, .78); backdrop-filter: blur(8px); }
+.reset-view { font-size: 16px; }
+.graph-hint { position: absolute; left: 16px; bottom: 7px; color: #7e8da5; font-size: 11px; pointer-events: none; }
+.empty-state { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; padding: 22px; text-align: center; background: rgba(11, 24, 41, .9); border: 1px solid var(--line); border-radius: 14px; }
+.empty-state strong { display: block; margin-bottom: 13px; }
+
+.details-panel { overflow-y: auto; background: var(--surface); }
+.details-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; padding: 42px; }
+.details-empty[hidden], #details-content[hidden] { display: none; }
+.details-empty h1 { margin: 25px 0 8px; font-size: 24px; }
+.details-empty p { max-width: 290px; margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
+.empty-orbit { width: 74px; height: 74px; border: 1px solid rgba(125,240,189,.35); border-radius: 50%; position: relative; }
+.empty-orbit::before { content: ""; position: absolute; inset: 12px; border: 1px dashed rgba(125,240,189,.25); border-radius: 50%; }
+.empty-orbit span { position: absolute; top: 8px; left: 28px; width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 22px var(--accent); }
+.topic-header { padding: 24px 23px 21px; border-bottom: 1px solid var(--line); }
+.topic-kicker { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 700; }
+.subject-dot { width: 8px; height: 8px; border-radius: 50%; }
+.topic-header h1 { margin: 10px 0 10px; font-size: 24px; letter-spacing: -.025em; line-height: 1.18; }
+.topic-header > p { margin: 0; color: #bdc7d6; font-size: 13px; line-height: 1.58; }
+.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 15px; }
+.chips span { padding: 5px 8px; border-radius: 6px; background: rgba(255,255,255,.055); color: var(--muted); font-size: 10px; text-transform: capitalize; }
+.chips .verified-chip { color: #fff3a6; background: rgba(255,243,166,.09); }
+.detail-section { padding: 20px 23px; border-bottom: 1px solid var(--line); }
+.detail-section h2 { margin: 0 0 13px; font-size: 14px; line-height: 1.45; }
+.status-control { display: grid; grid-template-columns: repeat(3, 1fr); padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); }
+.status-button { min-height: 36px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 700; }
+.status-button:hover { color: var(--text); }
+.status-button.active { color: var(--text); background: var(--surface-2); box-shadow: 0 3px 10px rgba(0,0,0,.2); }
+.assessment-card { margin: 15px; padding: 18px; border: 1px solid rgba(125,240,189,.16); border-radius: 13px; background: linear-gradient(145deg, rgba(125,240,189,.065), rgba(20,44,57,.15)); }
+.assessment-card .eyebrow, .eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
+.assessment-card h2 { font-size: 15px; }
+.assessment-help, .inline-note { color: var(--muted); font-size: 11px; line-height: 1.5; }
+.evidence-list { display: grid; gap: 8px; margin: 15px 0; }
+.evidence-list label { display: grid; grid-template-columns: auto 1fr; gap: 9px; align-items: start; color: #d1d8e2; font-size: 11px; line-height: 1.45; cursor: pointer; }
+.evidence-list input { min-height: auto; accent-color: var(--accent); margin: 2px 0 0; }
+.assessment-action { width: 100%; font-size: 11px; }
+.section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
+.section-heading h3 { margin: 0; font-size: 13px; }
+.section-heading > span { display: grid; place-items: center; min-width: 23px; height: 20px; padding: 0 5px; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.06); font-size: 10px; }
+.relationship-item { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 0; text-align: left; border: 0; border-top: 1px solid rgba(255,255,255,.055); color: var(--text); background: transparent; }
+.relationship-item:hover strong { color: var(--accent); }
+.relationship-item strong, .relationship-item small { display: block; }
+.relationship-item strong { font-size: 11px; }
+.relationship-item small { margin-top: 3px; color: var(--muted); font-size: 9px; }
+.relationship-arrow { color: var(--muted); }
+
+.workspace.logbook-mode { display: block; overflow-y: auto; background: radial-gradient(circle at 75% 5%, rgba(124,109,242,.11), transparent 35%), var(--bg); }
+.workspace.logbook-mode .graph-shell, .workspace.logbook-mode .details-panel { display: none; }
+.logbook-view[hidden] { display: none; }
+.logbook-view { min-height: 100%; padding: 38px 24px 70px; }
+.logbook-content { width: min(1120px, 100%); margin: 0 auto; }
+.logbook-hero { position: relative; overflow: hidden; display: flex; align-items: center; justify-content: space-between; gap: 25px; padding: 32px 35px; border: 1px solid rgba(125,240,189,.18); border-radius: 24px; background: linear-gradient(135deg, rgba(21,55,65,.95), rgba(21,31,61,.95)); box-shadow: 0 22px 60px rgba(0,0,0,.18); }
+.logbook-hero::after { content: ""; position: absolute; width: 260px; height: 260px; right: -90px; top: -130px; border: 1px solid rgba(125,240,189,.15); border-radius: 50%; box-shadow: 0 0 0 35px rgba(125,240,189,.025), 0 0 0 70px rgba(125,240,189,.018); }
+.logbook-hero > * { position: relative; z-index: 1; }
+.logbook-hero h1 { margin: 5px 0 9px; font-size: clamp(30px, 5vw, 48px); line-height: 1; letter-spacing: -.045em; }
+.logbook-hero > div:first-child > p:last-child { max-width: 620px; margin: 0; color: #bdcad8; line-height: 1.55; font-size: 14px; }
+.level-card { display: flex; align-items: center; gap: 13px; min-width: 210px; padding: 13px; border: 1px solid rgba(255,255,255,.1); border-radius: 17px; background: rgba(4,14,27,.3); }
+.level-ring { --level-progress: 0deg; display: grid; place-content: center; flex: 0 0 auto; width: 68px; height: 68px; border-radius: 50%; text-align: center; background: radial-gradient(circle at center, #14283a 56%, transparent 58%), conic-gradient(var(--accent) var(--level-progress), rgba(255,255,255,.09) 0); }
+.level-ring strong, .level-ring small, .level-card > span strong, .level-card > span small { display: block; }
+.level-ring strong { font-size: 23px; line-height: 1; }
+.level-ring small { margin-top: 3px; color: var(--muted); font-size: 7px; font-weight: 900; letter-spacing: .12em; }
+.level-card > span strong { font-size: 13px; }
+.level-card > span small { margin-top: 4px; color: var(--muted); font-size: 10px; }
+.logbook-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0 38px; }
+.logbook-stat { display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 11px; padding: 16px; text-align: left; border: 1px solid var(--line); border-radius: 15px; color: var(--text); background: var(--surface); }
+.logbook-stat:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.24); }
+.logbook-stat > i { grid-row: span 2; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 12px; font-style: normal; background: rgba(255,255,255,.055); }
+.logbook-stat strong { font-size: 20px; line-height: 1; }
+.logbook-stat span { color: var(--muted); font-size: 10px; }
+.logbook-stat.known > i { color: var(--accent); }
+.logbook-stat.learning > i { color: #ffb454; }
+.logbook-stat.assessed > i { color: #fff3a6; }
+.logbook-stat.not-started > i { color: #8090a8; }
+.logbook-section { margin-top: 38px; }
+.logbook-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
+.logbook-section-heading h2 { margin: 0 0 5px; font-size: 21px; letter-spacing: -.02em; }
+.logbook-section-heading p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
+.adventure-path { position: relative; display: grid; gap: 9px; padding: 8px 0; }
+.adventure-path::before { content: ""; position: absolute; left: 34px; top: 35px; bottom: 35px; width: 3px; border-radius: 3px; background: linear-gradient(var(--accent), rgba(125,240,189,.08)); }
+.path-step { position: relative; display: grid; grid-template-columns: 70px minmax(0,1fr) auto; gap: 14px; align-items: center; min-height: 88px; padding: 10px 15px 10px 0; border: 1px solid transparent; border-radius: 18px; }
+.path-step:hover, .path-step.current { border-color: var(--line); background: rgba(255,255,255,.025); }
+.path-orb { position: relative; z-index: 1; display: grid; place-items: center; width: 68px; height: 68px; border: 5px solid #102034; border-radius: 50%; color: #07111e; background: var(--subject-color); box-shadow: inset 0 -7px 0 rgba(0,0,0,.14), 0 7px 18px rgba(0,0,0,.24); }
+.path-orb:hover { transform: scale(1.06) rotate(-3deg); }
+.path-orb span { font-size: 20px; font-weight: 900; }
+.path-copy small { color: var(--accent); font-size: 9px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
+.path-copy h3 { margin: 5px 0 5px; font-size: 14px; }
+.path-copy p { margin: 0; color: var(--muted); font-size: 10px; }
+.path-action { background: #ffdf69; color: #302500; box-shadow: inset 0 -3px 0 rgba(0,0,0,.13); }
+.subject-journeys { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
+.journey-card { display: grid; grid-template-columns: auto 1fr auto; gap: 11px; align-items: center; padding: 15px; text-align: left; border: 1px solid var(--line); border-radius: 15px; color: var(--text); background: var(--surface); }
+.journey-card:hover { border-color: rgba(125,240,189,.3); background: var(--surface-2); }
+.journey-card > i { grid-row: span 2; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 13px; color: #07111e; font-style: normal; font-weight: 900; }
+.journey-card strong, .journey-card small { display: block; }
+.journey-card strong { font-size: 12px; }
+.journey-card small { margin-top: 4px; color: var(--muted); font-size: 9px; }
+.journey-card b { font-size: 11px; }
+.journey-progress { grid-column: 2 / 4; height: 4px; overflow: hidden; border-radius: 4px; background: rgba(255,255,255,.07); }
+.journey-progress i { display: block; height: 100%; border-radius: inherit; }
+.ledger-controls { display: flex; align-items: center; gap: 8px; }
+.ledger-controls input { width: 220px; }
+.ledger-count { min-width: 75px; color: var(--muted); text-align: right; font-size: 10px; }
+.ledger-rows { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
+.ledger-row { display: grid; grid-template-columns: auto minmax(180px,1fr) auto auto; gap: 11px; align-items: center; min-height: 64px; padding: 9px 13px; border-bottom: 1px solid rgba(255,255,255,.055); }
+.ledger-row:last-child { border-bottom: 0; }
+.ledger-row:hover { background: rgba(255,255,255,.025); }
+.ledger-subject { width: 9px; height: 9px; border-radius: 50%; }
+.ledger-topic { padding: 3px 0; text-align: left; border: 0; color: var(--text); background: transparent; }
+.ledger-topic strong, .ledger-topic small { display: block; }
+.ledger-topic strong { font-size: 11px; }
+.ledger-topic small { margin-top: 4px; color: var(--muted); font-size: 9px; }
+.ledger-status { display: flex; align-items: center; gap: 5px; min-width: 85px; color: var(--muted); font-size: 9px; font-weight: 700; }
+.ledger-status i { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-style: normal; background: rgba(255,255,255,.05); }
+.ledger-status.known, .ledger-status.known i { color: var(--accent); }
+.ledger-status.learning, .ledger-status.learning i { color: #ffb454; }
+.ledger-status.assessed, .ledger-status.assessed i { color: #fff3a6; }
+.ledger-actions { display: flex; justify-content: flex-end; gap: 5px; }
+.ledger-actions button { min-height: 29px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: rgba(255,255,255,.025); font-size: 9px; font-weight: 700; }
+.ledger-actions button:hover { color: var(--text); background: var(--surface-2); }
+.ledger-actions .know-action { color: var(--accent); border-color: rgba(125,240,189,.22); }
+.load-more { display: block; margin: 14px auto 0; }
+.load-more[hidden] { display: none; }
+.ledger-empty, .activity-empty { margin: 0; padding: 25px; text-align: center; color: var(--muted); font-size: 11px; }
+.activity-timeline { display: grid; gap: 7px; }
+.activity-item { display: grid; grid-template-columns: auto 1fr auto; gap: 11px; align-items: center; padding: 12px 13px; text-align: left; border: 1px solid var(--line); border-radius: 12px; color: var(--text); background: rgba(255,255,255,.022); }
+.activity-item:hover { background: var(--surface-2); }
+.activity-item > i { display: grid; place-items: center; width: 29px; height: 29px; border-radius: 10px; color: var(--accent); background: rgba(125,240,189,.08); font-style: normal; }
+.activity-item.learning > i { color: #ffb454; background: rgba(255,180,84,.08); }
+.activity-item.assessed > i { color: #fff3a6; background: rgba(255,243,166,.08); }
+.activity-item span { font-size: 11px; }
+.activity-item time { color: var(--muted); font-size: 9px; }
+.logbook-empty { display: grid; place-items: center; min-height: 70vh; text-align: center; }
+.logbook-empty-icon { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 24px; color: var(--accent-ink); background: var(--accent); font-size: 30px; box-shadow: 0 15px 45px rgba(125,240,189,.2); }
+.logbook-empty h1 { margin: 24px 0 8px; }
+.logbook-empty p { max-width: 470px; margin: 0 0 20px; color: var(--muted); line-height: 1.6; }
+.name-once-note { margin: 14px 0 0; color: #c9d5de; font-size: 12px; line-height: 1.5; }
+.managed-profile-summary { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; margin-top: 17px; padding: 13px; border: 1px solid var(--line); border-radius: 13px; background: rgba(255,255,255,.025); }
+.managed-profile-summary[hidden], .field[hidden], .dialog-actions[hidden], .name-once-note[hidden] { display: none; }
+.profile-avatar { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 14px; color: var(--accent-ink); background: var(--accent); font-weight: 900; }
+.managed-profile-summary strong, .managed-profile-summary small { display: block; }
+.managed-profile-summary small { margin-top: 4px; color: var(--muted); font-size: 9px; }
+.celebration { position: fixed; z-index: 30; left: 50%; bottom: 25%; width: 1px; height: 1px; pointer-events: none; }
+.celebration i { position: absolute; width: 9px; height: 14px; border-radius: 2px; opacity: 0; }
+.celebration.playing i { animation: confetti-pop 1.25s cubic-bezier(.12,.7,.25,1) var(--delay) both; }
+@keyframes confetti-pop { 0% { opacity: 0; transform: translate(0,0) rotate(0); } 12% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--x),var(--y)) rotate(var(--r)); } }
+
+dialog { width: min(470px, calc(100% - 28px)); padding: 0; border: 1px solid var(--line); border-radius: 16px; color: var(--text); background: var(--surface); box-shadow: 0 30px 90px rgba(0,0,0,.55); }
+dialog::backdrop { background: rgba(2, 8, 16, .75); backdrop-filter: blur(4px); }
+.dialog-card { padding: 23px; }
+.dialog-heading { display: flex; justify-content: space-between; align-items: start; }
+.dialog-heading h2 { margin: 0; font-size: 22px; }
+.privacy-note { padding: 11px 12px; border-radius: 9px; color: #b7c9c4; background: rgba(125,240,189,.07); font-size: 11px; line-height: 1.5; }
+.field { display: grid; gap: 7px; margin-top: 18px; color: var(--muted); font-size: 11px; font-weight: 700; }
+.field input { width: 100%; }
+.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
+.danger-zone { margin-top: 23px; padding-top: 17px; border-top: 1px solid rgba(255, 94, 107, .18); }
+.danger-zone p { color: var(--muted); font-size: 11px; line-height: 1.45; }
+.danger-zone strong { color: var(--danger); }
+.danger-zone > div { display: flex; justify-content: space-between; gap: 8px; }
+.toast { position: fixed; z-index: 10; left: 50%; bottom: 20px; transform: translate(-50%, 20px); padding: 10px 15px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #17283e; box-shadow: 0 10px 35px rgba(0,0,0,.4); opacity: 0; pointer-events: none; transition: .2s ease; font-size: 12px; }
+.toast.visible { opacity: 1; transform: translate(-50%, 0); }
+
+@media (max-width: 1050px) {
+ .workspace { grid-template-columns: minmax(0, 1fr) 340px; }
+ .legend { display: none; }
+ .toolbar { flex-wrap: wrap; }
+ .toolbar .search-container { flex-basis: 100%; }
+ .ledger-row { grid-template-columns: auto minmax(150px,1fr) auto; }
+ .ledger-actions { grid-column: 2 / 4; }
+}
+
+@media (max-width: 760px) {
+ body { overflow: auto; }
+ .app-header { height: auto; min-height: 68px; padding: 10px 12px; }
+ .app-header { flex-wrap: wrap; gap: 8px; }
+ .view-switch { order: 3; flex-basis: 100%; justify-content: center; }
+ .brand small { display: none; }
+ .profile-controls select { max-width: 145px; }
+ .workspace { display: block; height: auto; }
+ .graph-shell { height: 73vh; min-height: 530px; border-right: 0; }
+ .toolbar { padding: 9px; gap: 7px; }
+ .toolbar > label { flex: 1; }
+ .toolbar > label select { width: 100%; min-width: 0; font-size: 11px; }
+ .summary-bar { grid-template-columns: 1fr; gap: 8px; }
+ .progress-track { grid-row: 2; }
+ .details-panel { min-height: 60vh; overflow: visible; border-top: 1px solid var(--line); }
+ .graph-hint { display: none; }
+ .logbook-view { padding: 20px 12px 55px; }
+ .logbook-hero { display: grid; padding: 25px 22px; }
+ .level-card { width: fit-content; }
+ .logbook-stats { grid-template-columns: repeat(2, 1fr); }
+ .subject-journeys { grid-template-columns: 1fr; }
+ .logbook-section-heading { display: grid; }
+ .ledger-controls { flex-wrap: wrap; }
+ .ledger-controls input { flex: 1 1 180px; width: auto; }
+ .ledger-row { grid-template-columns: auto minmax(0,1fr) auto; }
+ .ledger-status { min-width: auto; }
+ .ledger-actions { grid-column: 2 / 4; }
+ .activity-item { grid-template-columns: auto 1fr; }
+ .activity-item time { grid-column: 2; }
+}
+
+@media (max-width: 480px) {
+ .brand > span:last-child { display: none; }
+ .profile-controls select { max-width: 128px; }
+ .profile-controls { gap: 5px; }
+ .profile-controls .button { padding: 0 10px; }
+ .path-step { grid-template-columns: 60px minmax(0,1fr); padding-right: 4px; }
+ .path-orb { width: 58px; height: 58px; }
+ .adventure-path::before { left: 29px; }
+ .path-step > .button { grid-column: 2; justify-self: start; }
+ .managed-profile-summary { grid-template-columns: auto 1fr; }
+ .managed-profile-summary .button { grid-column: 1 / 3; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
+}
diff --git a/package.json b/package.json
index 54db870c..62ebefb1 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,8 @@
"license": "(ODbL-1.0 AND CC-BY-SA-4.0)",
"homepage": "https://withmarble.com",
"scripts": {
- "validate": "node scripts/validate.mjs"
+ "validate": "node scripts/validate.mjs",
+ "test": "node --test tests/*.test.mjs",
+ "serve": "node scripts/serve.mjs"
}
}
diff --git a/scripts/serve.mjs b/scripts/serve.mjs
new file mode 100644
index 00000000..e45267d3
--- /dev/null
+++ b/scripts/serve.mjs
@@ -0,0 +1,40 @@
+import { createReadStream, statSync } from "node:fs";
+import { createServer } from "node:http";
+import { extname, join, normalize, relative, resolve } from "node:path";
+
+const root = resolve(import.meta.dirname, "..");
+const port = Number.parseInt(process.env.PORT || "4173", 10);
+const contentTypes = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".svg": "image/svg+xml",
+};
+
+createServer((request, response) => {
+ const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
+ const requestedPath = pathname === "/" ? "/explorer/" : pathname;
+ let filePath = normalize(join(root, requestedPath));
+
+ if (relative(root, filePath).startsWith("..")) {
+ response.writeHead(403).end("Forbidden");
+ return;
+ }
+
+ try {
+ if (statSync(filePath).isDirectory()) filePath = join(filePath, "index.html");
+ const stats = statSync(filePath);
+ response.writeHead(200, {
+ "Content-Type": contentTypes[extname(filePath)] || "application/octet-stream",
+ "Content-Length": stats.size,
+ "Cache-Control": filePath.endsWith(".json") ? "public, max-age=300" : "no-cache",
+ });
+ createReadStream(filePath).pipe(response);
+ } catch {
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not found");
+ }
+}).listen(port, "127.0.0.1", () => {
+ console.log(`Marble Learning Map: http://127.0.0.1:${port}/explorer/`);
+});
diff --git a/tests/logbook.test.mjs b/tests/logbook.test.mjs
new file mode 100644
index 00000000..6947ef65
--- /dev/null
+++ b/tests/logbook.test.mjs
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { filterLogbookTopics, getProgressStats, getRecommendedTopics, getSubjectJourneys } from "../explorer/src/logbook.js";
+import { buildTaxonomy } from "../explorer/src/taxonomy.js";
+
+const topics = [
+ { id: "mt_a", name: "Foundations", subject: "Science", domain: "Basics", ageRangeStart: 4, ageRangeEnd: 5, centrality: 0.8 },
+ { id: "mt_b", name: "Next step", subject: "Science", domain: "Basics", ageRangeStart: 5, ageRangeEnd: 6, centrality: 0.7 },
+ { id: "mt_c", name: "Numbers", subject: "Mathematics", domain: "Number", ageRangeStart: 4, ageRangeEnd: 5, centrality: 0.9 },
+];
+const taxonomy = buildTaxonomy(topics, [{ topicId: "mt_b", prerequisiteId: "mt_a", strength: "hard", reason: "first" }]);
+const progress = {
+ mt_a: { status: "mastered", updatedAt: "2026-01-01T00:00:00.000Z", assessment: { verified: true } },
+ mt_c: { status: "learning", updatedAt: "2026-01-02T00:00:00.000Z" },
+};
+
+describe("logbook summaries", () => {
+ it("counts current progress and groups it by subject", () => {
+ assert.deepEqual(getProgressStats(topics, progress), { known: 1, learning: 1, assessed: 1, notStarted: 1, total: 3 });
+ assert.deepEqual(getSubjectJourneys(topics, progress), [
+ { subject: "Science", total: 2, known: 1, learning: 0, assessed: 1 },
+ { subject: "Mathematics", total: 1, known: 0, learning: 1, assessed: 0 },
+ ]);
+ });
+
+ it("recommends learning topics first and concepts whose prerequisites are met", () => {
+ assert.deepEqual(getRecommendedTopics(taxonomy, progress).map(({ id }) => id), ["mt_c", "mt_b"]);
+ });
+
+ it("filters by state and searches across subject and domain", () => {
+ assert.deepEqual(filterLogbookTopics(topics, progress, { status: "known" }).map(({ id }) => id), ["mt_a"]);
+ assert.deepEqual(filterLogbookTopics(topics, progress, { status: "not-started" }).map(({ id }) => id), ["mt_b"]);
+ assert.deepEqual(filterLogbookTopics(topics, progress, { query: "number" }).map(({ id }) => id), ["mt_c"]);
+ });
+});
diff --git a/tests/profile-store.test.mjs b/tests/profile-store.test.mjs
new file mode 100644
index 00000000..354ca26c
--- /dev/null
+++ b/tests/profile-store.test.mjs
@@ -0,0 +1,105 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { ProfileStore, STORAGE_KEY, sanitizeState } from "../explorer/src/profile-store.js";
+
+class MemoryStorage {
+ values = new Map();
+ getItem(key) { return this.values.get(key) ?? null; }
+ setItem(key, value) { this.values.set(key, value); }
+}
+
+const date = new Date("2026-06-15T10:00:00.000Z");
+
+describe("ProfileStore", () => {
+ it("keeps independent progress for multiple child profiles", () => {
+ const store = new ProfileStore(new MemoryStorage(), () => date);
+ store.addProfile("Ada");
+ const adaId = store.activeProfile.id;
+ store.setProgress("mt_one", "mastered");
+ store.addProfile("Linus");
+ store.setProgress("mt_two", "learning");
+
+ assert.deepEqual(store.activeProfile.progress, {
+ mt_two: { status: "learning", updatedAt: date.toISOString() },
+ });
+ store.setActive(adaId);
+ assert.equal(store.activeProfile.progress.mt_one.status, "mastered");
+ assert.equal(store.activeProfile.progress.mt_two, undefined);
+ });
+
+ it("records verified assessment evidence with mastery", () => {
+ const store = new ProfileStore(new MemoryStorage(), () => date);
+ store.addProfile("Ada");
+ store.setProgress("mt_one", "mastered", { verified: true, evidence: [0, 1, 1, 2] });
+
+ assert.deepEqual(store.activeProfile.progress.mt_one.assessment, {
+ verified: true,
+ evidence: [0, 1, 2],
+ assessedAt: date.toISOString(),
+ });
+
+ store.setProgress("mt_one", "mastered");
+ assert.equal(store.activeProfile.progress.mt_one.assessment.verified, true, "reapplying mastery preserves its assessment");
+ });
+
+ it("can clear one concept or reset only the active profile", () => {
+ const store = new ProfileStore(new MemoryStorage(), () => date);
+ store.addProfile("Ada");
+ store.setProgress("mt_one", "learning");
+ store.setProgress("mt_one", null);
+ assert.deepEqual(store.activeProfile.progress, {});
+
+ store.setProgress("mt_two", "mastered");
+ store.addProfile("Linus");
+ store.setProgress("mt_three", "mastered");
+ store.resetActiveProgress();
+ assert.deepEqual(store.activeProfile.progress, {});
+
+ store.setActive(store.state.profiles[0].id);
+ assert.equal(store.activeProfile.progress.mt_two.status, "mastered");
+ });
+
+ it("persists and restores state from the versioned local-storage key", () => {
+ const storage = new MemoryStorage();
+ const first = new ProfileStore(storage, () => date);
+ first.addProfile("Ada");
+ first.setProgress("mt_one", "learning");
+ const restored = new ProfileStore(storage, () => date);
+
+ assert.equal(JSON.parse(storage.getItem(STORAGE_KEY)).version, 2);
+ assert.equal(restored.activeProfile.name, "Ada");
+ assert.equal(restored.activeProfile.progress.mt_one.status, "learning");
+ });
+
+ it("keeps a chronological activity log and clears it with progress", () => {
+ const storage = new MemoryStorage();
+ const store = new ProfileStore(storage, () => date);
+ store.addProfile("Ada");
+ store.setProgress("mt_one", "learning");
+ store.setProgress("mt_one", "mastered");
+ store.setProgress("mt_one", "mastered", { verified: true, evidence: [0] });
+
+ assert.deepEqual(store.activeProfile.activities.map(({ action }) => action), ["learning", "mastered", "assessed"]);
+ assert.equal(store.activeProfile.activities[0].topicId, "mt_one");
+ store.resetActiveProgress();
+ assert.deepEqual(store.activeProfile.activities, []);
+ });
+});
+
+describe("sanitizeState", () => {
+ it("rejects malformed profiles, progress, and unknown statuses", () => {
+ const state = sanitizeState({
+ activeProfileId: "p1",
+ profiles: [
+ { id: "p1", name: " Ada ", progress: { mt_ok: { status: "learning" }, mt_bad: { status: "guessed" }, nope: { status: "mastered" } } },
+ { id: "p1", name: "duplicate", progress: {} },
+ { id: "p2", name: " ", progress: {} },
+ ],
+ });
+
+ assert.equal(state.profiles.length, 1);
+ assert.equal(state.profiles[0].name, "Ada");
+ assert.deepEqual(Object.keys(state.profiles[0].progress), ["mt_ok"]);
+ assert.deepEqual(state.profiles[0].activities, []);
+ });
+});
diff --git a/tests/taxonomy.test.mjs b/tests/taxonomy.test.mjs
new file mode 100644
index 00000000..73405a5f
--- /dev/null
+++ b/tests/taxonomy.test.mjs
@@ -0,0 +1,27 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { assessmentPromptFor, buildTaxonomy } from "../explorer/src/taxonomy.js";
+
+const topics = [
+ { id: "mt_a", name: "A", subject: "Science", ageRangeStart: 5, ageRangeEnd: 6, assessmentPrompt: "Can {{name}} do A?" },
+ { id: "mt_b", name: "B", subject: "Mathematics", ageRangeStart: 7, ageRangeEnd: 8, assessmentPrompt: "Can {{name}} do B?" },
+];
+const dependencies = [{ topicId: "mt_b", prerequisiteId: "mt_a", strength: "hard", reason: "A comes first" }];
+
+describe("buildTaxonomy", () => {
+ it("indexes prerequisite and unlock relationships in both directions", () => {
+ const result = buildTaxonomy(topics, dependencies);
+ assert.equal(result.prerequisites.get("mt_b")[0].topic.name, "A");
+ assert.equal(result.unlocks.get("mt_a")[0].topic.name, "B");
+ assert.deepEqual(result.subjects, ["Mathematics", "Science"]);
+ assert.equal(result.minAge, 5);
+ assert.equal(result.maxAge, 8);
+ });
+});
+
+describe("assessmentPromptFor", () => {
+ it("fills every child-name placeholder without mutating the topic", () => {
+ assert.equal(assessmentPromptFor(topics[0], "Ada"), "Can Ada do A?");
+ assert.equal(topics[0].assessmentPrompt, "Can {{name}} do A?");
+ });
+});