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 testisvitest run. Target one file withnpx vitest run <path>.- After any frontmatter change run
npm run catalog:generate, orcatalog:checkwill fail because the committedsrc/generated/learningCatalog.jsonis 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
| File | Responsibility |
|---|---|
src/features/learning/LibraryIndex.tsx | Header, resume strip, domain grid, flat results mode |
src/features/learning/DomainView.tsx | Breadcrumb, domain header, toolbar, topic sections |
src/features/learning/LessonRow.tsx | The compact single-line lesson row, shared |
src/features/learning/DomainCard.tsx | Grid card with lesson preview and progress |
src/features/learning/CatalogSearch.tsx | Search input and match ranking |
src/features/learning/FilterChips.tsx | Level / type / path chip group |
src/features/learning/useCatalogFilters.ts | Filter state synced to the query string |
src/features/learning/__tests__/LibraryIndex.test.tsx | Index behaviour |
src/features/learning/__tests__/DomainView.test.tsx | Domain behaviour |
src/features/learning/__tests__/useCatalogFilters.test.ts | Query-string round-trip |
Modify
| File | Change |
|---|---|
scripts/catalog/model.mjs | Add the empty-topic rule to validateCatalog |
scripts/__tests__/learning-contracts.test.mjs | Eight populated domains → nine |
learning/taxonomy.mjs | Add 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.tsx | Reduce to a path dispatcher |
src/features/learning/ReviewStatus.tsx | Muted variant for current |
src/features/learning/learning.css | Rewrite onto dbhub-* tokens and lp-* patterns |
src/generated/learningCatalog.json | Regenerated output, committed |
Delete
| File | Reason |
|---|---|
src/features/learning/__tests__/LibraryPage.test.tsx | Split 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'fromazure-database-platforms -
remove
'migration-labs'frommigration-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
titlebegins 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:currentrenders a dot witharia-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. UsesLessonActionsfor the existing complete/bookmark behaviour. -
DomainCard: icon, title, outcome,N lessons,N/M complete, first 4 lessons bysidebar_positionas 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, witharia-pressed. -
learning.css: rewrite ontodbhub-*custom properties and the homepagelp-*patterns. Remove the.learning-filters selectrules. -
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 Mupdates 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=labnow 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
ChunkLoadErroron 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.