From 96227251d83fbc054bca33b95f145d41027409bd Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:29:38 -0700 Subject: [PATCH 1/8] docs(spec): The Mastery Loop (Pillar 3, Spec 1) design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit learn → prove (self-graded flip-card check from the deep-dive's existing Feynman questions) → see it grow (library mastery coverage). Shared pure mastery.js model + IDB; zero new AI calls, no background.js changes. Research-backed by the "AI-Augmented Textbook"/Learn Your Way paper. Includes a rendered HTML version for review. --- .../specs/2026-06-16-mastery-loop-design.html | 143 ++++++++++++++++++ .../specs/2026-06-16-mastery-loop-design.md | 110 ++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-mastery-loop-design.html create mode 100644 docs/superpowers/specs/2026-06-16-mastery-loop-design.md diff --git a/docs/superpowers/specs/2026-06-16-mastery-loop-design.html b/docs/superpowers/specs/2026-06-16-mastery-loop-design.html new file mode 100644 index 0000000..0dfd894 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-mastery-loop-design.html @@ -0,0 +1,143 @@ + + + + + + The Mastery Loop — Pillar 3, Spec 1 + + + +
+ +
+

The Mastery Loop

+
+ Pillar 3 · Spec 1 · 2026-06-16 · Status: Approved (design) — pending plan
+ Surface: the deep-dive panel of the output tab + the library. +
+
+ learn (run a deep dive) → prove it (self-graded flip-card check) → see it grow (mastery across your library) +
+
+ +
+ Research backbone. Informed by "Towards an AI-Augmented Textbook" / Google "Learn Your Way" (arxiv 2509.13348v4): personalize + multi-represent + embedded assessment, RCT-validated for better immediate and 3-day retention. RepoLens already has latent assessment — the deep-dive Feynman stage emits self-test questions. This spec turns that into an interactive mastery loop, adapted to a zero-backend, BYO-key extension: self-graded, no new AI calls. +
+ +

Goals

+ + +

Non-goals

+ + +

Components

+ +
+ ① The signal — mastery.js (new pure module) + IDB +

Per-repo record persisted in IDB via store.js:

+
mastery[repoId] = {
+  level: 'new' | 'explored' | 'understood',
+  lastCheckedAt: ISO | null,
+  lastResult: { gotIt, shaky, missed, total } | null,
+}
+

Levels: new scanned, no check · explored attempted, not passed · understood passed.

+

Pure exports (no DOM/network): MASTERY_LEVELS + levelLabel(); deriveCheckResult(questions, ratings){ level, score, glows, grows } (score = gotIt/total; understood when score ≥ (the UNDERSTOOD_THRESHOLD constant, ≈0.667 — e.g. 2 of 3 passes; compare against the 2/3 constant, not a rounded 0.67), tunable; glows = the questions you got, grows = the ones to revisit — pure reflection, no AI); aggregateMastery(records) → coverage counts.

+
+ +
+ ② Earn — the Understand-Check (Deep Dive panel) +

The deep-dive already prints self-test questions. Make them interactive flip cards:

+
┌─ Check your understanding ──────────────┐
+│  Q: What runs a Hono request?           │
+│              [ Reveal answer ]          │
+│  …answer…                               │
+│   [ Got it ]  [ Shaky ]  [ Missed ]     │
+└──────────────────────────────────────────┘
+  → tally → "Solid on routing · revisit middleware"
+  → marks this repo understood, saved to your library
+

After all cards: deriveCheckResult sets the level + a Glows & Grows summary from which questions you rated low, then persists to mastery[repoId]. Re-takeable; latest result wins. UI is DOM glue; all logic lives in mastery.js.

+
+ +
+ ③ See — the Mastery Map (library) + +

Rendered in library.js from the mastery records; stats from aggregateMastery.

+
+ +

Architecture (files)

+ + +

Testing

+ + +

Constraints

+

Zero-build, zero-dep, vanilla ES modules. No new AI calls, no background.js changes, no backend — mastery is local IDB; BYO-key untouched. Mono Ink; no emoji; card-flip motion behind prefers-reduced-motion; tasteful (no confetti / badge-soup).

+ +

Acceptance criteria

+ + +
+ Resolved decisions: earn = self-graded flip cards (no AI MCQ in v1) · 3 levels, understood = ≥⅔ "got it" (tunable) · 3-way rating (Got it / Shaky / Missed), "Shaky" feeds grows · mastery map v1 = indicators + aggregate + filter; corkboard/graph deferred to Spec 2. +
+ + + +
+ + diff --git a/docs/superpowers/specs/2026-06-16-mastery-loop-design.md b/docs/superpowers/specs/2026-06-16-mastery-loop-design.md new file mode 100644 index 0000000..2a0c326 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-mastery-loop-design.md @@ -0,0 +1,110 @@ +# The Mastery Loop (Pillar 3, Spec 1) + +- **Date:** 2026-06-16 +- **Status:** Approved (design) — pending implementation plan +- **Surface:** the deep-dive panel of the output tab + the library +- **Part of:** **Pillar 3 — The Knowledge Game** (library/corkboard/canvas made smooth, juicy, addictive). Pillar A (output-tab Four-Act narrative) shipped (PR #36). + - **Spec 1 — The Mastery Loop** ← *this* (mastery/progression + collection) + - **Spec 2 — The Knowledge Graph** (anchor-to-library + corkboard concept graph; "plug pieces, think in new ways") + - Juice (crafting/flow motion) + smalls (re-leveling, spaced repetition, mnemonics) woven in + +## Research backbone + +Informed by *"Towards an AI-Augmented Textbook"* / Google "Learn Your Way" (https://arxiv.org/html/2509.13348v4): **personalize + multi-represent + embedded assessment**, validated by RCT (better immediate *and* 3-day retention). RepoLens already does multi-representation (lenses) and has latent assessment — the deep-dive Feynman stage already emits self-test questions. This spec turns that latent assessment into an interactive **mastery loop**, the paper's "embedded questions + section quiz with Glows & Grows feedback," adapted to a zero-backend, BYO-key extension (self-graded, no new AI calls). + +## The loop + +**learn** (run a deep dive) → **prove it** (self-graded flip-card check) → **see it grow** (mastery coverage across your library). This is "evaluations compound" made tangible. + +## Goals + +- A shared, persisted per-repo **mastery signal** the rest of Pillar 3 builds on. +- An interactive, **self-graded** understand-check built from the deep-dive's existing `{q,a}` questions — no new AI calls. +- A library **mastery view** that shows knowledge coverage growing (the collection/progression payoff). +- Tasteful (Mono Ink, no badge-soup, no confetti); fully local. + +## Non-goals + +- No AI-graded MCQ / distractor generation (self-graded flip cards only in v1). +- No `background.js` / message-contract changes; no new AI calls; no backend. +- No corkboard knowledge-graph integration (that's Spec 2). +- No spaced-repetition scheduling UI yet (the data model leaves room — `lastCheckedAt` — but the resurfacing UX is a later small). + +## Components + +### ① The signal — `mastery.js` (new pure module) + IDB persistence + +Per-repo record, persisted in IDB via `store.js`: + +``` +mastery[repoId] = { + level: 'new' | 'explored' | 'understood', + lastCheckedAt: ISO string | null, + lastResult: { gotIt: number, shaky: number, missed: number, total: number } | null, +} +``` + +Levels: +- **new** — scanned, no check taken. +- **explored** — deep dive run / check attempted but not passed. +- **understood** — passed the check. + +`mastery.js` is pure (no DOM, no network) and exports: +- `MASTERY_LEVELS` (ordered: new < explored < understood) + `levelLabel(level)`. +- `deriveCheckResult(questions, ratings)` → `{ level, score, glows, grows }`. + - `ratings` is an array aligned to `questions`, each `'gotIt' | 'shaky' | 'missed'`. + - `score` = gotIt / total. + - `level` = `'understood'` when `score >= UNDERSTOOD_THRESHOLD` where `UNDERSTOOD_THRESHOLD = 2/3` (≈0.667 — e.g. 2 of 3 questions passes; tunable constant). Compare against the `2/3` constant, not a rounded `0.67` (2/3 = 0.6667 < 0.67, so a rounded literal would wrongly require 3-of-3). Else `'explored'`. + - `glows` = the `q` text of the gotIt questions (what you're solid on); `grows` = the `q` text of shaky/missed questions (what to revisit). No AI — pure reflection of which questions were self-rated low. +- `aggregateMastery(records)` → `{ understood, explored, new, total }` for the library view. + +### ② Earn — the Understand-Check (deep-dive panel, "Go Deeper") + +The deep-dive output already renders a "self-test questions" section from `deepdive.parseFeynman().questions`. Turn it interactive: +- Each question is a flip card: shows `q`; **Reveal answer** shows `a`; then three self-rating buttons: **Got it · Shaky · Missed**. +- After all cards rated, call `deriveCheckResult(questions, ratings)`, persist to `mastery[repoId]` (via `store.js`), and show a **Glows & Grows** summary ("Solid on: …; revisit: …") + the new level. +- Re-taking is allowed; the latest result replaces the prior one. +- The flip-card UI is DOM glue in `output-tab.js`/HTML/CSS; all scoring/leveling/summary logic lives in `mastery.js` (testable). + +### ③ See — the Mastery Map (library) + +- Each library card gains a subtle level indicator (small ring/dot — *not* a loud badge): new (faint), explored (half), understood (full/accent). +- One honest aggregate line at the top of the library: e.g. "Understood 12 of 40 · 7 explored." +- A level filter (All / Understood / Explored / New) reusing the existing library filter pattern. +- Rendered in `library.js` from the mastery records; coverage stats from `aggregateMastery`. + +## Architecture (files) + +- **Create** `mastery.js` — pure model + helpers (one responsibility: the mastery signal + scoring + aggregation). +- **Create** `tests/mastery.test.js` — `deriveCheckResult` thresholds/levels/glows-grows, `aggregateMastery`, level helpers. +- **Modify** `store.js` — add `getMastery(repoId)` / `setMastery(repoId, record)` (IDB; follow the existing scene/library store patterns). Add a store test in `tests/`. +- **Modify** `output-tab.js` (+ `output-tab.html` styles) — render the flip-card check in the deep-dive panel; wire ratings → `deriveCheckResult` → `store.setMastery` → summary + level. +- **Modify** `library.js` (+ styles) — card level indicators, the aggregate line, the level filter. + +## Testing + +- **vitest (pure):** `mastery.js` — `deriveCheckResult` (understood vs explored at the threshold boundary; glows/grows partition by rating), `aggregateMastery` counts, level ordering/labels. +- **vitest + fake-indexeddb:** `store.js` mastery get/set round-trip (matches existing `store-*.test.js` pattern). +- **DOM glue:** `node --check` on changed JS; `npm run check:html`; manual smoke (run a deep dive → take the check → see the level on the library card + aggregate update). +- Existing suite stays green; `eslint .` 0 errors; HTML parse gate passes. + +## Constraints + +- Zero-build, zero-dep, vanilla ES modules. No new AI calls, no `background.js` changes, no backend — mastery is local IDB only; BYO-key untouched. +- Mono Ink palette; no emoji on product surfaces; the card-flip/reveal motion gated behind `prefers-reduced-motion` using `--dur-*`/`--ease-*`. Tasteful — no confetti, no badge-soup. + +## Acceptance criteria + +- [ ] After a deep dive, the self-test questions render as an interactive self-graded check (reveal + Got it / Shaky / Missed). +- [ ] Completing the check persists `mastery[repoId]` and shows a Glows & Grows summary + the earned level. +- [ ] `understood` requires `score >= 2/3` (the `UNDERSTOOD_THRESHOLD` constant, ≈0.667; e.g. 2 of 3 questions); otherwise `explored`. +- [ ] Library cards show their mastery level; the library shows an honest aggregate ("Understood X of Y") and a level filter. +- [ ] `mastery.js` logic is fully unit-tested; mastery persistence is tested with fake-indexeddb. +- [ ] All existing tests pass + new tests; `eslint .` 0 errors; HTML gate passes; no AI calls or `background.js` changes added. + +## Resolved decisions + +- Earn mechanism = **self-graded flip cards** (no AI-graded MCQ in v1). +- 3 levels (new/explored/understood); understood threshold = ≥⅔ "got it" (tunable constant). +- 3-way self-rating (Got it / Shaky / Missed); "Shaky" doesn't count toward mastered, feeds "grows." +- Mastery map v1 = card indicators + aggregate line + level filter; corkboard/graph integration deferred to Spec 2. From 3eeae6a1bd33154a2d84d0ee0c773c5846ec7a1b Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:34:38 -0700 Subject: [PATCH 2/8] =?UTF-8?q?docs(spec):=20mastery=20loop=20=E2=80=94=20?= =?UTF-8?q?fold=20in=20review=20(counts,=20CRUD=20store,=20edge=20cases,?= =?UTF-8?q?=20no-%=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-16-mastery-loop-design.html | 20 +++++----- .../specs/2026-06-16-mastery-loop-design.md | 37 ++++++++++++------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/specs/2026-06-16-mastery-loop-design.html b/docs/superpowers/specs/2026-06-16-mastery-loop-design.html index 0dfd894..eb05be6 100644 --- a/docs/superpowers/specs/2026-06-16-mastery-loop-design.html +++ b/docs/superpowers/specs/2026-06-16-mastery-loop-design.html @@ -75,7 +75,8 @@

Components

lastResult: { gotIt, shaky, missed, total } | null, }

Levels: new scanned, no check · explored attempted, not passed · understood passed.

-

Pure exports (no DOM/network): MASTERY_LEVELS + levelLabel(); deriveCheckResult(questions, ratings){ level, score, glows, grows } (score = gotIt/total; understood when score ≥ (the UNDERSTOOD_THRESHOLD constant, ≈0.667 — e.g. 2 of 3 passes; compare against the 2/3 constant, not a rounded 0.67), tunable; glows = the questions you got, grows = the ones to revisit — pure reflection, no AI); aggregateMastery(records) → coverage counts.

+

Constants: UNDERSTOOD_THRESHOLD = 2/3, MASTERY_LEVELS. Pure exports (no DOM/network): levelLabel(); deriveCheckResult(questions, ratings){ level, score, gotIt, shaky, missed, total, glows, grows } (counts returned so the caller persists lastResult directly; score = gotIt/total; understood when score ≥ — compare against the 2/3 constant, not a rounded 0.67, so 2-of-3 passes; glows = questions you got, grows = ones to revisit, pure reflection, no AI); aggregateMastery(records) → coverage counts (coverage left derived in UI).

+

Edge cases: zero questionslevel: 'new', caller writes nothing (no accidental promotion); partial completion (closed before all rated) → persist nothing; retakes → latest completed result wins (no history in v1).

@@ -89,15 +90,15 @@

Components

└──────────────────────────────────────────┘ → tally → "Solid on routing · revisit middleware" → marks this repo understood, saved to your library -

After all cards: deriveCheckResult sets the level + a Glows & Grows summary from which questions you rated low, then persists to mastery[repoId]. Re-takeable; latest result wins. UI is DOM glue; all logic lives in mastery.js.

+

One card at a time (reveal → rate → auto-advance). The completion screen leads with the level earned + Glows & Grows, not a raw percentage (score is stored, not shown as the headline). Persist only on completion — write mastery[repoId] only when every question is rated; closing early writes nothing. Re-takeable; latest result wins. UI is DOM glue; all logic lives in mastery.js.

③ See — the Mastery Map (library)

Rendered in library.js from the mastery records; stats from aggregateMastery.

@@ -106,7 +107,7 @@

Architecture (files)

@@ -125,9 +126,10 @@

Constraints

Acceptance criteria

diff --git a/docs/superpowers/specs/2026-06-16-mastery-loop-design.md b/docs/superpowers/specs/2026-06-16-mastery-loop-design.md index 2a0c326..16426c6 100644 --- a/docs/superpowers/specs/2026-06-16-mastery-loop-design.md +++ b/docs/superpowers/specs/2026-06-16-mastery-loop-design.md @@ -51,33 +51,42 @@ Levels: `mastery.js` is pure (no DOM, no network) and exports: - `MASTERY_LEVELS` (ordered: new < explored < understood) + `levelLabel(level)`. -- `deriveCheckResult(questions, ratings)` → `{ level, score, glows, grows }`. +- Constants: `UNDERSTOOD_THRESHOLD = 2 / 3` and `MASTERY_LEVELS = { NEW: 'new', EXPLORED: 'explored', UNDERSTOOD: 'understood' }`. +- `deriveCheckResult(questions, ratings)` → `{ level, score, gotIt, shaky, missed, total, glows, grows }` (the counts are returned so the caller can persist `lastResult` directly). - `ratings` is an array aligned to `questions`, each `'gotIt' | 'shaky' | 'missed'`. - `score` = gotIt / total. - - `level` = `'understood'` when `score >= UNDERSTOOD_THRESHOLD` where `UNDERSTOOD_THRESHOLD = 2/3` (≈0.667 — e.g. 2 of 3 questions passes; tunable constant). Compare against the `2/3` constant, not a rounded `0.67` (2/3 = 0.6667 < 0.67, so a rounded literal would wrongly require 3-of-3). Else `'explored'`. + - `level` = `'understood'` when `score >= UNDERSTOOD_THRESHOLD`. Compare against the `2/3` constant, **not a rounded `0.67`** (2/3 = 0.6667 < 0.67, so a rounded literal would wrongly require 3-of-3; 2-of-3 must pass). Else `'explored'`. - `glows` = the `q` text of the gotIt questions (what you're solid on); `grows` = the `q` text of shaky/missed questions (what to revisit). No AI — pure reflection of which questions were self-rated low. -- `aggregateMastery(records)` → `{ understood, explored, new, total }` for the library view. + - **Zero questions** (`questions` empty) → `{ level: 'new', score: 0, total: 0, gotIt: 0, shaky: 0, missed: 0, glows: [], grows: [] }`. The caller writes nothing (see Edge cases) — no accidental promotion. +- `aggregateMastery(records)` → `{ understood, explored, new, total }` for the library view. (`coverage` is left derived in the UI, not stored.) + +### Edge cases (explicit) + +- **Zero-question check** → `deriveCheckResult` returns `level: 'new'` and the caller persists nothing. A deep dive that emitted no questions cannot earn mastery. +- **Partial completion** → if the user closes the panel before every question is rated (`ratings.length < questions.length`), **persist nothing**. Mastery is written only on a fully-rated check. +- **Retakes** → allowed; the latest completed result replaces the prior `mastery[repoId]` (latest wins). No history kept in v1. ### ② Earn — the Understand-Check (deep-dive panel, "Go Deeper") The deep-dive output already renders a "self-test questions" section from `deepdive.parseFeynman().questions`. Turn it interactive: -- Each question is a flip card: shows `q`; **Reveal answer** shows `a`; then three self-rating buttons: **Got it · Shaky · Missed**. -- After all cards rated, call `deriveCheckResult(questions, ratings)`, persist to `mastery[repoId]` (via `store.js`), and show a **Glows & Grows** summary ("Solid on: …; revisit: …") + the new level. -- Re-taking is allowed; the latest result replaces the prior one. +- **One card at a time:** show `q`; **Reveal answer** shows `a`; then three self-rating buttons (**Got it · Shaky · Missed**); rating auto-advances to the next card. Low friction. +- **Completion screen** (after all cards rated): lead with the **level earned** + **Glows & Grows** ("Solid on: …; revisit: …"), *not* a raw percentage — the score is stored but not the headline (a 66.7% feels like a grade; "Understood — revisit middleware" feels educational). +- **Persist only on completion:** call `deriveCheckResult(questions, ratings)` and write `mastery[repoId]` (via `store.js`) **only when every question is rated**. Closing early writes nothing. +- Re-taking is allowed; the latest completed result replaces the prior one. - The flip-card UI is DOM glue in `output-tab.js`/HTML/CSS; all scoring/leveling/summary logic lives in `mastery.js` (testable). ### ③ See — the Mastery Map (library) -- Each library card gains a subtle level indicator (small ring/dot — *not* a loud badge): new (faint), explored (half), understood (full/accent). -- One honest aggregate line at the top of the library: e.g. "Understood 12 of 40 · 7 explored." -- A level filter (All / Understood / Explored / New) reusing the existing library filter pattern. +- Each library card gains a subtle level indicator to the left of the title — *not* a loud badge: **new = ○** (faint outline), **explored = ◐** (half), **understood = ●** (accent fill). The text label appears only on hover/focus. +- One honest aggregate line at the top of the library: e.g. "Understood 12 of 40 · 7 explored." Never a "Mastery 82%" — that implies precision the self-graded signal doesn't have. +- A **single-select** level filter (All / Understood / Explored / New) reusing the existing library filter pattern (no multi-filter in v1). - Rendered in `library.js` from the mastery records; coverage stats from `aggregateMastery`. ## Architecture (files) - **Create** `mastery.js` — pure model + helpers (one responsibility: the mastery signal + scoring + aggregation). - **Create** `tests/mastery.test.js` — `deriveCheckResult` thresholds/levels/glows-grows, `aggregateMastery`, level helpers. -- **Modify** `store.js` — add `getMastery(repoId)` / `setMastery(repoId, record)` (IDB; follow the existing scene/library store patterns). Add a store test in `tests/`. +- **Modify** `store.js` — add `getAllMastery()` (read the full `{ [repoId]: record }` map for the library) and `setMastery(repoId, record)` (persist one already-computed record). **CRUD only** — no leveling/merge logic in the store; the level is computed in `mastery.js` and passed in. Do *not* add an `updateMastery` that computes inside the persistence layer. Follow the existing scene/library store patterns; add a store test in `tests/`. - **Modify** `output-tab.js` (+ `output-tab.html` styles) — render the flip-card check in the deep-dive panel; wire ratings → `deriveCheckResult` → `store.setMastery` → summary + level. - **Modify** `library.js` (+ styles) — card level indicators, the aggregate line, the level filter. @@ -97,9 +106,11 @@ The deep-dive output already renders a "self-test questions" section from `deepd - [ ] After a deep dive, the self-test questions render as an interactive self-graded check (reveal + Got it / Shaky / Missed). - [ ] Completing the check persists `mastery[repoId]` and shows a Glows & Grows summary + the earned level. -- [ ] `understood` requires `score >= 2/3` (the `UNDERSTOOD_THRESHOLD` constant, ≈0.667; e.g. 2 of 3 questions); otherwise `explored`. -- [ ] Library cards show their mastery level; the library shows an honest aggregate ("Understood X of Y") and a level filter. -- [ ] `mastery.js` logic is fully unit-tested; mastery persistence is tested with fake-indexeddb. +- [ ] `understood` requires `score >= 2/3` (the `UNDERSTOOD_THRESHOLD` constant, ≈0.667; e.g. 2 of 3 questions); otherwise `explored`. A 2-of-3 check earns `understood`. +- [ ] **Partial completion** (panel closed before all questions rated) persists nothing. +- [ ] A deep dive with **zero questions** cannot earn mastery — it stays `new` and writes nothing. +- [ ] Library cards show their mastery level (subtle ○ / ◐ / ● indicator, label on hover/focus); the library shows an honest aggregate ("Understood X of Y · N explored", never a "Mastery %") and a single-select level filter. +- [ ] `mastery.js` logic is fully unit-tested (incl. the 2/3 boundary, glows/grows partition, zero-question, aggregate counts); mastery persistence round-trip tested with fake-indexeddb. - [ ] All existing tests pass + new tests; `eslint .` 0 errors; HTML gate passes; no AI calls or `background.js` changes added. ## Resolved decisions From 8bf3d575ed6ad4effda89c28b755a75178f8e93c Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:41:10 -0700 Subject: [PATCH 3/8] docs(plan): The Mastery Loop implementation plan (Pillar 3, Spec 1) --- .../plans/2026-06-16-mastery-loop.html | 110 ++++ .../plans/2026-06-16-mastery-loop.md | 558 ++++++++++++++++++ 2 files changed, 668 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-mastery-loop.html create mode 100644 docs/superpowers/plans/2026-06-16-mastery-loop.md diff --git a/docs/superpowers/plans/2026-06-16-mastery-loop.html b/docs/superpowers/plans/2026-06-16-mastery-loop.html new file mode 100644 index 0000000..40cf375 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-mastery-loop.html @@ -0,0 +1,110 @@ + + + + + + The Mastery Loop — Implementation Plan + + + +
+ +
+

The Mastery Loop

+
+ Implementation plan · Pillar 3 / Spec 1 · 2026-06-16 · branch feat/mastery-loop
+ Overview for review. Full step-by-step code (TDD steps, commands, commits) lives in the companion .md. +
+
+ +
+ Goal. A self-graded mastery loop: earn a per-repo signal from the deep-dive's existing self-test questions, persist it locally, and show coverage growing across the library. No background.js changes, no new AI calls, fully local. +
+ +

Architecture

+

New pure mastery.js (all scoring/leveling/aggregation, fully unit-tested). store.js gains CRUD for a new IDB mastery store. The deep-dive panel's existing "Test Yourself" block becomes an interactive flip-card check that computes a result via mastery.js and persists it. The library reads the mastery map for per-card indicators, an honest aggregate, and a single-select level filter.

+ +
+ Phase 1 — Pure model +
+
Task 1 · mastery.js + tests create
+
mastery.js, tests/mastery.test.js
+
Full TDD: MASTERY_LEVELS, UNDERSTOOD_THRESHOLD = 2/3, levelLabel, levelRank, deriveCheckResult, aggregateMastery. Tests pin the 2/3 boundary (2-of-3 passes), glows/grows partition, zero-question → new, aggregate counts.
+
+
+ +
+ Phase 2 — Persistence +
+
Task 2 · IDB mastery store + CRUD modify
+
store/idb.js (v5→v6 + store), store.js, tests/store-mastery.test.js
+
getMastery / getAllMastery / setMastery(repoId, record), mirroring the decisions store; round-trip tested with fake-indexeddb. CRUD only — no compute in the store.
+
+
+ +
+ Phase 3 — Earn +
+
Task 3 · Interactive flip-card check modify
+
output-tab.js (deep-dive render, ~L988), output-tab.html (styles)
+
Replace the static "Test Yourself" block with one-card-at-a-time reveal → rate (Got it / Shaky / Missed) → auto-advance. On completion: compute via deriveCheckResult, show level + Glows/Grows (no %), persist via setMastery. Partial/zero-question → no write.
+
+
+ +
+ Phase 4 — See (library) +
+
Task 4 · Per-card indicator modify
+
library.js (card()), library-data.js, library.html
+
Load the mastery map once on library load, merge masteryLevel onto rows, render ○ / ◐ / ● left of the title (label on hover).
+
+
+
Task 5 · Aggregate line + level filter modify
+
library.js, library-filters.js, library.html
+
Honest aggregate ("Understood X of Y · N explored", never a %) via aggregateMastery; a single-select All/Understood/Explored/New filter mirroring the existing filter pattern.
+
+
+ +
+ Phase 5 — Verification +
+
Task 6 · Full pass
+
vitest run (prior + 2 new test files) · eslint . 0 errors · check:html · node --check · manual smoke (deep dive → check → library indicator + aggregate + filter; partial/zero-question write nothing).
+
+
+ +

Spec coverage

+ + +

Out of scope (per spec)

+

AI-graded MCQ, spaced-repetition resurfacing UI, corkboard knowledge-graph (Spec 2), mastery in the backup envelope.

+ + + +
+ + diff --git a/docs/superpowers/plans/2026-06-16-mastery-loop.md b/docs/superpowers/plans/2026-06-16-mastery-loop.md new file mode 100644 index 0000000..73ba6b3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-mastery-loop.md @@ -0,0 +1,558 @@ +# The Mastery Loop — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a self-graded mastery loop — earn a per-repo mastery signal from the deep-dive's existing self-test questions, persist it locally, and surface coverage growing across the library. + +**Architecture:** A new pure module `mastery.js` holds all scoring/leveling/aggregation (fully unit-tested). `store.js` gains CRUD persistence for a new IDB `mastery` store. The deep-dive panel's existing "Test Yourself" block becomes an interactive flip-card check that computes a result via `mastery.js` and persists it. The library reads the mastery map to show per-card level indicators, an honest aggregate, and a single-select level filter. No `background.js` changes, no new AI calls, fully local. + +**Tech Stack:** Vanilla ES modules (zero-build, no deps), Vitest (+ `fake-indexeddb` for store tests), `node --check` + `npm run check:html` for DOM/HTML glue. Brand: Mono Ink; motion behind `prefers-reduced-motion`. + +--- + +## File Structure + +- **Create** `mastery.js` — pure model: `MASTERY_LEVELS`, `UNDERSTOOD_THRESHOLD`, `levelLabel`, `levelRank`, `deriveCheckResult`, `aggregateMastery`. No DOM/network/IDB. +- **Create** `tests/mastery.test.js` — full unit coverage. +- **Modify** `store/idb.js` — register the `mastery` store (+ version bump). +- **Modify** `store.js` — `getMastery(repoId)`, `getAllMastery()`, `setMastery(repoId, record)` (CRUD; mirror the `decisions` store). +- **Create** `tests/store-mastery.test.js` — persistence round-trip with `fake-indexeddb`. +- **Modify** `output-tab.js` (+ `output-tab.html` styles) — interactive flip-card check in the deep-dive panel. +- **Modify** `library.js` (+ `library-data.js`, `library-filters.js`, `library.html` styles) — per-card level indicator, aggregate line, single-select level filter. + +--- + +## Phase 1 — The pure model + +### Task 1: `mastery.js` + tests + +**Files:** +- Create: `mastery.js` +- Test: `tests/mastery.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/mastery.test.js +import { describe, it, expect } from 'vitest'; +import { + MASTERY_LEVELS, UNDERSTOOD_THRESHOLD, levelLabel, levelRank, + deriveCheckResult, aggregateMastery, +} from '../mastery.js'; + +const Q = (n) => Array.from({ length: n }, (_, i) => ({ q: `q${i}`, a: `a${i}` })); + +describe('deriveCheckResult', () => { + it('marks understood at exactly 2 of 3 (the 2/3 boundary, not a rounded 0.67)', () => { + const r = deriveCheckResult(Q(3), ['gotIt', 'gotIt', 'missed']); + expect(r.level).toBe('understood'); + expect(r.score).toBeCloseTo(2 / 3); + expect(r.gotIt).toBe(2); + }); + + it('marks explored below the threshold', () => { + expect(deriveCheckResult(Q(3), ['gotIt', 'missed', 'missed']).level).toBe('explored'); + expect(deriveCheckResult(Q(2), ['gotIt', 'shaky']).level).toBe('explored'); // 0.5 < 2/3 + }); + + it('marks understood at 4 of 6', () => { + expect(deriveCheckResult(Q(6), ['gotIt', 'gotIt', 'gotIt', 'gotIt', 'shaky', 'missed']).level).toBe('understood'); + }); + + it('partitions glows (gotIt) from grows (shaky/missed) by question text', () => { + const r = deriveCheckResult(Q(3), ['gotIt', 'shaky', 'missed']); + expect(r.glows).toEqual(['q0']); + expect(r.grows).toEqual(['q1', 'q2']); + expect({ gotIt: r.gotIt, shaky: r.shaky, missed: r.missed, total: r.total }).toEqual({ gotIt: 1, shaky: 1, missed: 1, total: 3 }); + }); + + it('returns level new with zero counts for an empty check (no accidental promotion)', () => { + const r = deriveCheckResult([], []); + expect(r).toEqual({ level: 'new', score: 0, gotIt: 0, shaky: 0, missed: 0, total: 0, glows: [], grows: [] }); + }); +}); + +describe('aggregateMastery', () => { + it('counts levels across a records map', () => { + const recs = { + 'a/b': { level: 'understood' }, 'c/d': { level: 'understood' }, + 'e/f': { level: 'explored' }, 'g/h': { level: 'new' }, + }; + expect(aggregateMastery(recs)).toEqual({ total: 4, understood: 2, explored: 1, new: 1 }); + }); + it('treats unknown/missing levels as new and tolerates empty input', () => { + expect(aggregateMastery({})).toEqual({ total: 0, understood: 0, explored: 0, new: 0 }); + expect(aggregateMastery({ 'x/y': {} }).new).toBe(1); + }); +}); + +describe('level helpers', () => { + it('labels and ranks levels', () => { + expect(levelLabel('understood')).toBe('Understood'); + expect(levelLabel('whatever')).toBe('New'); + expect(levelRank('new')).toBeLessThan(levelRank('explored')); + expect(levelRank('explored')).toBeLessThan(levelRank('understood')); + }); + it('exposes the 2/3 threshold constant', () => { + expect(UNDERSTOOD_THRESHOLD).toBeCloseTo(2 / 3); + expect(MASTERY_LEVELS.UNDERSTOOD).toBe('understood'); + }); +}); +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `npx vitest run tests/mastery.test.js` +Expected: FAIL — `Cannot find module '../mastery.js'`. + +- [ ] **Step 3: Write the module** + +```js +// mastery.js +// Pure model for the Knowledge Game's mastery signal. No DOM, no network, no IDB — +// just scoring/leveling/aggregation, so it is fully unit-testable. The signal is +// earned (self-graded) from the deep-dive's self-test questions; store.js persists +// the already-computed record per repo. + +export const MASTERY_LEVELS = { NEW: 'new', EXPLORED: 'explored', UNDERSTOOD: 'understood' }; + +// "understood" = at least two-thirds of questions self-rated "got it". Compare +// against the 2/3 fraction, NOT a rounded 0.67 (2/3 = 0.6667 < 0.67 would wrongly +// require 3-of-3); 2-of-3 must pass. +export const UNDERSTOOD_THRESHOLD = 2 / 3; + +const LEVEL_LABELS = { new: 'New', explored: 'Explored', understood: 'Understood' }; +const LEVEL_ORDER = { new: 0, explored: 1, understood: 2 }; + +/** Display label for a level; unknown → 'New'. */ +export function levelLabel(level) { + return LEVEL_LABELS[level] || LEVEL_LABELS.new; +} + +/** Numeric rank for ordering (new < explored < understood). */ +export function levelRank(level) { + return LEVEL_ORDER[level] ?? 0; +} + +/** + * Score a self-graded understanding check. + * @param {{q:string,a:string}[]} questions + * @param {('gotIt'|'shaky'|'missed')[]} ratings aligned to questions + * @returns {{level:string,score:number,gotIt:number,shaky:number,missed:number,total:number,glows:string[],grows:string[]}} + */ +export function deriveCheckResult(questions, ratings) { + const qs = Array.isArray(questions) ? questions : []; + const rs = Array.isArray(ratings) ? ratings : []; + const total = qs.length; + if (total === 0) { + return { level: MASTERY_LEVELS.NEW, score: 0, gotIt: 0, shaky: 0, missed: 0, total: 0, glows: [], grows: [] }; + } + let gotIt = 0, shaky = 0, missed = 0; + const glows = [], grows = []; + qs.forEach((q, i) => { + const text = (q && q.q) || ''; + const r = rs[i]; + if (r === 'gotIt') { gotIt++; glows.push(text); } + else if (r === 'shaky') { shaky++; grows.push(text); } + else { missed++; grows.push(text); } + }); + const score = gotIt / total; + const level = score >= UNDERSTOOD_THRESHOLD ? MASTERY_LEVELS.UNDERSTOOD : MASTERY_LEVELS.EXPLORED; + return { level, score, gotIt, shaky, missed, total, glows, grows }; +} + +/** + * Coverage counts across a map of mastery records (repoId → record). + * @returns {{total:number,understood:number,explored:number,new:number}} + */ +export function aggregateMastery(records) { + const out = { total: 0, understood: 0, explored: 0, new: 0 }; + for (const rec of Object.values(records || {})) { + out.total++; + const lvl = rec && rec.level; + if (lvl === MASTERY_LEVELS.UNDERSTOOD) out.understood++; + else if (lvl === MASTERY_LEVELS.EXPLORED) out.explored++; + else out.new++; + } + return out; +} +``` + +- [ ] **Step 4: Run tests and confirm pass** + +Run: `npx vitest run tests/mastery.test.js` +Expected: PASS (all describe blocks green). + +- [ ] **Step 5: Lint + commit** + +Run: `npx eslint mastery.js tests/mastery.test.js` (expect 0 errors) +```bash +git add mastery.js tests/mastery.test.js +git commit -m "feat(mastery): pure mastery model (scoring, levels, aggregation)" +``` + +--- + +## Phase 2 — Persistence + +### Task 2: Register the `mastery` IDB store + store CRUD + +**Files:** +- Modify: `store/idb.js:9-10` (version + STORES) +- Modify: `store.js` (add mastery functions after the decisions section, ~line 252) +- Test: `tests/store-mastery.test.js` + +- [ ] **Step 1: Register the store** + +In `store/idb.js`, bump the version and add the store. Replace lines 9-10: + +```js +// v2 added 'collections'. v3 added 'decisions'. v4 added 'snapshots'. v5 added +// 'scenes'. v6 added 'mastery' (the Knowledge Game signal). Each upgrade is +// additive — onupgradeneeded creates any new store, so existing data survives. +const DB_VERSION = 6; +const STORES = ['repos', 'nodes', 'edges', 'collections', 'decisions', 'snapshots', 'scenes', 'mastery']; +``` + +- [ ] **Step 2: Write the failing store test** + +```js +// tests/store-mastery.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { setMastery, getMastery, getAllMastery } from '../store.js'; + +describe('mastery persistence', () => { + it('round-trips a record by repoId', async () => { + const rec = { level: 'understood', lastCheckedAt: '2026-06-16T00:00:00.000Z', lastResult: { gotIt: 2, shaky: 1, missed: 0, total: 3 } }; + await setMastery('honojs/hono', rec); + expect(await getMastery('honojs/hono')).toEqual(rec); + }); + + it('returns null for an unknown repo', async () => { + expect(await getMastery('nope/none')).toBeNull(); + }); + + it('getAllMastery returns a repoId→record map', async () => { + await setMastery('a/b', { level: 'explored' }); + await setMastery('c/d', { level: 'understood' }); + const map = await getAllMastery(); + expect(map['a/b'].level).toBe('explored'); + expect(map['c/d'].level).toBe('understood'); + }); +}); +``` + +- [ ] **Step 3: Run it and confirm it fails** + +Run: `npx vitest run tests/store-mastery.test.js` +Expected: FAIL — `setMastery is not a function` (not yet exported). + +- [ ] **Step 4: Add the store functions** + +In `store.js`, after the decisions section (after `listDecisions`, ~line 252), add (mirrors the `decisions` store; keyed by raw repoId): + +```js +// ─── mastery: per-repo Knowledge-Game signal ───────────────────────────────── + +/** Persist a repo's mastery record (already computed by mastery.js). Throws on failure. */ +export async function setMastery(repoId, record) { + if (!repoId) throw new Error('setMastery needs a repoId'); + await idbPut('mastery', { id: repoId, payload: record }); +} + +/** Get a repo's mastery record, or null if none / on store error. */ +export async function getMastery(repoId) { + try { + const row = await idbGet('mastery', repoId); + return (row && row.payload) || null; + } catch { + return null; + } +} + +/** All mastery records as a { repoId: record } map. Best-effort — {} on failure. */ +export async function getAllMastery() { + try { + const rows = await idbGetAll('mastery'); + const out = {}; + for (const r of rows || []) if (r && r.id) out[r.id] = r.payload; + return out; + } catch { + return {}; + } +} +``` + +- [ ] **Step 5: Run tests + confirm pass** + +Run: `npx vitest run tests/store-mastery.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 6: Guard the new store in backup (consistency with existing stores)** + +`exportStores`/`importStores`/`clearLibrary` enumerate stores explicitly. For v1, mastery does NOT need to be in the backup envelope (it's derivable by re-taking checks, and adding it widens scope). Leave backup as-is. *(Noted deliberately so a reviewer doesn't flag it as a gap.)* + +- [ ] **Step 7: Full suite + lint + commit** + +Run: `npx vitest run` (all pass), `npx eslint store.js store/idb.js tests/store-mastery.test.js` (0 errors) +```bash +git add store/idb.js store.js tests/store-mastery.test.js +git commit -m "feat(mastery): IDB mastery store + CRUD persistence" +``` + +--- + +## Phase 3 — Earn (the deep-dive check) + +### Task 3: Interactive flip-card understand-check + +**Files:** +- Modify: `output-tab.js` — the deep-dive render (`renderDeepDive`, ~line 967) where `questionsBlock` is built (line 988-989); add the check renderer + rating handler. +- Modify: `output-tab.html` — styles for the check (next to the `.dd-q` rules, ~line 240). + +- [ ] **Step 1: Read the current deep-dive render** + +Run: `grep -n "renderDeepDive\|questionsBlock\|fey.questions\|dd-q\|t10\|#t10" output-tab.js` and read `renderDeepDive` (~960-1010). Confirm: `fey.questions` is `[{q,a}]`, the block renders into the Deep Dive panel, and `lastData.repoId` is in scope. + +Also confirm the persistence path: run `grep -n "saveDecision\|from './store.js'\|import .* store" output-tab.js` to see whether the output tab already writes IDB **directly via `store.js`** (e.g. the Decision Log's `saveDecision`) or routes through a `background.js` message. Mirror that path for mastery. A direct `store.js` call from the output-tab page is correct here (extension pages share the `repolens` IDB origin, so `library.html` reads what `output-tab.html` writes) and keeps the spec's "no `background.js` changes" constraint. + +- [ ] **Step 2: Add check styles** + +In `output-tab.html`, after the `.dd-q` rules (~line 242), add: + +```css + .uc { margin-top: 8px; } + .uc-progress { font: 600 11px/1 var(--mono, monospace); letter-spacing:.08em; color: var(--text-faint); margin-bottom: 10px; } + .uc-q { font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 12px; } + .uc-a { font-size: 13px; color: var(--text-sub); line-height: 1.6; margin: 10px 0 14px; } + .uc-btn { font: 600 12px/1 inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border-2); background: var(--surface); color: var(--text-sub); cursor: pointer; margin-right: 8px; transition: color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out); } + .uc-btn:hover { color: var(--text); border-color: var(--accent); } + .uc-btn:active { transform: scale(0.97); } + .uc-done .uc-level { font: 800 13px/1 inherit; padding: 6px 12px; border-radius: 8px; display: inline-block; margin-bottom: 12px; } + .uc-done .uc-level.understood { background: var(--ok-bg); color: var(--ok-ink); } + .uc-done .uc-level.explored { background: var(--warn-bg); color: var(--warn-ink); } + .uc-gg-title { font: 700 10px/1 var(--mono, monospace); letter-spacing:.13em; text-transform: uppercase; color: var(--text-muted); margin: 10px 0 6px; } + .uc-gg li { font-size: 13px; color: var(--text-sub); line-height: 1.6; } + .uc-saved { font-size: 11px; color: var(--ok-ink); margin-top: 10px; } +``` + +- [ ] **Step 3: Add the check renderer + handler in `output-tab.js`** + +Add near `renderDeepDive` (and import the model + store at the top of the file with the other imports): + +```js +import { deriveCheckResult, levelLabel } from './mastery.js'; +import { setMastery } from './store.js'; +``` + +```js +// Self-graded "Check your understanding": one card at a time (reveal → rate → +// auto-advance). Persists mastery only on completion. Pure scoring is in mastery.js. +function renderUnderstandCheck(host, questions, repoId) { + if (!host || !questions?.length) return; // zero questions → no check, no write + const ratings = []; + let i = 0; + + const drawCard = () => { + const q = questions[i]; + host.innerHTML = `
+
Question ${i + 1} of ${questions.length}
+
${esc(q.q)}
+ +
`; + }; + + const drawAnswer = () => { + const q = questions[i]; + host.querySelector('.uc').innerHTML = ` +
Question ${i + 1} of ${questions.length}
+
${esc(q.q)}
+
${esc(q.a)}
+ + + `; + }; + + const finish = async () => { + const result = deriveCheckResult(questions, ratings); + const record = { + level: result.level, + lastCheckedAt: new Date().toISOString(), + lastResult: { gotIt: result.gotIt, shaky: result.shaky, missed: result.missed, total: result.total }, + }; + const list = (items) => items.length ? `` : ''; + host.innerHTML = `
+ ${esc(levelLabel(result.level))} + ${result.glows.length ? `
Solid on
${list(result.glows)}` : ''} + ${result.grows.length ? `
Revisit
${list(result.grows)}` : ''} + +
`; + try { + await setMastery(repoId, record); + const saved = host.querySelector('.uc-saved'); + if (saved) saved.hidden = false; + } catch (err) { + console.error('[mastery] save failed', err); + } + }; + + host.addEventListener('click', (e) => { + const action = e.target.closest('[data-uc]')?.dataset.uc; + if (!action) return; + if (action === 'reveal') { drawAnswer(); return; } + ratings[i] = action; // gotIt | shaky | missed + i++; + if (i < questions.length) drawCard(); + else finish(); // persist only here, when every card is rated + }); + + drawCard(); +} +``` + +- [ ] **Step 4: Wire it into the deep-dive render** + +Replace the static `questionsBlock` (line 988-989) with a placeholder host, and mount the check after the panel HTML is set. Concretely: keep a container `
Check your understanding
` in place of the old "Test Yourself" block, then after the deep-dive panel's `innerHTML` is assigned, call: + +```js +const ucHost = document.getElementById('dd-understand-check'); +renderUnderstandCheck(ucHost, fey.questions || [], lastData?.repoId); +``` + +(Read the surrounding assignment to place this call right after the panel HTML is written, like `renderCanvas`'s mount pattern.) + +- [ ] **Step 5: Verify** + +Run: `node --check output-tab.js && npm run check:html` +Manual smoke: run a deep dive → the "Check your understanding" card appears → reveal → rate through all → see the level + Glows/Grows + "Saved to your library"; close mid-way → nothing saved. + +- [ ] **Step 6: Commit** + +```bash +git add output-tab.js output-tab.html +git commit -m "feat(mastery): interactive self-graded understand-check in the deep dive" +``` + +--- + +## Phase 4 — See (the library mastery map) + +### Task 4: Per-card mastery indicator + +**Files:** +- Modify: `library.js` — load the mastery map where rows are loaded; pass level into `card()` (~line 152); render the indicator. +- Modify: `library-data.js` — thread a `mastery` level onto the row shape (or merge in `library.js`). +- Modify: `library.html` — indicator styles. + +- [ ] **Step 1: Read the load + card render path** + +Run: `grep -n "getAllMastery\|allRows\|libraryRow\|scrollPoints\|function card\|render(" library.js library-data.js` and read `card()` (~152-192) + where `allRows` is populated. Determine the single place the mastery map should be fetched (once, on library load) and merged onto rows by `repoId`. + +- [ ] **Step 2: Indicator styles** + +In `library.html`, add: + +```css + .lib-mastery { display:inline-block; width:11px; text-align:center; margin-right:6px; vertical-align:baseline; } + .lib-mastery.m-new { color: var(--text-faint); } + .lib-mastery.m-explored { color: var(--warn-ink); } + .lib-mastery.m-understood { color: var(--accent); } +``` + +- [ ] **Step 3: Load mastery + merge onto rows** + +Where the library loads its rows (the function that fills `allRows`), `import { getAllMastery } from './store.js';` and `import { levelLabel } from './mastery.js';`, fetch the map once and set `row.masteryLevel = (masteryMap[row.repoId]?.level) || 'new'` on each row. (Do this in `library.js` at load — keep `library-data.js` pure if it doesn't already touch the store.) + +- [ ] **Step 4: Render the glyph in `card()`** + +In `card(r, i)` (~line 152), before the repo title, add a glyph mapped from `r.masteryLevel` (`new → ○`, `explored → ◐`, `understood → ●`) with the level as the `title` (hover label): + +```js +const M_GLYPH = { new: '○', explored: '◐', understood: '●' }; +const mLevel = r.masteryLevel || 'new'; +const masteryDot = `${M_GLYPH[mLevel]}`; +``` + +Insert `${masteryDot}` immediately before the card's title text in the returned template. + +- [ ] **Step 5: Verify + commit** + +Run: `node --check library.js library-data.js && npm run check:html` +Manual: a repo you marked understood shows ● on its card. +```bash +git add library.js library-data.js library.html +git commit -m "feat(mastery): per-card mastery indicator in the library" +``` + +### Task 5: Aggregate line + single-select level filter + +**Files:** +- Modify: `library.js` — render the aggregate line; add the level-filter control + state. +- Modify: `library-filters.js` — apply the level filter in `applyFilters`. +- Modify: `library.html` — filter control markup/styles (near the existing sort/lang filters). + +- [ ] **Step 1: Read the filter architecture** + +Run: `grep -n "applyFilters\|state\.\|capability\|lang-filter\|lib-sort\|libraryStats" library.js library-filters.js library-data.js` and read `applyFilters` (in `library-filters.js`) + how an existing filter (e.g. `capability` or `lang`) is wired end-to-end: the `state` field, the control that sets it, and where `applyFilters` reads it. Mirror that exact pattern for `state.mastery`. + +- [ ] **Step 2: Aggregate line** + +Using `aggregateMastery` (`import { aggregateMastery } from './mastery.js';`) over the loaded mastery map, render one line near the library header: + +```js +const agg = aggregateMastery(masteryMap); +const masteryLine = agg.total + ? `Understood ${agg.understood} of ${agg.total}${agg.explored ? ` · ${agg.explored} explored` : ''}` + : ''; +``` + +Insert into the header area as plain text (no percentage). (Read where the existing header/stats render to place it.) + +- [ ] **Step 3: Add a single-select level filter** + +Add a control (mirroring the existing language/sort ` - ${hilite(r.name, hq)} + ${masteryDot}${hilite(r.name, hq)} ${isDemo(r) ? 'DEMO' : ''} ${owner ? `${hilite(owner, hq)}` : ''} ${platformBadge} @@ -2630,12 +2634,13 @@ async function init() { document.getElementById('lib-btn-corkboard')?.addEventListener('click', toggleCorkboardView); wireToolbar(); // before the empty-state return, so Import works on an empty library - const [points, cachedList, prefs, savedCollections, savedDecisions] = await Promise.all([ + const [points, cachedList, prefs, savedCollections, savedDecisions, masteryMap] = await Promise.all([ scrollPoints(), listCached().catch(() => []), chrome.storage.local.get(['librarySort', 'mascotEnabled', 'repolens_pinned', SAVED_FILTERS_KEY]).catch(() => ({})), listCollections().catch(() => []), listDecisions().catch(() => []), + getAllMastery(), ]); decisionMap = new Map(savedDecisions.map((d) => [d.repoId, d])); pinned = new Set(Array.isArray(prefs?.repolens_pinned) ? prefs.repolens_pinned : []); @@ -2685,6 +2690,7 @@ async function init() { hasCache: !!cached, blurb: r.blurb || cached?.description || '', searchText: searchParts.join(' '), + masteryLevel: masteryMap[r.repoId]?.level || 'new', }; }); From 195a7fff584fb097d5a8fb4b6038df2688ece280 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:10:26 -0700 Subject: [PATCH 8/8] feat(mastery): library aggregate line + level filter --- library-filters.js | 4 +++- library.html | 7 +++++++ library.js | 28 +++++++++++++++++++++++++--- tests/library-filters.test.js | 14 ++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/library-filters.js b/library-filters.js index 507d586..7da73c3 100644 --- a/library-filters.js +++ b/library-filters.js @@ -11,7 +11,7 @@ const FIT_ORDER = ['strong', 'solid', 'care', 'risky']; /** * Filter + sort the library rows for display/export. * @param {Array} allRows every library row - * @param {{ query?: string, sort?: string, collection?: string, decision?: string, lang?: string }} state + * @param {{ query?: string, sort?: string, collection?: string, decision?: string, lang?: string, mastery?: string }} state * @param {{ decisionMap?: Map, evalMap?: Map, rubric?: Array, collections?: Array, nlFilter?: object }} ctx * @returns {Array} the visible rows, ordered */ @@ -66,6 +66,8 @@ export function applyFilters(allRows, state, ctx = {}) { const lq = state.lang.toLowerCase(); rows = rows.filter((r) => (r.language || r.languages?.[0]?.name || '').toLowerCase() === lq); } + // Mastery level filter — repos with no record default to 'new'. + if (state.mastery) rows = rows.filter((r) => (r.masteryLevel || 'new') === state.mastery); // NL filter: restrict to the AI-ranked id list, preserving the AI order. if (nlFilter?.ids?.length) { const idOrder = new Map(nlFilter.ids.map((id, i) => [id, i])); diff --git a/library.html b/library.html index 8347dfe..5b42f65 100644 --- a/library.html +++ b/library.html @@ -252,6 +252,7 @@ .ls-pill.risky { background: var(--fit-risky); color: #250a08; } .ls-pill.unrated{ background: color-mix(in srgb, var(--text-muted) 18%, var(--surface)); color: var(--sub); } .ls-health { margin-left: auto; font: 600 12px var(--mono); color: var(--sub); } + .ls-mastery { font: 600 12px var(--mono); color: var(--sub); } .ls-bar { display: flex; height: 6px; border-radius: 999px; overflow: hidden; width: 80px; flex-shrink: 0; gap: 1px; } .ls-bar-seg { border-radius: 999px; } .ls-bar-strong { background: var(--fit-strong); } @@ -615,6 +616,12 @@

Library

+
diff --git a/library.js b/library.js index d458183..8907db8 100644 --- a/library.js +++ b/library.js @@ -4,7 +4,7 @@ // re-scan / source / remove actions. import { scrollPoints, deleteRepo, deleteSnapshots, exportStores, importStores, clearLibrary, listCollections, saveCollection, deleteCollection, listDecisions, saveDecision, listAllSnapshots, getLibraryGraph, getScene, saveScene, saveRepo, deleteScene, getAllMastery } from './store.js'; -import { levelLabel } from './mastery.js'; +import { levelLabel, aggregateMastery } from './mastery.js'; import { introStageA, shouldOfferMilestone, milestoneSteps, COPY } from './onboarding.js'; import { startCoachmark } from './coachmark.js'; import { DEMO_REPO, demoScene, isDemo } from './demo-repo.js'; @@ -100,7 +100,11 @@ let allRows = []; let snapsByRepo = new Map(); // repoId → snaps[] (batch-loaded once in init) let cacheByRepo = new Map(); // repoId → full cached analysis (instant reopen) let decisionMap = new Map(); // repoId → decision payload -const state = { query: '', sort: 'fit', capability: '', collection: '', decision: '', lang: '', view: 'list' }; +const state = { query: '', sort: 'fit', capability: '', collection: '', decision: '', lang: '', mastery: '', view: 'list' }; + +// Mastery records (repoId → { level, ... }), loaded once in init. Drives the +// header aggregate line and the level filter. +let masteryMap = {}; // Fit levels best→worst — module-level so cards, the compare modal, and the stats // bar share one source. (Was re-declared per-function, leaving the compare modal @@ -905,6 +909,14 @@ function renderStats() { const triagePill = s.total > 0 ? `${triagePct}% triaged` : ''; + // Honest mastery coverage — counts, never a percentage. + const agg = aggregateMastery(masteryMap); + const masteryLine = agg.total + ? `Understood ${agg.understood} of ${agg.total}${agg.explored ? ` · ${agg.explored} explored` : ''}` + : ''; + const masterySummary = masteryLine + ? `${masteryLine}` + : ''; host.innerHTML = String(html` ${s.total} repo${s.total === 1 ? '' : 's'} ${triagePill} @@ -913,6 +925,7 @@ function renderStats() { ${s.avgHealth != null ? html`avg health ${s.avgHealth}` : ''} ${stalePill} ${decSummary} + ${masterySummary} `); countUpStat(host.querySelector('.ls-total-n')); countUpStat(host.querySelector('.ls-health-n')); @@ -2634,7 +2647,7 @@ async function init() { document.getElementById('lib-btn-corkboard')?.addEventListener('click', toggleCorkboardView); wireToolbar(); // before the empty-state return, so Import works on an empty library - const [points, cachedList, prefs, savedCollections, savedDecisions, masteryMap] = await Promise.all([ + const [points, cachedList, prefs, savedCollections, savedDecisions, loadedMastery] = await Promise.all([ scrollPoints(), listCached().catch(() => []), chrome.storage.local.get(['librarySort', 'mascotEnabled', 'repolens_pinned', SAVED_FILTERS_KEY]).catch(() => ({})), @@ -2642,6 +2655,7 @@ async function init() { listDecisions().catch(() => []), getAllMastery(), ]); + masteryMap = loadedMastery || {}; decisionMap = new Map(savedDecisions.map((d) => [d.repoId, d])); pinned = new Set(Array.isArray(prefs?.repolens_pinned) ? prefs.repolens_pinned : []); savedFilters = Array.isArray(prefs?.[SAVED_FILTERS_KEY]) ? prefs[SAVED_FILTERS_KEY] : []; @@ -2848,6 +2862,14 @@ async function init() { }); } + const masterySel = document.getElementById('mastery-filter'); + if (masterySel) { + masterySel.addEventListener('change', (e) => { + state.mastery = e.target.value; + render(); + }); + } + // Vee onboarding: resume a pending intro, or offer the milestone tour. await checkOnboarding(); } diff --git a/tests/library-filters.test.js b/tests/library-filters.test.js index 4d7cdd0..2df0e0e 100644 --- a/tests/library-filters.test.js +++ b/tests/library-filters.test.js @@ -31,6 +31,20 @@ describe('applyFilters', () => { expect(out.map((r) => r.repoId).sort()).toEqual(['a/one', 'a/three']); }); + it('mastery filter keeps only rows at that level; missing masteryLevel defaults to new', () => { + const masteryRows = [ + mkRow('a/one', { masteryLevel: 'understood' }), + mkRow('a/two', { masteryLevel: 'explored' }), + mkRow('a/three'), // no masteryLevel → defaults to 'new' + ]; + const understood = applyFilters(masteryRows, { ...base, mastery: 'understood' }, {}); + expect(understood.map((r) => r.repoId)).toEqual(['a/one']); + + // The `|| 'new'` default path: a row with no masteryLevel matches mastery='new'. + const fresh = applyFilters(masteryRows, { ...base, mastery: 'new' }, {}); + expect(fresh.map((r) => r.repoId)).toEqual(['a/three']); + }); + it('decision=undecided hides rows that have a saved decision', () => { const decisionMap = new Map([['a/one', { decision: 'adopt', savedAt: '2026-01-01' }]]); const out = applyFilters(rows, { ...base, decision: 'undecided' }, { decisionMap });