Skip to main content

Learning library IA redesign — 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: Turn /learn from an 8-card index over a mis-filed taxonomy into a single searchable catalog surface with a taxonomy that describes its content and a gate that keeps it that way.

Architecture: LibraryPage.tsx stays the one registered route component and dispatches to LibraryIndex (grid, search, results mode) or DomainView (toolbar, topic sections), both built from shared LessonRow / FilterChips / CatalogSearch parts. Content is re-filed by frontmatter only, and validateCatalog gains one rule so the re-filing cannot decay.

Tech Stack: Docusaurus 3, React 18, TypeScript strict, vitest + @testing-library/react + jsdom, Node ESM build scripts.

Spec: docs/superpowers/specs/2026-07-31-learn-library-ia-redesign-design.md

Worktree: copilot-worktrees/azure-wiki-learn-ia, branch feat/learn-library-ia, off origin/main.


Ground rules

  • Run everything from the worktree root. PowerShell: chain with ;, not &&.
  • npm test is vitest run. Target one file with npx vitest run <path>.
  • After any frontmatter change run npm run catalog:generate, or catalog:check will fail because the committed src/generated/learningCatalog.json is stale.
  • This repo is published. Do not add real customer names, TPIDs, subscription/tenant/app IDs, or internal links to anything under docs/.
  • Commit after each task. Do not squash tasks together.

File structure

Create

FileResponsibility
src/features/learning/LibraryIndex.tsxHeader, resume strip, domain grid, flat results mode
src/features/learning/DomainView.tsxBreadcrumb, domain header, toolbar, topic sections
src/features/learning/LessonRow.tsxThe compact single-line lesson row, shared
src/features/learning/DomainCard.tsxGrid card with lesson preview and progress
src/features/learning/CatalogSearch.tsxSearch input and match ranking
src/features/learning/FilterChips.tsxLevel / type / path chip group
src/features/learning/useCatalogFilters.tsFilter state synced to the query string
src/features/learning/__tests__/LibraryIndex.test.tsxIndex behaviour
src/features/learning/__tests__/DomainView.test.tsxDomain behaviour
src/features/learning/__tests__/useCatalogFilters.test.tsQuery-string round-trip

Modify

FileChange
scripts/catalog/model.mjsAdd the empty-topic rule to validateCatalog
scripts/__tests__/learning-contracts.test.mjsEight populated domains → nine
learning/taxonomy.mjsAdd dp-300-exam-prep; delete two emptied topics
25 files under docs/domain / topic frontmatter, per the spec table
26 files under docs/Strip numeric prefix from frontmatter title
src/features/learning/LibraryPage.tsxReduce to a path dispatcher
src/features/learning/ReviewStatus.tsxMuted variant for current
src/features/learning/learning.cssRewrite onto dbhub-* tokens and lp-* patterns
src/generated/learningCatalog.jsonRegenerated output, committed

Delete

FileReason
src/features/learning/__tests__/LibraryPage.test.tsxSplit into the two new test files

Task 1: Add the empty-topic rule to validateCatalog

Written first and expected to fail, because today's taxonomy declares four topics no lesson uses. Task 3 turns it green.

Files:

  • Modify: scripts/catalog/model.mjs (validateCatalog, from line 133)

  • Test: scripts/__tests__/learning-contracts.test.mjs

  • Step 1: Write the failing test

Append to scripts/__tests__/learning-contracts.test.mjs:

it('rejects a topic that no lesson references', () => {
const lessons = [
{
id: 'x', route: '/a', domain: 'security-governance', topic: 'identity-access',
level: 'foundation', content_type: 'lesson', review_state: 'current',
last_reviewed: '2026-01-01', review_interval_days: 180, sources: [],
},
];
expect(() => validateCatalog(lessons, {paths: []}))
.toThrow(/Unreferenced topic/);
});

Import validateCatalog from ../catalog/model.mjs at the top if it is not already imported.

  • Step 2: Run it and confirm it fails

Run: npx vitest run scripts/__tests__/learning-contracts.test.mjs Expected: FAIL — no error is thrown, because the rule does not exist yet.

  • Step 3: Implement the rule

In validateCatalog, after the for (const lesson of lessons) loop and before the path validation block, add:

const referencedTopics = new Set(lessons.map((lesson) => `${lesson.domain}/${lesson.topic}`));
for (const domain of domains) {
for (const topic of domain.topics ?? []) {
const key = `${domain.id}/${topic.id}`;
if (!referencedTopics.has(key)) {
throw new Error(`Unreferenced topic: ${key} is declared in learning/taxonomy.mjs but no lesson uses it`);
}
}
}
  • Step 4: Run the test and confirm it passes

Run: npx vitest run scripts/__tests__/learning-contracts.test.mjs Expected: the new test PASSES.

  • Step 5: Confirm the rule fires on the real catalog

Run: npm run catalog:check Expected: FAIL naming one of azure-database-platforms/azure-sql-managed-instance, monitoring-performance-automation/ai-assisted-operations, migration-modernization/data-movement, migration-modernization/compatibility-modernization.

This failure is the point. It stays red until Task 3.

  • Step 6: Commit

git add -A; git commit -m "feat(catalog): reject taxonomy topics that no lesson references"


Task 2: Add the dp-300-exam-prep domain to the taxonomy

Files:

  • Modify: learning/taxonomy.mjs

  • Test: scripts/__tests__/learning-contracts.test.mjs

  • Step 1: Update the contract test from eight domains to nine

In learning-contracts.test.mjs, the test named defines exactly eight populated release-one domains becomes defines exactly nine populated release-one domains and asserts 9.

  • Step 2: Run it and confirm it fails

Run: npx vitest run scripts/__tests__/learning-contracts.test.mjs Expected: FAIL — received 8, expected 9.

  • Step 3: Add the domain

In learning/taxonomy.mjs, append to the domains array:

domain('dp-300-exam-prep', 'DP-300 exam preparation', 'Plan, revise, and track readiness for the DP-300 certification.', ['exam-blueprint', 'study-and-revision']),
  • Step 4: Run the test and confirm it passes

Run: npx vitest run scripts/__tests__/learning-contracts.test.mjs

  • Step 5: Commit

git add -A; git commit -m "feat(taxonomy): add DP-300 exam preparation domain"

Note: catalog:check is still red — the new domain has no lessons yet, and four topics are still unreferenced. Task 3 fixes both.


Task 3: Re-file 25 lessons and delete the two emptied topics

The content change. No lesson body is edited — only the domain and topic frontmatter keys.

Files:

  • Modify: the 25 files in the spec's re-file table

  • Modify: learning/taxonomy.mjs (delete two topics)

  • Modify: src/generated/learningCatalog.json (regenerated)

  • Step 1: Apply the 25 frontmatter edits

Work straight down the spec table. For each file, set domain: and topic: to the target values. Change nothing else — not sidebar_position, not level, not content_type, not last_reviewed.

  • Step 2: Delete the two emptied topics

In learning/taxonomy.mjs:

  • remove 'operations-frameworks' from azure-database-platforms

  • remove 'migration-labs' from migration-modernization

  • Step 3: Regenerate the catalog

Run: npm run catalog:generate Expected: Catalog generated for 92 lessons. If the count is not 92, a frontmatter edit broke a file — fix before continuing.

  • Step 4: Verify the distribution against the spec table

Run:

python -X utf8 -c "
import json, collections
c = json.load(open('src/generated/learningCatalog.json', encoding='utf-8'))
n = collections.Counter(x['domain'] for x in c['lessons'])
expected = {'oracle-database-azure':22,'monitoring-performance-automation':16,'azure-database-platforms':12,'resilience-backup-recovery':10,'security-governance':8,'azure-foundations-data':7,'dp-300-exam-prep':6,'sql-server-architecture':6,'migration-modernization':5}
bad = {k:(n.get(k,0),v) for k,v in expected.items() if n.get(k,0)!=v}
print('total:', sum(n.values()))
print('MISMATCH (got, want):', bad if bad else 'none')
"

Expected: total: 92 and MISMATCH: none.

  • Step 5: Confirm the gate from Task 1 is now green

Run: npm run catalog:check Expected: Catalog check passed for 92 lessons.

This is the proof the rule works in both directions: it failed on the real defect in Task 1 and passes now that the defect is gone.

  • Step 6: Run the full test suite

Run: npm test Expected: all pass. LibraryPage.test.tsx may fail if it asserts a hardcoded domain count — update it to 9 rather than deleting it; the split happens in Task 7.

  • Step 7: Commit

git add -A; git commit -m "refactor(taxonomy): file lessons by subject, not by content type or purpose"


Task 4: Strip numeric prefixes from lesson titles

Files:

  • Modify: the 26 files whose frontmatter title begins with a number

  • Modify: src/generated/learningCatalog.json

  • Step 1: List the files

python -X utf8 -c "
import json, re
c = json.load(open('src/generated/learningCatalog.json', encoding='utf-8'))
for x in c['lessons']:
if re.match(r'^\s*\d+\s*[\u00b7.\-]', x['title']):
print(x['source_path'], '|', x['title'])
"
  • Step 2: Edit each title

Remove the leading digits and separator only. title: 1 · Microsoft Entra ID becomes title: Microsoft Entra ID. Leave sidebar_position untouched — it already carries the ordering, verified present on all 26.

  • Step 3: Verify none remain

Run: npm run catalog:generate then re-run the Step 1 command. Expected: no output.

  • Step 4: Confirm sidebar order did not change

Run: npm start, open a re-titled section (for example the Azure foundations pages), and confirm the sidebar order is unchanged. Stop the dev server.

  • Step 5: Commit

git add -A; git commit -m "fix(docs): remove numeric prefixes from lesson titles"


Task 5: useCatalogFilters — query-string-synced filter state

Files:

  • Create: src/features/learning/useCatalogFilters.ts

  • Test: src/features/learning/__tests__/useCatalogFilters.test.ts

  • Step 1: Write the failing test

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { parseFilters, serializeFilters, applyFilters } from '../useCatalogFilters';

describe('useCatalogFilters', () => {
it('parses an empty query to all-defaults', () => {
expect(parseFilters('')).toEqual({ level: 'all', contentType: 'all', path: 'all', q: '' });
});

it('round-trips a populated filter set', () => {
const filters = { level: 'foundation', contentType: 'lab', path: 'all', q: 'geo' };
expect(parseFilters(serializeFilters(filters))).toEqual(filters);
});

it('omits defaults from the query string', () => {
expect(serializeFilters({ level: 'all', contentType: 'lab', path: 'all', q: '' }))
.toBe('?type=lab');
});

it('filters lessons by level and free text', () => {
const lessons = [
{ id: 'a', title: 'Geo-Replication', domain: 'd', topic: 't', level: 'foundation', content_type: 'lesson' },
{ id: 'b', title: 'Optimized Locking', domain: 'd', topic: 't', level: 'advanced', content_type: 'lesson' },
] as never[];
expect(applyFilters(lessons, { level: 'foundation', contentType: 'all', path: 'all', q: 'geo' }))
.toHaveLength(1);
});
});
  • Step 2: Run it and confirm it fails

Run: npx vitest run src/features/learning/__tests__/useCatalogFilters.test.ts Expected: FAIL — module not found.

  • Step 3: Implement the module

Export parseFilters, serializeFilters, applyFilters, and a useCatalogFilters() hook that reads useLocation().search, writes via useHistory().replace, and returns [filters, setFilters]. Query keys: level, type, path, q. Free-text match is case-insensitive across title, topic, and domain.

Keep the pure functions exported separately from the hook so they stay testable without a router.

  • Step 4: Run the test and confirm it passes

  • Step 5: Commit

git add -A; git commit -m "feat(learn): query-string-synced catalog filter state"


Task 6: Shared presentational parts

Files:

  • Create: src/features/learning/LessonRow.tsx, DomainCard.tsx, CatalogSearch.tsx, FilterChips.tsx

  • Modify: src/features/learning/ReviewStatus.tsx, learning.css

  • Step 1: Write the failing freshness test

In src/features/learning/__tests__/LibraryIndex.test.tsx (created empty for now), assert that ReviewStatus with state="current" renders the muted variant (no visible text label, accessible name still present) and that state="review-due" renders a visible label.

  • Step 2: Run it and confirm it fails

  • Step 3: Implement

  • ReviewStatus: current renders a dot with aria-label, no visible text. All other states keep the existing visible label.

  • LessonRow: one line — completion toggle, title link, level chip, type chip, freshness, bookmark. Target ~44px. Uses LessonActions for the existing complete/bookmark behaviour.

  • DomainCard: icon, title, outcome, N lessons, N/M complete, first 4 lessons by sidebar_position as links, View all N →, freshness chip only when the domain holds a non-current lesson.

  • CatalogSearch: controlled input, debounced, clear button.

  • FilterChips: chip buttons replacing the native <select> elements, with aria-pressed.

  • learning.css: rewrite onto dbhub-* custom properties and the homepage lp-* patterns. Remove the .learning-filters select rules.

  • Step 4: Run the tests and confirm they pass

  • Step 5: Commit

git add -A; git commit -m "feat(learn): compact lesson row, domain card, chip filters, muted current-state"


Task 7: Split LibraryPage into LibraryIndex and DomainView

Files:

  • Create: src/features/learning/LibraryIndex.tsx, DomainView.tsx

  • Modify: src/features/learning/LibraryPage.tsx

  • Create: __tests__/LibraryIndex.test.tsx, __tests__/DomainView.test.tsx

  • Delete: __tests__/LibraryPage.test.tsx

  • Step 1: Write the failing tests

LibraryIndex.test.tsx:

  • renders 9 domain cards with correct lesson counts
  • each card previews at most 4 lesson links
  • typing in catalog search replaces the grid with a flat result list
  • clearing search restores the grid
  • selecting a filter chip switches to results mode and updates the query string
  • the resume strip is absent when getContinueLearning() returns null

DomainView.test.tsx:

  • renders a breadcrumb linking to /learn

  • renders only non-empty topic sections

  • jump-rail counts equal the rendered section contents

  • Showing N of M updates when a chip is selected

  • clear-all restores the full set

  • Step 2: Run them and confirm they fail

  • Step 3: Implement both components

Move the index branch of LibraryPage into LibraryIndex, the domain branch into DomainView. Reduce LibraryPage.tsx to path parsing plus dispatch, so src/plugins/learning-routes/index.mjs needs no change.

  • Step 4: Run the tests and confirm they pass

  • Step 5: Delete the superseded test file

git rm src/features/learning/__tests__/LibraryPage.test.tsx

  • Step 6: Run the whole suite

Run: npm test; npm run typecheck

  • Step 7: Commit

git add -A; git commit -m "refactor(learn): split LibraryPage into LibraryIndex and DomainView"


Task 8: Verification before completion

  • Step 1: Full build

Run: npm run build Expected: pass, including catalog:check, check-encoding, check-links.

  • Step 2: Route check

Run npm run serve and confirm each resolves: /learn, the eight original /learn/<domain> URLs, the new /learn/dp-300-exam-prep, and /learn?type=lab.

  • Step 3: Confirm type=lab now spans domains

On /learn?type=lab, confirm the 10 labs appear under more than one domain. Before this work they were all under Migration.

  • Step 4: Visual pass

At 1440px and at 390px, check the domain grid, a large domain (Oracle, 22 lessons), and a small one (Migration, 5). Confirm no native <select> remains and that ✔ Current no longer repeats on every row.

  • Step 5: Public-safety scan

Run the share-safety skill over the changed files under docs/. This repository is published.

  • Step 6: Open the PR

main is branch-protected and the build check is required. Use the PR path — do not push directly to main.

Run: ./scripts/Publish-Wiki.ps1 -Message "Learning library IA redesign" -NoAutoMerge

-NoAutoMerge so the preview deployment can be reviewed before merge.


Out of scope

  • ChunkLoadError on the live site — a 404 on /assets/js/3b1f4e60.620b77fb.js, a stale chunk reference from an earlier deploy. Real, but unrelated. Separate branch.
  • Re-levelling — 60 of 92 lessons are intermediate, so the level facet stays weak. Needs a per-lesson judgement pass over the whole catalog; excluded here rather than guessed at.