# Two-Level Navigation: System Nav + Course Workspace

## Context

OBEsoft-v2 is a single-page OBE accreditation tool (PHP + SQLite backend, vanilla JS frontend,
no build step). Today it has **one flat sidebar with 16 tabs** that mixes two unrelated concerns:
system administration (All Courses, Setup, Administration) and per-course work (COs, Marks,
Attainment). Which course you are editing is chosen from a small `<select>` in the top bar, so a
teacher can be deep in a marks grid without any strong signal of *which* course they are in.

Three structural problems make this hard to change:

1. **The tab list is duplicated in 6 places** — section shells in `index.php`, desktop buttons in
   `sidebar.php`, mobile offcanvas buttons in `index.php`, account links in `nav.js`, a duplicate of
   those account links in `app.js`, and a 16-case switch in `renderCurrentTab()`. Adding a page means
   editing all six; missing one yields a dead link or a blank pane.
2. **No URL state.** Refresh always lands on Dashboard. Browser Back exits the app. Nothing is
   shareable.
3. **The UI offers what the API refuses.** Teachers see "Data & Backup" and "Setup" in the sidebar;
   the server 403s them.

**Outcome:** two clearly separated navigation levels — a system sidebar, and a per-course workspace
entered by explicitly opening a course — driven by a single nav registry, addressable by URL, and
gated by role. Users always know which course they are in and what is left to complete in it.

### Good news found during exploration

The split already exists cleanly in the data layer. Ten render functions and all sixteen mutators
funnel through `activeCourse()`; six renders never touch it. That boundary *is* the two levels — no
business logic needs rewriting, only the navigation around it.

### Decisions already made

| Decision | Choice |
|---|---|
| Nav shape | **Context-swapping sidebar** — one sidebar, contents swap when a course is open |
| Dashboard | **System overview + Course Overview** — system landing page; per-course stats merge into the workspace |
| Status data | **Derive client-side** — no migration, computed from data already loaded |
| Deep linking | **Hash routing** — `#/course/<id>/cos` |

---

## Visual Architecture

### Navigation tree

```
LEVEL 1 — SYSTEM                          LEVEL 2 — COURSE WORKSPACE
(sidebar default state)                   (sidebar after opening a course)

  Dashboard                                 < Back to Dashboard
                                            ┌───────────────────────┐
  COURSE MANAGEMENT                         │ CSE 305               │
    All Courses          ──── open ────>    │ Database Mgmt System  │
    Add New Course           course         │ Spring 2026 · Sec A   │
    Categories        (admin/manager)       │ ####____  62% ready   │
                                            └───────────────────────┘
  ACCREDITATION & SYSTEM
    Data & Backup     (admin/manager)         Course Overview      (o)
    Setup             (admin/manager)
    About                                     CURRICULUM & PLANNING
                                                COs & Mapping      (*)
  ACCOUNT & ACCESS                              T&L Plan           (o)
    <user name + role>                          Assessments        (v)
    Administration    (admin/manager)           CA (Alignment)     (o)
    Activity Log      (admin/manager)
                                              EVALUATION & RESULTS
                                                Students & Marks   (o)
                                                Attainment
                                                CQI Pipeline       (o)

                                              ACCREDITATION & SYSTEM
                                                Documents

                                            (*) active   (v) complete   (o) pending
```

### Screen layout — the context swap

```
SYSTEM MODE                                COURSE MODE
┌──────────────┬──────────────────────┐   ┌──────────────┬──────────────────────┐
│ OBE Ai       │  [saved]  [+ Course] │   │ OBE Ai       │  [saved]  [+ Course] │
├──────────────┼──────────────────────┤   ├──────────────┼──────────────────────┤
│ Dashboard    │ Course Mgmt /        │   │ < All Courses│ All Courses / CSE 305│
│              │ All Courses          │   │ ┌──────────┐ │ / COs & Mapping      │
│ COURSE MGMT  │ ──────────────────── │   │ │ CSE 305  │ │ ──────────────────── │
│ > All Courses│                      │   │ │ Database │ │                      │
│   Add Course │  [search] [category] │   │ │ ####_ 62%│ │   Course Learning    │
│   Categories │  ┌────────────────┐  │   │ └──────────┘ │   Outcomes (COs)     │
│              │  │ CSE 305  85% ● │  │   │              │                      │
│ ACCRED & SYS │  │ CSE 411  20% ● │  │   │  Course Ovw  │   [CO1 ...........]  │
│   Data&Backup│  │ EEE 201   0% ● │  │   │              │   [CO2 ...........]  │
│   Setup      │  └────────────────┘  │   │ CURRICULUM   │                      │
│   About      │                      │   │ >COs & Map(*)│   CO-PO Matrix       │
│              │                      │   │  T&L Plan (o)│   ┌────────────────┐ │
│ ACCOUNT      │                      │   │  Assessmts(v)│   │ PO1 PO2 PO3 .. │ │
│   Admin      │                      │   │  CA Align (o)│   └────────────────┘ │
│   Activity   │                      │   │              │                      │
└──────────────┴──────────────────────┘   └──────────────┴──────────────────────┘
        ^ breadcrumb always states the path, both modes
```

### Route map

```
#/dashboard                        System landing page
#/courses                          All Courses  (id stays "archive")
#/categories                       admin/manager
#/data  #/setup  #/about           #/admin  #/activity
#/course/<id>                      -> replaced -> #/course/<id>/overview
#/course/<id>/<section>            overview|cos|tla|assess|ca|marks|attain|cqi|docs
#/course/<id>/docs/<sub>           outline|attain   (3rd level = state.activeDoc)
(empty)                            -> replaced -> #/dashboard
```

### Data flow

```
   nav-registry.js          router.js              nav.js
   ───────────────          ─────────              ──────
   NAV_GROUPS[]     ──┬──>  parse(hash)     ──>    navRenderSidebar(mode, courseId)
   NAV_ITEMS[]        │     guard (role,           navRenderCourseContext(course)
     id/path/level    │            course          navRenderBreadcrumb(item, ...)
     icon/roles       │            access)         navRefreshStates(course)
     render: ()=>..   │     apply()          ──>   navActivate(item, ...)
     complete: c=>..  │                                 │
   courseReadiness()  └────────────────────────────────>│  toggles [data-tab] + section.active
                                                        └─> item.render()  (existing renderX fns)
```

The registry is the **single source of truth**. Sidebar, offcanvas, section shells, breadcrumb,
route table and readiness pips are all derived from it. Duplication sites collapse 6 → 1.

---

## Suggestions beyond the original spec

These came out of exploration. All are folded into the plan below.

1. **Merge Dashboard into Course Info as one "Course Overview" page.** The per-course stat cards and
   readiness checklist are *about* what is missing; the Course Info form is *how you fix it*.
   Splitting them across two clicks means two near-identical headers. Merged, `#/course/<id>/overview`
   answers "what is this course and what's left" — exactly what a workspace landing page must do. The
   existing edit form moves inside a Bootstrap collapse (already loaded, currently unused).
2. **Generate the section shells from the registry** rather than keeping 16 static `<section>` tags.
   Kills duplication site #1 outright. Safe because the shells are empty at parse time anyway.
3. **Nav items become real `<a href="#/...">`**, not `<button onclick>`. Free middle-click, keyboard
   nav, shareable links, and every inline `onclick` in the nav disappears.
4. **Close the 403 gap**: role-gate Data & Backup, Setup, Categories, Administration and Activity Log
   in the nav so teachers never see actions the server will refuse.
5. **A latent bug to fix while here**: the New Course modal's category `<select>` is populated once at
   `initApp` and never refreshed, so a newly created category doesn't appear until reload.
6. **Accessibility debt**: there is no `:focus-visible`, no `aria-current`, no `role="progressbar"`
   anywhere in the app. ~10 lines to add, and this refactor is the natural moment.

---

## The Nav Registry

New file `public/assets/js/nav-registry.js` — pure data and pure functions. **No DOM access, no
fetch, no top-level reference to anything in `app.js`** (it loads first).

```js
const NAV_GROUPS = [
  { id: "overview",   level: "system", label: null },
  { id: "courseMgmt", level: "system", label: "Course Management" },
  { id: "sysadmin",   level: "system", label: "Accreditation & System" },
  { id: "account",    level: "system", label: "Account & Access" },
  { id: "workspace",  level: "course", label: null },
  { id: "planning",   level: "course", label: "Curriculum & Planning" },
  { id: "evaluation", level: "course", label: "Evaluation & Results" },
  { id: "accredit",   level: "course", label: "Accreditation & System" }
];
```

Item fields: `id` (stable; also the `#tab-<id>` suffix and the `data-tab` value, so the existing
`[data-tab]` broadcast keeps working untouched) · `path` (hash slug, deliberately decoupled from `id`
so `archive` can live at the friendly `#/courses`) · `type` (`"page"` default, or `"action"`) ·
`roles` (null = everyone) · `render` (**a thunk, never a bare reference** — see Risks) · `async` ·
`complete` (predicate) · `derived` (readout; excluded from the readiness denominator) · `weight`.

```js
const NAV_ITEMS = [
  /* ---------- LEVEL 1: SYSTEM ---------- */
  { id:"dashboard", path:"dashboard", level:"system", group:"overview",
    label:"Dashboard", icon:"bi-speedometer2", roles:null, async:true,
    render: () => renderSystemDashboard() },

  { id:"archive", path:"courses", level:"system", group:"courseMgmt",
    label:"All Courses", icon:"bi-journals", roles:null, async:true,
    render: () => renderArchiveTab() },

  { id:"newcourse", type:"action", level:"system", group:"courseMgmt",
    label:"Add New Course", icon:"bi-journal-plus", roles:null,
    action: () => bootstrap.Modal
      .getOrCreateInstance(document.getElementById("newCourseModal")).show() },

  { id:"categories", path:"categories", level:"system", group:"courseMgmt",
    label:"Categories", icon:"bi-tags", roles:["admin","manager"],   // categories.manage
    async:true, render: () => renderCategoriesTab() },

  { id:"data",  path:"data",  level:"system", group:"sysadmin", label:"Data & Backup",
    icon:"bi-database-down", roles:["admin","manager"], render: () => renderData() },
  { id:"setup", path:"setup", level:"system", group:"sysadmin", label:"Setup",
    icon:"bi-sliders", roles:["admin","manager"], render: () => renderSetup() },
  { id:"about", path:"about", level:"system", group:"sysadmin", label:"About",
    icon:"bi-award", roles:null, render: () => renderAbout() },

  { id:"admin",    path:"admin",    level:"system", group:"account", label:"Administration",
    icon:"bi-shield-lock",    roles:["admin","manager"], async:true, render: () => renderAdminTab() },
  { id:"activity", path:"activity", level:"system", group:"account", label:"Activity Log",
    icon:"bi-clock-history",  roles:["admin","manager"], async:true, render: () => renderActivityTab() },

  /* ---------- LEVEL 2: COURSE WORKSPACE ---------- */
  { id:"course", path:"overview", level:"course", group:"workspace",
    label:"Course Overview", icon:"bi-info-circle", roles:null, weight:1,
    render: () => renderCourse(),
    complete: c => Boolean((c.code||"").trim() && (c.title||"").trim()
                        && (c.data.outline.prepared||"").trim()
                        && (c.data.outline.synopsis||"").trim()) },

  { id:"cos", path:"cos", level:"course", group:"planning",
    label:"COs & Mapping", icon:"bi-diagram-3", roles:null, weight:2,
    render: () => renderCos(),
    complete: c => c.cos.length > 0 && c.cos.every(co =>
                     (co.desc||"").trim() &&
                     Object.values(co.po || {}).some(v => +v > 0)) },

  { id:"tla", path:"tla", level:"course", group:"planning",
    label:"T&L Plan", icon:"bi-calendar3", roles:null, weight:1,
    render: () => renderTla(),
    complete: c => tlaPlanStatus(c).complete },

  { id:"assess", path:"assess", level:"course", group:"planning",
    label:"Assessments", icon:"bi-card-checklist", roles:null, weight:2,
    render: () => renderAssess(),
    complete: c => c.assessments.length > 0
                && c.assessments.every(a => a.items.length > 0)
                && Math.abs(c.assessments.reduce((s,a)=>s+(+a.weightPct||0),0) - 100) < 0.5 },

  { id:"ca", path:"ca", level:"course", group:"planning",
    label:"CA (Alignment)", icon:"bi-percent", roles:null, weight:1,
    render: () => renderCa(),
    complete: c => { const its = c.assessments.flatMap(a => a.items);
                     return its.length > 0 && its.every(it => c.cos.some(co => co.id === it.coId)); } },

  { id:"marks", path:"marks", level:"course", group:"evaluation",
    label:"Students & Marks", icon:"bi-table", roles:null, weight:2,
    render: () => renderMarks(),
    complete: c => c.students.length > 0 && c.students.every(s =>
                     c.marks[s.id] && Object.keys(c.marks[s.id]).length > 0) },

  { id:"attain", path:"attain", level:"course", group:"evaluation",
    label:"Attainment", icon:"bi-graph-up-arrow", roles:null, derived:true,
    render: () => renderAttain(),
    complete: c => computeAttainment(c).coResults.length > 0 },

  { id:"cqi", path:"cqi", level:"course", group:"evaluation",
    label:"CQI Pipeline", icon:"bi-arrow-repeat", roles:null, weight:1,
    render: () => renderCqi(),
    // VERIFIED field paths — app.js:1656 and app.js:1660
    complete: c => Boolean((c.data.cqi.lowAnalysis?.notes || "").trim()
                        && (c.data.cqi.effective || "").trim()) },

  /* NOTE: this id MUST stay "docs" — style.css:489 has
     `section#tab-docs { display:block !important }` inside @media print. */
  { id:"docs", path:"docs", level:"course", group:"accredit",
    label:"Documents", icon:"bi-file-earmark-text", roles:null, derived:true,
    subs:["outline","attain"], subDefault:"outline",
    render: () => renderDocs(),
    complete: c => c.cos.length > 0 && (c.data.outline.synopsis||"").trim() }
];
```

Lookups: `navById(id)`, `navByPath(level, path)`, `navAllowed(item)` (true when `!state.auth.enabled`
for legacy single-user mode, else `item.roles.some(r => userRoles.includes(r))`), `navGateState(item)`
returning `"visible" | "restricted" | "hidden"`, `navItemsFor(level)`.

> **On "Restricted":** the backend sends roles only, and course access is binary — so today *no* item
> should render locked; every gated item is `gate:"hide"`. Build `gate:"lock"` + `.is-restricted`
> styling anyway (~8 lines); it is the one-line-per-item change needed the day the backend starts
> sending a permission list. Document it as deliberately unused.

---

## Routing

New file `public/assets/js/router.js`. Binds nothing at load; `Router.start()` is called from
`initApp`.

```js
const Router = {
  current: { itemId: null, courseId: null, sub: null },
  navToken: 0,

  parse(hash) {
    const seg = String(hash||"").replace(/^#\/?/, "").split("/").filter(Boolean);
    if (!seg.length) return null;
    if (seg[0] === "course") {
      if (!seg[1]) return { bad: "nocourse" };
      const item = navByPath("course", seg[2] || "overview");
      return item ? { itemId:item.id, courseId:seg[1], sub:seg[3]||null } : { bad:"nopath" };
    }
    const item = navByPath("system", seg[0]);
    return item ? { itemId:item.id, courseId:null, sub:null } : { bad:"nopath" };
  },

  build(itemId, { courseId, sub } = {}) {
    const it = navById(itemId);
    if (!it) return "#/dashboard";
    if (it.level === "system") return `#/${it.path}`;
    const cid = courseId || state.activeCourseId;
    return `#/course/${encodeURIComponent(cid)}/${it.path}${sub ? "/" + sub : ""}`;
  },

  go(itemId, opts)      { this._set(this.build(itemId, opts), false); },
  replace(itemId, opts) { this._set(this.build(itemId, opts), true);  },

  _set(hash, replace) {
    if (window.location.hash === hash) { this.apply(); return; }  // hashchange won't fire
    if (replace) window.location.replace(location.pathname + location.search + hash);
    else         window.location.hash = hash;
  },

  start() { window.addEventListener("hashchange", () => this.apply()); this.apply(); },
  async apply() { /* below */ }
};
```

**`Router.apply()` — resolve, guard, activate:**

1. `const r = Router.parse(location.hash)`
2. `!r` → `replace("dashboard")`
3. `r.bad === "nopath"` → toast "That page doesn't exist." + `replace("dashboard")`
4. `r.bad === "nocourse"` → `replace("archive")`
5. `navGateState(item) === "hidden"` → toast "You don't have access to that page." + `replace("dashboard")`
6. **Course guard** when `item.level === "course"`:
   - no `courseId` → `replace("archive")`
   - course not in `state.courses` → `await API.getCourse(r.courseId)` (**the first real caller of
     this existing method at `api.js:294`**). Success → `state.courses.push(ensureCourseData(fetched))`.
     `null` (403 and 404 both return null today) → toast "You don't have access to that course.
     Request access from All Courses." + `replace("archive")`, return.
   - `state.activeCourseId = r.courseId`
7. **Sub-route**: if `item.subs`, `state.activeDoc = item.subs.includes(r.sub) ? r.sub : item.subDefault`
8. Dedupe against `Router.current` (prevents a double render on the same-hash `_set` path)
9. `Router.current = r; state.activeTab = item.id;`
10. `await navActivate(item, r.courseId, state.activeDoc)`

**Coexistence with the auth query params.** `URLSearchParams(location.search)` is unaffected by the
hash, so `initApp:340-341` and `showAuthGate:165-168` need **zero** changes. One fix: `clearUrlState`
(`app.js:182`) does `replaceState({}, "", location.pathname)`, which silently drops the hash — change
to `location.pathname + location.hash`. `Router.start()` can't run while the gate is up, because
`initApp` already returns at line 344 first.

**`showTab` becomes a one-line shim** delegating to `Router.go`, so **none of the 58 existing call
sites across 6 files need editing**. Same for `showTabMobile` and `switchCourse`. All real work moves
into `navActivate`.

---

## Sidebar Context Swap

**One tree, re-rendered** — not two parallel trees toggled with CSS, which would duplicate `data-tab`
attributes and break the broadcast. `navRenderSidebar(mode, courseId)` replaces `innerHTML` of
`#sidebarNav` and `#mobileNav`, and shows/hides `#sidebarCourseContext` / `#mobileCourseContext`.

One loop, two skins (`NAV_SKINS.desktop` reuses `.sidebar-link` / `.sidebar-category-header` /
`.sidebar-nav-list`; `NAV_SKINS.mobile` reuses the existing `.nav-pills` + `.mobile-offcanvas
.nav-link` styling). `href` = `Router.build(item.id, {courseId})`. Action items (`newcourse`) render
as `<button data-nav-action>`.

**Highlighting is unchanged.** Both skins emit `data-tab="<id>"`, so `navActivate` keeps the existing
one-liner from `showTab:485-487`:

```js
document.querySelectorAll("[data-tab]").forEach(b =>
  b.classList.toggle("active", b.dataset.tab === item.id));
```

This is why in-page buttons like `showTab('marks')` on the Course Overview keep highlighting the
sidebar for free. No new machinery.

**Re-render policy.** `navSetMode(mode, courseId)` caches `{mode, courseId}` and rebuilds `innerHTML`
only when either changes. On every navigation the cheaper `navRefreshStates(course)` runs, swapping
only state classes and pip icons — so completeness pips update the moment you leave a section you
just filled in.

**Offcanvas auto-close.** `<a href>` won't dismiss it; add one delegated listener on `#mobileNav` for
`[data-tab]` clicks → the existing `closeMobileNav()` (`app.js:428-434`).

### Course context card

Mounts in `#sidebarCourseContext` (desktop) and `#mobileCourseContext` (offcanvas); `d-none` in
system mode.

```html
<a class="sidebar-back-link" href="#/dashboard">
  <i class="bi bi-arrow-left"></i> <span>Back to Dashboard</span>
</a>
<div class="sidebar-course-context">
  <div class="scc-code">CSE 305</div>
  <div class="scc-title">Database Management System</div>
  <div class="scc-term">Spring 2026 · Section A</div>
  <div class="readiness-meter mt-2" role="progressbar"
       aria-valuenow="62" aria-valuemin="0" aria-valuemax="100" aria-label="Course readiness">
    <span style="width:62%"></span>
  </div>
  <div class="scc-meter-label">62% ready · 5 of 8 sections complete</div>
</div>
```

`navRefreshCourseContext()` (called from `cUpdate`, `app.js:883`, replacing `renderCourseSelect()`)
re-renders just this card, so editing the code/title updates the sidebar live — the behaviour the
dropdown was faking.

### Breadcrumb

Mounts in a static `<nav id="appBreadcrumb" class="app-breadcrumb no-print">` inside `<main>`, above
`#appSections`, using Bootstrap's own `.breadcrumb`. Course pages read
`All Courses / CSE 305 — Database Management System / COs & Mapping`, with real `<a href>` crumbs.

### CSS (`public/assets/css/style.css`, 493 → ~590 lines)

Hoist the magic number first — `57px` is hard-coded at lines 87, 100 and 101:

```css
:root { --topbar-h: 57px; }
/* EDIT :87   .app-body-wrapper { min-height: calc(100vh - var(--topbar-h)); } */
/* EDIT :100  .app-sidebar      { top: var(--topbar-h); }                      */
/* EDIT :101  .app-sidebar      { height: calc(100vh - var(--topbar-h)); }     */
```

Then an appended block: `.sidebar-back-link`, `.sidebar-course-context` + `.scc-*`,
`.readiness-meter`, `.sidebar-link-state` pips, `.is-complete` (#16a34a) / `.is-pending` (#cbd5e1) /
`.is-restricted`, `.app-breadcrumb`, and `:focus-visible` for all three link classes.

Two specificity notes:
- `.sidebar-link.active` sets `background`/`color` with `!important` (style.css:172-181). New state
  classes losing that fight is exactly right (active beats state). Only the **pip colour when active**
  needs `!important` back.
- Bootstrap's `a { color: … }` is element-level; `.sidebar-link { color:#475569 }` is class-level and
  wins, and `.sidebar-link` already sets `text-decoration:none`. Safe — but eyeball it.

**Print rule edit** (style.css:482) — drop the dead `.nav-tabs-wrapper` selector (defined nowhere),
add `.app-breadcrumb`:

```css
.app-navbar, .app-sidebar, .no-print, footer, .toast-container, .btn,
.mobile-offcanvas, .app-breadcrumb { display: none !important; }
```

---

## Readiness Model

| Section | Complete when | Weight |
|---|---|---|
| Course Overview | `code`, `title`, `data.outline.prepared`, `data.outline.synopsis` all non-empty | 1 |
| COs & Mapping | ≥1 CO; every CO has a `desc` and ≥1 PO mapped | 2 |
| T&L Plan | ≥10 teaching weeks (`kind` not exam/review/break), each with topic `.t`, TLO `.tlo` and a linked CO; every CO taught | 1 |
| Assessments | ≥1 assessment; each has ≥1 item; Σ`weightPct` = 100 ±0.5 | 2 |
| CA (Alignment) | ≥1 assessment item; every item's `coId` resolves to an existing CO | 1 |
| Students & Marks | ≥1 student; **every** student has ≥1 entry in `c.marks[studentId]` | 2 |
| Attainment | `computeAttainment(c).coResults.length > 0` — **derived**, excluded | — |
| Documents | `cos.length > 0` and a synopsis exists — **derived**, excluded | — |

Denominator = 10, so each check is worth a clean 10% or 20%.

```js
function courseReadiness(c) {
  const tracked = NAV_ITEMS.filter(i => i.level === "course" && i.complete);
  const byId = {}; let done = 0, total = 0;
  for (const it of tracked) {
    let ok = false;
    try { ok = Boolean(it.complete(c)); } catch (e) { ok = false; }  // never let a rule break nav
    byId[it.id] = ok;
    if (it.derived) continue;
    const w = it.weight || 1; total += w; if (ok) done += w;
  }
  return { pct: total ? Math.round(100*done/total) : 0, byId, done, total };
}
function courseNextStep(c) {   // drives the "Next: …" CTA on Course Overview
  const r = courseReadiness(c);
  return NAV_ITEMS.find(i => i.level === "course" && i.complete && !i.derived && !r.byId[i.id]) || null;
}
```

The `try/catch` matters: predicates dereference `c.data.outline.weeks`, `c.data.cqi.*` etc., which
only exist after `ensureCourseData()`. Everything in `state.courses` is normalized (`initApp:349`),
but **archive rows from `api/course-access.php` are not** — so All Courses computes readiness only
for courses present in `state.courses` and renders `—` otherwise. That is behaviourally correct:
admins/managers get every course, teachers get a percentage for their own and `—` for catalog entries
they can't read. No extra API call.

### State → class/icon

| State | Condition | Class | Icon | A11y |
|---|---|---|---|---|
| **Active** | `item.id === Router.current.itemId` | `.sidebar-link.active` *(existing rule)* | — | `aria-current="page"` |
| **Available** | visible, no `complete` rule | `.sidebar-link` | none | — |
| **Completed** | `readiness.byId[id] === true` | `.is-complete` | `bi-check-circle-fill` | `aria-label="… (complete)"` |
| **Pending** | tracked and false | `.is-pending` | `bi-circle` | `aria-label="… (incomplete)"` |
| **Restricted** | `navGateState() === "restricted"` | `.is-restricted`, `<span>` not `<a>` | `bi-lock-fill` | `aria-disabled="true"` |

`bi-check-circle-fill` / `bi-circle` is deliberately the same vocabulary the existing dashboard
checklist uses (`app.js:600-613`), so sidebar and overview read as one system for free.

---

## File-by-File Changes

### New files

| Path | ~Lines | Purpose |
|---|---|---|
| `public/assets/js/nav-registry.js` | 200 | `NAV_GROUPS`, `NAV_ITEMS`, lookups, `courseReadiness()`, `courseNextStep()` |
| `public/assets/js/router.js` | 150 | `Router.parse/build/go/replace/apply/start` |
| `public/assets/js/pages/tab-home.js` | 180 | `renderSystemDashboard()`; absorbs `showNoAssignedCourses()` as its empty state |
| `public/assets/js/pages/tab-categories.js` | 110 | Categories lifted out of `tab-admin.js` |

### `public/assets/js/nav.js` — rewritten (43 → ~230 lines)

Becomes the registry-driven DOM renderer for both skins: `NAV_SKINS`, `ensureSectionShells()`,
`navRenderSidebar()`, `navRenderCourseContext()`, `navRenderBreadcrumb()`, `navRefreshStates()`,
`navActivate()`, `navRenderAccountBlock()`. The current `renderAccountNav()` **and** its duplicate at
`app.js:301-318` collapse into `navRenderAccountBlock()`, which feeds the `account` group into the
same loop as everything else. Keep `navEscape()` as-is (nav.js loads before app.js, so it can't use
`esc`).

### `public/partials/sidebar.php` — 104 → ~18 lines

All four groups, all 12 buttons and both account placeholders deleted:

```php
<aside class="app-sidebar no-print d-none d-lg-flex flex-column flex-shrink-0"
       id="desktopSidebar" data-nav-mode="system">
  <div class="sidebar-inner py-3 px-3">
    <div id="sidebarCourseContext" class="d-none"></div>
    <nav id="sidebarNav" aria-label="Main navigation"></nav>
  </div>
</aside>
```

### `public/index.php` — 307 → ~185 lines

- **52-58**: delete `<select id="courseSelect">`; keep the New Course button.
- **91-98**: delete the `#courseSelectMobile` block; keep "Create New Course".
- **101-189**: delete all four hard-coded offcanvas `<ul>`s and the `#mobileAccount*` placeholders →
  `<div id="mobileCourseContext" class="mb-3 d-none"></div>` + `<div id="mobileNav" class="mobile-nav-list flex-grow-1"></div>`.
- **205-222**: delete all 16 `<section>` shells → `<nav id="appBreadcrumb" class="app-breadcrumb no-print"></nav>` + `<div id="appSections"></div>`.
- **296-305**: new script order —
  `bootstrap` → `api.js` → **`nav-registry.js`** → **`router.js`** → `nav.js` → `app.js` →
  **`pages/tab-home.js`** → `pages/tab-archive.js` → **`pages/tab-categories.js`** →
  `pages/tab-admin.js` → `pages/tab-activity.js`.
  Registry/router/nav execute nothing at load beyond object-literal creation, so they may precede
  `app.js`. `pages/*.js` stay after it because they call `esc()` and read `state`.

### `public/assets/js/app.js` — 1906 → ~1750 lines

| Lines | Change |
|---|---|
| 182 `clearUrlState` | append `+ window.location.hash` so a deep link survives verify/reset |
| 289-319 `installAuthControl` | mobile block deleted → `navRenderAccountBlock(auth)` |
| 321-335 `showNoAssignedCourses` | deleted (moves to `tab-home.js`) |
| 337-402 `initApp` | tail becomes `ensureSectionShells(); navRenderAccountBlock(state.auth); Router.start();`. Teacher-with-no-courses early return (359-363) deleted |
| 404-419 `renderCourseSelect` | **deleted** |
| 421-426 `switchCourse` | → `Router.go("course", { courseId: id })`; drop the now-noisy toast |
| 477-478 `submitNewCourseModal` | → `navMarkDirty(); Router.go("course", { courseId: saved.id })` |
| 482-495 `showTab` | → `Router.go(name)` shim (keeps all 58 call sites working unedited) |
| 497-516 `renderCurrentTab` | switch deleted → `const it = navById(state.activeTab); return it ? it.render() : undefined;` |
| 519-660 `renderDashboard` | body merged into `renderCourse`; function deleted |
| 663+ `renderCourse` | restructured: readiness banner → stat cards → checklist + chart → collapsed "Course details & parameters" accordion containing today's form + `loadCourseTeamPanel(c.id)` |
| 866, 869 | → `Router.go("archive")` / `Router.go("dashboard")` |
| 883 `cUpdate` | `renderCourseSelect()` → `navRefreshCourseContext()` |
| 1731 `switchDoc` | → `Router.go("docs", { sub: docType })`; **must stop calling `renderDocs()`** |

### `public/assets/js/pages/tab-archive.js` — 213 → ~295 lines

- **35-42** `openCourseFromArchive` → `Router.go("course", { courseId })`
- **183**: add `<select id="archiveCategory">` beside `#archiveSearch`, plus a "Clear filters" link
- **52-58** `renderArchiveList`: filter on search **AND** category (`course.categoryId`)
- **60-106**: 5 → 7 columns, adding **Category** and **Readiness**
- **6-33** `archiveStatusBadge` kept verbatim except the Open button becomes
  `<a href="#/course/<id>/overview">`; course code/title becomes a link when `is_member || is_admin`

### `public/assets/js/pages/tab-admin.js` — 314 → ~220 lines

Delete lines 110-147 (`renderCategories`), 149-153 (`loadAdminTabCategories`), 209-217 (the card),
278-289 (the handler), and both `loadAdminTabCategories()` entries in the `Promise.all` at 307/309.
Add a two-line pointer card linking to `#/categories`.

`tab-categories.js` keeps the existing `state.categories = …` sync **and additionally calls
`populateCategorySelect(document.getElementById("modalCourseCategory"))`** — fixing suggestion #6.

### `public/assets/js/api.js` — one addition

`flushPendingSaves()` next to `debouncedSaveCourse` (line 413). See Risks #8 for why this is **not**
`sendBeacon`.

### `src/Services/CourseAccessService.php` — the only backend change

`listArchive()` (verified at lines 68-79) selects no category. For the filter, add to the SELECT at
line 69: `c.category_id AS categoryId, cat.name AS category_name`, and after line 77:
`LEFT JOIN course_categories cat ON cat.id = c.category_id`. No migration, no new column, no
permission change.

---

## Migration Sequence

Each step ships independently and leaves the app working.

- **Step 0 — CSS hygiene, zero behaviour change.** Add `--topbar-h`, use it at style.css:87/100/101.
  Add `:focus-visible`. Remove the dead `.nav-tabs-wrapper` from the print list.
- **Step 1 — Registry + dispatch, zero visual change.** Add `nav-registry.js` with all 16 current
  items (`level` present but unused). Replace the switch at `app.js:497-516` with a registry lookup.
  Make `showTab` async and `await renderCurrentTab()`. **This alone fixes the fire-and-forget async
  bug** — Administration/Activity/All Courses stop flashing empty.
- **Step 2 — Generate the nav from the registry.** Rewrite `nav.js`; shell out `sidebar.php` and the
  offcanvas. Still flat, still no router. Add role gating here — **teachers lose Data & Backup and
  Setup**. *Duplication sites #2–#5 die here.*
- **Step 3 — Generate section shells.** `ensureSectionShells()`; `<main>` emptied. **Verify by
  printing the Documents tab** — the one step that can break silently.
- **Step 4 — Hash router.** Add `router.js`; `showTab`/`showTabMobile`/`switchCourse` → shims;
  `switchDoc` → sub-route; fix `clearUrlState`. Nav still flat, but URLs, refresh and back/forward work.
- **Step 5 — Two-level split. ⚠ RISKIEST.** Real `level` values; mode swap + context card;
  breadcrumb mounts; course routes become `#/course/<id>/<path>`; the guard + `API.getCourse` fallback
  land. Every course page's entry path changes at once, and a null `activeCourseId` first becomes
  user-visible. **Mitigation: implement and poke the guard *before* flipping the levels, and walk the
  full workspace matrix before merging.**
- **Step 6 — Retire the course dropdown.** Delete `#courseSelect`, `#courseSelectMobile`,
  `renderCourseSelect()`, rewire its 7 call sites. Safe **only after** Step 5 — until then the
  dropdown is the only way to change course.
- **Step 7 — Dashboard split.** Add `tab-home.js`; merge `renderDashboard` into `renderCourse`;
  delete `showNoAssignedCourses()` and the teacher-with-no-courses early return.
- **Step 8 — Completeness model.** `courseReadiness()` + pips + sidebar meter + "Next step" CTA +
  the readiness column on All Courses.
- **Step 9 — Page moves.** Extract Categories; add the `newcourse` action item; add the archive
  category filter (+ the `listArchive` SELECT change) and the Category column.

Second-riskiest is Step 3, precisely because it fails *silently* — nobody notices broken print until
an accreditation deadline.

---

## Risks & Gotchas

1. **Print CSS** depends on the literal id `tab-docs` (style.css:489). Generated shells must emit
   exactly that — enforced by the registry comment plus a boot-time
   `console.assert(document.getElementById("tab-docs"))`. `#appSections` wrapping the sections does
   **not** disturb `section.tab { display:none !important }` (neither selector cares about an extra
   ancestor), but verify it.
2. **`.sidebar-link.active` uses `!important`** for background and colour. New state classes losing
   that fight is desired; only the active-state pip colour needs `!important` back. Restricted items
   can never be active, so `.is-restricted` never collides.
3. **The `57px` magic number** is implicit (py-2 + a 40px brand emblem). Hoist it in Step 0 and **do
   not change topbar contents** — removing `#courseSelect` shortens it horizontally, not vertically.
   Anyone later moving a control in or out of the topbar will silently misalign the sticky sidebar.
4. **Async render sequencing.** `navActivate` must: (a) set state, (b) `[data-tab]` broadcast,
   (c) **toggle `section.active`**, (d) spinner if `item.async`, (e) scroll, (f) `await item.render()`.
   Step (c) must precede (f) — see #11. Bump `Router.navToken` per navigation and bail in the
   continuation if it moved, or a slow Admin fetch strands a spinner in a hidden section.
5. **`state.activeDoc` is a third nav level.** It becomes route segment 4. `Router.apply` sets it
   *before* dispatch, so `switchDoc` (app.js:1731) **must stop calling `renderDocs()`** or you double
   render. Do **not** scroll to top when only `sub` changed — jumping the report sheet is jarring.
6. **Load-order coupling.** `nav-registry.js` / `router.js` load *before* `app.js`, so they must never
   touch `state` / `esc` / render functions at top level. Concretely: **`render: renderCos` (a bare
   reference) throws a ReferenceError at parse time — it must be `render: () => renderCos()`.**
7. **Demo-course seeding** (`initApp:365-394`) is awaited, so it completes before `Router.start()`.
   But a fresh install now lands on the *system* dashboard instead of inside the seeded course — a
   first-run regression in perceived usefulness. Right after seeding, call
   `Router.replace("course", { courseId: saved.id })`.
8. **The 500 ms `debouncedSaveCourse` window.** Inside the SPA this is safe and unchanged — timers
   are keyed by course id and hold a *reference* to the object in `state.courses`, and hash changes
   never unload the page. The new exposure is that shareable URLs make F5 far more common.
   **`navigator.sendBeacon` will NOT work here**: `Auth::assertCsrf` (verified, `src/Auth/Auth.php:312-323`)
   reads `$_SERVER['HTTP_X_CSRF_TOKEN']` and throws 419 without it, and beacons cannot set headers.
   Do this instead:
   - **Flush on route change** — in `Router.apply`, before leaving a course page, `clearTimeout` the
     pending timer and call `API.saveCourse(c)` normally. Full headers, no size limit, no CSRF issue.
     This covers the overwhelmingly common case.
   - **On real unload**, if timers remain, `event.preventDefault()` to raise the browser's "Leave
     site?" prompt. Four lines, no workaround needed.
9. **Archive payload lacks the category** (verified). Don't join client-side against `state.courses` —
   teachers would then get category filtering only on their own courses, which is worse than nothing.
10. **`renderCategories()` is an unprefixed global** (`tab-admin.js:110`). Its only caller is
    `loadAdminTabCategories` (line 152) — grep once more before deleting.
11. **Chart.js sizing.** Merging the dashboard moves `#dashChart` into `#tab-course` (rename to
    `#courseOverviewChart`, keep the `charts.dash` key and its `destroy()` at app.js:639). Chart.js
    measures the canvas inside a `setTimeout(…, 50)`; if the section is still `display:none` then, you
    get a zero-size canvas. Today `showTab` toggles sections *before* rendering — `navActivate` must
    preserve that ordering. Same for `charts.co` / `charts.po` in `renderAttain`.
12. **Teacher with zero courses.** `#/course/x/cos` → `API.getCourse` returns null → toast +
    `#/courses`. The workspace group never appears in system mode, so there is no dead-end link.

---

## Verification

Seed: admin **A**, manager **M**, teacher **T1** (2 assigned courses — one near-complete, one empty),
teacher **T2** (0 courses).

**A. Routing** — bare `index.php` → `#/dashboard`. All Courses → `#/courses`; F5 → still there. Open
a course → `#/course/<id>/overview`; COs → `#/course/<id>/cos`; Back → overview; Back → `#/courses`;
Forward ×2 → cos. Paste `#/course/<id>/cos` into a fresh tab → lands on COs with the sidebar already
in course mode. `#/nonsense` → toast + `#/dashboard`. `#/course/does-not-exist/cos` → toast +
`#/courses`. `#/course` → `#/courses`. `#/course/<id>/docs/attain` → the Attainment Report variant;
Back → `docs/outline`; the page does **not** scroll to top on the sub-change.

**B. Auth query params** — signed out with `?auth_error=x` → gate shows the error, no console noise.
`index.php?verify_token=…#/course/<id>/cos` → the hash survives verification and resolves post-login.
Full reset-password flow end to end.

**C. Role matrix — system sidebar**

| | Dashboard | All Courses | Add Course | Categories | Data&Backup | Setup | About | Admin | Activity |
|---|---|---|---|---|---|---|---|---|---|
| A | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| M | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| T1 | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ |
| T2 | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ |

As T1, hand-type each hidden route → toast + redirect. Separately confirm `api/backup.php` still 403s
(UI gating must not be the only defence).

**D. Course workspace** — as T1, open an assigned course: sidebar swaps, context card shows
code/title/term/meter, "Back to Dashboard" returns and swaps back. Breadcrumb reads
`All Courses / <CODE> — <Title> / <Section>`; every crumb navigates. Edit the course code in Course
Overview → sidebar card **and** breadcrumb update immediately; F5 → persisted. As T1,
`#/course/<unassigned-id>/cos` → toast + `#/courses` (exercises the 403 path). As A, the same URL
loads and appends to `state.courses`. The "Course details & parameters" accordion opens/closes and the
team panel still loads inside it.

**E. Completeness** — new empty course → 0%, all pips `bi-circle`, "Next: add Course Outcomes" links
to `#/course/<id>/cos`. Add 3 COs with PO maps → COs pip green on the next navigation, meter ≥20%.
Assessments summing to exactly 100% with items → green; change one to 95% → reverts. Enter ≥1 mark for
every student → green; remove one student's marks → reverts. Sidebar meter equals the All Courses
readiness column. A catalog course not in `state.courses` shows `—`, **not** `0%`.

**F. All Courses** — category filter × search combine as AND; "Clear filters" restores. T2 requests
access → Pending Approval; A approves → T2 sees Member + a working Open link. Category column
populated; uncategorised show `—`. Code/title is a link for members/admins, plain text otherwise.

**G. Mobile (≤991px — DevTools iPhone SE + a real device)** — hamburger → offcanvas mirrors desktop
for the current mode. Tapping navigates **and** closes. Course mode shows the back link + context
card. No course `<select>` at any width. Resize 1200 → 900 → 1200 mid-session: mode and active item
hold.

**H. Print** — from `#/course/<id>/docs/outline`, Ctrl-P → only the report sheet; no sidebar,
breadcrumb, context card or buttons. Same from `docs/attain`. Ctrl-P from `#/dashboard` → near-blank;
confirm this is *unchanged* from today, not newly broken.

**I. Save integrity** — type in a CO description, click Attainment within 200 ms, wait 1 s, F5 → text
persisted (route-change flush). Type and press F5 within 200 ms → the "Leave site?" prompt appears;
staying then saves.

**J. Regression sweep (role A)** — all 17 routes render with a clean console. The in-page
`showTab('marks')` / `showTab('attain')` buttons on Course Overview navigate **and** highlight the
sidebar (proves the `[data-tab]` broadcast survived). The "Administration Panel" button on the All
Courses admin card (`tab-archive.js:195`) still works. Create a category → the New Course modal's
`<select>` includes it without a reload. Create a course → lands on `#/course/<newId>/overview` at 0%.
